arkgate 3.9.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +16 -4
- package/bin/ark-check-runtime.mjs +75 -3
- package/bin/ark-mcp-runtime.mjs +94 -0
- package/bin/lib/adapter-contract.mjs +14 -1
- package/bin/lib/ambient-state.mjs +64 -8
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/architecture-scan.mjs +35 -2
- package/bin/lib/arkrule-file-hints.mjs +71 -0
- package/bin/lib/arkrules-contract.mjs +382 -0
- package/bin/lib/arkrules-sensors.mjs +411 -0
- package/bin/lib/config-contract.mjs +85 -6
- package/bin/lib/doctor-advisories.mjs +22 -5
- package/bin/lib/doctor-plan.mjs +68 -13
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/enforcement-honesty.mjs +225 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +254 -0
- package/bin/lib/html-report-advisories.mjs +29 -3
- package/bin/lib/install-migrate.mjs +20 -2
- package/bin/lib/invariant-coverage-io.mjs +157 -0
- package/bin/lib/invariant-coverage.mjs +127 -0
- package/bin/lib/pilot-loop.mjs +19 -0
- package/bin/lib/policy-delta-io.mjs +33 -0
- package/bin/lib/post-green-path.mjs +22 -1
- package/bin/lib/presets.mjs +241 -1
- package/bin/lib/remediation.mjs +28 -0
- package/bin/lib/resolved-candidate-facts.mjs +14 -1
- package/bin/lib/rules-inventory.mjs +144 -0
- package/bin/lib/rules-under-contract.mjs +66 -0
- package/bin/lib/start-preview.mjs +24 -7
- package/bin/lib/upgrade-command.mjs +48 -2
- package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +14 -7
- package/dist/index.d.ts +615 -20
- package/dist/index.js +13 -6
- package/docs/README.md +4 -3
- package/docs/agent-guide.md +7 -3
- package/docs/ai-gates.md +6 -1
- package/docs/brownfield-adoption.md +20 -0
- package/docs/configuration.md +37 -4
- package/docs/develop.md +8 -2
- package/docs/enthusiast/README.md +11 -0
- package/docs/package-surface.md +13 -11
- package/docs/product-voice.md +9 -2
- package/docs/use.md +9 -0
- package/package.json +4 -17
- package/schemas/ark.analysis-result.schema.json +9 -1
- package/schemas/ark.arkrules.schema.json +141 -0
- package/schemas/ark.config.schema.json +10 -2
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/arkrules/ApplicationOrchestration.json +14 -0
- package/templates/arkrules/DomainModel.json +32 -0
- package/templates/arkrules/PersistenceAdapters.json +14 -0
- package/templates/arkrules/PresentationAdapters.json +14 -0
- package/templates/skills/ark-adopt.md +28 -1
- package/templates/skills/ark-architect.md +23 -0
- package/templates/skills/ark-autopilot.md +27 -1
- package/templates/skills/ark-contract.md +27 -1
- package/templates/skills/ark-coverage.md +30 -0
- package/templates/skills/ark-explain.md +23 -0
- package/templates/skills/ark-explore.md +30 -2
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +30 -0
- package/templates/skills/ark-runtime.md +4 -0
- package/templates/skills/ark-think.md +24 -1
- package/templates/skills/ark-upgrade.md +23 -0
- package/compat/nestjs.cjs +0 -2
- package/compat/nestjs.d.ts +0 -2
- package/compat/nestjs.js +0 -1
- package/compat/runtime.cjs +0 -2
- package/compat/runtime.d.ts +0 -2
- package/compat/runtime.js +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advisory graph-blind spots (Y09 direction — still parked as blocker work).
|
|
3
|
+
*
|
|
4
|
+
* Template-interpolation dynamic imports (`import(\`./x/${name}\`)`) never
|
|
5
|
+
* enter the static edge graph. Surface them as an advisory count + capped list
|
|
6
|
+
* so governed trees know where analysis is incomplete — never a hard verdict
|
|
7
|
+
* change, never false green from silence.
|
|
8
|
+
*
|
|
9
|
+
* Performance: lightweight AST walk only (no extractSemanticDependencies /
|
|
10
|
+
* full semantic binding). Doctor resident warm must stay under the 500 ms UX
|
|
11
|
+
* ceiling at the 10k fixture.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { normalize } from './scan-files.mjs';
|
|
16
|
+
|
|
17
|
+
const MAX_LIST = 8;
|
|
18
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
19
|
+
/** Full AST scan stays off huge trees so doctor resident warm keeps the 500 ms UX ceiling. */
|
|
20
|
+
const MAX_FULL_SCAN_FILES = 2500;
|
|
21
|
+
const LEXICAL_GATE = /\b(?:import|require)\s*\(|\bimport\s+\w+\s*=\s*require\s*\(/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Classify an unresolved dynamic dependency argument.
|
|
25
|
+
* @returns {'template-interpolation'|'non-literal'|null}
|
|
26
|
+
*/
|
|
27
|
+
export function classifyUnresolvedDependencyArg(ts, arg) {
|
|
28
|
+
if (!ts || !arg) return null;
|
|
29
|
+
if (ts.isTemplateExpression(arg)) return 'template-interpolation';
|
|
30
|
+
// NoSubstitutionTemplateLiteral is string-literal-like and already resolved.
|
|
31
|
+
if (ts.isStringLiteralLike(arg)) return null;
|
|
32
|
+
return 'non-literal';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Specifier expression for a dependency node (CallExpression or ImportEquals).
|
|
37
|
+
*/
|
|
38
|
+
export function unresolvedDependencyArg(ts, node) {
|
|
39
|
+
if (!node || typeof node !== 'object') return undefined;
|
|
40
|
+
if (Array.isArray(node.arguments)) return node.arguments[0];
|
|
41
|
+
// import x = require(expr) — ExternalModuleReference.expression
|
|
42
|
+
const modRef = node.moduleReference;
|
|
43
|
+
if (modRef && typeof modRef === 'object' && modRef.expression) return modRef.expression;
|
|
44
|
+
if (ts?.isExternalModuleReference?.(modRef) && modRef.expression) return modRef.expression;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Collect unresolvable dynamic import/require call sites with a shallow walk.
|
|
50
|
+
* Avoids extractSemanticDependencies (full binding / export walk) on every file.
|
|
51
|
+
*/
|
|
52
|
+
function collectDynamicBlindEdges(ts, sourceFile, rel, edges) {
|
|
53
|
+
const visit = (node) => {
|
|
54
|
+
// import('x') / require('x') / import(`./${x}`)
|
|
55
|
+
if (ts.isCallExpression(node) && node.expression) {
|
|
56
|
+
const expr = node.expression;
|
|
57
|
+
const isImport =
|
|
58
|
+
expr.kind === ts.SyntaxKind.ImportKeyword ||
|
|
59
|
+
(ts.isIdentifier(expr) && expr.text === 'import');
|
|
60
|
+
const isRequire = ts.isIdentifier(expr) && expr.text === 'require';
|
|
61
|
+
if (isImport || isRequire) {
|
|
62
|
+
const arg = node.arguments?.[0];
|
|
63
|
+
const reason = classifyUnresolvedDependencyArg(ts, arg);
|
|
64
|
+
if (reason) {
|
|
65
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
66
|
+
edges.push({
|
|
67
|
+
file: rel,
|
|
68
|
+
line: line + 1,
|
|
69
|
+
kind: isRequire ? 'require' : 'import',
|
|
70
|
+
reason,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// import x = require(expr)
|
|
77
|
+
if (ts.isImportEqualsDeclaration?.(node) && node.moduleReference) {
|
|
78
|
+
const arg = unresolvedDependencyArg(ts, node);
|
|
79
|
+
const reason = classifyUnresolvedDependencyArg(ts, arg);
|
|
80
|
+
if (reason) {
|
|
81
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
82
|
+
edges.push({
|
|
83
|
+
file: rel,
|
|
84
|
+
line: line + 1,
|
|
85
|
+
kind: 'require',
|
|
86
|
+
reason,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ts.forEachChild(node, visit);
|
|
92
|
+
};
|
|
93
|
+
visit(sourceFile);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Detect unresolvable import edges that leave the architecture graph incomplete.
|
|
98
|
+
*
|
|
99
|
+
* @returns {{
|
|
100
|
+
* available: boolean,
|
|
101
|
+
* advisory: true,
|
|
102
|
+
* blockerGrade: false,
|
|
103
|
+
* count: number,
|
|
104
|
+
* templateInterpolationCount: number,
|
|
105
|
+
* otherNonLiteralCount: number,
|
|
106
|
+
* truncated: number,
|
|
107
|
+
* edges: Array<{file:string,line:number,kind:string,reason:string}>,
|
|
108
|
+
* note: string,
|
|
109
|
+
* }}
|
|
110
|
+
*/
|
|
111
|
+
export function detectGraphBlindSpots(ts, root, files = []) {
|
|
112
|
+
if (!ts) {
|
|
113
|
+
return {
|
|
114
|
+
available: false,
|
|
115
|
+
advisory: true,
|
|
116
|
+
blockerGrade: false,
|
|
117
|
+
count: 0,
|
|
118
|
+
templateInterpolationCount: 0,
|
|
119
|
+
otherNonLiteralCount: 0,
|
|
120
|
+
truncated: 0,
|
|
121
|
+
edges: [],
|
|
122
|
+
note: 'TypeScript was not available; graph-blind template-interpolation edges were not scanned.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Large-tree deferral: re-reading every path for an advisory sensor would blow the
|
|
127
|
+
// doctorResidentWarm 500 ms absolute UX ceiling at the 10k perf fixture.
|
|
128
|
+
if (files.length > MAX_FULL_SCAN_FILES) {
|
|
129
|
+
return {
|
|
130
|
+
available: true,
|
|
131
|
+
advisory: true,
|
|
132
|
+
blockerGrade: false,
|
|
133
|
+
deferred: true,
|
|
134
|
+
count: 0,
|
|
135
|
+
templateInterpolationCount: 0,
|
|
136
|
+
otherNonLiteralCount: 0,
|
|
137
|
+
truncated: 0,
|
|
138
|
+
edges: [],
|
|
139
|
+
note: `Graph-blind full scan deferred (${files.length} files > ${MAX_FULL_SCAN_FILES}). Non-literal dynamic import/require remain unresolvable in architecture analysis; advisory incomplete-graph honesty is not enumerated at this scale.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resolvedRoot = path.resolve(root);
|
|
144
|
+
const edges = [];
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
let source;
|
|
147
|
+
try {
|
|
148
|
+
const stats = fs.statSync(file);
|
|
149
|
+
if (!stats.isFile() || stats.size === 0 || stats.size > MAX_FILE_BYTES) continue;
|
|
150
|
+
source = fs.readFileSync(file, 'utf8');
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// Cheap lexical gate — dynamic import/require and import-equals require forms.
|
|
155
|
+
if (!LEXICAL_GATE.test(source)) continue;
|
|
156
|
+
// setParentNodes=false: we only need positions + node kinds.
|
|
157
|
+
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.ESNext, false);
|
|
158
|
+
const rel = normalize(path.relative(resolvedRoot, path.resolve(file)));
|
|
159
|
+
collectDynamicBlindEdges(ts, sourceFile, rel, edges);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
edges.sort(
|
|
163
|
+
(a, b) =>
|
|
164
|
+
a.file.localeCompare(b.file) ||
|
|
165
|
+
a.line - b.line ||
|
|
166
|
+
a.reason.localeCompare(b.reason)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const templateInterpolationCount = edges.filter(
|
|
170
|
+
(e) => e.reason === 'template-interpolation'
|
|
171
|
+
).length;
|
|
172
|
+
const otherNonLiteralCount = edges.length - templateInterpolationCount;
|
|
173
|
+
const truncated = Math.max(0, edges.length - MAX_LIST);
|
|
174
|
+
const listed = edges.slice(0, MAX_LIST);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
available: true,
|
|
178
|
+
advisory: true,
|
|
179
|
+
blockerGrade: false,
|
|
180
|
+
count: edges.length,
|
|
181
|
+
templateInterpolationCount,
|
|
182
|
+
otherNonLiteralCount,
|
|
183
|
+
truncated,
|
|
184
|
+
edges: listed,
|
|
185
|
+
note:
|
|
186
|
+
edges.length === 0
|
|
187
|
+
? 'No unresolvable dynamic import/require edges detected in governed files (advisory scan).'
|
|
188
|
+
: `${edges.length} unresolvable dynamic edge(s) leave the architecture graph incomplete (${templateInterpolationCount} template-interpolation). Advisory only — never a hard architecture verdict; review or allowlist reviewed call sites.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Human doctor section. Unavailable prints a dim honesty line (not silence-as-done).
|
|
194
|
+
* Clean available scans stay silent.
|
|
195
|
+
*/
|
|
196
|
+
export function printGraphBlindSection(state, io) {
|
|
197
|
+
if (!state) return;
|
|
198
|
+
if (!state.available) {
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log(io.color.bold('Graph blind spots (advisory)'));
|
|
201
|
+
io.line(
|
|
202
|
+
' ',
|
|
203
|
+
io.color.dim(state.note || 'Graph-blind scan unavailable — incomplete-graph honesty not verified.')
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!state.count) return;
|
|
208
|
+
console.log('');
|
|
209
|
+
console.log(io.color.bold('Graph blind spots (advisory)'));
|
|
210
|
+
io.line(
|
|
211
|
+
io.warn,
|
|
212
|
+
`${state.count} unresolvable dynamic edge(s) (${state.templateInterpolationCount} template-interpolation) — graph incomplete`
|
|
213
|
+
);
|
|
214
|
+
for (const edge of state.edges.slice(0, 5)) {
|
|
215
|
+
io.line(
|
|
216
|
+
' ',
|
|
217
|
+
io.color.dim(`[${edge.reason}] ${edge.file}:${edge.line} (${edge.kind})`)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (state.count > 5) {
|
|
221
|
+
io.line(' ', io.color.dim(`…(+${state.count - 5} more in doctor JSON)`));
|
|
222
|
+
}
|
|
223
|
+
io.line(
|
|
224
|
+
' ',
|
|
225
|
+
io.color.dim('advisory only — does not change the architecture verdict; edges are blind, not clean')
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* HTML report body for graphBlindSpots (X01 parity). `esc` is the report escaper.
|
|
231
|
+
* Kept here so html-report-advisories stays under budget.
|
|
232
|
+
*/
|
|
233
|
+
export function graphBlindSpotsHtml(state, esc = (v) => String(v)) {
|
|
234
|
+
if (!state) return '';
|
|
235
|
+
const edges = Array.isArray(state.edges) ? state.edges : [];
|
|
236
|
+
let body;
|
|
237
|
+
if (state.available === false) {
|
|
238
|
+
body = `<p class="muted">${esc(state.note ?? 'Graph-blind scan unavailable.')}</p>`;
|
|
239
|
+
} else if ((state.count ?? 0) === 0) {
|
|
240
|
+
body = '<p class="muted">No unresolvable dynamic import/require edges detected (advisory scan).</p>';
|
|
241
|
+
} else {
|
|
242
|
+
const list = edges
|
|
243
|
+
.slice(0, 8)
|
|
244
|
+
.map((e) => `<li><span class="tag warn">${esc(e.reason)}</span> <code>${esc(e.file)}:${e.line}</code> (${esc(e.kind)})</li>`)
|
|
245
|
+
.join('');
|
|
246
|
+
const more = state.truncated > 0 ? `<p class="muted">…(+${state.truncated} more in doctor JSON)</p>` : '';
|
|
247
|
+
body = `<p><span class="tag warn">${state.count} unresolvable</span> dynamic edge(s) — graph incomplete (${state.templateInterpolationCount ?? 0} template-interpolation).</p><ul>${list}</ul>${more}<p class="muted">Advisory only — does not change the architecture verdict; edges are blind, not clean.</p>`;
|
|
248
|
+
}
|
|
249
|
+
return `
|
|
250
|
+
<section data-advisory="graphBlindSpots">
|
|
251
|
+
<h2>Graph blind spots <span class="muted">(advisory — incomplete graph honesty; never a hard verdict)</span></h2>
|
|
252
|
+
${body}
|
|
253
|
+
</section>`;
|
|
254
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* this report from falling behind the product again.
|
|
9
9
|
*/
|
|
10
10
|
import { effectiveCapabilityDeny } from './analysis-engine.mjs';
|
|
11
|
+
import { graphBlindSpotsHtml } from './graph-blind.mjs';
|
|
11
12
|
|
|
12
13
|
// htmlEscape is injected by the caller (html-report.mjs) — importing it back
|
|
13
14
|
// would be a dependency cycle, and the repo's own gate blocks that. The
|
|
@@ -134,9 +135,9 @@ function ambientStateHtml(state) {
|
|
|
134
135
|
}
|
|
135
136
|
const findings = Array.isArray(state.findings) ? state.findings : [];
|
|
136
137
|
const body = !state.active
|
|
137
|
-
? '<p class="muted">Idle — no <code>pure: true</code> layer opted in. Declare a pure layer to scan module-scope mutable state.</p>'
|
|
138
|
+
? '<p class="muted">Idle — no <code>pure: true</code> layer opted in. Advisory only; blocker-grade ambient diagnostics remain parked (Y07). Declare a pure layer to scan module-scope mutable state.</p>'
|
|
138
139
|
: findings.length === 0
|
|
139
|
-
? '<p class="muted">Active and clean —
|
|
140
|
+
? '<p class="muted">Active and clean under the MVP envelope — still advisory; not a Y07 blocker-grade pass. No module-scope <code>let</code>/<code>var</code> in pure layers.</p>'
|
|
140
141
|
: `<ul>${findings
|
|
141
142
|
.slice(0, 10)
|
|
142
143
|
.map(
|
|
@@ -147,7 +148,7 @@ function ambientStateHtml(state) {
|
|
|
147
148
|
(state.acknowledged > 0 ? `<p class="muted">acknowledged module state: ${state.acknowledged}</p>` : '');
|
|
148
149
|
return `
|
|
149
150
|
<section data-advisory="ambientState">
|
|
150
|
-
<h2>Ambient state <span class="muted">(advisory — opt-in via pure layers;
|
|
151
|
+
<h2>Ambient state <span class="muted">(advisory — opt-in via pure layers; blocker-grade Y07 parked)</span></h2>
|
|
151
152
|
${body}
|
|
152
153
|
</section>`;
|
|
153
154
|
}
|
|
@@ -240,6 +241,29 @@ function parseHealthHtml(health) {
|
|
|
240
241
|
* `computeDoctorAdvisories` returns — the parity guard enforces it.
|
|
241
242
|
* @param escape injected HTML escaper (dependency points html-report → here only)
|
|
242
243
|
*/
|
|
244
|
+
function rulesUnderContractHtml(section) {
|
|
245
|
+
if (!section || typeof section !== 'object') return '';
|
|
246
|
+
const note = section.note ? `<p class="muted">${esc(section.note)}</p>` : '';
|
|
247
|
+
if (section.active === false) {
|
|
248
|
+
return `
|
|
249
|
+
<section data-advisory="rulesUnderContract">
|
|
250
|
+
<h2>Rules under contract <span class="muted">(ArkRules opt-in)</span></h2>
|
|
251
|
+
${note}
|
|
252
|
+
</section>`;
|
|
253
|
+
}
|
|
254
|
+
return `
|
|
255
|
+
<section data-advisory="rulesUnderContract">
|
|
256
|
+
<h2>Rules under contract <span class="muted">(counts — not a score)</span></h2>
|
|
257
|
+
<ul>
|
|
258
|
+
<li>Structure rules: <strong>${Number(section.structureRules) || 0}</strong></li>
|
|
259
|
+
<li>Invariants: <strong>${Number(section.invariants) || 0}</strong></li>
|
|
260
|
+
<li>Covered invariants: <strong>${Number(section.coveredInvariants) || 0}</strong></li>
|
|
261
|
+
<li>Uncovered invariants: <strong>${Number(section.uncoveredInvariants) || 0}</strong></li>
|
|
262
|
+
</ul>
|
|
263
|
+
${note}
|
|
264
|
+
</section>`;
|
|
265
|
+
}
|
|
266
|
+
|
|
243
267
|
export function renderAdvisorySections(advisories, escape) {
|
|
244
268
|
if (!advisories || typeof advisories !== 'object') return '';
|
|
245
269
|
if (typeof escape === 'function') esc = escape;
|
|
@@ -248,6 +272,8 @@ export function renderAdvisorySections(advisories, escape) {
|
|
|
248
272
|
ambientStateHtml(advisories.ambientState),
|
|
249
273
|
physicalCohesionHtml(advisories.physicalCohesion),
|
|
250
274
|
parseHealthHtml(advisories.parseHealth),
|
|
275
|
+
graphBlindSpotsHtml(advisories.graphBlindSpots, esc),
|
|
276
|
+
rulesUnderContractHtml(advisories.rulesUnderContract),
|
|
251
277
|
]
|
|
252
278
|
.filter(Boolean)
|
|
253
279
|
.join('\n');
|
|
@@ -138,14 +138,32 @@ export function warnLockfileConflict(root) {
|
|
|
138
138
|
export function buildManagedAssetCatalog({ root, tools, compact = false, skillsOnly = false }) {
|
|
139
139
|
const selectedTools = tools instanceof Set ? tools : new Set(tools ?? []);
|
|
140
140
|
const assets = [];
|
|
141
|
+
// Path-keyed: codex + antigravity both target `.agents/skills/*/SKILL.md` (and
|
|
142
|
+
// antigravity + gemini share GEMINI.md). Duplicate plan entries caused apply to
|
|
143
|
+
// write once then fail the second pre-image assert (field: web-predial-ar).
|
|
144
|
+
const byPath = new Map();
|
|
141
145
|
const add = (relativePath, content, kind = 'gate', scope = 'whole-file') => {
|
|
142
|
-
|
|
146
|
+
const existing = byPath.get(relativePath);
|
|
147
|
+
if (existing) {
|
|
148
|
+
// Shared destinations (codex+antigravity skills, antigravity+gemini GEMINI.md)
|
|
149
|
+
// must agree on bytes; silent first-wins would hide divergent host templates.
|
|
150
|
+
if (existing.content !== content || existing.kind !== kind || existing.scope !== scope) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`managed asset catalog conflict at ${JSON.stringify(relativePath)}: ` +
|
|
153
|
+
`hosts produced different content/kind/scope for the same path`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const asset = {
|
|
143
159
|
relativePath,
|
|
144
160
|
content,
|
|
145
161
|
kind,
|
|
146
162
|
scope,
|
|
147
163
|
templateId: `${kind}:${relativePath}`,
|
|
148
|
-
}
|
|
164
|
+
};
|
|
165
|
+
byPath.set(relativePath, asset);
|
|
166
|
+
assets.push(asset);
|
|
149
167
|
};
|
|
150
168
|
|
|
151
169
|
if (!skillsOnly) {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooling I/O for ArkRules invariant coverage (AR10).
|
|
3
|
+
* Pure evaluation lives in Domain (`evaluateInvariantCoverage`); this module
|
|
4
|
+
* discovers test files and loads contents from disk (bounded).
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TEST_NAME_RE =
|
|
10
|
+
/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$|\/__tests__\/|\/tests?\//i;
|
|
11
|
+
|
|
12
|
+
/** Max files to load for coverage evidence (budget). */
|
|
13
|
+
const MAX_COVERAGE_FILES = 400;
|
|
14
|
+
/** Max bytes per file when reading for title/symbol mining. */
|
|
15
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when absolute is root or a file under root (separator-safe).
|
|
19
|
+
* @param {string} root
|
|
20
|
+
* @param {string} absolute
|
|
21
|
+
*/
|
|
22
|
+
function isPathInsideRoot(root, absolute) {
|
|
23
|
+
const rootResolved = path.resolve(root);
|
|
24
|
+
const absResolved = path.resolve(absolute);
|
|
25
|
+
if (absResolved === rootResolved) return true;
|
|
26
|
+
const relative = path.relative(rootResolved, absResolved);
|
|
27
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal glob match for testGlobs (double-star slash = zero path segments).
|
|
32
|
+
* @param {string} glob
|
|
33
|
+
* @param {string} file
|
|
34
|
+
*/
|
|
35
|
+
function matchSimpleGlob(glob, file) {
|
|
36
|
+
const pattern = String(glob || '').replace(/\\/g, '/');
|
|
37
|
+
const target = String(file || '').replace(/\\/g, '/');
|
|
38
|
+
if (!pattern) return false;
|
|
39
|
+
let out = '';
|
|
40
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
41
|
+
const c = pattern[i];
|
|
42
|
+
if (c === '*') {
|
|
43
|
+
if (pattern[i + 1] === '*') {
|
|
44
|
+
if (pattern[i + 2] === '/') {
|
|
45
|
+
out += '(?:.*/)?';
|
|
46
|
+
i += 2;
|
|
47
|
+
} else {
|
|
48
|
+
out += '.*';
|
|
49
|
+
i += 1;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
out += '[^/]*';
|
|
53
|
+
}
|
|
54
|
+
} else if (c === '?') {
|
|
55
|
+
out += '[^/]';
|
|
56
|
+
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
57
|
+
out += `\\${c}`;
|
|
58
|
+
} else {
|
|
59
|
+
out += c;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return new RegExp(`^${out}$`).test(target);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} root
|
|
67
|
+
* @param {{ files?: Array<{ path: string }> }} facts
|
|
68
|
+
* @param {{ testGlobs?: string[] }} [opts]
|
|
69
|
+
* @returns {{ fileContents: Record<string, string>, testFiles: string[], testGlobsMissing: boolean }}
|
|
70
|
+
*/
|
|
71
|
+
export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
72
|
+
const fileContents = {};
|
|
73
|
+
const testFiles = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const testGlobs = Array.isArray(opts.testGlobs)
|
|
76
|
+
? opts.testGlobs.filter((g) => typeof g === 'string' && g.length > 0)
|
|
77
|
+
: [];
|
|
78
|
+
const useCustomGlobs = testGlobs.length > 0;
|
|
79
|
+
|
|
80
|
+
const isTestPath = (rel) => {
|
|
81
|
+
if (useCustomGlobs) return testGlobs.some((g) => matchSimpleGlob(g, rel));
|
|
82
|
+
return DEFAULT_TEST_NAME_RE.test(rel);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const pushFile = (relPath, forceAsTest = false) => {
|
|
86
|
+
const rel = String(relPath || '')
|
|
87
|
+
.replace(/\\/g, '/')
|
|
88
|
+
.replace(/^\.\//, '');
|
|
89
|
+
if (!rel || seen.has(rel) || seen.size >= MAX_COVERAGE_FILES) return;
|
|
90
|
+
const absolute = path.resolve(root, rel);
|
|
91
|
+
if (!isPathInsideRoot(root, absolute)) return;
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.statSync(absolute);
|
|
94
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return;
|
|
95
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
96
|
+
seen.add(rel);
|
|
97
|
+
fileContents[rel] = content;
|
|
98
|
+
if (forceAsTest || isTestPath(rel)) testFiles.push(rel);
|
|
99
|
+
} catch {
|
|
100
|
+
// skip unreadable
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const file of facts?.files ?? []) {
|
|
105
|
+
if (file?.path) pushFile(file.path);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (useCustomGlobs) {
|
|
109
|
+
// Walk project roots and keep files matching custom globs.
|
|
110
|
+
for (const dir of ['.', 'tests', 'test', 'src', '__tests__', 'spec']) {
|
|
111
|
+
const absDir = path.join(root, dir === '.' ? '' : dir);
|
|
112
|
+
if (!fs.existsSync(absDir)) continue;
|
|
113
|
+
walkTestFiles(absDir, root, (rel) => {
|
|
114
|
+
if (isTestPath(rel)) pushFile(rel, true);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// Walk common test roots when facts only cover production include globs.
|
|
119
|
+
for (const dir of ['tests', 'test', 'src', '__tests__']) {
|
|
120
|
+
const absDir = path.join(root, dir);
|
|
121
|
+
if (!fs.existsSync(absDir)) continue;
|
|
122
|
+
walkTestFiles(absDir, root, (rel) => {
|
|
123
|
+
if (isTestPath(rel)) pushFile(rel, true);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const testGlobsMissing = testFiles.length === 0;
|
|
129
|
+
return { fileContents, testFiles, testGlobsMissing };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} dir
|
|
134
|
+
* @param {string} root
|
|
135
|
+
* @param {(rel: string) => void} onFile
|
|
136
|
+
* @param {number} [depth]
|
|
137
|
+
*/
|
|
138
|
+
function walkTestFiles(dir, root, onFile, depth = 0) {
|
|
139
|
+
if (depth > 8) return;
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
|
148
|
+
const absolute = path.join(dir, entry.name);
|
|
149
|
+
if (entry.isDirectory()) {
|
|
150
|
+
walkTestFiles(absolute, root, onFile, depth + 1);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!entry.isFile()) continue;
|
|
154
|
+
const rel = path.relative(root, absolute).replace(/\\/g, '/');
|
|
155
|
+
onFile(rel);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/invariantCoverage.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/invariant-coverage.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function titleMatchesInvariant(content, id) {
|
|
12
|
+
// Match describe/it/test string titles containing the invariant id.
|
|
13
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*['"\`][^'"\`]*${escaped}[^'"\`]*['"\`]`, 'i');
|
|
15
|
+
return re.test(content) || content.includes(id);
|
|
16
|
+
}
|
|
17
|
+
function symbolPresent(fileContents, symbol) {
|
|
18
|
+
if (!symbol)
|
|
19
|
+
return false;
|
|
20
|
+
// Support Aggregate.method or bare method name.
|
|
21
|
+
const parts = symbol.split('.');
|
|
22
|
+
const needle = parts[parts.length - 1];
|
|
23
|
+
const className = parts.length > 1 ? parts[0] : null;
|
|
24
|
+
for (const content of Object.values(fileContents)) {
|
|
25
|
+
if (className && !content.includes(className))
|
|
26
|
+
continue;
|
|
27
|
+
if (new RegExp(`(?:function\\s+|\\b)${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[(<]`).test(content) ||
|
|
28
|
+
content.includes(symbol)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
export function evaluateInvariantCoverage(input) {
|
|
35
|
+
const invariants = input.arkRules.invariants ?? [];
|
|
36
|
+
if (invariants.length === 0) {
|
|
37
|
+
return { coverage: [], violations: [], partial: false };
|
|
38
|
+
}
|
|
39
|
+
const testFiles = input.testFiles ?? [];
|
|
40
|
+
const testGlobsMissing = input.testGlobsMissing === true || testFiles.length === 0;
|
|
41
|
+
const coverage = [];
|
|
42
|
+
const violations = [];
|
|
43
|
+
for (const inv of invariants) {
|
|
44
|
+
const evidence = [];
|
|
45
|
+
const wantsTest = inv.coverage?.test !== false; // default: prefer test evidence when catalogued
|
|
46
|
+
const symbol = inv.coverage?.symbol;
|
|
47
|
+
if (!testGlobsMissing && wantsTest) {
|
|
48
|
+
for (const file of testFiles) {
|
|
49
|
+
const content = input.fileContents[file];
|
|
50
|
+
if (content && titleMatchesInvariant(content, inv.id)) {
|
|
51
|
+
evidence.push('test-title');
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (symbol && symbolPresent(input.fileContents, symbol)) {
|
|
57
|
+
evidence.push('symbol');
|
|
58
|
+
}
|
|
59
|
+
// Covered if any requested evidence is present.
|
|
60
|
+
// When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
|
|
61
|
+
const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
|
|
62
|
+
const covered = requiresEvidence && evidence.length > 0
|
|
63
|
+
? true
|
|
64
|
+
: inv.coverage?.test === false && !symbol
|
|
65
|
+
? true // explicitly no coverage requirements
|
|
66
|
+
: evidence.length > 0;
|
|
67
|
+
// Partial only when tests are missing *and* no other evidence (e.g. symbol) completed coverage.
|
|
68
|
+
const partial = testGlobsMissing && wantsTest && evidence.length === 0;
|
|
69
|
+
coverage.push({
|
|
70
|
+
invariantId: inv.id,
|
|
71
|
+
layer: inv.provenance.layer,
|
|
72
|
+
sourceFile: inv.provenance.sourceFile,
|
|
73
|
+
mode: inv.mode,
|
|
74
|
+
covered: covered && !partial,
|
|
75
|
+
evidence,
|
|
76
|
+
partial,
|
|
77
|
+
description: inv.description,
|
|
78
|
+
});
|
|
79
|
+
if (!covered || partial) {
|
|
80
|
+
// Enforced + proven uncovered → failsStrict; partial always advisory (never fake green).
|
|
81
|
+
const failsStrict = inv.mode === 'enforced' && !partial;
|
|
82
|
+
violations.push({
|
|
83
|
+
ruleId: 'INVARIANT_UNCOVERED',
|
|
84
|
+
message: partial
|
|
85
|
+
? `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered.`
|
|
86
|
+
: `Invariant ${inv.id} is not covered by a test title or declared symbol.`,
|
|
87
|
+
file: inv.provenance.sourceFile,
|
|
88
|
+
line: 1,
|
|
89
|
+
arkruleId: inv.id,
|
|
90
|
+
arkruleSource: inv.provenance.sourceFile,
|
|
91
|
+
fromLayer: inv.provenance.layer,
|
|
92
|
+
severity: failsStrict ? 'error' : 'warning',
|
|
93
|
+
failsStrict,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Top-level partial only from entry flags (symbol-only coverage must not stick partial).
|
|
98
|
+
return {
|
|
99
|
+
coverage,
|
|
100
|
+
violations,
|
|
101
|
+
partial: coverage.some((entry) => entry.partial),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Deterministic promotion gate: refuse advisory→enforced when invariant is uncovered.
|
|
106
|
+
*/
|
|
107
|
+
export function canPromoteInvariant(coverage) {
|
|
108
|
+
if (!coverage) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: 'No coverage evidence supplied for this invariant; evaluate coverage before promoting to enforced.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (coverage.partial) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: 'Coverage is partial (missing test globs); cannot promote until evidence is complete.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!coverage.covered) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: `Invariant ${coverage.invariantId} is uncovered; add a test title or symbol before promoting to enforced.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
|
|
127
|
+
}
|