@holmes-lab/holmes-kit 0.1.9 → 0.1.11

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 (53) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/doctor.d.ts +39 -0
  5. package/dist/holmes/cli/doctor.js +79 -0
  6. package/dist/holmes/cli/test-platform.d.ts +25 -0
  7. package/dist/holmes/cli/test-platform.js +38 -0
  8. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  9. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  10. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  11. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  12. package/dist/holmes/cpg/hash-cache.js +13 -5
  13. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  14. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  15. package/dist/holmes/cpg/proposed-content.js +72 -0
  16. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  17. package/dist/holmes/cpg/required-calls.js +93 -0
  18. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  19. package/dist/holmes/guardrail/cspec-change.js +70 -0
  20. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  21. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  22. package/dist/holmes/guardrail/write-target.js +69 -18
  23. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  24. package/dist/holmes/hooks/stop.d.ts +17 -0
  25. package/dist/holmes/hooks/stop.js +39 -2
  26. package/dist/holmes/mcp/handlers.d.ts +41 -0
  27. package/dist/holmes/mcp/handlers.js +173 -3
  28. package/dist/holmes/mcp/tool-schemas.js +12 -0
  29. package/dist/holmes/project/dependencies.d.ts +15 -0
  30. package/dist/holmes/project/dependencies.js +58 -0
  31. package/dist/holmes/project/json-state.d.ts +24 -0
  32. package/dist/holmes/project/json-state.js +30 -0
  33. package/dist/holmes/reverse/scan.js +8 -1
  34. package/dist/holmes/review/scope.d.ts +29 -0
  35. package/dist/holmes/review/scope.js +44 -0
  36. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  37. package/dist/holmes/rtm/test-scope.js +92 -2
  38. package/dist/holmes/server/dashboard.d.ts +22 -0
  39. package/dist/holmes/server/dashboard.js +114 -24
  40. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  41. package/dist/holmes/spec/approval-blockers.js +49 -6
  42. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  43. package/dist/holmes/spec/legacy-format.js +15 -1
  44. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  45. package/dist/holmes/spec/nonfunctional.js +119 -0
  46. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  47. package/dist/holmes/spec/spec-parser.js +46 -2
  48. package/dist/holmes/spec/spec-types.d.ts +4 -1
  49. package/dist/holmes/spec/spec-types.js +13 -1
  50. package/dist/holmes/testing/effects.d.ts +54 -0
  51. package/dist/holmes/testing/effects.js +107 -0
  52. package/package.json +3 -2
  53. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.splitCorpus = splitCorpus;
37
+ exports.parseForbiddenEdges = parseForbiddenEdges;
38
+ exports.findForbiddenEdgeViolations = findForbiddenEdgeViolations;
39
+ // @implements A-SPEC-224
40
+ const path = __importStar(require("node:path"));
41
+ const test_files_1 = require("./test-files");
42
+ /** Split a leading `tests:` marker off a source prefix. */
43
+ function splitCorpus(prefix) {
44
+ return prefix.startsWith('tests:')
45
+ ? { corpus: 'tests', sourcePrefix: prefix.slice('tests:'.length) }
46
+ : { corpus: 'production', sourcePrefix: prefix };
47
+ }
48
+ const RULE = /^(import|call)\s+(\S+)\s+-x->\s+(\S+)$/;
49
+ /**
50
+ * Read the rules out of a `## Forbidden Edges` section.
51
+ *
52
+ * @implements A-SPEC-224
53
+ * A malformed list item is RETURNED, never dropped. A typo that silently parses to nothing reads as
54
+ * "the rule passed" — the same shape as a gate that concludes there is nothing to govern, which
55
+ * this repository has now paid for in A-SPEC-191 §17, §29 and again in A-SPEC-222.2.
56
+ *
57
+ * Prose and blank lines are neither: a section is allowed to explain itself, and treating every
58
+ * sentence as a broken rule would make the report useless.
59
+ */
60
+ function parseForbiddenEdges(section) {
61
+ const rules = [];
62
+ const malformed = [];
63
+ for (const raw of String(section ?? '').split('\n')) {
64
+ const line = raw.trim();
65
+ if (!line.startsWith('- '))
66
+ continue; // prose, blank, or a non-list line
67
+ // Strip a trailing comment and the backticks a Markdown author naturally reaches for, so the
68
+ // rule reads the same whether or not it was written for a human eye.
69
+ const body = line.slice(2).replace(/\s+#.*$/, '').replace(/`/g, '').trim().replace(/\s+/g, ' ');
70
+ const m = RULE.exec(body);
71
+ if (!m) {
72
+ malformed.push(raw.trim());
73
+ continue;
74
+ }
75
+ const { corpus, sourcePrefix } = splitCorpus(m[2]);
76
+ rules.push({ kind: m[1], corpus, sourcePrefix, target: m[3], text: body });
77
+ }
78
+ return { rules, malformed };
79
+ }
80
+ /**
81
+ * Where an import specifier lands, as a repo-relative path.
82
+ *
83
+ * A relative specifier is resolved against the importing file; anything else (`node:path`, a package
84
+ * name) has nothing to resolve against and is compared as the literal string it is. Mixing the two
85
+ * inside one comparison would make a rule mean different things depending on how the author spelled
86
+ * the target.
87
+ */
88
+ function importTargetOf(sourcePath, to) {
89
+ if (!to.startsWith('.'))
90
+ return to;
91
+ return path.posix.normalize(path.posix.join(path.posix.dirname(sourcePath), to));
92
+ }
93
+ /**
94
+ * Judge a scan against a set of prohibitions.
95
+ *
96
+ * @implements A-SPEC-224
97
+ * Pure: no filesystem, no `process`. That is what lets the same function be exercised by synthetic
98
+ * fixtures AND by the real corpus — and the real corpus is the one that matters, because a rule
99
+ * checked only against fixtures stays green while the repository drifts.
100
+ *
101
+ * `import` matches by PREFIX so one rule covers a directory and survives an extension-less
102
+ * specifier. `call` matches EXACTLY, because its target is a bare callee name and a prefix there
103
+ * would make `list` catch `listFiles`.
104
+ */
105
+ function findForbiddenEdgeViolations(files, rules) {
106
+ const out = [];
107
+ if (!Array.isArray(files) || !Array.isArray(rules) || rules.length === 0)
108
+ return out;
109
+ for (const f of files) {
110
+ const sourcePath = f?.sourcePath;
111
+ if (typeof sourcePath !== 'string')
112
+ continue;
113
+ // @implements A-SPEC-229 — the file's corpus is decided by `isTestFile`, the same table the
114
+ // scanner and the phase gate already share. A third definition would drift from those two, and
115
+ // the cost of that drift is on record: one Python file was excluded from the graph as a test
116
+ // AND gated as production code at the same time.
117
+ const corpus = (0, test_files_1.isTestFile)(sourcePath) ? 'tests' : 'production';
118
+ const applicable = rules.filter((r) => r.corpus === corpus && sourcePath.startsWith(r.sourcePrefix));
119
+ if (applicable.length === 0)
120
+ continue;
121
+ for (const e of f.edges ?? []) {
122
+ for (const r of applicable) {
123
+ if (r.kind === 'import') {
124
+ if (e.rel !== 'imports')
125
+ continue;
126
+ if (!importTargetOf(sourcePath, e.to).startsWith(r.target))
127
+ continue;
128
+ }
129
+ else {
130
+ if (e.rel !== 'calls')
131
+ continue;
132
+ if (e.to !== r.target)
133
+ continue;
134
+ }
135
+ out.push({ rule: r.text, sourcePath, from: e.from, to: e.to, kind: r.kind });
136
+ }
137
+ }
138
+ }
139
+ return out;
140
+ }
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.HashCache = void 0;
37
37
  const fs = __importStar(require("node:fs"));
38
+ const json_state_1 = require("../project/json-state");
38
39
  const path = __importStar(require("node:path"));
39
40
  const node_crypto_1 = require("node:crypto");
40
41
  // @implements A-SPEC-113.1
@@ -58,16 +59,23 @@ class HashCache {
58
59
  this.hashes = this.load();
59
60
  }
60
61
  load() {
62
+ let parsed;
61
63
  try {
62
- const raw = fs.readFileSync(this.file, 'utf8');
63
- return JSON.parse(raw);
64
+ parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
64
65
  }
65
66
  catch {
66
- // Missing dir/file, or unreadable/corrupt JSON: treat as no prior hash
67
- // rather than throwing — a cold or damaged cache must never block a
68
- // reindex, only make it fall back to a full re-scan.
67
+ // Missing dir/file, or JSON that does not parse.
69
68
  return {};
70
69
  }
70
+ // @implements A-SPEC-237
71
+ // A SECOND check, because the one above cannot see the corruption that matters most. `null`,
72
+ // `42`, `"x"`, `true` and `[1,2]` are all valid JSON: the parse SUCCEEDS and the failure
73
+ // lands later, at the first property access, as a TypeError. Measured 2026-08-23 — four of those
74
+ // forms made `unchanged()` and `put()` throw, while this method's own comment promised that a
75
+ // damaged cache "must never block a reindex". The promise lived only in prose, so nobody checked.
76
+ if (!(0, json_state_1.isJsonStateObject)(parsed))
77
+ return {};
78
+ return parsed;
71
79
  }
72
80
  static hashOf(content) {
73
81
  return (0, node_crypto_1.createHash)('sha256').update(content, 'utf8').digest('hex');
@@ -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
+ }