Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .claude/commands/sync-pyscn.md
Original file line number Diff line number Diff line change
@@ -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 <baseline>..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, push, and open a PR**: commit the jscan changes and the SYNC.md update on a branch (e.g. `sync/pyscn-<shortsha>`), 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.
9 changes: 8 additions & 1 deletion jscan/SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
1 change: 1 addition & 0 deletions jscan/app/analyze_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion jscan/domain/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
6 changes: 5 additions & 1 deletion jscan/domain/complexity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
13 changes: 8 additions & 5 deletions jscan/service/complexity_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions jscan/service/complexity_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
}
Expand Down
8 changes: 4 additions & 4 deletions jscan/service/html_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ const htmlTemplate = `<!DOCTYPE html>
</div>
{{if .HasComplexity}}
<div class="metric-card">
<div class="metric-value">{{.Summary.TotalFunctions}}</div>
<div class="metric-label">Total Functions</div>
<div class="metric-value">{{if and (gt .Summary.FunctionsParsed 0) (ne .Summary.FunctionsParsed .Summary.TotalFunctions)}}{{.Summary.TotalFunctions}} / {{.Summary.FunctionsParsed}}{{else}}{{.Summary.TotalFunctions}}{{end}}</div>
<div class="metric-label">{{if and (gt .Summary.FunctionsParsed 0) (ne .Summary.FunctionsParsed .Summary.TotalFunctions)}}Reported / Parsed{{else}}Total Functions{{end}}</div>
</div>
<div class="metric-card">
<div class="metric-value">{{printf "%.2f" .Summary.AverageComplexity}}</div>
Expand All @@ -449,8 +449,8 @@ const htmlTemplate = `<!DOCTYPE html>

<div class="metric-grid">
<div class="metric-card">
<div class="metric-value">{{.Complexity.Summary.TotalFunctions}}</div>
<div class="metric-label">Total Functions</div>
<div class="metric-value">{{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}}</div>
<div class="metric-label">{{if and (gt .Complexity.Summary.FunctionsParsed 0) (ne .Complexity.Summary.FunctionsParsed .Complexity.Summary.TotalFunctions)}}Reported / Parsed{{else}}Total Functions{{end}}</div>
</div>
<div class="metric-card">
<div class="metric-value">{{printf "%.2f" .Complexity.Summary.AverageComplexity}}</div>
Expand Down
12 changes: 11 additions & 1 deletion jscan/service/output_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading