diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8fd74..b933140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,9 +82,13 @@ across the whole thing, honestly*. the project-built checks once *per* sub-package — correctness/lint (cluster A), dependency-health + `circular-deps` (cluster B), and `unused-code` + `coverage` (cluster C) — records namespaced per package (`backend/npm-audit`) with finding - paths re-prefixed so the by-file/focus join stays coherent (Phase 2). A single - package / declared workspace is unchanged (byte-identical). Declared workspaces - (npm/pnpm/yarn/nx/turbo/lerna) are healthy and don't alarm. + paths re-prefixed so the by-file/focus join stays coherent (Phase 2). The + scc-measured **codebase statistics** recover too: each sub-package's totals are + re-aggregated from the *one* cached `scc --by-file` walk, sliced to its subtree + (reuse, never re-walk), labelled per package (`api/codebase-stats`) — Phase 2b, + first increment. A single package / declared workspace is unchanged + (byte-identical). Declared workspaces (npm/pnpm/yarn/nx/turbo/lerna) are healthy + and don't alarm. - **`lizard`** as a true multi-language complexity + duplication engine, and a **Focus Areas** view synthesising the forensic axes (#36–#38). - **Command profiles** + a **`.checkup.yml`** override layer (stack / checks / diff --git a/bin/checkup.sh b/bin/checkup.sh index 515c4ea..2d882c3 100755 --- a/bin/checkup.sh +++ b/bin/checkup.sh @@ -1758,27 +1758,41 @@ else echo -e "${YELLOW}⚠️ scc produced no usable output${NC}" write_failed "codebase-stats" "scc returned no usable --by-file JSON" "$SCC_INTENT" else - STATS_BREAKDOWN=$(scc_breakdown "$SCC_KEEP_JSON" < "$SCC_BYFILE") - # "FILES CODE COMPLEXITY", reconstructed from the breakdown (faithful Σ). - read -r TOTAL_FILES TOTAL_CODE COMPLEXITY <<< "$(scc_breakdown_total "$STATS_BREAKDOWN")" - SCC_TOP_LANGS=$(scc_breakdown_toplangs "$STATS_BREAKDOWN" 3) - [ -z "$SCC_TOP_LANGS" ] && SCC_TOP_LANGS="n/a" - - # Console table from the first-party breakdown (replaces scc's whole-tree - # tabular output, which would contradict the first-party totals). - printf "%-24s %8s %12s %12s\n" "Language" "Files" "Code" "Complexity" - echo "------------------------------------------------------------" - echo "$STATS_BREAKDOWN" | jq -r '.[] | [.Name, .Count, .Code, .Complexity] | @tsv' \ - | awk -F'\t' '{ printf "%-24s %8s %12s %12s\n", $1, $2, $3, $4 }' - echo "" - echo -e "${BLUE}📈 Summary:${NC} ${TOTAL_CODE:-0} lines of code across ${TOTAL_FILES:-0} files (first-party)" - echo -e " Top: ${SCC_TOP_LANGS} | Complexity: ${COMPLEXITY:-0}" - - # Standardised parsed JSON for the tool-agnostic markdown writer. - write_parsed "codebase-stats" "pass" "${TOTAL_FILES:-0}" \ - "${TOTAL_CODE:-0} lines across ${TOTAL_FILES:-0} files, first-party (top: ${SCC_TOP_LANGS})" \ - '[]' \ - "$SCC_INTENT" + # Per-package recovery (#78 increment 2): on an undeclared fan-out, measure + # EACH assessment root off the ONE cached --by-file walk — slice the keep-set + # to the sub-package subtree (reuse, never re-walk) and emit a per-package + # record via SLUG_NS. Single package / declared workspace → + # TOPO_ASSESSMENT_ROOTS is ("."), the slice is the full keep-set, SLUG_NS + # stays empty → record + console output are byte-identical to before (gate). + for TOPO_ROOT in "${TOPO_ASSESSMENT_ROOTS[@]}"; do + SLUG_NS=""; [ "$TOPO_ROOT" != "." ] && SLUG_NS="$TOPO_ROOT" + STATS_KEEP=$(scc_keep_for_root "$TOPO_ROOT") + STATS_BREAKDOWN=$(scc_breakdown "$STATS_KEEP" < "$SCC_BYFILE") + # "FILES CODE COMPLEXITY", reconstructed from the breakdown (faithful Σ). + read -r TOTAL_FILES TOTAL_CODE COMPLEXITY <<< "$(scc_breakdown_total "$STATS_BREAKDOWN")" + SCC_TOP_LANGS=$(scc_breakdown_toplangs "$STATS_BREAKDOWN" 3) + [ -z "$SCC_TOP_LANGS" ] && SCC_TOP_LANGS="n/a" + + # On a fan-out, label which sub-package this table describes; a single + # package prints no header (byte-identical to before). + [ -n "$SLUG_NS" ] && echo -e "${BLUE}📦 ${TOPO_ROOT}/${NC}" + # Console table from the first-party breakdown (replaces scc's whole-tree + # tabular output, which would contradict the first-party totals). + printf "%-24s %8s %12s %12s\n" "Language" "Files" "Code" "Complexity" + echo "------------------------------------------------------------" + echo "$STATS_BREAKDOWN" | jq -r '.[] | [.Name, .Count, .Code, .Complexity] | @tsv' \ + | awk -F'\t' '{ printf "%-24s %8s %12s %12s\n", $1, $2, $3, $4 }' + echo "" + echo -e "${BLUE}📈 Summary:${NC} ${TOTAL_CODE:-0} lines of code across ${TOTAL_FILES:-0} files (first-party)" + echo -e " Top: ${SCC_TOP_LANGS} | Complexity: ${COMPLEXITY:-0}" + + # Standardised parsed JSON for the tool-agnostic markdown writer. + write_parsed "codebase-stats" "pass" "${TOTAL_FILES:-0}" \ + "${TOTAL_CODE:-0} lines across ${TOTAL_FILES:-0} files, first-party (top: ${SCC_TOP_LANGS})" \ + '[]' \ + "$SCC_INTENT" + done + SLUG_NS="" fi fi echo "" diff --git a/docs/architecture.md b/docs/architecture.md index 95c510b..3a5e52c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -207,11 +207,15 @@ resolve), and their records are namespaced — `.slug` carries `/` ( cross-tree by-file / focus join stays coherent, and the renderer maps each to its pillar on the base slug. A single package / declared workspace runs the loop once at `.` with no namespace — byte-identical to before. Recovered today: the -dependency-health (`npm-audit`, `deps-freshness`) and correctness/lint -(`typecheck`, `unit-tests`, `code-quality`, `type-aware-lint`, `build`) clusters. -The engine-routed / artifact / whole-tree checks (`complexity`, `duplication`, -`coverage`) stay root-scoped for now — recovering them per package (each needs its -own engine re-routing) is the remaining slice. Honesty survives recovery: a +dependency-health (`npm-audit`, `deps-freshness`, `circular-deps`), correctness/lint +(`typecheck`, `unit-tests`, `code-quality`, `type-aware-lint`, `build`) and +`unused-code` + `coverage` clusters (Phase 2/2b increment 1), plus the +scc-measured **`codebase-stats`** — re-aggregated per sub-package by slicing the +*one* cached `scc --by-file` walk to each root's subtree (`scc_keep_for_root`; +reuse, never re-walk), so no extra scc cost (Phase 2b increment 2). Still +root-scoped: the engine-routed `complexity` and `duplication`, which need their +engine (eslint / lizard / jscpd) re-routed per sub-package — the remaining slice. +Honesty survives recovery: a sub-package whose toolchain can't run still `skip`s with a reason (never green-by-default), the floor laid in #85. diff --git a/lib/scc-inventory.sh b/lib/scc-inventory.sh index 9396a79..1c248be 100644 --- a/lib/scc-inventory.sh +++ b/lib/scc-inventory.sh @@ -112,3 +112,27 @@ scc_perfile_findings() { ] | sort_by( -.ccn, .file )' } + +# scc_keep_for_root → prints the path to a keep-set file scoped to . +# The per-package measurement recover pass (#78 increment 2): the keep-set +# ($SCC_KEEP_JSON) is a JSON array of TARGET-relative first-party paths, so a +# sub-package's slice is just the entries under "/". The scc-based +# measurement arms then re-aggregate the ONE cached --by-file walk per +# sub-package (reuse, never re-walk — same seam as scc_breakdown). +# "." (single package / declared-workspace root) returns the FULL keep-set +# path UNCHANGED → byte-identical to the pre-#78 single-package behaviour (the +# acceptance gate). Other roots cache the slice at $RAW_DIR/scc-keep..json +# (path separators in flattened to '_' for a safe filename). A leading +# "./" on either side is normalised away, mirroring scc-aggregate.jq, so the +# slice is robust to the keep-set's prefix style. Needs $SCC_KEEP_JSON + $RAW_DIR. +scc_keep_for_root() { + local root="$1" + if [ "$root" = "." ]; then + printf '%s' "$SCC_KEEP_JSON" + return 0 + fi + local out="$RAW_DIR/scc-keep.${root//\//_}.json" + jq --arg p "${root#./}/" '[ .[] | select((sub("^\\./"; "")) | startswith($p)) ]' \ + "$SCC_KEEP_JSON" > "$out" 2>/dev/null || printf '[]' > "$out" + printf '%s' "$out" +} diff --git a/test/scc-inventory.test.sh b/test/scc-inventory.test.sh index c09e172..ef7aa09 100644 --- a/test/scc-inventory.test.sh +++ b/test/scc-inventory.test.sh @@ -174,6 +174,41 @@ assert_eq "stable under reorder" \ "$(scc_concentration "$CONC_KEEP" 25 < "$CONC_BYFILE" | jq -S -c '.')" \ "$(scc_concentration "$CONC_KEEP" 25 < "$TMP/conc-shuf.json" | jq -S -c '.')" +echo "" +echo "scc_keep_for_root: per-package slice of the keep-set (#78 increment 2)" +RAW_DIR="$TMP" +SCC_KEEP_JSON="$TMP/keep-slice.json" +echo '["backend/src/a.go","backend/cmd/main.go","frontend/src/app.ts","./frontend/src/util.ts","top.md"]' > "$SCC_KEEP_JSON" + +# "." returns the SAME file path unchanged → the byte-identical single-package gate. +assert_eq "root '.' returns the full keep-set path unchanged" "$SCC_KEEP_JSON" "$(scc_keep_for_root .)" + +BK=$(scc_keep_for_root backend) +assert_eq "backend slice keeps only backend/ files" \ + '["backend/src/a.go","backend/cmd/main.go"]' "$(jq -c '.' "$BK")" +assert_eq "slice cached under RAW_DIR" "$TMP/scc-keep.backend.json" "$BK" + +# The "./"-prefixed entry is normalised before matching but preserved verbatim in +# the slice (scc-aggregate.jq normalises Location at join time anyway). +FE=$(scc_keep_for_root frontend) +assert_eq "frontend slice keeps both (incl. ./-prefixed, verbatim)" \ + '["frontend/src/app.ts","./frontend/src/util.ts"]' "$(jq -c '.' "$FE")" + +assert_eq "no-match root → empty array" '[]' "$(jq -c '.' "$(scc_keep_for_root nope)")" + +NESTED=$(scc_keep_for_root "packages/api") +assert_eq "nested root flattens '/' to '_' in the filename" \ + "$TMP/scc-keep.packages_api.json" "$NESTED" + +# Round-trip: a backend slice fed to scc_breakdown sums ONLY backend code. +echo '[{"Name":"Go","Files":[ + {"Location":"backend/src/a.go","Code":30,"Complexity":3,"Lines":40}, + {"Location":"backend/cmd/main.go","Code":20,"Complexity":2,"Lines":25}]}, + {"Name":"TypeScript","Files":[ + {"Location":"frontend/src/app.ts","Code":99,"Complexity":9,"Lines":120}]}]' > "$TMP/slice-byfile.json" +assert_eq "breakdown over backend slice sums only backend Go" "50" \ + "$(scc_breakdown "$BK" < "$TMP/slice-byfile.json" | jq 'map(.Code)|add')" + echo "" echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]