arkgate 4.8.20 → 4.8.22
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 +172 -5
- package/README.md +107 -117
- package/bin/ark-check-runtime.mjs +3 -4
- package/bin/ark-layer-match.mjs +110 -18
- package/bin/lib/analysis-engine.mjs +13 -6
- package/bin/lib/config-contract.mjs +5 -0
- package/bin/lib/diagnostic-catalog.mjs +1 -0
- package/bin/lib/doctor-advisories.mjs +20 -0
- package/bin/lib/doctor-human.mjs +11 -9
- package/bin/lib/doctor-plan.mjs +15 -14
- package/bin/lib/first-run-help.mjs +7 -1
- package/bin/lib/html-report-advisories.mjs +8 -0
- package/bin/lib/html-report-depth.mjs +9 -8
- package/bin/lib/invariant-coverage.mjs +417 -72
- package/bin/lib/managed-upgrade.mjs +59 -14
- package/bin/lib/pilot-loop.mjs +183 -37
- package/bin/lib/upgrade-behavior-changes.mjs +83 -0
- package/bin/lib/upgrade-command.mjs +69 -31
- package/bin/lib/upgrade-outcome.mjs +359 -0
- package/bin/lib/violations.mjs +109 -0
- package/dist/{configTypes-VVknqcTJ.d.ts → configTypes-CTELo87-.d.ts} +7 -0
- package/dist/{diagnosticCatalog-BPYWNyJR.d.ts → diagnosticCatalog-WD2TPSwZ.d.ts} +78 -5
- package/dist/eslint/index.cjs +4 -4
- package/dist/eslint/index.d.ts +26 -3
- package/dist/eslint/index.js +4 -4
- package/dist/index.cjs +32 -25
- package/dist/index.d.ts +9 -4
- package/dist/index.js +33 -26
- package/dist/nestjs/index.cjs +9 -9
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +9 -9
- package/dist/runtime/index.cjs +22 -15
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +22 -15
- package/dist/{types-p_fCp6P-.d.ts → types-fQ9-KrBj.d.ts} +1 -1
- package/dist/{types-Cs-46DRu.d.ts → types-yDNEQ603.d.ts} +1 -1
- package/docs/README.md +5 -3
- package/docs/agent-guide.md +15 -7
- package/docs/brownfield-adoption.md +4 -3
- package/docs/configuration.md +34 -11
- package/docs/diagnostics.md +12 -2
- package/docs/package-surface.md +3 -2
- package/docs/product-voice.md +11 -3
- package/package.json +2 -1
- package/schemas/ark.config.schema.json +8 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -2
- package/templates/agent-skills/ark-architect/SKILL.md +2 -0
- package/templates/agent-skills/ark-autopilot/SKILL.md +6 -4
- package/templates/agent-skills/ark-contract/SKILL.md +2 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
- package/templates/agent-skills/ark-explain/SKILL.md +2 -0
- package/templates/agent-skills/ark-explore/SKILL.md +4 -2
- package/templates/agent-skills/ark-fix/SKILL.md +2 -0
- package/templates/agent-skills/ark-loop/SKILL.md +2 -0
- package/templates/agent-skills/ark-order/SKILL.md +2 -0
- package/templates/agent-skills/ark-place/SKILL.md +2 -0
- package/templates/agent-skills/ark-runtime/SKILL.md +2 -0
- package/templates/agent-skills/ark-think/SKILL.md +2 -0
- package/templates/agent-skills/ark-upgrade/SKILL.md +20 -7
- package/templates/skills/ark-adopt.md +5 -2
- package/templates/skills/ark-architect.md +2 -0
- package/templates/skills/ark-autopilot.md +6 -4
- package/templates/skills/ark-contract.md +2 -0
- package/templates/skills/ark-coverage.md +2 -0
- package/templates/skills/ark-explain.md +2 -0
- package/templates/skills/ark-explore.md +4 -2
- package/templates/skills/ark-fix.md +2 -0
- package/templates/skills/ark-loop.md +2 -0
- package/templates/skills/ark-order.md +2 -0
- package/templates/skills/ark-place.md +2 -0
- package/templates/skills/ark-runtime.md +2 -0
- package/templates/skills/ark-think.md +2 -0
- package/templates/skills/ark-upgrade.md +20 -7
|
@@ -56,56 +56,411 @@ function isUnderCoverageRoot(file, roots) {
|
|
|
56
56
|
return target === root || target.startsWith(`${root}/`);
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
function titleMatchesInvariant(content, id) {
|
|
60
|
-
// Match describe/it/test string titles containing the invariant id.
|
|
61
|
-
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
62
|
-
const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*['"\`][^'"\`]*${escaped}[^'"\`]*['"\`]`, 'i');
|
|
63
|
-
return re.test(content) || content.includes(id);
|
|
64
|
-
}
|
|
65
59
|
function escapeRegExp(value) {
|
|
66
60
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
61
|
}
|
|
62
|
+
function normalizePath(file) {
|
|
63
|
+
return file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
64
|
+
}
|
|
68
65
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* the name that must be declared.
|
|
66
|
+
* Blank comments, and optionally strings, with spaces so a regex cannot treat
|
|
67
|
+
* a comment or a quoted mention as code. Newlines stay so line structure holds.
|
|
68
|
+
* `//` and `/*` inside a string are not comments.
|
|
73
69
|
*/
|
|
74
|
-
function
|
|
70
|
+
function maskNonCode(content, blankStrings) {
|
|
71
|
+
const out = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
const n = content.length;
|
|
74
|
+
while (i < n) {
|
|
75
|
+
const c = content[i];
|
|
76
|
+
const next = content[i + 1];
|
|
77
|
+
if (c === '/' && next === '/') {
|
|
78
|
+
while (i < n && content[i] !== '\n') {
|
|
79
|
+
out.push(' ');
|
|
80
|
+
i += 1;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === '/' && next === '*') {
|
|
85
|
+
out.push(' ', ' ');
|
|
86
|
+
i += 2;
|
|
87
|
+
while (i < n && !(content[i] === '*' && content[i + 1] === '/')) {
|
|
88
|
+
out.push(content[i] === '\n' ? '\n' : ' ');
|
|
89
|
+
i += 1;
|
|
90
|
+
}
|
|
91
|
+
if (i < n) {
|
|
92
|
+
out.push(' ', ' ');
|
|
93
|
+
i += 2;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
98
|
+
const quote = c;
|
|
99
|
+
out.push(blankStrings ? ' ' : c);
|
|
100
|
+
i += 1;
|
|
101
|
+
while (i < n) {
|
|
102
|
+
const ch = content[i];
|
|
103
|
+
if (ch === '\\') {
|
|
104
|
+
const escaped = content[i + 1];
|
|
105
|
+
if (blankStrings) {
|
|
106
|
+
out.push(' ', escaped === undefined ? '' : ' ');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
out.push('\\');
|
|
110
|
+
if (escaped !== undefined)
|
|
111
|
+
out.push(escaped);
|
|
112
|
+
}
|
|
113
|
+
i += escaped === undefined ? 1 : 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === quote) {
|
|
117
|
+
out.push(blankStrings ? ' ' : ch);
|
|
118
|
+
i += 1;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
out.push(blankStrings && ch !== '\n' ? ' ' : ch);
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
out.push(c);
|
|
127
|
+
i += 1;
|
|
128
|
+
}
|
|
129
|
+
return out.join('');
|
|
130
|
+
}
|
|
131
|
+
/** Title string when `id` sits inside a describe/it/test/context title. Comments do not count. */
|
|
132
|
+
function matchTestTitle(content, id) {
|
|
133
|
+
if (!id)
|
|
134
|
+
return undefined;
|
|
135
|
+
const escaped = escapeRegExp(id);
|
|
136
|
+
const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*(['"\`])((?:(?!\\1)[^\\\\]|\\\\.)*?${escaped}(?:(?!\\1)[^\\\\]|\\\\.)*?)\\1`, 'i');
|
|
137
|
+
const match = re.exec(maskNonCode(content, false));
|
|
138
|
+
const title = match?.[2];
|
|
139
|
+
return title === undefined ? undefined : title;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Declaration shape of `name`, or undefined. Imports and calls do not count.
|
|
143
|
+
* `export const name =` is the binding shape sensors already treat as a
|
|
144
|
+
* declaration (arrow or function). Comments and strings are masked first so
|
|
145
|
+
* `// type Name` is not a declaration.
|
|
146
|
+
*/
|
|
147
|
+
function declarationShape(content, name) {
|
|
148
|
+
if (!name)
|
|
149
|
+
return undefined;
|
|
75
150
|
const n = escapeRegExp(name);
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
151
|
+
const code = maskNonCode(content, true);
|
|
152
|
+
const shapes = [
|
|
153
|
+
{
|
|
154
|
+
shape: 'function',
|
|
155
|
+
source: `(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:declare\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${n}\\s*[(<]`,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
shape: 'class',
|
|
159
|
+
source: `(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:abstract\\s+)?class\\s+${n}\\b`,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
shape: 'const',
|
|
163
|
+
source: `(?:^|[\\s;{}])export\\s+(?:const|let|var)\\s+${n}(?:\\s*:(?:[^=]|=>)+?)?\\s*=(?!>)`,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
shape: 'type',
|
|
167
|
+
source: `(?:^|[\\s;{}])(?:export\\s+)?(?:declare\\s+)?type\\s+${n}\\b`,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
shape: 'interface',
|
|
171
|
+
source: `(?:^|[\\s;{}])(?:export\\s+)?(?:declare\\s+)?interface\\s+${n}\\b`,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
shape: 'enum',
|
|
175
|
+
source: `(?:^|[\\s;{}])(?:export\\s+)?(?:declare\\s+)?(?:const\\s+)?enum\\s+${n}\\b`,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
shape: 'method',
|
|
179
|
+
source: `(?:^|[\\n;{}])\\s*(?:(?:public|private|protected|static|async|readonly|override|abstract|get|set|declare)\\s+)*${n}\\s*(?:<[^>\\n]*>)?\\s*\\([^;{}]*\\)\\s*(?::\\s*[^;{]+)?\\s*\\{`,
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
for (const entry of shapes) {
|
|
183
|
+
if (new RegExp(entry.source).test(code))
|
|
184
|
+
return entry.shape;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
function lineAt(content, index) {
|
|
189
|
+
const start = content.lastIndexOf('\n', index - 1) + 1;
|
|
190
|
+
const end = content.indexOf('\n', index);
|
|
191
|
+
return content.slice(start, end === -1 ? content.length : end);
|
|
192
|
+
}
|
|
193
|
+
function isImportLine(line) {
|
|
194
|
+
const trimmed = line.trim();
|
|
195
|
+
if (/^import\b/.test(trimmed))
|
|
196
|
+
return true;
|
|
197
|
+
if (/^export\s+/.test(trimmed) && /\bfrom\b/.test(trimmed))
|
|
198
|
+
return true;
|
|
199
|
+
return /\brequire\s*\(/.test(trimmed);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Context of a bare mention. Heuristic: comment, string, import line, or
|
|
203
|
+
* other text in a test file. A call in a source file is not labeled — the
|
|
204
|
+
* verdict stays `none` ("no declared symbol") rather than a guessed context.
|
|
205
|
+
* When several contexts appear, comment wins, then string, then test body,
|
|
206
|
+
* then import.
|
|
207
|
+
*/
|
|
208
|
+
function mentionContext(content, needle, isTest) {
|
|
209
|
+
if (!needle || !content.includes(needle))
|
|
210
|
+
return undefined;
|
|
211
|
+
const found = new Set();
|
|
212
|
+
let i = 0;
|
|
213
|
+
const n = content.length;
|
|
214
|
+
const note = (index, zone) => {
|
|
215
|
+
if (!content.startsWith(needle, index))
|
|
216
|
+
return;
|
|
217
|
+
if (zone === 'comment') {
|
|
218
|
+
found.add('comment');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (zone === 'string') {
|
|
222
|
+
found.add('string');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (isImportLine(lineAt(content, index))) {
|
|
226
|
+
found.add('import');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (isTest)
|
|
230
|
+
found.add('test-body');
|
|
231
|
+
};
|
|
232
|
+
while (i < n) {
|
|
233
|
+
const c = content[i];
|
|
234
|
+
const next = content[i + 1];
|
|
235
|
+
if (c === '/' && next === '/') {
|
|
236
|
+
while (i < n && content[i] !== '\n') {
|
|
237
|
+
note(i, 'comment');
|
|
238
|
+
i += 1;
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (c === '/' && next === '*') {
|
|
243
|
+
note(i, 'comment');
|
|
244
|
+
note(i + 1, 'comment');
|
|
245
|
+
i += 2;
|
|
246
|
+
while (i < n && !(content[i] === '*' && content[i + 1] === '/')) {
|
|
247
|
+
note(i, 'comment');
|
|
248
|
+
i += 1;
|
|
249
|
+
}
|
|
250
|
+
if (i < n) {
|
|
251
|
+
note(i, 'comment');
|
|
252
|
+
note(i + 1, 'comment');
|
|
253
|
+
i += 2;
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
258
|
+
const quote = c;
|
|
259
|
+
note(i, 'string');
|
|
260
|
+
i += 1;
|
|
261
|
+
while (i < n) {
|
|
262
|
+
const ch = content[i];
|
|
263
|
+
if (ch === '\\') {
|
|
264
|
+
note(i, 'string');
|
|
265
|
+
if (content[i + 1] !== undefined)
|
|
266
|
+
note(i + 1, 'string');
|
|
267
|
+
i += content[i + 1] === undefined ? 1 : 2;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (ch === quote) {
|
|
271
|
+
note(i, 'string');
|
|
272
|
+
i += 1;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
note(i, 'string');
|
|
276
|
+
i += 1;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
note(i, 'code');
|
|
281
|
+
i += 1;
|
|
282
|
+
}
|
|
283
|
+
if (found.has('comment'))
|
|
284
|
+
return 'comment';
|
|
285
|
+
if (found.has('string'))
|
|
286
|
+
return 'string';
|
|
287
|
+
if (found.has('test-body'))
|
|
288
|
+
return 'test-body';
|
|
289
|
+
if (found.has('import'))
|
|
290
|
+
return 'import';
|
|
291
|
+
return undefined;
|
|
84
292
|
}
|
|
85
|
-
/**
|
|
86
|
-
function
|
|
293
|
+
/** Last segment of `Aggregate.method` is the name that must be declared. */
|
|
294
|
+
function symbolNeedle(symbol) {
|
|
87
295
|
if (!symbol)
|
|
88
296
|
return undefined;
|
|
89
297
|
const parts = symbol.split('.');
|
|
90
|
-
const
|
|
91
|
-
if (!
|
|
298
|
+
const name = parts[parts.length - 1] ?? '';
|
|
299
|
+
if (!name)
|
|
92
300
|
return undefined;
|
|
93
|
-
const className = parts.length > 1 ? parts[0] : null;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
301
|
+
const className = parts.length > 1 ? (parts[0] ?? null) : null;
|
|
302
|
+
return { className, name };
|
|
303
|
+
}
|
|
304
|
+
function declaredLabel(invariant) {
|
|
305
|
+
return symbolNeedle(invariant.coverage?.symbol)?.name ?? invariant.id;
|
|
306
|
+
}
|
|
307
|
+
function sortedContentFiles(fileContents) {
|
|
308
|
+
return Object.keys(fileContents).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* One scan. `scanTests` is false when test globs are missing: a title in the
|
|
312
|
+
* loaded map must not prove a walk the scan did not claim. Test paths are
|
|
313
|
+
* still excluded from declaration search.
|
|
314
|
+
*/
|
|
315
|
+
function rankCoverage(invariant, files, scanTests = true) {
|
|
316
|
+
const testFiles = files.testFiles ?? [];
|
|
317
|
+
const tests = new Set(testFiles.map((file) => normalizePath(file)));
|
|
318
|
+
const roots = (files.coverageRoots ?? []).filter((root) => typeof root === 'string' && root.length > 0);
|
|
319
|
+
const wantsTest = invariant.coverage?.test !== false && scanTests;
|
|
320
|
+
const needle = symbolNeedle(invariant.coverage?.symbol ?? undefined);
|
|
321
|
+
let testTitle;
|
|
322
|
+
let outsideTitle;
|
|
323
|
+
if (wantsTest) {
|
|
324
|
+
for (const file of testFiles) {
|
|
325
|
+
const content = files.fileContents[file];
|
|
326
|
+
if (!content)
|
|
327
|
+
continue;
|
|
328
|
+
const title = matchTestTitle(content, invariant.id);
|
|
329
|
+
if (title === undefined)
|
|
330
|
+
continue;
|
|
331
|
+
if (roots.length === 0 || isUnderCoverageRoot(file, roots)) {
|
|
332
|
+
testTitle = { file, title };
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
outsideTitle ??= { file, title };
|
|
336
|
+
}
|
|
337
|
+
testTitle ??= outsideTitle;
|
|
338
|
+
}
|
|
339
|
+
let declaration;
|
|
340
|
+
if (needle) {
|
|
341
|
+
for (const file of sortedContentFiles(files.fileContents)) {
|
|
342
|
+
if (tests.has(normalizePath(file)))
|
|
343
|
+
continue;
|
|
344
|
+
const content = files.fileContents[file];
|
|
345
|
+
if (!content)
|
|
346
|
+
continue;
|
|
347
|
+
if (needle.className && !content.includes(needle.className))
|
|
348
|
+
continue;
|
|
349
|
+
const shape = declarationShape(content, needle.name);
|
|
350
|
+
if (!shape)
|
|
351
|
+
continue;
|
|
352
|
+
declaration = { file, shape };
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
let mention;
|
|
357
|
+
if (!testTitle && !declaration) {
|
|
358
|
+
if (wantsTest) {
|
|
359
|
+
for (const file of testFiles) {
|
|
360
|
+
const content = files.fileContents[file];
|
|
361
|
+
if (!content)
|
|
362
|
+
continue;
|
|
363
|
+
const context = mentionContext(content, invariant.id, true);
|
|
364
|
+
if (!context)
|
|
365
|
+
continue;
|
|
366
|
+
mention = { file, context };
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!mention && needle) {
|
|
371
|
+
for (const file of sortedContentFiles(files.fileContents)) {
|
|
372
|
+
if (tests.has(normalizePath(file)))
|
|
373
|
+
continue;
|
|
374
|
+
const content = files.fileContents[file];
|
|
375
|
+
if (!content)
|
|
376
|
+
continue;
|
|
377
|
+
const context = mentionContext(content, needle.name, false);
|
|
378
|
+
if (!context)
|
|
379
|
+
continue;
|
|
380
|
+
mention = { file, context };
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
let best = testTitle
|
|
386
|
+
? { kind: 'test-title', file: testTitle.file, title: testTitle.title }
|
|
387
|
+
: declaration
|
|
388
|
+
? { kind: 'declaration', file: declaration.file, shape: declaration.shape }
|
|
389
|
+
: mention
|
|
390
|
+
? { kind: 'mention-only', file: mention.file, context: mention.context }
|
|
391
|
+
: { kind: 'none' };
|
|
392
|
+
if (best.kind === 'mention-only' &&
|
|
393
|
+
best.context === 'comment' &&
|
|
394
|
+
needle &&
|
|
395
|
+
symbolInCodeElsewhere(files.fileContents, tests, needle.name, best.file)) {
|
|
396
|
+
best = { kind: 'declaration-miss', symbol: needle.name };
|
|
397
|
+
}
|
|
398
|
+
return { best, ...(testTitle ? { testTitle } : {}), ...(declaration ? { declaration } : {}) };
|
|
399
|
+
}
|
|
400
|
+
/** True when `name` survives comment/string masking in some non-test file other than `mentionFile`. */
|
|
401
|
+
function symbolInCodeElsewhere(fileContents, tests, name, mentionFile) {
|
|
402
|
+
const needle = new RegExp(`(?:^|[^A-Za-z0-9_$])${escapeRegExp(name)}(?=$|[^A-Za-z0-9_$])`);
|
|
403
|
+
const mention = normalizePath(mentionFile);
|
|
404
|
+
for (const file of Object.keys(fileContents)) {
|
|
405
|
+
if (tests.has(normalizePath(file)) || normalizePath(file) === mention)
|
|
99
406
|
continue;
|
|
100
407
|
const content = fileContents[file];
|
|
101
408
|
if (!content)
|
|
102
409
|
continue;
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
410
|
+
if (needle.test(maskNonCode(content, true)))
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Best coverage evidence for `invariant` in `files`.
|
|
417
|
+
* test-title beats a declaration; a declaration beats a bare mention; a bare
|
|
418
|
+
* mention beats silence. Callers do not re-scan to explain the verdict.
|
|
419
|
+
*/
|
|
420
|
+
export function classifyCoverage(invariant, files) {
|
|
421
|
+
return rankCoverage(invariant, files).best;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Policy: a test title or any declaration counts, including type, interface,
|
|
425
|
+
* and enum. A mention-only verdict never counts. `none` does not count.
|
|
426
|
+
*/
|
|
427
|
+
export function countsAsCoverage(ev) {
|
|
428
|
+
switch (ev.kind) {
|
|
429
|
+
case 'test-title':
|
|
430
|
+
case 'declaration':
|
|
431
|
+
return true;
|
|
432
|
+
case 'mention-only':
|
|
433
|
+
case 'declaration-miss':
|
|
434
|
+
case 'none':
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function mentionWhere(context) {
|
|
439
|
+
switch (context) {
|
|
440
|
+
case 'test-body':
|
|
441
|
+
return 'a test body';
|
|
442
|
+
case 'comment':
|
|
443
|
+
return 'a comment';
|
|
444
|
+
case 'string':
|
|
445
|
+
return 'a string';
|
|
446
|
+
case 'import':
|
|
447
|
+
return 'an import';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** Sentence for the verdict. `INVARIANT_UNCOVERED` prints this; it does not re-derive it. */
|
|
451
|
+
export function describeCoverage(invariant, ev) {
|
|
452
|
+
switch (ev.kind) {
|
|
453
|
+
case 'test-title':
|
|
454
|
+
return `found \`${ev.title}\` in a describe/it title in ${ev.file}`;
|
|
455
|
+
case 'declaration':
|
|
456
|
+
return `found \`${ev.shape} ${declaredLabel(invariant)}\` in ${ev.file}`;
|
|
457
|
+
case 'mention-only':
|
|
458
|
+
return `${invariant.id} appears only in ${mentionWhere(ev.context)} in ${ev.file}; put it in a describe/it title`;
|
|
459
|
+
case 'declaration-miss':
|
|
460
|
+
return `no declaration matched \`${ev.symbol}\``;
|
|
461
|
+
case 'none':
|
|
462
|
+
return 'no scanned test names it in a describe/it title and no declared symbol was found';
|
|
107
463
|
}
|
|
108
|
-
return undefined;
|
|
109
464
|
}
|
|
110
465
|
export function evaluateInvariantCoverage(input) {
|
|
111
466
|
const invariants = input.arkRules.invariants ?? [];
|
|
@@ -131,49 +486,40 @@ export function evaluateInvariantCoverage(input) {
|
|
|
131
486
|
const coverage = [];
|
|
132
487
|
const violations = [];
|
|
133
488
|
for (const inv of invariants) {
|
|
134
|
-
const evidence = [];
|
|
135
489
|
const wantsTest = inv.coverage?.test !== false; // default: prefer test evidence when catalogued
|
|
136
490
|
const symbol = inv.coverage?.symbol;
|
|
491
|
+
// Missing globs: do not let a loaded title prove a walk we did not claim.
|
|
492
|
+
const scanTests = !testGlobsMissing && wantsTest;
|
|
493
|
+
const ranked = rankCoverage(inv, { fileContents: input.fileContents, testFiles, coverageRoots }, scanTests);
|
|
494
|
+
const ev = ranked.best;
|
|
495
|
+
// Legacy `evidence` is derivable from the same scan: counting kinds only.
|
|
496
|
+
// A test title and a declaration can both be present; mention-only is absent.
|
|
497
|
+
const evidence = [];
|
|
498
|
+
if (scanTests && ranked.testTitle)
|
|
499
|
+
evidence.push('test-title');
|
|
500
|
+
if (ranked.declaration)
|
|
501
|
+
evidence.push('symbol');
|
|
502
|
+
const counts = countsAsCoverage(ev);
|
|
137
503
|
let testEvidenceFile;
|
|
138
504
|
let outsideDeclaredRoots;
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let fallbackOutside;
|
|
144
|
-
for (const file of testFiles) {
|
|
145
|
-
const content = input.fileContents[file];
|
|
146
|
-
if (!content || !titleMatchesInvariant(content, inv.id))
|
|
147
|
-
continue;
|
|
148
|
-
if (!rootsDeclared || isUnderCoverageRoot(file, coverageRoots)) {
|
|
149
|
-
testEvidenceFile = file;
|
|
150
|
-
outsideDeclaredRoots = rootsDeclared ? false : undefined;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
fallbackOutside ??= file;
|
|
154
|
-
}
|
|
155
|
-
if (testEvidenceFile === undefined && fallbackOutside !== undefined) {
|
|
156
|
-
testEvidenceFile = fallbackOutside;
|
|
157
|
-
outsideDeclaredRoots = true;
|
|
505
|
+
if (scanTests && ranked.testTitle) {
|
|
506
|
+
testEvidenceFile = ranked.testTitle.file;
|
|
507
|
+
if (rootsDeclared) {
|
|
508
|
+
outsideDeclaredRoots = !isUnderCoverageRoot(testEvidenceFile, coverageRoots);
|
|
158
509
|
}
|
|
159
|
-
if (testEvidenceFile !== undefined)
|
|
160
|
-
evidence.push('test-title');
|
|
161
510
|
}
|
|
162
|
-
const symbolEvidenceFile =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (symbolEvidenceFile)
|
|
166
|
-
evidence.push('symbol');
|
|
167
|
-
// Covered if any requested evidence is present.
|
|
511
|
+
const symbolEvidenceFile = ranked.declaration?.file;
|
|
512
|
+
const shape = ranked.declaration?.shape;
|
|
513
|
+
// Covered if the policy says this verdict counts.
|
|
168
514
|
// When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
|
|
169
515
|
const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
|
|
170
|
-
const covered = requiresEvidence &&
|
|
516
|
+
const covered = requiresEvidence && counts
|
|
171
517
|
? true
|
|
172
518
|
: inv.coverage?.test === false && !symbol
|
|
173
519
|
? true // explicitly no coverage requirements
|
|
174
|
-
:
|
|
520
|
+
: counts;
|
|
175
521
|
// Partial only when tests are missing *and* no other evidence (e.g. symbol) completed coverage.
|
|
176
|
-
const partial = testGlobsMissing && wantsTest &&
|
|
522
|
+
const partial = testGlobsMissing && wantsTest && !counts;
|
|
177
523
|
coverage.push({
|
|
178
524
|
invariantId: inv.id,
|
|
179
525
|
layer: inv.provenance.layer,
|
|
@@ -185,6 +531,7 @@ export function evaluateInvariantCoverage(input) {
|
|
|
185
531
|
description: inv.description,
|
|
186
532
|
...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
|
|
187
533
|
...(symbolEvidenceFile !== undefined ? { symbolEvidenceFile } : {}),
|
|
534
|
+
...(shape !== undefined ? { shape } : {}),
|
|
188
535
|
...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
|
|
189
536
|
coverageRootsDeclared: rootsDeclared,
|
|
190
537
|
});
|
|
@@ -215,13 +562,11 @@ export function evaluateInvariantCoverage(input) {
|
|
|
215
562
|
? coverageBudgetExhausted
|
|
216
563
|
? `Invariant ${inv.id} coverage cannot be proven (${budgetDetail}); reporting partial, not covered.`
|
|
217
564
|
: `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
|
|
218
|
-
: //
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
? `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.`
|
|
224
|
-
: `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.`) +
|
|
565
|
+
: // The sentence is the verdict. It names a title, a declaration
|
|
566
|
+
// shape, a bare mention, or silence — it does not re-scan.
|
|
567
|
+
`Invariant ${inv.id}: ${describeCoverage(inv, ev)} (${kind === 'tests-disappeared'
|
|
568
|
+
? 'tests-disappeared — a suite exists'
|
|
569
|
+
: 'never-had-tests — the scan found no tests at all'}). ArkGate matches declared text; it never executes tests.`) +
|
|
225
570
|
(partial && coverageBudgetExhausted ? budgetExhaustedTail : discardTail),
|
|
226
571
|
file: inv.provenance.sourceFile,
|
|
227
572
|
line: 1,
|