arkgate 4.0.0 → 4.1.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 +142 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +148 -8
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +277 -59
- package/bin/lib/enforcement-honesty.mjs +351 -26
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/graph-blind.mjs +1 -1
- package/bin/lib/html-report-advisories.mjs +8 -25
- package/bin/lib/html-report-depth.mjs +167 -3
- package/bin/lib/html-report.mjs +12 -5
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +100 -1
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +389 -5
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/upgrade-command.mjs +325 -14
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +3 -2
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +38 -0
- package/docs/configuration.md +59 -7
- package/docs/package-surface.md +3 -2
- package/docs/product-voice.md +10 -1
- package/docs/typescript-support.md +9 -5
- package/docs/use.md +7 -5
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +23 -5
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
- package/templates/skills/ark-upgrade.md +57 -11
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -9,9 +9,16 @@ import {
|
|
|
9
9
|
resolveOperatingMode,
|
|
10
10
|
shouldShowNewHereNudge,
|
|
11
11
|
} from '../ark-shared.mjs';
|
|
12
|
+
import * as arkShared from '../ark-shared.mjs';
|
|
12
13
|
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
13
14
|
import { describePackageVersionDualTruth } from './field-install.mjs';
|
|
14
15
|
export { summarizeRulesUnderContract };
|
|
16
|
+
|
|
17
|
+
/** Optional S3 dual-match classifier when ark-shared exports it (soft dep for S5 landing). */
|
|
18
|
+
const matchingLayersForRelativePath =
|
|
19
|
+
typeof arkShared.matchingLayersForRelativePath === 'function'
|
|
20
|
+
? arkShared.matchingLayersForRelativePath
|
|
21
|
+
: null;
|
|
15
22
|
import {
|
|
16
23
|
collectAdoptionGaps,
|
|
17
24
|
detectSkillGaps,
|
|
@@ -70,12 +77,33 @@ export function computeCoverage(root, config, files, rules) {
|
|
|
70
77
|
const layers = config.layers ?? [];
|
|
71
78
|
const counts = new Map(layers.map((layer) => [layer.name, 0]));
|
|
72
79
|
const unclassified = [];
|
|
80
|
+
const dualMembership = [];
|
|
73
81
|
for (const file of files) {
|
|
82
|
+
const rel = normalize(path.relative(root, file));
|
|
74
83
|
const layer = layerForFile(root, file, layers);
|
|
75
84
|
if (layer && counts.has(layer)) counts.set(layer, counts.get(layer) + 1);
|
|
76
|
-
else unclassified.push(
|
|
85
|
+
else unclassified.push(rel);
|
|
86
|
+
|
|
87
|
+
// P0A-DUAL-MATCH: surface files that match 2+ layers (winner still single-valued).
|
|
88
|
+
// Soft: classifier is S3; S5 landing must not hard-require it.
|
|
89
|
+
if (matchingLayersForRelativePath) {
|
|
90
|
+
try {
|
|
91
|
+
const hits = matchingLayersForRelativePath(rel, layers);
|
|
92
|
+
if (hits.length > 1) {
|
|
93
|
+
dualMembership.push({
|
|
94
|
+
file: rel,
|
|
95
|
+
layers: hits.map((h) => h.layer),
|
|
96
|
+
winner: layer ?? hits[0]?.layer ?? null,
|
|
97
|
+
scores: Object.fromEntries(hits.map((h) => [h.layer, h.score])),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* pure classifier only — ignore if unavailable */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
77
104
|
}
|
|
78
105
|
unclassified.sort();
|
|
106
|
+
dualMembership.sort((a, b) => a.file.localeCompare(b.file));
|
|
79
107
|
const layerRows = layers.map((layer) => ({
|
|
80
108
|
name: layer.name,
|
|
81
109
|
patterns: layer.patterns ?? [],
|
|
@@ -101,6 +129,15 @@ export function computeCoverage(root, config, files, rules) {
|
|
|
101
129
|
suggestions: buildUnclassifiedSuggestions(unclassified),
|
|
102
130
|
emptyLayers,
|
|
103
131
|
layersWithoutRules,
|
|
132
|
+
dualMembership: {
|
|
133
|
+
count: dualMembership.length,
|
|
134
|
+
// Cap samples so doctor/JSON stay bounded on large trees.
|
|
135
|
+
samples: dualMembership.slice(0, 25),
|
|
136
|
+
note:
|
|
137
|
+
dualMembership.length > 0
|
|
138
|
+
? `${dualMembership.length} file(s) match multiple layers; classification uses highest path-anchored specificity (winner listed). Review overlapping globs if the winner looks wrong.`
|
|
139
|
+
: null,
|
|
140
|
+
},
|
|
104
141
|
};
|
|
105
142
|
}
|
|
106
143
|
|
|
@@ -162,6 +199,20 @@ export function runCoverage(root, config, files, rules, asJson) {
|
|
|
162
199
|
console.log('');
|
|
163
200
|
console.log(`Layers with no rule edge (can import anything): ${layersWithoutRules.join(', ')}`);
|
|
164
201
|
}
|
|
202
|
+
if (cov.dualMembership?.count > 0) {
|
|
203
|
+
console.log('');
|
|
204
|
+
console.log(
|
|
205
|
+
`Dual-match: ${cov.dualMembership.count} file(s) match multiple layers (winner by path-anchored specificity).`
|
|
206
|
+
);
|
|
207
|
+
for (const sample of (cov.dualMembership.samples ?? []).slice(0, 5)) {
|
|
208
|
+
console.log(
|
|
209
|
+
` ${sample.file} → ${sample.winner} (also: ${sample.layers.filter((l) => l !== sample.winner).join(', ')})`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (cov.dualMembership.count > 5) {
|
|
213
|
+
console.log(` … +${cov.dualMembership.count - 5} more (see --coverage --json dualMembership)`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
165
216
|
}
|
|
166
217
|
|
|
167
218
|
// --doctor: one consolidated health view — coverage, violations, gates, skills, baseline,
|
|
@@ -210,9 +261,15 @@ export function buildRemediationPlan(
|
|
|
210
261
|
...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
|
|
211
262
|
};
|
|
212
263
|
});
|
|
213
|
-
// Order:
|
|
264
|
+
// Order: value edges first (runtime coupling), then type-only placement debt as a group,
|
|
265
|
+
// and within each bucket: mechanical-safe → judgment → deferred (NEW-TYPEONLY-VOLUME).
|
|
214
266
|
const rank = { 'mechanical-safe': 0, judgment: 1, deferred: 2 };
|
|
215
|
-
steps.sort((a, b) =>
|
|
267
|
+
steps.sort((a, b) => {
|
|
268
|
+
const aType = a.typeOnly || a.namedBindingsTypeOnly ? 1 : 0;
|
|
269
|
+
const bType = b.typeOnly || b.namedBindingsTypeOnly ? 1 : 0;
|
|
270
|
+
if (aType !== bType) return aType - bType;
|
|
271
|
+
return rank[a.class] - rank[b.class];
|
|
272
|
+
});
|
|
216
273
|
const countOf = (cls) => steps.filter((s) => s.class === cls).length;
|
|
217
274
|
const counts = {
|
|
218
275
|
mechanicalSafe: countOf('mechanical-safe'),
|
|
@@ -233,9 +290,17 @@ export function buildRemediationPlan(
|
|
|
233
290
|
designSmells = designSmells ?? [];
|
|
234
291
|
const patternBets =
|
|
235
292
|
options.patternBets ?? buildPatternBetsFromSmells(designSmells);
|
|
236
|
-
|
|
293
|
+
// P1-type: goal.met uses blocking (value) edges only; type-only placement debt still listed.
|
|
294
|
+
const blockingCount =
|
|
295
|
+
typeof options.blockingViolationCount === 'number'
|
|
296
|
+
? options.blockingViolationCount
|
|
297
|
+
: activeViolations.filter((v) => v.failsStrict !== false).length;
|
|
298
|
+
const typeOnlyCount = activeViolations.filter(
|
|
299
|
+
(v) => v.typeOnly || v.namedBindingsTypeOnly
|
|
300
|
+
).length;
|
|
301
|
+
const edgesMet = blockingCount === 0 && !notHonestlyEnforced;
|
|
237
302
|
const designWeak = isDesignWeak(designSmells, {
|
|
238
|
-
activeViolations:
|
|
303
|
+
activeViolations: blockingCount,
|
|
239
304
|
governedPercent,
|
|
240
305
|
totalFiles,
|
|
241
306
|
});
|
|
@@ -252,19 +317,38 @@ export function buildRemediationPlan(
|
|
|
252
317
|
});
|
|
253
318
|
|
|
254
319
|
let statement =
|
|
255
|
-
|
|
256
|
-
? `Resolve ${
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
320
|
+
blockingCount > 0
|
|
321
|
+
? `Resolve ${blockingCount} architecture violation(s) without weakening the contract.` +
|
|
322
|
+
(typeOnlyCount > 0
|
|
323
|
+
? ` (${typeOnlyCount} type-only placement debt also reported — SharedTypes / owning layer.)`
|
|
324
|
+
: '')
|
|
325
|
+
: typeOnlyCount > 0
|
|
326
|
+
? `No blocking value edges — ${typeOnlyCount} type-only placement debt remain (prefer SharedTypes / owning layer; not runtime coupling).`
|
|
327
|
+
: emptyScope
|
|
328
|
+
? 'No source files matched the contract include paths — this "clean" result checks nothing. Fix include/layers (monorepo → apps/packages, or /ark-adopt) so Ark has real code to govern.'
|
|
329
|
+
: governedLow
|
|
330
|
+
? `No violations — but Ark governs only ${governedPercent}% of your code, so this "clean" result checks almost nothing. Classify the rest (ark-check --coverage, then /ark-adopt) so it's actually enforced.`
|
|
331
|
+
: 'No active violations — the architecture already meets its contract.';
|
|
262
332
|
if (designWeak) {
|
|
263
333
|
statement =
|
|
264
334
|
'No active edge violations — contract edges are clean, but design smells remain (ENFORCE · design-weak). Shape residual is plan B only; not healthy finished.';
|
|
265
335
|
}
|
|
266
336
|
if (completeness !== ANALYSIS_COMPLETENESS.complete) statement = analysisIncompleteStatement(completeness);
|
|
267
337
|
|
|
338
|
+
// NEW-TYPEONLY-VOLUME: group type-only steps for plan messaging (value first).
|
|
339
|
+
const typeOnlySteps = steps.filter((s) => s.typeOnly || s.namedBindingsTypeOnly);
|
|
340
|
+
const valueSteps = steps.filter((s) => !(s.typeOnly || s.namedBindingsTypeOnly));
|
|
341
|
+
const typeOnlyGroup =
|
|
342
|
+
typeOnlySteps.length > 0
|
|
343
|
+
? {
|
|
344
|
+
count: typeOnlySteps.length,
|
|
345
|
+
valueCount: valueSteps.length,
|
|
346
|
+
guidance:
|
|
347
|
+
'Type-only placement debt is non-blocking. Prefer a SharedTypes (or owning) layer both sides may import; fix value runtime coupling first. See templates/layers/shared-types.starter.json.',
|
|
348
|
+
stepIds: typeOnlySteps.map((s) => s.id).slice(0, 40),
|
|
349
|
+
}
|
|
350
|
+
: null;
|
|
351
|
+
|
|
268
352
|
return {
|
|
269
353
|
version: '1',
|
|
270
354
|
completeness,
|
|
@@ -289,9 +373,13 @@ export function buildRemediationPlan(
|
|
|
289
373
|
needsDecision: counts.judgment,
|
|
290
374
|
deferred: counts.deferred,
|
|
291
375
|
patternBetCount: patternBets.length,
|
|
376
|
+
...(typeOnlyCount > 0
|
|
377
|
+
? { typeOnlyPlacementDebt: typeOnlyCount, typeOnlyNonBlocking: true }
|
|
378
|
+
: {}),
|
|
292
379
|
},
|
|
293
380
|
counts,
|
|
294
381
|
steps,
|
|
382
|
+
...(typeOnlyGroup ? { typeOnlyGroup } : {}),
|
|
295
383
|
// Additive: pattern evolution bets derived from design smells (never auto).
|
|
296
384
|
patternBets,
|
|
297
385
|
designSmells,
|
|
@@ -375,13 +463,26 @@ export function runPlan(
|
|
|
375
463
|
`${color.yellow(`${plan.counts.judgment} need your decision`)} · ` +
|
|
376
464
|
`${color.dim(`${plan.counts.deferred} deferred`)}`
|
|
377
465
|
);
|
|
466
|
+
if (plan.typeOnlyGroup?.count) {
|
|
467
|
+
console.log('');
|
|
468
|
+
console.log(
|
|
469
|
+
color.dim(
|
|
470
|
+
`Type-only group: ${plan.typeOnlyGroup.count} placement-debt step(s) after ${plan.typeOnlyGroup.valueCount} value step(s) — non-blocking; SharedTypes starter optional.`
|
|
471
|
+
)
|
|
472
|
+
);
|
|
473
|
+
}
|
|
378
474
|
console.log('');
|
|
379
475
|
const tag = {
|
|
380
476
|
'mechanical-safe': color.green('auto '),
|
|
381
477
|
judgment: color.yellow('decide'),
|
|
382
478
|
deferred: color.dim('defer '),
|
|
383
479
|
};
|
|
480
|
+
let typeOnlyHeaderPrinted = false;
|
|
384
481
|
for (const step of plan.steps) {
|
|
482
|
+
if ((step.typeOnly || step.namedBindingsTypeOnly) && !typeOnlyHeaderPrinted) {
|
|
483
|
+
console.log(color.dim(' --- type-only placement debt (non-blocking) ---'));
|
|
484
|
+
typeOnlyHeaderPrinted = true;
|
|
485
|
+
}
|
|
385
486
|
const where = `${step.file}${step.line ? `:${step.line}` : ''}`;
|
|
386
487
|
console.log(` [${tag[step.class]}] ${step.edge} ${color.dim(where)}`);
|
|
387
488
|
console.log(color.dim(` ${step.rationale}`));
|
|
@@ -429,9 +530,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
429
530
|
? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
|
|
430
531
|
: 0;
|
|
431
532
|
const activeCount = violations.length - suppressed;
|
|
533
|
+
// productHonesty: blocking = failsStrict !== false only (type-only placement debt excluded).
|
|
534
|
+
const blockingActive = violations.filter((v, index) => {
|
|
535
|
+
if (v.failsStrict === false) return false;
|
|
536
|
+
if (!baseline.exists) return true;
|
|
537
|
+
return !baseline.keys.has(occurrenceKeys[index]);
|
|
538
|
+
}).length;
|
|
432
539
|
const designSmells = detectDesignSmells(root, config, files, cov);
|
|
433
540
|
const observedDesignFitness = summarizeDesignFitness(designSmells, {
|
|
434
|
-
activeViolations:
|
|
541
|
+
activeViolations: blockingActive,
|
|
435
542
|
governedPercent: cov.governed.percent,
|
|
436
543
|
totalFiles: cov.governed.totalFiles,
|
|
437
544
|
});
|
|
@@ -453,18 +560,84 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
453
560
|
designSmells,
|
|
454
561
|
});
|
|
455
562
|
const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth);
|
|
456
|
-
|
|
457
|
-
|
|
563
|
+
// AR12 — compute once for JSON + product honesty + human lines.
|
|
564
|
+
// P1M: classification gate on extraMergeTeeth (no teeth at empty graph).
|
|
565
|
+
const rulesUnderContract = summarizeRulesUnderContract(
|
|
566
|
+
root,
|
|
567
|
+
config,
|
|
568
|
+
options.facts ?? options.architectureFacts,
|
|
569
|
+
{
|
|
570
|
+
governedPercent: cov.governed?.percent ?? null,
|
|
571
|
+
populatedLayerCount: Array.isArray(cov.layers)
|
|
572
|
+
? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
|
|
573
|
+
: null,
|
|
574
|
+
classifiedFiles: cov.governed?.classifiedFiles ?? null,
|
|
575
|
+
}
|
|
576
|
+
);
|
|
577
|
+
// Single residual expression (nextPilot || extractionCard) — HTML report uses the same.
|
|
578
|
+
const residualPilot = pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
|
|
579
|
+
const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
|
|
580
|
+
const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
|
|
581
|
+
const totalFilesEarly = cov.governed.totalFiles || 0;
|
|
582
|
+
const operatingMode = resolveOperatingMode({
|
|
583
|
+
governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
|
|
584
|
+
// planMet uses blocking (failsStrict !== false) only — type-only placement debt alone
|
|
585
|
+
// must not force adapt via unmet plan (parity with merge/exit and productHonesty).
|
|
586
|
+
planMet:
|
|
587
|
+
analysisComplete &&
|
|
588
|
+
blockingActive === 0 &&
|
|
589
|
+
!emptyScopeEarly &&
|
|
590
|
+
cov.governed.percent >= 50,
|
|
591
|
+
mature: cov.governed.totalFiles >= 150,
|
|
458
592
|
totalFiles: cov.governed.totalFiles,
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
activeHost: writePath.activeHost,
|
|
466
|
-
hardWriteActive: writePath.capabilities?.['hard-write'] === true,
|
|
593
|
+
emptyLayers: cov.emptyLayers,
|
|
594
|
+
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
|
|
595
|
+
presentationShare:
|
|
596
|
+
totalFilesEarly > 0 && presentationRowEarly
|
|
597
|
+
? presentationRowEarly.files / totalFilesEarly
|
|
598
|
+
: null,
|
|
467
599
|
});
|
|
600
|
+
// Evidence-backed hard only (never capabilities-from-hook-files alone).
|
|
601
|
+
const hardWriteActive = writePath.enforcementState?.localWrite?.hard === true;
|
|
602
|
+
const packageInstalled = writePath.enforcementState?.localWrite?.installed === true;
|
|
603
|
+
const dualTruthNext =
|
|
604
|
+
packageVersionTruth?.dualTruth === true
|
|
605
|
+
? `Bump package.json arkgate pin to ${packageVersionTruth.cliVersion || 'this CLI'} (or re-run install without --no-install)`
|
|
606
|
+
: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
|
|
607
|
+
? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
|
|
608
|
+
: null;
|
|
609
|
+
const { coverageHonesty, baselineHonesty, writePathHonesty, productHonesty } =
|
|
610
|
+
computeDoctorEnforcementHonesty({
|
|
611
|
+
governedPercent: cov.governed.percent,
|
|
612
|
+
totalFiles: cov.governed.totalFiles,
|
|
613
|
+
emptyScope: emptyScopeEarly,
|
|
614
|
+
baselineExists: baseline.exists,
|
|
615
|
+
frozenKeys: baseline.exists ? baseline.keys.size : 0,
|
|
616
|
+
activeViolations: activeCount,
|
|
617
|
+
activeBlockingViolations: blockingActive,
|
|
618
|
+
suppressed,
|
|
619
|
+
totalViolations: violations.length,
|
|
620
|
+
activeHost: writePath.activeHost,
|
|
621
|
+
hardWriteActive,
|
|
622
|
+
designWeak: designFitness.designWeak === true,
|
|
623
|
+
designWeakLabel: designFitness.label,
|
|
624
|
+
designSmellCount: designSmells.length,
|
|
625
|
+
designSmellsWithOpenEdges: designSmells.length > 0 && blockingActive > 0,
|
|
626
|
+
packageVersionTruth,
|
|
627
|
+
residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
|
|
628
|
+
pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
|
|
629
|
+
arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
|
|
630
|
+
? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
|
|
631
|
+
: rulesUnderContract?.active === true
|
|
632
|
+
? { active: true, extraMergeTeeth: false }
|
|
633
|
+
: null,
|
|
634
|
+
primaryNextAction: postGreenPath?.action ?? dualTruthNext,
|
|
635
|
+
operatingMode,
|
|
636
|
+
packageInstalled,
|
|
637
|
+
selfHost:
|
|
638
|
+
packageVersionTruth?.selfHost === true ||
|
|
639
|
+
packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
|
|
640
|
+
});
|
|
468
641
|
|
|
469
642
|
if (asJson) {
|
|
470
643
|
(options.writeJson ?? console.log)(
|
|
@@ -473,20 +646,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
473
646
|
ok: analysisComplete && (options.designDelta?.valid ?? true),
|
|
474
647
|
doctor: {
|
|
475
648
|
completeness,
|
|
476
|
-
operatingMode
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
totalFiles: cov.governed.totalFiles,
|
|
481
|
-
emptyLayers: cov.emptyLayers,
|
|
482
|
-
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
|
|
483
|
-
presentationShare: (() => {
|
|
484
|
-
const total = cov.governed.totalFiles || 0;
|
|
485
|
-
if (total <= 0) return null;
|
|
486
|
-
const p = cov.layers.find((r) => r.name === 'PresentationAdapters');
|
|
487
|
-
return p ? p.files / total : null;
|
|
488
|
-
})(),
|
|
489
|
-
}),
|
|
649
|
+
operatingMode,
|
|
650
|
+
// Monorepo walk-up (NEW-MONOREPO-CWD-WALKUP): where ark.config.json was resolved.
|
|
651
|
+
configRoot: options.configRoot ?? root,
|
|
652
|
+
...(options.configWalkedUp ? { configWalkedUp: true } : {}),
|
|
490
653
|
// Path-correct ENFORCE can still be design-weak (P02).
|
|
491
654
|
designFitness,
|
|
492
655
|
designSmells,
|
|
@@ -495,24 +658,24 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
495
658
|
postGreenPath,
|
|
496
659
|
...(postGreenPath
|
|
497
660
|
? { primaryNextAction: postGreenPath.action, ...DESIGN_WEAK_HONESTY_FLAGS }
|
|
498
|
-
:
|
|
661
|
+
: productHonesty.primaryNextAction
|
|
662
|
+
? { primaryNextAction: productHonesty.primaryNextAction }
|
|
663
|
+
: {}),
|
|
499
664
|
// Q03: advisory golden for new-code placement (absent = no claim).
|
|
500
665
|
goldenPattern,
|
|
501
666
|
// Y06: advisory pure-layer opt-in (null when not applicable).
|
|
502
667
|
pureLayerOptIn,
|
|
503
668
|
// Q04: one-pilot loop (extraction card → re-doctor).
|
|
504
669
|
pilotLoop,
|
|
505
|
-
// AR12 — Rules under contract (honest counts, not a score).
|
|
506
|
-
// Pass architecture facts when available so coverage can scan real tests.
|
|
507
|
-
rulesUnderContract: summarizeRulesUnderContract(
|
|
508
|
-
root,
|
|
509
|
-
config,
|
|
510
|
-
options.facts ?? options.architectureFacts
|
|
511
|
-
),
|
|
512
670
|
// Dual-truth: managed CLI vs package.json pin (not a gate fail).
|
|
513
671
|
packageVersionTruth,
|
|
514
672
|
// Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
|
|
673
|
+
// (rulesUnderContract is also in advisories; re-assert after spread so mergePlanes wins.)
|
|
515
674
|
...doctorAdvisories,
|
|
675
|
+
// AR12 + P1-M mergePlanes (authoritative; after advisories spread).
|
|
676
|
+
rulesUnderContract,
|
|
677
|
+
// P0-B — single anti-false-green honesty surface (never a score).
|
|
678
|
+
productHonesty,
|
|
516
679
|
governed: cov.governed,
|
|
517
680
|
coverageHonesty,
|
|
518
681
|
emptyLayers: cov.emptyLayers,
|
|
@@ -524,6 +687,28 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
524
687
|
suppressed,
|
|
525
688
|
value: summary.valueCount,
|
|
526
689
|
typeOnly: summary.typeOnlyCount,
|
|
690
|
+
// DL-TYPEEDGE-POLICY-FIELD / P1-type: always emit when type-only findings
|
|
691
|
+
// exist or the type-edge policy surface is active (default: always on).
|
|
692
|
+
typeEdgePolicy: {
|
|
693
|
+
active: true,
|
|
694
|
+
valueBlocksMerge: true,
|
|
695
|
+
typeOnlyIsPlacementDebt: true,
|
|
696
|
+
typeOnlyCount: summary.typeOnlyCount,
|
|
697
|
+
...(summary.typeOnlyCount > 0
|
|
698
|
+
? {
|
|
699
|
+
volume:
|
|
700
|
+
summary.typeOnlyCount >= 20
|
|
701
|
+
? 'high'
|
|
702
|
+
: summary.typeOnlyCount >= 5
|
|
703
|
+
? 'moderate'
|
|
704
|
+
: 'low',
|
|
705
|
+
starter:
|
|
706
|
+
'templates/layers/shared-types.starter.json — optional SharedTypes layer both sides may import.',
|
|
707
|
+
}
|
|
708
|
+
: {}),
|
|
709
|
+
guidance:
|
|
710
|
+
'Type-only edges (`import type` / pure type modules) are placement debt — fix value runtime coupling first; place shared types in a SharedTypes / owning layer.',
|
|
711
|
+
},
|
|
527
712
|
concentrated: summary.concentrated,
|
|
528
713
|
dominant: summary.dominant,
|
|
529
714
|
topEdges: summary.edges.slice(0, 5),
|
|
@@ -601,19 +786,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
601
786
|
console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
|
|
602
787
|
if (!analysisComplete) line(warn, analysisIncompleteStatement(completeness));
|
|
603
788
|
|
|
604
|
-
const emptyScope =
|
|
605
|
-
const
|
|
606
|
-
const presentationRow = cov.layers.find((r) => r.name === 'PresentationAdapters');
|
|
607
|
-
const mode = resolveOperatingMode({
|
|
608
|
-
governedPercent: emptyScope ? 0 : cov.governed.percent,
|
|
609
|
-
planMet: analysisComplete && activeCount === 0 && !emptyScope && cov.governed.percent >= 50,
|
|
610
|
-
mature: cov.governed.totalFiles >= 150,
|
|
611
|
-
totalFiles: cov.governed.totalFiles,
|
|
612
|
-
emptyLayers: cov.emptyLayers,
|
|
613
|
-
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
|
|
614
|
-
presentationShare:
|
|
615
|
-
totalFiles > 0 && presentationRow ? presentationRow.files / totalFiles : null,
|
|
616
|
-
});
|
|
789
|
+
const emptyScope = emptyScopeEarly;
|
|
790
|
+
const mode = operatingMode;
|
|
617
791
|
console.log('');
|
|
618
792
|
console.log(color.bold('Operating mode'));
|
|
619
793
|
// Modes are detected states, not user-picked settings. Plain-language "what you do next".
|
|
@@ -651,6 +825,27 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
651
825
|
);
|
|
652
826
|
}
|
|
653
827
|
|
|
828
|
+
// P0-B — single honesty surface (never a score; never "all good" when residual remains).
|
|
829
|
+
if (productHonesty) {
|
|
830
|
+
console.log('');
|
|
831
|
+
console.log(color.bold('Product honesty'));
|
|
832
|
+
line(
|
|
833
|
+
productHonesty.unfinished ? warn : ok,
|
|
834
|
+
`${productHonesty.headline} — ${productHonesty.primaryMessage}`
|
|
835
|
+
);
|
|
836
|
+
if (productHonesty.unfinished && Array.isArray(productHonesty.reasonIds) && productHonesty.reasonIds.length > 0) {
|
|
837
|
+
line(' ', color.dim(`signals: ${productHonesty.reasonIds.join(', ')} (notAScore)`));
|
|
838
|
+
}
|
|
839
|
+
if (rulesUnderContract?.mergePlanes?.failMergeWhen) {
|
|
840
|
+
line(
|
|
841
|
+
' ',
|
|
842
|
+
color.dim(
|
|
843
|
+
`merge planes: ${rulesUnderContract.mergePlanes.failMergeWhen} · ${rulesUnderContract.mergePlanes.dualPlaneStamp}`
|
|
844
|
+
)
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
654
849
|
console.log('');
|
|
655
850
|
console.log(color.bold('Design fitness'));
|
|
656
851
|
if (designSmells.length === 0) {
|
|
@@ -738,13 +933,36 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
738
933
|
}
|
|
739
934
|
if (cov.emptyLayers.length > 0) line(warn, `Empty layers (pattern matches nothing): ${cov.emptyLayers.join(', ')}`);
|
|
740
935
|
if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
|
|
936
|
+
if (cov.dualMembership?.count > 0) {
|
|
937
|
+
line(
|
|
938
|
+
warn,
|
|
939
|
+
`Dual-match: ${cov.dualMembership.count} file(s) match multiple layers — ${cov.dualMembership.note ?? 'review overlapping globs'}`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
741
942
|
if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
|
|
742
943
|
|
|
743
944
|
if (packageVersionTruth?.dualTruth) {
|
|
744
945
|
console.log('');
|
|
745
946
|
console.log(color.bold('Package pin (dual-truth)'));
|
|
746
947
|
line(warn, packageVersionTruth.note);
|
|
747
|
-
actions.push(
|
|
948
|
+
actions.push(
|
|
949
|
+
dualTruthNext ||
|
|
950
|
+
'bump package.json arkgate pin to match this CLI (or install without --no-install)'
|
|
951
|
+
);
|
|
952
|
+
} else if (packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT') {
|
|
953
|
+
console.log('');
|
|
954
|
+
console.log(color.bold('Package pin'));
|
|
955
|
+
line(warn, packageVersionTruth.note);
|
|
956
|
+
actions.push(
|
|
957
|
+
dualTruthNext ||
|
|
958
|
+
'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (options.configWalkedUp && options.configRoot) {
|
|
962
|
+
line(
|
|
963
|
+
ok,
|
|
964
|
+
`Config walk-up: using monorepo root ${options.configRoot} (ark.config.json not in cwd package)`
|
|
965
|
+
);
|
|
748
966
|
}
|
|
749
967
|
|
|
750
968
|
if (showNewHere) {
|