arkgate 4.8.19 → 4.8.20

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 (55) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +4 -4
  3. package/bin/ark-check-runtime.mjs +23 -0
  4. package/bin/ark-layer-match.mjs +141 -9
  5. package/bin/ark.mjs +6 -2
  6. package/bin/lib/analysis-engine.mjs +6 -6
  7. package/bin/lib/config-contract.mjs +1 -0
  8. package/bin/lib/doctor-advisories.mjs +2 -1
  9. package/bin/lib/doctor-plan.mjs +2 -0
  10. package/bin/lib/enforcement-honesty.mjs +30 -2
  11. package/bin/lib/html-report-depth.mjs +5 -1
  12. package/bin/lib/invariant-coverage.mjs +44 -13
  13. package/bin/lib/package-manager.mjs +5 -2
  14. package/bin/lib/physical-cohesion.mjs +68 -51
  15. package/bin/lib/reshape-decisions.mjs +2 -2
  16. package/bin/lib/rules-under-contract.mjs +40 -4
  17. package/bin/lib/start-install-recovery.mjs +156 -0
  18. package/bin/lib/start-preview.mjs +0 -54
  19. package/dist/{configTypes-VD0qcubY.d.ts → configTypes-VVknqcTJ.d.ts} +2 -0
  20. package/dist/{diagnosticCatalog-D2v0ASN2.d.ts → diagnosticCatalog-BPYWNyJR.d.ts} +9 -3
  21. package/dist/eslint/index.cjs +4 -4
  22. package/dist/eslint/index.d.ts +12 -4
  23. package/dist/eslint/index.js +4 -4
  24. package/dist/index.cjs +18 -18
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +20 -20
  27. package/dist/nestjs/index.cjs +5 -5
  28. package/dist/nestjs/index.d.ts +3 -3
  29. package/dist/nestjs/index.js +5 -5
  30. package/dist/runtime/index.cjs +10 -10
  31. package/dist/runtime/index.d.ts +6 -6
  32. package/dist/runtime/index.js +10 -10
  33. package/dist/{types-BSzRy2X1.d.ts → types-Cs-46DRu.d.ts} +1 -1
  34. package/dist/{types-D5GT5ZT8.d.ts → types-p_fCp6P-.d.ts} +1 -1
  35. package/docs/README.md +1 -1
  36. package/docs/configuration.md +20 -5
  37. package/docs/develop.md +1 -1
  38. package/docs/package-surface.md +2 -2
  39. package/docs/use.md +1 -1
  40. package/package.json +3 -3
  41. package/schemas/ark.config.schema.json +6 -0
  42. package/server.json +2 -2
  43. package/templates/agent-skills/README.md +1 -1
  44. package/templates/agent-skills/ark-adopt/SKILL.md +15 -1
  45. package/templates/agent-skills/ark-autopilot/SKILL.md +2 -0
  46. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  47. package/templates/agent-skills/ark-explain/SKILL.md +1 -1
  48. package/templates/agent-skills/ark-explore/SKILL.md +13 -1
  49. package/templates/agent-skills/ark-upgrade/SKILL.md +5 -3
  50. package/templates/skills/ark-adopt.md +15 -1
  51. package/templates/skills/ark-autopilot.md +2 -0
  52. package/templates/skills/ark-coverage.md +1 -1
  53. package/templates/skills/ark-explain.md +1 -1
  54. package/templates/skills/ark-explore.md +13 -1
  55. package/templates/skills/ark-upgrade.md +5 -3
@@ -228,6 +228,7 @@ export const ARK_CONFIG_SCHEMA = {
228
228
  },
229
229
  },
230
230
  },
231
+ sharedImportsSlice: { type: 'string', enum: ['deny'] },
231
232
  },
232
233
  },
233
234
  safety: {
@@ -200,7 +200,8 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
200
200
  physicalCohesion,
201
201
  files,
202
202
  root,
203
- decisionMemory
203
+ decisionMemory,
204
+ { layers: config?.layers, rules: rules ?? config?.rules }
204
205
  );
205
206
  // Prefer architecture facts paths when available; still union the doctor walk
206
207
  // so an empty-catalog residual can see Domain files the facts subset missed.
@@ -59,6 +59,7 @@ import {
59
59
  import {
60
60
  buildCoverageHonesty,
61
61
  computeDoctorEnforcementHonesty,
62
+ physicalCohesionResidualRemains,
62
63
  } from './enforcement-honesty.mjs';
63
64
  import {
64
65
  computePureLayerOptInNudge,
@@ -693,6 +694,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
693
694
  github: githubForBoundary,
694
695
  adoptionStance: stanceFile,
695
696
  stewardNudge: doctorAdvisories.stewardNudge,
697
+ physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion),
696
698
  });
697
699
 
698
700
  // Improvement compass: projection only — never feeds ok/valid/goal.met.
@@ -288,14 +288,26 @@ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extra
288
288
  * primaryNextAction?: string | null,
289
289
  * operatingMode?: string | null,
290
290
  * activeBlockingViolations?: number | null,
291
+ * physicalCohesionResidual?: boolean,
291
292
  * }} input
292
293
  */
294
+ export function physicalCohesionResidualRemains(cohesion) {
295
+ const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
296
+ if (findings.length === 0) return false;
297
+ const covered = new Set();
298
+ for (const row of cohesion?.reshapeDecisions?.current ?? []) {
299
+ if (row?.suppressesPilot === true && typeof row.concept === 'string') covered.add(row.concept);
300
+ }
301
+ return findings.some((finding) => !covered.has(finding?.concept));
302
+ }
303
+
293
304
  export function buildProductHonesty(input = {}) {
294
305
  const reasons = [];
295
306
  const cov = input.coverageHonesty;
296
307
  const base = input.baselineHonesty;
297
308
  const write = input.writePathHonesty;
298
309
  const designWeak = input.designWeak === true;
310
+ const cohesionResidual = input.physicalCohesionResidual === true;
299
311
  const dualTruth = input.packageVersionTruth?.dualTruth === true;
300
312
  const pinCode = input.packageVersionTruth?.code || write?.packagePinCode || null;
301
313
  const pinAbsent =
@@ -365,6 +377,14 @@ export function buildProductHonesty(input = {}) {
365
377
  });
366
378
  }
367
379
 
380
+ if (cohesionResidual) {
381
+ reasons.push({
382
+ id: 'physical-cohesion-residual',
383
+ message:
384
+ 'Physical cohesion residual remains — the gate verdict and exit code are unchanged, and the reshape card is one judgment, not a queue to empty.',
385
+ });
386
+ }
387
+
368
388
  if (dualTruth) {
369
389
  reasons.push({
370
390
  id: 'package-version-dual-truth',
@@ -551,6 +571,12 @@ export function buildProductHonesty(input = {}) {
551
571
  headline = 'Advisory-only adoption — merge status is not required';
552
572
  } else if (!unfinished) {
553
573
  headline = 'Honesty clear on residual signals';
574
+ } else if (
575
+ wholeTreeGoverned &&
576
+ architectureReasons.length === 1 &&
577
+ architectureReasons[0].id === 'physical-cohesion-residual'
578
+ ) {
579
+ headline = 'Residual remaining';
554
580
  } else if (coverageIncomplete) {
555
581
  headline = 'Not finished / not whole-tree guarantee';
556
582
  } else {
@@ -592,8 +618,8 @@ export function buildProductHonesty(input = {}) {
592
618
  : 'unknown';
593
619
 
594
620
  return {
595
- finished: !unfinished && wholeTreeGoverned && !designWeak && activeBlocking === 0,
596
- elegant: !designWeak && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
621
+ finished: !unfinished && wholeTreeGoverned && !designWeak && !cohesionResidual && activeBlocking === 0,
622
+ elegant: !designWeak && !cohesionResidual && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
597
623
  wholeTreeGuarantee:
598
624
  wholeTreeGoverned &&
599
625
  !designWeak &&
@@ -656,6 +682,7 @@ export function computeDoctorEnforcementHonesty({
656
682
  stewardNudge,
657
683
  nativeFailClosed,
658
684
  nativeFailClosedPolicy,
685
+ physicalCohesionResidual,
659
686
  } = {}) {
660
687
  const coverageHonesty = buildCoverageHonesty({
661
688
  percent: governedPercent,
@@ -713,6 +740,7 @@ export function computeDoctorEnforcementHonesty({
713
740
  considerMergeBoundary: true,
714
741
  emptyStewards,
715
742
  stewardNudge,
743
+ physicalCohesionResidual,
716
744
  });
717
745
  return {
718
746
  coverageHonesty,
@@ -17,6 +17,7 @@ import {
17
17
  buildBaselineHonesty,
18
18
  buildWritePathHonesty,
19
19
  buildProductHonesty,
20
+ physicalCohesionResidualRemains,
20
21
  } from './enforcement-honesty.mjs';
21
22
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
23
  import { attachExtraDoctorSections } from './doctor-advisories.mjs';
@@ -25,6 +26,7 @@ import { describePackageVersionDualTruth } from './field-install.mjs';
25
26
  import { buildDoctorImprovementCompass } from './improvement-compass-doctor.mjs';
26
27
  import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
27
28
  import { computePhysicalCohesion } from './physical-cohesion.mjs';
29
+ import { computeReshapeDecisionMemory } from './reshape-decisions.mjs';
28
30
  import { POST_GREEN_LEDE, operatingModeTitle } from './product-copy.mjs';
29
31
 
30
32
  function esc(value) {
@@ -153,6 +155,8 @@ export function buildReportDepthPayload(
153
155
  : packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
154
156
  ? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
155
157
  : null;
158
+ const physicalCohesion = computePhysicalCohesion(root, files);
159
+ physicalCohesion.reshapeDecisions = computeReshapeDecisionMemory(root, files).summary;
156
160
  const productHonesty = buildProductHonesty({
157
161
  coverageHonesty,
158
162
  baselineHonesty,
@@ -169,9 +173,9 @@ export function buildReportDepthPayload(
169
173
  : null,
170
174
  primaryNextAction: postGreenPath?.action ?? dualTruthNext,
171
175
  activeBlockingViolations: activeBlockingCount,
176
+ physicalCohesionResidual: physicalCohesionResidualRemains(physicalCohesion),
172
177
  });
173
178
  // Doctor parity: same physical-cohesion + baseline stale facts as runDoctor.
174
- const physicalCohesion = computePhysicalCohesion(root, files);
175
179
  const baselineStale =
176
180
  typeof baselineSplit.baselineStale === 'number' ? baselineSplit.baselineStale : null;
177
181
  // Improvement compass — same projection as doctor; notAScore; never a gate input.
@@ -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;
@@ -62,22 +62,50 @@ function titleMatchesInvariant(content, id) {
62
62
  const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*['"\`][^'"\`]*${escaped}[^'"\`]*['"\`]`, 'i');
63
63
  return re.test(content) || content.includes(id);
64
64
  }
65
- function symbolPresent(fileContents, symbol) {
65
+ function escapeRegExp(value) {
66
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+ /**
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.
73
+ */
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));
84
+ }
85
+ /** Witness path, or undefined when no non-test file declares `symbol`. */
86
+ function symbolPresent(fileContents, testFiles, symbol) {
66
87
  if (!symbol)
67
- return false;
68
- // Support Aggregate.method or bare method name.
88
+ return undefined;
69
89
  const parts = symbol.split('.');
70
- const needle = parts[parts.length - 1];
90
+ const needle = parts[parts.length - 1] ?? '';
91
+ if (!needle)
92
+ return undefined;
71
93
  const className = parts.length > 1 ? parts[0] : null;
72
- for (const content of Object.values(fileContents)) {
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))
99
+ continue;
100
+ const content = fileContents[file];
101
+ if (!content)
102
+ continue;
73
103
  if (className && !content.includes(className))
74
104
  continue;
75
- if (new RegExp(`(?:function\\s+|\\b)${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[(<]`).test(content) ||
76
- content.includes(symbol)) {
77
- return true;
78
- }
105
+ if (declaresIdentifier(content, needle))
106
+ return file;
79
107
  }
80
- return false;
108
+ return undefined;
81
109
  }
82
110
  export function evaluateInvariantCoverage(input) {
83
111
  const invariants = input.arkRules.invariants ?? [];
@@ -131,9 +159,11 @@ export function evaluateInvariantCoverage(input) {
131
159
  if (testEvidenceFile !== undefined)
132
160
  evidence.push('test-title');
133
161
  }
134
- if (symbol && symbolPresent(input.fileContents, symbol)) {
162
+ const symbolEvidenceFile = symbol
163
+ ? symbolPresent(input.fileContents, testFiles, symbol)
164
+ : undefined;
165
+ if (symbolEvidenceFile)
135
166
  evidence.push('symbol');
136
- }
137
167
  // Covered if any requested evidence is present.
138
168
  // When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
139
169
  const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
@@ -154,6 +184,7 @@ export function evaluateInvariantCoverage(input) {
154
184
  partial,
155
185
  description: inv.description,
156
186
  ...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
187
+ ...(symbolEvidenceFile !== undefined ? { symbolEvidenceFile } : {}),
157
188
  ...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
158
189
  coverageRootsDeclared: rootsDeclared,
159
190
  });
@@ -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
- return `npx --package=arkgate ${bin}${argsStr ? ` ${argsStr}` : ''}`;
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
  /**
@@ -15,6 +15,7 @@
15
15
  * as W01 layer roles: a miss costs a warning line, never a verdict.
16
16
  */
17
17
  import path from 'node:path';
18
+ import { layerForRelativePath, sliceIdForPath } from '../ark-layer-match.mjs';
18
19
 
19
20
  /** ADR 0010 D3 — corpus-calibrated, fixed. */
20
21
  const CLUSTER_MIN = 40;
@@ -126,76 +127,92 @@ export function computePhysicalCohesion(root, files) {
126
127
  };
127
128
  }
128
129
 
130
+ function governedDestinationDir(anchor, concept) {
131
+ return !anchor || anchor === '.' ? concept : `${anchor}/${concept}`;
132
+ }
133
+
134
+ function anchorIsConsolidationSubtree(anchor, concept) {
135
+ return String(anchor).split('/').some((part) => part === concept);
136
+ }
137
+
138
+ function declaredSliceFolders(rules) {
139
+ const out = [];
140
+ const seen = new Set();
141
+ for (const rule of Array.isArray(rules) ? rules : []) {
142
+ const folders = Array.isArray(rule?.sliceFolders) ? rule.sliceFolders : [];
143
+ for (const folder of folders) {
144
+ if (typeof folder !== 'string' || folder.length === 0 || seen.has(folder.toLowerCase())) continue;
145
+ seen.add(folder.toLowerCase());
146
+ out.push(folder);
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function destinationKeepsLayerAndSlice(fromRel, toRel, layers, sliceFolders) {
153
+ return layerForRelativePath(fromRel, layers) === layerForRelativePath(toRel, layers)
154
+ && sliceIdForPath(fromRel, sliceFolders) === sliceIdForPath(toRel, sliceFolders);
155
+ }
156
+
157
+ function withheldPilot(concept, note) {
158
+ return { proposed: true, applied: false, neverMechanicalSafe: true, concept, note, nextPilot: null };
159
+ }
160
+
129
161
  /**
130
- * R2 — the proposed reshape pilot for the TOP finding (one at a time, Q04
131
- * discipline; ADR 0010 D4–D7). Proposal only: no apply path exists. Moves
132
- * target the smallest convention-free anchor; sampled `to` paths must fall
133
- * under the consolidated feature directory the agent will create via the
134
- * governed write path (T02 preflight validates every real move there).
162
+ * R2 — proposed reshape pilot (ADR 0010 D4–D7). `to` is `concept/` under the
163
+ * source anchor and is kept only when the generated layer and slice matchers
164
+ * agree. Tooling does not import Kernel preflight. Withheld when every
165
+ * candidate fails.
135
166
  */
136
- export function computeReshapePilot(cohesion, files, root) {
167
+ export function computeReshapePilot(cohesion, files, root, contract) {
137
168
  const top = cohesion?.findings?.[0];
138
169
  if (!top) return null;
139
- // Recompute the FULL anchor map for the top concept: the finding's anchors
140
- // are display-filtered (>= MIRROR_MIN, capped), and pilot selection over
141
- // that trimmed list falsely reported "nothing to move" when the only
142
- // movable anchor sat below the display floor (cross-model review finding).
170
+ const layers = contract?.layers;
171
+ const sliceFolders = declaredSliceFolders(contract?.rules);
172
+ // Full anchor map: the finding's anchors are display-filtered (>= MIRROR_MIN).
143
173
  const byAnchor = new Map();
144
174
  const relOf = (abs) => path.relative(root, abs).split(path.sep).join('/');
145
- for (const abs of Array.isArray(files) ? files : []) {
175
+ const fileList = Array.isArray(files) ? files : [];
176
+ for (const abs of fileList) {
146
177
  const rel = relOf(abs);
147
- const r = classifyPhysical(rel);
148
- if (!r || r.concept !== top.concept) continue;
149
- byAnchor.set(r.anchor, (byAnchor.get(r.anchor) ?? 0) + 1);
178
+ const ranked = classifyPhysical(rel);
179
+ if (!ranked || ranked.concept !== top.concept) continue;
180
+ byAnchor.set(ranked.anchor, (byAnchor.get(ranked.anchor) ?? 0) + 1);
150
181
  }
151
- const targetDir = `src/features/${top.concept}`;
152
182
  const movable = [...byAnchor.entries()]
153
- .filter(
154
- ([anchor]) =>
155
- !CONVENTION_ANCHOR_RE.test(`${anchor}/`) &&
156
- // The consolidation target subtree is DONE, not a source — without
157
- // this the loop re-proposes the files it just moved, forever
158
- // (end-to-end pilot-loop finding).
159
- anchor !== targetDir &&
160
- !anchor.startsWith(`${targetDir}/`)
161
- )
183
+ .filter(([anchor]) => !CONVENTION_ANCHOR_RE.test(`${anchor}/`) && !anchorIsConsolidationSubtree(anchor, top.concept))
162
184
  .map(([anchor, count]) => ({ path: anchor, files: count }))
163
185
  .sort((a, b) => a.files - b.files || (a.path < b.path ? -1 : 1));
164
186
  if (movable.length === 0) {
165
- return {
166
- proposed: true,
167
- applied: false,
168
- neverMechanicalSafe: true,
169
- concept: top.concept,
170
- note: 'Every remaining anchor for this concept is fixed by framework convention or already consolidated — nothing to move; consider the merge-card review instead.',
171
- nextPilot: null,
172
- };
187
+ return withheldPilot(top.concept, 'Every remaining anchor for this concept is fixed by framework convention or already consolidated — nothing to move; consider the merge-card review instead.');
188
+ }
189
+ const legalAnchors = [];
190
+ for (const anchor of movable) {
191
+ const targetDir = governedDestinationDir(anchor.path, top.concept);
192
+ const legal = fileList.map(relOf).filter((rel) => {
193
+ const ranked = classifyPhysical(rel);
194
+ if (!ranked || ranked.concept !== top.concept || ranked.anchor !== anchor.path) return false;
195
+ const to = `${targetDir}/${rel.split('/').at(-1)}`;
196
+ return destinationKeepsLayerAndSlice(rel, to, layers, sliceFolders);
197
+ }).sort().map((rel) => ({ from: rel, to: `${targetDir}/${rel.split('/').at(-1)}` }));
198
+ if (legal.length > 0) legalAnchors.push({ ...anchor, targetDir, legal });
199
+ }
200
+ if (legalAnchors.length === 0) {
201
+ return withheldPilot(top.concept, 'No governed destination keeps the layer and the slice.');
173
202
  }
174
- // Smallest movable cluster worth piloting; if none reaches the floor, take
175
- // the largest movable anchor so the pilot still exists and stays honest.
176
- const pilotAnchor = movable.find((a) => a.files >= 10) ?? movable[movable.length - 1];
177
- const rels = (Array.isArray(files) ? files : [])
178
- .map(relOf)
179
- .filter((rel) => {
180
- const r = classifyPhysical(rel);
181
- return r && r.concept === top.concept && r.anchor === pilotAnchor.path;
182
- })
183
- .sort();
184
- const moves = rels.slice(0, MAX_MOVE_SAMPLE).map((rel) => ({
185
- from: rel,
186
- to: `${targetDir}/${rel.split('/').at(-1)}`,
187
- }));
203
+ // Smallest cluster of at least 10 files; otherwise the largest movable anchor.
204
+ const pilotAnchor = legalAnchors.find((a) => a.files >= 10) ?? legalAnchors.at(-1);
188
205
  return {
189
206
  proposed: true,
190
207
  applied: false,
191
208
  neverMechanicalSafe: true,
192
209
  concept: top.concept,
193
210
  nextPilot: {
194
- pilotTarget: `${top.concept} @ ${pilotAnchor.path} (${pilotAnchor.files} file(s))`,
195
- move: `Consolidate the ${top.concept} cluster from ${pilotAnchor.path} under ${targetDir}/ — one anchor only, moves proposed as an architecture change map and validated by the atomic preflight before any write.`,
196
- moveSample: moves,
197
- movesTotal: rels.length,
198
- successSignal: `re-run doctor: the ${top.concept} cluster count drops and the verdict stays green`,
211
+ pilotTarget: `${top.concept} @ ${pilotAnchor.path} (${pilotAnchor.legal.length} file(s))`,
212
+ move: `Consolidate the ${top.concept} cluster from ${pilotAnchor.path} under ${pilotAnchor.targetDir}/ — one anchor only, same layer and slice, moves proposed as an architecture change map and validated by the atomic preflight before any write.`,
213
+ moveSample: pilotAnchor.legal.slice(0, MAX_MOVE_SAMPLE),
214
+ movesTotal: pilotAnchor.legal.length,
215
+ successSignal: `re-run doctor: the ${top.concept} cluster count drops, layer and slice id unchanged, card still proposed`,
199
216
  killSwitch: 'revert this move set; nothing else was touched',
200
217
  doNot: [
201
218
  'never move files under app/ or pages/ — fixed by framework convention',
@@ -214,7 +214,7 @@ export function computeReshapeDecisionMemory(root, files, today = new Date().toI
214
214
  }
215
215
 
216
216
  /** Select one actionable finding while respecting explicit current verdicts. */
217
- export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis) {
217
+ export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis, contract) {
218
218
  const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
219
219
  if (findings.length === 0) return null;
220
220
  const currentByTarget = new Map(
@@ -224,7 +224,7 @@ export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis
224
224
  const anchors = analysis.anchorSets.get(finding.concept) ?? [];
225
225
  const decision = currentByTarget.get(targetKey(finding.concept, anchors));
226
226
  if (decision?.suppressesPilot) continue;
227
- const pilot = computeReshapePilot({ ...cohesion, findings: [finding] }, files, root);
227
+ const pilot = computeReshapePilot({ ...cohesion, findings: [finding] }, files, root, contract);
228
228
  if (!pilot?.nextPilot) return pilot;
229
229
  return {
230
230
  ...pilot,
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import path from 'node:path';
8
8
  import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
9
- import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
9
+ import { evaluateInvariantCoverage, formatCoverageDiscards } from './invariant-coverage.mjs';
10
10
  import {
11
11
  coverageOptionsFromConfig,
12
12
  invariantIdsFromCatalog,
@@ -138,9 +138,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
138
138
  fileContents: coverageInputs.fileContents,
139
139
  testFiles: coverageInputs.testFiles,
140
140
  testGlobsMissing: coverageInputs.testGlobsMissing,
141
- // No coverageStats / coverageRoots: this caller reads coverage ROWS and
142
- // drops the violations, and both only shape violation messages. Passing
143
- // them would look like wiring while changing nothing observable here.
141
+ // Stats shape the green doctor line (discard counts), not only a failing
142
+ // sentence. Roots stay out: this caller drops violations.
143
+ ...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
144
144
  coverageBudgetExhausted: coverageInputs.coverageBudgetExhausted === true,
145
145
  });
146
146
  const covById = new Map(
@@ -189,6 +189,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
189
189
  const uncoveredTruncated = Math.max(0, uncoveredAll.length - UNCOVERED_CATALOG_MAX);
190
190
  const uncovered = uncoveredAll.slice(0, UNCOVERED_CATALOG_MAX);
191
191
 
192
+ const symbolEvidence = (coverage.coverage ?? [])
193
+ .filter((row) => typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0)
194
+ .map((row) => ({ id: row.invariantId, file: row.symbolEvidenceFile }));
192
195
  const coveredAll = (coverage.coverage ?? [])
193
196
  .filter((row) => row.covered)
194
197
  .map((row) => ({
@@ -196,6 +199,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
196
199
  layer: row.layer ?? null,
197
200
  mode: row.mode ?? null,
198
201
  description: row.description ?? null,
202
+ ...(typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0
203
+ ? { symbolEvidenceFile: row.symbolEvidenceFile }
204
+ : {}),
199
205
  }));
200
206
  const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
201
207
  const coveredSample = coveredAll.slice(0, COVERED_SAMPLE_MAX);
@@ -259,6 +265,8 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
259
265
  uncoveredTruncated,
260
266
  coveredSample,
261
267
  coveredTruncated,
268
+ symbolEvidence,
269
+ ...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
262
270
  mergePlanes,
263
271
  notAScore: true,
264
272
  note: 'ArkRules plane (intra-layer) — counts and catalog, never a score. Green with uncovered residual must say so. Structure sensors are heuristics; invariants are catalog+coverage evidence, not a business runtime.',
@@ -280,6 +288,27 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
280
288
  }
281
289
  }
282
290
 
291
+ /**
292
+ * Symbol witness paths and discard counts. Printed on green runs too —
293
+ * a passing check used to hide both. Depth and byte caps stay internal;
294
+ * only the counts already in the failure sentence are shown.
295
+ * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
296
+ * @returns {string[]}
297
+ */
298
+ export function formatArkRulesEvidenceLines(section) {
299
+ if (!section || typeof section !== 'object') return [];
300
+ const lines = [];
301
+ const evidence = Array.isArray(section.symbolEvidence) ? section.symbolEvidence : [];
302
+ for (const row of evidence) {
303
+ if (typeof row?.file !== 'string' || row.file.length === 0) continue;
304
+ const id = typeof row.id === 'string' && row.id.length > 0 ? row.id : 'invariant';
305
+ lines.push(`ArkRules: ${id} symbol ${row.file}`);
306
+ }
307
+ const discard = formatCoverageDiscards(section.coverageStats);
308
+ if (discard) lines.push(`ArkRules:${discard}`);
309
+ return lines;
310
+ }
311
+
283
312
  /**
284
313
  * Compact / details doctor lines. Empty when the map is off — absence is silent.
285
314
  * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
@@ -332,6 +361,7 @@ export function formatArkRulesDoctorLines(section) {
332
361
  else if (typeof section.note === 'string' && /failed/i.test(section.note)) {
333
362
  lines.push(`ArkRules: ${section.note}`);
334
363
  }
364
+ lines.push(...formatArkRulesEvidenceLines(section));
335
365
  return lines;
336
366
  }
337
367
 
@@ -475,6 +505,7 @@ export function formatRulesUnderContractHtml(section, esc) {
475
505
  <code>${escape(c.id)}</code>
476
506
  <span class="tag">covered</span>
477
507
  <span class="dim">· ${escape(c.layer || '?')}</span>
508
+ ${c.symbolEvidenceFile ? `<span class="dim">· ${escape(c.symbolEvidenceFile)}</span>` : ''}
478
509
  ${c.description ? `<div class="msg">${escape(c.description)}</div>` : ''}
479
510
  </li>`
480
511
  )
@@ -528,6 +559,11 @@ export function formatRulesUnderContractHtml(section, esc) {
528
559
  }
529
560
  ${uncoveredBlock}
530
561
  ${coveredBlock}
562
+ ${
563
+ formatArkRulesEvidenceLines(section)
564
+ .map((line) => `<p class="muted" style="margin-top:.35rem;font-size:.84rem">${escape(line)}</p>`)
565
+ .join('')
566
+ }
531
567
  ${
532
568
  coveredSample.length || uncovered.length
533
569
  ? `<p class="muted" style="margin-top:.65rem;font-size:.84rem">Covered = catalog evidence found (symbol and/or test title). Not a claim that business semantics are fully proven end-to-end.</p>`