From 4184167d34659d73593427f1be2624ec9b2375ce Mon Sep 17 00:00:00 2001 From: DaisukeYoda <27394393+DaisukeYoda@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:35:44 +0900 Subject: [PATCH 1/4] chore: add /sync-pyscn command for SYNC.md-driven sync runs Co-Authored-By: Claude Fable 5 --- .claude/commands/sync-pyscn.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .claude/commands/sync-pyscn.md diff --git a/.claude/commands/sync-pyscn.md b/.claude/commands/sync-pyscn.md new file mode 100644 index 0000000..4f715f2 --- /dev/null +++ b/.claude/commands/sync-pyscn.md @@ -0,0 +1,32 @@ +--- +description: pyscn → jscan sync run driven by jscan/SYNC.md +--- + +Run a pyscn → jscan sync. The single source of truth for policy, file mapping, and history is `jscan/SYNC.md` — read it first and follow it; if this command and SYNC.md disagree, SYNC.md wins. + +## Procedure + +1. **Read `jscan/SYNC.md`** — note the current baseline SHA, the file-mapping tables with classifications, "Pending changes", and "pyscn-specific, unported features". + +2. **Update the pyscn clone**: in `../pyscn`, run `git fetch origin`. All subsequent git commands run against `origin/main` (do not check anything out). + +3. **Enumerate candidate commits**: `git log --oneline ..origin/main` restricted to the paths that appear in SYNC.md's file-mapping tables (`internal/analyzer/`, `domain/`, `.claude-plugin/`). Then filter out noise: + - Commits that are part of pyscn's own core adoption (deleting local copies, switching imports to `polyscan/core`) — already accounted for; nothing to port. + - Commits touching only unmapped/pyscn-specific files (tests of unported features, config-loader plumbing, MCP, docs). + +4. **Classify each remaining commit** against SYNC.md: + - Touches a **core-shared algorithm** (anything living in `core/`): nothing to port here — but flag it, because the change belongs in `core/` itself. If pyscn changed behavior that core should own, propose a `core/` change instead of a jscan port. + - **case-by-case** row: read the diff, decide port / skip. Respect the "do not overwrite" notes (e.g. `calculateComplexityPenalty`, `calculateDeadCodePenalty` are intentionally divergent). + - **reference-only** row: never port code. Summarize significant design-direction changes for the human. + - Depends on a feature listed under "pyscn-specific, unported features": skip, and say which feature it's blocked on. + +5. **Present the triage before porting**: list each candidate commit with verdict (port / skip / core-issue / report-only) and a one-line reason. Get user confirmation before writing code unless the user already said to proceed. + +6. **Port the approved changes** to jscan, adapting to JS/TS semantics and jscan's architecture (jscan builds `domain.Clone` directly, has its own config-loader layer, etc.). After porting: `cd jscan && go build ./... && go test ./...` must pass. If a port changes shared-algorithm behavior, add/extend a parity test against core where one exists. + +7. **Update `jscan/SYNC.md`**: + - Record skipped-this-run changes under "Pending changes" with commit SHAs and reasons. + - Add a "Sync history" row: date, new pyscn SHA, summary of what was ported and skipped. + - Update the baseline SHA (and its date) to the `origin/main` HEAD you synced against. + +8. **Commit** the jscan changes and the SYNC.md update on a branch (e.g. `sync/pyscn-`), one commit per logical port plus one for SYNC.md, following the repo's commit-message style. Do not push or open a PR unless asked. From 215d6ed47ec20dec57f859633962348c5997ac4a Mon Sep 17 00:00:00 2001 From: DaisukeYoda <27394393+DaisukeYoda@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:35:44 +0900 Subject: [PATCH 2/4] sync(jscan): disclose pre-filter function coverage in complexity summary Port of pyscn #597 (92c436d + ca95d2e): add FunctionsParsed (pre- min_complexity-filter parsed count) to ComplexitySummary and AnalyzeSummary, surface it as functions_parsed in JSON and as "N reported / M parsed" in text and HTML output when filtering drops functions, so users aren't misled into thinking jscan parsed fewer functions than it did. Co-Authored-By: Claude Fable 5 --- jscan/app/analyze_usecase.go | 1 + jscan/domain/analyze.go | 5 ++++- jscan/domain/complexity.go | 6 +++++- jscan/service/complexity_service.go | 13 ++++++++----- jscan/service/complexity_service_test.go | 7 +++++-- jscan/service/html_formatter.go | 8 ++++---- jscan/service/output_formatter.go | 12 +++++++++++- 7 files changed, 38 insertions(+), 14 deletions(-) diff --git a/jscan/app/analyze_usecase.go b/jscan/app/analyze_usecase.go index 12b1ee4..87eb88b 100644 --- a/jscan/app/analyze_usecase.go +++ b/jscan/app/analyze_usecase.go @@ -122,6 +122,7 @@ func (uc *AnalyzeUseCase) Execute(ctx context.Context, config AnalyzeConfig, pat result.Complexity = response result.Summary.ComplexityEnabled = true result.Summary.TotalFunctions = response.Summary.TotalFunctions + result.Summary.FunctionsParsed = response.Summary.FunctionsParsed result.Summary.AverageComplexity = response.Summary.AverageComplexity result.Summary.HighComplexityCount = response.Summary.HighRiskFunctions result.Summary.MediumComplexityCount = response.Summary.MediumRiskFunctions diff --git a/jscan/domain/analyze.go b/jscan/domain/analyze.go index acd8aa2..81217be 100644 --- a/jscan/domain/analyze.go +++ b/jscan/domain/analyze.go @@ -108,7 +108,10 @@ type AnalyzeSummary struct { ArchCompliance float64 `json:"arch_compliance" yaml:"arch_compliance"` // Key metrics - TotalFunctions int `json:"total_functions" yaml:"total_functions"` + // TotalFunctions is the post-filter count (functions included after min_complexity filtering). + TotalFunctions int `json:"total_functions" yaml:"total_functions"` + // FunctionsParsed is the pre-filter count of all functions parsed before min_complexity filtering. + FunctionsParsed int `json:"functions_parsed" yaml:"functions_parsed"` AverageComplexity float64 `json:"average_complexity" yaml:"average_complexity"` HighComplexityCount int `json:"high_complexity_count" yaml:"high_complexity_count"` MediumComplexityCount int `json:"medium_complexity_count" yaml:"medium_complexity_count"` diff --git a/jscan/domain/complexity.go b/jscan/domain/complexity.go index ca9f6f6..6b14fd5 100644 --- a/jscan/domain/complexity.go +++ b/jscan/domain/complexity.go @@ -111,7 +111,11 @@ type FunctionComplexity struct { // ComplexitySummary represents aggregate statistics type ComplexitySummary struct { - TotalFunctions int `json:"total_functions" yaml:"total_functions"` + // TotalFunctions is the post-filter count (functions included in results after min_complexity filtering). + TotalFunctions int `json:"total_functions" yaml:"total_functions"` + // FunctionsParsed is the pre-filter count of all functions parsed before min_complexity filtering. + // When min_complexity drops trivial functions, FunctionsParsed > TotalFunctions. + FunctionsParsed int `json:"functions_parsed" yaml:"functions_parsed"` AverageComplexity float64 `json:"average_complexity" yaml:"average_complexity"` MaxComplexity int `json:"max_complexity" yaml:"max_complexity"` MinComplexity int `json:"min_complexity" yaml:"min_complexity"` diff --git a/jscan/service/complexity_service.go b/jscan/service/complexity_service.go index 6521b0a..1f92f41 100644 --- a/jscan/service/complexity_service.go +++ b/jscan/service/complexity_service.go @@ -77,11 +77,12 @@ func (s *ComplexityServiceImpl) Analyze(ctx context.Context, req domain.Complexi } // Filter and sort results + functionsParsed := len(allFunctions) filteredFunctions := s.filterFunctions(allFunctions, req) sortedFunctions := s.sortFunctions(filteredFunctions, req.SortBy) // Generate summary - summary := s.generateSummary(sortedFunctions, filesProcessed, req) + summary := s.generateSummary(sortedFunctions, filesProcessed, req, functionsParsed) return &domain.ComplexityResponse{ Functions: sortedFunctions, @@ -219,11 +220,13 @@ func (s *ComplexityServiceImpl) sortFunctions(functions []domain.FunctionComplex return sorted } -// generateSummary generates a summary of the complexity analysis -func (s *ComplexityServiceImpl) generateSummary(functions []domain.FunctionComplexity, filesProcessed int, req domain.ComplexityRequest) domain.ComplexitySummary { +// generateSummary generates a summary of the complexity analysis. +// functionsParsed is the pre-filter function count (all functions parsed before min_complexity filtering). +func (s *ComplexityServiceImpl) generateSummary(functions []domain.FunctionComplexity, filesProcessed int, req domain.ComplexityRequest, functionsParsed int) domain.ComplexitySummary { summary := domain.ComplexitySummary{ - FilesAnalyzed: filesProcessed, - TotalFunctions: len(functions), + FilesAnalyzed: filesProcessed, + TotalFunctions: len(functions), + FunctionsParsed: functionsParsed, } if len(functions) == 0 { diff --git a/jscan/service/complexity_service_test.go b/jscan/service/complexity_service_test.go index 68a158c..57e9002 100644 --- a/jscan/service/complexity_service_test.go +++ b/jscan/service/complexity_service_test.go @@ -349,7 +349,7 @@ func TestComplexityService_generateSummary_Empty(t *testing.T) { cfg := &config.ComplexityConfig{} service := NewComplexityService(cfg) - summary := service.generateSummary([]domain.FunctionComplexity{}, 0, domain.ComplexityRequest{}) + summary := service.generateSummary([]domain.FunctionComplexity{}, 0, domain.ComplexityRequest{}, 0) if summary.TotalFunctions != 0 { t.Error("Empty functions should have 0 total") @@ -369,11 +369,14 @@ func TestComplexityService_generateSummary_WithFunctions(t *testing.T) { {Name: "c", Metrics: domain.ComplexityMetrics{Complexity: 25}, RiskLevel: domain.RiskLevelHigh}, } - summary := service.generateSummary(functions, 2, domain.ComplexityRequest{}) + summary := service.generateSummary(functions, 2, domain.ComplexityRequest{}, 5) if summary.TotalFunctions != 3 { t.Errorf("TotalFunctions should be 3, got %d", summary.TotalFunctions) } + if summary.FunctionsParsed != 5 { + t.Errorf("FunctionsParsed should be 5, got %d", summary.FunctionsParsed) + } if summary.FilesAnalyzed != 2 { t.Errorf("FilesAnalyzed should be 2, got %d", summary.FilesAnalyzed) } diff --git a/jscan/service/html_formatter.go b/jscan/service/html_formatter.go index da4133a..e2071f6 100644 --- a/jscan/service/html_formatter.go +++ b/jscan/service/html_formatter.go @@ -421,8 +421,8 @@ const htmlTemplate = ` {{if .HasComplexity}}
-
{{.Summary.TotalFunctions}}
-
Total Functions
+
{{if and (gt .Summary.FunctionsParsed 0) (ne .Summary.FunctionsParsed .Summary.TotalFunctions)}}{{.Summary.TotalFunctions}} / {{.Summary.FunctionsParsed}}{{else}}{{.Summary.TotalFunctions}}{{end}}
+
{{if and (gt .Summary.FunctionsParsed 0) (ne .Summary.FunctionsParsed .Summary.TotalFunctions)}}Reported / Parsed{{else}}Total Functions{{end}}
{{printf "%.2f" .Summary.AverageComplexity}}
@@ -449,8 +449,8 @@ const htmlTemplate = `
-
{{.Complexity.Summary.TotalFunctions}}
-
Total Functions
+
{{if and (gt .Complexity.Summary.FunctionsParsed 0) (ne .Complexity.Summary.FunctionsParsed .Complexity.Summary.TotalFunctions)}}{{.Complexity.Summary.TotalFunctions}} / {{.Complexity.Summary.FunctionsParsed}}{{else}}{{.Complexity.Summary.TotalFunctions}}{{end}}
+
{{if and (gt .Complexity.Summary.FunctionsParsed 0) (ne .Complexity.Summary.FunctionsParsed .Complexity.Summary.TotalFunctions)}}Reported / Parsed{{else}}Total Functions{{end}}
{{printf "%.2f" .Complexity.Summary.AverageComplexity}}
diff --git a/jscan/service/output_formatter.go b/jscan/service/output_formatter.go index 051204a..93935ae 100644 --- a/jscan/service/output_formatter.go +++ b/jscan/service/output_formatter.go @@ -202,6 +202,7 @@ func BuildAnalyzeSummary( if complexityResponse != nil { summary.ComplexityEnabled = true summary.TotalFunctions = complexityResponse.Summary.TotalFunctions + summary.FunctionsParsed = complexityResponse.Summary.FunctionsParsed summary.AverageComplexity = complexityResponse.Summary.AverageComplexity summary.HighComplexityCount = complexityResponse.Summary.HighRiskFunctions summary.MediumComplexityCount = complexityResponse.Summary.MediumRiskFunctions @@ -412,6 +413,15 @@ func calculateDuplicationPercentage(response *domain.CloneResponse) float64 { } // writeComplexityText writes complexity response as plain text +// formatFunctionCoverage renders the post-filter function count, disclosing the +// pre-filter parsed count when min_complexity filtering dropped functions. +func formatFunctionCoverage(reported, parsed int) string { + if parsed > 0 && parsed != reported { + return fmt.Sprintf("%d reported / %d parsed", reported, parsed) + } + return fmt.Sprintf("%d", reported) +} + func (f *OutputFormatterImpl) writeComplexityText(response *domain.ComplexityResponse, writer io.Writer) error { fmt.Fprintf(writer, "\n=== Complexity Analysis ===\n\n") fmt.Fprintf(writer, "Generated: %s\n", response.GeneratedAt) @@ -420,7 +430,7 @@ func (f *OutputFormatterImpl) writeComplexityText(response *domain.ComplexityRes // Summary fmt.Fprintf(writer, "Summary:\n") fmt.Fprintf(writer, " Files analyzed: %d\n", response.Summary.FilesAnalyzed) - fmt.Fprintf(writer, " Total functions: %d\n", response.Summary.TotalFunctions) + fmt.Fprintf(writer, " Total functions: %s\n", formatFunctionCoverage(response.Summary.TotalFunctions, response.Summary.FunctionsParsed)) fmt.Fprintf(writer, " Average complexity: %.2f\n", response.Summary.AverageComplexity) fmt.Fprintf(writer, " Max complexity: %d\n", response.Summary.MaxComplexity) fmt.Fprintf(writer, " Min complexity: %d\n", response.Summary.MinComplexity) From 0684c339e0b616e66aaa16d72c01997733891ebd Mon Sep 17 00:00:00 2001 From: DaisukeYoda <27394393+DaisukeYoda@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:35:44 +0900 Subject: [PATCH 3/4] docs(jscan): record 2026-07-25 pyscn sync, move baseline to 6af3ee7 Co-Authored-By: Claude Fable 5 --- jscan/SYNC.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/jscan/SYNC.md b/jscan/SYNC.md index 3fd4e1a..57213cd 100644 --- a/jscan/SYNC.md +++ b/jscan/SYNC.md @@ -4,7 +4,7 @@ Language-independent algorithms now live in `core/` and are consumed directly by The `/sync-pyscn` command reads this file to run a sync. - Upstream: https://github.com/ludo-technologies/pyscn (local clone: `../pyscn`) -- **Sync baseline SHA**: `249b1213f0ce9be60842b6a96b92a3dad6539892` (2026-07-07) +- **Sync baseline SHA**: `6af3ee7b8e41e3821fbfa27d6f4280ebaacef8a9` (2026-07-25, v1.27.0) - The initial catch-up (clone detection / scoring / misc, 2026-06-11–12) compared jscan against pyscn's state up to `fb3fe92`. Exceptions that were deliberately not ported are listed under "Pending changes". - Update this value to pyscn's `origin/main` HEAD on each sync run. @@ -121,6 +121,12 @@ Skipped during the 2026-07-08 sync (`fb3fe92`..`249b121`): - **Cognitive-metrics-based risk / Health Score integration** (`3f159b4`) — makes `calculateComplexityPenalty` also weigh `AverageCognitiveComplexity` and `AverageNestingDepth` (unported features; see "pyscn-specific, unported features"). `calculateComplexityPenalty` is also flagged in the file mapping as an intentional jscan divergence (ESLint-aligned high/medium ratio vs. pyscn's average-complexity curve) — do not overwrite - **Module community detection** (`9bc832e`, `1b0590b`, `785e72b`, `8b03fbb`, `fd0db46`, `7a63058`, #582/#583/#602) — new pyscn subsystem; see "pyscn-specific, unported features" for details +Skipped during the 2026-07-25 sync (`249b121`..`6af3ee7`): + +- **CBO Python-scoping/import refinements** (`0a4c469`/#637 same-file top-level peer exclusion, `28d1378` name-collision hardening, `acbd2c8` module-constant exclusion, `826491b` PEP 563 annotation-only deps, `ff01735`/#644 Cython primitives, `c3edc9e` module-qualified enum coupling, `0238bab` acronym constructor coupling) — `cbo.go` is reference-only. Design direction worth noting: pyscn keeps tightening CBO noise exclusion; the *concepts* of same-file peer-class exclusion and module-constant exclusion may deserve an independent JS/TS-native implementation later +- **Sparse request bool overrides / sentinel unification** (`6a12c8b`, `f1e9a4b`, `a70b5a5`) — pyscn config-loader-layer rework (`FlagTracker` removal, `BoolPtr` sparse requests, `ShowDetails` response resolution). jscan's config-loader layer differs; already-skipped category (analyze-config plumbing) +- **LCOM4 regression test** (`21e512e`) — LCOM is an unported feature + ## Sync history | Date | pyscn SHA | Summary | @@ -134,4 +140,5 @@ Skipped during the 2026-07-08 sync (`fb3fe92`..`249b121`): | 2026-07-12 | *(monorepo `core/`)* | Phase 2b clone adoption (#9 / `c57f3b4`): jscan deleted local `grouping_strategy.go`, `textual_similarity.go`, `syntactic_similarity.go`, `ast_features.go` and wired `core/clone` (grouping strategies, dedup passes, similarity gates, `PairClassifier`). Language-side keeps fragment extraction, TreeConverter, JS cost model, LSH orchestration, and `removeJSComments` as `CommentStripper`. Further algorithm changes for clone grouping/gates go to `core/clone`, not jscan copies | | 2026-07-18 | *(monorepo `core/`)* | Phase 2b CFG/graph/LSH adoption (#11): jscan adopted `core/cfg` data structures, reachability, complexity, dead-code post-processing; `core/graph` Tarjan SCC and Martin metrics; `core/lsh` MinHash/index kernels; and shared `core/domain` scoring calculators. JavaScript classifiers, CFG construction, source enrichment, dynamic-import filtering, LSH candidate caps, and jscan-specific scoring penalties remain language-side. Intentional core semantics: exception edges contribute to complexity, unreachable registered blocks are included in complexity, and isolated dependency nodes use I=0/D=1. Reachability prunes only normal fallthrough after terminators so exception/control-transfer targets remain reachable | | 2026-07-18 | *(monorepo `core/`)* | Phase 2b APTED adoption (#10): jscan deleted its local APTED algorithm/tree/default-cost copies and now uses `core/apted` directly with max-size normalization. Language-side keeps only the JS/TS parser converter and JavaScript cost model. | +| 2026-07-25 | `6af3ee7` (v1.27.0) | Normal sync (`249b121`..`6af3ee7`, 33 commits touching mapped paths; first run of the recreated `/sync-pyscn` command). Ported: function-coverage disclosure (#597, `92c436d`+`ca95d2e`) — `FunctionsParsed` (pre-`min_complexity`-filter parsed count) added to `ComplexitySummary`/`AnalyzeSummary`, propagated through the complexity service and analyze summary, surfaced as `functions_parsed` in JSON and as "N reported / M parsed" in text and HTML output when filtering drops functions. Already in sync via core adoption: LSH `FindCandidatesLimit` cap delegation (`a3854e5` ≙ polyscan#20) and lazy-predecessor-edge filtering (`e5b2482` ≙ jscan's `loadTimeGraph.Predecessors`). 11 pyscn core-adoption migrate commits carry nothing to port. Skipped: 7 CBO Python-scoping refinements (reference-only), config-loader plumbing (`6a12c8b`, `f1e9a4b`, `a70b5a5`), LCOM test — see "Pending changes" | | 2026-07-24 | *(pyscn Phase 3 done, `152d9d0`)* | pyscn completed [polyscan#12](https://github.com/ludo-technologies/polyscan/issues/12): adopted `core/clone` (#664), `core/apted` (#669), `core/cfg` (#670), `core/graph` (#672), `core/lsh`+`core/lcom`+`core/dfa`+`core/semantic` (#673), and `core/domain` scoring/grade mapping (#674). Every **sync**-classified row in this file is retired: rows whose files were deleted on both sides (`apted.go`, `minhash.go`, `ast_features.go`, `grouping_strategy.go`/`*_grouping.go`/`group_dedup.go`, `syntactic_similarity.go`) were removed outright; rows whose files remain as thin core-delegating wrappers on both sides (`lsh_index.go`, `cfg.go`, `reachability.go`, `coupling_metrics.go`, `circular_detector.go`, `domain/analyze.go`, `domain/system_analysis.go`) were reclassified to **case-by-case**. Grade computation is identical between pyscn and jscan by construction — both call `core/domain.GradeFromScore`/`IsHealthyScore` directly and each has its own unit test asserting parity with core | From 0eda7b92c9aff17f15dd9fa826dc8f96899bdc97 Mon Sep 17 00:00:00 2001 From: DaisukeYoda <27394393+DaisukeYoda@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:29:22 +0900 Subject: [PATCH 4/4] chore: make /sync-pyscn open a PR at the end of a run Co-Authored-By: Claude Fable 5 --- .claude/commands/sync-pyscn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/commands/sync-pyscn.md b/.claude/commands/sync-pyscn.md index 4f715f2..0388f27 100644 --- a/.claude/commands/sync-pyscn.md +++ b/.claude/commands/sync-pyscn.md @@ -29,4 +29,4 @@ Run a pyscn → jscan sync. The single source of truth for policy, file mapping, - Add a "Sync history" row: date, new pyscn SHA, summary of what was ported and skipped. - Update the baseline SHA (and its date) to the `origin/main` HEAD you synced against. -8. **Commit** the jscan changes and the SYNC.md update on a branch (e.g. `sync/pyscn-`), one commit per logical port plus one for SYNC.md, following the repo's commit-message style. Do not push or open a PR unless asked. +8. **Commit, push, and open a PR**: commit the jscan changes and the SYNC.md update on a branch (e.g. `sync/pyscn-`), one commit per logical port plus one for SYNC.md, following the repo's commit-message style. Push the branch and open a PR against `main` with `gh pr create`. The PR body should include: a summary of what was ported, the full triage table (commit → verdict → reason), and how the ports were verified (tests + E2E). If the run found anything that belongs in `core/` or a reference-only design-direction change worth flagging, file or link issues and mention them in the PR body.