arkgate 3.6.1 → 3.7.0

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.
@@ -29,7 +29,7 @@ export const DESIGN_SMELL_OUTCOMES = Object.freeze({
29
29
  'io-under-application':
30
30
  'Business/application code reaches the database or external APIs directly — the AI will keep pasting I/O into the wrong place. Put data access behind a port/adapter.',
31
31
  'handler-in-persistence':
32
- 'HTTP handlers live under data/repository folders — names look like “storage” but they are routes. Move handlers to the API/UI layer so the AI stops mixing transport and storage.',
32
+ 'Static framework HTTP imports or route definitions live under data/repository folders — names look like “storage” but the code still owns transport. Move HTTP handling to the API/UI layer so the AI stops mixing transport and storage.',
33
33
  'god-module':
34
34
  'A few huge files own too many responsibilities — the AI cannot safely edit one concern without breaking others. Split the pilot file by job (one export surface per concern).',
35
35
  'domain-logic-in-ui':
@@ -70,6 +70,12 @@ const IO_IMPORT_RE =
70
70
  /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm|typeorm|knex|mongodb|pg|mysql2|better-sqlite3|ioredis|redis)['"]|require\(\s*['"](?:@?prisma\/client|pg|knex|typeorm)/;
71
71
  const HANDLER_CONTENT_RE =
72
72
  /\b(?:@Controller|@Get|@Post|@Put|@Delete|Router\(\)|createRouter|express\.Router|fastify\.(?:get|post)|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b|export\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=)/;
73
+ const FRAMEWORK_HTTP_IMPORT_RE =
74
+ /(?:^|[;\n])\s*(?:import\s+(?:type\s+)?(?:[^;]{0,512}?\s+from\s+)?|export\s+(?:type\s+)?[^;]{0,512}?\s+from\s+)['"]next\/server(?:\.js)?['"]/;
75
+ const ROUTE_DEFINITION_CALL_RE =
76
+ /\bdefineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/;
77
+ const ROUTE_DEFINITION_DECLARATION_RE =
78
+ /\b(?:export\s+)?(?:declare\s+)?(?:async\s+)?function\s+defineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/g;
73
79
  const DOMAIN_LOGIC_UI_RE =
74
80
  /\b(?:export\s+)?(?:async\s+)?function\s+(?:can|calculate|compute|should)[A-Z]\w*|\b(?:export\s+)?const\s+(?:can|calculate|compute|should)[A-Z]\w*\s*=/;
75
81
  const EXPORT_RE =
@@ -139,7 +145,33 @@ function isPresentationLayer(name) {
139
145
  function isPersistenceLayer(name) {
140
146
  return (
141
147
  typeof name === 'string' &&
142
- (/persist|repository|infra|data.?access/i.test(name) || name === 'PersistenceAdapters')
148
+ /persist|repository|data.?access/i.test(name)
149
+ );
150
+ }
151
+
152
+ function stripObviousCommentsAndTemplates(source) {
153
+ return source
154
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
155
+ .replace(/\/\/[^\n]*/g, ' ')
156
+ .replace(/`(?:\\[\s\S]|[^\\`])*`/g, ' ');
157
+ }
158
+
159
+ function hasFrameworkHttpImport(source) {
160
+ return FRAMEWORK_HTTP_IMPORT_RE.test(stripObviousCommentsAndTemplates(source));
161
+ }
162
+
163
+ function hasRouteDefinitionCall(source) {
164
+ const code = stripObviousCommentsAndTemplates(source)
165
+ .replace(/(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/g, ' ')
166
+ .replace(ROUTE_DEFINITION_DECLARATION_RE, 'function __ark_defineRoute_declaration__(');
167
+ return ROUTE_DEFINITION_CALL_RE.test(code);
168
+ }
169
+
170
+ function hasHollowPersistenceShape(source) {
171
+ return (
172
+ hasFrameworkHttpImport(source) ||
173
+ hasRouteDefinitionCall(source) ||
174
+ HANDLER_CONTENT_RE.test(source)
143
175
  );
144
176
  }
145
177
 
@@ -172,6 +204,24 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
172
204
  if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
173
205
  relFiles.push(rel);
174
206
  }
207
+ // Y02: the general scan cap must not hide Persistence candidates in large
208
+ // trees. Filter by the stable role/path heuristics first, then bound reads.
209
+ const persistenceUniverse = [];
210
+ for (const f of files) {
211
+ const rel = normalizeRel(resolvedRoot, f);
212
+ if (!rel || rel.startsWith('..')) continue;
213
+ if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel)) continue;
214
+ if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
215
+ persistenceUniverse.push(rel);
216
+ }
217
+ const allPersistenceCandidates = [...new Set(persistenceUniverse)].sort().filter(
218
+ (rel) =>
219
+ PERSISTENCE_PATH_RE.test(rel) ||
220
+ isPersistenceLayer(layerNameFor(resolvedRoot, rel, config))
221
+ );
222
+ const persistenceCandidates = allPersistenceCandidates.slice(0, MAX_SCAN_FILES);
223
+ const persistenceCandidatesTruncated =
224
+ allPersistenceCandidates.length - persistenceCandidates.length;
175
225
 
176
226
  // soft-contract: layers with files but no rule edges
177
227
  const withoutRules = Array.isArray(coverage?.layersWithoutRules)
@@ -220,8 +270,7 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
220
270
  }
221
271
  if (/\/(?:domain|application|infrastructure|adapters)\//.test(rel)) hasHexPorts = true;
222
272
 
223
- const abs = path.join(resolvedRoot, rel);
224
- const source = readTextLimited(abs);
273
+ const source = readTextLimited(path.join(resolvedRoot, rel));
225
274
  if (source == null) continue;
226
275
 
227
276
  const layer = layerNameFor(resolvedRoot, rel, config);
@@ -232,13 +281,6 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
232
281
  godEvidence.push(rel);
233
282
  }
234
283
 
235
- if (
236
- (PERSISTENCE_PATH_RE.test(rel) || isPersistenceLayer(layer)) &&
237
- HANDLER_CONTENT_RE.test(source)
238
- ) {
239
- handlerInPersist.push(rel);
240
- }
241
-
242
284
  if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && DOMAIN_LOGIC_UI_RE.test(source)) {
243
285
  domainInUi.push(rel);
244
286
  }
@@ -257,6 +299,13 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
257
299
  }
258
300
  }
259
301
 
302
+ for (const rel of persistenceCandidates) {
303
+ const source = readTextLimited(path.join(resolvedRoot, rel));
304
+ if (source != null && hasHollowPersistenceShape(source)) {
305
+ handlerInPersist.push(rel);
306
+ }
307
+ }
308
+
260
309
  if (!falseGreen?.risk && ioUnderAppFiles.length > 0) {
261
310
  smells.push(
262
311
  makeDesignSmell({
@@ -270,13 +319,17 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
270
319
  }
271
320
 
272
321
  if (handlerInPersist.length > 0) {
322
+ const capNote =
323
+ persistenceCandidatesTruncated > 0
324
+ ? `; ${persistenceCandidatesTruncated} more Persistence candidate(s) were not inspected by the bounded scan`
325
+ : '';
273
326
  smells.push(
274
327
  makeDesignSmell({
275
328
  id: 'handler-in-persistence',
276
329
  severity: 'warn',
277
- message: `HTTP/route handler shape found under persistence/repository paths (${handlerInPersist.length} file(s)) — semantic false-green risk.`,
330
+ message: `Static framework HTTP import or route-definition/handler shape found in Persistence-role modules (${handlerInPersist.length} file(s)${capNote}) — semantic false-green risk.`,
278
331
  evidence: handlerInPersist.slice(0, 12),
279
- fix: 'Move handlers to Presentation/API; keep Persistence as data access only (/ark-explore shape-focus).',
332
+ fix: 'Move HTTP imports and route definitions to Presentation/API; keep Persistence as data access only (/ark-explore shape-focus).',
280
333
  })
281
334
  );
282
335
  }
@@ -414,7 +467,7 @@ function successSignalFor(id) {
414
467
  case 'io-under-application':
415
468
  return '0 Application-layer files import prisma/supabase/drizzle/pg clients; I/O behind ports';
416
469
  case 'handler-in-persistence':
417
- return '0 HTTP handler shapes under persistence/repository globs';
470
+ return '0 static framework HTTP imports or route-definition/handler shapes in Persistence-role modules';
418
471
  case 'god-module':
419
472
  return 'Pilot god module split; fan-in and export surface reduced without new edge violations';
420
473
  case 'domain-logic-in-ui':
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Doctor's advisory sensors, aggregated (W01 contract health + U05 ambient
3
- * state + X04 physical cohesion). Advisory only: nothing here feeds a
2
+ * Doctor's advisory sensors, aggregated (W01 contract health, U05 ambient
3
+ * state, X04 physical cohesion, Y03 parse health). Nothing here feeds a
4
4
  * verdict, designFitness, or an exit code. One seam keeps doctor-plan.mjs
5
5
  * inside its module budget as new advisory surfaces land.
6
6
  */
@@ -8,17 +8,30 @@ import { computeAmbientState, printAmbientStateSection } from './ambient-state.m
8
8
  import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
9
9
  import {
10
10
  computePhysicalCohesion,
11
- computeReshapePilot,
12
11
  printPhysicalCohesionSection,
13
12
  } from './physical-cohesion.mjs';
13
+ import {
14
+ computeDecisionAwareReshapePilot,
15
+ computeReshapeDecisionMemory,
16
+ printReshapeDecisionsSection,
17
+ } from './reshape-decisions.mjs';
18
+ import { printParseHealthSection, summarizeParseHealth } from './parse-health.mjs';
14
19
 
15
- export function computeDoctorAdvisories(root, config, cov, rules, files, ts) {
20
+ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth) {
16
21
  const physicalCohesion = computePhysicalCohesion(root, files);
17
- physicalCohesion.reshapePilot = computeReshapePilot(physicalCohesion, files, root);
22
+ const decisionMemory = computeReshapeDecisionMemory(root, files);
23
+ physicalCohesion.reshapeDecisions = decisionMemory.summary;
24
+ physicalCohesion.reshapePilot = computeDecisionAwareReshapePilot(
25
+ physicalCohesion,
26
+ files,
27
+ root,
28
+ decisionMemory
29
+ );
18
30
  return {
19
31
  contractHealth: computeContractHealth(root, config, cov, rules),
20
32
  ambientState: computeAmbientState(ts, root, config, files),
21
33
  physicalCohesion,
34
+ parseHealth: parseHealth ?? summarizeParseHealth(),
22
35
  };
23
36
  }
24
37
 
@@ -30,4 +43,6 @@ export function printDoctorAdvisories(advisories, io) {
30
43
  advisories.physicalCohesion?.reshapePilot,
31
44
  io
32
45
  );
46
+ printReshapeDecisionsSection(advisories.physicalCohesion?.reshapeDecisions, io);
47
+ printParseHealthSection(advisories.parseHealth, io);
33
48
  }
@@ -424,7 +424,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
424
424
  patternBets: patternBetsForLoop,
425
425
  designSmells,
426
426
  });
427
- const { contractHealth, ambientState, physicalCohesion } = computeDoctorAdvisories(root, config, cov, rules, files, options.ts); // W01+U05+X04 advisories — never a verdict
427
+ const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth); // advisory only — never a verdict
428
428
 
429
429
  if (asJson) {
430
430
  console.log(
@@ -461,11 +461,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
461
461
  goldenPattern,
462
462
  // Q04: one-pilot loop (extraction card → re-doctor).
463
463
  pilotLoop,
464
- // Advisories, never a verdict: W01 contract health, U05 ambient
465
- // state (opt-in), X04 physical cohesion + proposed reshape pilot.
466
- contractHealth,
467
- ambientState,
468
- physicalCohesion,
464
+ // Advisories, never a verdict: W01 contract health, U05 ambient state,
465
+ // X04 physical cohesion/reshape pilot, Y03 parse health.
466
+ ...doctorAdvisories,
469
467
  governed: cov.governed,
470
468
  emptyLayers: cov.emptyLayers,
471
469
  layersWithoutRules: cov.layersWithoutRules,
@@ -647,7 +645,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
647
645
  );
648
646
  }
649
647
 
650
- printDoctorAdvisories({ contractHealth, ambientState, physicalCohesion }, { line, warn, color }); // advisory sections
648
+ printDoctorAdvisories(doctorAdvisories, { line, warn, color }); // advisory sections
651
649
 
652
650
  console.log('');
653
651
  console.log(color.bold('Coverage'));
@@ -152,6 +152,46 @@ function ambientStateHtml(state) {
152
152
  </section>`;
153
153
  }
154
154
 
155
+ function reshapeDecisionsHtml(memory) {
156
+ if (!memory) return '';
157
+ const lifecycle = memory.lifecycle ?? {};
158
+ const rows = [];
159
+ if (memory.decisionFile?.invalid) {
160
+ rows.push(
161
+ `<p><span class="tag warn">invalid</span> ${esc(memory.decisionFile.path)} is ignored; no reshape decision suppresses a pilot.</p>`
162
+ );
163
+ }
164
+ for (const decision of memory.current ?? []) {
165
+ const tag = decision.verdict === 'accepted' ? 'ok' : 'warn';
166
+ const review = decision.reviewBy ? ` · review-by ${esc(decision.reviewBy)}` : '';
167
+ rows.push(
168
+ `<p><span class="tag ${tag}">${esc(decision.verdict)}</span> <b>${esc(decision.concept)}</b>${review} — ${esc(decision.reason)}${decision.suppressesPilot ? ' Pilot pressure suppressed; mirror facts remain visible.' : ' Pilot remains available.'}</p>`
169
+ );
170
+ }
171
+ if ((memory.currentCount ?? 0) > (memory.current?.length ?? 0)) {
172
+ rows.push(`<p class="muted">…(+${memory.currentCount - memory.current.length} more current decision(s))</p>`);
173
+ }
174
+ if ((lifecycle.expiredCount ?? 0) > 0) {
175
+ rows.push(`<p><span class="tag warn">expired</span> ${lifecycle.expiredCount} decision(s) no longer apply; pilot pressure is active again.</p>`);
176
+ }
177
+ if ((lifecycle.malformedCount ?? 0) > 0) {
178
+ rows.push(`<p><span class="tag warn">malformed</span> ${lifecycle.malformedCount} review-by date(s) are invalid; those decisions are ignored.</p>`);
179
+ }
180
+ if ((lifecycle.staleCount ?? 0) > 0) {
181
+ const stale = (lifecycle.stale ?? [])
182
+ .slice(0, 4)
183
+ .map((decision) => `<code>${esc(decision.concept)}</code>`)
184
+ .join(' · ');
185
+ const more = lifecycle.staleCount > 4 ? ` …(+${lifecycle.staleCount - 4} more)` : '';
186
+ rows.push(`<p><span class="tag warn">stale</span> ${lifecycle.staleCount} decision(s) have a changed anchor set and no longer apply: ${stale}${more}</p>`);
187
+ }
188
+ if ((lifecycle.undated ?? 0) > 0) {
189
+ rows.push(`<p class="muted">${lifecycle.undated} current decision(s) have no review-by date.</p>`);
190
+ }
191
+ if (rows.length === 0) return '';
192
+ return `<div data-advisory="reshapeDecisions"><h3>Reshape decisions <span class="muted">(explicit; pilot pressure only)</span></h3>${rows.join('\n')}</div>`;
193
+ }
194
+
155
195
  function physicalCohesionHtml(pc) {
156
196
  if (!pc) return '';
157
197
  const findings = Array.isArray(pc.findings) ? pc.findings : [];
@@ -173,10 +213,28 @@ function physicalCohesionHtml(pc) {
173
213
  <section data-advisory="physicalCohesion">
174
214
  <h2>Physical cohesion <span class="muted">(advisory — facts, not a score; the verdict is unchanged)</span></h2>
175
215
  ${body}
216
+ ${reshapeDecisionsHtml(pc.reshapeDecisions)}
176
217
  ${pilot}
177
218
  </section>`;
178
219
  }
179
220
 
221
+ function parseHealthHtml(health) {
222
+ if (!health) return '';
223
+ const files = Array.isArray(health.files) ? health.files : [];
224
+ const body = health.available === false
225
+ ? '<p class="muted">Parse health was not available for this rendering — no clean claim is made.</p>'
226
+ : health.affectedFiles === 0
227
+ ? `<p class="muted">No parse diagnostics found across ${health.scannedFiles ?? 0} governed file(s) scanned.</p>`
228
+ : `<p><span class="tag warn">${health.affectedFiles} affected</span> ${health.diagnosticCount} parse diagnostic(s) across ${health.scannedFiles} governed file(s).</p>` +
229
+ `<ul>${files.map((f) => `<li><code>${esc(f.file)}</code> — ${f.diagnosticCount} parse diagnostic(s)</li>`).join('')}</ul>` +
230
+ (health.truncated > 0 ? `<p class="muted">…(+${health.truncated} more affected file(s); doctor list capped)</p>` : '');
231
+ return `
232
+ <section data-advisory="parseHealth">
233
+ <h2>Parse health <span class="muted">(advisory — unreadable syntax is never silently called clean)</span></h2>
234
+ ${body}
235
+ </section>`;
236
+ }
237
+
180
238
  /**
181
239
  * Render every doctor advisory as report sections. Keys must cover everything
182
240
  * `computeDoctorAdvisories` returns — the parity guard enforces it.
@@ -189,6 +247,7 @@ export function renderAdvisorySections(advisories, escape) {
189
247
  contractHealthHtml(advisories.contractHealth),
190
248
  ambientStateHtml(advisories.ambientState),
191
249
  physicalCohesionHtml(advisories.physicalCohesion),
250
+ parseHealthHtml(advisories.parseHealth),
192
251
  ]
193
252
  .filter(Boolean)
194
253
  .join('\n');
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Y03 — parse honesty over the ASTs already created by architecture-scan.
3
+ * Advisory only: counts are transported, never raw TypeScript diagnostics.
4
+ */
5
+
6
+ export const PARSE_HEALTH_FILE_CAP = 12;
7
+
8
+ function unavailableParseHealth(scannedFiles = 0) {
9
+ return {
10
+ advisory: true,
11
+ available: false,
12
+ status: 'unavailable',
13
+ scannedFiles,
14
+ affectedFiles: 0,
15
+ diagnosticCount: 0,
16
+ files: [],
17
+ truncated: 0,
18
+ overflow: false,
19
+ };
20
+ }
21
+
22
+ /** Aggregate cached/per-file parse counts into a deterministic doctor surface. */
23
+ export function summarizeParseHealth(scanned) {
24
+ if (!Array.isArray(scanned)) return unavailableParseHealth();
25
+ const rows = scanned
26
+ .map(({ relFile, entry }) => ({
27
+ file: relFile,
28
+ diagnosticCount: entry?.parseDiagnosticCount,
29
+ }));
30
+ if (rows.some(({ file, diagnosticCount }) =>
31
+ typeof file !== 'string' || file.length === 0 ||
32
+ !Number.isSafeInteger(diagnosticCount) || diagnosticCount < 0
33
+ )) return unavailableParseHealth(rows.length);
34
+ const affected = rows
35
+ .filter(({ diagnosticCount }) => diagnosticCount > 0)
36
+ .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
37
+ let diagnosticCount = 0;
38
+ for (const row of affected) {
39
+ if (!Number.isSafeInteger(diagnosticCount + row.diagnosticCount)) {
40
+ return unavailableParseHealth(rows.length);
41
+ }
42
+ diagnosticCount += row.diagnosticCount;
43
+ }
44
+ const truncated = Math.max(0, affected.length - PARSE_HEALTH_FILE_CAP);
45
+ return {
46
+ advisory: true,
47
+ available: true,
48
+ status: affected.length > 0 ? 'parse-diagnostics' : 'ok',
49
+ scannedFiles: rows.length,
50
+ affectedFiles: affected.length,
51
+ diagnosticCount,
52
+ files: affected.slice(0, PARSE_HEALTH_FILE_CAP),
53
+ truncated,
54
+ overflow: truncated > 0,
55
+ };
56
+ }
57
+
58
+ /** Human doctor section; clean parse health stays quiet. */
59
+ export function printParseHealthSection(health, io) {
60
+ if (!health || health.affectedFiles === 0) return;
61
+ console.log('');
62
+ console.log(io.color.bold('Parse health (advisory)'));
63
+ io.line(
64
+ io.warn,
65
+ `${health.affectedFiles} governed file(s) carry ${health.diagnosticCount} parse diagnostic(s) across ${health.scannedFiles} scanned file(s).`
66
+ );
67
+ for (const finding of health.files ?? []) {
68
+ io.line(io.warn, `${finding.file} — ${finding.diagnosticCount} parse diagnostic(s)`);
69
+ }
70
+ if (health.truncated > 0) {
71
+ io.line(' ', io.color.dim(`…(+${health.truncated} more affected file(s); list capped)`));
72
+ }
73
+ io.line(' ', io.color.dim('advisory only — the gate verdict, design fitness, and pattern bets are unchanged'));
74
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Y01 — explicit verdict memory for X04 reshape pilots.
3
+ *
4
+ * Decisions bind to a concept plus its complete, sorted anchor set. Counts,
5
+ * move samples, and change-map hashes are deliberately excluded: evidence may
6
+ * drift without overturning an adopter's verdict, while a changed physical
7
+ * layout makes the old record stale. Advisory only; mirror facts stay intact.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { classifyPhysical, computeReshapePilot } from './physical-cohesion.mjs';
12
+
13
+ export const RESHAPE_DECISIONS_PATH = '.ark/reshape-decisions.json';
14
+
15
+ const MAX_DECISION_BYTES = 64 * 1024;
16
+ const MAX_DECISIONS = 200;
17
+ const MAX_LIFECYCLE_ITEMS = 12;
18
+ const MAX_ANCHOR_EVIDENCE = 20;
19
+ const VERDICTS = new Set(['accepted', 'deferred', 'rejected']);
20
+
21
+ function normalizeAnchor(raw) {
22
+ const portable = String(raw).trim().replace(/\\/g, '/');
23
+ if (portable === '.') return '.';
24
+ if (!portable || portable.startsWith('/') || /^[A-Za-z]:\//.test(portable) || portable.includes('\0')) {
25
+ return null;
26
+ }
27
+ const segments = portable.split('/');
28
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) return null;
29
+ return segments.join('/');
30
+ }
31
+
32
+ function targetKey(concept, anchors) {
33
+ return JSON.stringify([concept, anchors]);
34
+ }
35
+
36
+ function compareText(left, right) {
37
+ return left < right ? -1 : left > right ? 1 : 0;
38
+ }
39
+
40
+ function sameStrings(left, right) {
41
+ return left.length === right.length && left.every((value, index) => value === right[index]);
42
+ }
43
+
44
+ function lifecycleStatus(decision, today) {
45
+ const reviewBy = decision.reviewBy;
46
+ if (reviewBy === undefined) return 'undated';
47
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewBy)) return 'malformed';
48
+ const date = new Date(`${reviewBy}T00:00:00.000Z`);
49
+ if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== reviewBy) {
50
+ return 'malformed';
51
+ }
52
+ return typeof today === 'string' && reviewBy < today ? 'expired' : 'current';
53
+ }
54
+
55
+ /** Bounded, fail-loud loader. A broken file never suppresses a pilot. */
56
+ export function loadReshapeDecisions(root) {
57
+ const relPath = RESHAPE_DECISIONS_PATH;
58
+ const abs = path.join(root, relPath);
59
+ let stats;
60
+ try {
61
+ stats = fs.statSync(abs);
62
+ } catch {
63
+ return { path: relPath, exists: false, decisions: [] };
64
+ }
65
+ const invalid = (error) => ({ path: relPath, exists: true, invalid: true, error, decisions: [] });
66
+ if (!stats.isFile()) return invalid('not a regular file');
67
+ if (stats.size > MAX_DECISION_BYTES) {
68
+ return invalid(`larger than ${MAX_DECISION_BYTES} bytes`);
69
+ }
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
73
+ } catch (error) {
74
+ return invalid(error instanceof Error ? error.message : 'unreadable JSON');
75
+ }
76
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
77
+ return invalid('expected an object with decisions[]');
78
+ }
79
+ const unknownTop = Object.keys(parsed).filter((key) => !['schemaVersion', 'decisions'].includes(key));
80
+ if (unknownTop.length > 0) return invalid(`unknown field: ${unknownTop[0]}`);
81
+ if (parsed.schemaVersion !== undefined && parsed.schemaVersion !== '1') {
82
+ return invalid('schemaVersion must be "1" when present');
83
+ }
84
+ if (!Array.isArray(parsed.decisions)) return invalid('expected { decisions: [...] }');
85
+ if (parsed.decisions.length > MAX_DECISIONS) {
86
+ return invalid(`more than ${MAX_DECISIONS} entries`);
87
+ }
88
+
89
+ const decisions = [];
90
+ const seen = new Set();
91
+ for (const entry of parsed.decisions) {
92
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
93
+ return invalid('every decision must be an object');
94
+ }
95
+ const unknown = Object.keys(entry).filter((key) =>
96
+ !['concept', 'anchors', 'verdict', 'reason', 'reviewBy'].includes(key)
97
+ );
98
+ if (unknown.length > 0) return invalid(`decision has unknown field: ${unknown[0]}`);
99
+ const concept = typeof entry.concept === 'string' ? entry.concept.trim() : '';
100
+ const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
101
+ if (!concept || !reason || !VERDICTS.has(entry.verdict)) {
102
+ return invalid('every decision needs concept, verdict (accepted/deferred/rejected), and reason');
103
+ }
104
+ if (entry.reviewBy !== undefined && typeof entry.reviewBy !== 'string') {
105
+ return invalid('reviewBy must be a string when present');
106
+ }
107
+ if (!Array.isArray(entry.anchors) || entry.anchors.length === 0) {
108
+ return invalid('every decision needs a non-empty anchors array');
109
+ }
110
+ if (entry.anchors.some((anchor) => typeof anchor !== 'string')) {
111
+ return invalid('every decision anchor must be a string');
112
+ }
113
+ const anchors = entry.anchors.map(normalizeAnchor);
114
+ if (anchors.some((anchor) => anchor === null)) {
115
+ return invalid('anchors must be canonical project-relative paths');
116
+ }
117
+ anchors.sort();
118
+ if (new Set(anchors).size !== anchors.length) return invalid('decision anchors must be unique');
119
+ const key = targetKey(concept, anchors);
120
+ if (seen.has(key)) return invalid('duplicate decision target');
121
+ seen.add(key);
122
+ decisions.push({
123
+ concept,
124
+ anchors,
125
+ verdict: entry.verdict,
126
+ reason,
127
+ ...(entry.reviewBy !== undefined ? { reviewBy: entry.reviewBy } : {}),
128
+ });
129
+ }
130
+ decisions.sort(
131
+ (left, right) =>
132
+ compareText(left.concept, right.concept) ||
133
+ compareText(targetKey(left.concept, left.anchors), targetKey(right.concept, right.anchors))
134
+ );
135
+ return { path: relPath, exists: true, decisions };
136
+ }
137
+
138
+ function anchorsByConcept(root, files) {
139
+ const result = new Map();
140
+ const resolvedRoot = path.resolve(root);
141
+ for (const file of Array.isArray(files) ? files : []) {
142
+ const abs = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
143
+ const rel = path.relative(resolvedRoot, abs);
144
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
145
+ const classified = classifyPhysical(rel);
146
+ if (!classified) continue;
147
+ if (!result.has(classified.concept)) result.set(classified.concept, new Set());
148
+ result.get(classified.concept).add(classified.anchor);
149
+ }
150
+ return new Map([...result].map(([concept, anchors]) => [concept, [...anchors].sort()]));
151
+ }
152
+
153
+ /** Pure lifecycle/staleness resolution; callers inject `today` in tests. */
154
+ export function analyzeReshapeDecisions(
155
+ root,
156
+ files,
157
+ state = { path: RESHAPE_DECISIONS_PATH, exists: false, decisions: [] },
158
+ today = null
159
+ ) {
160
+ const anchorSets = anchorsByConcept(root, files);
161
+ const current = [];
162
+ const expired = [];
163
+ const malformed = [];
164
+ const stale = [];
165
+ for (const decision of state.invalid ? [] : state.decisions ?? []) {
166
+ const currentAnchors = anchorSets.get(decision.concept) ?? [];
167
+ if (!sameStrings(decision.anchors, currentAnchors)) {
168
+ stale.push({
169
+ ...decision,
170
+ currentAnchorCount: currentAnchors.length,
171
+ currentAnchors: currentAnchors.slice(0, MAX_ANCHOR_EVIDENCE),
172
+ });
173
+ continue;
174
+ }
175
+ const status = lifecycleStatus(decision, today);
176
+ if (status === 'expired') expired.push(decision);
177
+ else if (status === 'malformed') malformed.push(decision);
178
+ else {
179
+ current.push({
180
+ ...decision,
181
+ lifecycle: status,
182
+ suppressesPilot: decision.verdict === 'deferred' || decision.verdict === 'rejected',
183
+ });
184
+ }
185
+ }
186
+ const summary = {
187
+ advisory: true,
188
+ explicitOnly: true,
189
+ neverChangesFacts: true,
190
+ decisionFile: {
191
+ path: state.path ?? RESHAPE_DECISIONS_PATH,
192
+ present: state.exists === true,
193
+ invalid: state.invalid === true,
194
+ ...(state.invalid ? { error: state.error ?? 'invalid' } : {}),
195
+ },
196
+ currentCount: current.length,
197
+ current: current.slice(0, MAX_LIFECYCLE_ITEMS),
198
+ lifecycle: {
199
+ undated: current.filter((decision) => decision.lifecycle === 'undated').length,
200
+ malformedCount: malformed.length,
201
+ malformed: malformed.slice(0, MAX_LIFECYCLE_ITEMS),
202
+ expiredCount: expired.length,
203
+ expired: expired.slice(0, MAX_LIFECYCLE_ITEMS),
204
+ staleCount: stale.length,
205
+ stale: stale.slice(0, MAX_LIFECYCLE_ITEMS),
206
+ },
207
+ };
208
+ return { summary, current, anchorSets };
209
+ }
210
+
211
+ /** Filesystem/clock wrapper for doctor and report callers. */
212
+ export function computeReshapeDecisionMemory(root, files, today = new Date().toISOString().slice(0, 10)) {
213
+ return analyzeReshapeDecisions(root, files, loadReshapeDecisions(root), today);
214
+ }
215
+
216
+ /** Select one actionable finding while respecting explicit current verdicts. */
217
+ export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis) {
218
+ const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
219
+ if (findings.length === 0) return null;
220
+ const currentByTarget = new Map(
221
+ analysis.current.map((decision) => [targetKey(decision.concept, decision.anchors), decision])
222
+ );
223
+ for (const finding of findings) {
224
+ const anchors = analysis.anchorSets.get(finding.concept) ?? [];
225
+ const decision = currentByTarget.get(targetKey(finding.concept, anchors));
226
+ if (decision?.suppressesPilot) continue;
227
+ const pilot = computeReshapePilot({ ...cohesion, findings: [finding] }, files, root);
228
+ if (!pilot?.nextPilot) return pilot;
229
+ return {
230
+ ...pilot,
231
+ ...(decision ? { decision } : {}),
232
+ nextPilot: {
233
+ ...pilot.nextPilot,
234
+ decisionTarget: { concept: finding.concept, anchors },
235
+ decisionFile: RESHAPE_DECISIONS_PATH,
236
+ },
237
+ };
238
+ }
239
+ return {
240
+ proposed: false,
241
+ applied: false,
242
+ neverMechanicalSafe: true,
243
+ nextPilot: null,
244
+ suppressedByDecision: true,
245
+ note: 'Every displayed reshape target has an explicit current rejected/deferred decision; mirror facts remain visible.',
246
+ };
247
+ }
248
+
249
+ /** Human doctor section; lifecycle stays visible even after the sensor quiets. */
250
+ export function printReshapeDecisionsSection(memory, io) {
251
+ const lifecycle = memory?.lifecycle;
252
+ const hasContent =
253
+ memory?.decisionFile?.invalid ||
254
+ memory?.currentCount > 0 ||
255
+ lifecycle?.expiredCount > 0 ||
256
+ lifecycle?.malformedCount > 0 ||
257
+ lifecycle?.staleCount > 0;
258
+ if (!hasContent) return;
259
+ console.log('');
260
+ console.log(io.color.bold('Reshape decisions (advisory)'));
261
+ if (memory.decisionFile.invalid) {
262
+ io.line(io.warn, `${memory.decisionFile.path} is present but invalid — decisions are ignored.`);
263
+ }
264
+ for (const decision of memory.current.slice(0, 5)) {
265
+ const review = decision.reviewBy ? ` · review-by ${decision.reviewBy}` : '';
266
+ io.line(' ', `[${decision.concept}] ${decision.verdict}${review} — ${decision.reason}`);
267
+ }
268
+ if (memory.currentCount > memory.current.slice(0, 5).length) {
269
+ io.line(' ', io.color.dim(`…(+${memory.currentCount - 5} more current decision(s))`));
270
+ }
271
+ if (lifecycle.expiredCount > 0) {
272
+ io.line(io.warn, `${lifecycle.expiredCount} reshape decision(s) expired — pilot pressure is active again.`);
273
+ }
274
+ if (lifecycle.malformedCount > 0) {
275
+ io.line(io.warn, `${lifecycle.malformedCount} reshape decision(s) have malformed review-by dates — ignored.`);
276
+ }
277
+ if (lifecycle.staleCount > 0) {
278
+ io.line(io.warn, `${lifecycle.staleCount} reshape decision(s) have a changed anchor set — stale; update or delete them.`);
279
+ }
280
+ if (lifecycle.undated > 0) {
281
+ io.line(' ', io.color.dim(`${lifecycle.undated} current decision(s) have no review-by date.`));
282
+ }
283
+ io.line(' ', io.color.dim('explicit verdicts affect pilot pressure only; physical facts and the gate verdict are unchanged'));
284
+ }