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.
- package/CHANGELOG.md +46 -0
- package/README.md +4 -4
- package/bin/ark-check.mjs +3 -3
- package/bin/ark-layer-match.mjs +2 -1
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +66 -13
- package/bin/lib/design-smells.mjs +67 -14
- package/bin/lib/doctor-advisories.mjs +20 -5
- package/bin/lib/doctor-plan.mjs +5 -7
- package/bin/lib/html-report-advisories.mjs +59 -0
- package/bin/lib/parse-health.mjs +74 -0
- package/bin/lib/reshape-decisions.mjs +284 -0
- package/bin/lib/ts-resolve.mjs +3 -2
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +7 -7
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -6
- package/docs/agent-guide.md +33 -2
- package/docs/ai-gates.md +3 -2
- package/docs/configuration.md +6 -0
- package/docs/package-surface.md +6 -5
- package/docs/typescript-support.md +9 -0
- package/package.json +2 -1
- package/server.json +2 -2
- package/templates/skills/ark-autopilot.md +12 -0
- package/templates/skills/ark-explore.md +8 -1
- package/templates/skills/ark-fix.md +11 -1
- package/templates/skills/ark-loop.md +14 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* state
|
|
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
|
-
|
|
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
|
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
466
|
-
|
|
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(
|
|
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
|
+
}
|