arkgate 4.8.2 → 4.8.4
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 +257 -3
- package/README.md +47 -9
- package/bin/ark-check-runtime.mjs +340 -5
- package/bin/ark-layer-match.mjs +170 -13
- package/bin/ark-mcp-runtime.mjs +9 -2
- package/bin/lib/analysis-completeness.mjs +86 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/ark-order-facts.mjs +59 -0
- package/bin/lib/ark-order-sensors.mjs +31 -2
- package/bin/lib/arkrule-file-hints.mjs +6 -2
- package/bin/lib/arkrules-contract.mjs +9 -1
- package/bin/lib/arkrules-sensors.mjs +22 -2
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/config-extras.mjs +2 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +8 -2
- package/bin/lib/first-run-help.mjs +12 -0
- package/bin/lib/invariant-coverage-io.mjs +175 -19
- package/bin/lib/invariant-coverage.mjs +110 -7
- package/bin/lib/literal-path-drift-io.mjs +569 -0
- package/bin/lib/literal-path-drift.mjs +761 -0
- package/bin/lib/policy-delta-io.mjs +5 -0
- package/bin/lib/remediation.mjs +24 -1
- package/bin/lib/resolved-candidate-facts.mjs +31 -0
- package/bin/lib/rules-under-contract.mjs +5 -0
- package/bin/lib/scan-files.mjs +54 -0
- package/bin/lib/sensor-promote-cli.mjs +372 -0
- package/bin/lib/sensor-promote-io.mjs +246 -0
- package/bin/lib/sensor-promotion.mjs +363 -0
- package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
- package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
- package/dist/eslint/index.cjs +5 -5
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +5 -5
- package/dist/index.cjs +31 -31
- package/dist/index.d.ts +85 -7
- package/dist/index.js +31 -31
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +4 -4
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +89 -9
- package/docs/develop.md +24 -2
- package/docs/diagnostics.md +79 -1
- package/docs/enthusiast/README.md +6 -4
- package/docs/package-surface.md +36 -4
- package/docs/product-voice.md +15 -5
- package/docs/use.md +8 -5
- package/package.json +2 -2
- package/schemas/ark.arkrules.schema.json +1 -0
- package/schemas/ark.config.schema.json +72 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
- package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +4 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/agent-skills/ark-place/SKILL.md +6 -2
- package/templates/arkrules/ApplicationOrchestration.json +6 -0
- package/templates/skills/ark-adopt.md +13 -3
- package/templates/skills/ark-autopilot.md +1 -1
- package/templates/skills/ark-contract.md +4 -0
- package/templates/skills/ark-coverage.md +1 -0
- package/templates/skills/ark-place.md +6 -2
|
@@ -9,10 +9,20 @@ import path from 'node:path';
|
|
|
9
9
|
const DEFAULT_TEST_NAME_RE =
|
|
10
10
|
/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$|\/__tests__\/|\/tests?\//i;
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
12
|
+
/** Default max files to load for coverage evidence (budget). Config: `coverage.maxFiles`. */
|
|
13
|
+
export const DEFAULT_MAX_COVERAGE_FILES = 400;
|
|
14
|
+
/**
|
|
15
|
+
* Hard ceiling on `coverage.maxFiles`. The config validator implements no
|
|
16
|
+
* `maximum` keyword for integers, so a schema bound would be accepted and then
|
|
17
|
+
* silently ignored — the clamp here is the only real enforcement. Retained
|
|
18
|
+
* files are held in memory at up to MAX_FILE_BYTES each, so an unbounded cap is
|
|
19
|
+
* an unbounded heap.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_COVERAGE_FILES_CAP = 20_000;
|
|
14
22
|
/** Max bytes per file when reading for title/symbol mining. */
|
|
15
23
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
24
|
+
/** Max directory depth for the test walk. Deeper directories are counted, not silent. */
|
|
25
|
+
const MAX_WALK_DEPTH = 8;
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
28
|
* True when absolute is root or a file under root (separator-safe).
|
|
@@ -62,6 +72,30 @@ function matchSimpleGlob(glob, file) {
|
|
|
62
72
|
return new RegExp(`^${out}$`).test(target);
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Coverage scan options carried by ark.config.json (`coverage`).
|
|
77
|
+
* Absent config → `{}`: the built-in heuristic and default budget stay in force.
|
|
78
|
+
* @param {{ coverage?: { testGlobs?: unknown, maxFiles?: unknown, coverageRoots?: unknown } } | null | undefined} config
|
|
79
|
+
* @returns {{ testGlobs?: string[], maxFiles?: number, coverageRoots?: string[] }}
|
|
80
|
+
*/
|
|
81
|
+
export function coverageOptionsFromConfig(config) {
|
|
82
|
+
const coverage = config?.coverage;
|
|
83
|
+
if (!coverage || typeof coverage !== 'object') return {};
|
|
84
|
+
const options = {};
|
|
85
|
+
if (Array.isArray(coverage.testGlobs)) {
|
|
86
|
+
const globs = coverage.testGlobs.filter((g) => typeof g === 'string' && g.length > 0);
|
|
87
|
+
if (globs.length > 0) options.testGlobs = globs;
|
|
88
|
+
}
|
|
89
|
+
if (Number.isInteger(coverage.maxFiles) && coverage.maxFiles > 0) {
|
|
90
|
+
options.maxFiles = Math.min(coverage.maxFiles, MAX_COVERAGE_FILES_CAP);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(coverage.coverageRoots)) {
|
|
93
|
+
const roots = coverage.coverageRoots.filter((r) => typeof r === 'string' && r.length > 0);
|
|
94
|
+
if (roots.length > 0) options.coverageRoots = roots;
|
|
95
|
+
}
|
|
96
|
+
return options;
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
/**
|
|
66
100
|
* Declared invariant ids from an Effective catalog. Empty when the extra is off.
|
|
67
101
|
* @param {{ invariants?: Array<{ id?: unknown }> } | null | undefined} arkRules
|
|
@@ -76,18 +110,65 @@ export function invariantIdsFromCatalog(arkRules) {
|
|
|
76
110
|
/**
|
|
77
111
|
* @param {string} root
|
|
78
112
|
* @param {{ files?: Array<{ path: string }> }} facts
|
|
79
|
-
* @param {{ testGlobs?: string[], invariantIds?: string[] }} [opts]
|
|
113
|
+
* @param {{ testGlobs?: string[], invariantIds?: string[], maxFiles?: number, coverageRoots?: string[] }} [opts]
|
|
80
114
|
* @returns {{
|
|
81
115
|
* fileContents: Record<string, string>,
|
|
82
116
|
* testFiles: string[],
|
|
83
117
|
* testGlobsMissing: boolean,
|
|
84
118
|
* coverageBudgetExhausted: boolean,
|
|
119
|
+
* coverageRoots?: string[],
|
|
120
|
+
* stats: {
|
|
121
|
+
* filesRead: number,
|
|
122
|
+
* filesLoaded: number,
|
|
123
|
+
* testFilesRetained: number,
|
|
124
|
+
* maxFiles: number,
|
|
125
|
+
* discarded: {
|
|
126
|
+
* budget: number,
|
|
127
|
+
* noInvariantMention: number,
|
|
128
|
+
* oversize: number,
|
|
129
|
+
* unreadable: number,
|
|
130
|
+
* depthLimited: number,
|
|
131
|
+
* outOfRoot: number,
|
|
132
|
+
* },
|
|
133
|
+
* },
|
|
85
134
|
* }}
|
|
86
135
|
*/
|
|
87
136
|
export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
88
137
|
const fileContents = {};
|
|
89
138
|
const testFiles = [];
|
|
90
139
|
const seen = new Set();
|
|
140
|
+
// Every path pushFile has already judged, retained or not. `seen` holds only
|
|
141
|
+
// what was retained, so without this the walk roots overlap ('.' contains
|
|
142
|
+
// 'tests' and 'src') and one discarded file is counted — and read — once per
|
|
143
|
+
// overlapping root. The numbers we print must count files, not visits.
|
|
144
|
+
const offered = new Set();
|
|
145
|
+
const maxFiles =
|
|
146
|
+
Number.isInteger(opts.maxFiles) && opts.maxFiles > 0
|
|
147
|
+
? Math.min(opts.maxFiles, MAX_COVERAGE_FILES_CAP)
|
|
148
|
+
: DEFAULT_MAX_COVERAGE_FILES;
|
|
149
|
+
// Reads, not retentions. A test is read before it can be judged for naming an
|
|
150
|
+
// invariant, so the budget bounds what we KEEP, not what we open. Reporting
|
|
151
|
+
// only the retained count made maxFiles look like an I/O knob it is not.
|
|
152
|
+
let filesRead = 0;
|
|
153
|
+
// Every discard is counted. A file dropped without a number is a coverage
|
|
154
|
+
// verdict the user cannot explain.
|
|
155
|
+
const discarded = {
|
|
156
|
+
budget: 0,
|
|
157
|
+
noInvariantMention: 0,
|
|
158
|
+
oversize: 0,
|
|
159
|
+
unreadable: 0,
|
|
160
|
+
depthLimited: 0,
|
|
161
|
+
outOfRoot: 0,
|
|
162
|
+
};
|
|
163
|
+
// Root with every symlink resolved, computed once: the containment test for
|
|
164
|
+
// symlinked candidates compares resolved path to resolved root.
|
|
165
|
+
let realRoot = root;
|
|
166
|
+
try {
|
|
167
|
+
realRoot = fs.realpathSync.native(root);
|
|
168
|
+
} catch {
|
|
169
|
+
// Unresolvable root: fall back to the literal path rather than failing the
|
|
170
|
+
// whole scan. Containment is then as strict as it was before.
|
|
171
|
+
}
|
|
91
172
|
// Declared invariant ids. When present, a test file is RETAINED only if it
|
|
92
173
|
// mentions one: scanning is cheap (hundreds of small files), retaining is
|
|
93
174
|
// what costs memory. Without this the budget goes to whichever N tests the
|
|
@@ -112,31 +193,61 @@ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
|
112
193
|
const rel = String(relPath || '')
|
|
113
194
|
.replace(/\\/g, '/')
|
|
114
195
|
.replace(/^\.\//, '');
|
|
115
|
-
if (!rel ||
|
|
196
|
+
if (!rel || offered.has(rel)) return;
|
|
197
|
+
offered.add(rel);
|
|
116
198
|
const absolute = path.resolve(root, rel);
|
|
117
199
|
if (!isPathInsideRoot(root, absolute)) return;
|
|
118
200
|
try {
|
|
119
201
|
const stat = fs.statSync(absolute);
|
|
120
|
-
|
|
202
|
+
// Not a file (directory, socket, symlink to a directory): never a
|
|
203
|
+
// coverage candidate, so it is not a discard either.
|
|
204
|
+
if (!stat.isFile()) return;
|
|
205
|
+
// statSync followed the link. A symlink that leaves the root must not
|
|
206
|
+
// become evidence: an out-of-root file naming an invariant would forge
|
|
207
|
+
// coverage for a test that is not in this repo. Compared against the
|
|
208
|
+
// resolved root so a repo living under a symlinked prefix (macOS /tmp)
|
|
209
|
+
// is not mistaken for an escape. Counted, never silent.
|
|
210
|
+
if (!isPathInsideRoot(realRoot, fs.realpathSync.native(absolute))) {
|
|
211
|
+
discarded.outOfRoot += 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
215
|
+
discarded.oversize += 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// The budget bounds candidates, not visits: a directory or an
|
|
219
|
+
// out-of-root path was never going to be evidence, so counting it as a
|
|
220
|
+
// budget casualty would send the user to raise a cap that was not the
|
|
221
|
+
// reason. Checked here so a file past the cap is never read either.
|
|
222
|
+
if (seen.size >= maxFiles) {
|
|
223
|
+
discarded.budget += 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
121
226
|
const content = fs.readFileSync(absolute, 'utf8');
|
|
227
|
+
filesRead += 1;
|
|
122
228
|
const asTest = forceAsTest || isTestPath(rel);
|
|
123
229
|
// A test that names no invariant is evidence of nothing: scan it, drop
|
|
124
230
|
// it, and let it cost no budget.
|
|
125
|
-
if (asTest && !mentionsInvariant(content))
|
|
231
|
+
if (asTest && !mentionsInvariant(content)) {
|
|
232
|
+
discarded.noInvariantMention += 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
126
235
|
seen.add(rel);
|
|
127
236
|
fileContents[rel] = content;
|
|
128
237
|
if (asTest) testFiles.push(rel);
|
|
129
238
|
} catch {
|
|
130
|
-
//
|
|
239
|
+
// Unreadable (permissions, broken symlink, file moved mid-scan): counted,
|
|
240
|
+
// never dropped in silence.
|
|
241
|
+
discarded.unreadable += 1;
|
|
131
242
|
}
|
|
132
243
|
};
|
|
133
244
|
|
|
134
245
|
// Tests FIRST, then production files.
|
|
135
246
|
//
|
|
136
|
-
// The order is load-bearing, not stylistic. `pushFile` stops at
|
|
137
|
-
//
|
|
138
|
-
// budget — so walking facts first consumed the
|
|
139
|
-
// pushed nothing. Coverage then reported `testGlobsMissing: true`, which the
|
|
247
|
+
// The order is load-bearing, not stylistic. `pushFile` stops at the file
|
|
248
|
+
// budget (`coverage.maxFiles`, default 400), and a real repo has far more
|
|
249
|
+
// production files than the budget — so walking facts first consumed the
|
|
250
|
+
// whole budget and the test walk pushed nothing. Coverage then reported `testGlobsMissing: true`, which the
|
|
140
251
|
// caller renders as "never-had-tests": a claim about the USER's repo that was
|
|
141
252
|
// actually about our own budget. Measured on a 4511-file project: every
|
|
142
253
|
// invariant reported uncovered while its test sat on disk with the invariant
|
|
@@ -145,24 +256,59 @@ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
|
145
256
|
const testWalkRoots = useCustomGlobs
|
|
146
257
|
? ['.', 'tests', 'test', 'src', '__tests__', 'spec']
|
|
147
258
|
: ['tests', 'test', 'src', '__tests__'];
|
|
259
|
+
// Directories, deduplicated across overlapping walk roots. '.' contains
|
|
260
|
+
// 'tests' and 'src', so the same directory is offered to the walk more than
|
|
261
|
+
// once: counting each visit would report N subtrees where one exists. And a
|
|
262
|
+
// directory refused at depth under one root may be entered from a nearer
|
|
263
|
+
// root, so 'depth-limited' is only the ones NO walk ever entered.
|
|
264
|
+
const dirs = { walked: new Set(), depthLimited: new Set(), unreadable: new Set() };
|
|
148
265
|
for (const dir of testWalkRoots) {
|
|
149
266
|
const absDir = path.join(root, dir === '.' ? '' : dir);
|
|
150
267
|
if (!fs.existsSync(absDir)) continue;
|
|
151
|
-
walkTestFiles(
|
|
152
|
-
|
|
153
|
-
|
|
268
|
+
walkTestFiles(
|
|
269
|
+
absDir,
|
|
270
|
+
root,
|
|
271
|
+
(rel) => {
|
|
272
|
+
if (isTestPath(rel)) pushFile(rel, true);
|
|
273
|
+
},
|
|
274
|
+
0,
|
|
275
|
+
dirs
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
for (const dir of dirs.depthLimited) {
|
|
279
|
+
if (!dirs.walked.has(dir)) discarded.depthLimited += 1;
|
|
280
|
+
}
|
|
281
|
+
for (const dir of dirs.unreadable) {
|
|
282
|
+
if (!dirs.walked.has(dir)) discarded.unreadable += 1;
|
|
154
283
|
}
|
|
155
284
|
|
|
156
285
|
for (const file of facts?.files ?? []) {
|
|
157
286
|
if (file?.path) pushFile(file.path);
|
|
158
287
|
}
|
|
159
288
|
|
|
289
|
+
// Echoed, not applied here: the roots are a declaration Domain compares the
|
|
290
|
+
// scan against. Tooling filtering by them would hide the disagreement that is
|
|
291
|
+
// the whole point of the declaration.
|
|
292
|
+
const coverageRoots = Array.isArray(opts.coverageRoots)
|
|
293
|
+
? opts.coverageRoots.filter((r) => typeof r === 'string' && r.length > 0)
|
|
294
|
+
: [];
|
|
160
295
|
const testGlobsMissing = testFiles.length === 0;
|
|
161
296
|
return {
|
|
162
297
|
fileContents,
|
|
163
298
|
testFiles,
|
|
164
299
|
testGlobsMissing,
|
|
165
|
-
|
|
300
|
+
...(coverageRoots.length > 0 ? { coverageRoots } : {}),
|
|
301
|
+
// Exhausted means the cap actually cost the user a file. Landing exactly
|
|
302
|
+
// on the cap with nothing dropped is a full budget, not an exhausted one:
|
|
303
|
+
// reporting it would tell the user to raise a cap that discarded nothing.
|
|
304
|
+
coverageBudgetExhausted: discarded.budget > 0,
|
|
305
|
+
stats: {
|
|
306
|
+
filesRead,
|
|
307
|
+
filesLoaded: seen.size,
|
|
308
|
+
testFilesRetained: testFiles.length,
|
|
309
|
+
maxFiles,
|
|
310
|
+
discarded,
|
|
311
|
+
},
|
|
166
312
|
};
|
|
167
313
|
}
|
|
168
314
|
|
|
@@ -171,23 +317,33 @@ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
|
171
317
|
* @param {string} root
|
|
172
318
|
* @param {(rel: string) => void} onFile
|
|
173
319
|
* @param {number} [depth]
|
|
320
|
+
* @param {{ walked: Set<string>, depthLimited: Set<string>, unreadable: Set<string> }} [dirs]
|
|
174
321
|
*/
|
|
175
|
-
function walkTestFiles(dir, root, onFile, depth = 0) {
|
|
176
|
-
if (depth >
|
|
322
|
+
function walkTestFiles(dir, root, onFile, depth = 0, dirs) {
|
|
323
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
324
|
+
// The whole subtree is dropped here. Recorded by path, not counted: a
|
|
325
|
+
// nearer walk root may still reach it within the depth limit.
|
|
326
|
+
if (dirs) dirs.depthLimited.add(dir);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
177
329
|
let entries;
|
|
178
330
|
try {
|
|
179
331
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
180
332
|
} catch {
|
|
333
|
+
if (dirs) dirs.unreadable.add(dir);
|
|
181
334
|
return;
|
|
182
335
|
}
|
|
336
|
+
if (dirs) dirs.walked.add(dir);
|
|
183
337
|
for (const entry of entries) {
|
|
184
338
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
|
185
339
|
const absolute = path.join(dir, entry.name);
|
|
186
340
|
if (entry.isDirectory()) {
|
|
187
|
-
walkTestFiles(absolute, root, onFile, depth + 1);
|
|
341
|
+
walkTestFiles(absolute, root, onFile, depth + 1, dirs);
|
|
188
342
|
continue;
|
|
189
343
|
}
|
|
190
|
-
|
|
344
|
+
// Symlinks are candidates too: pushFile stats through them, so a broken one
|
|
345
|
+
// is counted as unreadable instead of vanishing from the walk.
|
|
346
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
191
347
|
const rel = path.relative(root, absolute).replace(/\\/g, '/');
|
|
192
348
|
onFile(rel);
|
|
193
349
|
}
|
|
@@ -8,6 +8,48 @@
|
|
|
8
8
|
* Pure CLI helper (bin/lib/invariant-coverage.mjs). Zero Node I/O.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Human-readable discard tail. Empty when the scan discarded nothing.
|
|
13
|
+
* `omitBudget` drops the budget clause and the load totals for messages whose
|
|
14
|
+
* own text already carries them — the same number twice reads as two facts.
|
|
15
|
+
*/
|
|
16
|
+
function formatCoverageDiscards(stats, omitBudget = false) {
|
|
17
|
+
if (!stats)
|
|
18
|
+
return '';
|
|
19
|
+
const d = stats.discarded;
|
|
20
|
+
const parts = [];
|
|
21
|
+
if (d.budget > 0 && !omitBudget)
|
|
22
|
+
parts.push(`${d.budget} past the ${stats.maxFiles}-file budget`);
|
|
23
|
+
if (d.noInvariantMention > 0)
|
|
24
|
+
parts.push(`${d.noInvariantMention} naming no catalogued invariant`);
|
|
25
|
+
if (d.oversize > 0)
|
|
26
|
+
parts.push(`${d.oversize} over the per-file byte cap`);
|
|
27
|
+
if (d.unreadable > 0)
|
|
28
|
+
parts.push(`${d.unreadable} unreadable (files or directories)`);
|
|
29
|
+
if (d.depthLimited > 0)
|
|
30
|
+
parts.push(`${d.depthLimited} directories past the walk depth limit`);
|
|
31
|
+
if (d.outOfRoot > 0)
|
|
32
|
+
parts.push(`${d.outOfRoot} symlinked outside the project root`);
|
|
33
|
+
if (parts.length === 0)
|
|
34
|
+
return '';
|
|
35
|
+
const totals = omitBudget
|
|
36
|
+
? ''
|
|
37
|
+
: ` (loaded ${stats.filesLoaded} files, kept ${stats.testFilesRetained} tests)`;
|
|
38
|
+
return ` Scan discarded ${parts.join(', ')}${totals}.`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* True when `file` sits inside one of the declared coverage roots.
|
|
42
|
+
* A root is a path prefix, `.` (or `''`) meaning the whole project.
|
|
43
|
+
*/
|
|
44
|
+
function isUnderCoverageRoot(file, roots) {
|
|
45
|
+
const target = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
46
|
+
return roots.some((rawRoot) => {
|
|
47
|
+
const root = rawRoot.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
48
|
+
if (root === '' || root === '.')
|
|
49
|
+
return true;
|
|
50
|
+
return target === root || target.startsWith(`${root}/`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
11
53
|
function titleMatchesInvariant(content, id) {
|
|
12
54
|
// Match describe/it/test string titles containing the invariant id.
|
|
13
55
|
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -39,20 +81,49 @@ export function evaluateInvariantCoverage(input) {
|
|
|
39
81
|
const testFiles = input.testFiles ?? [];
|
|
40
82
|
const testGlobsMissing = input.testGlobsMissing === true || testFiles.length === 0;
|
|
41
83
|
const coverageBudgetExhausted = input.coverageBudgetExhausted === true;
|
|
84
|
+
const stats = input.coverageStats;
|
|
85
|
+
const discardTail = formatCoverageDiscards(stats);
|
|
86
|
+
// The budget-exhausted sentence already carries the cap, the load and the
|
|
87
|
+
// discards at the cap, so its tail reports only the other discard reasons.
|
|
88
|
+
const budgetExhaustedTail = formatCoverageDiscards(stats, true);
|
|
89
|
+
// Numbers, not adjectives: a budget-exhausted verdict must say how big the
|
|
90
|
+
// budget was, what it bought, and which knob raises it.
|
|
91
|
+
const budgetDetail = stats
|
|
92
|
+
? `coverage file budget exhausted: ${stats.filesLoaded} files loaded at the ${stats.maxFiles}-file cap, ${stats.testFilesRetained} tests retained, ${stats.discarded.budget} files discarded at the cap; raise "coverage.maxFiles" in ark.config.json (the cap bounds files RETAINED as evidence${typeof stats.filesRead === 'number' ? `; ${stats.filesRead} were read` : ''})`
|
|
93
|
+
: 'coverage file budget exhausted';
|
|
94
|
+
const coverageRoots = (input.coverageRoots ?? []).filter((root) => typeof root === 'string' && root.length > 0);
|
|
95
|
+
const rootsDeclared = coverageRoots.length > 0;
|
|
96
|
+
const declaredRootsList = coverageRoots.join(', ');
|
|
42
97
|
const coverage = [];
|
|
43
98
|
const violations = [];
|
|
44
99
|
for (const inv of invariants) {
|
|
45
100
|
const evidence = [];
|
|
46
101
|
const wantsTest = inv.coverage?.test !== false; // default: prefer test evidence when catalogued
|
|
47
102
|
const symbol = inv.coverage?.symbol;
|
|
103
|
+
let testEvidenceFile;
|
|
104
|
+
let outsideDeclaredRoots;
|
|
48
105
|
if (!testGlobsMissing && wantsTest) {
|
|
106
|
+
// A covering test INSIDE a declared root wins over one outside it: the
|
|
107
|
+
// finding is "the only proof lives where the runner does not go", not
|
|
108
|
+
// "some proof lives there".
|
|
109
|
+
let fallbackOutside;
|
|
49
110
|
for (const file of testFiles) {
|
|
50
111
|
const content = input.fileContents[file];
|
|
51
|
-
if (content
|
|
52
|
-
|
|
112
|
+
if (!content || !titleMatchesInvariant(content, inv.id))
|
|
113
|
+
continue;
|
|
114
|
+
if (!rootsDeclared || isUnderCoverageRoot(file, coverageRoots)) {
|
|
115
|
+
testEvidenceFile = file;
|
|
116
|
+
outsideDeclaredRoots = rootsDeclared ? false : undefined;
|
|
53
117
|
break;
|
|
54
118
|
}
|
|
119
|
+
fallbackOutside ??= file;
|
|
120
|
+
}
|
|
121
|
+
if (testEvidenceFile === undefined && fallbackOutside !== undefined) {
|
|
122
|
+
testEvidenceFile = fallbackOutside;
|
|
123
|
+
outsideDeclaredRoots = true;
|
|
55
124
|
}
|
|
125
|
+
if (testEvidenceFile !== undefined)
|
|
126
|
+
evidence.push('test-title');
|
|
56
127
|
}
|
|
57
128
|
if (symbol && symbolPresent(input.fileContents, symbol)) {
|
|
58
129
|
evidence.push('symbol');
|
|
@@ -76,20 +147,44 @@ export function evaluateInvariantCoverage(input) {
|
|
|
76
147
|
evidence,
|
|
77
148
|
partial,
|
|
78
149
|
description: inv.description,
|
|
150
|
+
...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
|
|
151
|
+
...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
|
|
79
152
|
});
|
|
153
|
+
// The covering test exists but sits outside the roots the project declared
|
|
154
|
+
// its runner walks. ArkGate does not execute tests, so it cannot tell the
|
|
155
|
+
// difference — it can only report that the two declarations disagree.
|
|
156
|
+
if (outsideDeclaredRoots === true && testEvidenceFile !== undefined) {
|
|
157
|
+
violations.push({
|
|
158
|
+
ruleId: 'INVARIANT_COVERAGE_OUTSIDE_ROOTS',
|
|
159
|
+
message: `Invariant ${inv.id} is covered only by ${testEvidenceFile}, which is outside the declared coverage roots (${declaredRootsList}). ` +
|
|
160
|
+
'ArkGate matches declared text and never executes tests, so it cannot tell whether that file is run: move the test under a declared root, or add its root to "coverage.coverageRoots" in ark.config.json.',
|
|
161
|
+
file: testEvidenceFile,
|
|
162
|
+
line: 1,
|
|
163
|
+
arkruleId: inv.id,
|
|
164
|
+
arkruleSource: inv.provenance.sourceFile,
|
|
165
|
+
fromLayer: inv.provenance.layer,
|
|
166
|
+
severity: 'warning',
|
|
167
|
+
failsStrict: false,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
80
170
|
if (!covered || partial) {
|
|
81
171
|
// Enforced + proven uncovered → failsStrict; partial always advisory (never fake green).
|
|
82
172
|
const failsStrict = inv.mode === 'enforced' && !partial;
|
|
83
173
|
const kind = testGlobsMissing || testFiles.length === 0 ? 'never-had-tests' : 'tests-disappeared';
|
|
84
174
|
violations.push({
|
|
85
175
|
ruleId: 'INVARIANT_UNCOVERED',
|
|
86
|
-
message: partial
|
|
176
|
+
message: (partial
|
|
87
177
|
? coverageBudgetExhausted
|
|
88
|
-
? `Invariant ${inv.id} coverage cannot be proven (
|
|
178
|
+
? `Invariant ${inv.id} coverage cannot be proven (${budgetDetail}); reporting partial, not covered.`
|
|
89
179
|
: `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
|
|
90
|
-
:
|
|
91
|
-
|
|
92
|
-
|
|
180
|
+
: // Say what was actually checked. "Not covered by a test
|
|
181
|
+
// title" reads as "there is no test", and its inverse
|
|
182
|
+
// reads as "there is a test and it runs" — neither is
|
|
183
|
+
// something a text match can know.
|
|
184
|
+
kind === 'tests-disappeared'
|
|
185
|
+
? `Invariant ${inv.id}: no scanned test names it in a describe/it title and no declared symbol was found (tests-disappeared — a suite exists). ArkGate matches declared text; it never executes tests.`
|
|
186
|
+
: `Invariant ${inv.id}: no scanned test names it in a describe/it title and no declared symbol was found (never-had-tests — the scan found no tests at all). ArkGate matches declared text; it never executes tests.`) +
|
|
187
|
+
(partial && coverageBudgetExhausted ? budgetExhaustedTail : discardTail),
|
|
93
188
|
file: inv.provenance.sourceFile,
|
|
94
189
|
line: 1,
|
|
95
190
|
arkruleId: inv.id,
|
|
@@ -130,5 +225,13 @@ export function canPromoteInvariant(coverage) {
|
|
|
130
225
|
reason: `Invariant ${coverage.invariantId} is uncovered; add a test title or symbol before promoting to enforced.`,
|
|
131
226
|
};
|
|
132
227
|
}
|
|
228
|
+
// Promotion is the moment coverage stops being advice, so an evidence file
|
|
229
|
+
// the project itself says its runner does not walk cannot carry it.
|
|
230
|
+
if (coverage.outsideDeclaredRoots === true) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
reason: `Invariant ${coverage.invariantId} is covered only by ${coverage.testEvidenceFile ?? 'a test'}, outside the declared coverage roots; ArkGate cannot tell whether that test runs, so it will not promote on it.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
133
236
|
return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
|
|
134
237
|
}
|