@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.
- package/CHANGELOG.md +118 -0
- package/README.md +48 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/doctor.d.ts +39 -0
- package/dist/holmes/cli/doctor.js +79 -0
- 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
|
@@ -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
|
-
|
|
63
|
-
return JSON.parse(raw);
|
|
64
|
+
parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
64
65
|
}
|
|
65
66
|
catch {
|
|
66
|
-
// Missing dir/file, or
|
|
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
|
-
|
|
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
|
+
}
|