@holmes-lab/holmes-kit 0.1.8 → 0.1.10

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/test-platform.d.ts +25 -0
  5. package/dist/holmes/cli/test-platform.js +38 -0
  6. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  7. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  8. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  9. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  10. package/dist/holmes/cpg/hash-cache.js +13 -5
  11. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  12. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  13. package/dist/holmes/cpg/proposed-content.js +72 -0
  14. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  15. package/dist/holmes/cpg/required-calls.js +93 -0
  16. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  17. package/dist/holmes/guardrail/cspec-change.js +70 -0
  18. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  19. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  20. package/dist/holmes/guardrail/write-target.js +69 -18
  21. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  22. package/dist/holmes/hooks/stop.d.ts +17 -0
  23. package/dist/holmes/hooks/stop.js +39 -2
  24. package/dist/holmes/mcp/handlers.d.ts +41 -0
  25. package/dist/holmes/mcp/handlers.js +173 -3
  26. package/dist/holmes/mcp/tool-schemas.js +12 -0
  27. package/dist/holmes/project/dependencies.d.ts +15 -0
  28. package/dist/holmes/project/dependencies.js +58 -0
  29. package/dist/holmes/project/json-state.d.ts +24 -0
  30. package/dist/holmes/project/json-state.js +30 -0
  31. package/dist/holmes/reverse/scan.js +8 -1
  32. package/dist/holmes/review/scope.d.ts +29 -0
  33. package/dist/holmes/review/scope.js +44 -0
  34. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  35. package/dist/holmes/rtm/test-scope.js +92 -2
  36. package/dist/holmes/server/dashboard.d.ts +77 -0
  37. package/dist/holmes/server/dashboard.js +703 -183
  38. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  39. package/dist/holmes/spec/approval-blockers.js +49 -6
  40. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  41. package/dist/holmes/spec/legacy-format.js +15 -1
  42. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  43. package/dist/holmes/spec/nonfunctional.js +119 -0
  44. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  45. package/dist/holmes/spec/spec-parser.js +46 -2
  46. package/dist/holmes/spec/spec-types.d.ts +4 -1
  47. package/dist/holmes/spec/spec-types.js +13 -1
  48. package/dist/holmes/testing/effects.d.ts +54 -0
  49. package/dist/holmes/testing/effects.js +107 -0
  50. package/package.json +3 -2
  51. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
@@ -593,24 +593,90 @@ function extractEdgesFromTree(tree, lang = 'typescript') {
593
593
  }
594
594
  return parts.length ? parts.join('.') : '<module>';
595
595
  };
596
- const visit = (node) => {
596
+ // @implements A-SPEC-231
597
+ // A dependency wears more than one spelling, and until 2026-08-22 the graph saw exactly one of
598
+ // them. Measured: `await import('../x')`, `require('../x')`, `export {y} from '../x'` and
599
+ // `export * from '../x'` all produced NO imports edge, so a Forbidden Edges ban could be walked
600
+ // past four ways. A rule that sees one spelling bans the spelling, not the dependency.
601
+ //
602
+ // RELATIVE specifiers only, string literals only. `require('js-yaml')` is an external dependency
603
+ // and a different subject; `require(base + '/x')` cannot be read statically and stays a stated
604
+ // blind spot rather than a guess.
605
+ const relativeLiteral = (n) => {
606
+ if (!n)
607
+ return null;
608
+ // A template literal WITHOUT substitutions is a string literal wearing different quotes —
609
+ // \`require(\`../x\`)\` walked past the first cut of this check (adversarial pass 2, 2026-08-22).
610
+ // One with substitutions is computed and stays unreadable, which is the stated blind spot.
611
+ const isTemplate = n.type === 'template_string';
612
+ if (n.type !== 'string' && !isTemplate)
613
+ return null;
614
+ if (isTemplate && n.namedChildren && n.namedChildren.some((c) => c.type === 'template_substitution'))
615
+ return null;
616
+ const text = n.text.replace(/^['"`]|['"`]$/g, '');
617
+ return text.startsWith('.') ? text : null;
618
+ };
619
+ const firstArgSpecifier = (node) => {
620
+ const args = node.childForFieldName('arguments');
621
+ if (!args || !args.namedChildren || args.namedChildren.length === 0)
622
+ return null;
623
+ return relativeLiteral(args.namedChildren[0]);
624
+ };
625
+ // @implements A-SPEC-231
626
+ // The enclosing name is carried DOWN the descent instead of re-walked UP from every call site.
627
+ // Same answer, different cost: the upward walk is O(nodes x depth), invisible in a batch scan
628
+ // and intolerable in a hook. Measured 2026-08-22 before this change — a 2KB file nested 800 deep
629
+ // took 10,558ms and 500 deep took 2,663ms, while a FLAT 1MB file took 1,031ms. The cost followed
630
+ // depth, not size, so no size cap could have bounded it. The quadratic predates this work;
631
+ // A-SPEC-230 is what moved it onto the write path.
632
+ const namedScopeOf = (node) => {
633
+ if (node.type !== 'function_declaration' && node.type !== 'method_definition' && node.type !== 'class_declaration')
634
+ return null;
635
+ const nm = node.childForFieldName('name');
636
+ return nm ? nm.text : null;
637
+ };
638
+ const visit = (node, scope) => {
597
639
  if (node.type === 'import_statement') {
598
640
  const src = node.childForFieldName('source') ?? (node.namedChildren && node.namedChildren.find((c) => c.type === 'string'));
599
641
  if (src)
600
642
  out.push({ from: '<module>', to: src.text.replace(/['"]/g, ''), rel: 'imports' });
601
643
  }
644
+ else if (node.type === 'export_statement') {
645
+ // `export { x } from '…'` and `export * from '…'` — a re-export names no local binding but is
646
+ // every bit a module dependency.
647
+ const src = node.childForFieldName('source');
648
+ const spec = relativeLiteral(src);
649
+ if (spec)
650
+ out.push({ from: '<module>', to: spec, rel: 'imports' });
651
+ }
602
652
  else if (node.type === 'call_expression') {
603
653
  const fn = node.childForFieldName('function');
654
+ // Dynamic `import('…')`: the callee is neither an identifier nor a member expression, so the
655
+ // branch below never saw it at all.
656
+ if (fn && fn.type === 'import') {
657
+ const spec = firstArgSpecifier(node);
658
+ if (spec)
659
+ out.push({ from: '<module>', to: spec, rel: 'imports' });
660
+ }
604
661
  if (fn && (fn.type === 'identifier' || fn.type === 'member_expression')) {
605
662
  const property = fn.childForFieldName && fn.childForFieldName('property');
606
663
  const callee = fn.type === 'identifier' ? fn.text : (property ? property.text : fn.text);
607
- out.push({ from: enclosingFn(node), to: callee, rel: 'calls' });
664
+ out.push({ from: scope.length ? scope.join('.') : '<module>', to: callee, rel: 'calls' });
665
+ // The calls edge above is KEPT. Adding the imports edge alongside makes this an extension;
666
+ // replacing it would leave no single run able to say which it had been.
667
+ if (callee === 'require') {
668
+ const spec = firstArgSpecifier(node);
669
+ if (spec)
670
+ out.push({ from: '<module>', to: spec, rel: 'imports' });
671
+ }
608
672
  }
609
673
  }
674
+ const named = namedScopeOf(node);
675
+ const childScope = named ? scope.concat(named) : scope;
610
676
  for (let i = 0; i < node.childCount; i++)
611
- visit(node.child(i));
677
+ visit(node.child(i), childScope);
612
678
  };
613
- visit(tree.rootNode);
679
+ visit(tree.rootNode, []);
614
680
  return out;
615
681
  }
616
682
  /**
@@ -0,0 +1,51 @@
1
+ import { CodeEdge } from './language-parser';
2
+ import { ForbiddenEdgeRule } from './forbidden-edges';
3
+ import { RequiredCallRule } from './required-calls';
4
+ /** What the gate could actually see. `none` means no verdict was formed, not that nothing was wrong. */
5
+ export type JudgedAs = 'full' | 'fragment' | 'none';
6
+ export interface ProposedContentCheck {
7
+ forbidden: ForbiddenEdgeRule[];
8
+ required: RequiredCallRule[];
9
+ sourcePath: string;
10
+ content: string;
11
+ /** `full` for a whole-file write; `fragment` for an edit's replacement text. */
12
+ mode: 'full' | 'fragment';
13
+ parser: {
14
+ extractEdges(code: string, lang: 'typescript'): CodeEdge[];
15
+ };
16
+ }
17
+ /**
18
+ * Judge ONE proposed file against structural constraints, without scanning the repository.
19
+ *
20
+ * @implements A-SPEC-230
21
+ * Both engines already judge file by file — a rule's `sourcePrefix` selects the path and only that
22
+ * file's edges are read — so the gate needs one parse, not a scan. Measured 2026-08-22: 2ms for a
23
+ * typical file, 18ms for the 109KB outlier, against 3.3s + 1.5s for the two full scans.
24
+ *
25
+ * FRAGMENTS ARE JUDGED DIFFERENTLY, and the asymmetry is the point:
26
+ *
27
+ * - A BAN is sound on a fragment. If the added text contains a forbidden edge, that edge is really
28
+ * being added. Not seeing the rest of the file can make this MISS something; it cannot make it
29
+ * WRONG.
30
+ * - An OBLIGATION is not. The companion call may already sit elsewhere in the file, so concluding
31
+ * "absent" from a fragment manufactures a false positive — and false positives are precisely what
32
+ * make a rule distrusted and then removed.
33
+ *
34
+ * A parse failure yields no verdict rather than a denial. This repository usually fails closed and
35
+ * that judgement has been right, but the asymmetry matters: the seal gate blocks forgery of evidence
36
+ * that cannot be recovered, while this one blocks a design-discipline violation that the suite
37
+ * catches again before the commit. Blocking every unparseable work-in-progress buys nothing the
38
+ * suite does not already provide.
39
+ */
40
+ export declare function checkProposedContent(opts: ProposedContentCheck): {
41
+ violations: string[];
42
+ judged: JudgedAs;
43
+ };
44
+ /**
45
+ * What this check does NOT promise, carried in the refusal itself.
46
+ *
47
+ * @implements A-SPEC-230
48
+ * A gate's silence reads as "safe". Stating the approximations where the reader already is — rather
49
+ * than in a document they will not open — is what keeps a pass from becoming false confidence.
50
+ */
51
+ export declare const CSPEC_GATE_LIMITS: string;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CSPEC_GATE_LIMITS = void 0;
4
+ exports.checkProposedContent = checkProposedContent;
5
+ const forbidden_edges_1 = require("./forbidden-edges");
6
+ const required_calls_1 = require("./required-calls");
7
+ /** Extensions the TypeScript grammar can actually read. @implements A-SPEC-231 */
8
+ const TS_FAMILY_RE = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx)$/i;
9
+ /**
10
+ * Judge ONE proposed file against structural constraints, without scanning the repository.
11
+ *
12
+ * @implements A-SPEC-230
13
+ * Both engines already judge file by file — a rule's `sourcePrefix` selects the path and only that
14
+ * file's edges are read — so the gate needs one parse, not a scan. Measured 2026-08-22: 2ms for a
15
+ * typical file, 18ms for the 109KB outlier, against 3.3s + 1.5s for the two full scans.
16
+ *
17
+ * FRAGMENTS ARE JUDGED DIFFERENTLY, and the asymmetry is the point:
18
+ *
19
+ * - A BAN is sound on a fragment. If the added text contains a forbidden edge, that edge is really
20
+ * being added. Not seeing the rest of the file can make this MISS something; it cannot make it
21
+ * WRONG.
22
+ * - An OBLIGATION is not. The companion call may already sit elsewhere in the file, so concluding
23
+ * "absent" from a fragment manufactures a false positive — and false positives are precisely what
24
+ * make a rule distrusted and then removed.
25
+ *
26
+ * A parse failure yields no verdict rather than a denial. This repository usually fails closed and
27
+ * that judgement has been right, but the asymmetry matters: the seal gate blocks forgery of evidence
28
+ * that cannot be recovered, while this one blocks a design-discipline violation that the suite
29
+ * catches again before the commit. Blocking every unparseable work-in-progress buys nothing the
30
+ * suite does not already provide.
31
+ */
32
+ function checkProposedContent(opts) {
33
+ const { forbidden, required, sourcePath, content, mode, parser } = opts;
34
+ const rulesPresent = (forbidden?.length ?? 0) > 0 || (required?.length ?? 0) > 0;
35
+ if (!rulesPresent || typeof content !== 'string' || content.trim().length === 0) {
36
+ return { violations: [], judged: 'none' };
37
+ }
38
+ // @implements A-SPEC-231 — the parser is asked for TypeScript, so only TypeScript-family files may
39
+ // be handed to it. Measured 2026-08-22: a Python file's `list()` parsed as TypeScript became a
40
+ // calls edge and a false positive against an obligation rule, and false positives are what get a
41
+ // rule switched off. Not judging is reported as `none`, never as a pass.
42
+ if (!TS_FAMILY_RE.test(sourcePath))
43
+ return { violations: [], judged: 'none' };
44
+ let edges;
45
+ try {
46
+ edges = parser.extractEdges(content, 'typescript');
47
+ }
48
+ catch {
49
+ return { violations: [], judged: 'none' };
50
+ }
51
+ const asScanned = [{ path: sourcePath, sourcePath, symbols: [], edges, implementsSpecs: [] }];
52
+ const violations = [];
53
+ for (const v of (0, forbidden_edges_1.findForbiddenEdgeViolations)(asScanned, forbidden ?? [])) {
54
+ violations.push(`${v.sourcePath}: ${v.rule} (${v.kind} → ${v.to})`);
55
+ }
56
+ // Obligations only where the whole file is visible — see the asymmetry above.
57
+ if (mode === 'full') {
58
+ for (const v of (0, required_calls_1.findRequiredCallViolations)(asScanned, required ?? [])) {
59
+ violations.push(`${v.sourcePath} :: ${v.fn}: ${v.rule} (calls ${v.trigger}, none of the required)`);
60
+ }
61
+ }
62
+ return { violations, judged: mode };
63
+ }
64
+ /**
65
+ * What this check does NOT promise, carried in the refusal itself.
66
+ *
67
+ * @implements A-SPEC-230
68
+ * A gate's silence reads as "safe". Stating the approximations where the reader already is — rather
69
+ * than in a document they will not open — is what keeps a pass from becoming false confidence.
70
+ */
71
+ exports.CSPEC_GATE_LIMITS = '이 검사가 보장하지 않는 것: 제어 흐름을 보지 않으므로 분기에 따라 규칙을 비껴가는 코드는 통과합니다;'
72
+ + ' 모듈 최상위 호출은 파일 단위로 귀속됩니다; 판정 대상은 이 파일 하나이며 저장소 전체가 아닙니다.';
@@ -0,0 +1,62 @@
1
+ import { ScannedFile } from './cpg-scanner';
2
+ import { RuleCorpus } from './forbidden-edges';
3
+ /**
4
+ * An obligation: within a scope, calling one thing requires calling another.
5
+ *
6
+ * @implements A-SPEC-228
7
+ * `Forbidden Edges` says "do not call B". It cannot say "if you call B, also call C", and that is
8
+ * the rule this session paid for three times — "anything reading the active graph must go through
9
+ * `filterGoverned`" lived only in prose, so its bypasses were found one at a time.
10
+ *
11
+ * `Allowed Dependencies` read as a whitelist does not express it either: a whitelist is the
12
+ * complement of a ban, not an implication.
13
+ */
14
+ export interface RequiredCallRule {
15
+ /** Which corpus this rule judges. @implements A-SPEC-229 */
16
+ corpus: RuleCorpus;
17
+ /** Repo-relative prefix of the files this obligation applies to. */
18
+ sourcePrefix: string;
19
+ /** The call that triggers the obligation. */
20
+ trigger: string;
21
+ /** Any ONE of these satisfies it. */
22
+ required: string[];
23
+ /** The rule's own text, so a violation can quote what it broke. */
24
+ text: string;
25
+ }
26
+ export interface RequiredCallViolation {
27
+ rule: string;
28
+ sourcePath: string;
29
+ /** The enclosing definition that called the trigger without a companion. */
30
+ fn: string;
31
+ trigger: string;
32
+ }
33
+ /**
34
+ * Read the obligations out of a `## Layer Rules` section.
35
+ *
36
+ * @implements A-SPEC-228
37
+ * `-needs->` pairs with `Forbidden Edges`' `-x->` so a reader can tell a ban from an obligation at a
38
+ * glance. A malformed list item is RETURNED rather than dropped: a typo that parses to nothing reads
39
+ * as "the rule passed", which is the failure shape this whole line of work exists to remove.
40
+ */
41
+ export declare function parseRequiredCalls(section: string): {
42
+ rules: RequiredCallRule[];
43
+ malformed: string[];
44
+ };
45
+ /**
46
+ * Judge a scan against a set of obligations.
47
+ *
48
+ * @implements A-SPEC-228
49
+ * The unit is the FUNCTION, taken from `CodeEdge.from`. File granularity would pass a file that
50
+ * calls the trigger in one function and the companion in another, and that relaxation defeats the
51
+ * rule's purpose.
52
+ *
53
+ * Deliberately incomplete, and the direction is stated rather than discovered: static co-occurrence
54
+ * does not read control flow, so a function that skips the companion on one branch still passes.
55
+ * Measured evidence that it is worth having anyway — run against the pre-71cd807 dashboard, it flags
56
+ * `startDashboardServer`, the real defect that shipped three bypassed endpoints.
57
+ *
58
+ * Test files are outside the scan entirely (`CpgScanner` excludes them so test symbols cannot
59
+ * corrupt the production graph), so the `tspec-mirror.test.ts` bypass of the same rule remains
60
+ * invisible here. Catching one incident of three is not "solved".
61
+ */
62
+ export declare function findRequiredCallViolations(files: ScannedFile[], rules: RequiredCallRule[]): RequiredCallViolation[];
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseRequiredCalls = parseRequiredCalls;
4
+ exports.findRequiredCallViolations = findRequiredCallViolations;
5
+ const test_files_1 = require("./test-files");
6
+ const forbidden_edges_1 = require("./forbidden-edges");
7
+ const RULE = /^call\s+(\S+)\s+(\S+)\s+-needs->\s+(.+)$/;
8
+ /**
9
+ * Read the obligations out of a `## Layer Rules` section.
10
+ *
11
+ * @implements A-SPEC-228
12
+ * `-needs->` pairs with `Forbidden Edges`' `-x->` so a reader can tell a ban from an obligation at a
13
+ * glance. A malformed list item is RETURNED rather than dropped: a typo that parses to nothing reads
14
+ * as "the rule passed", which is the failure shape this whole line of work exists to remove.
15
+ */
16
+ function parseRequiredCalls(section) {
17
+ const rules = [];
18
+ const malformed = [];
19
+ for (const raw of String(section ?? '').split('\n')) {
20
+ const line = raw.trim();
21
+ if (!line.startsWith('- '))
22
+ continue; // prose, blank, or not a list item
23
+ const body = line.slice(2).replace(/\s+#.*$/, '').replace(/`/g, '').trim().replace(/\s+/g, ' ');
24
+ if (!/-needs->/.test(body) && !/^call\b/.test(body))
25
+ continue; // not an obligation at all
26
+ const m = RULE.exec(body);
27
+ if (!m) {
28
+ malformed.push(raw.trim());
29
+ continue;
30
+ }
31
+ const required = m[3].split('|').map((s) => s.trim()).filter(Boolean);
32
+ if (required.length === 0) {
33
+ malformed.push(raw.trim());
34
+ continue;
35
+ }
36
+ const { corpus, sourcePrefix } = (0, forbidden_edges_1.splitCorpus)(m[1]);
37
+ rules.push({ corpus, sourcePrefix, trigger: m[2], required, text: body });
38
+ }
39
+ return { rules, malformed };
40
+ }
41
+ /**
42
+ * Judge a scan against a set of obligations.
43
+ *
44
+ * @implements A-SPEC-228
45
+ * The unit is the FUNCTION, taken from `CodeEdge.from`. File granularity would pass a file that
46
+ * calls the trigger in one function and the companion in another, and that relaxation defeats the
47
+ * rule's purpose.
48
+ *
49
+ * Deliberately incomplete, and the direction is stated rather than discovered: static co-occurrence
50
+ * does not read control flow, so a function that skips the companion on one branch still passes.
51
+ * Measured evidence that it is worth having anyway — run against the pre-71cd807 dashboard, it flags
52
+ * `startDashboardServer`, the real defect that shipped three bypassed endpoints.
53
+ *
54
+ * Test files are outside the scan entirely (`CpgScanner` excludes them so test symbols cannot
55
+ * corrupt the production graph), so the `tspec-mirror.test.ts` bypass of the same rule remains
56
+ * invisible here. Catching one incident of three is not "solved".
57
+ */
58
+ function findRequiredCallViolations(files, rules) {
59
+ const out = [];
60
+ if (!Array.isArray(files) || !Array.isArray(rules) || rules.length === 0)
61
+ return out;
62
+ for (const f of files) {
63
+ const sourcePath = f?.sourcePath;
64
+ if (typeof sourcePath !== 'string')
65
+ continue;
66
+ // @implements A-SPEC-229 — see forbidden-edges.ts for why `isTestFile` decides this.
67
+ const corpus = (0, test_files_1.isTestFile)(sourcePath) ? 'tests' : 'production';
68
+ const applicable = rules.filter((r) => r.corpus === corpus && sourcePath.startsWith(r.sourcePrefix));
69
+ if (applicable.length === 0)
70
+ continue;
71
+ const callsByFunction = new Map();
72
+ for (const e of f.edges ?? []) {
73
+ if (e.rel !== 'calls' || !e.from)
74
+ continue;
75
+ let set = callsByFunction.get(e.from);
76
+ if (!set) {
77
+ set = new Set();
78
+ callsByFunction.set(e.from, set);
79
+ }
80
+ set.add(e.to);
81
+ }
82
+ for (const [fn, calls] of callsByFunction) {
83
+ for (const r of applicable) {
84
+ if (!calls.has(r.trigger))
85
+ continue;
86
+ if (r.required.some((req) => calls.has(req)))
87
+ continue;
88
+ out.push({ rule: r.text, sourcePath, fn, trigger: r.trigger });
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The C-SPEC a risk action is about, or `null` when it is about something else.
3
+ *
4
+ * @implements A-SPEC-225
5
+ * Both spellings resolve to the same id, because the same edit reaches the same document whether the
6
+ * caller named `C-SPEC-224` or `.ax/specs/04_cpg/C-SPEC-224.md`. A verdict that depends on how the
7
+ * caller spelled the target is a verdict the caller chooses — the identity-not-spelling rule
8
+ * A-SPEC-163 set for paths, applied to this field.
9
+ */
10
+ export declare function cspecTargetId(target: string): string | null;
11
+ /**
12
+ * Did the proposal change what this C-SPEC forbids?
13
+ *
14
+ * @implements A-SPEC-225
15
+ * Pure — text in, boolean out — so the verdict can be exercised without a store, and the I/O stays
16
+ * in the handler exactly as `risk-classifier`'s existing split requires.
17
+ *
18
+ * An unreadable side answers `true`. Concluding "unchanged" from content one cannot see is the
19
+ * failure this repository has now paid for three times: A-SPEC-191 §17 and §29 both began as "the
20
+ * gate did not refuse to govern — it concluded there was nothing to govern", and A-SPEC-222.2 was
21
+ * the same shape again. Not seeing is not sameness.
22
+ */
23
+ export declare function cspecConstraintChanged(current: string | null, proposed: string | null): boolean;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cspecTargetId = cspecTargetId;
4
+ exports.cspecConstraintChanged = cspecConstraintChanged;
5
+ // @implements A-SPEC-225
6
+ const spec_parser_1 = require("../spec/spec-parser");
7
+ const forbidden_edges_1 = require("../cpg/forbidden-edges");
8
+ /**
9
+ * The C-SPEC a risk action is about, or `null` when it is about something else.
10
+ *
11
+ * @implements A-SPEC-225
12
+ * Both spellings resolve to the same id, because the same edit reaches the same document whether the
13
+ * caller named `C-SPEC-224` or `.ax/specs/04_cpg/C-SPEC-224.md`. A verdict that depends on how the
14
+ * caller spelled the target is a verdict the caller chooses — the identity-not-spelling rule
15
+ * A-SPEC-163 set for paths, applied to this field.
16
+ */
17
+ function cspecTargetId(target) {
18
+ if (typeof target !== 'string')
19
+ return null;
20
+ const m = /\bC-SPEC-\d{3,}(?:\.\d+)?\b/.exec(target);
21
+ return m ? m[0] : null;
22
+ }
23
+ /** The constraint content of one document, in a form two documents can be compared by. */
24
+ function constraintFingerprint(text) {
25
+ let spec;
26
+ try {
27
+ spec = (0, spec_parser_1.parseSpec)(text);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ const sections = spec.sections ?? {};
33
+ const forbidden = sections['Forbidden Edges'];
34
+ const layerRules = sections['Layer Rules'];
35
+ if (forbidden === undefined && layerRules === undefined)
36
+ return null;
37
+ const { rules, malformed } = (0, forbidden_edges_1.parseForbiddenEdges)(forbidden ?? '');
38
+ // Sorted sets, not raw text: whitespace, backticks and trailing comments are not constraints, and
39
+ // calling a human for those would make the rule itself distrusted.
40
+ const ruleKeys = rules.map((r) => `${r.kind}|${r.sourcePrefix}|${r.target}`).sort();
41
+ // Malformed lines count. A rule broken by a typo is a rule that no longer binds anything, which is
42
+ // indistinguishable in effect from deleting it.
43
+ const brokenKeys = malformed.map((l) => l.replace(/\s+/g, ' ').trim()).sort();
44
+ // Layer Rules is compared as normalised prose. Nothing judges that section yet (A-SPEC-224 leaves
45
+ // it out), but DETECTION IS NOT JUDGEMENT — and the field this feeds names both sections. Missing
46
+ // it would honour "a constraint change needs a human" for only half of what it promises.
47
+ const layerKey = (layerRules ?? '').replace(/\s+/g, ' ').trim();
48
+ return JSON.stringify({ ruleKeys, brokenKeys, layerKey });
49
+ }
50
+ /**
51
+ * Did the proposal change what this C-SPEC forbids?
52
+ *
53
+ * @implements A-SPEC-225
54
+ * Pure — text in, boolean out — so the verdict can be exercised without a store, and the I/O stays
55
+ * in the handler exactly as `risk-classifier`'s existing split requires.
56
+ *
57
+ * An unreadable side answers `true`. Concluding "unchanged" from content one cannot see is the
58
+ * failure this repository has now paid for three times: A-SPEC-191 §17 and §29 both began as "the
59
+ * gate did not refuse to govern — it concluded there was nothing to govern", and A-SPEC-222.2 was
60
+ * the same shape again. Not seeing is not sameness.
61
+ */
62
+ function cspecConstraintChanged(current, proposed) {
63
+ if (!current || !proposed)
64
+ return true;
65
+ const a = constraintFingerprint(current);
66
+ const b = constraintFingerprint(proposed);
67
+ if (a === null || b === null)
68
+ return true;
69
+ return a !== b;
70
+ }
@@ -290,6 +290,117 @@ const WRITE_VERB_AX_RE = /\b(?:tee|cp|install|dd|ln|mv|rsync)\b[^;&|\n]*\.ax\/(?
290
290
  // `python3 -c "open('.ax/ledger/...','a')"`. Read-only one-liners are not distinguishable from writes
291
291
  // here, so this is deliberately conservative on the .ax governance surface only.
292
292
  const INTERPRETER_AX_RE = /\b(?:node|python3?|ruby|perl|deno|bun)\b[^;&|\n]*-(?:e|c|-eval)\b[^\n]*\.ax\/(?:specs|decisions|ledger|cpg_cache|state|roles)\b/i;
293
+ /**
294
+ * Commands that only read. Everything else over a governance path is a write.
295
+ *
296
+ * @implements A-SPEC-226
297
+ * The list is inverted, exactly as `write-target.ts` inverted tool recognition and for the same
298
+ * measured reason. Shell writes were judged by ENUMERATING write shapes — removal verbs, `>`
299
+ * clobbers, write verbs, interpreter one-liners — under a comment claiming those "cover every
300
+ * mutation channel". Measured 2026-08-22 against an approved spec: `sed -i`, `perl -i -pe` and
301
+ * `ex -sc` all returned allow, while the SAME edit through the Write tool was denied as seal
302
+ * forgery. A fifth variant would have been found the same way.
303
+ *
304
+ * Scoped to the governance surface on purpose. Inverting the whole shell would spread friction
305
+ * across the project; here the cost is bounded and the thing being protected is the seal.
306
+ */
307
+ const READ_ONLY_SHELL = new Set([
308
+ 'cat', 'grep', 'rg', 'ls', 'find', 'head', 'tail', 'wc', 'diff', 'awk', 'sort', 'uniq',
309
+ 'less', 'more', 'file', 'stat', 'du', 'cut', 'tr', 'column', 'jq', 'basename', 'dirname',
310
+ 'realpath', 'echo', 'printf', 'true', 'test',
311
+ ]);
312
+ /** `git` subcommands that only read. `commit`/`checkout`/`add` are not among them. */
313
+ const READ_ONLY_GIT = new Set([
314
+ 'show', 'log', 'status', 'diff', 'ls-tree', 'ls-files', 'blame', 'cat-file', 'rev-parse',
315
+ 'merge-base', 'describe',
316
+ ]);
317
+ /** An in-place edit flag, including bundled forms: `-i`, `--in-place`, `-pi`, `-ipe`. */
318
+ const IN_PLACE_FLAG_RE = /^--in-place\b|^-[A-Za-z]*i[A-Za-z]*$/;
319
+ /**
320
+ * Split a command into the clauses that run separately.
321
+ *
322
+ * @implements A-SPEC-226
323
+ * One clause being a write makes the whole command a write: judging only the first word lets a write
324
+ * hide behind a pipe (`cat SPEC | tee OTHER`).
325
+ */
326
+ function splitShellClauses(cmd) {
327
+ return cmd
328
+ .split(/\|\||&&|;|\||\n/)
329
+ .map((c) => c.replace(/^[\s(]+/, '').trim())
330
+ .filter((c) => c.length > 0);
331
+ }
332
+ /**
333
+ * Is this single clause known to only read?
334
+ *
335
+ * @implements A-SPEC-226
336
+ * The verdict needs the flags, not just the name: `sed -n` reads and `sed -i` writes. A name-only
337
+ * allowlist leaves exactly the hole this slice closes.
338
+ *
339
+ * Interpreters are never read-only — a one-liner's intent is not statically visible, and the
340
+ * existing INTERPRETER_AX_RE already took that position. Compound forms (`for`, `while`, `if`)
341
+ * are not either: their body cannot be read by enumeration, and calling something unreadable a read
342
+ * is how a gate switches itself off.
343
+ */
344
+ function isReadOnlyShellClause(clause) {
345
+ const tokens = clause.split(/\s+/).filter(Boolean);
346
+ if (tokens.length === 0)
347
+ return false;
348
+ const name = tokens[0].replace(/^.*\//, ''); // /usr/bin/grep -> grep
349
+ if (name === 'git')
350
+ return tokens.length > 1 && READ_ONLY_GIT.has(tokens[1]);
351
+ if (name === 'sed' || name === 'perl') {
352
+ return !tokens.slice(1).some((t) => IN_PLACE_FLAG_RE.test(t));
353
+ }
354
+ return READ_ONLY_SHELL.has(name);
355
+ }
356
+ /** Append-only evidence: restoring it deletes records rather than undoing an edit. */
357
+ const LEDGER_PATH_RE = /(^|[\s'"=(/])\.ax\/ledger(\/|\b)/;
358
+ /**
359
+ * A restore that moves the working tree TOWARD the committed state.
360
+ *
361
+ * @implements A-SPEC-227
362
+ * Direction is what matters, and syntax can read it. Without a ref, `git checkout --` and
363
+ * `git restore` copy from the index — if an approved spec had been tampered with in the working
364
+ * tree, this UNDOES that and repairs the seal. With a ref, the content is replaced by another
365
+ * revision while `approved_digest` stays put, which is the drift the gate exists to catch.
366
+ *
367
+ * Only the explicit `--` form counts. `git checkout .ax/specs/` cannot be resolved by syntax —
368
+ * branch names carry slashes and revisions carry dots — so it is treated as a ref. The side effect
369
+ * is useful: it pushes callers toward the unambiguous spelling, which is the recommended one anyway.
370
+ *
371
+ * Deliberately NOT folded into `isReadOnlyShellClause`. A restore is not a read; it is a write that
372
+ * is not a governance risk, and merging the two would blur what "read-only" means.
373
+ */
374
+ function isIndexRestoreClause(clause) {
375
+ const tokens = clause.split(/\s+/).filter(Boolean);
376
+ if (tokens.length < 2)
377
+ return false;
378
+ if (tokens[0].replace(/^.*\//, '') !== 'git')
379
+ return false;
380
+ if (tokens[1] === 'checkout') {
381
+ const sep = tokens.indexOf('--');
382
+ if (sep === -1)
383
+ return false; // no separator: the token could be a ref
384
+ return tokens.slice(2, sep).every((t) => t.startsWith('-')); // flags are not refs
385
+ }
386
+ if (tokens[1] === 'restore') {
387
+ return !tokens.slice(2).some((t) => t === '-s' || t === '--source' || t.startsWith('--source='));
388
+ }
389
+ return false;
390
+ }
391
+ /**
392
+ * A shell command that touches a protected governance path and is not recognisably read-only.
393
+ *
394
+ * @implements A-SPEC-226
395
+ */
396
+ function shellWritesProtectedAx(ncmd) {
397
+ if (!CMD_PROTECTED_AX_RE.test(ncmd))
398
+ return false;
399
+ // @implements A-SPEC-227 — the ledger exemption is cancelled here, not inside the restore check,
400
+ // because it is a property of the TARGET rather than of the command shape.
401
+ const restoreAllowed = !LEDGER_PATH_RE.test(ncmd);
402
+ return !splitShellClauses(ncmd).every((c) => isReadOnlyShellClause(c) || (restoreAllowed && isIndexRestoreClause(c)));
403
+ }
293
404
  /**
294
405
  * @implements A-SPEC-125.1
295
406
  * Reversibility axis: can the action's effect be undone? Conservative default —
@@ -318,6 +429,17 @@ function classifyReversibility(action) {
318
429
  || WRITE_VERB_AX_RE.test(ncmd) || INTERPRETER_AX_RE.test(ncmd)) {
319
430
  return { axis, level: 'hard-hitl', reasons: ['removal command targets a protected .ax governance path'] };
320
431
  }
432
+ // @implements A-SPEC-226 — the four enumerations above are kept, not replaced. Keeping them makes
433
+ // this addition purely monotonic: what was blocked stays blocked, and only previously-missed
434
+ // shapes become blocked. Replacing them would make it impossible to tell in one step whether this
435
+ // change loosened or tightened the gate.
436
+ if (shellWritesProtectedAx(ncmd)) {
437
+ return {
438
+ axis,
439
+ level: 'hard-hitl',
440
+ reasons: ['shell command over a protected .ax governance path is not recognisably read-only'],
441
+ };
442
+ }
321
443
  // A destructive recursive `rm` — either `-rf` on a non-safe target, or a recursive delete of
322
444
  // a catastrophic root (/, ~, $HOME, ., .., glob) even WITHOUT `-f` — is whole-tree destruction,
323
445
  // unconditionally hard-hitl regardless of `tracked`. The single-file `tracked===true` auto