@event4u/agent-config 8.11.0 → 8.12.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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +49 -279
- package/README.md +2 -2
- package/dist/agent-src/contexts/execution/roadmap-ci-steps-mechanics.md +117 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +106 -94
- package/dist/agent-src/rules/architecture.md +6 -51
- package/dist/agent-src/rules/code-comment-discipline.md +10 -94
- package/dist/agent-src/rules/decision-revisit-gate.md +9 -77
- package/dist/agent-src/rules/improve-before-implement.md +7 -66
- package/dist/agent-src/rules/no-roadmap-references.md +13 -77
- package/dist/agent-src/rules/persona-governance.md +5 -47
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +6 -32
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +21 -104
- package/dist/agent-src/rules/untrusted-input-defense.md +3 -75
- package/dist/agent-src/skills/agent-docs-writing/SKILL.md +83 -0
- package/dist/agent-src/skills/decision-review/SKILL.md +86 -0
- package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +58 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/contexts/knowledge-card.md +12 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +14 -14
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/router.json +1 -1
- package/docs/CLAIMS.md +30 -2
- package/docs/architecture.md +1 -1
- package/docs/archive/CHANGELOG-pre-8.12.0.md +304 -0
- package/docs/benchmark.md +39 -0
- package/docs/contracts/ai-council-config.md +9 -4
- package/docs/contracts/persona-schema.md +49 -0
- package/docs/contracts/provider-lifecycle.md +29 -4
- package/docs/decisions/ADR-120-council-chairman-mode.md +70 -0
- package/docs/decisions/ADR-121-knowledge-sensitivity-classes.md +90 -0
- package/docs/decisions/INDEX.md +2 -0
- package/docs/design/council-vs-solo-baseline.md +92 -0
- package/docs/design/utilization-window-criteria.md +51 -0
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +68 -0
- package/docs/guidelines/agent-infra/rule-body-migration-inventory.md +241 -0
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +80 -3
- package/docs/guidelines/code-clarity.md +86 -1
- package/docs/maintainers/branch-protection.md +87 -0
- package/docs/maintainers/system-map.md +85 -0
- package/docs/proof.md +14 -3
- package/package.json +1 -1
- package/src/config/agent-settings.template.yml +9 -0
- package/src/scripts/_cli/cmd_doctor.ts +95 -0
- package/src/scripts/_lib/knowledge_global.ts +44 -0
- package/src/scripts/_lib/knowledge_global_promote.ts +150 -0
- package/src/scripts/ai_council/orchestrator.ts +188 -1
- package/src/scripts/bench_persona_placebo.ts +308 -0
- package/src/scripts/bench_quality_rerun.ts +357 -0
- package/src/scripts/build_proof.ts +9 -0
- package/src/scripts/check_artefact_count_messaging.ts +56 -0
- package/src/scripts/check_knowledge_cards.ts +33 -4
- package/src/scripts/check_portability.ts +1 -0
- package/src/scripts/complexity_report.ts +776 -0
- package/src/scripts/consumer_matrix.ts +251 -12
- package/src/scripts/council_cli.ts +56 -1
- package/src/scripts/explain_run.ts +593 -0
- package/src/scripts/knowledge_global_cli.ts +234 -45
- package/src/scripts/rule_backlinks.ts +0 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight complexity report — council-adjudicated (Phase 5 of the
|
|
4
|
+
* feedback-8-11 roadmap): a REPORT-ONLY soft ratchet over five deterministic
|
|
5
|
+
* proxy metrics for package complexity. No per-feature declaration duty, no
|
|
6
|
+
* pass/fail gate — it prints the current numbers, the delta against the
|
|
7
|
+
* previous run (if any), and a kill criterion the metric itself is subject
|
|
8
|
+
* to. Exit code is ALWAYS 0.
|
|
9
|
+
*
|
|
10
|
+
* Metrics (each documents its own method inline in the rendered report):
|
|
11
|
+
* 1. Active settings axes — top + second-level keys in
|
|
12
|
+
* `src/config/agent-settings.template.yml`.
|
|
13
|
+
* 2. Runtime-state surfaces — distinct `agents/runtime/state/<x>`
|
|
14
|
+
* path literals referenced by `src/**/*.ts`
|
|
15
|
+
* (grepped, NOT a live directory listing —
|
|
16
|
+
* the dir is gitignored and usually absent).
|
|
17
|
+
* 3. Cross-subsystem dep edges — edge count from the committed discovery
|
|
18
|
+
* graph cache if present; otherwise a
|
|
19
|
+
* proxy count of import edges between
|
|
20
|
+
* top-level `src/scripts/*.ts` modules
|
|
21
|
+
* (never triggers a graph rebuild — that
|
|
22
|
+
* would make this "lightweight" report
|
|
23
|
+
* spawn the full manifest builder).
|
|
24
|
+
* 4. Always-loaded rule bytes — byte sum of the rules the router marks
|
|
25
|
+
* `type: "always"` (the 9-rule kernel),
|
|
26
|
+
* read from `dist/router.json` + the
|
|
27
|
+
* condensed `dist/agent-src/rules/*.md`,
|
|
28
|
+
* falling back to `src/rules/*.md`
|
|
29
|
+
* frontmatter when the dist tree is
|
|
30
|
+
* absent (fresh checkout, pre-`task sync`).
|
|
31
|
+
* 5. Mandatory gates / workflow — case-insensitive whole-word `gate`
|
|
32
|
+
* mention count across the work-engine
|
|
33
|
+
* dispatcher directives
|
|
34
|
+
* (`src/agent-src/templates/scripts/work_engine/directives/`),
|
|
35
|
+
* averaged per directive file. A rough,
|
|
36
|
+
* deterministic proxy — NOT a semantic
|
|
37
|
+
* gate-graph analysis.
|
|
38
|
+
* 6. Rule→skill coupling — routing-target + backlink counts from
|
|
39
|
+
* `rule_backlinks.ts`'s `collect()` over
|
|
40
|
+
* `src/rules/*.md` (module reused as-is —
|
|
41
|
+
* no new scanning logic here).
|
|
42
|
+
*
|
|
43
|
+
* Soft ratchet: when the checked-in baseline
|
|
44
|
+
* `internal/reports/complexity-baseline.json` exists, the report renders a
|
|
45
|
+
* `## Ratchet vs baseline` section — per metric baseline vs current vs Δ,
|
|
46
|
+
* with a WARN line for anything above baseline. Still report-only: the WARN
|
|
47
|
+
* asks for a justification-or-rebaseline in the raising PR, it never fails
|
|
48
|
+
* anything. Exit code stays ALWAYS 0.
|
|
49
|
+
*
|
|
50
|
+
* Every function below takes an explicit root/path argument (no hidden
|
|
51
|
+
* module-level mutable state) so tests can point the counters at small
|
|
52
|
+
* fixture trees and get fully deterministic output.
|
|
53
|
+
*
|
|
54
|
+
* Usage:
|
|
55
|
+
* ./scripts-run src/scripts/complexity_report [--root <path>] [--out <path>] [--quiet]
|
|
56
|
+
*
|
|
57
|
+
* CI wiring: `task complexity-report` (defined in `taskfiles/content.yml`,
|
|
58
|
+
* wired into both CI task lists in `Taskfile.yml` since PR #918) — report-only,
|
|
59
|
+
* exit code always 0, so it never fails the build.
|
|
60
|
+
*/
|
|
61
|
+
import * as fs from 'node:fs';
|
|
62
|
+
import * as path from 'node:path';
|
|
63
|
+
import process from 'node:process';
|
|
64
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
65
|
+
import YAML from 'yaml';
|
|
66
|
+
|
|
67
|
+
import { collect as collectRuleBacklinks } from './rule_backlinks.js';
|
|
68
|
+
|
|
69
|
+
const PROG = 'complexity_report';
|
|
70
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
71
|
+
export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
72
|
+
export const DEFAULT_OUT = path.join(REPO_ROOT, 'internal', 'reports', 'complexity-report.md');
|
|
73
|
+
|
|
74
|
+
export const KILL_CRITERION =
|
|
75
|
+
'Kill criterion: if this report is cited by zero decisions (ADR/roadmap/PR) within 3 releases, ' +
|
|
76
|
+
'delete the script and record the honest null.';
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// fs helpers
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function _exists(p: string): boolean {
|
|
83
|
+
try {
|
|
84
|
+
fs.statSync(p);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _isFile(p: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
return fs.statSync(p).isFile();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Recursively collect files under `dir` whose name matches `ext` (e.g. '.ts'). */
|
|
100
|
+
function _walkFiles(dir: string, ext: string, acc: string[] = []): string[] {
|
|
101
|
+
let entries: fs.Dirent[];
|
|
102
|
+
try {
|
|
103
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
return acc;
|
|
106
|
+
}
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
const p = path.join(dir, e.name);
|
|
109
|
+
if (e.isDirectory()) {
|
|
110
|
+
_walkFiles(p, ext, acc);
|
|
111
|
+
} else if (e.isFile() && e.name.endsWith(ext)) {
|
|
112
|
+
acc.push(p);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return acc;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Metric 1 — active settings axes
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export interface SettingsAxesResult {
|
|
123
|
+
top: number;
|
|
124
|
+
second: number;
|
|
125
|
+
total: number;
|
|
126
|
+
method: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Top-level key count + the sum of second-level key counts for every
|
|
131
|
+
* top-level key whose value is a plain mapping (arrays/scalars contribute 0
|
|
132
|
+
* at the second level). Deterministic over a YAML parse — no comment-line
|
|
133
|
+
* counting, no regex.
|
|
134
|
+
*/
|
|
135
|
+
export function countSettingsAxes(templatePath: string): SettingsAxesResult {
|
|
136
|
+
const method = `YAML-parsed \`${path.basename(templatePath)}\`: top-level keys + (sum of key-counts of every top-level mapping value).`;
|
|
137
|
+
if (!_isFile(templatePath)) {
|
|
138
|
+
return { top: 0, second: 0, total: 0, method: `${method} File not found.` };
|
|
139
|
+
}
|
|
140
|
+
const doc = YAML.parse(fs.readFileSync(templatePath, 'utf-8')) as Record<string, unknown> | null;
|
|
141
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
142
|
+
return { top: 0, second: 0, total: 0, method: `${method} File did not parse to a mapping.` };
|
|
143
|
+
}
|
|
144
|
+
const topKeys = Object.keys(doc);
|
|
145
|
+
let second = 0;
|
|
146
|
+
for (const k of topKeys) {
|
|
147
|
+
const v = doc[k];
|
|
148
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
149
|
+
second += Object.keys(v as Record<string, unknown>).length;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { top: topKeys.length, second, total: topKeys.length + second, method };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Metric 2 — runtime-state surfaces
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export interface RuntimeStateResult {
|
|
160
|
+
count: number;
|
|
161
|
+
names: string[];
|
|
162
|
+
method: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const STATE_SLASH_RE = /agents\/runtime\/state\/([^\s'"`,)\]]+)/g;
|
|
166
|
+
const STATE_JOIN_RE = /['"]agents['"]\s*,\s*['"]runtime['"]\s*,\s*['"]state['"]\s*,\s*['"]([^'"]+)['"]/g;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Distinct `agents/runtime/state/<x>` surfaces referenced by shipped
|
|
170
|
+
* TypeScript source, derived by grepping the two literal shapes the
|
|
171
|
+
* codebase actually uses (`'agents/runtime/state/<x>'` and
|
|
172
|
+
* `path.join(..., 'agents', 'runtime', 'state', '<x>')`) — never by
|
|
173
|
+
* listing a live directory, which is gitignored and normally absent on a
|
|
174
|
+
* fresh checkout / in CI.
|
|
175
|
+
*/
|
|
176
|
+
export function countRuntimeStateSurfaces(srcDir: string): RuntimeStateResult {
|
|
177
|
+
const method =
|
|
178
|
+
`Grepped \`agents/runtime/state/<x>\` (slash literal) and ` +
|
|
179
|
+
`\`'agents','runtime','state','<x>'\` (path.join literal) across every ` +
|
|
180
|
+
`.ts file under \`${path.relative(REPO_ROOT, srcDir) || srcDir}\`; counts the ` +
|
|
181
|
+
`distinct first path segment after \`state/\` once per name, regardless of ` +
|
|
182
|
+
`call-site count. Tokens containing glob/template characters ` +
|
|
183
|
+
`(\`*\`, \`<\`, \`>\`) are dropped as grep artifacts, not surfaces. ` +
|
|
184
|
+
`Proxy for "surfaces the shipped code writes", not a ` +
|
|
185
|
+
`runtime read/write trace.`;
|
|
186
|
+
// Excludes this reporter's own source file — its docstrings quote the
|
|
187
|
+
// `agents/runtime/state/<x>` literal shapes as documentation, which
|
|
188
|
+
// would otherwise self-match and pollute the count with placeholder noise.
|
|
189
|
+
const files = _walkFiles(srcDir, '.ts').filter((f) => path.basename(f) !== 'complexity_report.ts');
|
|
190
|
+
const names = new Set<string>();
|
|
191
|
+
for (const f of files) {
|
|
192
|
+
const text = fs.readFileSync(f, 'utf-8');
|
|
193
|
+
for (const re of [STATE_SLASH_RE, STATE_JOIN_RE]) {
|
|
194
|
+
re.lastIndex = 0;
|
|
195
|
+
let m: RegExpExecArray | null;
|
|
196
|
+
while ((m = re.exec(text)) !== null) {
|
|
197
|
+
const seg = (m[1] as string).split('/')[0]?.replace(/\.$/, '');
|
|
198
|
+
// Requires at least one alphanumeric char — drops pure-prose
|
|
199
|
+
// punctuation artifacts (e.g. a doc comment ending "state/…")
|
|
200
|
+
// without trying to be clever about what counts as a "real" name.
|
|
201
|
+
// Also drops glob/template placeholder tokens (`*.json`,
|
|
202
|
+
// `<concern>.json`, `<id>.json`) — grep artifacts from prose,
|
|
203
|
+
// patterns, and docstrings, not actual surfaces.
|
|
204
|
+
if (seg && /[A-Za-z0-9]/.test(seg) && !/[*<>]/.test(seg)) names.add(seg);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const sorted = [...names].sort();
|
|
209
|
+
return { count: sorted.length, names: sorted, method };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Metric 3 — cross-subsystem dependency edges
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export interface DependencyEdgesResult {
|
|
217
|
+
count: number;
|
|
218
|
+
source: 'graph-cache' | 'import-proxy';
|
|
219
|
+
method: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Prefer the committed discovery-graph cache (`agents/runtime/state/discovery-graph-v1.json`,
|
|
224
|
+
* written by `discovery_graph.ts build`) when it exists and parses — its edge
|
|
225
|
+
* count is the real cross-subsystem relation graph. When absent (the common
|
|
226
|
+
* case: the cache is gitignored and this report never triggers a rebuild,
|
|
227
|
+
* which would spawn `build_discovery_manifest.ts` and stop being
|
|
228
|
+
* "lightweight"), fall back to a proxy: import edges between top-level
|
|
229
|
+
* `src/scripts/*.ts` modules (direct children only — `_lib/`, `_cli/`, and
|
|
230
|
+
* other subdirectories are excluded, since those are intra-module helpers,
|
|
231
|
+
* not cross-subsystem edges).
|
|
232
|
+
*/
|
|
233
|
+
export function countDependencyEdges(root: string): DependencyEdgesResult {
|
|
234
|
+
const graphCache = path.join(root, 'agents', 'runtime', 'state', 'discovery-graph-v1.json');
|
|
235
|
+
if (_isFile(graphCache)) {
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(fs.readFileSync(graphCache, 'utf-8')) as { edges?: unknown[] };
|
|
238
|
+
if (Array.isArray(parsed.edges)) {
|
|
239
|
+
return {
|
|
240
|
+
count: parsed.edges.length,
|
|
241
|
+
source: 'graph-cache',
|
|
242
|
+
method: `Read \`edges.length\` from the committed discovery-graph cache at ` + `\`${path.relative(root, graphCache)}\`.`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// fall through to the proxy below
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const scriptsDir = path.join(root, 'src', 'scripts');
|
|
250
|
+
let entries: fs.Dirent[] = [];
|
|
251
|
+
try {
|
|
252
|
+
entries = fs.readdirSync(scriptsDir, { withFileTypes: true });
|
|
253
|
+
} catch {
|
|
254
|
+
// scriptsDir doesn't exist in this tree (e.g. a fixture root) — 0 edges.
|
|
255
|
+
}
|
|
256
|
+
const topFiles = entries.filter((e) => e.isFile() && e.name.endsWith('.ts')).map((e) => e.name);
|
|
257
|
+
const known = new Set(topFiles.map((f) => f.replace(/\.ts$/, '')));
|
|
258
|
+
// Matches both from-imports and bare side-effect imports of a sibling
|
|
259
|
+
// `.js` specifier — both are real edges between top-level modules.
|
|
260
|
+
// (Wording avoids literal import-shaped strings: prepack-check's
|
|
261
|
+
// shipped-import scanner reads comments too.)
|
|
262
|
+
const importRe = /(?:from|import)\s+['"]\.\/([A-Za-z0-9_-]+)\.js['"]/g;
|
|
263
|
+
let edges = 0;
|
|
264
|
+
for (const f of topFiles) {
|
|
265
|
+
const mod = f.replace(/\.ts$/, '');
|
|
266
|
+
const text = fs.readFileSync(path.join(scriptsDir, f), 'utf-8');
|
|
267
|
+
importRe.lastIndex = 0;
|
|
268
|
+
let m: RegExpExecArray | null;
|
|
269
|
+
while ((m = importRe.exec(text)) !== null) {
|
|
270
|
+
const target = m[1] as string;
|
|
271
|
+
if (target !== mod && known.has(target)) edges += 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
count: edges,
|
|
276
|
+
source: 'import-proxy',
|
|
277
|
+
method:
|
|
278
|
+
'No usable discovery-graph cache found at `agents/runtime/state/discovery-graph-v1.json` ' +
|
|
279
|
+
'(gitignored, rebuilding it here would spawn the full manifest builder) — counted ' +
|
|
280
|
+
'relative sibling-`.js` import edges between top-level ' +
|
|
281
|
+
'`src/scripts/*.ts` files instead (subdirectories like `_lib/`, `_cli/` excluded).',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Metric 4 — always-loaded rule bytes
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
export interface AlwaysRuleBytesResult {
|
|
290
|
+
count: number;
|
|
291
|
+
bytes: number;
|
|
292
|
+
ids: string[];
|
|
293
|
+
method: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ALWAYS_TYPE_RE = /^type:\s*"?always"?\s*$/m;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Byte sum of the rules the discovery layer marks as always-loaded — the
|
|
300
|
+
* kernel. Prefers `dist/router.json`'s `kernel` array (the built router the
|
|
301
|
+
* agent actually consults) resolved against the condensed
|
|
302
|
+
* `dist/agent-src/rules/<id>.md` files; falls back to scanning
|
|
303
|
+
* `src/rules/*.md` frontmatter for `type: "always"` when `dist/` hasn't been
|
|
304
|
+
* generated yet (fresh checkout, pre-`task sync`).
|
|
305
|
+
*/
|
|
306
|
+
export function countAlwaysRuleBytes(root: string): AlwaysRuleBytesResult {
|
|
307
|
+
const routerPath = path.join(root, 'dist', 'router.json');
|
|
308
|
+
if (_isFile(routerPath)) {
|
|
309
|
+
try {
|
|
310
|
+
const router = JSON.parse(fs.readFileSync(routerPath, 'utf-8')) as { kernel?: unknown };
|
|
311
|
+
if (Array.isArray(router.kernel)) {
|
|
312
|
+
const ids = (router.kernel as unknown[]).filter((x): x is string => typeof x === 'string').sort();
|
|
313
|
+
let bytes = 0;
|
|
314
|
+
const found: string[] = [];
|
|
315
|
+
for (const id of ids) {
|
|
316
|
+
const p = path.join(root, 'dist', 'agent-src', 'rules', `${id}.md`);
|
|
317
|
+
if (_isFile(p)) {
|
|
318
|
+
bytes += Buffer.byteLength(fs.readFileSync(p, 'utf-8'), 'utf-8');
|
|
319
|
+
found.push(id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (found.length > 0) {
|
|
323
|
+
return {
|
|
324
|
+
count: found.length,
|
|
325
|
+
bytes,
|
|
326
|
+
ids: found,
|
|
327
|
+
method:
|
|
328
|
+
"Byte sum of `dist/agent-src/rules/<id>.md` for every id in `dist/router.json`'s " +
|
|
329
|
+
'`kernel` array (the always-loaded rule set the router ships).',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// fall through to the frontmatter scan below
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const rulesDir = path.join(root, 'src', 'rules');
|
|
338
|
+
let bytes = 0;
|
|
339
|
+
const ids: string[] = [];
|
|
340
|
+
let files: string[] = [];
|
|
341
|
+
try {
|
|
342
|
+
files = fs
|
|
343
|
+
.readdirSync(rulesDir, { withFileTypes: true })
|
|
344
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
345
|
+
.map((e) => e.name);
|
|
346
|
+
} catch {
|
|
347
|
+
files = [];
|
|
348
|
+
}
|
|
349
|
+
for (const name of files.sort()) {
|
|
350
|
+
const p = path.join(rulesDir, name);
|
|
351
|
+
const text = fs.readFileSync(p, 'utf-8');
|
|
352
|
+
const fmEnd = text.indexOf('\n---', 4);
|
|
353
|
+
const frontmatter = text.startsWith('---') && fmEnd > -1 ? text.slice(0, fmEnd) : '';
|
|
354
|
+
if (ALWAYS_TYPE_RE.test(frontmatter)) {
|
|
355
|
+
bytes += Buffer.byteLength(text, 'utf-8');
|
|
356
|
+
ids.push(name.replace(/\.md$/, ''));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
count: ids.length,
|
|
361
|
+
bytes,
|
|
362
|
+
ids,
|
|
363
|
+
method:
|
|
364
|
+
'`dist/router.json` / `dist/agent-src/rules/` unavailable — byte sum of `src/rules/*.md` ' +
|
|
365
|
+
'files whose frontmatter has `type: "always"` instead.',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Metric 5 — mandatory gates per core workflow (proxy)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
export interface GateMentionsResult {
|
|
374
|
+
total: number;
|
|
375
|
+
files: number;
|
|
376
|
+
perFile: number;
|
|
377
|
+
method: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const GATE_RE = /\bgate\b/gi;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Total case-insensitive whole-word `gate` mentions across the work-engine
|
|
384
|
+
* dispatcher directives, averaged per directive file. A static text-mention
|
|
385
|
+
* count, NOT a semantic count of enforced preconditions — documented as
|
|
386
|
+
* such in the rendered report.
|
|
387
|
+
*/
|
|
388
|
+
export function countGateMentions(directivesDir: string): GateMentionsResult {
|
|
389
|
+
const method =
|
|
390
|
+
`Case-insensitive whole-word \`gate\` mention count across every .ts file under ` +
|
|
391
|
+
`\`${path.relative(REPO_ROOT, directivesDir) || directivesDir}\`, divided by the file count. ` +
|
|
392
|
+
'A static text-mention proxy (docstrings + code both count), not a semantic gate-graph analysis.';
|
|
393
|
+
const files = _walkFiles(directivesDir, '.ts');
|
|
394
|
+
let total = 0;
|
|
395
|
+
for (const f of files) {
|
|
396
|
+
const text = fs.readFileSync(f, 'utf-8');
|
|
397
|
+
GATE_RE.lastIndex = 0;
|
|
398
|
+
const matches = text.match(GATE_RE);
|
|
399
|
+
total += matches ? matches.length : 0;
|
|
400
|
+
}
|
|
401
|
+
const perFile = files.length > 0 ? Math.round((total / files.length) * 100) / 100 : 0;
|
|
402
|
+
return { total, files: files.length, perFile, method };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Metric 6 — rule→skill coupling
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
export interface RuleSkillCouplingResult {
|
|
410
|
+
targets: number;
|
|
411
|
+
backlinks: number;
|
|
412
|
+
method: string;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Distinct routing targets + total rule→target backlinks, computed by
|
|
417
|
+
* reusing `rule_backlinks.ts`'s `collect()` (frontmatter `routes_to:` +
|
|
418
|
+
* "Body migrated to" prose over `src/rules/*.md`) — no new scanning logic
|
|
419
|
+
* lives here. A target is a skill / guideline / context / contract a rule
|
|
420
|
+
* routes into; the backlink total is how many rule→target routes exist.
|
|
421
|
+
*/
|
|
422
|
+
export function countRuleSkillCoupling(rulesDir: string): RuleSkillCouplingResult {
|
|
423
|
+
const byTarget = collectRuleBacklinks(rulesDir);
|
|
424
|
+
let backlinks = 0;
|
|
425
|
+
for (const links of byTarget.values()) backlinks += links.length;
|
|
426
|
+
return {
|
|
427
|
+
targets: byTarget.size,
|
|
428
|
+
backlinks,
|
|
429
|
+
method:
|
|
430
|
+
"Reused `rule_backlinks.ts`'s `collect()` over `src/rules/*.md` — distinct routing " +
|
|
431
|
+
'targets (frontmatter `routes_to:` + "Body migrated to" prose) and total ' +
|
|
432
|
+
'rule→target backlinks. No new scanning logic.',
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Soft-ratchet baseline
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
export interface BaselineMetrics {
|
|
441
|
+
settings_axes_total: number;
|
|
442
|
+
runtime_state_surfaces: number;
|
|
443
|
+
dependency_edges: number;
|
|
444
|
+
always_rule_bytes: number;
|
|
445
|
+
gate_mentions_total: number;
|
|
446
|
+
rule_skill_coupling_backlinks: number;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface Baseline {
|
|
450
|
+
schema_version: 1;
|
|
451
|
+
baselined_at: string;
|
|
452
|
+
reason: string;
|
|
453
|
+
metrics: BaselineMetrics;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export const BASELINE_RELPATH = path.join('internal', 'reports', 'complexity-baseline.json');
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Read the checked-in soft-ratchet baseline. Returns null when the file is
|
|
460
|
+
* absent or unparseable — the report then skips the ratchet section instead
|
|
461
|
+
* of failing (report-only, exit 0 always).
|
|
462
|
+
*/
|
|
463
|
+
export function loadBaseline(baselinePath: string): Baseline | null {
|
|
464
|
+
if (!_isFile(baselinePath)) return null;
|
|
465
|
+
try {
|
|
466
|
+
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8')) as Baseline;
|
|
467
|
+
if (parsed === null || typeof parsed !== 'object' || typeof parsed.metrics !== 'object' || parsed.metrics === null) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
return parsed;
|
|
471
|
+
} catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Snapshot assembly + rendering
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
export interface Snapshot {
|
|
481
|
+
schema_version: 1;
|
|
482
|
+
generated_at: string;
|
|
483
|
+
settings_axes: SettingsAxesResult;
|
|
484
|
+
runtime_state: RuntimeStateResult;
|
|
485
|
+
dependency_edges: DependencyEdgesResult;
|
|
486
|
+
always_rule_bytes: AlwaysRuleBytesResult;
|
|
487
|
+
gate_mentions: GateMentionsResult;
|
|
488
|
+
rule_skill_coupling: RuleSkillCouplingResult;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export interface Roots {
|
|
492
|
+
root: string;
|
|
493
|
+
settingsTemplate: string;
|
|
494
|
+
srcDir: string;
|
|
495
|
+
directivesDir: string;
|
|
496
|
+
rulesDir: string;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function defaultRoots(root: string): Roots {
|
|
500
|
+
return {
|
|
501
|
+
root,
|
|
502
|
+
settingsTemplate: path.join(root, 'src', 'config', 'agent-settings.template.yml'),
|
|
503
|
+
srcDir: path.join(root, 'src'),
|
|
504
|
+
directivesDir: path.join(root, 'src', 'agent-src', 'templates', 'scripts', 'work_engine', 'directives'),
|
|
505
|
+
rulesDir: path.join(root, 'src', 'rules'),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function collectSnapshot(roots: Roots, now: Date): Snapshot {
|
|
510
|
+
return {
|
|
511
|
+
schema_version: 1,
|
|
512
|
+
generated_at: now.toISOString().slice(0, 10),
|
|
513
|
+
settings_axes: countSettingsAxes(roots.settingsTemplate),
|
|
514
|
+
runtime_state: countRuntimeStateSurfaces(roots.srcDir),
|
|
515
|
+
dependency_edges: countDependencyEdges(roots.root),
|
|
516
|
+
always_rule_bytes: countAlwaysRuleBytes(roots.root),
|
|
517
|
+
gate_mentions: countGateMentions(roots.directivesDir),
|
|
518
|
+
rule_skill_coupling: countRuleSkillCoupling(roots.rulesDir),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const RAW_BLOCK_RE = /<!-- complexity-report-raw\n([\s\S]*?)\n-->/;
|
|
523
|
+
|
|
524
|
+
/** Extract the embedded machine-parseable snapshot from a previously rendered report. */
|
|
525
|
+
export function parsePreviousSnapshot(text: string): Snapshot | null {
|
|
526
|
+
const m = RAW_BLOCK_RE.exec(text);
|
|
527
|
+
if (!m) return null;
|
|
528
|
+
try {
|
|
529
|
+
return JSON.parse(m[1] as string) as Snapshot;
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function _fmt(n: number): string {
|
|
536
|
+
return n.toLocaleString('en-US');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function _deltaCell(curr: number, prev: number | undefined): string {
|
|
540
|
+
if (prev === undefined) return '—';
|
|
541
|
+
const d = curr - prev;
|
|
542
|
+
if (d === 0) return '0';
|
|
543
|
+
return d > 0 ? `+${_fmt(d)}` : _fmt(d);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const RATCHET_INSTRUCTION =
|
|
547
|
+
'justify in the PR that raises it, or re-baseline deliberately ' +
|
|
548
|
+
'(update complexity-baseline.json in the same PR with a one-line reason field).';
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The soft-ratchet section: per metric, checked-in baseline vs current vs Δ.
|
|
552
|
+
* Metrics above baseline get a WARN line (justify-or-rebaseline instruction);
|
|
553
|
+
* metrics below baseline render as improvements. Never fails anything —
|
|
554
|
+
* the caller still always exits 0.
|
|
555
|
+
*/
|
|
556
|
+
function _renderRatchetSection(current: Snapshot, baseline: Baseline | null): string[] {
|
|
557
|
+
const lines: string[] = [];
|
|
558
|
+
lines.push('## Ratchet vs baseline');
|
|
559
|
+
lines.push('');
|
|
560
|
+
if (baseline === null) {
|
|
561
|
+
lines.push(`No baseline file found at \`${BASELINE_RELPATH}\` — ratchet comparison skipped.`);
|
|
562
|
+
return lines;
|
|
563
|
+
}
|
|
564
|
+
lines.push(`Baseline: \`${BASELINE_RELPATH}\` (baselined ${baseline.baselined_at} — "${baseline.reason}").`);
|
|
565
|
+
lines.push('');
|
|
566
|
+
const rows: Array<{ label: string; base: number; curr: number }> = [
|
|
567
|
+
{ label: 'Active settings axes', base: baseline.metrics.settings_axes_total, curr: current.settings_axes.total },
|
|
568
|
+
{ label: 'Runtime-state surfaces', base: baseline.metrics.runtime_state_surfaces, curr: current.runtime_state.count },
|
|
569
|
+
{ label: 'Cross-subsystem dependency edges', base: baseline.metrics.dependency_edges, curr: current.dependency_edges.count },
|
|
570
|
+
{ label: 'Always-loaded rule bytes', base: baseline.metrics.always_rule_bytes, curr: current.always_rule_bytes.bytes },
|
|
571
|
+
{ label: 'Gate mentions (total)', base: baseline.metrics.gate_mentions_total, curr: current.gate_mentions.total },
|
|
572
|
+
{
|
|
573
|
+
label: 'Rule→skill coupling (backlinks)',
|
|
574
|
+
base: baseline.metrics.rule_skill_coupling_backlinks,
|
|
575
|
+
curr: current.rule_skill_coupling.backlinks,
|
|
576
|
+
},
|
|
577
|
+
];
|
|
578
|
+
lines.push('| Metric | Baseline | Current | Δ |');
|
|
579
|
+
lines.push('|---|---|---|---|');
|
|
580
|
+
for (const r of rows) {
|
|
581
|
+
lines.push(`| ${r.label} | ${_fmt(r.base)} | ${_fmt(r.curr)} | ${_deltaCell(r.curr, r.base)} |`);
|
|
582
|
+
}
|
|
583
|
+
lines.push('');
|
|
584
|
+
const above = rows.filter((r) => r.curr > r.base);
|
|
585
|
+
const below = rows.filter((r) => r.curr < r.base);
|
|
586
|
+
for (const r of above) {
|
|
587
|
+
lines.push(`WARN: ${r.label} is above baseline (${_fmt(r.base)} → ${_fmt(r.curr)}, +${_fmt(r.curr - r.base)}) — ${RATCHET_INSTRUCTION}`);
|
|
588
|
+
}
|
|
589
|
+
for (const r of below) {
|
|
590
|
+
lines.push(`Improved: ${r.label} is below baseline (${_fmt(r.base)} → ${_fmt(r.curr)}, ${_fmt(r.curr - r.base)}).`);
|
|
591
|
+
}
|
|
592
|
+
if (above.length === 0 && below.length === 0) {
|
|
593
|
+
lines.push('All metrics at baseline.');
|
|
594
|
+
}
|
|
595
|
+
return lines;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function renderReport(current: Snapshot, previous: Snapshot | null, baseline: Baseline | null = null): string {
|
|
599
|
+
const lines: string[] = [];
|
|
600
|
+
lines.push('# Complexity Report');
|
|
601
|
+
lines.push('');
|
|
602
|
+
lines.push(`Generated: ${current.generated_at} · generator: \`src/scripts/complexity_report.ts\` (report-only, always exits 0).`);
|
|
603
|
+
lines.push('');
|
|
604
|
+
lines.push(`> ${KILL_CRITERION}`);
|
|
605
|
+
lines.push('');
|
|
606
|
+
lines.push(
|
|
607
|
+
'This is a soft ratchet, not a gate: no feature carries a per-change ' +
|
|
608
|
+
'declaration duty against these numbers, and the script never fails CI. ' +
|
|
609
|
+
'It exists to make complexity growth *visible* over time.',
|
|
610
|
+
);
|
|
611
|
+
lines.push('');
|
|
612
|
+
lines.push(
|
|
613
|
+
'**Wired into CI as `task complexity-report`** (report-only, exit 0 always — ' +
|
|
614
|
+
'it never fails the build). Regenerate manually with ' +
|
|
615
|
+
'`npx tsx src/scripts/complexity_report.ts` or `task complexity-report`.',
|
|
616
|
+
);
|
|
617
|
+
lines.push('');
|
|
618
|
+
lines.push('## Metrics');
|
|
619
|
+
lines.push('');
|
|
620
|
+
lines.push('| # | Metric | Value | Method |');
|
|
621
|
+
lines.push('|---|---|---|---|');
|
|
622
|
+
lines.push(
|
|
623
|
+
`| 1 | Active settings axes | ${_fmt(current.settings_axes.total)} ` +
|
|
624
|
+
`(${current.settings_axes.top} top + ${current.settings_axes.second} second-level) | ${current.settings_axes.method} |`,
|
|
625
|
+
);
|
|
626
|
+
lines.push(`| 2 | Runtime-state surfaces (PROXY) | ${_fmt(current.runtime_state.count)} | ${current.runtime_state.method} |`);
|
|
627
|
+
const edgesProxy = current.dependency_edges.source === 'import-proxy' ? ' (PROXY)' : '';
|
|
628
|
+
lines.push(
|
|
629
|
+
`| 3 | Cross-subsystem dependency edges${edgesProxy} | ${_fmt(current.dependency_edges.count)} (source: ${current.dependency_edges.source}) | ` +
|
|
630
|
+
`${current.dependency_edges.method} |`,
|
|
631
|
+
);
|
|
632
|
+
lines.push(
|
|
633
|
+
`| 4 | Always-loaded rule bytes | ${_fmt(current.always_rule_bytes.bytes)} bytes across ${current.always_rule_bytes.count} rule(s) | ` +
|
|
634
|
+
`${current.always_rule_bytes.method} |`,
|
|
635
|
+
);
|
|
636
|
+
lines.push(
|
|
637
|
+
`| 5 | Mandatory gates per core workflow (PROXY) | ${current.gate_mentions.perFile.toFixed(2)} avg/file ` +
|
|
638
|
+
`(${current.gate_mentions.total} mentions across ${current.gate_mentions.files} files) | ${current.gate_mentions.method} |`,
|
|
639
|
+
);
|
|
640
|
+
lines.push(
|
|
641
|
+
`| 6 | Rule→skill coupling | ${_fmt(current.rule_skill_coupling.targets)} targets, ` +
|
|
642
|
+
`${_fmt(current.rule_skill_coupling.backlinks)} backlinks | ${current.rule_skill_coupling.method} |`,
|
|
643
|
+
);
|
|
644
|
+
lines.push('');
|
|
645
|
+
lines.push(`**Runtime-state surfaces found:** ${current.runtime_state.names.map((n) => `\`${n}\``).join(', ') || '—'}`);
|
|
646
|
+
lines.push('');
|
|
647
|
+
lines.push(`**Always-loaded rule ids:** ${current.always_rule_bytes.ids.map((n) => `\`${n}\``).join(', ') || '—'}`);
|
|
648
|
+
lines.push('');
|
|
649
|
+
|
|
650
|
+
lines.push('## Delta vs previous report');
|
|
651
|
+
lines.push('');
|
|
652
|
+
if (previous === null) {
|
|
653
|
+
lines.push('No previous report found — this is the baseline run.');
|
|
654
|
+
} else {
|
|
655
|
+
lines.push(`Previous report generated: ${previous.generated_at}.`);
|
|
656
|
+
lines.push('');
|
|
657
|
+
lines.push('| Metric | Previous | Current | Δ |');
|
|
658
|
+
lines.push('|---|---|---|---|');
|
|
659
|
+
lines.push(
|
|
660
|
+
`| Active settings axes | ${_fmt(previous.settings_axes.total)} | ${_fmt(current.settings_axes.total)} | ` +
|
|
661
|
+
`${_deltaCell(current.settings_axes.total, previous.settings_axes.total)} |`,
|
|
662
|
+
);
|
|
663
|
+
lines.push(
|
|
664
|
+
`| Runtime-state surfaces | ${_fmt(previous.runtime_state.count)} | ${_fmt(current.runtime_state.count)} | ` +
|
|
665
|
+
`${_deltaCell(current.runtime_state.count, previous.runtime_state.count)} |`,
|
|
666
|
+
);
|
|
667
|
+
lines.push(
|
|
668
|
+
`| Cross-subsystem dependency edges | ${_fmt(previous.dependency_edges.count)} | ${_fmt(current.dependency_edges.count)} | ` +
|
|
669
|
+
`${_deltaCell(current.dependency_edges.count, previous.dependency_edges.count)} |`,
|
|
670
|
+
);
|
|
671
|
+
lines.push(
|
|
672
|
+
`| Always-loaded rule bytes | ${_fmt(previous.always_rule_bytes.bytes)} | ${_fmt(current.always_rule_bytes.bytes)} | ` +
|
|
673
|
+
`${_deltaCell(current.always_rule_bytes.bytes, previous.always_rule_bytes.bytes)} |`,
|
|
674
|
+
);
|
|
675
|
+
lines.push(
|
|
676
|
+
`| Gate mentions (total) | ${_fmt(previous.gate_mentions.total)} | ${_fmt(current.gate_mentions.total)} | ` +
|
|
677
|
+
`${_deltaCell(current.gate_mentions.total, previous.gate_mentions.total)} |`,
|
|
678
|
+
);
|
|
679
|
+
// Reports rendered before metric 6 existed have no rule_skill_coupling
|
|
680
|
+
// in their embedded snapshot — degrade to an em-dash previous cell.
|
|
681
|
+
const prevCoupling = previous.rule_skill_coupling as RuleSkillCouplingResult | undefined;
|
|
682
|
+
lines.push(
|
|
683
|
+
`| Rule→skill coupling (backlinks) | ${prevCoupling ? _fmt(prevCoupling.backlinks) : '—'} | ` +
|
|
684
|
+
`${_fmt(current.rule_skill_coupling.backlinks)} | ` +
|
|
685
|
+
`${_deltaCell(current.rule_skill_coupling.backlinks, prevCoupling?.backlinks)} |`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
lines.push('');
|
|
689
|
+
lines.push(..._renderRatchetSection(current, baseline));
|
|
690
|
+
lines.push('');
|
|
691
|
+
lines.push('## Raw metrics (machine-parseable — do not hand-edit)');
|
|
692
|
+
lines.push('');
|
|
693
|
+
lines.push('The delta section above is computed by parsing this block out of the previous report.');
|
|
694
|
+
lines.push('');
|
|
695
|
+
lines.push('<!-- complexity-report-raw');
|
|
696
|
+
lines.push(JSON.stringify(current, null, 2));
|
|
697
|
+
lines.push('-->');
|
|
698
|
+
lines.push('');
|
|
699
|
+
return lines.join('\n');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// CLI
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
interface ParsedArgs {
|
|
707
|
+
root: string;
|
|
708
|
+
out: string;
|
|
709
|
+
quiet: boolean;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function parseArgs(argv: string[]): ParsedArgs {
|
|
713
|
+
const out: ParsedArgs = { root: REPO_ROOT, out: DEFAULT_OUT, quiet: false };
|
|
714
|
+
for (let i = 0; i < argv.length; i++) {
|
|
715
|
+
const a = argv[i] as string;
|
|
716
|
+
if (a === '--root') {
|
|
717
|
+
out.root = path.resolve(argv[++i] ?? out.root);
|
|
718
|
+
} else if (a.startsWith('--root=')) {
|
|
719
|
+
out.root = path.resolve(a.slice('--root='.length));
|
|
720
|
+
} else if (a === '--out') {
|
|
721
|
+
out.out = path.resolve(argv[++i] ?? out.out);
|
|
722
|
+
} else if (a.startsWith('--out=')) {
|
|
723
|
+
out.out = path.resolve(a.slice('--out='.length));
|
|
724
|
+
} else if (a === '--quiet') {
|
|
725
|
+
out.quiet = true;
|
|
726
|
+
} else if (a === '-h' || a === '--help') {
|
|
727
|
+
process.stdout.write(`usage: ${PROG} [--root PATH] [--out PATH] [--quiet]\n`);
|
|
728
|
+
process.exit(0);
|
|
729
|
+
}
|
|
730
|
+
// Unrecognized args are ignored, not fatal — this is a report-only
|
|
731
|
+
// soft ratchet; exit code is always 0 (see module docstring).
|
|
732
|
+
}
|
|
733
|
+
if (out.out === DEFAULT_OUT && out.root !== REPO_ROOT) {
|
|
734
|
+
out.out = path.join(out.root, 'internal', 'reports', 'complexity-report.md');
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Always returns 0 — report-only soft ratchet, per the roadmap spec. */
|
|
740
|
+
export function main(argv?: string[]): number {
|
|
741
|
+
try {
|
|
742
|
+
const args = parseArgs(argv ?? process.argv.slice(2));
|
|
743
|
+
const roots = defaultRoots(args.root);
|
|
744
|
+
const snapshot = collectSnapshot(roots, new Date());
|
|
745
|
+
const previousText = _exists(args.out) ? fs.readFileSync(args.out, 'utf-8') : null;
|
|
746
|
+
const previous = previousText ? parsePreviousSnapshot(previousText) : null;
|
|
747
|
+
const baseline = loadBaseline(path.join(args.root, BASELINE_RELPATH));
|
|
748
|
+
const report = renderReport(snapshot, previous, baseline);
|
|
749
|
+
fs.mkdirSync(path.dirname(args.out), { recursive: true });
|
|
750
|
+
fs.writeFileSync(args.out, report, 'utf-8');
|
|
751
|
+
if (!args.quiet) {
|
|
752
|
+
process.stdout.write(`${PROG}: wrote ${path.relative(args.root, args.out)}\n`);
|
|
753
|
+
}
|
|
754
|
+
} catch (exc) {
|
|
755
|
+
// Report-only: never fail the caller. Surface the error for
|
|
756
|
+
// diagnosis, still exit 0.
|
|
757
|
+
process.stderr.write(`${PROG}: error (non-fatal, report may be incomplete): ${String(exc)}\n`);
|
|
758
|
+
}
|
|
759
|
+
return 0;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function _isCliEntry(): boolean {
|
|
763
|
+
if (process.argv[1] === undefined) return false;
|
|
764
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
765
|
+
if (import.meta.url === argvUrl) return true;
|
|
766
|
+
try {
|
|
767
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
768
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1] as string));
|
|
769
|
+
return here === argv;
|
|
770
|
+
} catch {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (_isCliEntry()) {
|
|
775
|
+
process.exitCode = main();
|
|
776
|
+
}
|