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.
Files changed (75) hide show
  1. package/CHANGELOG.md +172 -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 +417 -72
  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-WD2TPSwZ.d.ts} +78 -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 +32 -25
  27. package/dist/index.d.ts +9 -4
  28. package/dist/index.js +33 -26
  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 +22 -15
  33. package/dist/runtime/index.d.ts +6 -6
  34. package/dist/runtime/index.js +22 -15
  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 +5 -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 +2 -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
@@ -10,6 +10,13 @@ import {
10
10
  formatPackageInstallDecisionHuman,
11
11
  } from '../ark-shared.mjs';
12
12
  import { describePackageVersionDualTruth } from './field-install.mjs';
13
+ import {
14
+ exitCodeFor,
15
+ formatBlockedAssets,
16
+ formatPostUpgradeHuman,
17
+ runPostUpgradeVerification,
18
+ upgradeOutcome,
19
+ } from './upgrade-outcome.mjs';
13
20
  import { __packageRoot } from './gate-files.mjs';
14
21
  import {
15
22
  applyManagedUpgrade,
@@ -316,15 +323,13 @@ export function buildUpgradeNextCommand(args, planDigest) {
316
323
  return arkCommand(args.root, 'arkgate', argsStr);
317
324
  }
318
325
 
319
- function verify(root, json, arkCheck, runArkCheck) {
320
- const args = ['--root', root, '--config', 'ark.config.json', '--strict-merge'];
321
- if (!json) return { exitCode: runArkCheck(args, { cwd: root }) };
322
- const result = spawnSync(process.execPath, [arkCheck, ...args, '--json'], {
323
- cwd: root,
324
- stdio: ['ignore', 'pipe', 'pipe'],
325
- encoding: 'utf8',
326
- });
327
- return { exitCode: result.status ?? 1, stderr: result.stderr?.trim() || undefined };
326
+ function stampBlockedNextCommands(plan, args) {
327
+ if ((plan?.summary?.blocked ?? 0) === 0) return plan;
328
+ const command = buildUpgradeNextCommand({ ...args, acceptConflicts: true }, plan.planDigest);
329
+ for (const asset of plan.assets ?? []) {
330
+ if (asset.blocked) asset.nextCommand = command;
331
+ }
332
+ return plan;
328
333
  }
329
334
 
330
335
  function runProjectLocalCli({ root, script, argv }) {
@@ -544,11 +549,14 @@ export function runUpgradeCommand(args, dependencies) {
544
549
  }).status ?? 1;
545
550
  }
546
551
 
547
- const plan = planManagedUpgrade(root, {
548
- tools: args.tools,
549
- acceptConflicts: args.acceptConflicts,
550
- refreshSkills: args.refreshSkills === true,
551
- });
552
+ const plan = stampBlockedNextCommands(
553
+ planManagedUpgrade(root, {
554
+ tools: args.tools,
555
+ acceptConflicts: args.acceptConflicts,
556
+ refreshSkills: args.refreshSkills === true,
557
+ }),
558
+ args
559
+ );
552
560
  if (!args.apply) {
553
561
  const wouldWrite = plan.summary?.wouldWrite ?? 0;
554
562
  const blocked = plan.summary?.blocked ?? 0;
@@ -577,11 +585,14 @@ export function runUpgradeCommand(args, dependencies) {
577
585
  );
578
586
  } else {
579
587
  renderManagedUpgrade(plan, {
580
- next: needsApply
581
- ? args.install
582
- ? `Update the package and recompute this preview with: ${command}`
583
- : `Apply the exact preview with: ${command}`
584
- : undefined,
588
+ next:
589
+ blocked > 0
590
+ ? undefined
591
+ : needsApply
592
+ ? args.install
593
+ ? `Update the package and recompute this preview with: ${command}`
594
+ : `Apply the exact preview with: ${command}`
595
+ : undefined,
585
596
  skillDrift,
586
597
  hostSelection: hostHonesty,
587
598
  });
@@ -600,54 +611,80 @@ export function runUpgradeCommand(args, dependencies) {
600
611
  } catch (error) {
601
612
  const message = error instanceof Error ? error.message : String(error);
602
613
  console.error(message);
603
- return 2;
614
+ return exitCodeFor(upgradeOutcome({ error }));
604
615
  }
605
616
  if (applied.blocked) {
617
+ stampBlockedNextCommands(applied, args);
606
618
  const command = buildUpgradeNextCommand(
607
619
  { ...args, acceptConflicts: true },
608
620
  applied.planDigest
609
621
  );
622
+ const refusal = formatBlockedAssets(applied, { refused: true }).join('\n');
623
+ console.error(refusal);
610
624
  if (args.json) {
611
625
  console.log(
612
626
  managedUpgradeJson(applied, {
613
627
  blocked: true,
628
+ outcome: 'blocked',
614
629
  reasonCode: applied.reasonCode ?? 'managed-consent-required',
615
630
  nextCommand: command,
616
631
  })
617
632
  );
618
633
  } else {
619
- renderManagedUpgrade(applied, {
620
- next: 'Preview again with --accept-conflicts, then use that preview\'s exact next command.',
621
- });
634
+ renderManagedUpgrade(applied, { refused: true });
622
635
  }
623
- return 1;
636
+ return exitCodeFor(upgradeOutcome({ blocked: true }));
624
637
  }
625
638
  if (applied.nothingToApply && !applied.applied) {
626
- if (args.json) console.log(JSON.stringify(applied, null, 2));
639
+ const idle = upgradeOutcome({ nothingToApply: true, applied: false });
640
+ if (args.json) console.log(JSON.stringify({ ...applied, outcome: idle.kind }, null, 2));
627
641
  else {
628
642
  renderManagedUpgrade(applied);
629
643
  console.log('No managed content writes pending.');
630
644
  }
631
- return 0;
645
+ return exitCodeFor(idle);
632
646
  }
633
- const verification = args.strict
634
- ? { mode: 'strict-merge', ...verify(root, args.json, dependencies.arkCheck, dependencies.runArkCheck) }
635
- : { mode: 'skipped', exitCode: 0 };
647
+ const postUpgrade = runPostUpgradeVerification(root, {
648
+ strict: args.strict,
649
+ arkCheck: dependencies.arkCheck,
650
+ packageVersion: dependencies.cliVersion,
651
+ ...(dependencies.checkResult ? { checkResult: dependencies.checkResult } : {}),
652
+ ...(typeof dependencies.spawnArchitectureCheck === 'function'
653
+ ? { checkResult: dependencies.spawnArchitectureCheck(root) }
654
+ : {}),
655
+ });
656
+ const outcome = upgradeOutcome({ applied: true, postUpgrade });
657
+ const verification = {
658
+ mode: postUpgrade.mode,
659
+ exitCode: postUpgrade.exitCode,
660
+ ...(postUpgrade.stderr ? { stderr: postUpgrade.stderr } : {}),
661
+ };
636
662
  const dualTruth = describePackageVersionDualTruth(root);
637
663
  const skillDrift = buildSkillDriftSummary(applied);
638
664
  const hostHonesty = buildHostSelectionHonesty(applied);
639
665
  // FX05: post-upgrade verification block (advisory, notAScore).
640
666
  const postUpgradeChecks = buildPostUpgradeChecks(root, {
641
667
  cliVersion: dependencies.cliVersion,
642
- verification,
668
+ verification: {
669
+ ...verification,
670
+ verdict: postUpgrade.verdict,
671
+ failing: postUpgrade.failing,
672
+ },
643
673
  dualTruth,
644
674
  });
675
+ const postUpgradePayload = {
676
+ verdict: postUpgrade.verdict,
677
+ failing: postUpgrade.failing,
678
+ behaviorChanges: postUpgrade.behaviorChanges,
679
+ };
645
680
  if (args.json) {
646
681
  console.log(
647
682
  JSON.stringify(
648
683
  {
649
684
  ...applied,
685
+ outcome: outcome.kind,
650
686
  verification,
687
+ postUpgrade: postUpgradePayload,
651
688
  skillDrift,
652
689
  hostSelection: hostHonesty,
653
690
  postUpgradeChecks,
@@ -673,6 +710,7 @@ export function runUpgradeCommand(args, dependencies) {
673
710
  );
674
711
  } else {
675
712
  renderManagedUpgrade(applied, { skillDrift, hostSelection: hostHonesty });
713
+ for (const line of formatPostUpgradeHuman(postUpgrade)) console.log(line);
676
714
  if (!args.strict) console.log('Architecture verification skipped (--no-strict).');
677
715
  if (args.install === false || dualTruth.dualTruth) {
678
716
  console.log(
@@ -690,5 +728,5 @@ export function runUpgradeCommand(args, dependencies) {
690
728
  console.log(` [note] mcp: ${postUpgradeChecks.mcpNote}`);
691
729
  }
692
730
  }
693
- return verification.exitCode;
731
+ return exitCodeFor(outcome);
694
732
  }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * UpgradeOutcome — one value for `upgrade --apply`.
3
+ *
4
+ * Sketch (D2). Callers see one object and one exit mapping. Gitignore
5
+ * detection, rule aggregation, and version-note lookup stay in here.
6
+ *
7
+ * classifyManagedAsset({ ..., gitignored }) →
8
+ * { state: 'absent-local', requiresConsent: false } when recorded + gitignored + missing
9
+ *
10
+ * runPostUpgradeVerification(root) →
11
+ * { verdict: 'green'|'red'|'skipped', failing: [{ ruleId, count, sample }], behaviorChanges: string[] }
12
+ *
13
+ * exitCodeFor(outcome) →
14
+ * 0 applied+green (also nothing-to-apply / verification skipped)
15
+ * 3 applied+red
16
+ * 2 blocked
17
+ * 1 error
18
+ *
19
+ * JSON fields on the existing upgrade payload are additive (`outcome`, `postUpgrade`,
20
+ * per-asset `reason` / `nextCommand`, state `absent-local`).
21
+ */
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ import { behaviorChangeLines } from './upgrade-behavior-changes.mjs';
25
+
26
+ /**
27
+ * @typedef {'applied-green'|'applied-skipped'|'applied-red'|'blocked'|'error'|'nothing'|'preview'} UpgradeOutcomeKind
28
+ * @typedef {{ ruleId: string, count: number, sample: string }} FailingRule
29
+ * @typedef {{
30
+ * verdict: 'green'|'red'|'skipped',
31
+ * failing: FailingRule[],
32
+ * behaviorChanges: string[],
33
+ * mode: 'strict-merge'|'skipped',
34
+ * exitCode: number,
35
+ * stderr?: string,
36
+ * }} PostUpgradeVerification
37
+ * @typedef {{
38
+ * kind: UpgradeOutcomeKind,
39
+ * applied: boolean,
40
+ * blocked: boolean,
41
+ * postUpgrade: PostUpgradeVerification|null,
42
+ * error?: string,
43
+ * }} UpgradeOutcome
44
+ */
45
+
46
+ export const ABSENT_LOCAL_REASON =
47
+ 'Recorded managed file is missing and gitignored (per-machine file). Recreated without consent.';
48
+
49
+ /**
50
+ * @param {string} state
51
+ * @returns {string}
52
+ */
53
+ export function blockedAssetReason(state) {
54
+ if (state === 'missing') {
55
+ return 'Recorded managed file is missing and is not gitignored. Apply will not recreate a deleted file without consent.';
56
+ }
57
+ if (state === 'conflicted') {
58
+ return 'Managed base and local edits both changed. Apply will not overwrite without consent.';
59
+ }
60
+ return 'Apply needs consent before changing this managed file.';
61
+ }
62
+
63
+ /**
64
+ * Paths git would ignore. Not a git repo, or git missing → empty set
65
+ * (fail closed toward consent). Global excludes are not consulted.
66
+ *
67
+ * @param {string} root
68
+ * @param {string[]} relativePaths
69
+ * @returns {Set<string>}
70
+ */
71
+ export function gitignoredPathSet(root, relativePaths) {
72
+ const paths = [...new Set(relativePaths.filter((entry) => typeof entry === 'string' && entry.length > 0))];
73
+ if (paths.length === 0) return new Set();
74
+ let result;
75
+ try {
76
+ result = spawnSync(
77
+ 'git',
78
+ ['-c', 'core.excludesFile=/dev/null', 'check-ignore', '-z', '--stdin'],
79
+ {
80
+ cwd: root,
81
+ input: `${paths.join('\0')}\0`,
82
+ encoding: 'utf8',
83
+ timeout: 5000,
84
+ env: {
85
+ ...process.env,
86
+ GIT_CONFIG_NOSYSTEM: '1',
87
+ GIT_CONFIG_GLOBAL: '/dev/null',
88
+ },
89
+ }
90
+ );
91
+ } catch {
92
+ return new Set();
93
+ }
94
+ if (!result || result.error || (result.status !== 0 && result.status !== 1)) return new Set();
95
+ const ignored = new Set();
96
+ for (const entry of String(result.stdout ?? '').split('\0')) {
97
+ if (entry) ignored.add(entry);
98
+ }
99
+ return ignored;
100
+ }
101
+
102
+ /**
103
+ * @param {unknown} check
104
+ * @returns {FailingRule[]}
105
+ */
106
+ export function aggregateFailingRules(check) {
107
+ const violations = Array.isArray(check?.violations) ? check.violations : [];
108
+ /** @type {Map<string, FailingRule>} */
109
+ const groups = new Map();
110
+ for (const violation of violations) {
111
+ if (!violation || typeof violation !== 'object') continue;
112
+ if (violation.failsStrict === false) continue;
113
+ const ruleId = typeof violation.ruleId === 'string' && violation.ruleId.trim()
114
+ ? violation.ruleId.trim()
115
+ : 'UNKNOWN';
116
+ const existing = groups.get(ruleId) ?? { ruleId, count: 0, sample: '' };
117
+ existing.count += 1;
118
+ if (!existing.sample) existing.sample = sampleForViolation(violation, ruleId);
119
+ groups.set(ruleId, existing);
120
+ }
121
+ return [...groups.values()].sort(
122
+ (left, right) => right.count - left.count || left.ruleId.localeCompare(right.ruleId)
123
+ );
124
+ }
125
+
126
+ /**
127
+ * @param {Record<string, unknown>} violation
128
+ * @param {string} ruleId
129
+ */
130
+ function sampleForViolation(violation, ruleId) {
131
+ const file = typeof violation.file === 'string'
132
+ ? violation.file
133
+ : typeof violation.path === 'string'
134
+ ? violation.path
135
+ : '';
136
+ const line = Number.isInteger(violation.line) ? `:${violation.line}` : '';
137
+ const where = file ? `${file}${line}` : '';
138
+ const message = typeof violation.message === 'string' ? violation.message.trim() : '';
139
+ const sample = [where, message].filter(Boolean).join(' — ') || ruleId;
140
+ return sample.length > 180 ? `${sample.slice(0, 177)}...` : sample;
141
+ }
142
+
143
+ /**
144
+ * @param {string} stdout
145
+ * @returns {Record<string, unknown>|null}
146
+ */
147
+ export function parseCheckStdout(stdout) {
148
+ const text = typeof stdout === 'string' ? stdout.trim() : '';
149
+ if (!text) return null;
150
+ try {
151
+ const parsed = JSON.parse(text);
152
+ return parsed && typeof parsed === 'object' ? parsed : null;
153
+ } catch {
154
+ const start = text.indexOf('{');
155
+ const end = text.lastIndexOf('}');
156
+ if (start === -1 || end <= start) return null;
157
+ try {
158
+ const parsed = JSON.parse(text.slice(start, end + 1));
159
+ return parsed && typeof parsed === 'object' ? parsed : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @param {string} root
168
+ * @param {string} arkCheck
169
+ * @returns {{ exitCode: number, stdout: string, stderr: string }}
170
+ */
171
+ function spawnArchitectureCheck(root, arkCheck) {
172
+ if (typeof arkCheck !== 'string' || !arkCheck) {
173
+ return { exitCode: 1, stdout: '', stderr: 'architecture check binary is missing' };
174
+ }
175
+ const result = spawnSync(
176
+ process.execPath,
177
+ [arkCheck, '--root', root, '--config', 'ark.config.json', '--strict-merge', '--json'],
178
+ {
179
+ cwd: root,
180
+ stdio: ['ignore', 'pipe', 'pipe'],
181
+ encoding: 'utf8',
182
+ }
183
+ );
184
+ return {
185
+ exitCode: result.status ?? 1,
186
+ stdout: result.stdout ?? '',
187
+ stderr: result.stderr ?? '',
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Structured post-upgrade verification. Reuses check JSON (rule ids), and
193
+ * attaches behavior-change lines from the shipped per-version table.
194
+ *
195
+ * @param {string} root
196
+ * @param {{
197
+ * strict?: boolean,
198
+ * arkCheck?: string,
199
+ * packageVersion?: string,
200
+ * checkResult?: { exitCode?: number, status?: number, stdout?: string, stderr?: string },
201
+ * }} [options]
202
+ * @returns {PostUpgradeVerification}
203
+ */
204
+ export function runPostUpgradeVerification(root, options = {}) {
205
+ const behaviorChanges = behaviorChangeLines();
206
+ if (options.strict === false) {
207
+ return {
208
+ verdict: 'skipped',
209
+ failing: [],
210
+ behaviorChanges,
211
+ mode: 'skipped',
212
+ exitCode: 0,
213
+ };
214
+ }
215
+ const raw = options.checkResult ?? spawnArchitectureCheck(root, options.arkCheck);
216
+ const parsed = parseCheckStdout(raw.stdout ?? '');
217
+ const failing = aggregateFailingRules(parsed);
218
+ const exitCode = raw.exitCode ?? raw.status ?? 1;
219
+ const red = exitCode !== 0 || parsed?.ok === false || parsed?.valid === false;
220
+ if (red && failing.length === 0) {
221
+ const sampleSource = String(raw.stderr || 'architecture check failed').trim() || 'architecture check failed';
222
+ failing.push({
223
+ ruleId: 'ARCHITECTURE_CHECK',
224
+ count: 1,
225
+ sample: sampleSource.length > 180 ? `${sampleSource.slice(0, 177)}...` : sampleSource,
226
+ });
227
+ }
228
+ const stderr = typeof raw.stderr === 'string' && raw.stderr.trim() ? raw.stderr.trim() : undefined;
229
+ return {
230
+ verdict: red ? 'red' : 'green',
231
+ failing: red ? failing : [],
232
+ behaviorChanges,
233
+ mode: 'strict-merge',
234
+ exitCode: red ? (exitCode || 1) : 0,
235
+ ...(stderr ? { stderr } : {}),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * @param {{
241
+ * applied?: boolean,
242
+ * blocked?: boolean,
243
+ * nothingToApply?: boolean,
244
+ * postUpgrade?: PostUpgradeVerification|null,
245
+ * error?: unknown,
246
+ * }} input
247
+ * @returns {UpgradeOutcome}
248
+ */
249
+ export function upgradeOutcome(input) {
250
+ if (input.error != null && input.error !== false) {
251
+ const message = input.error instanceof Error ? input.error.message : String(input.error);
252
+ return { kind: 'error', applied: false, blocked: false, postUpgrade: null, error: message };
253
+ }
254
+ if (input.blocked) {
255
+ return { kind: 'blocked', applied: false, blocked: true, postUpgrade: null };
256
+ }
257
+ if (input.nothingToApply && !input.applied) {
258
+ return { kind: 'nothing', applied: false, blocked: false, postUpgrade: null };
259
+ }
260
+ const postUpgrade = input.postUpgrade ?? null;
261
+ if (postUpgrade?.verdict === 'red') {
262
+ return { kind: 'applied-red', applied: true, blocked: false, postUpgrade };
263
+ }
264
+ if (postUpgrade?.verdict === 'skipped') {
265
+ return { kind: 'applied-skipped', applied: Boolean(input.applied), blocked: false, postUpgrade };
266
+ }
267
+ return { kind: 'applied-green', applied: Boolean(input.applied), blocked: false, postUpgrade };
268
+ }
269
+
270
+ /**
271
+ * @param {UpgradeOutcome|null|undefined} outcome
272
+ * @returns {0|1|2|3}
273
+ */
274
+ export function exitCodeFor(outcome) {
275
+ switch (outcome?.kind) {
276
+ case 'applied-green':
277
+ case 'applied-skipped':
278
+ case 'nothing':
279
+ case 'preview':
280
+ return 0;
281
+ case 'applied-red':
282
+ return 3;
283
+ case 'blocked':
284
+ return 2;
285
+ case 'error':
286
+ return 1;
287
+ default:
288
+ return 1;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * @param {{ mode?: string, exitCode?: number, verdict?: string }} [verification]
294
+ * @returns {boolean|null}
295
+ */
296
+ export function architectureVerificationOk(verification) {
297
+ if (!verification || verification.mode === 'skipped' || verification.verdict === 'skipped') return null;
298
+ if (verification.verdict === 'green') return true;
299
+ if (verification.verdict === 'red') return false;
300
+ if (verification.exitCode === 0) return true;
301
+ if (typeof verification.exitCode === 'number') return false;
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * @param {{ mode?: string, exitCode?: number, verdict?: string, failing?: FailingRule[] }} [verification]
307
+ * @returns {string}
308
+ */
309
+ export function architectureVerificationDetail(verification) {
310
+ if (!verification || verification.mode === 'skipped' || verification.verdict === 'skipped') {
311
+ return 'Strict architecture verification was skipped (--no-strict).';
312
+ }
313
+ const code = verification.exitCode;
314
+ if (architectureVerificationOk(verification) === true) {
315
+ return 'Strict-merge architecture verification passed.';
316
+ }
317
+ const failing = Array.isArray(verification.failing) ? verification.failing : [];
318
+ const rules = failing.map((row) => `${row.ruleId} ×${row.count}`).join(', ');
319
+ const head = `Architecture verification exit ${code ?? 'unknown'}.`;
320
+ if (!rules) return head;
321
+ return `${head} Failing rules: ${rules}. Read behaviorChanges before editing code.`;
322
+ }
323
+
324
+ /**
325
+ * @param {{ assets?: Array<{ blocked?: boolean, path?: string, state?: string, reason?: string, nextCommand?: string }>, blocked?: boolean, nextCommand?: string }} plan
326
+ * @param {{ refused?: boolean }} [options]
327
+ * @returns {string[]}
328
+ */
329
+ export function formatBlockedAssets(plan, options = {}) {
330
+ const blocked = (plan?.assets ?? []).filter((asset) => asset?.blocked);
331
+ if (blocked.length === 0) return [];
332
+ const lines = [options.refused ? 'Apply refused.' : 'Blocked until you consent:'];
333
+ for (const asset of blocked) {
334
+ const reason = asset.reason || blockedAssetReason(asset.state ?? '');
335
+ lines.push(` ${asset.path} — ${reason}`);
336
+ }
337
+ const next = blocked.find((asset) => asset.nextCommand)?.nextCommand ?? plan?.nextCommand;
338
+ if (next) lines.push(`Next: ${next}`);
339
+ return lines;
340
+ }
341
+
342
+ /**
343
+ * @param {PostUpgradeVerification|null|undefined} postUpgrade
344
+ * @returns {string[]}
345
+ */
346
+ export function formatPostUpgradeHuman(postUpgrade) {
347
+ if (!postUpgrade) return [];
348
+ const lines = [`Post-upgrade verification: ${postUpgrade.verdict}.`];
349
+ if (postUpgrade.verdict !== 'red') return lines;
350
+ lines.push('Failing rules:');
351
+ const failing = postUpgrade.failing ?? [];
352
+ if (failing.length === 0) lines.push(' (check returned red without a rule id)');
353
+ for (const row of failing) {
354
+ lines.push(` ${row.ruleId} ×${row.count} — ${row.sample}`);
355
+ }
356
+ lines.push('Read behaviorChanges before editing code.');
357
+ for (const note of postUpgrade.behaviorChanges ?? []) lines.push(` ${note}`);
358
+ return lines;
359
+ }
@@ -97,6 +97,115 @@ export function printWarning(warning) {
97
97
  );
98
98
  }
99
99
 
100
+ /** Advisory the engine emits once per shared-root → slice edge. Humans see a group. */
101
+ export const SHARED_IMPORTS_SLICE_RULE_ID = 'SHARED_IMPORTS_SLICE';
102
+
103
+ /**
104
+ * Where the full per-edge list lives. Human check output names this on every
105
+ * grouped line. `--json` is the machine list; `--doctor` prints every edge.
106
+ */
107
+ export const SHARED_IMPORTS_SLICE_FULL_LIST =
108
+ 'Full list: ark-check --json or ark-check --doctor';
109
+
110
+ const SHARED_IMPORTS_SLICE_EXAMPLE_LIMIT = 3;
111
+
112
+ function formatAdvisoryCount(count) {
113
+ return String(count).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
114
+ }
115
+
116
+ function layerLabel(value) {
117
+ return typeof value === 'string' && value.trim() ? value.trim() : '?';
118
+ }
119
+
120
+ /** Group key: one fact per rule and layer edge. */
121
+ export function sharedImportsSliceGroupKey(warning) {
122
+ return [
123
+ typeof warning?.ruleId === 'string' ? warning.ruleId : SHARED_IMPORTS_SLICE_RULE_ID,
124
+ layerLabel(warning?.fromLayer),
125
+ layerLabel(warning?.toLayer),
126
+ ].join('\0');
127
+ }
128
+
129
+ function formatSharedImportsSliceExample(warning) {
130
+ const file = typeof warning?.file === 'string' && warning.file.trim() ? warning.file.trim() : 'unknown';
131
+ const line = Number.isInteger(warning?.line) && warning.line > 0 ? warning.line : 1;
132
+ const slice =
133
+ typeof warning?.toSlice === 'string' && warning.toSlice.trim() ? warning.toSlice.trim() : 'slice';
134
+ const target =
135
+ typeof warning?.target === 'string' && warning.target.trim() ? ` (${warning.target.trim()})` : '';
136
+ return `${file}:${line} → ${slice}${target}`;
137
+ }
138
+
139
+ /** One human line for a (ruleId, fromLayer → toLayer) group. Count plus top 3 edges. */
140
+ export function formatSharedImportsSliceSummary(group) {
141
+ const sample = group[0] ?? {};
142
+ const ruleId =
143
+ typeof sample.ruleId === 'string' ? sample.ruleId : SHARED_IMPORTS_SLICE_RULE_ID;
144
+ const count = group.length;
145
+ const noun = count === 1 ? 'bridge' : 'bridges';
146
+ const examples = group
147
+ .slice(0, SHARED_IMPORTS_SLICE_EXAMPLE_LIMIT)
148
+ .map(formatSharedImportsSliceExample)
149
+ .join('; ');
150
+ return (
151
+ `warning ${ruleId} ${layerLabel(sample.fromLayer)} → ${layerLabel(sample.toLayer)}: ` +
152
+ `${formatAdvisoryCount(count)} shared-root → slice ${noun} (e.g. ${examples}). ` +
153
+ `The wall is direct-only. ${SHARED_IMPORTS_SLICE_FULL_LIST}`
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Human check lines. SHARED_IMPORTS_SLICE collapses to one line per group.
159
+ * Every other warning stays one line. Does not mutate `warnings` — `--json`
160
+ * and doctor still walk the per-edge array.
161
+ */
162
+ export function humanWarningLines(warnings) {
163
+ const groups = new Map();
164
+ const slots = [];
165
+ for (const warning of warnings ?? []) {
166
+ if (warning?.ruleId !== SHARED_IMPORTS_SLICE_RULE_ID) {
167
+ slots.push({ kind: 'line', line: formatWarningLine(warning) });
168
+ continue;
169
+ }
170
+ const key = sharedImportsSliceGroupKey(warning);
171
+ let group = groups.get(key);
172
+ if (!group) {
173
+ group = [];
174
+ groups.set(key, group);
175
+ slots.push({ kind: 'group', key });
176
+ }
177
+ group.push(warning);
178
+ }
179
+ return slots.map((slot) =>
180
+ slot.kind === 'line' ? slot.line : formatSharedImportsSliceSummary(groups.get(slot.key))
181
+ );
182
+ }
183
+
184
+ function printColoredWarningLine(line) {
185
+ const painted = line.startsWith('warning ')
186
+ ? `${color.yellow('warning')}${line.slice('warning'.length)}`
187
+ : line;
188
+ console.error(painted);
189
+ }
190
+
191
+ /** ark-check human renderer. Grouping stops here; the warnings array is unchanged. */
192
+ export function printHumanWarnings(warnings) {
193
+ for (const line of humanWarningLines(warnings)) {
194
+ printColoredWarningLine(line);
195
+ }
196
+ }
197
+
198
+ /** Doctor human list: every shared-root → slice edge, not the grouped summary. */
199
+ export function printSharedImportsSliceBridgeList(warnings) {
200
+ const lines = (warnings ?? [])
201
+ .filter((warning) => warning?.ruleId === SHARED_IMPORTS_SLICE_RULE_ID)
202
+ .map((warning) => formatWarningLine(warning));
203
+ if (lines.length === 0) return;
204
+ console.log('');
205
+ console.log(`Shared-root → slice bridges (${formatAdvisoryCount(lines.length)}):`);
206
+ for (const line of lines) console.log(line);
207
+ }
208
+
100
209
  /** Non-blocking type-only placement debt: exit stays 0; do not paint ✖. */
101
210
  export function isNonBlockingPlacementDebt(violation) {
102
211
  return violation?.failsStrict === false;
@@ -49,6 +49,8 @@ type ArkConfigLayer = {
49
49
  /** Alias of reserved — empty pattern matches are allowed. */
50
50
  allowEmpty?: boolean;
51
51
  };
52
+ /** How a starred `sliceFolders` prefix is named. Absent equals `path`. */
53
+ type ArkConfigSliceIdentity = 'path' | 'stars';
52
54
  type ArkConfigRule = {
53
55
  from: string;
54
56
  to: string;
@@ -56,6 +58,11 @@ type ArkConfigRule = {
56
58
  message?: string;
57
59
  peerIsolation?: boolean;
58
60
  sliceFolders?: string[];
61
+ /**
62
+ * How a starred `sliceFolders` prefix is named.
63
+ * Absent and `path` keep today's ids. `stars` is the last literal plus the star bindings.
64
+ */
65
+ sliceIdentity?: ArkConfigSliceIdentity;
59
66
  /** Roots the repo declares shared on purpose — evidence, not unclassifiable. */
60
67
  sharedRoots?: string[];
61
68
  /** Directed slice→slice edges the repo declares on purpose. */