Skip to content

Commit db2f27a

Browse files
feat(complexity): cyclomatic complexity column + high-complexity-untested recipe (research note § 1.4) (#70)
* feat(complexity): cyclomatic complexity column + high-complexity-untested recipe Research note § 1.4 ship-pick (c) per § 5 cadence. Schema bump SCHEMA_VERSION 7 → 8. Schema: - symbols.complexity REAL column. NULL for non-function kinds and class methods (v1 limitation documented in recipe .md). Parser: - complexityStack maintained alongside scopeStack. Function entry pushes {symbolIndex, count: 1}; branching-node visitors increment top.count; function exit pops + writes count into the already- pushed symbol row's complexity field. - McCabe decision points counted: if, while, do-while, for, for-in, for-of, case X (not default:), &&/||/??, ?:, catch. Bundled recipe high-complexity-untested: - Joins symbols (complexity >= 10) with coverage (< 50%). - Combines structural + runtime evidence axes — surfaces refactor- priority candidates that untested-and-dead and worst-covered-exports miss (they catch dead-or-uncalled, this catches called-but-undertested- AND-branchy). Empirical sanity check on codemap's own index after reindex: - extractFileData (parser.ts main visitor) → complexity 108 ✓ - stringifyTypeNode → 42 ✓ - All non-function kinds have NULL complexity ✓ - high-complexity-untested recipe returns 7 functions all from src/parser.ts (which has 0% coverage; complexity ≥ 10) ✓ Lockstep updates per Rule 10 (templates/agents + .agents): - Trigger pattern row "What's high-complexity AND undertested?" - Quick reference row for SELECT name, complexity FROM symbols - Recipe-id list extended in SKILL.md Plus architecture.md (schema version 8, complexity column docs), glossary.md (cyclomatic complexity entry), patch changeset. Files changed: - src/db.ts (SCHEMA_VERSION + symbols.complexity column + insertSymbols bind + SymbolRow optional complexity field) - src/parser.ts (complexityStack + branching node visitors + push/pop in FunctionDeclaration / VariableDeclaration arrow-fn paths) - templates/recipes/high-complexity-untested.{sql,md} - docs/architecture.md (schema version + symbols column doc) - docs/glossary.md (new entry) - templates/agents/rules/codemap.md + .agents/rules/codemap.md (trigger + quick-ref rows) - templates/agents/skills/codemap/SKILL.md + .agents/skills/codemap/ SKILL.md (recipe-id list) - .changeset/cyclomatic-complexity.md (patch) Verification: - bun test: 754 pass - bun run check passes (format, lint, typecheck, 23/23 golden queries) - Live re-index against codemap source produces sensible complexity values (parser visitor itself is the highest at 108, which tracks) * docs(skill): add complexity column to symbols schema in skill files CodeRabbit catch on PR #70: the high-complexity-untested recipe row was added to .agents/skills/codemap/SKILL.md but the symbols table schema section (under "### `symbols` — Functions, types, ...") still listed columns through `visibility` only, missing the new `complexity REAL` column. Verified by reading the file — claim was correct. Both lockstep mirrors (.agents/ + templates/agents/) updated with the same row: | complexity | REAL | Cyclomatic complexity (`1 + decision points`) for function-shaped symbols. NULL for non-functions and class methods (v1). Powers --recipe high-complexity-untested. Decision points: if, while, do…while, for/for-in/for-of, case X: (not default:), &&/||/??/?:, catch | Per docs/README.md Rule 10 — agent rule + skill schema docs must stay in lockstep with code-side schema changes. The trigger-pattern row + recipe-id list were already updated; the schema-table row was the gap. * fix(complexity): per-function visitors fix multi-declarator misattribution + cleanups CodeRabbit raised three valid findings on PR #70. All fact-checked against the code; all correct. A) docs/architecture.md symbols schema table was malformed: - Markdown table separator row had extra `| --- | ---` segments because oxfmt mis-counted columns when the description contained `|` chars inside `&&`/`||`/`??` backtick spans. - The complexity row's description was split across THREE cells with broken backtick fences. - Fix: restored single-row layout (3 cells: Column | Type | Description) and rephrased the decision-point list to avoid `|` inside backticks ("short-circuit `&&` / `||` / `??`" instead of "`&&`/`||`/`??`"). B) src/parser.ts complexity misattribution on multi-declarator VariableDeclaration (e.g. `const a = () => {…}, b = () => {…};`): Pre-fix: VariableDeclaration enter pushed all declarators' complexity entries up front. Then visitor traversed `a`'s body — branches incremented top (= b's entry). Then `b`'s body. Exit pops in reverse → symbols[1].complexity = 3 (wrong), symbols[0].complexity = 1 (wrong). Real bug. Fix: push/pop complexity on the FUNCTION-shaped node visitors (ArrowFunctionExpression / FunctionExpression) — not on VariableDeclaration. The VariableDeclaration handler still creates the symbol row but only RECORDS the symbol → init-node mapping in a WeakMap. The ArrowFunctionExpression / FunctionExpression enter handler reads the WeakMap to know which symbol to write back to; anonymous arrow fns (callbacks, IIFEs) get -1 and just track count without persistence. Verified against fixture: const a = () => { if (1===1) {…} }, b = () => { if (2===2) {…} }, c = () => 5; → a=2, b=2, c=1 (correct; pre-fix was a=1, b=3, c=1) C) popComplexityInto guard was a no-op (callers passed top.symbolIndex, so the equality check was always true). Simplified to parameterless popComplexityTop() that always pops + writes back if symbolIndex >= 0. Folds naturally into the B refactor — every push/pop pair now lives in a function-shaped visitor. Also re-ran codemap query against codemap source post-fix: extractFileData=108, stringifyTypeNode=42, extractClassMembers=18, extractLiteralValue=15, extractObjectMembers=14 Same scores as pre-fix on these (no FunctionExpression / arrow nesting in those particular functions, so the bug didn't surface) — confirms the refactor is a strict improvement, not a regression. * docs: audit + lift remaining stale references; concise-comments sweep on parser.ts Fact-checking against codebase post-PR-#69-and-#70 surfaced four stale spots; concise-comments rule re-applied to recently-authored parser.ts comments. DOCS LIFTED (post-FTS5 / Mermaid / complexity merge): - README.md (root) line 113 — --format enum was missing `mermaid`. Updated to <text|json|sarif|annotations|mermaid> + added the bounded-input contract one-liner + 50-edge ceiling note. Added --with-fts example block alongside (was missing entirely; README is the canonical CLI surface per docs/README.md Single source of truth table). - docs/architecture.md output-formatters paragraph — described only formatSarif + formatAnnotations; missing formatMermaid + bounded- input contract. Added formatMermaid description + MERMAID_MAX_EDGES reference + the no-auto-truncation reasoning (would be a verdict masquerading as output mode). Updated the --format CLI enum to include mermaid; same for the MCP tools format union. - .agents/skills/codemap/SKILL.md + templates/agents/skills/codemap/ SKILL.md — recipe-id list missed three coverage recipes (untested-and-dead, files-by-coverage, worst-covered-exports) shipped earlier in PR #65/#56 era. Lockstep update per Rule 10. Skill now lists 20 of 20 bundled recipe ids. CONCISE-COMMENTS SWEEP on parser.ts (recently authored): - Trimmed the 14-line complexityStack JSDoc block to 6 lines. Kept: the -1 sentinel rationale (non-obvious), the WeakMap rationale (the bug fix from PR #70 review). Cut: re-stating push/pop semantics obvious from method names + step-by-step "this then that" prose. - Removed the "Defer complexity push to..." comment in the VariableDeclaration handler. The 4-line block restated the design decision documented one screen up in the complexityStack jsdoc; cross-ref makes it redundant. Per concise-comments § "Cut" rule: "Cross-references that save grep time" — keep when they actually do; cut when they restate. Verification: - bun run check: format + lint + typecheck + 23/23 golden ✓ - Recipe count: SQL files = 20, skill mentions = 20 (1:1 match) ✓ - SCHEMA_VERSION = 8 in db.ts; docs/architecture.md says 8 ✓ - complexity column documented in architecture.md + glossary.md ✓ - --with-fts in README.md + architecture.md + glossary.md + roadmap.md (consumer-facing surfaces all aligned) ✓ - --format mermaid in README.md + architecture.md + glossary.md + agent rule/skill ✓
1 parent 560390b commit db2f27a

12 files changed

Lines changed: 281 additions & 93 deletions

File tree

.agents/rules/codemap.md

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -96,30 +96,32 @@ Violating this order is wrong even if you get the right answer — it wastes tim
9696

9797
If the question looks like any of these → use the index:
9898

99-
| Question shape | Table(s) |
100-
| ------------------------------------------------------------- | -------------------------------------------------------- |
101-
| "What/which files import X?" | `imports` (by `source`) or `dependencies` (by `to_path`) |
102-
| "Where is X defined?" | `symbols` |
103-
| "What does file X export?" | `exports` |
104-
| "What hooks does component X use?" / "List React components" | `components` |
105-
| "What are the CSS variables/tokens for X?" | `css_variables` |
106-
| "Find all TODOs/FIXMEs" | `markers` |
107-
| "Who depends on file X?" / "What does file X depend on?" | `dependencies` |
108-
| "How many files/symbols/components are there?" | any table with `COUNT(*)` |
109-
| "What are the CSS classes in X?" | `css_classes` |
110-
| "What keyframe animations exist?" | `css_keyframes` |
111-
| "What fields does interface/type X have?" | `type_members` |
112-
| "Is symbol X deprecated?" / "What does X do?" | `symbols` (`doc_comment`) |
113-
| "What's `@internal` / `@beta` / `@alpha` / `@private`?" | `symbols.visibility` (parsed JSDoc tag — not regex) |
114-
| "Who calls X?" / "What does X call?" | `calls` |
115-
| "Is symbol X tested?" / "What's the coverage of file Y?" | `coverage` (after `ingest-coverage`) |
116-
| "What's structurally dead AND untested?" | `--recipe untested-and-dead` |
117-
| "Rank files by test coverage" | `--recipe files-by-coverage` |
118-
| "Worst-covered exported functions" | `--recipe worst-covered-exports` |
119-
| "Which components touch deprecated APIs?" | `--recipe components-touching-deprecated` |
120-
| "What's risky to refactor right now?" | `--recipe refactor-risk-ranking` |
121-
| "Which exports has nobody imported?" | `--recipe unimported-exports` |
122-
| "Find @deprecated functions with TODO/FIXME and low coverage" | `--recipe text-in-deprecated-functions` (needs FTS5 on) |
99+
| Question shape | Table(s) |
100+
| ------------------------------------------------------------- | --------------------------------------------------------- |
101+
| "What/which files import X?" | `imports` (by `source`) or `dependencies` (by `to_path`) |
102+
| "Where is X defined?" | `symbols` |
103+
| "What does file X export?" | `exports` |
104+
| "What hooks does component X use?" / "List React components" | `components` |
105+
| "What are the CSS variables/tokens for X?" | `css_variables` |
106+
| "Find all TODOs/FIXMEs" | `markers` |
107+
| "Who depends on file X?" / "What does file X depend on?" | `dependencies` |
108+
| "How many files/symbols/components are there?" | any table with `COUNT(*)` |
109+
| "What are the CSS classes in X?" | `css_classes` |
110+
| "What keyframe animations exist?" | `css_keyframes` |
111+
| "What fields does interface/type X have?" | `type_members` |
112+
| "Is symbol X deprecated?" / "What does X do?" | `symbols` (`doc_comment`) |
113+
| "What's `@internal` / `@beta` / `@alpha` / `@private`?" | `symbols.visibility` (parsed JSDoc tag — not regex) |
114+
| "Who calls X?" / "What does X call?" | `calls` |
115+
| "Is symbol X tested?" / "What's the coverage of file Y?" | `coverage` (after `ingest-coverage`) |
116+
| "What's structurally dead AND untested?" | `--recipe untested-and-dead` |
117+
| "Rank files by test coverage" | `--recipe files-by-coverage` |
118+
| "Worst-covered exported functions" | `--recipe worst-covered-exports` |
119+
| "Which components touch deprecated APIs?" | `--recipe components-touching-deprecated` |
120+
| "What's risky to refactor right now?" | `--recipe refactor-risk-ranking` |
121+
| "Which exports has nobody imported?" | `--recipe unimported-exports` |
122+
| "Find @deprecated functions with TODO/FIXME and low coverage" | `--recipe text-in-deprecated-functions` (needs FTS5 on) |
123+
| "What's high-complexity AND undertested?" | `--recipe high-complexity-untested` |
124+
| "What's the cyclomatic complexity of symbol X?" | `SELECT name, complexity FROM symbols WHERE name = '...'` |
123125

124126
## When Grep / Read IS appropriate
125127

.agents/skills/codemap/SKILL.md

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ After **`bun run build`**, **`node dist/index.mjs query …`** or a linked **`co
3434

3535
Replace placeholders (`'...'`) with your module path, file glob, or symbol name.
3636

37-
**CLI shortcuts:** **`bun src/index.ts query --json --recipe <id>`** runs bundled SQL (preferred for agents). **`bun src/index.ts query --recipe <id>`** without **`--json`** prints a table. **`bun src/index.ts query --recipes-json`** prints every bundled recipe (**`id`**, **`description`**, **`sql`**, optional **`actions`**) as JSON (no index / DB required). **`bun src/index.ts query --print-sql <id>`** prints one recipe’s SQL only. Ids include **`fan-out`**, **`fan-out-sample`** (**`GROUP_CONCAT`** samples), **`fan-out-sample-json`** (same, but **`json_group_array`** — needs SQLite JSON1), **`fan-in`**, **`index-summary`**, **`files-largest`**, **`components-by-hooks`**, **`components-touching-deprecated`** (UNION of hook + call paths to `@deprecated` symbols), **`markers-by-kind`**, **`deprecated-symbols`**, **`refactor-risk-ranking`** (per-file `(fan_in + 1) × (100 - avg_coverage_pct)`), **`text-in-deprecated-functions`** (FTS5 ⨯ symbols ⨯ coverage demo — needs `--with-fts` enabled), **`unimported-exports`** (exports with no detectable importer; v1 doesn't follow re-export chains — see recipe `.md` for caveats), **`visibility-tags`**, **`barrel-files`**, **`files-hashes`** — see **`bun src/index.ts query --help`**.
37+
**CLI shortcuts:** **`bun src/index.ts query --json --recipe <id>`** runs bundled SQL (preferred for agents). **`bun src/index.ts query --recipe <id>`** without **`--json`** prints a table. **`bun src/index.ts query --recipes-json`** prints every bundled recipe (**`id`**, **`description`**, **`sql`**, optional **`actions`**) as JSON (no index / DB required). **`bun src/index.ts query --print-sql <id>`** prints one recipe’s SQL only. Ids include **`fan-out`**, **`fan-out-sample`** (**`GROUP_CONCAT`** samples), **`fan-out-sample-json`** (same, but **`json_group_array`** — needs SQLite JSON1), **`fan-in`**, **`index-summary`**, **`files-largest`**, **`components-by-hooks`**, **`components-touching-deprecated`** (UNION of hook + call paths to `@deprecated` symbols), **`markers-by-kind`**, **`deprecated-symbols`**, **`refactor-risk-ranking`** (per-file `(fan_in + 1) × (100 - avg_coverage_pct)`), **`high-complexity-untested`** (cyclomatic complexity ≥ 10 + coverage < 50%; per-function), **`text-in-deprecated-functions`** (FTS5 ⨯ symbols ⨯ coverage demo — needs `--with-fts` enabled), **`unimported-exports`** (exports with no detectable importer; v1 doesn't follow re-export chains — see recipe `.md` for caveats), **`visibility-tags`**, **`barrel-files`**, **`files-hashes`**, **`untested-and-dead`** (exported AND uncalled AND uncovered), **`files-by-coverage`** (per-file rollup of statement coverage), **`worst-covered-exports`** (lowest-covered exported symbols) — see **`bun src/index.ts query --help`**.
3838

3939
**Output flags** (compose with **`--recipe`** or ad-hoc SQL):
4040

@@ -132,22 +132,23 @@ LIMIT 10
132132

133133
### `symbols` — Functions, types, interfaces, enums, constants, classes
134134

135-
| Column | Type | Description |
136-
| ----------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
137-
| id | INTEGER PK | Auto-increment ID |
138-
| file_path | TEXT FK | References `files(path)` |
139-
| name | TEXT | Symbol name |
140-
| kind | TEXT | `function`, `class`, `type`, `interface`, `enum`, `const` |
141-
| line_start | INTEGER | Start line (1-based) |
142-
| line_end | INTEGER | End line (1-based) |
143-
| signature | TEXT | Reconstructed signature with generics and return types |
144-
| is_exported | INTEGER | 1 if exported |
145-
| is_default_export | INTEGER | 1 if default export |
146-
| members | TEXT | JSON enum members (NULL for non-enums) |
147-
| doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent |
148-
| value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) |
149-
| parent_name | TEXT | Enclosing symbol name (class/function), NULL = top-level |
150-
| visibility | TEXT | Line-leading JSDoc tag: `public` / `private` / `internal` / `alpha` / `beta`; NULL when absent. First match in document order wins |
135+
| Column | Type | Description |
136+
| ----------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | -------------------- |
137+
| id | INTEGER PK | Auto-increment ID |
138+
| file_path | TEXT FK | References `files(path)` |
139+
| name | TEXT | Symbol name |
140+
| kind | TEXT | `function`, `class`, `type`, `interface`, `enum`, `const` |
141+
| line_start | INTEGER | Start line (1-based) |
142+
| line_end | INTEGER | End line (1-based) |
143+
| signature | TEXT | Reconstructed signature with generics and return types |
144+
| is_exported | INTEGER | 1 if exported |
145+
| is_default_export | INTEGER | 1 if default export |
146+
| members | TEXT | JSON enum members (NULL for non-enums) |
147+
| doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent |
148+
| value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) |
149+
| parent_name | TEXT | Enclosing symbol name (class/function), NULL = top-level |
150+
| visibility | TEXT | Line-leading JSDoc tag: `public` / `private` / `internal` / `alpha` / `beta`; NULL when absent. First match in document order wins |
151+
| complexity | REAL | Cyclomatic complexity (`1 + decision points`) for function-shaped symbols. NULL for non-functions and class methods (v1). Powers `--recipe high-complexity-untested`. Decision points: `if`, `while`, `do…while`, `for`/`for-in`/`for-of`, `case X:` (not `default:`), `&&`/` | | `/`??`/`?:`, `catch` |
151152

152153
### `calls` — Function-scoped call edges (deduped per file)
153154

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
feat(complexity): cyclomatic complexity column on `symbols` + bundled recipe (research note § 1.4 ship-pick (c))
6+
7+
Adds per-function cyclomatic complexity computed during AST walking. Schema bump `SCHEMA_VERSION` 7 → 8 — first reindex after upgrade triggers a full rebuild via the existing version-mismatch path.
8+
9+
**What lands:**
10+
11+
- New `complexity REAL` column on `symbols`. Computed via McCabe formula (`1 + decision points`) for function-shaped symbols (top-level `function` declarations + arrow-function consts). `NULL` for non-functions (interfaces, types, enums, plain consts) and class methods (v1 limitation; documented in the recipe `.md`).
12+
- Decision points counted: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` arms (not `default:` fall-through), `&&` / `||` / `??` short-circuit operators, `?:` ternary, `catch` clauses.
13+
- New bundled recipe `high-complexity-untested` — function-shaped symbols with complexity ≥ 10 AND measured coverage < 50%. Combines structural + runtime evidence axes; surfaces refactor-priority candidates that single-axis recipes (`untested-and-dead`, `worst-covered-exports`) miss because they're "called but undertested."
14+
15+
**Implementation:**
16+
17+
- Parser visitor (`src/parser.ts`) maintains a `complexityStack` keyed by symbol index. On function entry, pushes counter at 1 + symbol index. Branching-node visitors increment the top counter. On function exit, pops and writes complexity into the symbol row already pushed during entry.
18+
- Nested function declarations get their own stack entries — inner branches don't count toward the outer function. (Standard McCabe — each function counted independently.)
19+
20+
**Pre-v1 patch** per `.agents/lessons.md` "changesets bump policy": schema-bumping changes are minor in semver but pre-v1 we default to patch unless the bump forces a `.codemap.db` rebuild. This one does (column added; auto-detected by `createSchema()` mismatch path) — every consumer's first run after upgrade re-indexes from scratch.
21+
22+
Agent rule + skill lockstep updated per `docs/README.md` Rule 10 — both `templates/agents/` and `.agents/` codemap rule + skill mention the `complexity` column, the new recipe, and the cyclomatic-complexity definition.
23+
24+
**Out of scope:**
25+
26+
- **Class method complexity**`MethodDefinition` visitor currently doesn't push to the complexity stack. Documented in `high-complexity-untested.md` v1 limitation; refactor opportunity for class-heavy projects.
27+
- **Per-class / per-file rollups**`complexity` is per-symbol; project-local recipes can `SUM` / `AVG` it as needed.

0 commit comments

Comments
 (0)