@holmes-lab/holmes-kit 0.1.9 → 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.
- package/CHANGELOG.md +99 -0
- package/README.md +48 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/test-platform.d.ts +25 -0
- package/dist/holmes/cli/test-platform.js +38 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
- package/dist/holmes/cpg/cpg-scanner.js +53 -1
- package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
- package/dist/holmes/cpg/forbidden-edges.js +140 -0
- package/dist/holmes/cpg/hash-cache.js +13 -5
- package/dist/holmes/cpg/language-parser-walk.js +70 -4
- package/dist/holmes/cpg/proposed-content.d.ts +51 -0
- package/dist/holmes/cpg/proposed-content.js +72 -0
- package/dist/holmes/cpg/required-calls.d.ts +62 -0
- package/dist/holmes/cpg/required-calls.js +93 -0
- package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
- package/dist/holmes/guardrail/cspec-change.js +70 -0
- package/dist/holmes/guardrail/risk-classifier.js +122 -0
- package/dist/holmes/guardrail/write-target.d.ts +42 -0
- package/dist/holmes/guardrail/write-target.js +69 -18
- package/dist/holmes/hooks/pre-tool-use.js +90 -5
- package/dist/holmes/hooks/stop.d.ts +17 -0
- package/dist/holmes/hooks/stop.js +39 -2
- package/dist/holmes/mcp/handlers.d.ts +41 -0
- package/dist/holmes/mcp/handlers.js +173 -3
- package/dist/holmes/mcp/tool-schemas.js +12 -0
- package/dist/holmes/project/dependencies.d.ts +15 -0
- package/dist/holmes/project/dependencies.js +58 -0
- package/dist/holmes/project/json-state.d.ts +24 -0
- package/dist/holmes/project/json-state.js +30 -0
- package/dist/holmes/reverse/scan.js +8 -1
- package/dist/holmes/review/scope.d.ts +29 -0
- package/dist/holmes/review/scope.js +44 -0
- package/dist/holmes/rtm/test-scope.d.ts +44 -0
- package/dist/holmes/rtm/test-scope.js +92 -2
- package/dist/holmes/server/dashboard.d.ts +22 -0
- package/dist/holmes/server/dashboard.js +114 -24
- package/dist/holmes/spec/approval-blockers.d.ts +21 -5
- package/dist/holmes/spec/approval-blockers.js +49 -6
- package/dist/holmes/spec/legacy-format.d.ts +14 -0
- package/dist/holmes/spec/legacy-format.js +15 -1
- package/dist/holmes/spec/nonfunctional.d.ts +70 -0
- package/dist/holmes/spec/nonfunctional.js +119 -0
- package/dist/holmes/spec/spec-parser.d.ts +25 -0
- package/dist/holmes/spec/spec-parser.js +46 -2
- package/dist/holmes/spec/spec-types.d.ts +4 -1
- package/dist/holmes/spec/spec-types.js +13 -1
- package/dist/holmes/testing/effects.d.ts +54 -0
- package/dist/holmes/testing/effects.js +107 -0
- package/package.json +3 -2
- 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
|
-
|
|
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:
|
|
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
|