arkgate 4.8.20 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +124 -5
  2. package/README.md +107 -117
  3. package/bin/ark-check-runtime.mjs +3 -4
  4. package/bin/ark-layer-match.mjs +110 -18
  5. package/bin/lib/analysis-engine.mjs +13 -6
  6. package/bin/lib/config-contract.mjs +5 -0
  7. package/bin/lib/diagnostic-catalog.mjs +1 -0
  8. package/bin/lib/doctor-advisories.mjs +20 -0
  9. package/bin/lib/doctor-human.mjs +11 -9
  10. package/bin/lib/doctor-plan.mjs +15 -14
  11. package/bin/lib/first-run-help.mjs +7 -1
  12. package/bin/lib/html-report-advisories.mjs +8 -0
  13. package/bin/lib/html-report-depth.mjs +9 -8
  14. package/bin/lib/invariant-coverage.mjs +396 -75
  15. package/bin/lib/managed-upgrade.mjs +59 -14
  16. package/bin/lib/pilot-loop.mjs +183 -37
  17. package/bin/lib/upgrade-behavior-changes.mjs +83 -0
  18. package/bin/lib/upgrade-command.mjs +69 -31
  19. package/bin/lib/upgrade-outcome.mjs +359 -0
  20. package/bin/lib/violations.mjs +109 -0
  21. package/dist/{configTypes-VVknqcTJ.d.ts → configTypes-CTELo87-.d.ts} +7 -0
  22. package/dist/{diagnosticCatalog-BPYWNyJR.d.ts → diagnosticCatalog-B7BXlet6.d.ts} +73 -5
  23. package/dist/eslint/index.cjs +4 -4
  24. package/dist/eslint/index.d.ts +26 -3
  25. package/dist/eslint/index.js +4 -4
  26. package/dist/index.cjs +24 -17
  27. package/dist/index.d.ts +9 -4
  28. package/dist/index.js +25 -18
  29. package/dist/nestjs/index.cjs +9 -9
  30. package/dist/nestjs/index.d.ts +3 -3
  31. package/dist/nestjs/index.js +9 -9
  32. package/dist/runtime/index.cjs +18 -11
  33. package/dist/runtime/index.d.ts +6 -6
  34. package/dist/runtime/index.js +18 -11
  35. package/dist/{types-p_fCp6P-.d.ts → types-fQ9-KrBj.d.ts} +1 -1
  36. package/dist/{types-Cs-46DRu.d.ts → types-yDNEQ603.d.ts} +1 -1
  37. package/docs/README.md +4 -3
  38. package/docs/agent-guide.md +15 -7
  39. package/docs/brownfield-adoption.md +4 -3
  40. package/docs/configuration.md +34 -11
  41. package/docs/diagnostics.md +12 -2
  42. package/docs/package-surface.md +3 -2
  43. package/docs/product-voice.md +11 -3
  44. package/package.json +1 -1
  45. package/schemas/ark.config.schema.json +8 -0
  46. package/server.json +2 -2
  47. package/templates/agent-skills/README.md +1 -1
  48. package/templates/agent-skills/ark-adopt/SKILL.md +5 -2
  49. package/templates/agent-skills/ark-architect/SKILL.md +2 -0
  50. package/templates/agent-skills/ark-autopilot/SKILL.md +6 -4
  51. package/templates/agent-skills/ark-contract/SKILL.md +2 -0
  52. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  53. package/templates/agent-skills/ark-explain/SKILL.md +2 -0
  54. package/templates/agent-skills/ark-explore/SKILL.md +4 -2
  55. package/templates/agent-skills/ark-fix/SKILL.md +2 -0
  56. package/templates/agent-skills/ark-loop/SKILL.md +2 -0
  57. package/templates/agent-skills/ark-order/SKILL.md +2 -0
  58. package/templates/agent-skills/ark-place/SKILL.md +2 -0
  59. package/templates/agent-skills/ark-runtime/SKILL.md +2 -0
  60. package/templates/agent-skills/ark-think/SKILL.md +2 -0
  61. package/templates/agent-skills/ark-upgrade/SKILL.md +20 -7
  62. package/templates/skills/ark-adopt.md +5 -2
  63. package/templates/skills/ark-architect.md +2 -0
  64. package/templates/skills/ark-autopilot.md +6 -4
  65. package/templates/skills/ark-contract.md +2 -0
  66. package/templates/skills/ark-coverage.md +2 -0
  67. package/templates/skills/ark-explain.md +2 -0
  68. package/templates/skills/ark-explore.md +4 -2
  69. package/templates/skills/ark-fix.md +2 -0
  70. package/templates/skills/ark-loop.md +2 -0
  71. package/templates/skills/ark-order.md +2 -0
  72. package/templates/skills/ark-place.md +2 -0
  73. package/templates/skills/ark-runtime.md +2 -0
  74. package/templates/skills/ark-think.md +2 -0
  75. package/templates/skills/ark-upgrade.md +20 -7
@@ -56,57 +56,388 @@ 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
- * True when `name` is declared here. Imports and calls do not count.
70
- * `export const name =` is the same binding shape sensors already treat as a
71
- * declaration (arrow or function). The last segment of `Aggregate.method` is
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 declaresIdentifier(content, name) {
75
- const n = escapeRegExp(name);
76
- const functionDecl = new RegExp(`(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:declare\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${n}\\s*[(<]`);
77
- const classDecl = new RegExp(`(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:abstract\\s+)?class\\s+${n}\\b`);
78
- const constDecl = new RegExp(`(?:^|[\\s;{}])export\\s+(?:const|let|var)\\s+${n}\\s*=`);
79
- const methodDecl = new RegExp(`(?:^|[\\n;{}])\\s*(?:(?:public|private|protected|static|async|readonly|override|abstract|get|set|declare)\\s+)*${n}\\s*(?:<[^>\\n]*>)?\\s*\\([^;{}]*\\)\\s*(?::\\s*[^;{]+)?\\s*\\{`);
80
- return (functionDecl.test(content) ||
81
- classDecl.test(content) ||
82
- constDecl.test(content) ||
83
- methodDecl.test(content));
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('');
84
130
  }
85
- /** Witness path, or undefined when no non-test file declares `symbol`. */
86
- function symbolPresent(fileContents, testFiles, symbol) {
87
- if (!symbol)
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)
88
134
  return undefined;
89
- const parts = symbol.split('.');
90
- const needle = parts[parts.length - 1] ?? '';
91
- if (!needle)
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)
92
149
  return undefined;
93
- const className = parts.length > 1 ? parts[0] : null;
94
- const tests = new Set(testFiles.map((file) => file.replace(/\\/g, '/').replace(/^\.\//, '')));
95
- const files = Object.keys(fileContents).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
96
- for (const file of files) {
97
- const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
98
- if (tests.has(normalized))
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
+ }
99
240
  continue;
100
- const content = fileContents[file];
101
- if (!content)
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
+ }
102
255
  continue;
103
- if (className && !content.includes(className))
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
+ }
104
278
  continue;
105
- if (declaresIdentifier(content, needle))
106
- return file;
279
+ }
280
+ note(i, 'code');
281
+ i += 1;
107
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';
108
291
  return undefined;
109
292
  }
293
+ /** Last segment of `Aggregate.method` is the name that must be declared. */
294
+ function symbolNeedle(symbol) {
295
+ if (!symbol)
296
+ return undefined;
297
+ const parts = symbol.split('.');
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
+ }
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';
439
+ }
440
+ }
110
441
  export function evaluateInvariantCoverage(input) {
111
442
  const invariants = input.arkRules.invariants ?? [];
112
443
  if (invariants.length === 0) {
@@ -131,49 +462,40 @@ export function evaluateInvariantCoverage(input) {
131
462
  const coverage = [];
132
463
  const violations = [];
133
464
  for (const inv of invariants) {
134
- const evidence = [];
135
465
  const wantsTest = inv.coverage?.test !== false; // default: prefer test evidence when catalogued
136
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);
137
479
  let testEvidenceFile;
138
480
  let outsideDeclaredRoots;
139
- if (!testGlobsMissing && wantsTest) {
140
- // A covering test INSIDE a declared root wins over one outside it: the
141
- // finding is "the only proof lives where the runner does not go", not
142
- // "some proof lives there".
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;
481
+ if (scanTests && ranked.testTitle) {
482
+ testEvidenceFile = ranked.testTitle.file;
483
+ if (rootsDeclared) {
484
+ outsideDeclaredRoots = !isUnderCoverageRoot(testEvidenceFile, coverageRoots);
158
485
  }
159
- if (testEvidenceFile !== undefined)
160
- evidence.push('test-title');
161
486
  }
162
- const symbolEvidenceFile = symbol
163
- ? symbolPresent(input.fileContents, testFiles, symbol)
164
- : undefined;
165
- if (symbolEvidenceFile)
166
- evidence.push('symbol');
167
- // Covered if any requested evidence is present.
487
+ const symbolEvidenceFile = ranked.declaration?.file;
488
+ const shape = ranked.declaration?.shape;
489
+ // Covered if the policy says this verdict counts.
168
490
  // When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
169
491
  const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
170
- const covered = requiresEvidence && evidence.length > 0
492
+ const covered = requiresEvidence && counts
171
493
  ? true
172
494
  : inv.coverage?.test === false && !symbol
173
495
  ? true // explicitly no coverage requirements
174
- : evidence.length > 0;
496
+ : counts;
175
497
  // Partial only when tests are missing *and* no other evidence (e.g. symbol) completed coverage.
176
- const partial = testGlobsMissing && wantsTest && evidence.length === 0;
498
+ const partial = testGlobsMissing && wantsTest && !counts;
177
499
  coverage.push({
178
500
  invariantId: inv.id,
179
501
  layer: inv.provenance.layer,
@@ -185,6 +507,7 @@ export function evaluateInvariantCoverage(input) {
185
507
  description: inv.description,
186
508
  ...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
187
509
  ...(symbolEvidenceFile !== undefined ? { symbolEvidenceFile } : {}),
510
+ ...(shape !== undefined ? { shape } : {}),
188
511
  ...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
189
512
  coverageRootsDeclared: rootsDeclared,
190
513
  });
@@ -215,13 +538,11 @@ export function evaluateInvariantCoverage(input) {
215
538
  ? coverageBudgetExhausted
216
539
  ? `Invariant ${inv.id} coverage cannot be proven (${budgetDetail}); reporting partial, not covered.`
217
540
  : `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
218
- : // Say what was actually checked. "Not covered by a test
219
- // title" reads as "there is no test", and its inverse
220
- // reads as "there is a test and it runs" — neither is
221
- // something a text match can know.
222
- kind === 'tests-disappeared'
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.`) +
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.`) +
225
546
  (partial && coverageBudgetExhausted ? budgetExhaustedTail : discardTail),
226
547
  file: inv.provenance.sourceFile,
227
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({ recorded, currentContent, targetContent: desired, kind }) {
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
- options.verification?.mode === 'skipped'
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 ? ' (consent required)' : '';
987
- console.log(` ${asset.state.padEnd(10)} ${asset.path}${consent}`);
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');