pi-aia-asf 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +26 -0
- package/index.ts +29 -0
- package/lib/code-health/config.mjs +176 -0
- package/lib/code-health/detect.mjs +128 -0
- package/lib/code-health/index.mjs +78 -0
- package/lib/code-health/report.mjs +169 -0
- package/lib/code-health/run.mjs +213 -0
- package/package.json +4 -3
- package/skills/aia-asf/SKILL.md +4 -1
- package/skills/aia-asf/references/06b-testing-qa.md +52 -0
- package/skills/aia-asf/references/06e-code-health.md +115 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-09-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Code Health Gate** — objective, configurable measurement of code
|
|
15
|
+
convolution at three levels, replacing pure judgment with measurable
|
|
16
|
+
thresholds (references/06e-code-health.md):
|
|
17
|
+
- **function** — cyclomatic complexity (20), cognitive complexity (15),
|
|
18
|
+
lines per function (50), nesting depth (4), parameters (3) via eslint
|
|
19
|
+
(`--rule` flags, not the project's own config);
|
|
20
|
+
- **module** — lines per file (300) via eslint, duplication (5% / min 5
|
|
21
|
+
lines / 50 tokens) via jscpd, circular imports (any cycle = fail) via
|
|
22
|
+
madge;
|
|
23
|
+
- **architecture** — optional dependency-cruiser rules (off by default).
|
|
24
|
+
- **`/asf health`** — runs the gate and prints a `metric | value | threshold |
|
|
25
|
+
status` table with a verdict; exit 1 when the gate fails. Flags: `--diff`
|
|
26
|
+
(regression trend vs the committed baseline), `--update-baseline`, `--json`.
|
|
27
|
+
- **Gate 8 in `/asf verify`** — large work runs the code health gate after the
|
|
28
|
+
M1 traceability matrix; block mode fails the gate, warn mode reports only.
|
|
29
|
+
- **`.asf-code-health.json`** — optional project config, merged over defaults
|
|
30
|
+
(defaults ← project ← CLI). On/off at every level: master `enabled`,
|
|
31
|
+
per-level, per-metric. `gate.mode` block|warn, `gate.scope` large|all|off,
|
|
32
|
+
`missingTool` warn|fail, `toolTimeoutSeconds` (default 120s, Rule 15).
|
|
33
|
+
- **Committed baseline** — `.asf-code-health-baseline.json` is committed so the
|
|
34
|
+
trend survives across machines/CI; `--diff` flags any regression.
|
|
35
|
+
- **Test suites** — `test/test-code-health.mjs` (73 checks: pure modules,
|
|
36
|
+
pipeline triggers + non-triggers with fake tools, real-exec timeout,
|
|
37
|
+
real-eslint integration) and `test/test-code-health-ext.mjs` (12 checks
|
|
38
|
+
through the real `/asf health` + `/asf verify` command handlers with a real
|
|
39
|
+
eslint).
|
|
40
|
+
|
|
41
|
+
## [0.7.0] - 2026-09-11
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **Absolute wait ceiling — 30 minutes (06b Rule 15)** — no single wait may
|
|
46
|
+
exceed 30 min (1800s). Addresses the reported failure where tool calls waited
|
|
47
|
+
forever for the system to react and the whole process kept hanging,
|
|
48
|
+
especially during testing. The ceiling is a **backstop, never a substitute**
|
|
49
|
+
for a real per-call bound: a 30s suite still gets ~120s, and a lone 1800s
|
|
50
|
+
bound is treated as not having bounded anything. Hitting the ceiling means
|
|
51
|
+
kill the wait and diagnose the blocking cause — it is a bug signal, not
|
|
52
|
+
patience.
|
|
53
|
+
- **Explicit per-run waiver** — the ceiling can be disabled, but only
|
|
54
|
+
deliberately and only for a **single run**: stated before running, with the
|
|
55
|
+
reason and a finite larger bound. A waiver does not persist, does not carry
|
|
56
|
+
over, and resets immediately afterwards. Blanket/standing waivers are
|
|
57
|
+
disallowed; repeated need for waivers is a setup defect to report (Rule 11).
|
|
58
|
+
- **"Every waiting call carries its own explicit timeout"** — makes the rule
|
|
59
|
+
cover *every* tool call or command that waits on the system, not just the
|
|
60
|
+
obviously slow ones, with testing called out as the highest-risk area
|
|
61
|
+
(harnesses, servers, daemons, browsers hanging on ports, locks and prompts).
|
|
62
|
+
Bounds are sized from the stated expected duration with headroom.
|
|
63
|
+
- New Definition-of-Done item for the ceiling; ceiling applies at **both
|
|
64
|
+
scales** (small work is not exempt).
|
|
65
|
+
- **Durable test suite in `test/`** — the ASF suites were previously only in
|
|
66
|
+
`/tmp` and would have been lost. `npm test` now runs 67 checks (timeouts,
|
|
67
|
+
ceiling, delegation, docs-rule) against the real shipped skill text;
|
|
68
|
+
`test/setup.sh` prepares the jiti harness against the installed pi.
|
|
69
|
+
|
|
70
|
+
### Verification
|
|
71
|
+
|
|
72
|
+
- 67/67 checks pass (`npm test`): 13 timeouts, 17 ceiling, 19 delegation,
|
|
73
|
+
18 docs-rule.
|
|
74
|
+
- The 17 new ceiling checks were confirmed to **fail 11/17 on the pre-change
|
|
75
|
+
text** before implementation, proving they assert real content.
|
|
76
|
+
- SKILL.md YAML frontmatter re-verified as parsing (pi silently skips skills
|
|
77
|
+
with malformed frontmatter).
|
|
78
|
+
|
|
79
|
+
|
|
10
80
|
## [0.6.1] - 2026-08-28
|
|
11
81
|
|
|
12
82
|
### Removed
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ You can also force/start a session explicitly:
|
|
|
29
29
|
/asf bugfix — major bugfix
|
|
30
30
|
/asf refactor — architectural refactor
|
|
31
31
|
/asf status — show current phase + state
|
|
32
|
+
/asf health — run the code health gate (function/module/architecture)
|
|
32
33
|
/asf approve — mark PLAN.md as approved (Gate 5)
|
|
33
34
|
/asf abort — end the session
|
|
34
35
|
```
|
|
@@ -60,6 +61,31 @@ Then `/reload`.
|
|
|
60
61
|
- **Extension** (`index.ts`) — `/asf` commands, per-project phase state (`~/.pi/agent/skills/aia-asf/projects/<project>/state.json`), dependency checks.
|
|
61
62
|
- **Specs shared with pi-vigilant** — ASF drives `capture_spec` during intake; pi-vigilant re-verifies every spec at task end and blocks "done" while MUST specs are open. One spec file, two systems.
|
|
62
63
|
|
|
64
|
+
## Code Health Gate
|
|
65
|
+
|
|
66
|
+
`/asf health` measures convolution **objectively** at three levels — function
|
|
67
|
+
(complexity, cognitive complexity, lines, depth, params), module (file lines,
|
|
68
|
+
duplication, circular imports) and architecture (optional dependency rules) —
|
|
69
|
+
and fails the gate when a threshold is crossed. It is **on by default** with
|
|
70
|
+
conservative thresholds; configure via `.asf-code-health.json` at the project
|
|
71
|
+
root (every level/metric can be disabled independently). For large work it is
|
|
72
|
+
**Gate 8** in `/asf verify`. See `skills/aia-asf/references/06e-code-health.md`.
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"enabled": true,
|
|
77
|
+
"gate": { "mode": "block", "scope": "large" },
|
|
78
|
+
"missingTool": "warn",
|
|
79
|
+
"function": { "complexity": { "max": 20 }, "maxDepth": { "max": 4 } },
|
|
80
|
+
"module": { "duplication": { "thresholdPercent": 5 } }
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Flags: `--diff` (compare vs committed `.asf-code-health-baseline.json`),
|
|
85
|
+
`--update-baseline`, `--json`. Tools are invoked via `npx --no-install` — they
|
|
86
|
+
must be project devDependencies (eslint, eslint-plugin-sonarjs, jscpd, madge,
|
|
87
|
+
dependency-cruiser); the gate never installs anything.
|
|
88
|
+
|
|
63
89
|
## Hygiene rules enforced
|
|
64
90
|
|
|
65
91
|
- Test-first; only green commits
|
package/index.ts
CHANGED
|
@@ -355,6 +355,21 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
355
355
|
}
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
if (scale === "large") {
|
|
359
|
+
// Gate 8: Code Health — objective convolution measurement.
|
|
360
|
+
const { runHealth } = await import("./lib/code-health/index.mjs");
|
|
361
|
+
const health = await runHealth(process.cwd(), []);
|
|
362
|
+
lines.push("\nCODE HEALTH (Gate 8 — large work):");
|
|
363
|
+
lines.push(health.text);
|
|
364
|
+
if (health.exitCode === 1) {
|
|
365
|
+
lines.push("\n ✗ GATE NOT PASSED — resolve code-health violations before delivery.");
|
|
366
|
+
} else if (!health.healthy && health.config?.gate?.mode === "warn") {
|
|
367
|
+
lines.push("\n ⚠ warn mode: violations reported but not blocking.");
|
|
368
|
+
} else {
|
|
369
|
+
lines.push("\n ✓ Code health gate passed.");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
358
373
|
lines.push(
|
|
359
374
|
"\nDefinition of Done checklist (references/06b-testing-qa.md Rule 10):",
|
|
360
375
|
);
|
|
@@ -374,6 +389,19 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
374
389
|
);
|
|
375
390
|
return lines.join("\n");
|
|
376
391
|
}
|
|
392
|
+
case "health": {
|
|
393
|
+
// Code Health Gate: objective measurement of convolution at function /
|
|
394
|
+
// module / architecture level (references/06e-code-health.md).
|
|
395
|
+
const { runHealth } = await import("./lib/code-health/index.mjs");
|
|
396
|
+
const res = await runHealth(process.cwd(), args.slice(1));
|
|
397
|
+
const tail =
|
|
398
|
+
res.exitCode === 1
|
|
399
|
+
? "\n\n✗ GATE FAILED — resolve the violations before delivery."
|
|
400
|
+
: res.config?.gate?.mode === "warn" && !res.healthy
|
|
401
|
+
? "\n\n(warn mode: violations reported, gate not blocking)"
|
|
402
|
+
: "";
|
|
403
|
+
return res.text + tail;
|
|
404
|
+
}
|
|
377
405
|
case "abort":
|
|
378
406
|
return await setPhase(ctx, "none");
|
|
379
407
|
default:
|
|
@@ -385,6 +413,7 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
385
413
|
" /asf refactor — architectural refactor (large, gated)\n" +
|
|
386
414
|
" /asf small — small change, automatic (no gates)\n" +
|
|
387
415
|
" /asf status — show current phase\n" +
|
|
416
|
+
" /asf health — run the code health gate (function/module/architecture)\n" +
|
|
388
417
|
" /asf verify — run the definition-of-done QA gate\n" +
|
|
389
418
|
" /asf abort — end the current session\n\n" +
|
|
390
419
|
dependencySummary()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Health Gate — configuration.
|
|
3
|
+
*
|
|
4
|
+
* Pure module: defaults + merge + normalization. No IO beyond reading the
|
|
5
|
+
* config file it is handed. SSOT for every threshold (06c Rule 2).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Built-in defaults — the gate is ON with conservative values. */
|
|
9
|
+
export const DEFAULT_CONFIG = {
|
|
10
|
+
enabled: true,
|
|
11
|
+
|
|
12
|
+
gate: {
|
|
13
|
+
mode: "block", // "block" | "warn"
|
|
14
|
+
scope: "large", // "large" | "all" | "off"
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
missingTool: "warn", // "warn" | "fail"
|
|
18
|
+
|
|
19
|
+
// Per-tool spawn bound (06b Rule 15: every wait carries its own timeout).
|
|
20
|
+
toolTimeoutSeconds: 120,
|
|
21
|
+
|
|
22
|
+
function: {
|
|
23
|
+
enabled: true,
|
|
24
|
+
complexity: { enabled: true, max: 20 },
|
|
25
|
+
cognitiveComplexity: { enabled: true, max: 15 },
|
|
26
|
+
maxLinesPerFunction: { enabled: true, max: 50 },
|
|
27
|
+
maxDepth: { enabled: true, max: 4 },
|
|
28
|
+
maxParams: { enabled: true, max: 3 },
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
module: {
|
|
32
|
+
enabled: true,
|
|
33
|
+
maxFileLines: { enabled: true, max: 300 },
|
|
34
|
+
duplication: { enabled: true, thresholdPercent: 5, minLines: 5, minTokens: 50 },
|
|
35
|
+
circularDependencies: { enabled: true },
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
architecture: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
dependencyRules: { enabled: false, config: ".asf-code-health.rules.mjs" },
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
ignore: [
|
|
44
|
+
"**/node_modules/**",
|
|
45
|
+
"**/dist/**",
|
|
46
|
+
"**/build/**",
|
|
47
|
+
"**/coverage/**",
|
|
48
|
+
"**/test/**",
|
|
49
|
+
"**/tests/**",
|
|
50
|
+
"**/__tests__/**",
|
|
51
|
+
"**/fixtures/**",
|
|
52
|
+
"**/*.test.*",
|
|
53
|
+
"**/*.spec.*",
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Deep-merge `overrides` onto `base`, returning a new object. */
|
|
58
|
+
export function mergeConfig(base, overrides) {
|
|
59
|
+
if (overrides === undefined || overrides === null) return structuredClone(base);
|
|
60
|
+
if (typeof overrides !== "object" || Array.isArray(overrides)) return structuredClone(base);
|
|
61
|
+
const out = structuredClone(base);
|
|
62
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
63
|
+
if (value === undefined) continue;
|
|
64
|
+
if (
|
|
65
|
+
value !== null &&
|
|
66
|
+
typeof value === "object" &&
|
|
67
|
+
!Array.isArray(value) &&
|
|
68
|
+
typeof out[key] === "object" &&
|
|
69
|
+
out[key] !== null &&
|
|
70
|
+
!Array.isArray(out[key])
|
|
71
|
+
) {
|
|
72
|
+
out[key] = mergeConfig(out[key], value);
|
|
73
|
+
} else {
|
|
74
|
+
out[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isPlainObject(v) {
|
|
81
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Coerce a value to a positive finite number, falling back to `fallback`. */
|
|
85
|
+
function num(value, fallback) {
|
|
86
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
87
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function bool(value, fallback) {
|
|
91
|
+
return typeof value === "boolean" ? value : fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function oneOf(value, allowed, fallback) {
|
|
95
|
+
return allowed.includes(value) ? value : fallback;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a raw config object (from .asf-code-health.json) into a complete
|
|
100
|
+
* config with every field typed. Unknown keys are dropped; wrong types fall
|
|
101
|
+
* back to the default for that field.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeConfig(raw) {
|
|
104
|
+
const d = DEFAULT_CONFIG;
|
|
105
|
+
if (!isPlainObject(raw)) return structuredClone(d);
|
|
106
|
+
const g = isPlainObject(raw.gate) ? raw.gate : {};
|
|
107
|
+
const f = isPlainObject(raw.function) ? raw.function : {};
|
|
108
|
+
const m = isPlainObject(raw.module) ? raw.module : {};
|
|
109
|
+
const a = isPlainObject(raw.architecture) ? raw.architecture : {};
|
|
110
|
+
const metric = (obj, name) => (isPlainObject(obj[name]) ? obj[name] : {});
|
|
111
|
+
|
|
112
|
+
const complexity = metric(f, "complexity");
|
|
113
|
+
const cognitive = metric(f, "cognitiveComplexity");
|
|
114
|
+
const linesFn = metric(f, "maxLinesPerFunction");
|
|
115
|
+
const depth = metric(f, "maxDepth");
|
|
116
|
+
const params = metric(f, "maxParams");
|
|
117
|
+
const fileLines = metric(m, "maxFileLines");
|
|
118
|
+
const dup = metric(m, "duplication");
|
|
119
|
+
const circ = metric(m, "circularDependencies");
|
|
120
|
+
const depRules = metric(a, "dependencyRules");
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
enabled: bool(raw.enabled, d.enabled),
|
|
124
|
+
gate: {
|
|
125
|
+
mode: oneOf(g.mode, ["block", "warn"], d.gate.mode),
|
|
126
|
+
scope: oneOf(g.scope, ["large", "all", "off"], d.gate.scope),
|
|
127
|
+
},
|
|
128
|
+
missingTool: oneOf(raw.missingTool, ["warn", "fail"], d.missingTool),
|
|
129
|
+
toolTimeoutSeconds: num(raw.toolTimeoutSeconds, d.toolTimeoutSeconds),
|
|
130
|
+
function: {
|
|
131
|
+
enabled: bool(f.enabled, d.function.enabled),
|
|
132
|
+
complexity: { enabled: bool(complexity.enabled, true), max: num(complexity.max, d.function.complexity.max) },
|
|
133
|
+
cognitiveComplexity: { enabled: bool(cognitive.enabled, true), max: num(cognitive.max, d.function.cognitiveComplexity.max) },
|
|
134
|
+
maxLinesPerFunction: { enabled: bool(linesFn.enabled, true), max: num(linesFn.max, d.function.maxLinesPerFunction.max) },
|
|
135
|
+
maxDepth: { enabled: bool(depth.enabled, true), max: num(depth.max, d.function.maxDepth.max) },
|
|
136
|
+
maxParams: { enabled: bool(params.enabled, true), max: num(params.max, d.function.maxParams.max) },
|
|
137
|
+
},
|
|
138
|
+
module: {
|
|
139
|
+
enabled: bool(m.enabled, d.module.enabled),
|
|
140
|
+
maxFileLines: { enabled: bool(fileLines.enabled, true), max: num(fileLines.max, d.module.maxFileLines.max) },
|
|
141
|
+
duplication: {
|
|
142
|
+
enabled: bool(dup.enabled, true),
|
|
143
|
+
thresholdPercent: num(dup.thresholdPercent, d.module.duplication.thresholdPercent),
|
|
144
|
+
minLines: num(dup.minLines, d.module.duplication.minLines),
|
|
145
|
+
minTokens: num(dup.minTokens, d.module.duplication.minTokens),
|
|
146
|
+
},
|
|
147
|
+
circularDependencies: { enabled: bool(circ.enabled, true) },
|
|
148
|
+
},
|
|
149
|
+
architecture: {
|
|
150
|
+
enabled: bool(a.enabled, d.architecture.enabled),
|
|
151
|
+
dependencyRules: {
|
|
152
|
+
enabled: bool(depRules.enabled, false),
|
|
153
|
+
config: typeof depRules.config === "string" && depRules.config ? depRules.config : d.architecture.dependencyRules.config,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
ignore: Array.isArray(raw.ignore) && raw.ignore.every((x) => typeof x === "string") && raw.ignore.length > 0
|
|
157
|
+
? raw.ignore
|
|
158
|
+
: [...d.ignore],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Load the project config: read `.asf-code-health.json` if present, normalize.
|
|
164
|
+
* `readFile` is injectable for tests.
|
|
165
|
+
*/
|
|
166
|
+
export async function loadConfig(projectRoot, readFile = null) {
|
|
167
|
+
const fs = readFile || (await import("node:fs/promises")).readFile;
|
|
168
|
+
let raw = null;
|
|
169
|
+
try {
|
|
170
|
+
const text = await fs(`${projectRoot}/.asf-code-health.json`, "utf-8");
|
|
171
|
+
raw = JSON.parse(text);
|
|
172
|
+
} catch {
|
|
173
|
+
raw = null; // no file, or unreadable/invalid → defaults
|
|
174
|
+
}
|
|
175
|
+
return normalizeConfig(raw);
|
|
176
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Health Gate — detection & metric planning.
|
|
3
|
+
*
|
|
4
|
+
* Pure module: given the project's file list, tool availability and config,
|
|
5
|
+
* decide which metrics run, are skipped (language absent / tool missing) or
|
|
6
|
+
* are disabled. No IO.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Languages the gate's metrics understand. */
|
|
10
|
+
export const LANGUAGES = {
|
|
11
|
+
javascript: { name: "JavaScript", exts: [".js", ".jsx", ".mjs", ".cjs"] },
|
|
12
|
+
typescript: { name: "TypeScript", exts: [".ts", ".tsx", ".mts", ".cts"] },
|
|
13
|
+
python: { name: "Python", exts: [".py"] },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect which languages are present in a file list.
|
|
18
|
+
* Returns a Set of language keys ("javascript", "typescript", "python", …).
|
|
19
|
+
*/
|
|
20
|
+
export function detectLanguages(filePaths) {
|
|
21
|
+
const present = new Set();
|
|
22
|
+
for (const p of filePaths) {
|
|
23
|
+
for (const [key, lang] of Object.entries(LANGUAGES)) {
|
|
24
|
+
if (lang.exts.some((ext) => p.endsWith(ext))) present.add(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return present;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A metric that the gate can run. */
|
|
31
|
+
export function planMetrics(config, languages, tools) {
|
|
32
|
+
const plans = [];
|
|
33
|
+
const hasJs = languages.has("javascript") || languages.has("typescript");
|
|
34
|
+
const hasTs = languages.has("typescript");
|
|
35
|
+
const hasPython = languages.has("python");
|
|
36
|
+
// eslint/madge/dependency-cruiser are JS/TS-only; jscpd is language-agnostic.
|
|
37
|
+
const anySource = languages.size > 0;
|
|
38
|
+
|
|
39
|
+
const push = (plan) => plans.push(plan);
|
|
40
|
+
|
|
41
|
+
if (!config.enabled) return plans;
|
|
42
|
+
|
|
43
|
+
const f = config.function;
|
|
44
|
+
if (hasJs) {
|
|
45
|
+
push(metric("complexity", "function", "eslint", f.enabled && f.complexity.enabled, f.complexity.max, "max cyclomatic complexity", tools.eslint, "eslint"));
|
|
46
|
+
push(metric("cognitiveComplexity", "function", "eslint-sonarjs", f.enabled && f.cognitiveComplexity.enabled, f.cognitiveComplexity.max, "max cognitive complexity", tools.eslintSonarjs, "eslint-plugin-sonarjs"));
|
|
47
|
+
push(metric("maxLinesPerFunction", "function", "eslint", f.enabled && f.maxLinesPerFunction.enabled, f.maxLinesPerFunction.max, "max lines per function", tools.eslint, "eslint"));
|
|
48
|
+
push(metric("maxDepth", "function", "eslint", f.enabled && f.maxDepth.enabled, f.maxDepth.max, "max nesting depth", tools.eslint, "eslint"));
|
|
49
|
+
push(metric("maxParams", "function", "eslint", f.enabled && f.maxParams.enabled, f.maxParams.max, "max parameters", tools.eslint, "eslint"));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const m = config.module;
|
|
53
|
+
if (hasJs) {
|
|
54
|
+
push(metric("maxFileLines", "module", "eslint", m.enabled && m.maxFileLines.enabled, m.maxFileLines.max, "max lines per file", tools.eslint, "eslint"));
|
|
55
|
+
}
|
|
56
|
+
if (anySource) {
|
|
57
|
+
push({
|
|
58
|
+
key: "duplication",
|
|
59
|
+
level: "module",
|
|
60
|
+
tool: "jscpd",
|
|
61
|
+
enabled: m.enabled && m.duplication.enabled,
|
|
62
|
+
threshold: m.duplication.thresholdPercent,
|
|
63
|
+
thresholdLabel: `max ${m.duplication.thresholdPercent}% duplicated`,
|
|
64
|
+
toolAvailable: tools.jscpd,
|
|
65
|
+
toolName: "jscpd",
|
|
66
|
+
compare: "value",
|
|
67
|
+
args: { thresholdPercent: m.duplication.thresholdPercent, minLines: m.duplication.minLines, minTokens: m.duplication.minTokens },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (hasJs) {
|
|
71
|
+
push(metric("circularDependencies", "module", "madge", m.enabled && m.circularDependencies.enabled, 0, "no circular imports", tools.madge, "madge"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const a = config.architecture;
|
|
75
|
+
if (hasJs && a.dependencyRules.enabled) {
|
|
76
|
+
push({
|
|
77
|
+
key: "dependencyRules",
|
|
78
|
+
level: "architecture",
|
|
79
|
+
tool: "dependency-cruiser",
|
|
80
|
+
enabled: a.enabled && true,
|
|
81
|
+
threshold: 0,
|
|
82
|
+
thresholdLabel: "no rule violations",
|
|
83
|
+
toolAvailable: tools.dependencyCruiser,
|
|
84
|
+
toolName: "dependency-cruiser",
|
|
85
|
+
compare: "violations",
|
|
86
|
+
args: { config: a.dependencyRules.config },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return plans;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function metric(key, level, tool, enabled, threshold, thresholdLabel, toolAvailable, toolName) {
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
level,
|
|
97
|
+
tool,
|
|
98
|
+
enabled,
|
|
99
|
+
threshold,
|
|
100
|
+
thresholdLabel,
|
|
101
|
+
toolAvailable,
|
|
102
|
+
toolName,
|
|
103
|
+
// eslint/madge/depcruise report VIOLATION COUNTS: any violation fails.
|
|
104
|
+
// jscpd reports a measured percentage: value > threshold fails.
|
|
105
|
+
compare: tool === "jscpd" ? "value" : "violations",
|
|
106
|
+
args: {},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tool availability check (pure): given a map of tool → boolean, mark each
|
|
112
|
+
* plan as runnable / missing-tool / language-skipped / disabled.
|
|
113
|
+
*/
|
|
114
|
+
export function resolvePlans(plans, languages) {
|
|
115
|
+
const hasJs = languages.has("javascript") || languages.has("typescript");
|
|
116
|
+
const anySource = languages.size > 0;
|
|
117
|
+
return plans.map((p) => {
|
|
118
|
+
if (!p.enabled) return { ...p, status: "disabled", reason: "disabled in config" };
|
|
119
|
+
if (p.tool === "eslint" || p.tool === "eslint-sonarjs" || p.tool === "madge") {
|
|
120
|
+
if (!hasJs) return { ...p, status: "skip-lang", reason: "no JS/TS sources" };
|
|
121
|
+
}
|
|
122
|
+
if (p.tool === "jscpd" && !anySource) {
|
|
123
|
+
return { ...p, status: "skip-lang", reason: "no source files" };
|
|
124
|
+
}
|
|
125
|
+
if (!p.toolAvailable) return { ...p, status: "skip-tool", reason: `tool not installed: ${p.toolName}` };
|
|
126
|
+
return { ...p, status: "run" };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Health Gate — CLI orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Thin entry point used by `/asf health` and `/asf verify` (Gate 8):
|
|
5
|
+
* load config → detect languages/tools → plan → run → evaluate → report.
|
|
6
|
+
* `deps` is injectable for tests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loadConfig } from "./config.mjs";
|
|
10
|
+
import { detectLanguages, planMetrics, resolvePlans } from "./detect.mjs";
|
|
11
|
+
import { listSourceFiles, detectTools, runAll } from "./run.mjs";
|
|
12
|
+
import { evaluateRow, verdict, formatReport, jsonReport, diffRows, formatDiff, baselinePayload } from "./report.mjs";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const BASELINE_FILE = ".asf-code-health-baseline.json";
|
|
17
|
+
|
|
18
|
+
/** Parse CLI args: --diff, --json, --update-baseline, --verbose. */
|
|
19
|
+
export function parseArgs(args) {
|
|
20
|
+
const flags = { diff: false, json: false, updateBaseline: false, verbose: false };
|
|
21
|
+
for (const a of args || []) {
|
|
22
|
+
if (a === "--diff") flags.diff = true;
|
|
23
|
+
else if (a === "--json") flags.json = true;
|
|
24
|
+
else if (a === "--update-baseline") flags.updateBaseline = true;
|
|
25
|
+
else if (a === "--verbose") flags.verbose = true;
|
|
26
|
+
}
|
|
27
|
+
return flags;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run the gate. Returns { text, json, exitCode, healthy, rows, config }.
|
|
32
|
+
*/
|
|
33
|
+
export async function runHealth(projectRoot, args = [], deps = {}) {
|
|
34
|
+
const flags = parseArgs(args);
|
|
35
|
+
const config = await loadConfig(projectRoot, deps.readFile);
|
|
36
|
+
if (!config.enabled) {
|
|
37
|
+
const text = "Code Health Gate: disabled (enabled: false).";
|
|
38
|
+
return { text, json: { enabled: false }, exitCode: 0, healthy: true, rows: [], config };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = listSourceFiles(projectRoot, config.ignore);
|
|
42
|
+
const languages = detectLanguages(files);
|
|
43
|
+
const tools = deps.tools || detectTools(projectRoot, deps);
|
|
44
|
+
const plans = resolvePlans(planMetrics(config, languages, tools), languages);
|
|
45
|
+
const results = await runAll(plans, projectRoot, { ...deps, timeoutMs: config.toolTimeoutSeconds * 1000 });
|
|
46
|
+
const rows = plans.map((p) => evaluateRow(p, results[p.key]));
|
|
47
|
+
const v = verdict(rows, config);
|
|
48
|
+
|
|
49
|
+
// Gate semantics: block only when configured to, and only for the scope.
|
|
50
|
+
const gateBlocks = config.gate.mode === "block" && config.gate.scope !== "off";
|
|
51
|
+
const exitCode = gateBlocks && !v.healthy ? 1 : 0;
|
|
52
|
+
|
|
53
|
+
let text = formatReport(rows, config);
|
|
54
|
+
|
|
55
|
+
if (flags.updateBaseline) {
|
|
56
|
+
const payload = baselinePayload(rows);
|
|
57
|
+
writeFileSync(join(projectRoot, BASELINE_FILE), JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
58
|
+
text += `\n\nBaseline updated: ${BASELINE_FILE}`;
|
|
59
|
+
} else if (flags.diff) {
|
|
60
|
+
const baselinePath = join(projectRoot, BASELINE_FILE);
|
|
61
|
+
let baselineRows = [];
|
|
62
|
+
if (existsSync(baselinePath)) {
|
|
63
|
+
try {
|
|
64
|
+
const base = JSON.parse(readFileSync(baselinePath, "utf-8"));
|
|
65
|
+
baselineRows = Object.entries(base.metrics || {}).map(([key, m]) => ({ key, value: m.value, status: m.status }));
|
|
66
|
+
} catch {
|
|
67
|
+
/* unreadable baseline → no diff */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const changes = diffRows(rows, baselineRows);
|
|
71
|
+
text += "\n\n" + formatDiff(changes);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const json = flags.json ? jsonReport(rows, config) : null;
|
|
75
|
+
return { text, json, exitCode, healthy: v.healthy, rows, config };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { BASELINE_FILE };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Health Gate — evaluation & reporting.
|
|
3
|
+
*
|
|
4
|
+
* Pure module: results + plans → rows + verdict; text formatting; diff against
|
|
5
|
+
* a baseline. No IO.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Evaluate a metric result against its plan. */
|
|
9
|
+
export function evaluateRow(plan, result) {
|
|
10
|
+
if (plan.status === "disabled") {
|
|
11
|
+
return { key: plan.key, level: plan.level, value: "—", threshold: plan.thresholdLabel, status: "disabled", reason: plan.reason };
|
|
12
|
+
}
|
|
13
|
+
if (plan.status === "skip-lang") {
|
|
14
|
+
return { key: plan.key, level: plan.level, value: "—", threshold: plan.thresholdLabel, status: "skipped", reason: plan.reason };
|
|
15
|
+
}
|
|
16
|
+
if (plan.status === "skip-tool") {
|
|
17
|
+
return { key: plan.key, level: plan.level, value: "—", threshold: plan.thresholdLabel, status: "skipped", reason: plan.reason };
|
|
18
|
+
}
|
|
19
|
+
if (result && result.error) {
|
|
20
|
+
return { key: plan.key, level: plan.level, value: "error", threshold: plan.thresholdLabel, status: "error", reason: result.error };
|
|
21
|
+
}
|
|
22
|
+
if (result && result.unavailable) {
|
|
23
|
+
return { key: plan.key, level: plan.level, value: "—", threshold: plan.thresholdLabel, status: "skipped", reason: "rule not available in installed eslint" };
|
|
24
|
+
}
|
|
25
|
+
const value = result ? result.value : null;
|
|
26
|
+
const compare = plan.compare || "value";
|
|
27
|
+
const failed = value === null || value === undefined
|
|
28
|
+
? true
|
|
29
|
+
: compare === "violations"
|
|
30
|
+
? value > 0
|
|
31
|
+
: value > plan.threshold;
|
|
32
|
+
return {
|
|
33
|
+
key: plan.key,
|
|
34
|
+
level: plan.level,
|
|
35
|
+
value: formatValue(plan.key, value),
|
|
36
|
+
threshold: plan.thresholdLabel,
|
|
37
|
+
status: failed ? "fail" : "pass",
|
|
38
|
+
reason: failed ? `value ${value} exceeds ${plan.thresholdLabel}` : "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatValue(key, value) {
|
|
43
|
+
if (value === null || value === undefined) return "?";
|
|
44
|
+
if (key === "duplication") return `${value}%`;
|
|
45
|
+
if (key === "circularDependencies") return `${value} cycle(s)`;
|
|
46
|
+
return String(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Aggregate rows into a verdict. */
|
|
50
|
+
export function verdict(rows, config) {
|
|
51
|
+
const failures = rows.filter((r) => r.status === "fail");
|
|
52
|
+
const errors = rows.filter((r) => r.status === "error");
|
|
53
|
+
const skipped = rows.filter((r) => r.status === "skipped");
|
|
54
|
+
const ran = rows.filter((r) => r.status === "pass" || r.status === "fail");
|
|
55
|
+
// missingTool: "fail" — a skipped tool is a gate failure (can't verify).
|
|
56
|
+
const missingIsFailure = config?.missingTool === "fail" && skipped.length > 0;
|
|
57
|
+
// With missingTool "warn" (default), skipped metrics never fail the gate —
|
|
58
|
+
// a fresh project without devDeps must not fail. ran === 0 + skipped === 0
|
|
59
|
+
// means nothing to measure (no source files) → healthy.
|
|
60
|
+
const healthy = failures.length === 0 && errors.length === 0 && !missingIsFailure;
|
|
61
|
+
return { healthy, failures: failures.length, errors: errors.length, skipped: skipped.length, ran: ran.length };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Human-readable report. */
|
|
65
|
+
export function formatReport(rows, config) {
|
|
66
|
+
if (!config.enabled) {
|
|
67
|
+
return "Code Health Gate: disabled (enabled: false).";
|
|
68
|
+
}
|
|
69
|
+
const lines = [];
|
|
70
|
+
lines.push("Code Health Gate report");
|
|
71
|
+
lines.push("──────────────────────");
|
|
72
|
+
if (rows.length === 0) {
|
|
73
|
+
lines.push("(no metrics to run — no source files, or all levels disabled)");
|
|
74
|
+
}
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
const mark =
|
|
77
|
+
row.status === "fail" ? "✗" :
|
|
78
|
+
row.status === "pass" ? "✓" :
|
|
79
|
+
row.status === "error" ? "!" :
|
|
80
|
+
row.status === "skipped" ? "–" : "·";
|
|
81
|
+
lines.push(`${mark} ${row.level.padEnd(12)} ${row.key.padEnd(22)} ${String(row.value).padEnd(10)} ${row.threshold}${row.reason ? " (" + row.reason + ")" : ""}`);
|
|
82
|
+
}
|
|
83
|
+
const v = verdict(rows, config);
|
|
84
|
+
lines.push("──────────────────────");
|
|
85
|
+
if (v.healthy && v.ran === 0 && v.skipped > 0) {
|
|
86
|
+
lines.push(`UNVERIFIED — no metric could run (${v.skipped} skipped: tools missing). Set missingTool: "fail" to enforce.`);
|
|
87
|
+
} else if (v.healthy) {
|
|
88
|
+
lines.push(`HEALTHY — ${v.ran} metric(s) checked${v.skipped ? `, ${v.skipped} skipped` : ""}`);
|
|
89
|
+
} else if (v.failures === 0 && v.errors === 0 && v.skipped > 0) {
|
|
90
|
+
lines.push(`NOT healthy — ${v.skipped} metric(s) skipped (missingTool: "fail"). Install the tools or lower the bar.`);
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(`${v.failures} violation(s), ${v.errors} error(s)${v.skipped ? `, ${v.skipped} skipped` : ""} — NOT healthy`);
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Machine-readable report. */
|
|
98
|
+
export function jsonReport(rows, config) {
|
|
99
|
+
return {
|
|
100
|
+
enabled: config.enabled,
|
|
101
|
+
generatedAt: new Date().toISOString(),
|
|
102
|
+
rows: rows.map((r) => ({
|
|
103
|
+
key: r.key,
|
|
104
|
+
level: r.level,
|
|
105
|
+
value: r.value,
|
|
106
|
+
threshold: r.threshold,
|
|
107
|
+
status: r.status,
|
|
108
|
+
reason: r.reason || "",
|
|
109
|
+
})),
|
|
110
|
+
verdict: config.enabled ? verdict(rows, config) : { healthy: true, failures: 0, errors: 0, skipped: 0, ran: 0 },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Diff the current rows against a baseline (previous report's rows).
|
|
116
|
+
* Flags metrics that got WORSE: value increased (for max-threshold metrics),
|
|
117
|
+
* or a metric that was pass and is now fail.
|
|
118
|
+
*/
|
|
119
|
+
export function diffRows(currentRows, baselineRows) {
|
|
120
|
+
const byKey = new Map(baselineRows.map((r) => [r.key, r]));
|
|
121
|
+
const changes = [];
|
|
122
|
+
for (const row of currentRows) {
|
|
123
|
+
if (row.status !== "pass" && row.status !== "fail") continue;
|
|
124
|
+
const prev = byKey.get(row.key);
|
|
125
|
+
if (!prev) {
|
|
126
|
+
changes.push({ key: row.key, change: "new", detail: `new metric: ${row.value}` });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const prevNum = numeric(prev.value);
|
|
130
|
+
const curNum = numeric(row.value);
|
|
131
|
+
if (prevNum !== null && curNum !== null) {
|
|
132
|
+
if (curNum > prevNum) {
|
|
133
|
+
changes.push({ key: row.key, change: "worse", detail: `${prev.value} → ${row.value}` });
|
|
134
|
+
}
|
|
135
|
+
} else if (prev.status === "pass" && row.status === "fail") {
|
|
136
|
+
changes.push({ key: row.key, change: "worse", detail: `${prev.value} → ${row.value}` });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return changes;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function numeric(value) {
|
|
143
|
+
if (typeof value === "number") return value;
|
|
144
|
+
if (typeof value !== "string") return null;
|
|
145
|
+
const n = Number.parseFloat(value);
|
|
146
|
+
return Number.isFinite(n) ? n : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Baseline payload (committed .asf-code-health-baseline.json). */
|
|
150
|
+
export function baselinePayload(rows) {
|
|
151
|
+
return {
|
|
152
|
+
generatedAt: new Date().toISOString(),
|
|
153
|
+
metrics: Object.fromEntries(
|
|
154
|
+
rows
|
|
155
|
+
.filter((r) => r.status === "pass" || r.status === "fail")
|
|
156
|
+
.map((r) => [r.key, { value: r.value, status: r.status }]),
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Format a diff for display. */
|
|
162
|
+
export function formatDiff(changes) {
|
|
163
|
+
if (changes.length === 0) return "No metric regressed since the baseline.";
|
|
164
|
+
const lines = ["Metric regressions vs baseline:"];
|
|
165
|
+
for (const c of changes) {
|
|
166
|
+
lines.push(` ✗ ${c.key}: ${c.change} (${c.detail})`);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Health Gate — execution.
|
|
3
|
+
*
|
|
4
|
+
* IO layer: list source files, spawn the metric tools with bounded timeouts
|
|
5
|
+
* (06b Rule 15), collect results. `exec` is injectable for tests (fake tools).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, relative, sep } from "node:path";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
/** Run a command with a hard timeout; resolve on exit. */
|
|
13
|
+
export function exec(cmd, args, { cwd, timeoutMs = 120_000, env } = {}) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
|
|
16
|
+
let stdout = "";
|
|
17
|
+
let stderr = "";
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
const timer = setTimeout(() => {
|
|
20
|
+
timedOut = true;
|
|
21
|
+
child.kill("SIGKILL");
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
24
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
25
|
+
child.on("error", (err) => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve({ code: -1, stdout, stderr, timedOut, error: err.message });
|
|
28
|
+
});
|
|
29
|
+
child.on("close", (code) => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve({ code: code ?? -1, stdout, stderr, timedOut });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Recursively list source files under root, skipping ignored dirs/files. */
|
|
37
|
+
export function listSourceFiles(root, ignorePatterns) {
|
|
38
|
+
const ignoreDirs = new Set(["node_modules", ".git", "dist", "build", "coverage", "test", "tests", "__tests__", "fixtures", ".next", ".cache"]);
|
|
39
|
+
const out = [];
|
|
40
|
+
const walk = (dir) => {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
const abs = join(dir, e.name);
|
|
49
|
+
const rel = relative(root, abs);
|
|
50
|
+
if (e.isDirectory()) {
|
|
51
|
+
if (ignoreDirs.has(e.name) || rel.split(sep).some((part) => ignoreDirs.has(part))) continue;
|
|
52
|
+
walk(abs);
|
|
53
|
+
} else if (e.isFile()) {
|
|
54
|
+
if (/\.(test|spec)\.[^.]+$/.test(e.name)) continue;
|
|
55
|
+
if (ignorePatterns && ignorePatterns.some((p) => p.includes(e.name))) continue;
|
|
56
|
+
out.push(rel);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(root);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Detect which metric tools are available in the project. */
|
|
65
|
+
export function detectTools(root, { execFn = exec, timeoutMs = 30_000 } = {}) {
|
|
66
|
+
const bin = (name) => existsSync(join(root, "node_modules", ".bin", name));
|
|
67
|
+
const pkg = (name) => existsSync(join(root, "node_modules", name));
|
|
68
|
+
const eslint = bin("eslint");
|
|
69
|
+
const eslintSonarjs = eslint && pkg("eslint-plugin-sonarjs");
|
|
70
|
+
const jscpd = bin("jscpd") || pkg("jscpd");
|
|
71
|
+
const madge = bin("madge") || pkg("madge");
|
|
72
|
+
const dependencyCruiser = bin("depcruise") || pkg("dependency-cruiser");
|
|
73
|
+
const typescriptParser = pkg("@typescript-eslint/parser");
|
|
74
|
+
return { eslint, eslintSonarjs, jscpd, madge, dependencyCruiser, typescriptParser };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ESLINT_RULES = {
|
|
78
|
+
complexity: (max) => ["error", max],
|
|
79
|
+
cognitiveComplexity: (max) => ["error", max],
|
|
80
|
+
maxLinesPerFunction: (max) => ["error", { max }],
|
|
81
|
+
maxDepth: (max) => ["error", max],
|
|
82
|
+
maxParams: (max) => ["error", max],
|
|
83
|
+
maxFileLines: (max) => ["error", { max }],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const ESLINT_RULE_IDS = {
|
|
87
|
+
complexity: "complexity",
|
|
88
|
+
cognitiveComplexity: "cognitive-complexity",
|
|
89
|
+
maxLinesPerFunction: "max-lines-per-function",
|
|
90
|
+
maxDepth: "max-depth",
|
|
91
|
+
maxParams: "max-params",
|
|
92
|
+
maxFileLines: "max-lines",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Run eslint once for all eslint-backed plans; return key → violation count. */
|
|
96
|
+
export async function runEslint(plans, root, { execFn = exec, timeoutMs = 120_000 } = {}) {
|
|
97
|
+
const eslintPlans = plans.filter((p) => (p.tool === "eslint" || p.tool === "eslint-sonarjs") && p.status === "run");
|
|
98
|
+
if (eslintPlans.length === 0) return {};
|
|
99
|
+
const files = listSourceFiles(root, null).filter((f) => /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(f));
|
|
100
|
+
if (files.length === 0) return {};
|
|
101
|
+
const hasTs = files.some((f) => /\.tsx?$/.test(f));
|
|
102
|
+
const args = ["--no-eslintrc", "--format", "json"];
|
|
103
|
+
if (hasTs) {
|
|
104
|
+
if (!detectTools(root).typescriptParser) {
|
|
105
|
+
return { __error: "TypeScript parser (@typescript-eslint/parser) not installed — cannot lint .ts files" };
|
|
106
|
+
}
|
|
107
|
+
args.push("--parser", "@typescript-eslint/parser");
|
|
108
|
+
}
|
|
109
|
+
for (const p of eslintPlans) {
|
|
110
|
+
const ruleId = ESLINT_RULE_IDS[p.key];
|
|
111
|
+
if (!ruleId) continue;
|
|
112
|
+
const rule = ESLINT_RULES[p.key](p.threshold);
|
|
113
|
+
args.push("--rule", JSON.stringify({ [ruleId]: rule }));
|
|
114
|
+
}
|
|
115
|
+
args.push(...files);
|
|
116
|
+
const res = await execFn("npx", ["--no-install", "eslint", ...args], { cwd: root, timeoutMs });
|
|
117
|
+
if (res.timedOut) return { __error: `eslint timed out after ${timeoutMs / 1000}s` };
|
|
118
|
+
if (res.code !== 0 && !res.stdout) return { __error: `eslint failed: ${(res.stderr || "").slice(0, 300)}` };
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
data = JSON.parse(res.stdout);
|
|
122
|
+
} catch {
|
|
123
|
+
return { __error: `eslint produced no JSON: ${(res.stderr || res.stdout || "").slice(0, 200)}` };
|
|
124
|
+
}
|
|
125
|
+
const counts = {};
|
|
126
|
+
const unavailable = new Set();
|
|
127
|
+
for (const file of data) {
|
|
128
|
+
for (const msg of file.messages || []) {
|
|
129
|
+
if (msg.ruleId) counts[msg.ruleId] = (counts[msg.ruleId] || 0) + 1;
|
|
130
|
+
// A rule the installed eslint does not know (e.g. sonarjs plugin absent)
|
|
131
|
+
// is reported as a message, not a config error — treat as unavailable.
|
|
132
|
+
if (msg.ruleId && /was not found/.test(msg.message || "")) unavailable.add(msg.ruleId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const p of eslintPlans) {
|
|
137
|
+
const ruleId = ESLINT_RULE_IDS[p.key];
|
|
138
|
+
if (unavailable.has(ruleId)) {
|
|
139
|
+
out[p.key] = { value: 0, unavailable: true };
|
|
140
|
+
} else {
|
|
141
|
+
out[p.key] = { value: counts[ruleId] || 0 };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Run jscpd; return { value: duplication percentage } or { error }. */
|
|
148
|
+
export async function runJscpd(plan, root, { execFn = exec, timeoutMs = 120_000, tmpDir = null } = {}) {
|
|
149
|
+
const { thresholdPercent, minLines, minTokens } = plan.args;
|
|
150
|
+
const outDir = tmpDir || join(root, ".asf-code-health-tmp");
|
|
151
|
+
const args = [
|
|
152
|
+
"--threshold", String(thresholdPercent),
|
|
153
|
+
"--min-lines", String(minLines),
|
|
154
|
+
"--min-tokens", String(minTokens),
|
|
155
|
+
"--reporters", "json",
|
|
156
|
+
"--output", outDir,
|
|
157
|
+
"--ignore", "**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/test/**,**/tests/**,**/__tests__/**,**/fixtures/**,**/*.test.*,**/*.spec.*",
|
|
158
|
+
root,
|
|
159
|
+
];
|
|
160
|
+
const res = await execFn("npx", ["--no-install", "jscpd", ...args], { cwd: root, timeoutMs });
|
|
161
|
+
if (res.timedOut) return { error: `jscpd timed out after ${timeoutMs / 1000}s` };
|
|
162
|
+
const reportPath = join(outDir, "jscpd-report.json");
|
|
163
|
+
try {
|
|
164
|
+
const text = await (await import("node:fs/promises")).readFile(reportPath, "utf-8");
|
|
165
|
+
const report = JSON.parse(text);
|
|
166
|
+
const pct = report?.statistics?.total?.percentage;
|
|
167
|
+
if (typeof pct !== "number") return { error: "jscpd report missing statistics.total.percentage" };
|
|
168
|
+
return { value: Math.round(pct * 10) / 10 };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { error: `jscpd report unreadable: ${err.message}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Run madge --circular; return { value: cycle count } or { error }. */
|
|
175
|
+
export async function runMadge(plan, root, { execFn = exec, timeoutMs = 120_000 } = {}) {
|
|
176
|
+
const res = await execFn("npx", ["--no-install", "madge", "--circular", "--extensions", "js,jsx,ts,tsx,mjs,cjs", root], { cwd: root, timeoutMs });
|
|
177
|
+
if (res.timedOut) return { error: `madge timed out after ${timeoutMs / 1000}s` };
|
|
178
|
+
const text = res.stdout + res.stderr;
|
|
179
|
+
if (res.code === 0) return { value: 0 };
|
|
180
|
+
const match = text.match(/(\d+)\s+circular dependenc/i);
|
|
181
|
+
return { value: match ? Number(match[1]) : 1 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Run dependency-cruiser; return { value: 0|1 } or { error }. */
|
|
185
|
+
export async function runDepcruise(plan, root, { execFn = exec, timeoutMs = 120_000 } = {}) {
|
|
186
|
+
const cfg = plan.args.config;
|
|
187
|
+
const files = listSourceFiles(root, null).filter((f) => /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(f));
|
|
188
|
+
if (files.length === 0) return { value: 0 };
|
|
189
|
+
const res = await execFn("npx", ["--no-install", "depcruise", "--config", cfg, "--output-type", "err", "--fail-on", "error", ...files], { cwd: root, timeoutMs });
|
|
190
|
+
if (res.timedOut) return { error: `dependency-cruiser timed out after ${timeoutMs / 1000}s` };
|
|
191
|
+
return { value: res.code === 0 ? 0 : 1 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Run all plans; returns key → result. */
|
|
195
|
+
export async function runAll(plans, root, opts = {}) {
|
|
196
|
+
const results = {};
|
|
197
|
+
const runnable = plans.filter((p) => p.status === "run");
|
|
198
|
+
const eslintResults = await runEslint(runnable, root, opts);
|
|
199
|
+
if (eslintResults.__error) {
|
|
200
|
+
for (const p of runnable.filter((x) => x.tool === "eslint" || x.tool === "eslint-sonarjs")) {
|
|
201
|
+
results[p.key] = { error: eslintResults.__error };
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
for (const [key, value] of Object.entries(eslintResults)) results[key] = value;
|
|
205
|
+
}
|
|
206
|
+
for (const p of runnable) {
|
|
207
|
+
if (p.tool === "eslint" || p.tool === "eslint-sonarjs") continue;
|
|
208
|
+
if (p.tool === "jscpd") results[p.key] = await runJscpd(p, root, opts);
|
|
209
|
+
else if (p.tool === "madge") results[p.key] = await runMadge(p, root, opts);
|
|
210
|
+
else if (p.tool === "dependency-cruiser") results[p.key] = await runDepcruise(p, root, opts);
|
|
211
|
+
}
|
|
212
|
+
return results;
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-aia-asf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Ai Applied Agentic Software Factory — codifies the full software development flow: intake, research, spec capture, adversarial analysis, planning with approval gates, test-first implementation, and release. Requires pi-vigilant, pi-smart-web-search, pi-smart-fetch, and pi-aia-browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
"README.md",
|
|
26
26
|
"LICENSE",
|
|
27
27
|
"CHANGELOG.md",
|
|
28
|
-
"skills/"
|
|
28
|
+
"skills/",
|
|
29
|
+
"lib/"
|
|
29
30
|
],
|
|
30
31
|
"scripts": {
|
|
31
32
|
"clean": "echo 'nothing to clean'",
|
|
32
33
|
"build": "echo 'nothing to build — pi loads .ts via jiti at runtime'",
|
|
33
34
|
"check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --allowImportingTsExtensions --noCheck index.ts",
|
|
34
|
-
"test": "
|
|
35
|
+
"test": "./test/run-all.sh",
|
|
35
36
|
"release": "./scripts/release.sh"
|
|
36
37
|
},
|
|
37
38
|
"pi": {
|
package/skills/aia-asf/SKILL.md
CHANGED
|
@@ -204,7 +204,7 @@ Execute the task list milestone by milestone. Discipline rules:
|
|
|
204
204
|
3. **No scope creep**: if something new is discovered that changes specs, capture it, ask the user, and update the plan before implementing.
|
|
205
205
|
4. **Descriptive commits**: `git commit -m "type: specific description of what and why"` (e.g. `fix: verify specs before rotation`). No vague messages, no placeholders.
|
|
206
206
|
5. **Browser testing — MANDATORY for any web interface**: if the deliverable has a UI/website/web app, test it through `pi-aia-browser` (`browser_init`, `browser_navigate`, `browser_click`, `browser_type`, `browser_screenshot`, `browser_dom`, …) to replicate the user's real experience — not just curl/API checks. Verify: loads, key user journeys, responsive behavior, console errors. **Silent async paths included**: ingest through the real flow and wait for the enrichment to land (06b Rule 9).
|
|
207
|
-
6. **Bounded waits — ALWAYS (06b Rule 15)**:
|
|
207
|
+
6. **Bounded waits — ALWAYS (06b Rule 15)**: **every** tool call or command that waits on the system gets an explicit, reasonable **hard timeout** (`timeout N …` or the tool's `timeout` parameter) — never run unbounded. State the expected duration first and size the bound to it with headroom (a 30s suite gets ~120s, not 3000s). This matters most **during testing**, where harnesses, servers, daemons and browsers hang on ports, locks and prompts. **Absolute ceiling: no single wait may exceed 30 min (1800s).** The ceiling is a backstop, never a substitute for the real per-call bound. When it is hit: kill the wait and diagnose what it is blocking on — that is a bug signal, not patience. The ceiling may be **waived only for a single run**, stated explicitly beforehand with a reason and a finite larger bound; the waiver **does not persist** and resets immediately after that run. A 50,000-second hang is a failure to investigate.
|
|
208
208
|
7. **Let pi-vigilant do its job**: it will auto-continue after premature stops and verify specs at settle. When it asks for `update_spec_status` with evidence, do it.
|
|
209
209
|
8. **CHANGELOG discipline**: every user-visible change gets a CHANGELOG entry describing exactly what changed (no placeholder text).
|
|
210
210
|
9. **Challenge approved designs (M4)**: if a spec's literal reading creates product tension (e.g. feedback clusters under "Plan" when "Plan = plans"), stop and resolve it with the user before implementing — never implement blindly and call it delivered.
|
|
@@ -220,6 +220,8 @@ Also check the **modularity DoD** from `references/06c-code-quality.md` (Phase 7
|
|
|
220
220
|
|
|
221
221
|
**Large work:** run `/asf verify` — it mechanically validates the **spec-to-code traceability matrix** (M1): every `met` spec must carry `trace` (outcome → codePath → testFile + assertion), testFile must exist, assertion must appear in it. FAIL rows block delivery. **Verify ingested specs from external planning docs too** — the doc's ✅ markers are claims, not evidence.
|
|
222
222
|
|
|
223
|
+
**Code Health Gate (large work, Gate 8):** run `/asf health` — it measures convolution objectively at function / module / architecture level (see `references/06e-code-health.md`) and fails the gate when thresholds are crossed. The gate is **on by default** with conservative thresholds; configure via `.asf-code-health.json` at the project root. **Fix the cause, not the threshold** (06e Rule 2). Small work shows the report but never blocks.
|
|
224
|
+
|
|
223
225
|
1. Run the full test suite (all of it, not a subset); fix failures; re-run until green.
|
|
224
226
|
2. **Verify the artifact a user would actually get**: inspect the packaged file list
|
|
225
227
|
(`npm pack` → `tar tzf`), install/load it clean-room in a fresh dir with caches
|
|
@@ -280,5 +282,6 @@ Also check the **modularity DoD** from `references/06c-code-quality.md` (Phase 7
|
|
|
280
282
|
- `references/06-implementation.md` — coding discipline details (incl. M4 challenge designs, M5 trace before claiming)
|
|
281
283
|
- `references/06b-testing-qa.md` — **mandatory testing & QA standard** (14 rules + definition of done)
|
|
282
284
|
- `references/06c-code-quality.md` — **mandatory modularity & maintainability standard** (9 rules, SSOT, testable-standalone, single escalation path, documents-are-code)
|
|
285
|
+
- `references/06e-code-health.md` — **Code Health Gate** (objective convolution measurement; Gate 8 for large work)
|
|
283
286
|
- `references/06d-delegation.md` — **intercom & subagents**: when to message another session, when to spawn isolated workers, the no-mutual-dependencies rule, and why exit codes lie
|
|
284
287
|
- `references/07-release.md` — release workflow (versioning, CHANGELOG, tags, npm, CI/CD)
|
|
@@ -147,6 +147,7 @@ never arrives, the wiring is dead — that is a failed test.
|
|
|
147
147
|
- [ ] **E2E behavioral test: every feature spec has a test through the real entry point asserting the operator-facing outcome**
|
|
148
148
|
- [ ] Unverifiable specs → `partial` + asked the user (never self-certified)
|
|
149
149
|
- [ ] **Every long-running command ran under an explicit timeout with a stated expected duration (no unbounded waits)**
|
|
150
|
+
- [ ] **No wait exceeded the 30 min (1800s) absolute ceiling — or it was explicitly waived for that single run, with a reason and a finite bound**
|
|
150
151
|
|
|
151
152
|
## Rule 11 — Report honestly
|
|
152
153
|
|
|
@@ -216,3 +217,54 @@ done — the wiring may be dead.
|
|
|
216
217
|
- **A hang that outlives its expected duration is a failure to investigate,**
|
|
217
218
|
not a wait to endure. If you cannot verify something within a bounded time,
|
|
218
219
|
say so explicitly (Rule 11) instead of waiting indefinitely.
|
|
220
|
+
|
|
221
|
+
### Every waiting call carries its own explicit timeout
|
|
222
|
+
|
|
223
|
+
This applies to **every tool call or command that waits on the system** — not
|
|
224
|
+
just the obviously slow ones. Testing is where this bites hardest: a test
|
|
225
|
+
harness, dev server, daemon, browser launch, or watcher can block forever on a
|
|
226
|
+
port, a lock, a prompt, or a process that never exits.
|
|
227
|
+
|
|
228
|
+
- Shell: `timeout <seconds> <command>` — always.
|
|
229
|
+
- Tool calls: pass the tool's explicit `timeout` parameter — always.
|
|
230
|
+
- Pick the bound from the **stated expected duration**, with headroom. A test
|
|
231
|
+
suite you expect to take 30s gets ~120s, not 3000s.
|
|
232
|
+
- "I'll just run it and see" is the failure mode this rule exists to prevent.
|
|
233
|
+
|
|
234
|
+
### The absolute ceiling — 30 minutes, no exceptions unless explicitly waived
|
|
235
|
+
|
|
236
|
+
> **Real failure (user report):** tool calls waited forever for the system to
|
|
237
|
+
> react and the whole process kept hanging — especially during testing.
|
|
238
|
+
|
|
239
|
+
**No single wait may exceed 30 minutes (1800s).** This is a hard ceiling, not a
|
|
240
|
+
default and not a target:
|
|
241
|
+
|
|
242
|
+
- **It is a backstop, not a substitute for a per-call bound.** The ceiling never
|
|
243
|
+
replaces the real timeout: a 30s suite still gets a ~120s bound. If your only
|
|
244
|
+
bound is 1800s, you have not bounded anything — you have deferred the hang.
|
|
245
|
+
- **Never exceed it.** If a command genuinely needs longer, that is a signal to
|
|
246
|
+
restructure it (run it in the background and poll with bounded checks, split
|
|
247
|
+
it into stages, or reduce the work) — not to raise the number.
|
|
248
|
+
- **When the ceiling is hit, kill the wait and diagnose.** Hitting 30 minutes is
|
|
249
|
+
a bug signal and a failure to investigate: find what it is blocking on
|
|
250
|
+
(network, lock, hung process, missing dependency, waiting on stdin) and fix
|
|
251
|
+
the root cause. Never re-run the same unbounded wait hoping for a different
|
|
252
|
+
result.
|
|
253
|
+
|
|
254
|
+
**Explicit per-run waiver.** The ceiling can be disabled, but only deliberately
|
|
255
|
+
and only for a **single run**:
|
|
256
|
+
|
|
257
|
+
- The agent must **state it explicitly before running** — what is being run, why
|
|
258
|
+
it legitimately needs more than 30 minutes, and the new bound. For example:
|
|
259
|
+
*"Waiving the 30-min ceiling for this one run: full integration suite against
|
|
260
|
+
a cold container build, expected ~45 min, bound 3600s."*
|
|
261
|
+
- A waiver covers **that one run only**. It **does not persist**, does not carry
|
|
262
|
+
over to the next command, and **resets immediately afterwards** — the next
|
|
263
|
+
wait is bounded by 30 minutes again.
|
|
264
|
+
- A waiver still requires a **finite bound**. Waiving the ceiling means choosing
|
|
265
|
+
a larger explicit number, never running unbounded.
|
|
266
|
+
- Blanket or standing waivers are not allowed. If waivers are needed repeatedly,
|
|
267
|
+
that is a defect in the setup — report it (Rule 11) instead of normalizing it.
|
|
268
|
+
|
|
269
|
+
**Applies at both scales.** Small work is not exempt: a hang wastes the same
|
|
270
|
+
time regardless of how the task was classified.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# 06e — Code Health Gate
|
|
2
|
+
|
|
3
|
+
**Status: mandatory for large work (gate blocks), advisory for small work.**
|
|
4
|
+
|
|
5
|
+
The Code Health Gate is the objective counterpart to the judgment-based rules
|
|
6
|
+
in [06c-code-quality.md](06c-code-quality.md). Where 06c tells you *what* good
|
|
7
|
+
code looks like, 06e *measures* it and fails the gate when the measurement
|
|
8
|
+
crosses a threshold. It exists because judgment alone does not catch gradual
|
|
9
|
+
convolution: a function that grows 5 lines per cycle never triggers a review
|
|
10
|
+
until it is unreadable.
|
|
11
|
+
|
|
12
|
+
## 1. What it measures
|
|
13
|
+
|
|
14
|
+
Three levels, each with objective thresholds (all configurable):
|
|
15
|
+
|
|
16
|
+
| Level | Metric | Tool | Default threshold |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| function | cyclomatic complexity | eslint `complexity` | 20 |
|
|
19
|
+
| function | cognitive complexity | eslint `cognitive-complexity` (sonarjs) | 15 |
|
|
20
|
+
| function | lines per function | eslint `max-lines-per-function` | 50 |
|
|
21
|
+
| function | nesting depth | eslint `max-depth` | 4 |
|
|
22
|
+
| function | parameters | eslint `max-params` | 3 |
|
|
23
|
+
| module | lines per file | eslint `max-lines` | 300 |
|
|
24
|
+
| module | duplication | jscpd | 5% (min 5 lines / 50 tokens) |
|
|
25
|
+
| module | circular imports | madge `--circular` | any cycle = fail |
|
|
26
|
+
| architecture | dependency rules | dependency-cruiser | opt-in (off by default) |
|
|
27
|
+
|
|
28
|
+
Metrics for languages the project does not use are silently skipped. Metrics
|
|
29
|
+
whose tool is not installed are skipped (or fail the gate when
|
|
30
|
+
`missingTool: "fail"`).
|
|
31
|
+
|
|
32
|
+
## 2. Invocation
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
/asf health # run the gate, print the report
|
|
36
|
+
/asf health --diff # compare against the committed baseline
|
|
37
|
+
/asf health --update-baseline # commit current values as the new baseline
|
|
38
|
+
/asf health --json # machine-readable output
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`/asf verify` (large work) runs the gate as **Gate 8** after the M1
|
|
42
|
+
traceability matrix. Small work shows the report but never blocks.
|
|
43
|
+
|
|
44
|
+
## 3. Configuration
|
|
45
|
+
|
|
46
|
+
Optional `.asf-code-health.json` at the project root. Merged over the defaults
|
|
47
|
+
(defaults ← project file ← CLI flags). Every level and every metric can be
|
|
48
|
+
switched on/off independently.
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"enabled": true,
|
|
53
|
+
"gate": { "mode": "block", "scope": "large" },
|
|
54
|
+
"missingTool": "warn",
|
|
55
|
+
"toolTimeoutSeconds": 120,
|
|
56
|
+
"function": {
|
|
57
|
+
"complexity": { "max": 20 },
|
|
58
|
+
"cognitiveComplexity": { "max": 15 },
|
|
59
|
+
"maxLinesPerFunction": { "max": 50 },
|
|
60
|
+
"maxDepth": { "max": 4 },
|
|
61
|
+
"maxParams": { "max": 3 }
|
|
62
|
+
},
|
|
63
|
+
"module": {
|
|
64
|
+
"maxFileLines": { "max": 300 },
|
|
65
|
+
"duplication": { "thresholdPercent": 5, "minLines": 5, "minTokens": 50 },
|
|
66
|
+
"circularDependencies": {}
|
|
67
|
+
},
|
|
68
|
+
"architecture": {
|
|
69
|
+
"dependencyRules": { "enabled": false, "config": ".asf-code-health.rules.mjs" }
|
|
70
|
+
},
|
|
71
|
+
"ignore": ["**/node_modules/**", "**/dist/**", "**/test/**", "**/*.test.*"]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Semantics:
|
|
76
|
+
|
|
77
|
+
- `enabled: false` — the gate is off entirely; `/asf health` reports
|
|
78
|
+
"disabled" and the gate never fires.
|
|
79
|
+
- `gate.mode: "block"` (default) — violations fail the gate (exit 1).
|
|
80
|
+
`"warn"` — violations are reported but the gate passes.
|
|
81
|
+
- `gate.scope: "large"` (default) — the gate blocks only large work.
|
|
82
|
+
`"all"` — blocks small work too. `"off"` — never blocks.
|
|
83
|
+
- `missingTool: "warn"` (default) — a missing tool skips its metrics.
|
|
84
|
+
`"fail"` — a missing tool fails the gate (you cannot verify what you
|
|
85
|
+
cannot measure).
|
|
86
|
+
- `toolTimeoutSeconds` — hard bound on every tool spawn (Rule 15). A tool
|
|
87
|
+
that exceeds it is killed and its metrics become errors.
|
|
88
|
+
|
|
89
|
+
## 4. Baseline & trend
|
|
90
|
+
|
|
91
|
+
`.asf-code-health-baseline.json` is **committed** so the trend survives across
|
|
92
|
+
machines and CI. `--diff` flags any metric that regressed since the baseline;
|
|
93
|
+
`--update-baseline` records the current values (run it deliberately, when the
|
|
94
|
+
code is in a state you want to hold the line at).
|
|
95
|
+
|
|
96
|
+
## 5. Tooling
|
|
97
|
+
|
|
98
|
+
Tools are invoked via `npx --no-install` — they must be devDependencies of the
|
|
99
|
+
project, never dependencies of pi-aia-asf. eslint is invoked with `--rule`
|
|
100
|
+
flags (not the project's own config) so the gate measures against *its*
|
|
101
|
+
thresholds, independent of what the project has configured. The gate never
|
|
102
|
+
installs anything.
|
|
103
|
+
|
|
104
|
+
## 6. Rules
|
|
105
|
+
|
|
106
|
+
1. **Run it before delivery.** Large work cannot be delivered with a failing
|
|
107
|
+
gate (unless the user explicitly overrides).
|
|
108
|
+
2. **Fix the cause, not the threshold.** Raising a threshold requires a
|
|
109
|
+
written justification in the commit message; silently raising it to make
|
|
110
|
+
the gate pass is a violation of 06c Rule 1.
|
|
111
|
+
3. **The gate is a floor, not a ceiling.** Passing thresholds does not mean
|
|
112
|
+
the code is good — 06c rules still apply.
|
|
113
|
+
4. **Never disable the gate to ship.** If a metric is genuinely inapplicable,
|
|
114
|
+
disable that metric in `.asf-code-health.json` with a comment in the
|
|
115
|
+
commit message, not the whole gate.
|