arkgate 3.9.2 → 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 +73 -0
- package/README.md +16 -5
- 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/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 +14 -1
- package/bin/lib/doctor-plan.mjs +21 -0
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +19 -0
- package/bin/lib/html-report-advisories.mjs +24 -0
- 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/policy-delta-io.mjs +33 -0
- 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 +2 -2
- 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 +23 -0
- package/templates/skills/ark-explain.md +23 -0
- package/templates/skills/ark-explore.md +26 -1
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +26 -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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooling adapter: load Effective Contract from disk for a root config.
|
|
3
|
+
* Pure resolution lives in Domain (`resolveEffectiveContract`); this module owns I/O.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
emptyEffectiveArkRules,
|
|
9
|
+
buildEffectiveArkRules,
|
|
10
|
+
loadArkRulesContract,
|
|
11
|
+
} from './arkrules-contract.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {Record<string, unknown>} config loaded ark.config.json object
|
|
16
|
+
* @param {{ observeInput?: (abs: string, kind: string) => void }} [opts]
|
|
17
|
+
* @returns {{ arkRules: ReturnType<typeof emptyEffectiveArkRules>, warnings: Array<{path:string,message:string,severity:string}>, errors: Array<{path:string,message:string}> }}
|
|
18
|
+
*/
|
|
19
|
+
export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
20
|
+
const refs = config?.arkRules;
|
|
21
|
+
if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) {
|
|
22
|
+
return { arkRules: emptyEffectiveArkRules(), warnings: [], errors: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const layerNames = new Set(
|
|
26
|
+
Array.isArray(config.layers) ? config.layers.map((layer) => layer.name) : []
|
|
27
|
+
);
|
|
28
|
+
const errors = [];
|
|
29
|
+
const warnings = [];
|
|
30
|
+
const parts = [];
|
|
31
|
+
const referenced = new Set();
|
|
32
|
+
|
|
33
|
+
for (const layer of Object.keys(refs).sort()) {
|
|
34
|
+
const relRaw = refs[layer];
|
|
35
|
+
const pathKey = `$.arkRules[${JSON.stringify(layer)}]`;
|
|
36
|
+
if (typeof relRaw !== 'string' || relRaw.length === 0) {
|
|
37
|
+
errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (relRaw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(relRaw)) {
|
|
41
|
+
errors.push({
|
|
42
|
+
path: pathKey,
|
|
43
|
+
message: 'must be a project-relative path (absolute paths are not allowed)',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!layerNames.has(layer)) {
|
|
48
|
+
errors.push({
|
|
49
|
+
path: pathKey,
|
|
50
|
+
message: `layer ${JSON.stringify(layer)} is not declared in layers[]`,
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
56
|
+
referenced.add(rel);
|
|
57
|
+
const absolute = path.resolve(root, rel);
|
|
58
|
+
opts.observeInput?.(absolute, 'arkrules');
|
|
59
|
+
if (!fs.existsSync(absolute)) {
|
|
60
|
+
errors.push({
|
|
61
|
+
path: pathKey,
|
|
62
|
+
message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(absolute, 'utf8');
|
|
69
|
+
} catch (error) {
|
|
70
|
+
errors.push({
|
|
71
|
+
path: pathKey,
|
|
72
|
+
message: `referenced ArkRules file ${JSON.stringify(rel)} could not be read: ${
|
|
73
|
+
error instanceof Error ? error.message : String(error)
|
|
74
|
+
}`,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const loaded = loadArkRulesContract(JSON.parse(content), rel, layer);
|
|
80
|
+
parts.push({ layer, sourceFile: rel, file: loaded.config });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
errors.push({
|
|
83
|
+
path: pathKey,
|
|
84
|
+
message:
|
|
85
|
+
error instanceof Error
|
|
86
|
+
? error.message
|
|
87
|
+
: `referenced ArkRules file ${JSON.stringify(rel)} failed to load`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Drift: unreferenced files under arkrules/
|
|
93
|
+
const arkrulesDir = path.join(root, 'arkrules');
|
|
94
|
+
if (fs.existsSync(arkrulesDir) && fs.statSync(arkrulesDir).isDirectory()) {
|
|
95
|
+
for (const name of fs.readdirSync(arkrulesDir).sort()) {
|
|
96
|
+
if (!name.endsWith('.json')) continue;
|
|
97
|
+
const rel = `arkrules/${name}`;
|
|
98
|
+
if (!referenced.has(rel)) {
|
|
99
|
+
warnings.push({
|
|
100
|
+
path: rel,
|
|
101
|
+
message: `ArkRules file ${JSON.stringify(rel)} is not referenced by arkRules and will not be enforced`,
|
|
102
|
+
severity: 'advisory',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (errors.length > 0) {
|
|
109
|
+
return { arkRules: emptyEffectiveArkRules(), warnings, errors };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
arkRules: buildEffectiveArkRules(parts),
|
|
113
|
+
warnings,
|
|
114
|
+
errors: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -201,6 +201,110 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
|
|
|
201
201
|
return { changed, skipped };
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Read declared arkgate pin from consumer package.json (deps or devDeps).
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {string|null}
|
|
208
|
+
*/
|
|
209
|
+
export function readDeclaredArkgatePin(root) {
|
|
210
|
+
const pkgPath = path.join(root, 'package.json');
|
|
211
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
212
|
+
try {
|
|
213
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
214
|
+
const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
|
|
215
|
+
const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
|
|
216
|
+
if (typeof deps.arkgate === 'string') return deps.arkgate;
|
|
217
|
+
if (typeof dev.arkgate === 'string') return dev.arkgate;
|
|
218
|
+
return null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Dual-truth: CLI/package-shipped version vs consumer package.json pin.
|
|
226
|
+
* Used by doctor + upgrade so agents never confuse managed-asset CLI with CI pin.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} root
|
|
229
|
+
* @param {{ cliVersion?: string|null }} [opts]
|
|
230
|
+
* @returns {{
|
|
231
|
+
* dualTruth: boolean,
|
|
232
|
+
* cliVersion: string|null,
|
|
233
|
+
* declaredPin: string|null,
|
|
234
|
+
* code: 'PACKAGE_PIN_BEHIND_CLI' | 'PACKAGE_PIN_MATCHES' | 'PACKAGE_PIN_ABSENT' | 'CLI_VERSION_UNKNOWN',
|
|
235
|
+
* note: string
|
|
236
|
+
* }}
|
|
237
|
+
*/
|
|
238
|
+
export function describePackageVersionDualTruth(root, opts = {}) {
|
|
239
|
+
const cliVersion =
|
|
240
|
+
typeof opts.cliVersion === 'string' && opts.cliVersion
|
|
241
|
+
? opts.cliVersion
|
|
242
|
+
: arkPackageVersion();
|
|
243
|
+
const declaredPin = readDeclaredArkgatePin(root);
|
|
244
|
+
if (!cliVersion) {
|
|
245
|
+
return {
|
|
246
|
+
dualTruth: false,
|
|
247
|
+
cliVersion: null,
|
|
248
|
+
declaredPin,
|
|
249
|
+
code: 'CLI_VERSION_UNKNOWN',
|
|
250
|
+
note: 'Could not read shipped arkgate package version for this CLI.',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (!declaredPin) {
|
|
254
|
+
return {
|
|
255
|
+
dualTruth: false,
|
|
256
|
+
cliVersion,
|
|
257
|
+
declaredPin: null,
|
|
258
|
+
code: 'PACKAGE_PIN_ABSENT',
|
|
259
|
+
note: 'No arkgate pin in package.json; CI/npx may not resolve this CLI version.',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Normalize ^x.y.z / ~x.y.z / x.y.z for comparison of leading version token.
|
|
263
|
+
const pinCore = String(declaredPin).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0];
|
|
264
|
+
const matches =
|
|
265
|
+
pinCore === cliVersion ||
|
|
266
|
+
pinCore.startsWith(`${cliVersion}.`) ||
|
|
267
|
+
cliVersion.startsWith(pinCore.split('.').slice(0, 3).join('.'));
|
|
268
|
+
// Dual-truth when declared pin is clearly older major/minor than CLI, or different major.
|
|
269
|
+
const pinParts = pinCore.split('.').map((p) => Number.parseInt(p, 10));
|
|
270
|
+
const cliParts = cliVersion.split('.').map((p) => Number.parseInt(p, 10));
|
|
271
|
+
let behind = false;
|
|
272
|
+
if (
|
|
273
|
+
pinParts.length >= 1 &&
|
|
274
|
+
cliParts.length >= 1 &&
|
|
275
|
+
pinParts.every((n) => Number.isFinite(n)) &&
|
|
276
|
+
cliParts.every((n) => Number.isFinite(n))
|
|
277
|
+
) {
|
|
278
|
+
for (let i = 0; i < 3; i += 1) {
|
|
279
|
+
const p = pinParts[i] ?? 0;
|
|
280
|
+
const c = cliParts[i] ?? 0;
|
|
281
|
+
if (p < c) {
|
|
282
|
+
behind = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
if (p > c) break;
|
|
286
|
+
}
|
|
287
|
+
} else if (!matches) {
|
|
288
|
+
behind = true;
|
|
289
|
+
}
|
|
290
|
+
if (behind) {
|
|
291
|
+
return {
|
|
292
|
+
dualTruth: true,
|
|
293
|
+
cliVersion,
|
|
294
|
+
declaredPin,
|
|
295
|
+
code: 'PACKAGE_PIN_BEHIND_CLI',
|
|
296
|
+
note: `Managed CLI is arkgate@${cliVersion} but package.json pins ${declaredPin}. Bump the pin or re-run install so CI resolves the same version (common after upgrade --no-install).`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
dualTruth: false,
|
|
301
|
+
cliVersion,
|
|
302
|
+
declaredPin,
|
|
303
|
+
code: 'PACKAGE_PIN_MATCHES',
|
|
304
|
+
note: `package.json pin ${declaredPin} is aligned with CLI arkgate@${cliVersion}.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
204
308
|
/**
|
|
205
309
|
* Pin `arkgate` in package.json devDependencies (no package manager network call).
|
|
206
310
|
*
|
package/bin/lib/graph-blind.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { normalize } from './scan-files.mjs';
|
|
|
16
16
|
|
|
17
17
|
const MAX_LIST = 8;
|
|
18
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;
|
|
19
21
|
const LEXICAL_GATE = /\b(?:import|require)\s*\(|\bimport\s+\w+\s*=\s*require\s*\(/;
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -121,6 +123,23 @@ export function detectGraphBlindSpots(ts, root, files = []) {
|
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
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
|
+
|
|
124
143
|
const resolvedRoot = path.resolve(root);
|
|
125
144
|
const edges = [];
|
|
126
145
|
for (const file of files) {
|
|
@@ -241,6 +241,29 @@ function parseHealthHtml(health) {
|
|
|
241
241
|
* `computeDoctorAdvisories` returns — the parity guard enforces it.
|
|
242
242
|
* @param escape injected HTML escaper (dependency points html-report → here only)
|
|
243
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
|
+
|
|
244
267
|
export function renderAdvisorySections(advisories, escape) {
|
|
245
268
|
if (!advisories || typeof advisories !== 'object') return '';
|
|
246
269
|
if (typeof escape === 'function') esc = escape;
|
|
@@ -250,6 +273,7 @@ export function renderAdvisorySections(advisories, escape) {
|
|
|
250
273
|
physicalCohesionHtml(advisories.physicalCohesion),
|
|
251
274
|
parseHealthHtml(advisories.parseHealth),
|
|
252
275
|
graphBlindSpotsHtml(advisories.graphBlindSpots, esc),
|
|
276
|
+
rulesUnderContractHtml(advisories.rulesUnderContract),
|
|
253
277
|
]
|
|
254
278
|
.filter(Boolean)
|
|
255
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
|
+
}
|
|
@@ -2,6 +2,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { analyzePolicyDelta } from './analysis-engine.mjs';
|
|
5
|
+
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
6
|
+
import { loadInvariantCoverageInputs } from './invariant-coverage-io.mjs';
|
|
7
|
+
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
5
8
|
|
|
6
9
|
function readJsonFile(filePath, label) {
|
|
7
10
|
if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
|
|
@@ -151,11 +154,41 @@ export function analyzePolicyTransition({
|
|
|
151
154
|
throw new Error('Policy delta was requested but no policy base could be resolved.');
|
|
152
155
|
}
|
|
153
156
|
if (!base) return undefined;
|
|
157
|
+
|
|
158
|
+
// AR02/AR11: load Effective ArkRules so mode edits inside arkrules/*.json classify,
|
|
159
|
+
// and attach candidate coverage so covered advisory→enforced can auto-allow.
|
|
160
|
+
const baseLoad = loadEffectiveArkRulesFromDisk(root, base.config);
|
|
161
|
+
const candidateLoad = loadEffectiveArkRulesFromDisk(root, candidateConfig);
|
|
162
|
+
if (candidateLoad.errors.length > 0) {
|
|
163
|
+
const message = candidateLoad.errors
|
|
164
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
165
|
+
.join('\n');
|
|
166
|
+
throw new Error(`Invalid candidate Effective Contract:\n${message}`);
|
|
167
|
+
}
|
|
168
|
+
// Base load errors: best-effort empty (base git tree may lack arkrules files on disk).
|
|
169
|
+
const baseArkRules = baseLoad.errors.length > 0 ? undefined : baseLoad.arkRules;
|
|
170
|
+
const candidateArkRules = candidateLoad.arkRules;
|
|
171
|
+
|
|
172
|
+
let candidateInvariantCoverage;
|
|
173
|
+
if ((candidateArkRules?.invariants?.length ?? 0) > 0) {
|
|
174
|
+
const coverageInputs = loadInvariantCoverageInputs(root, { files: [] });
|
|
175
|
+
const evaluated = evaluateInvariantCoverage({
|
|
176
|
+
arkRules: candidateArkRules,
|
|
177
|
+
fileContents: coverageInputs.fileContents,
|
|
178
|
+
testFiles: coverageInputs.testFiles,
|
|
179
|
+
testGlobsMissing: coverageInputs.testGlobsMissing,
|
|
180
|
+
});
|
|
181
|
+
candidateInvariantCoverage = evaluated.coverage;
|
|
182
|
+
}
|
|
183
|
+
|
|
154
184
|
return analyzePolicyDelta({
|
|
155
185
|
baseConfig: base.config,
|
|
156
186
|
candidateConfig,
|
|
157
187
|
acknowledgement: readPolicyAcknowledgement(root, acknowledgementPath),
|
|
158
188
|
baseSource: base.source,
|
|
159
189
|
candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
|
|
190
|
+
...(baseArkRules ? { baseArkRules } : {}),
|
|
191
|
+
candidateArkRules,
|
|
192
|
+
...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
|
|
160
193
|
});
|
|
161
194
|
}
|