arkgate 4.8.19 → 4.8.21
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 +220 -0
- package/README.md +107 -117
- package/bin/ark-check-runtime.mjs +26 -4
- package/bin/ark-layer-match.mjs +236 -12
- package/bin/ark.mjs +6 -2
- package/bin/lib/analysis-engine.mjs +13 -6
- package/bin/lib/config-contract.mjs +6 -0
- package/bin/lib/diagnostic-catalog.mjs +1 -0
- package/bin/lib/doctor-advisories.mjs +22 -1
- package/bin/lib/doctor-human.mjs +11 -9
- package/bin/lib/doctor-plan.mjs +17 -14
- package/bin/lib/enforcement-honesty.mjs +30 -2
- 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 +12 -7
- package/bin/lib/invariant-coverage.mjs +406 -54
- package/bin/lib/managed-upgrade.mjs +59 -14
- package/bin/lib/package-manager.mjs +5 -2
- package/bin/lib/physical-cohesion.mjs +68 -51
- package/bin/lib/pilot-loop.mjs +183 -37
- package/bin/lib/reshape-decisions.mjs +2 -2
- package/bin/lib/rules-under-contract.mjs +40 -4
- package/bin/lib/start-install-recovery.mjs +156 -0
- package/bin/lib/start-preview.mjs +0 -54
- 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-VD0qcubY.d.ts → configTypes-CTELo87-.d.ts} +9 -0
- package/dist/{diagnosticCatalog-D2v0ASN2.d.ts → diagnosticCatalog-B7BXlet6.d.ts} +79 -5
- package/dist/eslint/index.cjs +4 -4
- package/dist/eslint/index.d.ts +35 -4
- package/dist/eslint/index.js +4 -4
- package/dist/index.cjs +25 -18
- package/dist/index.d.ts +13 -4
- package/dist/index.js +27 -20
- 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 +18 -11
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +18 -11
- package/dist/{types-D5GT5ZT8.d.ts → types-fQ9-KrBj.d.ts} +1 -1
- package/dist/{types-BSzRy2X1.d.ts → types-yDNEQ603.d.ts} +1 -1
- package/docs/README.md +4 -3
- package/docs/agent-guide.md +15 -7
- package/docs/brownfield-adoption.md +4 -3
- package/docs/configuration.md +46 -8
- package/docs/develop.md +1 -1
- package/docs/diagnostics.md +12 -2
- package/docs/package-surface.md +4 -3
- package/docs/product-voice.md +11 -3
- package/docs/use.md +1 -1
- package/package.json +3 -3
- package/schemas/ark.config.schema.json +14 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +18 -1
- package/templates/agent-skills/ark-architect/SKILL.md +2 -0
- package/templates/agent-skills/ark-autopilot/SKILL.md +6 -2
- package/templates/agent-skills/ark-contract/SKILL.md +2 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +3 -1
- package/templates/agent-skills/ark-explain/SKILL.md +3 -1
- package/templates/agent-skills/ark-explore/SKILL.md +15 -1
- 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 +25 -10
- package/templates/skills/ark-adopt.md +18 -1
- package/templates/skills/ark-architect.md +2 -0
- package/templates/skills/ark-autopilot.md +6 -2
- package/templates/skills/ark-contract.md +2 -0
- package/templates/skills/ark-coverage.md +3 -1
- package/templates/skills/ark-explain.md +3 -1
- package/templates/skills/ark-explore.md +15 -1
- 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 +25 -10
|
@@ -19,7 +19,7 @@ export const INVARIANT_COVERAGE_ROOTS_MESSAGE = 'A domain invariant is enforced,
|
|
|
19
19
|
* `omitBudget` drops the budget clause and the load totals for messages whose
|
|
20
20
|
* own text already carries them — the same number twice reads as two facts.
|
|
21
21
|
*/
|
|
22
|
-
function formatCoverageDiscards(stats, omitBudget = false) {
|
|
22
|
+
export function formatCoverageDiscards(stats, omitBudget = false) {
|
|
23
23
|
if (!stats)
|
|
24
24
|
return '';
|
|
25
25
|
const d = stats.discarded;
|
|
@@ -56,28 +56,387 @@ function isUnderCoverageRoot(file, roots) {
|
|
|
56
56
|
return target === root || target.startsWith(`${root}/`);
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
function
|
|
60
|
-
|
|
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);
|
|
59
|
+
function escapeRegExp(value) {
|
|
60
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
64
61
|
}
|
|
65
|
-
function
|
|
62
|
+
function normalizePath(file) {
|
|
63
|
+
return file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
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.
|
|
69
|
+
*/
|
|
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*(['"\`])([^'"\`]*${escaped}[^'"\`]*)\\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;
|
|
150
|
+
const n = escapeRegExp(name);
|
|
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*=`,
|
|
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;
|
|
292
|
+
}
|
|
293
|
+
/** Last segment of `Aggregate.method` is the name that must be declared. */
|
|
294
|
+
function symbolNeedle(symbol) {
|
|
66
295
|
if (!symbol)
|
|
67
|
-
return
|
|
68
|
-
// Support Aggregate.method or bare method name.
|
|
296
|
+
return undefined;
|
|
69
297
|
const parts = symbol.split('.');
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
298
|
+
const name = parts[parts.length - 1] ?? '';
|
|
299
|
+
if (!name)
|
|
300
|
+
return undefined;
|
|
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
|
+
}
|
|
78
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
|
+
const 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
|
+
return { best, ...(testTitle ? { testTitle } : {}), ...(declaration ? { declaration } : {}) };
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Best coverage evidence for `invariant` in `files`.
|
|
396
|
+
* test-title beats a declaration; a declaration beats a bare mention; a bare
|
|
397
|
+
* mention beats silence. Callers do not re-scan to explain the verdict.
|
|
398
|
+
*/
|
|
399
|
+
export function classifyCoverage(invariant, files) {
|
|
400
|
+
return rankCoverage(invariant, files).best;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Policy: a test title or any declaration counts, including type, interface,
|
|
404
|
+
* and enum. A mention-only verdict never counts. `none` does not count.
|
|
405
|
+
*/
|
|
406
|
+
export function countsAsCoverage(ev) {
|
|
407
|
+
switch (ev.kind) {
|
|
408
|
+
case 'test-title':
|
|
409
|
+
case 'declaration':
|
|
410
|
+
return true;
|
|
411
|
+
case 'mention-only':
|
|
412
|
+
case 'none':
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function mentionWhere(context) {
|
|
417
|
+
switch (context) {
|
|
418
|
+
case 'test-body':
|
|
419
|
+
return 'a test body';
|
|
420
|
+
case 'comment':
|
|
421
|
+
return 'a comment';
|
|
422
|
+
case 'string':
|
|
423
|
+
return 'a string';
|
|
424
|
+
case 'import':
|
|
425
|
+
return 'an import';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Sentence for the verdict. `INVARIANT_UNCOVERED` prints this; it does not re-derive it. */
|
|
429
|
+
export function describeCoverage(invariant, ev) {
|
|
430
|
+
switch (ev.kind) {
|
|
431
|
+
case 'test-title':
|
|
432
|
+
return `found \`${ev.title}\` in a describe/it title in ${ev.file}`;
|
|
433
|
+
case 'declaration':
|
|
434
|
+
return `found \`${ev.shape} ${declaredLabel(invariant)}\` in ${ev.file}`;
|
|
435
|
+
case 'mention-only':
|
|
436
|
+
return `${invariant.id} appears only in ${mentionWhere(ev.context)} in ${ev.file}; put it in a describe/it title`;
|
|
437
|
+
case 'none':
|
|
438
|
+
return 'no scanned test names it in a describe/it title and no declared symbol was found';
|
|
79
439
|
}
|
|
80
|
-
return false;
|
|
81
440
|
}
|
|
82
441
|
export function evaluateInvariantCoverage(input) {
|
|
83
442
|
const invariants = input.arkRules.invariants ?? [];
|
|
@@ -103,47 +462,40 @@ export function evaluateInvariantCoverage(input) {
|
|
|
103
462
|
const coverage = [];
|
|
104
463
|
const violations = [];
|
|
105
464
|
for (const inv of invariants) {
|
|
106
|
-
const evidence = [];
|
|
107
465
|
const wantsTest = inv.coverage?.test !== false; // default: prefer test evidence when catalogued
|
|
108
466
|
const symbol = inv.coverage?.symbol;
|
|
467
|
+
// Missing globs: do not let a loaded title prove a walk we did not claim.
|
|
468
|
+
const scanTests = !testGlobsMissing && wantsTest;
|
|
469
|
+
const ranked = rankCoverage(inv, { fileContents: input.fileContents, testFiles, coverageRoots }, scanTests);
|
|
470
|
+
const ev = ranked.best;
|
|
471
|
+
// Legacy `evidence` is derivable from the same scan: counting kinds only.
|
|
472
|
+
// A test title and a declaration can both be present; mention-only is absent.
|
|
473
|
+
const evidence = [];
|
|
474
|
+
if (scanTests && ranked.testTitle)
|
|
475
|
+
evidence.push('test-title');
|
|
476
|
+
if (ranked.declaration)
|
|
477
|
+
evidence.push('symbol');
|
|
478
|
+
const counts = countsAsCoverage(ev);
|
|
109
479
|
let testEvidenceFile;
|
|
110
480
|
let outsideDeclaredRoots;
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
let fallbackOutside;
|
|
116
|
-
for (const file of testFiles) {
|
|
117
|
-
const content = input.fileContents[file];
|
|
118
|
-
if (!content || !titleMatchesInvariant(content, inv.id))
|
|
119
|
-
continue;
|
|
120
|
-
if (!rootsDeclared || isUnderCoverageRoot(file, coverageRoots)) {
|
|
121
|
-
testEvidenceFile = file;
|
|
122
|
-
outsideDeclaredRoots = rootsDeclared ? false : undefined;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
fallbackOutside ??= file;
|
|
481
|
+
if (scanTests && ranked.testTitle) {
|
|
482
|
+
testEvidenceFile = ranked.testTitle.file;
|
|
483
|
+
if (rootsDeclared) {
|
|
484
|
+
outsideDeclaredRoots = !isUnderCoverageRoot(testEvidenceFile, coverageRoots);
|
|
126
485
|
}
|
|
127
|
-
if (testEvidenceFile === undefined && fallbackOutside !== undefined) {
|
|
128
|
-
testEvidenceFile = fallbackOutside;
|
|
129
|
-
outsideDeclaredRoots = true;
|
|
130
|
-
}
|
|
131
|
-
if (testEvidenceFile !== undefined)
|
|
132
|
-
evidence.push('test-title');
|
|
133
|
-
}
|
|
134
|
-
if (symbol && symbolPresent(input.fileContents, symbol)) {
|
|
135
|
-
evidence.push('symbol');
|
|
136
486
|
}
|
|
137
|
-
|
|
487
|
+
const symbolEvidenceFile = ranked.declaration?.file;
|
|
488
|
+
const shape = ranked.declaration?.shape;
|
|
489
|
+
// Covered if the policy says this verdict counts.
|
|
138
490
|
// When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
|
|
139
491
|
const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
|
|
140
|
-
const covered = requiresEvidence &&
|
|
492
|
+
const covered = requiresEvidence && counts
|
|
141
493
|
? true
|
|
142
494
|
: inv.coverage?.test === false && !symbol
|
|
143
495
|
? true // explicitly no coverage requirements
|
|
144
|
-
:
|
|
496
|
+
: counts;
|
|
145
497
|
// Partial only when tests are missing *and* no other evidence (e.g. symbol) completed coverage.
|
|
146
|
-
const partial = testGlobsMissing && wantsTest &&
|
|
498
|
+
const partial = testGlobsMissing && wantsTest && !counts;
|
|
147
499
|
coverage.push({
|
|
148
500
|
invariantId: inv.id,
|
|
149
501
|
layer: inv.provenance.layer,
|
|
@@ -154,6 +506,8 @@ export function evaluateInvariantCoverage(input) {
|
|
|
154
506
|
partial,
|
|
155
507
|
description: inv.description,
|
|
156
508
|
...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
|
|
509
|
+
...(symbolEvidenceFile !== undefined ? { symbolEvidenceFile } : {}),
|
|
510
|
+
...(shape !== undefined ? { shape } : {}),
|
|
157
511
|
...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
|
|
158
512
|
coverageRootsDeclared: rootsDeclared,
|
|
159
513
|
});
|
|
@@ -184,13 +538,11 @@ export function evaluateInvariantCoverage(input) {
|
|
|
184
538
|
? coverageBudgetExhausted
|
|
185
539
|
? `Invariant ${inv.id} coverage cannot be proven (${budgetDetail}); reporting partial, not covered.`
|
|
186
540
|
: `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
|
|
187
|
-
: //
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
? `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.`
|
|
193
|
-
: `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.`) +
|
|
541
|
+
: // The sentence is the verdict. It names a title, a declaration
|
|
542
|
+
// shape, a bare mention, or silence — it does not re-scan.
|
|
543
|
+
`Invariant ${inv.id}: ${describeCoverage(inv, ev)} (${kind === 'tests-disappeared'
|
|
544
|
+
? 'tests-disappeared — a suite exists'
|
|
545
|
+
: 'never-had-tests — the scan found no tests at all'}). ArkGate matches declared text; it never executes tests.`) +
|
|
194
546
|
(partial && coverageBudgetExhausted ? budgetExhaustedTail : discardTail),
|
|
195
547
|
file: inv.provenance.sourceFile,
|
|
196
548
|
line: 1,
|
|
@@ -8,10 +8,19 @@ import {
|
|
|
8
8
|
formatManagedUpgradeSelfServiceHonesty,
|
|
9
9
|
projectManagedUpgradeSelfServiceHonesty,
|
|
10
10
|
} from './managed-upgrade-honesty.mjs';
|
|
11
|
+
import {
|
|
12
|
+
ABSENT_LOCAL_REASON,
|
|
13
|
+
architectureVerificationDetail,
|
|
14
|
+
architectureVerificationOk,
|
|
15
|
+
blockedAssetReason,
|
|
16
|
+
formatBlockedAssets,
|
|
17
|
+
gitignoredPathSet,
|
|
18
|
+
} from './upgrade-outcome.mjs';
|
|
11
19
|
import {
|
|
12
20
|
buildUpgradeWhatsNewSuggestions,
|
|
13
21
|
formatUpgradeWhatsNewSuggestions,
|
|
14
22
|
} from './upgrade-whats-new.mjs';
|
|
23
|
+
import { arkCommand } from './package-manager.mjs';
|
|
15
24
|
import {
|
|
16
25
|
KNOWN_TOOLS,
|
|
17
26
|
arkPackageVersion,
|
|
@@ -354,9 +363,20 @@ function afterFileContent(asset, currentFile, desiredScoped) {
|
|
|
354
363
|
return Buffer.from(upsertCodexMcpTable(currentFile?.toString('utf8') ?? '', 'ark', desiredScoped));
|
|
355
364
|
}
|
|
356
365
|
|
|
357
|
-
export function classifyManagedAsset({
|
|
366
|
+
export function classifyManagedAsset({
|
|
367
|
+
recorded,
|
|
368
|
+
currentContent,
|
|
369
|
+
targetContent: desired,
|
|
370
|
+
kind,
|
|
371
|
+
gitignored = false,
|
|
372
|
+
}) {
|
|
358
373
|
const targetIdentity = managedContentIdentity(desired, kind);
|
|
359
374
|
if (currentContent == null) {
|
|
375
|
+
// Fresh worktree: a recorded per-machine file (gitignored .mcp.json) is
|
|
376
|
+
// absent by nature. Recreate it. A deleted tracked file still needs consent.
|
|
377
|
+
if (recorded && gitignored === true) {
|
|
378
|
+
return { state: 'absent-local', managed: true, requiresConsent: false };
|
|
379
|
+
}
|
|
360
380
|
return { state: 'missing', managed: true, requiresConsent: Boolean(recorded) };
|
|
361
381
|
}
|
|
362
382
|
const currentIdentity = managedContentIdentity(currentContent, kind);
|
|
@@ -456,6 +476,10 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
456
476
|
const assets = [];
|
|
457
477
|
const nextEntries = [];
|
|
458
478
|
const selectedPaths = new Set();
|
|
479
|
+
const ignoredPaths = gitignoredPathSet(
|
|
480
|
+
resolvedRoot,
|
|
481
|
+
catalog.assets.map((asset) => asset.relativePath)
|
|
482
|
+
);
|
|
459
483
|
|
|
460
484
|
for (const catalogAsset of catalog.assets) {
|
|
461
485
|
selectedPaths.add(catalogAsset.relativePath);
|
|
@@ -472,6 +496,7 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
472
496
|
currentContent: currentScoped,
|
|
473
497
|
targetContent: desiredScoped,
|
|
474
498
|
kind: catalogAsset.kind,
|
|
499
|
+
gitignored: ignoredPaths.has(catalogAsset.relativePath),
|
|
475
500
|
});
|
|
476
501
|
const accepted = options.acceptConflicts === true;
|
|
477
502
|
// FX04: --refresh-skills opt-in rewrites customized *skill* assets to package
|
|
@@ -496,11 +521,17 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
496
521
|
}).reason === 'stamp-refresh';
|
|
497
522
|
const canApply =
|
|
498
523
|
classified.state === 'stale' ||
|
|
524
|
+
classified.state === 'absent-local' ||
|
|
499
525
|
skillRefresh ||
|
|
500
526
|
stampRefresh ||
|
|
501
527
|
(classified.state === 'missing' && (!recorded || accepted)) ||
|
|
502
528
|
(classified.state === 'conflicted' && accepted);
|
|
503
529
|
const blocked = classified.requiresConsent && !accepted && !skillRefresh;
|
|
530
|
+
const explanation = blocked
|
|
531
|
+
? blockedAssetReason(classified.state)
|
|
532
|
+
: classified.state === 'absent-local'
|
|
533
|
+
? ABSENT_LOCAL_REASON
|
|
534
|
+
: null;
|
|
504
535
|
const desiredFile = afterFileContent(catalogAsset, currentFile, desiredScoped);
|
|
505
536
|
const asset = {
|
|
506
537
|
path: catalogAsset.relativePath,
|
|
@@ -512,7 +543,9 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
512
543
|
? { reason: 'unparsed managed TOML scope preserved' }
|
|
513
544
|
: stampRefresh
|
|
514
545
|
? { reason: 'stamp-refresh' }
|
|
515
|
-
:
|
|
546
|
+
: explanation
|
|
547
|
+
? { reason: explanation }
|
|
548
|
+
: {}),
|
|
516
549
|
action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
|
|
517
550
|
willApply: canApply,
|
|
518
551
|
blocked,
|
|
@@ -591,6 +624,16 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
591
624
|
manifestContent,
|
|
592
625
|
};
|
|
593
626
|
plan.planDigest = managedPlanDigest(plan);
|
|
627
|
+
if (summary.blocked > 0) {
|
|
628
|
+
const nextCommand = arkCommand(
|
|
629
|
+
resolvedRoot,
|
|
630
|
+
'arkgate',
|
|
631
|
+
['upgrade', '--apply', '--no-install', '--accept-conflicts', '--plan-digest', plan.planDigest].join(' ')
|
|
632
|
+
);
|
|
633
|
+
for (const asset of assets) {
|
|
634
|
+
if (asset.blocked) asset.nextCommand = nextCommand;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
594
637
|
return plan;
|
|
595
638
|
}
|
|
596
639
|
|
|
@@ -710,16 +753,8 @@ export function buildPostUpgradeChecks(root, options = {}) {
|
|
|
710
753
|
});
|
|
711
754
|
checks.push({
|
|
712
755
|
id: 'architecture-verification',
|
|
713
|
-
ok:
|
|
714
|
-
|
|
715
|
-
? null
|
|
716
|
-
: options.verification?.exitCode === 0,
|
|
717
|
-
detail:
|
|
718
|
-
options.verification?.mode === 'skipped'
|
|
719
|
-
? 'Strict architecture verification was skipped (--no-strict).'
|
|
720
|
-
: options.verification?.exitCode === 0
|
|
721
|
-
? 'Strict-merge architecture verification passed.'
|
|
722
|
-
: `Architecture verification exit ${options.verification?.exitCode ?? 'unknown'}.`,
|
|
756
|
+
ok: architectureVerificationOk(options.verification),
|
|
757
|
+
detail: architectureVerificationDetail(options.verification),
|
|
723
758
|
});
|
|
724
759
|
checks.push({
|
|
725
760
|
id: 'package-version-truth',
|
|
@@ -983,8 +1018,12 @@ export function renderManagedUpgrade(plan, options = {}) {
|
|
|
983
1018
|
);
|
|
984
1019
|
console.log(`Profile: ${plan.profile}; hosts: ${plan.hosts.join(', ') || 'shared only'}.`);
|
|
985
1020
|
for (const asset of plan.assets) {
|
|
986
|
-
const consent = asset.requiresConsent
|
|
987
|
-
|
|
1021
|
+
const consent = asset.requiresConsent
|
|
1022
|
+
? ' (consent required)'
|
|
1023
|
+
: asset.state === 'absent-local'
|
|
1024
|
+
? ' (gitignored; recreated without consent)'
|
|
1025
|
+
: '';
|
|
1026
|
+
console.log(` ${String(asset.state).padEnd(12)} ${asset.path}${consent}`);
|
|
988
1027
|
}
|
|
989
1028
|
const summary = plan.summary;
|
|
990
1029
|
const managedAssets = summary.managedAssets ?? summary.total ?? plan.assets.length;
|
|
@@ -998,6 +1037,11 @@ export function renderManagedUpgrade(plan, options = {}) {
|
|
|
998
1037
|
: '') +
|
|
999
1038
|
`; customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
|
|
1000
1039
|
);
|
|
1040
|
+
for (const line of formatBlockedAssets(plan, {
|
|
1041
|
+
refused: options.refused === true || plan.blocked === true,
|
|
1042
|
+
})) {
|
|
1043
|
+
console.log(line);
|
|
1044
|
+
}
|
|
1001
1045
|
const honesty =
|
|
1002
1046
|
plan.selfService ??
|
|
1003
1047
|
projectManagedUpgradeSelfServiceHonesty({
|
|
@@ -1040,6 +1084,7 @@ export function renderManagedUpgrade(plan, options = {}) {
|
|
|
1040
1084
|
);
|
|
1041
1085
|
return;
|
|
1042
1086
|
}
|
|
1087
|
+
if (blocked > 0 && !options.next) return;
|
|
1043
1088
|
console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
|
|
1044
1089
|
if (options.next) console.log(options.next);
|
|
1045
1090
|
else console.log('Apply the exact preview with: npx arkgate upgrade --apply --no-install');
|
|
@@ -94,9 +94,12 @@ export function arkCommand(root, bin, argsStr = '') {
|
|
|
94
94
|
/**
|
|
95
95
|
* Run an Ark binary from package `arkgate` when local bins may be missing.
|
|
96
96
|
* Bare `npx arkgate-check` 404s — npx treats that bin name as its own package.
|
|
97
|
+
* Pass `packageSpec` (e.g. `arkgate@4.8.19`) when Age blocked a local add.
|
|
97
98
|
*/
|
|
98
|
-
export function arkPackageRecoveryCommand(bin, argsStr = '') {
|
|
99
|
-
|
|
99
|
+
export function arkPackageRecoveryCommand(bin, argsStr = '', packageSpec) {
|
|
100
|
+
const spec =
|
|
101
|
+
typeof packageSpec === 'string' && packageSpec.trim() ? packageSpec.trim() : 'arkgate';
|
|
102
|
+
return `npx --package=${spec} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/**
|