From 0aa9948b54f31eb000afacc662d7c11528ff8e7e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 17:19:13 +0300 Subject: [PATCH 1/9] feat(index): Sonar cognitive complexity column and recipes Add symbols.cognitive_complexity in the same oxc walk as cyclomatic, including class methods. Ship high-cognitive-complexity (min_score 15) and extend high-complexity-untested SELECT. Schema v38; plan deleted. --- .changeset/cognitive-complexity.md | 5 + docs/architecture.md | 53 ++++----- docs/glossary.md | 6 +- docs/plans/ast-hash-duplication.md | 2 +- docs/plans/cognitive-complexity.md | 102 ------------------ docs/plans/graph-estimated-crap.md | 2 +- docs/roadmap.md | 1 - .../minimal/high-cognitive-complexity.json | 12 +++ .../minimal/high-complexity-untested.json | 1 + fixtures/golden/scenarios.json | 5 + src/db.ts | 13 ++- src/extractors/complexity.test.ts | 68 ++++++++++++ src/extractors/complexity.ts | 71 +++++++++--- src/extractors/symbols.ts | 11 ++ src/extractors/types.ts | 6 ++ src/parser.test.ts | 9 ++ templates/agent-content/rule/00-full.md | 1 + .../recipes/high-cognitive-complexity.md | 23 ++++ .../recipes/high-cognitive-complexity.sql | 14 +++ templates/recipes/high-complexity-untested.md | 12 +-- .../recipes/high-complexity-untested.sql | 1 + 21 files changed, 260 insertions(+), 158 deletions(-) create mode 100644 .changeset/cognitive-complexity.md delete mode 100644 docs/plans/cognitive-complexity.md create mode 100644 fixtures/golden/minimal/high-cognitive-complexity.json create mode 100644 src/extractors/complexity.test.ts create mode 100644 templates/recipes/high-cognitive-complexity.md create mode 100644 templates/recipes/high-cognitive-complexity.sql diff --git a/.changeset/cognitive-complexity.md b/.changeset/cognitive-complexity.md new file mode 100644 index 00000000..2a019dfa --- /dev/null +++ b/.changeset/cognitive-complexity.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Add SonarSource cognitive complexity on `symbols` (same function-shaped coverage as cyclomatic, including class methods). New recipe `high-cognitive-complexity`; `high-complexity-untested` rows include `cognitive_complexity`. diff --git a/docs/architecture.md b/docs/architecture.md index 5551561e..39072c95 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -266,32 +266,33 @@ All base tables use `STRICT` mode; **`source_fts`** is an FTS5 virtual table (no ### `symbols` — Functions, constants, classes, interfaces, types, enums (`STRICT`) -| Column | Type | Description | -| ----------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| id | INTEGER PK | Auto-increment row id | -| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | -| name | TEXT | Symbol name | -| kind | TEXT | `function`, `const`, `let`, `var`, `class`, `interface`, `type`, `enum`, `method`, `property`, `getter`, `setter` (last four are class members). `let` / `var` are distinct from `const` so callers can filter on mutability (e.g. `WHERE kind = 'const'` excludes mutable bindings; `WHERE kind IN ('let','var')` lists reassignable ones). | -| line_start | INTEGER | Start line (1-based) | -| line_end | INTEGER | End line | -| signature | TEXT | Reconstructed signature with generics and return types (e.g. `identity(val): T`, `interface Repo extends Iterable`, `class Store extends Base implements IStore`) | -| is_exported | INTEGER | 1 if exported | -| is_default_export | INTEGER | 1 if default export | -| members | TEXT | JSON array of enum members (NULL for non-enums). Each entry: `{"name":"…","value":"…"}` (value omitted for implicit-value enums) | -| doc_comment | TEXT | Leading JSDoc comment text (cleaned: `*` prefixes stripped, trimmed). NULL when absent. Preserves `@deprecated`, `@param`, etc. tags | -| value | TEXT | Literal value for `const` declarations (strings, numbers, booleans, `null`). NULL for non-literal or non-const symbols. Handles `as const` and simple template literals | -| parent_name | TEXT | Nearest **named** enclosing scope (class, function, method, arrow-assigned-to-const). Walks past anonymous arrows / IIFEs / callbacks (e.g. `forEach(() => …)` inside `foo` → `parent_name='foo'`). NULL when no named owner exists — true module-scope OR inside a top-level anonymous IIFE. **For a strict "module-scope only" filter use `scope_local_id = 0`** (the canonical answer), not `parent_name IS NULL` | -| visibility | TEXT | JSDoc visibility tag derived from `doc_comment` at parse time: `public` / `private` / `internal` / `alpha` / `beta`. NULL when no tag present. Tag must start its own line (after the JSDoc `*` prefix); first match in document order wins. Powers the `visibility-tags` recipe and `WHERE visibility = ?` queries via the partial index `idx_symbols_visibility` | -| complexity | REAL | Cyclomatic complexity (McCabe; `1 + decision points`) for function-shaped symbols only. NULL for non-functions (interfaces, types, enums, plain consts) and class methods (v1 limitation). Decision points: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` arms (not `default:`), short-circuit logical/nullish operators, ternary `?:`, and `catch` clauses. Powers the `high-complexity-untested` recipe | -| name_column_start | INTEGER | 0-based column of the symbol-name token on `line_start` (per [R.6]) | -| name_column_end | INTEGER | One-past-last column of the symbol-name token | -| scope_local_id | INTEGER | Enclosing scope where the symbol's NAME is declared (joins `scopes.local_id`). Default `0` (module) | -| body_line_count | INTEGER | `line_end - line_start + 1` for function-shaped symbols; NULL for non-functions | -| param_count | INTEGER | Parameter count for function-shaped symbols; NULL otherwise | -| nesting_depth | INTEGER | Max conditional/loop/ternary nesting inside the body; NULL for non-functions | -| return_type | TEXT | Stringified return type for function-shaped symbols; NULL when unannotated or N/A | -| is_async | INTEGER | 1 for async function-shaped symbols (`function`, `method`, arrow-assigned `function` kind) | -| is_generator | INTEGER | 1 for generator function-shaped symbols | +| Column | Type | Description | +| -------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| id | INTEGER PK | Auto-increment row id | +| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | +| name | TEXT | Symbol name | +| kind | TEXT | `function`, `const`, `let`, `var`, `class`, `interface`, `type`, `enum`, `method`, `property`, `getter`, `setter` (last four are class members). `let` / `var` are distinct from `const` so callers can filter on mutability (e.g. `WHERE kind = 'const'` excludes mutable bindings; `WHERE kind IN ('let','var')` lists reassignable ones). | +| line_start | INTEGER | Start line (1-based) | +| line_end | INTEGER | End line | +| signature | TEXT | Reconstructed signature with generics and return types (e.g. `identity(val): T`, `interface Repo extends Iterable`, `class Store extends Base implements IStore`) | +| is_exported | INTEGER | 1 if exported | +| is_default_export | INTEGER | 1 if default export | +| members | TEXT | JSON array of enum members (NULL for non-enums). Each entry: `{"name":"…","value":"…"}` (value omitted for implicit-value enums) | +| doc_comment | TEXT | Leading JSDoc comment text (cleaned: `*` prefixes stripped, trimmed). NULL when absent. Preserves `@deprecated`, `@param`, etc. tags | +| value | TEXT | Literal value for `const` declarations (strings, numbers, booleans, `null`). NULL for non-literal or non-const symbols. Handles `as const` and simple template literals | +| parent_name | TEXT | Nearest **named** enclosing scope (class, function, method, arrow-assigned-to-const). Walks past anonymous arrows / IIFEs / callbacks (e.g. `forEach(() => …)` inside `foo` → `parent_name='foo'`). NULL when no named owner exists — true module-scope OR inside a top-level anonymous IIFE. **For a strict "module-scope only" filter use `scope_local_id = 0`** (the canonical answer), not `parent_name IS NULL` | +| visibility | TEXT | JSDoc visibility tag derived from `doc_comment` at parse time: `public` / `private` / `internal` / `alpha` / `beta`. NULL when no tag present. Tag must start its own line (after the JSDoc `*` prefix); first match in document order wins. Powers the `visibility-tags` recipe and `WHERE visibility = ?` queries via the partial index `idx_symbols_visibility` | +| complexity | REAL | Cyclomatic complexity (McCabe; `1 + decision points`) for function-shaped symbols (top-level `function`, named arrow/const, class methods). NULL for non-functions. Decision points: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` arms (not `default:`), short-circuit logical/nullish operators, ternary `?:`, and `catch` clauses. Powers `high-complexity-untested` (cyclomatic gate) | +| cognitive_complexity | INTEGER | SonarSource cognitive complexity for the same function-shaped symbols as `complexity` (NULL otherwise). Penalizes nesting; computed in the same oxc walk as cyclomatic (`src/extractors/complexity.ts`). Powers `high-cognitive-complexity`; also exposed as a column on `high-complexity-untested` rows | +| name_column_start | INTEGER | 0-based column of the symbol-name token on `line_start` (per [R.6]) | +| name_column_end | INTEGER | One-past-last column of the symbol-name token | +| scope_local_id | INTEGER | Enclosing scope where the symbol's NAME is declared (joins `scopes.local_id`). Default `0` (module) | +| body_line_count | INTEGER | `line_end - line_start + 1` for function-shaped symbols; NULL for non-functions | +| param_count | INTEGER | Parameter count for function-shaped symbols; NULL otherwise | +| nesting_depth | INTEGER | Max conditional/loop/ternary nesting inside the body; NULL for non-functions | +| return_type | TEXT | Stringified return type for function-shaped symbols; NULL when unannotated or N/A | +| is_async | INTEGER | 1 for async function-shaped symbols (`function`, `method`, arrow-assigned `function` kind) | +| is_generator | INTEGER | 1 for generator function-shaped symbols | ### `calls` — Function-scoped call edges, deduped per file (`STRICT`) diff --git a/docs/glossary.md b/docs/glossary.md index 4794470c..a65cf490 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -137,7 +137,11 @@ React components (PascalCase + JSX return or hook usage). PascalCase functions t ### `symbols.complexity` / cyclomatic complexity / McCabe -Per-function decision-point count (REAL column on `symbols`). Computed by the parser walker (`src/parser.ts`) per the McCabe formula: `1 + (decision points)`. Counted nodes: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` (not `default:` — that's the fall-through arm, not a decision), `&&`, `||`, `??`, `?:`, `catch`. Function-shaped symbols only — non-functions (interfaces, types, enums, plain consts) and class methods get `complexity = NULL` (v1 limitation; class methods tracked under `high-complexity-untested.md`). Joins to `coverage` via `(file_path, name, line_start)` natural key for the bundled `high-complexity-untested` recipe (complexity ≥ 10 ⨯ coverage < 50%). +Per-function decision-point count (REAL column on `symbols`). Computed by the parser walker per the McCabe formula: `1 + (decision points)`. Counted nodes: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` (not `default:`), `&&`, `||`, `??`, `?:`, `catch`. Function-shaped symbols only (top-level `function`, named arrow/const, class methods); non-functions get `complexity = NULL`. Joins to `coverage` for `high-complexity-untested` (cyclomatic ≥ 10 ⨯ coverage < 50%). See also [`symbols.cognitive_complexity`](#symbolscognitive_complexity--cognitive-complexity). + +### `symbols.cognitive_complexity` / cognitive complexity + +SonarSource cognitive complexity (INTEGER on `symbols`) for the same function-shaped symbols as cyclomatic `complexity`. Penalizes nested control flow; same oxc walk as McCabe (`src/extractors/complexity.ts`). Recipes: `high-cognitive-complexity` (`min_score` default 15); `high-complexity-untested` includes the column while filtering on cyclomatic `complexity`. ### `source_fts` (FTS5 virtual table) / `--with-fts` / opt-in full-text diff --git a/docs/plans/ast-hash-duplication.md b/docs/plans/ast-hash-duplication.md index ce2cbe2a..cd941b87 100644 --- a/docs/plans/ast-hash-duplication.md +++ b/docs/plans/ast-hash-duplication.md @@ -147,5 +147,5 @@ Register golden scenario per [`docs/golden-queries.md`](../golden-queries.md); g ## Dependencies - Shipped: `symbols` extraction, `hashContent`, recipe loader -- Independent of [churn-complexity-hotspots](./churn-complexity-hotspots.md), [cognitive-complexity](./cognitive-complexity.md) +- Independent of [churn-complexity-hotspots](./churn-complexity-hotspots.md), [`symbols.cognitive_complexity`](../glossary.md#symbolscognitive_complexity--cognitive-complexity) - Supersedes motivation for suffix-array semantic dupes (stay deferred) diff --git a/docs/plans/cognitive-complexity.md b/docs/plans/cognitive-complexity.md deleted file mode 100644 index 26a4d220..00000000 --- a/docs/plans/cognitive-complexity.md +++ /dev/null @@ -1,102 +0,0 @@ -# Cognitive complexity substrate — plan - -> **Status:** open · **Priority:** P2 · **Effort:** M (~2 weeks) -> -> **Motivator:** `symbols.complexity` stores cyclomatic (McCabe) counts only. Agents and recipes (`high-complexity-untested`, future churn-hotspots JOINs) lack **cognitive complexity** — a separate signal that penalizes nesting and non-linear control flow more than flat branch chains. Surfacing both axes improves refactor-priority ranking without new verdict verbs. -> -> **Roadmap:** [§ Core substrate & platform](../roadmap.md#core-substrate--platform) - ---- - -## Agent start here - -Extend **`createComplexityTracker`** in one oxc visitor pass (R.1 single-pass — no second AST walk). Ship **column + migration + one parse fixture** before the bundled recipe. Follow [`substrate-extraction.md`](./substrate-extraction.md) tier patterns for schema bump. - -### Key touchpoints - -| File | What to read | -| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| [`src/extractors/complexity.ts`](../../src/extractors/complexity.ts) | `createComplexityTracker`, `complexityExtractor`, `popTop` | -| [`src/parser.ts`](../../src/parser.ts) | `complexity: createComplexityTracker(symbols)` wiring | -| [`src/db.ts`](../../src/db.ts) | `symbols` table — add `cognitive_complexity` | -| [`templates/recipes/high-complexity-untested.sql`](../../templates/recipes/high-complexity-untested.sql) | Optional SELECT extension in v2 | -| Parser / golden fixtures | Nested-`if` fixture asserting cognitive > cyclomatic | - -### Architecture - -```text -oxc visitor (existing complexity walk) - → cognitive counter + nesting stack (parallel to cyclomatic) - → symbol row: complexity + cognitive_complexity + nesting_depth -recipe high-cognitive-complexity - → SQL filter on cognitive_complexity >= :min_score -``` - -### Tracer bullet (slice 1) - -1. Tracker increments on nested `if` fixture (cognitive > cyclomatic). 2. `SCHEMA_VERSION` bump + migration. 3. Reindex self or fixture. Recipe in slice 2. - -### Out of scope (v1) - -Replacing cyclomatic `complexity`; Sonar-exact parity certification (match spec behavior on fixtures, not full corpus diff). - ---- - -## Pre-locked decisions - -| # | Decision | Source | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| C.1 | **New column** `symbols.cognitive_complexity INTEGER` — nullable; populated for function-scoped symbols same as cyclomatic. | [Moat B](../roadmap.md#moats-load-bearing) | -| C.2 | **Same extractor walk** — extend `complexityExtractor` / `createComplexityTracker` in one oxc visitor pass; no second AST traversal. | [substrate-extraction R.1](./substrate-extraction.md#pre-locked-decisions) | -| C.3 | **SonarSource cognitive rules** — increment on breaks in linear flow; nesting increment when entering `if`/`loop`/`catch`/`switch`; boolean sequence handling per published spec. | [SonarSource cognitive complexity spec](https://www.sonarsource.com/docs/CognitiveComplexity.pdf) | -| C.4 | **Moat-A exposure** — parametrised recipe `high-cognitive-complexity` (threshold param); optionally extend `high-complexity-untested` with `cognitive_complexity` column in SELECT. | Moat A | -| C.5 | **Keep cyclomatic** — do not replace `complexity`; recipes choose axis via SQL. | Backwards compat | - ---- - -## Implementation steps - -1. Extend `ComplexityTracker` with cognitive counter + nesting penalty stack (parallel to cyclomatic stack). -2. Wire increments on `IfStatement`, loops, `catch`, `switch`, `&&`/`||` sequences per spec (unit-test small fixtures). -3. `popTop()` writes `cognitive_complexity` onto symbol row alongside `complexity` / `nesting_depth`. -4. `SCHEMA_VERSION` bump + migration in `db.ts`. -5. Bundled recipe `high-cognitive-complexity` (`params: min_score`, default 15). -6. Golden parse fixture: nested `if` chain — cognitive > cyclomatic. -7. Docs — `architecture.md` `symbols` columns; `glossary.md` disambiguate cyclomatic vs cognitive. - ---- - -### Verification - -```bash -bun test src/parser.test.ts # nested-if fixture -bun src/index.ts --files -bun src/index.ts query --recipe high-cognitive-complexity --json -bun run typecheck # SymbolRow + db migration -``` - ---- - -## Acceptance - -- [ ] Nested control-flow fixture: `cognitive_complexity` > `complexity` -- [ ] Flat `if` chain: cognitive ≈ cyclomatic (within spec tolerance) -- [ ] Incremental reindex updates column for changed files -- [ ] No new CLI verdict verb - ---- - -## Open decisions (impl PR) - -| # | Question | -| --- | ----------------------------------------------------------------------- | -| Q1 | Populate cognitive for arrow fns / methods only, or all function kinds? | -| Q2 | Default `min_score` for `high-cognitive-complexity` recipe (15 vs 20)? | -| Q3 | Extend `high-complexity-untested` SELECT in v1 or defer to v2? | - ---- - -## Dependencies - -- Shipped: `src/extractors/complexity.ts`, `symbols.complexity`, `high-complexity-untested` recipe -- Synergy: [churn-complexity-hotspots](./churn-complexity-hotspots.md) may JOIN cognitive column in v2 recipe param diff --git a/docs/plans/graph-estimated-crap.md b/docs/plans/graph-estimated-crap.md index 00d54981..2f5f5323 100644 --- a/docs/plans/graph-estimated-crap.md +++ b/docs/plans/graph-estimated-crap.md @@ -125,5 +125,5 @@ bun test scripts/query-golden-coverage-matrix.test.mjs # after golden scenario ## Dependencies - Shipped: `symbols.complexity`, `coverage`, `dependencies`, `calls`, `references`, `test_suites`, `affected-tests` glob conventions -- Synergy: [cognitive-complexity](./cognitive-complexity.md) (optional second axis in same recipe later); [coverage-deletion-confidence](./coverage-deletion-confidence.md) (opposite signal — dead + zero coverage) +- Synergy: [`symbols.cognitive_complexity`](../glossary.md#symbolscognitive_complexity--cognitive-complexity) (optional second axis in same recipe later); [coverage-deletion-confidence](./coverage-deletion-confidence.md) (opposite signal — dead + zero coverage) - Weaker until: [c9-plugin-layer](./c9-plugin-layer.md) (framework test files may be misclassified) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6ecb8113..24165a84 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,7 +111,6 @@ Predicate-as-API only — enrich row shape and audit deltas; no standalone pass/ - [ ] **`history` table** (deferred — revisit-triggered) — temporal queries: "when did symbol X get `@deprecated`?", "coverage trend over last 50 commits", "files that became dead this week". `audit --base ` covers the most-common temporal question (PR-scoped diff) without schema growth, so the table earns its place only when bigger questions emerge. Two shapes (per-commit snapshots ~N × DB size; append-only event log heavier CTE walks); both pay an N-reindexes backfill cost (~30s per reindex). **Revisit triggers:** two consumers ship `jq`-based "audit-runs-over-time" workflows, OR `query_baselines` evolution becomes a recurring agent need. - [ ] **`codemap audit` verdict + thresholds** (v1.x) — `verdict: "pass" | "warn" | "fail"` driven by an `audit.deltas[].{added_max, action}` field on the config object (`.codemap/config.{ts,js,json}`). Triggers: two consumers ship `jq`-based threshold scripts with similar shapes, OR one consumer asks with a concrete config sketch. Until then, raw deltas + consumer-side `jq` is the CI exit-code idiom. **Likely accelerant:** the Marketplace Action (next item) shipping is the most plausible path to firing the trigger — once `- uses: stainless-code/codemap@v1` is the dominant CI path, real `jq` threshold scripts will surface. - [ ] **GitHub Marketplace Action — publish + listing finish** — core Action implementation is in-tree: root `action.yml`, `query --ci`, `audit --format sarif` / `--ci`, package-manager detection, dogfood smoke, and opt-in `pr-comment` summary renderer have shipped. Remaining work is the release/listing slice: `MARKETPLACE.md`, `v1.0.0` / floating `v1` tags, Marketplace setup, sacrificial-repo smoke, and making `action-smoke` blocking once the Action tag exists. Action version stream is independent of CLI version (`package.json` currently drives CLI/npm version; Action publishes at its own `v1.0.0`). Plan: [`plans/github-marketplace-action.md`](./plans/github-marketplace-action.md). Effort: S. -- [ ] **Cognitive complexity column** — `symbols.cognitive_complexity` (SonarSource rules) alongside cyclomatic `complexity` in the same oxc walk; recipe `high-cognitive-complexity`. Improves refactor-priority JOINs with coverage/churn recipes. Plan: [`plans/cognitive-complexity.md`](./plans/cognitive-complexity.md). Effort: M. - [ ] **Churn × complexity hotspots** — `file_churn` table (git `log --numstat` over indexed paths, recency-weighted commits, optional trend) + bundled recipe **`churn-complexity-hotspots`** JOINing `symbols.complexity` for ranked refactor targets. Distinct from outcome alias `hotspots` → `fan-in`. Score is a recipe column, not a verdict ([Moat A](./roadmap.md#moats-load-bearing)). Plan: [`plans/churn-complexity-hotspots.md`](./plans/churn-complexity-hotspots.md). Effort: L–M. - [ ] **AST-hash duplication** — `symbols.body_hash` column (normalized AST hash via oxc, computed at parse time — Rust-native, fast) + bundled `duplicates` recipe joining on `body_hash` (`GROUP BY body_hash HAVING COUNT(*) > 1`). **Different shape from token-level suffix-array dupes** (catches structurally-identical functions, not copy-paste with renamed variables). Substrate addition — consumer writes the JOIN that decides "this is a problem"; no severity, no suppression-by-default. Plan: [`plans/ast-hash-duplication.md`](./plans/ast-hash-duplication.md). Effort: M. - [ ] **Falsifiable benchmark CI on named external fixtures** — structural-cost A/B (indexed queries vs `find` + `grep` + `Read`-loop discovery) on zod, fastify, vue-core, next.js. Numbers land in [`docs/benchmark.md`](./benchmark.md); headline figures surface in `MARKETPLACE.md` only after external runs land. Harness: [benchmark § Agent eval harness](./benchmark.md#agent-eval-harness) + external fixture extension; pair with **Agent eval: quality × tokens × wall** for scored completion metrics. **Partial:** manual [`.github/workflows/agent-eval-external.yml`](../.github/workflows/agent-eval-external.yml) for in-repo fixture paths (not zod/fastify/nightly). Effort: M. **Self-index regression guardrail shipped** (#96): `bun run check:perf-baseline` + weekly scheduled workflow (demoted from PR hard gate — GHA runner variance). diff --git a/fixtures/golden/minimal/high-cognitive-complexity.json b/fixtures/golden/minimal/high-cognitive-complexity.json new file mode 100644 index 00000000..eadf0ece --- /dev/null +++ b/fixtures/golden/minimal/high-cognitive-complexity.json @@ -0,0 +1,12 @@ +[ + { + "name": "labyrinth", + "kind": "function", + "file_path": "src/lib/complexity-fixture.ts", + "line_start": 22, + "line_end": 83, + "cognitive_complexity": 40, + "complexity": 19, + "nesting_depth": 5 + } +] diff --git a/fixtures/golden/minimal/high-complexity-untested.json b/fixtures/golden/minimal/high-complexity-untested.json index 9166acdf..d602fc85 100644 --- a/fixtures/golden/minimal/high-complexity-untested.json +++ b/fixtures/golden/minimal/high-complexity-untested.json @@ -6,6 +6,7 @@ "line_start": 22, "line_end": 83, "complexity": 19, + "cognitive_complexity": 40, "coverage_pct": 0 } ] diff --git a/fixtures/golden/scenarios.json b/fixtures/golden/scenarios.json index 44848523..2b41c951 100644 --- a/fixtures/golden/scenarios.json +++ b/fixtures/golden/scenarios.json @@ -452,6 +452,11 @@ "prompt": "Functions with nesting_depth >= 4, ranked by depth/complexity.", "recipe": "deeply-nested-functions" }, + { + "id": "high-cognitive-complexity", + "prompt": "Functions with cognitive complexity >= default threshold (15).", + "recipe": "high-cognitive-complexity" + }, { "id": "circular-imports", "prompt": "Files in import cycles (SCCs of size >= 2) via Tarjan.", diff --git a/src/db.ts b/src/db.ts index 79f9c400..9116a482 100644 --- a/src/db.ts +++ b/src/db.ts @@ -3,7 +3,7 @@ import type { CodemapDatabase, BindValues } from "./sqlite-db"; /** Bump only on rebuild-forcing DDL changes (NOT on additive tables/columns). * See `docs/architecture.md` § Schema Versioning. */ -export const SCHEMA_VERSION = 37; +export const SCHEMA_VERSION = 38; /** Moat-A: default call-graph surfaces exclude callback-synthesis edges. */ export const CALLS_AST_ONLY_SQL = "(provenance IS NULL OR provenance = 'ast')"; @@ -62,6 +62,7 @@ export function createTables(db: CodemapDatabase) { parent_name TEXT, visibility TEXT, complexity REAL, + cognitive_complexity INTEGER, name_column_start INTEGER NOT NULL DEFAULT 0, name_column_end INTEGER NOT NULL DEFAULT 0, scope_local_id INTEGER NOT NULL DEFAULT 0, @@ -910,6 +911,11 @@ export interface SymbolRow { * column existed; absence binds as `null`. */ complexity?: number | null; + /** + * SonarSource cognitive complexity for function-shaped symbols (same NULL + * rules as `complexity`). Optional for back-compat; absence binds as `null`. + */ + cognitive_complexity?: number | null; /** 0-based byte column of the symbol-name token start on `line_start` (per [R.6]). Optional for back-compat; defaults to 0. */ name_column_start?: number; /** 0-based byte column one past the symbol-name token end. Optional for back-compat; defaults to 0. */ @@ -1000,8 +1006,8 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { batchInsert( db, symbols, - "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value, parent_name, visibility, complexity, name_column_start, name_column_end, scope_local_id, body_line_count, param_count, nesting_depth, return_type, is_async, is_generator)", - "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value, parent_name, visibility, complexity, cognitive_complexity, name_column_start, name_column_end, scope_local_id, body_line_count, param_count, nesting_depth, return_type, is_async, is_generator)", + "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (s, v) => v.push( s.file_path, @@ -1018,6 +1024,7 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { s.parent_name, s.visibility, s.complexity ?? null, + s.cognitive_complexity ?? null, s.name_column_start ?? 0, s.name_column_end ?? 0, s.scope_local_id ?? 0, diff --git a/src/extractors/complexity.test.ts b/src/extractors/complexity.test.ts new file mode 100644 index 00000000..7145d12e --- /dev/null +++ b/src/extractors/complexity.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; + +import { extractFileData } from "../parser"; + +describe("cognitive complexity", () => { + it("nested if chain: cognitive_complexity > cyclomatic complexity", () => { + const src = `export function nested(level: number): number { + if (level > 0) { + if (level > 1) { + if (level > 2) { + return level; + } + return 2; + } + return 1; + } + return 0; +} +`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sym = d.symbols.find((s) => s.name === "nested"); + expect(sym?.complexity).toBe(4); + expect(sym?.cognitive_complexity).toBeGreaterThan(sym!.complexity!); + }); + + it("flat if-else-if chain: cognitive ≈ cyclomatic", () => { + const src = `export function flat(n: number): number { + if (n % 2 === 0) return 1; + else if (n % 3 === 0) return 2; + else if (n % 5 === 0) return 3; + return 0; +} +`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sym = d.symbols.find((s) => s.name === "flat"); + expect(sym?.complexity).toBe(4); + expect(sym?.cognitive_complexity).toBeGreaterThanOrEqual(3); + expect(sym?.cognitive_complexity).toBeLessThanOrEqual(sym!.complexity! + 2); + }); + + it("class method: both complexity and cognitive_complexity populated", () => { + const src = `export class Svc { + run(level: number): void { + if (level > 0) { + if (level > 1) { + if (level > 2) { + return; + } + } + } + } +} +`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const run = d.symbols.find((s) => s.name === "run"); + expect(run?.kind).toBe("method"); + expect(run?.complexity).toBeGreaterThan(1); + expect(run?.cognitive_complexity).toBeGreaterThan(run!.complexity!); + }); + + it("non-function symbols stay NULL for cognitive_complexity", () => { + const src = `export interface Row { id: string }\nexport const X = 1;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + for (const sym of d.symbols) { + expect(sym.cognitive_complexity ?? null).toBeNull(); + } + }); +}); diff --git a/src/extractors/complexity.ts b/src/extractors/complexity.ts index 06240174..5ea5c500 100644 --- a/src/extractors/complexity.ts +++ b/src/extractors/complexity.ts @@ -1,30 +1,41 @@ /** - * Cyclomatic complexity (McCabe) tracker + extractor. Shared-state pattern: - * tracker on `ctx.complexity`, mutated by `symbolsExtractor`'s function-shape - * handlers (push/pop alongside symbol-row emission) AND by - * `complexityExtractor`'s branching handlers (increment) + fn-expr push-pop. + * Cyclomatic complexity (McCabe) + SonarSource cognitive complexity tracker + * and extractor. Shared-state pattern: tracker on `ctx.complexity`, mutated by + * `symbolsExtractor`'s function-shape handlers (push/pop alongside symbol-row + * emission) AND by `complexityExtractor`'s branching handlers + fn-expr / + * method push-pop. * - * Factory closes over `symbols` so `popTop()` writes the final count back - * onto the row at the tracked index. + * Factory closes over `symbols` so `popTop()` writes counts back onto the row + * at the tracked index. */ import type { SymbolRow } from "../db"; import type { ComplexityTracker, TierExtractor } from "./types"; +interface StackFrame { + symbolIndex: number; + count: number; + currentDepth: number; + maxDepth: number; + cognitive: number; + cognitiveNest: number; +} + export function createComplexityTracker( symbols: SymbolRow[], ): ComplexityTracker { - const stack: { - symbolIndex: number; - count: number; - currentDepth: number; - maxDepth: number; - }[] = []; + const stack: StackFrame[] = []; const arrowMap = new WeakMap(); - return { pushFor(symbolIndex) { - stack.push({ symbolIndex, count: 1, currentDepth: 0, maxDepth: 0 }); + stack.push({ + symbolIndex, + count: 1, + currentDepth: 0, + maxDepth: 0, + cognitive: 0, + cognitiveNest: 0, + }); }, popTop() { const top = stack.pop(); @@ -32,6 +43,7 @@ export function createComplexityTracker( if (top.symbolIndex >= 0) { symbols[top.symbolIndex].complexity = top.count; symbols[top.symbolIndex].nesting_depth = top.maxDepth; + symbols[top.symbolIndex].cognitive_complexity = top.cognitive; } }, increment() { @@ -54,6 +66,24 @@ export function createComplexityTracker( getArrowSymbol(node) { return arrowMap.get(node); }, + cognitiveStructural() { + const top = stack[stack.length - 1]; + if (!top) return; + top.cognitive += 1 + top.cognitiveNest; + }, + cognitiveFlat() { + const top = stack[stack.length - 1]; + if (!top) return; + top.cognitive += 1; + }, + enterCognitiveNest() { + const top = stack[stack.length - 1]; + if (top) top.cognitiveNest++; + }, + exitCognitiveNest() { + const top = stack[stack.length - 1]; + if (top && top.cognitiveNest > 0) top.cognitiveNest--; + }, }; } @@ -62,10 +92,15 @@ export const complexityExtractor: TierExtractor = { register(visitor, ctx) { const { complexity } = ctx; const nest = () => { + complexity.cognitiveStructural(); + complexity.enterCognitiveNest(); complexity.enterNest(); complexity.increment(); }; - const unnest = () => complexity.exitNest(); + const unnest = () => { + complexity.exitCognitiveNest(); + complexity.exitNest(); + }; Object.assign(visitor, { // `symbolsExtractor`'s VariableDeclaration populates the arrow @@ -83,10 +118,10 @@ export const complexityExtractor: TierExtractor = { "FunctionExpression:exit"() { complexity.popTop(); }, - // Cyclomatic-complexity branching nodes — each adds 1. Block-bearing // forms (if/for/while/try/ternary) ALSO increment nesting_depth - // on enter and decrement on exit. + // on enter and decrement on exit. Cognitive uses Sonar structural + // increments (+1 + nesting) on the same shapes. IfStatement: nest, "IfStatement:exit": unnest, WhileStatement: nest, @@ -110,6 +145,7 @@ export const complexityExtractor: TierExtractor = { // nesting bump (switch arms are sibling, not depth). if (node.test !== null && node.test !== undefined) { complexity.increment(); + complexity.cognitiveFlat(); } }, LogicalExpression(node: any) { @@ -121,6 +157,7 @@ export const complexityExtractor: TierExtractor = { node.operator === "??" ) { complexity.increment(); + complexity.cognitiveFlat(); } }, }); diff --git a/src/extractors/symbols.ts b/src/extractors/symbols.ts index 1e524fc6..c5feeb2d 100644 --- a/src/extractors/symbols.ts +++ b/src/extractors/symbols.ts @@ -424,6 +424,7 @@ function registerSymbolHandlers( symbols, jsDocComments, source, + complexity, ); }, "ClassDeclaration:exit"(node: any) { @@ -448,6 +449,7 @@ function extractClassMembers( out: SymbolRow[], jsDocComments: JsDocEntry[], source: string, + complexity: ExtractContext["complexity"], ) { if (!members?.length) return; for (const m of members) { @@ -467,6 +469,7 @@ function extractClassMembers( const sig = `${prefix}${buildFunctionSignature(name, fn)}`; const methodLineStart = offsetToLine(lineMap, m.start); const methodLineEnd = offsetToLine(lineMap, m.end); + const symbolIndex = out.length; out.push({ file_path: filePath, name, @@ -487,6 +490,14 @@ function extractClassMembers( param_count: fn?.params?.length ?? 0, ...functionShapeColumns(fn), }); + // Method bodies are FunctionExpression nodes — mark for the same + // push/pop bridge as named arrow inits (see complexityExtractor). + if ( + fn?.type === "FunctionExpression" || + fn?.type === "ArrowFunctionExpression" + ) { + complexity.markArrowSymbol(fn, symbolIndex); + } } else if (m.type === "PropertyDefinition") { let prefix = ""; if (m.accessibility && m.accessibility !== "public") { diff --git a/src/extractors/types.ts b/src/extractors/types.ts index 303f8e72..98fcbe45 100644 --- a/src/extractors/types.ts +++ b/src/extractors/types.ts @@ -70,6 +70,12 @@ export interface ComplexityTracker { exitNest(): void; markArrowSymbol(node: object, symbolIndex: number): void; getArrowSymbol(node: object): number | undefined; + /** Sonar structural break (+1 + current cognitive nesting). */ + cognitiveStructural(): void; + /** Flat +1 (switch case, boolean operator) — no nesting penalty. */ + cognitiveFlat(): void; + enterCognitiveNest(): void; + exitCognitiveNest(): void; } /** diff --git a/src/parser.test.ts b/src/parser.test.ts index 6ed67c49..11096460 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -519,6 +519,15 @@ describe("extractFileData", () => { expect(create?.signature).toContain("static"); }); + it("class methods get cyclomatic and cognitive complexity", () => { + const src = `export class Svc {\n score(level: number): number {\n if (level > 0) {\n if (level > 1) {\n if (level > 2) return level;\n }\n }\n return 0;\n }\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const score = d.symbols.find((s) => s.name === "score"); + expect(score?.kind).toBe("method"); + expect(score?.complexity).toBe(4); + expect(score?.cognitive_complexity).toBeGreaterThan(score!.complexity!); + }); + it("class properties get parent_name", () => { const src = `export class Config {\n private host: string;\n readonly port = 3000;\n}\n`; const d = extractFileData("/proj/x.ts", src, "x.ts"); diff --git a/templates/agent-content/rule/00-full.md b/templates/agent-content/rule/00-full.md index 785b3239..0e4c6540 100644 --- a/templates/agent-content/rule/00-full.md +++ b/templates/agent-content/rule/00-full.md @@ -58,6 +58,7 @@ If the question matches any of these, use the index instead of grepping: | "Which components touch deprecated APIs?" | `--recipe components-touching-deprecated` | | "What's risky to refactor right now?" | `--recipe refactor-risk-ranking` | | "What's high-complexity AND undertested?" | `--recipe high-complexity-untested` | +| "What's cognitively complex (nesting-heavy)?" | `--recipe high-cognitive-complexity` (default `min_score=15`; `--params min_score=20` to tighten) | ## Quick reference queries diff --git a/templates/recipes/high-cognitive-complexity.md b/templates/recipes/high-cognitive-complexity.md new file mode 100644 index 00000000..8fc2d36b --- /dev/null +++ b/templates/recipes/high-cognitive-complexity.md @@ -0,0 +1,23 @@ +--- +params: + - name: min_score + type: number + required: false + default: 15 + description: Minimum SonarSource cognitive complexity score (default matches Sonar rule threshold) +actions: + - type: review-cognitive-complexity + auto_fixable: false + description: "Function-shaped symbols with cognitive complexity ≥ min_score — nesting-heavy control flow ranked above flat branch chains. Prefer early returns and extracted helpers." +--- + +# high-cognitive-complexity + +Functions with **cognitive complexity** ≥ `min_score` (default **15**, Sonar-aligned). Uses the same function-shaped symbol coverage as cyclomatic `symbols.complexity` (top-level functions, named arrow/const, class methods). + +Distinct from `high-complexity-untested` (cyclomatic gate + coverage) and `deeply-nested-functions` (`nesting_depth` only). + +```bash +codemap query --recipe high-cognitive-complexity +codemap query --recipe high-cognitive-complexity --params min_score=20 +``` diff --git a/templates/recipes/high-cognitive-complexity.sql b/templates/recipes/high-cognitive-complexity.sql new file mode 100644 index 00000000..18599813 --- /dev/null +++ b/templates/recipes/high-cognitive-complexity.sql @@ -0,0 +1,14 @@ +SELECT + name, + kind, + file_path, + line_start, + line_end, + cognitive_complexity, + complexity, + nesting_depth +FROM symbols +WHERE cognitive_complexity IS NOT NULL + AND cognitive_complexity >= ? +ORDER BY cognitive_complexity DESC, complexity DESC, file_path, name +LIMIT 50; diff --git a/templates/recipes/high-complexity-untested.md b/templates/recipes/high-complexity-untested.md index 0e4736f9..6c1b6070 100644 --- a/templates/recipes/high-complexity-untested.md +++ b/templates/recipes/high-complexity-untested.md @@ -16,7 +16,11 @@ McCabe formula: `1 + (decision points)`. Branching nodes counted by Codemap's pa - `&&` / `||` / `??` short-circuit operators (`?` / `:` ternary too) - `catch` clauses -**Computed for function-shaped symbols only** — non-function kinds (interfaces, types, enums, plain consts) and class member methods get `complexity = NULL` and are excluded by `WHERE s.complexity IS NOT NULL`. +**Computed for function-shaped symbols** — top-level `function` declarations, named arrow/const bindings, and class methods (`MethodDefinition` bodies). Non-function kinds (interfaces, types, enums, plain consts) get `complexity = NULL` and are excluded by `WHERE s.complexity IS NOT NULL`. + +## Cognitive complexity column (`symbols.cognitive_complexity`) + +Each row also includes **SonarSource cognitive complexity** for the same symbol (nesting-heavy control flow scores higher than flat branch chains). The recipe **filter** still uses cyclomatic `>= 10`; use `high-cognitive-complexity` when cognitive score alone is the gate. ## Why the joint signal @@ -31,8 +35,4 @@ McCabe formula: `1 + (decision points)`. Branching nodes counted by Codemap's pa - **Complexity threshold**: change `>= 10` to project's risk-appetite (5 for strict; 15 for tolerant). - **Coverage threshold**: change `< 50` to project's risk-appetite (`< 80` for strict). - **Filter to a directory**: `AND s.file_path LIKE 'src/api/%'` to scope. -- **Include class members**: complexity is computed per top-level function; class methods currently inherit `null` (see "v1 limitation" below). - -## v1 limitation — class methods are NULL - -Complexity is currently computed for top-level `function` declarations and arrow-function consts; class methods (`MethodDefinition`) inherit `NULL`. Tracked for a future codemap release — file an issue if class-heavy projects need this sooner. +- **Sort by cognitive instead of cyclomatic**: `ORDER BY s.cognitive_complexity DESC` in a project-local recipe override. diff --git a/templates/recipes/high-complexity-untested.sql b/templates/recipes/high-complexity-untested.sql index 36c55d7d..f89b90a9 100644 --- a/templates/recipes/high-complexity-untested.sql +++ b/templates/recipes/high-complexity-untested.sql @@ -12,6 +12,7 @@ SELECT s.line_start, s.line_end, s.complexity, + s.cognitive_complexity, ROUND(COALESCE(c.coverage_pct, 0), 1) AS coverage_pct FROM symbols s LEFT JOIN coverage c ON c.file_path = s.file_path From 9e06ff1ee8da7536ebbc3a13b8f6b2fee38508e6 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 17:22:41 +0300 Subject: [PATCH 2/9] fix(complexity): score else-if chains at parent nesting level Else-if alternates no longer inherit consequent cognitive nesting; golden pins labyrinth cognitive_complexity at 27 on minimal fixture. --- docs/architecture.md | 2 +- docs/glossary.md | 2 +- .../minimal/high-cognitive-complexity.json | 2 +- .../minimal/high-complexity-untested.json | 2 +- src/extractors/complexity.test.ts | 37 +++++++++++++++---- src/extractors/complexity.ts | 32 +++++++++++++--- src/extractors/types.ts | 2 +- .../recipes/high-cognitive-complexity.md | 4 +- templates/recipes/high-complexity-untested.md | 2 +- 9 files changed, 65 insertions(+), 20 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 39072c95..68bfd35c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -283,7 +283,7 @@ All base tables use `STRICT` mode; **`source_fts`** is an FTS5 virtual table (no | parent_name | TEXT | Nearest **named** enclosing scope (class, function, method, arrow-assigned-to-const). Walks past anonymous arrows / IIFEs / callbacks (e.g. `forEach(() => …)` inside `foo` → `parent_name='foo'`). NULL when no named owner exists — true module-scope OR inside a top-level anonymous IIFE. **For a strict "module-scope only" filter use `scope_local_id = 0`** (the canonical answer), not `parent_name IS NULL` | | visibility | TEXT | JSDoc visibility tag derived from `doc_comment` at parse time: `public` / `private` / `internal` / `alpha` / `beta`. NULL when no tag present. Tag must start its own line (after the JSDoc `*` prefix); first match in document order wins. Powers the `visibility-tags` recipe and `WHERE visibility = ?` queries via the partial index `idx_symbols_visibility` | | complexity | REAL | Cyclomatic complexity (McCabe; `1 + decision points`) for function-shaped symbols (top-level `function`, named arrow/const, class methods). NULL for non-functions. Decision points: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` arms (not `default:`), short-circuit logical/nullish operators, ternary `?:`, and `catch` clauses. Powers `high-complexity-untested` (cyclomatic gate) | -| cognitive_complexity | INTEGER | SonarSource cognitive complexity for the same function-shaped symbols as `complexity` (NULL otherwise). Penalizes nesting; computed in the same oxc walk as cyclomatic (`src/extractors/complexity.ts`). Powers `high-cognitive-complexity`; also exposed as a column on `high-complexity-untested` rows | +| cognitive_complexity | INTEGER | Sonar-inspired cognitive complexity for the same function-shaped symbols as `complexity` (NULL otherwise). Penalizes nesting over flat else-if chains; same oxc walk as cyclomatic. Powers `high-cognitive-complexity`; also exposed as a column on `high-complexity-untested` rows | | name_column_start | INTEGER | 0-based column of the symbol-name token on `line_start` (per [R.6]) | | name_column_end | INTEGER | One-past-last column of the symbol-name token | | scope_local_id | INTEGER | Enclosing scope where the symbol's NAME is declared (joins `scopes.local_id`). Default `0` (module) | diff --git a/docs/glossary.md b/docs/glossary.md index a65cf490..03840b73 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -141,7 +141,7 @@ Per-function decision-point count (REAL column on `symbols`). Computed by the pa ### `symbols.cognitive_complexity` / cognitive complexity -SonarSource cognitive complexity (INTEGER on `symbols`) for the same function-shaped symbols as cyclomatic `complexity`. Penalizes nested control flow; same oxc walk as McCabe (`src/extractors/complexity.ts`). Recipes: `high-cognitive-complexity` (`min_score` default 15); `high-complexity-untested` includes the column while filtering on cyclomatic `complexity`. +SonarSource-inspired cognitive complexity (INTEGER on `symbols`) for the same function-shaped symbols as cyclomatic `complexity`. Penalizes nested control flow; computed in the same parser walk as McCabe. Recipes: `high-cognitive-complexity` (`min_score` default 15, Sonar rule threshold); `high-complexity-untested` includes the column while filtering on cyclomatic `complexity`. ### `source_fts` (FTS5 virtual table) / `--with-fts` / opt-in full-text diff --git a/fixtures/golden/minimal/high-cognitive-complexity.json b/fixtures/golden/minimal/high-cognitive-complexity.json index eadf0ece..459de665 100644 --- a/fixtures/golden/minimal/high-cognitive-complexity.json +++ b/fixtures/golden/minimal/high-cognitive-complexity.json @@ -5,7 +5,7 @@ "file_path": "src/lib/complexity-fixture.ts", "line_start": 22, "line_end": 83, - "cognitive_complexity": 40, + "cognitive_complexity": 27, "complexity": 19, "nesting_depth": 5 } diff --git a/fixtures/golden/minimal/high-complexity-untested.json b/fixtures/golden/minimal/high-complexity-untested.json index d602fc85..f7c750c6 100644 --- a/fixtures/golden/minimal/high-complexity-untested.json +++ b/fixtures/golden/minimal/high-complexity-untested.json @@ -6,7 +6,7 @@ "line_start": 22, "line_end": 83, "complexity": 19, - "cognitive_complexity": 40, + "cognitive_complexity": 27, "coverage_pct": 0 } ] diff --git a/src/extractors/complexity.test.ts b/src/extractors/complexity.test.ts index 7145d12e..b4aa8a06 100644 --- a/src/extractors/complexity.test.ts +++ b/src/extractors/complexity.test.ts @@ -23,19 +23,42 @@ describe("cognitive complexity", () => { expect(sym?.cognitive_complexity).toBeGreaterThan(sym!.complexity!); }); - it("flat if-else-if chain: cognitive ≈ cyclomatic", () => { - const src = `export function flat(n: number): number { + it("flat if-else-if chain: cognitive ≈ cyclomatic, below nested peer", () => { + const flatSrc = `export function flat(n: number): number { if (n % 2 === 0) return 1; else if (n % 3 === 0) return 2; else if (n % 5 === 0) return 3; return 0; } `; - const d = extractFileData("/proj/x.ts", src, "x.ts"); - const sym = d.symbols.find((s) => s.name === "flat"); - expect(sym?.complexity).toBe(4); - expect(sym?.cognitive_complexity).toBeGreaterThanOrEqual(3); - expect(sym?.cognitive_complexity).toBeLessThanOrEqual(sym!.complexity! + 2); + const nestedSrc = `export function nested(level: number): number { + if (level > 0) { + if (level > 1) { + if (level > 2) { + return level; + } + return 2; + } + return 1; + } + return 0; +} +`; + const flat = extractFileData("/proj/x.ts", flatSrc, "x.ts").symbols.find( + (s) => s.name === "flat", + ); + const nested = extractFileData( + "/proj/x.ts", + nestedSrc, + "x.ts", + ).symbols.find((s) => s.name === "nested"); + expect(flat?.complexity).toBe(4); + expect(nested?.complexity).toBe(4); + expect(flat?.cognitive_complexity).toBe(3); + expect(nested?.cognitive_complexity).toBe(6); + expect(flat!.cognitive_complexity!).toBeLessThan( + nested!.cognitive_complexity!, + ); }); it("class method: both complexity and cognitive_complexity populated", () => { diff --git a/src/extractors/complexity.ts b/src/extractors/complexity.ts index 5ea5c500..46ee4b67 100644 --- a/src/extractors/complexity.ts +++ b/src/extractors/complexity.ts @@ -66,10 +66,13 @@ export function createComplexityTracker( getArrowSymbol(node) { return arrowMap.get(node); }, - cognitiveStructural() { + cognitiveStructural(opts?: { elseIf?: boolean }) { const top = stack[stack.length - 1]; if (!top) return; - top.cognitive += 1 + top.cognitiveNest; + const nestLevel = opts?.elseIf + ? Math.max(0, top.cognitiveNest - 1) + : top.cognitiveNest; + top.cognitive += 1 + nestLevel; }, cognitiveFlat() { const top = stack[stack.length - 1]; @@ -91,8 +94,12 @@ export const complexityExtractor: TierExtractor = { tierId: "complexity", register(visitor, ctx) { const { complexity } = ctx; + // Else-if chains reuse the parent `if`'s cognitive nesting level (Sonar + // spec) — mark alternate `IfStatement` nodes on the parent enter. + const elseIfNodes = new WeakSet(); + const nest = () => { - complexity.cognitiveStructural(); + complexity.cognitiveStructural({}); complexity.enterCognitiveNest(); complexity.enterNest(); complexity.increment(); @@ -101,6 +108,21 @@ export const complexityExtractor: TierExtractor = { complexity.exitCognitiveNest(); complexity.exitNest(); }; + const nestIf = (node: any) => { + if (node.alternate?.type === "IfStatement") { + elseIfNodes.add(node.alternate); + } + const isElseIf = elseIfNodes.has(node); + complexity.cognitiveStructural({ elseIf: isElseIf }); + if (!isElseIf) complexity.enterCognitiveNest(); + complexity.enterNest(); + complexity.increment(); + }; + const unnestIf = (node: any) => { + if (!elseIfNodes.has(node)) complexity.exitCognitiveNest(); + elseIfNodes.delete(node); + complexity.exitNest(); + }; Object.assign(visitor, { // `symbolsExtractor`'s VariableDeclaration populates the arrow @@ -122,8 +144,8 @@ export const complexityExtractor: TierExtractor = { // forms (if/for/while/try/ternary) ALSO increment nesting_depth // on enter and decrement on exit. Cognitive uses Sonar structural // increments (+1 + nesting) on the same shapes. - IfStatement: nest, - "IfStatement:exit": unnest, + IfStatement: nestIf, + "IfStatement:exit": unnestIf, WhileStatement: nest, "WhileStatement:exit": unnest, DoWhileStatement: nest, diff --git a/src/extractors/types.ts b/src/extractors/types.ts index 98fcbe45..d0d4869e 100644 --- a/src/extractors/types.ts +++ b/src/extractors/types.ts @@ -71,7 +71,7 @@ export interface ComplexityTracker { markArrowSymbol(node: object, symbolIndex: number): void; getArrowSymbol(node: object): number | undefined; /** Sonar structural break (+1 + current cognitive nesting). */ - cognitiveStructural(): void; + cognitiveStructural(opts?: { elseIf?: boolean }): void; /** Flat +1 (switch case, boolean operator) — no nesting penalty. */ cognitiveFlat(): void; enterCognitiveNest(): void; diff --git a/templates/recipes/high-cognitive-complexity.md b/templates/recipes/high-cognitive-complexity.md index 8fc2d36b..2eabc797 100644 --- a/templates/recipes/high-cognitive-complexity.md +++ b/templates/recipes/high-cognitive-complexity.md @@ -4,7 +4,7 @@ params: type: number required: false default: 15 - description: Minimum SonarSource cognitive complexity score (default matches Sonar rule threshold) + description: Minimum cognitive complexity score (default 15 matches Sonar rule threshold) actions: - type: review-cognitive-complexity auto_fixable: false @@ -13,7 +13,7 @@ actions: # high-cognitive-complexity -Functions with **cognitive complexity** ≥ `min_score` (default **15**, Sonar-aligned). Uses the same function-shaped symbol coverage as cyclomatic `symbols.complexity` (top-level functions, named arrow/const, class methods). +Functions with **cognitive complexity** ≥ `min_score` (default **15**, Sonar rule threshold). Uses the same function-shaped symbol coverage as cyclomatic `symbols.complexity` (top-level functions, named arrow/const, class methods). Distinct from `high-complexity-untested` (cyclomatic gate + coverage) and `deeply-nested-functions` (`nesting_depth` only). diff --git a/templates/recipes/high-complexity-untested.md b/templates/recipes/high-complexity-untested.md index 6c1b6070..45cd2959 100644 --- a/templates/recipes/high-complexity-untested.md +++ b/templates/recipes/high-complexity-untested.md @@ -16,7 +16,7 @@ McCabe formula: `1 + (decision points)`. Branching nodes counted by Codemap's pa - `&&` / `||` / `??` short-circuit operators (`?` / `:` ternary too) - `catch` clauses -**Computed for function-shaped symbols** — top-level `function` declarations, named arrow/const bindings, and class methods (`MethodDefinition` bodies). Non-function kinds (interfaces, types, enums, plain consts) get `complexity = NULL` and are excluded by `WHERE s.complexity IS NOT NULL`. +**Computed for function-shaped symbols** — top-level `function` declarations, named arrow/const bindings, and class methods. Non-function kinds (interfaces, types, enums, plain consts) get `complexity = NULL` and are excluded by `WHERE s.complexity IS NOT NULL`. ## Cognitive complexity column (`symbols.cognitive_complexity`) From 460493b38549b074aa10bc9ee781d53fb3e5556b Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 17:42:50 +0300 Subject: [PATCH 3/9] feat(agents): add harden-pr skill for post-slice production hardening Codifies the review-until-clean loop as a Tier-3 skill with lite (per tracer bullet) and full (pre-PR) modes, and wires lite harden into tracer-bullets cadence. --- .agents/rules/agents-tier-system.md | 2 +- .agents/rules/tracer-bullets.md | 7 ++- .agents/skills/harden-pr/SKILL.md | 97 +++++++++++++++++++++++++++++ .cursor/skills/harden-pr | 1 + 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 .agents/skills/harden-pr/SKILL.md create mode 120000 .cursor/skills/harden-pr diff --git a/.agents/rules/agents-tier-system.md b/.agents/rules/agents-tier-system.md index 5ba0a9b4..12a3c9b7 100644 --- a/.agents/rules/agents-tier-system.md +++ b/.agents/rules/agents-tier-system.md @@ -52,7 +52,7 @@ Today's Tier-2 rules: Pure intent-triggered. The skill description is detailed enough that Cursor surfaces it on relevant phrases. No always-on cost. -Skills stay rule-less when the work is **explicitly invoked** by the user, not pattern-triggered. Today: `audit-pr-architecture`, `codemap` (consumer pointer), `diagnose`, `docs-governance`, `docs-lifecycle-sweep`, `grill-me`, `improve-codebase-architecture`, `pr-comment-fact-check`, `write-a-skill`. (Skills like `gritql-codemods` and `ubiquitous-language` would also fit this tier if adopted.) +Skills stay rule-less when the work is **explicitly invoked** by the user, not pattern-triggered. Today: `audit-pr-architecture`, `codemap` (consumer pointer), `diagnose`, `docs-governance`, `docs-lifecycle-sweep`, `grill-me`, `harden-pr`, `improve-codebase-architecture`, `pr-comment-fact-check`, `write-a-skill`. (Skills like `gritql-codemods` and `ubiquitous-language` would also fit this tier if adopted.) ## Authoring guidelines diff --git a/.agents/rules/tracer-bullets.md b/.agents/rules/tracer-bullets.md index 3ac9d8f0..fbfe9e55 100644 --- a/.agents/rules/tracer-bullets.md +++ b/.agents/rules/tracer-bullets.md @@ -15,8 +15,9 @@ AI agents tend to produce complete solutions in one leap — all parsers, all sc 1. **Start with one vertical slice** that touches all relevant layers for the simplest case 2. **Commit and validate** that slice before expanding — the pre-commit hook will run format, lint, typecheck, and tests on staged files (when AI/agent env vars trigger it) -3. **Expand outward** from the working slice in subsequent commits -4. **Never build horizontal layers in isolation** (e.g. all DB helpers before any CLI wiring, or all docs before any working index path) +3. **Lite-harden the slice** — run [`harden-pr`](../skills/harden-pr/SKILL.md) in **lite** mode (parallel reviewers → fix in-bounds → up to 2 passes). When the user has asked for commits: one `harden: …` commit before the next slice. Does not change feature intent — production polish only. +4. **Expand outward** from the working slice in subsequent commits +5. **Never build horizontal layers in isolation** (e.g. all DB helpers before any CLI wiring, or all docs before any working index path) ## Feature layers in this project @@ -65,3 +66,5 @@ Good — tracer bullet: ## Commit cadence Each commit should represent a functional, describable milestone — not a placeholder. Every tracer bullet is a shippable slice that works end-to-end, even if the feature isn't complete yet. Small commits get validated by the pre-commit hook and are easier to review and revert. + +Before opening a PR, run [`harden-pr`](../skills/harden-pr/SKILL.md) in **full** mode on `origin/main...HEAD` (or accept the offer when the plan checklist is complete). diff --git a/.agents/skills/harden-pr/SKILL.md b/.agents/skills/harden-pr/SKILL.md new file mode 100644 index 00000000..50f01387 --- /dev/null +++ b/.agents/skills/harden-pr/SKILL.md @@ -0,0 +1,97 @@ +--- +name: harden-pr +description: >- + Production-harden a branch without changing PR intent — spawn parallel reviewer + subagents, fix in-bounds findings, loop until clean or cap. Use after a tracer-bullet + commit (lite), before PR is done (full), or on "harden", "review until clean", + "production-ready pass", "make this merge-ready locally". Sister to babysit (external + GitHub/CI) — hand off there only after local hardening. NEVER redesign the feature or + change observable runtime behavior. +--- + +# Harden PR + +Local production-hardening loop: parallel reviewer subagents → merge findings → fix in-bounds → re-verify → repeat. Refines bugs, tests, docs, and hygiene — **not** the feature's goal or runtime behavior. + +Sister skills: [`audit-pr-architecture`](../audit-pr-architecture/SKILL.md) (extended structural reviewer). After local hardening, hand off to the Cursor **`babysit`** skill (personal — GitHub comments + CI). + +## Modes + +| Mode | When | Scope | Max passes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ---------- | +| **Lite** | After each tracer-bullet slice commit ([`tracer-bullets`](../../rules/tracer-bullets.md) cadence) | Files in the slice diff | 2 | +| **Full** | User intent ("full harden", "PR done", "production-ready pass") **or** offer when an in-flight `docs/plans/.md` checklist is complete | `origin/main...HEAD` | 3 | + +Default to **lite** when invoked immediately after a slice commit. Default to **full** when the user signals branch completion. + +## Intent anchor (every reviewer prompt includes this) + +Resolve in order; stop at the first hit: + +1. **Plan doc** — in-flight `docs/plans/.md`: goal + non-goals +2. **Commit range** — `git log --oneline origin/main...HEAD` + `git diff --name-status origin/main...HEAD` +3. **User anchor** — ask once: "What must not change?" (1–2 sentences) + +Reviewers treat the anchor as contract. Findings that would violate it → **report, do not apply**. + +## In-bounds vs out-of-bounds + +**Fix:** bugs, missing tests, docs/changeset drift, lint/type/format, error-handling gaps, edge cases, **behavior-preserving refactors in touched files**, in-scope nits (naming, comment hygiene, cheap lint fixes). + +**Report only:** redesign, new capabilities, semantic API changes, nits outside the diff, refactors unrelated to a flagged issue. + +## Reviewer roster + +Spawn applicable reviewers **in parallel** via subagents. Each returns `{ finding, severity, file, fixable_in_bounds }`. + +### Core (always) + +1. **Correctness** — bugs, edge cases, missing tests in changed paths +2. **Ship-readiness** — docs, changesets, consumer-surface leaks ([`consumer-surfaces`](../../rules/consumer-surfaces.md)), error messages; run [`verify-after-each-step`](../../rules/verify-after-each-step.md) checks on touched files +3. **Structure (lite)** — boundary smells on the diff only (imports across declared layers, barrel bypasses); query codemap per [`codemap`](../codemap/SKILL.md) + +### Extended (adaptive — spawn when diff triggers match) + +| Reviewer | Trigger | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Structure (full)** | ≥5 files moved between top-level `src/` modules, new `src//` folder, or user asked for structural review → run [`audit-pr-architecture`](../audit-pr-architecture/SKILL.md) read-only; apply only fixable in-bounds items, not audit-doc recommendations that change design | +| **Schema / migration** | `src/db.ts`, `SCHEMA_VERSION`, migration paths | +| **Consumer surface** | `templates/agent-content/**`, root `README.md`, CLI help/errors, `.changeset/*.md` bodies | +| **Security** | auth, secrets, env handling, user-input paths | +| **Performance** | hot paths, benchmarks, worker pools | + +Re-derive layer globs from `docs/architecture.md` § Layering — don't hardcode module lists that drift. + +## Loop + +``` +resolve intent anchor +spawn reviewers (parallel) +merge + dedupe findings +if none actionable → done +fix in-bounds (pass 1: all; passes 2+: blockers first, then in-scope nits) +run project checks on touched files +if clean and no new findings → done +if pass cap hit → emit deferred-nits list → done +else → next pass +``` + +**Pass cap behavior:** after cap, stop auto-fixing; list deferred nits. Do not block the next tracer slice. + +## Git + +When the user has asked for commits: one `harden: …` commit per hardening cycle (lite or full), after the loop finishes. Never commit without explicit user request. + +## Handoff + +When **full** harden completes clean (or capped with only deferred nits): offer the Cursor **`babysit`** skill for GitHub comments, CI, and merge conflicts — external merge-readiness is out of scope here. + +## Quick invoke + +Replace the copy-pasted loop prompt with: + +> **Lite** (post-slice): `harden-pr lite` +> +> **Full** (branch done): `harden-pr full` + +Or attach this skill and say "harden after this commit" / "production-ready pass". diff --git a/.cursor/skills/harden-pr b/.cursor/skills/harden-pr new file mode 120000 index 00000000..69b49c1d --- /dev/null +++ b/.cursor/skills/harden-pr @@ -0,0 +1 @@ +../../.agents/skills/harden-pr \ No newline at end of file From 1a25fec314904d6b6546af5ad72923ae90184e42 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 17:54:48 +0300 Subject: [PATCH 4/9] harden: cognitive-complexity ship fixes from full harden pass Minor changeset for SCHEMA 38 rebuild, agent rule column ref, arrow/bind test coverage, and doc/comment hygiene from harden-pr review. --- .changeset/cognitive-complexity.md | 2 +- fixtures/minimal/README.md | 2 +- src/db.ts | 2 +- src/extractors/complexity.test.ts | 23 +++++++++++++++++++++++ src/parser.test.ts | 9 --------- templates/agent-content/rule/00-full.md | 3 ++- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/.changeset/cognitive-complexity.md b/.changeset/cognitive-complexity.md index 2a019dfa..e3d7b4f9 100644 --- a/.changeset/cognitive-complexity.md +++ b/.changeset/cognitive-complexity.md @@ -1,5 +1,5 @@ --- -"@stainless-code/codemap": patch +"@stainless-code/codemap": minor --- Add SonarSource cognitive complexity on `symbols` (same function-shaped coverage as cyclomatic, including class methods). New recipe `high-cognitive-complexity`; `high-complexity-untested` rows include `cognitive_complexity`. diff --git a/fixtures/minimal/README.md b/fixtures/minimal/README.md index 7a29811b..335cad89 100644 --- a/fixtures/minimal/README.md +++ b/fixtures/minimal/README.md @@ -21,7 +21,7 @@ Stable tree exercising every codemap surface — used by `test:golden`, `test:ag | **`type_members`** | `ClientConfig`, `Transport`, `ProductCardProps`; homonym guard via `src/types/homonym-mammal.ts` (`Mammal` name collision) | | **`css_*`** | Variables, classes, keyframes, `@import` | | **`test_suites`** | `src/__tests__/smoke.test.ts` — skip / only / todo / nested describe (vitest) | -| **`file_metrics` / complexity** | `lib/complexity-fixture.ts` — `large-functions`, `deeply-nested-functions`, `high-complexity-untested`, `catch_rethrows` heuristics | +| **`file_metrics` / complexity** | `lib/complexity-fixture.ts` — `large-functions`, `deeply-nested-functions`, `high-complexity-untested`, `high-cognitive-complexity`, `catch_rethrows` heuristics | | **`runtime_markers`** | `console.*`, `process.env` (`env.ts`), `throw` path in cache | | **`suppressions`** | `codemap-ignore-next-line` in `orphan.ts` | | **`boundaries`** | `.codemap/config.json` — `ui-no-api` deny rule → `boundary-violations` recipe | diff --git a/src/db.ts b/src/db.ts index 9116a482..37aaa9bb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -986,7 +986,7 @@ function batchInsert( ) { if (items.length === 0) return; // Per-table cap: narrow tables (4-col bindings) batch up to 5000 rows; - // wide tables (20-col symbols) batch up to floor(32766/20) = 1638. Both + // wide tables (24-col symbols) batch up to floor(32766/24) = 1365. Both // are much higher than the pre-2026-05 fixed 500 → fewer round-trips // through the bun:sqlite / better-sqlite3 binding boundary. const batchSize = batchSizeForTuple(one); diff --git a/src/extractors/complexity.test.ts b/src/extractors/complexity.test.ts index b4aa8a06..8c9f0e4b 100644 --- a/src/extractors/complexity.test.ts +++ b/src/extractors/complexity.test.ts @@ -61,6 +61,29 @@ describe("cognitive complexity", () => { ); }); + it("named arrow and function expression: cognitive_complexity populated", () => { + const src = `export const arrow = (level: number): number => { + if (level > 0) { + if (level > 1) { + if (level > 2) return level; + } + } + return 0; +}; +export const bind = function (n: number): number { + if (n % 2 === 0) return 1; + return 0; +}; +`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const arrow = d.symbols.find((s) => s.name === "arrow"); + const bind = d.symbols.find((s) => s.name === "bind"); + expect(arrow?.complexity).toBe(4); + expect(arrow?.cognitive_complexity).toBeGreaterThan(arrow!.complexity!); + expect(bind?.complexity).toBe(2); + expect(bind?.cognitive_complexity).toBe(1); + }); + it("class method: both complexity and cognitive_complexity populated", () => { const src = `export class Svc { run(level: number): void { diff --git a/src/parser.test.ts b/src/parser.test.ts index 11096460..6ed67c49 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -519,15 +519,6 @@ describe("extractFileData", () => { expect(create?.signature).toContain("static"); }); - it("class methods get cyclomatic and cognitive complexity", () => { - const src = `export class Svc {\n score(level: number): number {\n if (level > 0) {\n if (level > 1) {\n if (level > 2) return level;\n }\n }\n return 0;\n }\n}\n`; - const d = extractFileData("/proj/x.ts", src, "x.ts"); - const score = d.symbols.find((s) => s.name === "score"); - expect(score?.kind).toBe("method"); - expect(score?.complexity).toBe(4); - expect(score?.cognitive_complexity).toBeGreaterThan(score!.complexity!); - }); - it("class properties get parent_name", () => { const src = `export class Config {\n private host: string;\n readonly port = 3000;\n}\n`; const d = extractFileData("/proj/x.ts", src, "x.ts"); diff --git a/templates/agent-content/rule/00-full.md b/templates/agent-content/rule/00-full.md index 0e4c6540..be317494 100644 --- a/templates/agent-content/rule/00-full.md +++ b/templates/agent-content/rule/00-full.md @@ -50,7 +50,8 @@ If the question matches any of these, use the index instead of grepping: | "Are there import cycles?" / "Files in cycles" | `--recipe circular-imports` / `module_cycles` | | "Where do barrel files re-export from?" | `--recipe barrel-chains` / `re_export_chains` | | "Functions over 50 lines / deeply nested" | `--recipe large-functions` / `deeply-nested-functions` | -| "What's the cyclomatic complexity / nesting depth of X?" | `symbols.complexity` / `symbols.nesting_depth` | +| "What's the cyclomatic / cognitive complexity of X?" | `symbols.complexity` / `symbols.cognitive_complexity` (Sonar-inspired; class methods included) | +| "What's the nesting depth of X?" | `symbols.nesting_depth` | | "Is symbol X tested?" / "What's the coverage of file Y?" | `coverage` (after `codemap ingest-coverage`) | | "What's structurally dead AND untested?" | `--recipe untested-and-dead` | | "Worst-covered exported functions" | `--recipe worst-covered-exports` | From 2e6d912217fbedb5374e6aeae654c2a338788c8c Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 17:57:27 +0300 Subject: [PATCH 5/9] harden: refresh files-hashes golden after minimal README edit README content_hash drifted when fixtures/minimal/README.md listed high-cognitive-complexity; golden now matches indexer line_count. --- fixtures/golden/minimal/files-hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/golden/minimal/files-hashes.json b/fixtures/golden/minimal/files-hashes.json index c7bcf941..6e6f6825 100644 --- a/fixtures/golden/minimal/files-hashes.json +++ b/fixtures/golden/minimal/files-hashes.json @@ -13,7 +13,7 @@ }, { "path": "README.md", - "content_hash": "83dd2274fa918728c812bf27eef86da7dd1cb24fa09c70e5bbad9173750121b5", + "content_hash": "4f6f0b898fbc9cecd08c66bf33b54cbc3a8d176da86fdeda8860a16e1c1cd3f6", "language": "md", "line_count": 58 }, From 96a707b8bff4ecb532c4a04e223771d794eeb1fc Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 17:57:57 +0300 Subject: [PATCH 6/9] fix(agents): harden-pr runs autonomously without mid-loop prompts Skill invocation now authorizes the end commit and forbids asking about commits, babysit, or the next pass until the loop finishes. --- .agents/skills/harden-pr/SKILL.md | 84 ++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/.agents/skills/harden-pr/SKILL.md b/.agents/skills/harden-pr/SKILL.md index 50f01387..6f7e4019 100644 --- a/.agents/skills/harden-pr/SKILL.md +++ b/.agents/skills/harden-pr/SKILL.md @@ -2,18 +2,41 @@ name: harden-pr description: >- Production-harden a branch without changing PR intent — spawn parallel reviewer - subagents, fix in-bounds findings, loop until clean or cap. Use after a tracer-bullet - commit (lite), before PR is done (full), or on "harden", "review until clean", - "production-ready pass", "make this merge-ready locally". Sister to babysit (external - GitHub/CI) — hand off there only after local hardening. NEVER redesign the feature or - change observable runtime behavior. + subagents, fix in-bounds findings, loop autonomously until clean or pass cap, then + report once. Use after a tracer-bullet commit (lite), before PR is done (full), or on + "harden", "harden-pr", "review until clean", "production-ready pass". Invoking this + skill authorizes one harden commit at cycle end. NEVER stop mid-loop to ask about + commits, babysit, or the next pass. NEVER redesign the feature or change observable + runtime behavior. --- # Harden PR -Local production-hardening loop: parallel reviewer subagents → merge findings → fix in-bounds → re-verify → repeat. Refines bugs, tests, docs, and hygiene — **not** the feature's goal or runtime behavior. +Local production-hardening loop: parallel reviewer subagents → merge findings → fix in-bounds → re-verify → repeat until clean or cap → **one final report**. Refines bugs, tests, docs, and hygiene — **not** the feature's goal or runtime behavior. -Sister skills: [`audit-pr-architecture`](../audit-pr-architecture/SKILL.md) (extended structural reviewer). After local hardening, hand off to the Cursor **`babysit`** skill (personal — GitHub comments + CI). +**Invoking this skill (`/harden-pr`, `harden-pr lite`, `harden-pr full`) is a run-to-completion command.** The agent executes the full loop before ending the turn. + +Sister skills: [`audit-pr-architecture`](../audit-pr-architecture/SKILL.md) (extended structural reviewer). Mention **`babysit`** only in the final report (full mode) — never mid-loop. + +## Run-to-completion (read first) + +**NEVER** stop between passes to ask: + +- whether to commit +- whether to run babysit +- whether to continue to the next pass +- whether to spawn another reviewer + +**ONLY** allowed mid-loop question: intent anchor step 3 when plan doc and commit range both fail to state what must not change. + +Otherwise: resolve anchor → run all passes → fix → verify → next pass → finish → report. + +| Phase | Behavior | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **During loop** | Autonomous. Spawn reviewers in parallel, merge findings, fix in-bounds, re-run checks, advance pass counter. | +| **After loop** | Single concise report: mode, passes run, fixes made, checks status, deferred nits (if any). | +| **Commit** | If there are uncommitted fixes: one `harden: …` commit **without asking** — skill invocation authorizes it. If no fixes: skip commit. | +| **Babysit** | Full mode only. One line at end of report: "For GitHub/CI, run `/babysit`." Do not ask. | ## Modes @@ -30,7 +53,7 @@ Resolve in order; stop at the first hit: 1. **Plan doc** — in-flight `docs/plans/.md`: goal + non-goals 2. **Commit range** — `git log --oneline origin/main...HEAD` + `git diff --name-status origin/main...HEAD` -3. **User anchor** — ask once: "What must not change?" (1–2 sentences) +3. **User anchor** — ask once: "What must not change?" (1–2 sentences). **Only step that may interrupt the loop.** Reviewers treat the anchor as contract. Findings that would violate it → **report, do not apply**. @@ -42,7 +65,7 @@ Reviewers treat the anchor as contract. Findings that would violate it → **rep ## Reviewer roster -Spawn applicable reviewers **in parallel** via subagents. Each returns `{ finding, severity, file, fixable_in_bounds }`. +Spawn applicable reviewers **in parallel** via subagents in **one batch per pass**. Each returns `{ finding, severity, file, fixable_in_bounds }`. ### Core (always) @@ -64,34 +87,39 @@ Re-derive layer globs from `docs/architecture.md` § Layering — don't hardcode ## Loop +Execute **without pausing for user input** until exit condition: + ``` resolve intent anchor -spawn reviewers (parallel) -merge + dedupe findings -if none actionable → done -fix in-bounds (pass 1: all; passes 2+: blockers first, then in-scope nits) -run project checks on touched files -if clean and no new findings → done -if pass cap hit → emit deferred-nits list → done -else → next pass +pass = 1 +loop: + spawn reviewers (parallel, one batch) + merge + dedupe findings + if none actionable → goto done + fix in-bounds (pass 1: all; passes 2+: blockers first, then in-scope nits) + run project checks on touched files + if clean and no new findings → goto done + if pass >= max_passes → goto capped + pass += 1 + goto loop +capped: + emit deferred-nits list +done: + if uncommitted fixes → git commit -m "harden: …" + emit final report (include babysit one-liner if full mode) ``` **Pass cap behavior:** after cap, stop auto-fixing; list deferred nits. Do not block the next tracer slice. ## Git -When the user has asked for commits: one `harden: …` commit per hardening cycle (lite or full), after the loop finishes. Never commit without explicit user request. - -## Handoff - -When **full** harden completes clean (or capped with only deferred nits): offer the Cursor **`babysit`** skill for GitHub comments, CI, and merge conflicts — external merge-readiness is out of scope here. +Skill invocation **is** the commit authorization. After the loop: if fixes exist, create one `harden: …` commit immediately — do not ask first. If the working tree is clean, skip. ## Quick invoke -Replace the copy-pasted loop prompt with: - -> **Lite** (post-slice): `harden-pr lite` -> -> **Full** (branch done): `harden-pr full` +| Intent | Say | +| ----------- | ------------------------------------------------------ | +| Post-slice | `/harden-pr lite` or `/harden-pr` after a slice commit | +| Branch done | `/harden-pr full` or "production-ready pass" | -Or attach this skill and say "harden after this commit" / "production-ready pass". +Replaces the old copy-paste: _"spawn subagents → fix → loop until clean"_ — this skill **is** that loop. From 2c6eccbd60f4509bf6199f46c4e75f8db02d59f7 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 18:01:06 +0300 Subject: [PATCH 7/9] harden: assert cognitive_complexity DB round-trip and schema rebuild insertSymbols SELECT test mirrors visibility pattern; v27 rebuild asserts symbols.cognitive_complexity via pragma_table_info. --- src/db.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/db.test.ts b/src/db.test.ts index 55d3b54b..1e3b2627 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -182,6 +182,66 @@ describe("SQLite layer (in-memory)", () => { } }); + it("symbols.cognitive_complexity round-trips via insertSymbols", () => { + const db = openCodemapDatabase(":memory:"); + try { + createTables(db); + insertFile(db, { + path: "x.ts", + content_hash: "abc", + size: 1, + line_count: 1, + language: "ts", + last_modified: 0, + indexed_at: 0, + }); + insertSymbols(db, [ + { + file_path: "x.ts", + name: "nested", + kind: "function", + line_start: 1, + line_end: 10, + signature: "nested(): void", + is_exported: 1, + is_default_export: 0, + members: null, + doc_comment: null, + value: null, + parent_name: null, + visibility: null, + complexity: 4, + cognitive_complexity: 6, + }, + { + file_path: "x.ts", + name: "plain", + kind: "interface", + line_start: 12, + line_end: 12, + signature: "interface plain", + is_exported: 1, + is_default_export: 0, + members: null, + doc_comment: null, + value: null, + parent_name: null, + visibility: null, + }, + ]); + + const rows = db + .query("SELECT name, cognitive_complexity FROM symbols ORDER BY name") + .all() as Array<{ name: string; cognitive_complexity: number | null }>; + expect(rows).toEqual([ + { name: "nested", cognitive_complexity: 6 }, + { name: "plain", cognitive_complexity: null }, + ]); + } finally { + closeDb(db); + } + }); + it("query_baselines round-trips upsert / get / list / delete", () => { const db = openCodemapDatabase(":memory:"); try { @@ -409,6 +469,15 @@ describe("SQLite layer (in-memory)", () => { .get() as { n: number } ).n, ).toBe(2); + expect( + ( + db + .query<{ n: number }>( + "SELECT COUNT(*) AS n FROM pragma_table_info('symbols') WHERE name = 'cognitive_complexity'", + ) + .get() as { n: number } + ).n, + ).toBe(1); expect(listQueryBaselines(db).map((b) => b.name)).toEqual(["fan-out"]); expect(getQueryBaseline(db, "fan-out")?.git_ref).toBe("v27-head"); From f26d486fe607ca0727fb952ba3818cd1545a6d14 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 18:01:49 +0300 Subject: [PATCH 8/9] docs(agents): add production bar goal to harden-pr skill Defines pristine/maximum production readiness as the north star reviewers optimize for, with an explicit checklist and production-bar status in reports. --- .agents/skills/harden-pr/SKILL.md | 43 ++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/.agents/skills/harden-pr/SKILL.md b/.agents/skills/harden-pr/SKILL.md index 6f7e4019..2f24546d 100644 --- a/.agents/skills/harden-pr/SKILL.md +++ b/.agents/skills/harden-pr/SKILL.md @@ -1,18 +1,20 @@ --- name: harden-pr description: >- - Production-harden a branch without changing PR intent — spawn parallel reviewer - subagents, fix in-bounds findings, loop autonomously until clean or pass cap, then - report once. Use after a tracer-bullet commit (lite), before PR is done (full), or on - "harden", "harden-pr", "review until clean", "production-ready pass". Invoking this - skill authorizes one harden commit at cycle end. NEVER stop mid-loop to ask about - commits, babysit, or the next pass. NEVER redesign the feature or change observable - runtime behavior. + Bring a branch to pristine, maximum production readiness without changing PR intent — + spawn parallel reviewer subagents, fix in-bounds findings, loop autonomously until + clean or pass cap, then report once. Use after a tracer-bullet commit (lite), before PR + is done (full), or on "harden", "harden-pr", "pristine", "review until clean", + "production-ready pass". Invoking this skill authorizes one harden commit at cycle end. + NEVER stop mid-loop to ask about commits, babysit, or the next pass. NEVER redesign the + feature or change observable runtime behavior. --- # Harden PR -Local production-hardening loop: parallel reviewer subagents → merge findings → fix in-bounds → re-verify → repeat until clean or cap → **one final report**. Refines bugs, tests, docs, and hygiene — **not** the feature's goal or runtime behavior. +**Goal:** leave the PR / feature in **pristine, maximum production state** — every changed path shippable, verified, documented, and hygienic. Polish and harden what the PR already does; **never** change its intent or runtime behavior. + +Local loop: parallel reviewer subagents → merge findings → fix in-bounds → re-verify → repeat until clean or cap → **one final report**. **Invoking this skill (`/harden-pr`, `harden-pr lite`, `harden-pr full`) is a run-to-completion command.** The agent executes the full loop before ending the turn. @@ -34,7 +36,7 @@ Otherwise: resolve anchor → run all passes → fix → verify → next pass | Phase | Behavior | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **During loop** | Autonomous. Spawn reviewers in parallel, merge findings, fix in-bounds, re-run checks, advance pass counter. | -| **After loop** | Single concise report: mode, passes run, fixes made, checks status, deferred nits (if any). | +| **After loop** | Single concise report: mode, passes run, production-bar status (met / gaps), fixes made, checks status, deferred nits (if any). | | **Commit** | If there are uncommitted fixes: one `harden: …` commit **without asking** — skill invocation authorizes it. If no fixes: skip commit. | | **Babysit** | Full mode only. One line at end of report: "For GitHub/CI, run `/babysit`." Do not ask. | @@ -47,6 +49,23 @@ Otherwise: resolve anchor → run all passes → fix → verify → next pass Default to **lite** when invoked immediately after a slice commit. Default to **full** when the user signals branch completion. +## Production bar (what "pristine" means) + +Reviewers optimize for this bar on in-scope files. **Full** mode applies it to the entire `origin/main...HEAD` diff; **lite** to the slice diff. + +| Area | Pristine = | +| --------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Correctness** | No known bugs or unhandled edge cases in changed paths; behavior matches intent anchor | +| **Tests** | Changed behavior covered; affected tests pass | +| **Checks** | Format, lint, typecheck clean on touched files ([`verify-after-each-step`](../../rules/verify-after-each-step.md)) | +| **Docs** | User-visible changes reflected in docs, changesets, help text — no drift | +| **Surfaces** | No maintainer leaks into consumer surfaces ([`consumer-surfaces`](../../rules/consumer-surfaces.md)) | +| **Structure** | No boundary violations or barrel bypasses in the diff | +| **Hygiene** | No dead code, TODO slop, or sloppy naming in touched files; errors actionable | +| **Ship shape** | A reviewer could merge without "fix before ship" notes (except deferred out-of-scope nits) | + +If a finding moves the bar toward pristine and stays in-bounds → **fix it**, including nits in touched files. + ## Intent anchor (every reviewer prompt includes this) Resolve in order; stop at the first hit: @@ -69,9 +88,9 @@ Spawn applicable reviewers **in parallel** via subagents in **one batch per pass ### Core (always) -1. **Correctness** — bugs, edge cases, missing tests in changed paths -2. **Ship-readiness** — docs, changesets, consumer-surface leaks ([`consumer-surfaces`](../../rules/consumer-surfaces.md)), error messages; run [`verify-after-each-step`](../../rules/verify-after-each-step.md) checks on touched files -3. **Structure (lite)** — boundary smells on the diff only (imports across declared layers, barrel bypasses); query codemap per [`codemap`](../codemap/SKILL.md) +1. **Correctness** — gaps vs production bar; bugs, edge cases, missing tests in changed paths +2. **Ship-readiness** — gaps vs production bar; docs, changesets, consumer-surface leaks, error messages; run [`verify-after-each-step`](../../rules/verify-after-each-step.md) checks on touched files +3. **Structure (lite)** — gaps vs production bar; boundary smells on the diff (imports across declared layers, barrel bypasses); query codemap per [`codemap`](../codemap/SKILL.md) ### Extended (adaptive — spawn when diff triggers match) From 720aaa5dda693b1c00ce6f219d989f5c6d935310 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 19:16:47 +0300 Subject: [PATCH 9/9] docs(extractors): JSDoc for cognitive nest enter/exit on ComplexityTracker Mirror enterNest/exitNest one-liners; addresses CodeRabbit PR #173 thread. --- src/extractors/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extractors/types.ts b/src/extractors/types.ts index d0d4869e..a44eecd7 100644 --- a/src/extractors/types.ts +++ b/src/extractors/types.ts @@ -74,7 +74,9 @@ export interface ComplexityTracker { cognitiveStructural(opts?: { elseIf?: boolean }): void; /** Flat +1 (switch case, boolean operator) — no nesting penalty. */ cognitiveFlat(): void; + /** Enter cognitive nesting scope (paired with exitCognitiveNest). */ enterCognitiveNest(): void; + /** Exit cognitive nesting scope. */ exitCognitiveNest(): void; }