arkgate 4.8.15 → 4.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -5
  3. package/bin/ark-shared.mjs +2 -0
  4. package/bin/ark.mjs +17 -8
  5. package/bin/lib/analysis-engine.mjs +5 -5
  6. package/bin/lib/architecture-scan.mjs +17 -0
  7. package/bin/lib/baseline-key.mjs +2 -0
  8. package/bin/lib/config-contract.mjs +1 -1
  9. package/bin/lib/diagnostic-catalog.mjs +2 -0
  10. package/bin/lib/doctor-advisories.mjs +26 -0
  11. package/bin/lib/doctor-green-cite.mjs +139 -0
  12. package/bin/lib/doctor-human.mjs +98 -63
  13. package/bin/lib/doctor-next-actions.mjs +9 -0
  14. package/bin/lib/doctor-plan.mjs +6 -3
  15. package/bin/lib/field-install.mjs +47 -7
  16. package/bin/lib/first-run-help.mjs +1 -0
  17. package/bin/lib/improvement-compass-doctor.mjs +3 -1
  18. package/bin/lib/improvement-compass-map.mjs +3 -1
  19. package/bin/lib/invariant-coverage.mjs +121 -0
  20. package/bin/lib/invariant-tests-path.mjs +212 -0
  21. package/bin/lib/package-manager.mjs +8 -0
  22. package/bin/lib/prototype-shortcuts.mjs +224 -0
  23. package/bin/lib/remediation.mjs +11 -0
  24. package/bin/lib/start-preview.mjs +20 -1
  25. package/dist/{configTypes-Dt3DpVbd.d.ts → configTypes-VD0qcubY.d.ts} +2 -1
  26. package/dist/{diagnosticCatalog-BEg85XlE.d.ts → diagnosticCatalog-KWvGLI1U.d.ts} +24 -3
  27. package/dist/eslint/index.cjs +4 -4
  28. package/dist/eslint/index.d.ts +1 -1
  29. package/dist/eslint/index.js +4 -4
  30. package/dist/index.cjs +31 -31
  31. package/dist/index.d.ts +5 -5
  32. package/dist/index.js +31 -31
  33. package/dist/nestjs/index.cjs +1 -1
  34. package/dist/nestjs/index.d.ts +3 -3
  35. package/dist/nestjs/index.js +1 -1
  36. package/dist/runtime/index.cjs +10 -10
  37. package/dist/runtime/index.d.ts +6 -6
  38. package/dist/runtime/index.js +10 -10
  39. package/dist/{types-TBiv0WHL.d.ts → types-BSzRy2X1.d.ts} +1 -1
  40. package/dist/{types-CN9tVMPz.d.ts → types-D5GT5ZT8.d.ts} +1 -1
  41. package/docs/README.md +1 -1
  42. package/docs/agent-guide.md +2 -0
  43. package/docs/configuration.md +11 -3
  44. package/docs/develop.md +3 -1
  45. package/docs/diagnostics.md +25 -1
  46. package/docs/package-surface.md +1 -1
  47. package/docs/use.md +5 -3
  48. package/package.json +1 -1
  49. package/schemas/ark.config.schema.json +1 -1
  50. package/server.json +2 -2
  51. package/templates/agent-skills/README.md +1 -1
  52. package/templates/agent-skills/ark-adopt/SKILL.md +15 -0
  53. package/templates/agent-skills/ark-autopilot/SKILL.md +6 -1
  54. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  55. package/templates/agent-skills/ark-explain/SKILL.md +2 -1
  56. package/templates/agent-skills/ark-explore/SKILL.md +19 -0
  57. package/templates/agent-skills/ark-place/SKILL.md +4 -0
  58. package/templates/skills/ark-adopt.md +15 -0
  59. package/templates/skills/ark-autopilot.md +6 -1
  60. package/templates/skills/ark-coverage.md +1 -1
  61. package/templates/skills/ark-explain.md +2 -1
  62. package/templates/skills/ark-explore.md +19 -0
  63. package/templates/skills/ark-place.md +4 -0
@@ -16,6 +16,12 @@ import {
16
16
  invariantIdsFromCatalog,
17
17
  loadInvariantCoverageInputs,
18
18
  } from './invariant-coverage-io.mjs';
19
+ import { catalogHasEnforcedInvariant } from './invariant-coverage.mjs';
20
+ import {
21
+ declaredCoverageRootsPresent,
22
+ declaredInvariantTestsPathPresent,
23
+ scanDemandsInvariantTestsPath,
24
+ } from './invariant-tests-path.mjs';
19
25
  import { loadArkRuleFileHints } from './arkrule-file-hints.mjs';
20
26
  import { collectGovernedFiles } from './scan-files.mjs';
21
27
 
@@ -198,9 +204,20 @@ export function resolveArchitectureSnapshot({
198
204
  filterHintPreload(coverageInputs?.fileContents, scoped),
199
205
  scoped
200
206
  );
207
+ const adopted = scanDemandsInvariantTestsPath(root, args);
208
+ const pathPresent = adopted
209
+ ? declaredInvariantTestsPathPresent(root, effectiveConfig.coverage)
210
+ : true;
211
+ // Silent when nothing is enforced — do not probe coverageRoots on that path.
212
+ const rootsPresent = catalogHasEnforcedInvariant(arkRulesLoad.arkRules?.invariants)
213
+ ? declaredCoverageRootsPresent(root, effectiveConfig.coverage)
214
+ : true;
201
215
  const analyzed = analyzeTrustedResolvedProject({
202
216
  contract: analysisContract,
203
217
  facts,
218
+ ...(adopted ? { adopted: true } : {}),
219
+ ...(adopted && pathPresent === false ? { invariantTestsPathPresent: false } : {}),
220
+ ...(rootsPresent === false ? { coverageRootsPresent: false } : {}),
204
221
  ...(coverageInputs ? { coverageInputs } : {}),
205
222
  ...(fileHints ? { fileHints } : {}),
206
223
  });
@@ -12,6 +12,8 @@
12
12
  export const NON_FREEZABLE_BASELINE_RULE_IDS = [
13
13
  'ARKRULE_SCOPE_EMPTY',
14
14
  'INVARIANT_CATALOG_EMPTY',
15
+ 'INVARIANT_TESTS_PATH_MISSING',
16
+ 'INVARIANT_COVERAGE_ROOTS_MISSING',
15
17
  ];
16
18
  /**
17
19
  * STRUCTURE freeze `target`: sensor id, plus `:symbol` when a method/class is known.
@@ -243,7 +243,7 @@ export const ARK_CONFIG_SCHEMA = {
243
243
  coverage: {
244
244
  type: 'object',
245
245
  additionalProperties: false,
246
- description: 'Invariant coverage scan controls. testGlobs replaces the built-in test-name heuristic; maxFiles raises or lowers the evidence file budget and also bounds structural-hint preload for orchestration-only, thin-adapter, and writes-via-aggregate (default 400; there is no arkrules.hintBudget); coverageRoots declares where the project runs its tests, so a covering test found outside them is reported instead of silently certifying an invariant.',
246
+ description: 'Invariant coverage scan controls. testGlobs replaces the built-in test-name heuristic; maxFiles raises or lowers the evidence file budget and also bounds structural-hint preload for orchestration-only, thin-adapter, and writes-via-aggregate (default 400; there is no arkrules.hintBudget); coverageRoots declares where the project runs its tests, so a covering test found outside them is reported instead of silently certifying an invariant. When any invariant is enforced, missing coverageRoots fails closed.',
247
247
  properties: {
248
248
  testGlobs: { ...stringArraySchema, minItems: 1 },
249
249
  maxFiles: {
@@ -59,6 +59,8 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
59
59
  entry('INVARIANT_CATALOG_EMPTY', 'arkrules', 'Domain invariant catalog is empty', 'ArkRules is on and a Domain-role layer has code, but invariants[] has no phrases the code must preserve. Empty looks like “done” until someone fills the catalog.', 'Add 1–2 short phrases to invariants[] in arkrules/<Domain>.json (or the mapped file). Starters show the shape. Advisory unless a domain structure rule is already enforced — then --strict-merge can refuse. Do not freeze this finding.', { oftenAdvisory: true }),
60
60
  entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green. When the message reports an exhausted file budget, raise coverage.maxFiles (or narrow coverage.testGlobs) in ark.config.json.'),
61
61
  entry('INVARIANT_COVERAGE_OUTSIDE_ROOTS', 'arkrules', 'Covering test outside the declared coverage roots', 'The only test naming this invariant sits outside coverage.coverageRoots — the places the project declares its runner executes. ArkGate matches declared text and never executes tests, so it cannot tell whether that file is ever run: coverage there is a test that exists, not a test that runs.', 'Move the test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json. Advisory: it never fails strict, but promotion to enforced refuses on it.', { oftenAdvisory: true }),
62
+ entry('INVARIANT_TESTS_PATH_MISSING', 'arkrules', 'Domain-invariant tests path missing under adopted', 'The project is adopted (required CI or explicit advisory) and has domain invariants that want test evidence, but ark.config.json does not name where those tests live. Green coverage without a real tests path is an empty checkbox.', 'Add coverage.testGlobs or coverage.coverageRoots in ark.config.json pointing at a real tests folder, then re-run. Adopted mode fails closed until that path is present. Not freezable.'),
63
+ entry('INVARIANT_COVERAGE_ROOTS_MISSING', 'arkrules', 'Coverage roots missing while an invariant is enforced', 'A catalogued domain invariant is enforced, but ark.config.json does not name coverage.coverageRoots — the folders where the project says its test runner actually goes. Without that declaration, INVARIANT_COVERAGE_OUTSIDE_ROOTS cannot fire and coverage can certify a test no runner runs.', 'Add coverage.coverageRoots in ark.config.json pointing at the folder the test runner uses, then re-run. testGlobs alone is not enough. Fail-closed until that path is present. Not freezable.'),
62
64
  // ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
63
65
  entry('ARKRUN_MISSING_ROOT', 'arkrun', 'No kernel factory in composition roots', 'The ArkRun extra is on but no createArkKernel / createStrictArkKernel / createArkKernelFromConfig / createStrictArkKernelFromConfig factory was found in arkRun.compositionRoots, so agents can skip the kernel while the write gate stays green.', 'Import createStrictArkKernel from arkgate/runtime (same npm package; @arkgate/runtime is deprecated) and call it in a composition root listed in arkRun.compositionRoots, then preflight again. Never mechanical-safe — factory placement is a design decision.'),
64
66
  entry('ARKRUN_KERNEL_IN_DOMAIN', 'arkrun', 'Domain-role layer imports the kernel', 'A Domain-role layer imports arkgate/runtime, @arkgate/runtime, or kernel types. Domain stays kernel-free; composition roots and adapters own the factory.', 'Move the kernel import out of the Domain-role layer into a composition root or adapter. Import from arkgate/runtime (same npm package; @arkgate/runtime is deprecated), then preflight again. Never mechanical-safe.'),
@@ -36,6 +36,7 @@ import {
36
36
  summarizeArkOrderSection,
37
37
  } from './ark-order-doctor.mjs';
38
38
  import { composeMergePlanesHonesty } from './extra-merge-teeth.mjs';
39
+ import { collectPrototypeShortcutsResidual } from './prototype-shortcuts.mjs';
39
40
 
40
41
  export function attachExtraDoctorSections(rulesUnderContract, config, classification, findings) {
41
42
  const arkRulesMerge = {
@@ -127,6 +128,24 @@ export function printCompactExtraDoctorLines(advisories, io) {
127
128
  io.line(io.warn, noDomain.ask);
128
129
  if (noDomain.nextAction) io.line(' ', `Next: ${noDomain.nextAction}`);
129
130
  }
131
+ const prototypeShortcuts = advisories?.prototypeShortcuts;
132
+ if (prototypeShortcuts?.ask) {
133
+ console.log('');
134
+ io.line(io.warn, prototypeShortcuts.ask);
135
+ if (prototypeShortcuts.nextAction) io.line(' ', `Next: ${prototypeShortcuts.nextAction}`);
136
+ }
137
+ const testsPath = advisories?.invariantTestsPath;
138
+ if (testsPath?.missing && testsPath.ask) {
139
+ console.log('');
140
+ io.line(io.warn, testsPath.ask);
141
+ if (testsPath.nextAction) io.line(' ', `Next: ${testsPath.nextAction}`);
142
+ }
143
+ const coverageRoots = advisories?.invariantCoverageRoots;
144
+ if (coverageRoots?.missing && coverageRoots.ask) {
145
+ console.log('');
146
+ io.line(io.warn, coverageRoots.ask);
147
+ if (coverageRoots.nextAction) io.line(' ', `Next: ${coverageRoots.nextAction}`);
148
+ }
130
149
  const rulesUnderContract = advisories?.rulesUnderContract;
131
150
  const arkRulesLines = formatArkRulesDoctorLines(rulesUnderContract);
132
151
  if (arkRulesLines.length > 0) {
@@ -221,7 +240,14 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
221
240
  classification,
222
241
  activeViolations
223
242
  );
243
+ const prototypeShortcuts = collectPrototypeShortcutsResidual({
244
+ root,
245
+ config,
246
+ coverage: cov,
247
+ files,
248
+ });
224
249
  return {
250
+ ...(prototypeShortcuts ? { prototypeShortcuts } : {}),
225
251
  contractHealth: computeContractHealth(root, config, cov, rules),
226
252
  ambientState: computeAmbientState(ts, root, config, files),
227
253
  physicalCohesion,
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Golden rule: a doctor green / healthy claim must cite a file, config key,
3
+ * or test. Uncited greens are dishonest — demote or omit.
4
+ *
5
+ * Tooling only. Reuses existing doctor view fields (IT01 / ADR / CI
6
+ * residual shape). No new schema, RPC, or skill name.
7
+ */
8
+ import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
9
+ import { CI_MERGE_BOUNDARY_REL } from './ci-merge-boundary.mjs';
10
+
11
+ export const HEALTHY_CLAIM = 'Healthy — nothing to do.';
12
+ export const HEALTHY_KEEP = ' Keep write path + CI.';
13
+ export const UNCITE_SUFFIX =
14
+ 'not green until a file, config key, or test is named';
15
+
16
+ const CONFIG_KEY_DOT = /^[A-Za-z_][\w]*\.[A-Za-z_][\w.]*$/;
17
+ const CONFIG_KEY_BARE = new Set([
18
+ 'include',
19
+ 'exclude',
20
+ 'layers',
21
+ 'rules',
22
+ 'arkRules',
23
+ 'arkRun',
24
+ 'arkOrder',
25
+ 'coverage',
26
+ 'stewards',
27
+ 'schemaVersion',
28
+ ]);
29
+ const FILE_LIKE =
30
+ /(?:^|\/)(?:\.[A-Za-z][\w.-]*|[A-Za-z][\w.-]*\.(?:json|ya?ml|md|ts|mjs|js|cjs))$|\/|[.](?:json|ya?ml|md|ts|mjs|js|cjs)$/;
31
+ const TEST_LIKE = /(?:^|\/)tests?\/|(?:\.|\b)test\.(?:ts|mjs|js)\b/i;
32
+
33
+ /**
34
+ * True when a stranger can open this in about a minute.
35
+ * Generic words ("CI", "gates") are not cites.
36
+ */
37
+ export function isConcreteCite(value) {
38
+ if (typeof value !== 'string') return false;
39
+ const cite = value.trim();
40
+ if (cite.length < 3 || cite.length > 240) return false;
41
+ if (/\s/.test(cite)) return false;
42
+ if (cite === REQUIRED_GATE_WORKFLOW) return false;
43
+ return (
44
+ FILE_LIKE.test(cite) ||
45
+ TEST_LIKE.test(cite) ||
46
+ CONFIG_KEY_DOT.test(cite) ||
47
+ CONFIG_KEY_BARE.has(cite)
48
+ );
49
+ }
50
+
51
+ export function uniqueConcreteCites(cites) {
52
+ const out = [];
53
+ for (const raw of Array.isArray(cites) ? cites : [cites]) {
54
+ if (!isConcreteCite(raw)) continue;
55
+ const cite = String(raw).trim();
56
+ if (!out.includes(cite)) out.push(cite);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * @returns {{ mark: 'ok', text: string } | { mark: 'warn', text: string }}
63
+ */
64
+ export function citeOrDemoteGreen(claim, cites) {
65
+ const named = typeof claim === 'string' && claim.trim() ? claim.trim() : 'This claim';
66
+ const list = uniqueConcreteCites(cites);
67
+ if (list.length > 0) {
68
+ return { mark: 'ok', text: `${named} (${list.join(' · ')})` };
69
+ }
70
+ return { mark: 'warn', text: `${named} — ${UNCITE_SUFFIX}` };
71
+ }
72
+
73
+ export function citedGreen(line, marks, claim, cites) {
74
+ const row = citeOrDemoteGreen(claim, cites);
75
+ line(row.mark === 'ok' ? marks.ok : marks.warn, row.text);
76
+ return row;
77
+ }
78
+
79
+ /** Backing artifacts already on the doctor view — no second scan. */
80
+ export function healthyCitesFromView(view) {
81
+ const cites = ['ark.config.json'];
82
+ if (view?.ciMergeBoundary) cites.push(CI_MERGE_BOUNDARY_REL);
83
+ const workflow =
84
+ view?.ciMergeBoundary?.ci?.workflowFile || view?.ciNotFailClosed?.workflowFile;
85
+ if (workflow) cites.push(workflow);
86
+ return uniqueConcreteCites(cites);
87
+ }
88
+
89
+ export function printHealthyHeadline(view, color) {
90
+ const row = citeOrDemoteGreen(HEALTHY_CLAIM, healthyCitesFromView(view));
91
+ if (row.mark === 'ok') {
92
+ console.log(color.green(`✔ ${row.text}`));
93
+ console.log(color.dim(HEALTHY_KEEP));
94
+ return row;
95
+ }
96
+ console.log(color.yellow(`! ${row.text}`));
97
+ return row;
98
+ }
99
+
100
+ export function displayedMissingGates(gatesMissing, view) {
101
+ const list = Array.isArray(gatesMissing) ? gatesMissing : [];
102
+ const hideGlob = Boolean(
103
+ view?.ciNotFailClosed?.workflowFile ||
104
+ view?.ciNotFailClosed?.error === 'ci-not-fail-closed' ||
105
+ view?.ciMergeBoundary?.ci?.workflowPresent
106
+ );
107
+ return hideGlob ? list.filter((item) => item !== REQUIRED_GATE_WORKFLOW) : list;
108
+ }
109
+
110
+ export function ciNotFailClosedNotice(view) {
111
+ const file = view?.ciNotFailClosed?.workflowFile;
112
+ if (!file) return null;
113
+ return `CI not fail-closed: ${file} — remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only`;
114
+ }
115
+
116
+ export function ciMergeGreenCites(view) {
117
+ const cites = [];
118
+ if (view?.ciMergeBoundary) cites.push(CI_MERGE_BOUNDARY_REL);
119
+ const workflow = view?.ciMergeBoundary?.ci?.workflowFile || view?.ciNotFailClosed?.workflowFile;
120
+ if (workflow) cites.push(workflow);
121
+ return uniqueConcreteCites(cites);
122
+ }
123
+
124
+ export function foundGateCites(view) {
125
+ const cites = ['AGENTS.md'];
126
+ if (view?.ciMergeBoundary) cites.push(CI_MERGE_BOUNDARY_REL);
127
+ const workflow = view?.ciMergeBoundary?.ci?.workflowFile;
128
+ if (workflow) cites.push(workflow);
129
+ return uniqueConcreteCites(cites);
130
+ }
131
+
132
+ export function writePathGreenCites(writePath) {
133
+ return uniqueConcreteCites([
134
+ ...(Array.isArray(writePath?.capabilityEvidence?.['hard-write'])
135
+ ? writePath.capabilityEvidence['hard-write']
136
+ : []),
137
+ ...(Array.isArray(writePath?.evidence) ? writePath.evidence : []),
138
+ ]);
139
+ }
@@ -13,24 +13,16 @@ import { enforcementDoctorLines } from './enforcement-state.mjs';
13
13
  import { analysisIncompleteStatement } from './analysis-completeness.mjs';
14
14
  import { skillGapsForActiveHost, detectCodexHomeGap, codexConcernIsActive } from './agent-gates.mjs';
15
15
  import { agentHomeConcernIsActive } from './agent-homes.mjs';
16
- import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
17
16
  import { layerGuidanceLine } from './layer-description.mjs';
18
-
19
- function displayedMissingGates(gatesMissing, view) {
20
- const list = Array.isArray(gatesMissing) ? gatesMissing : [];
21
- const hideGlob = Boolean(
22
- view?.ciNotFailClosed?.workflowFile ||
23
- view?.ciNotFailClosed?.error === 'ci-not-fail-closed' ||
24
- view?.ciMergeBoundary?.ci?.workflowPresent
25
- );
26
- return hideGlob ? list.filter((item) => item !== REQUIRED_GATE_WORKFLOW) : list;
27
- }
28
-
29
- function ciNotFailClosedNotice(view) {
30
- const file = view?.ciNotFailClosed?.workflowFile;
31
- if (!file) return null;
32
- return `CI not fail-closed: ${file} — remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only`;
33
- }
17
+ import {
18
+ citedGreen,
19
+ ciMergeGreenCites,
20
+ ciNotFailClosedNotice,
21
+ displayedMissingGates,
22
+ foundGateCites,
23
+ printHealthyHeadline,
24
+ writePathGreenCites,
25
+ } from './doctor-green-cite.mjs';
34
26
 
35
27
  function lineWith(ok, warn, bad, color) {
36
28
  return (mark, text) => console.log(` ${mark} ${text}`);
@@ -97,14 +89,13 @@ export function printDoctorCompactHuman(view) {
97
89
  : 'import rules check out. Keep host + CI.',
98
90
  };
99
91
  const modeTitle = operatingModeTitle(mode, designFitness.designWeak, stewardUnfinished);
100
- line(
101
- modeMark,
102
- `${modeTitle} ${
103
- designFitness.designWeak
104
- ? 'import rules check out; leftover design work remains.'
105
- : modeHelp[mode]
106
- }`
107
- );
92
+ const modeClaim = `${modeTitle} — ${
93
+ designFitness.designWeak
94
+ ? 'import rules check out; leftover design work remains.'
95
+ : modeHelp[mode]
96
+ }`;
97
+ if (modeMark === ok) citedGreen(line, { ok, warn }, modeClaim, ['ark.config.json']);
98
+ else line(modeMark, modeClaim);
108
99
  if (emptyScope) {
109
100
  line(
110
101
  bad,
@@ -114,17 +105,18 @@ export function printDoctorCompactHuman(view) {
114
105
 
115
106
  console.log('');
116
107
  if (ciMergeBoundary?.ci?.state) {
117
- line(
118
- ciMergeBoundary.ci.state === 'required' ? ok : warn,
119
- `CI merge: ${ciMergeBoundary.ci.state}`
120
- );
108
+ const mergeClaim = `CI merge: ${ciMergeBoundary.ci.state}`;
109
+ if (ciMergeBoundary.ci.state === 'required') {
110
+ citedGreen(line, { ok, warn }, mergeClaim, ciMergeGreenCites(view));
111
+ } else {
112
+ line(warn, mergeClaim);
113
+ }
121
114
  }
122
115
  if (adopted === 'advisory-only-acked') {
123
116
  line(warn, 'Adoption: advisory-only ack — not a required GitHub status.');
124
117
  }
125
118
  if (isDoctorHealthyNothingToDo(designFitness, uniqueActions, adopted)) {
126
- console.log(color.green('✔ Healthy — nothing to do.'));
127
- console.log(color.dim(' Keep write path + CI.'));
119
+ printHealthyHeadline(view, color);
128
120
  } else {
129
121
  console.log(color.bold('Primary next action'));
130
122
  console.log(` 1. ${uniqueActions[0]}`);
@@ -132,13 +124,10 @@ export function printDoctorCompactHuman(view) {
132
124
 
133
125
  console.log('');
134
126
  console.log(color.bold('Coverage'));
135
- const govMark =
136
- emptyScope || cov.governed.percent < 50
137
- ? bad
138
- : cov.governed.percent >= 80
139
- ? ok
140
- : warn;
141
- line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
127
+ const govClaim = `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`;
128
+ if (emptyScope || cov.governed.percent < 50) line(bad, govClaim);
129
+ else if (cov.governed.percent >= 80) citedGreen(line, { ok, warn }, govClaim, ['ark.config.json', 'include', 'layers']);
130
+ else line(warn, govClaim);
142
131
  for (const row of cov.layers ?? []) {
143
132
  const guidance = layerGuidanceLine(row);
144
133
  if (guidance) line(' ', `${row.name} — ${guidance}`);
@@ -175,7 +164,7 @@ export function printDoctorCompactHuman(view) {
175
164
  ...doctorAdvisories,
176
165
  layerOwners: view.layerOwners,
177
166
  adrPresence: view.adrPresence,
178
- statesTransitions: view.statesTransitions, statusTransitionCatalog: view.statusTransitionCatalog, noDomainFrontend: view.noDomainFrontend,
167
+ statesTransitions: view.statesTransitions, statusTransitionCatalog: view.statusTransitionCatalog, noDomainFrontend: view.noDomainFrontend, invariantTestsPath: view.invariantTestsPath, invariantCoverageRoots: view.invariantCoverageRoots,
179
168
  },
180
169
  { line, warn }
181
170
  );
@@ -261,7 +250,12 @@ export function printDoctorDetailsHuman(view) {
261
250
  `Dual-match: ${cov.dualMembership.count} file(s) match multiple layers — ${cov.dualMembership.note ?? 'review overlapping globs'}`
262
251
  );
263
252
  }
264
- if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
253
+ if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) {
254
+ citedGreen(line, { ok, warn }, 'Every layer classifies files; no empty layers', [
255
+ 'ark.config.json',
256
+ 'layers',
257
+ ]);
258
+ }
265
259
  const captioned = (cov.layers ?? []).filter((row) => layerGuidanceLine(row));
266
260
  if (captioned.length > 0) {
267
261
  console.log('');
@@ -288,7 +282,8 @@ export function printDoctorDetailsHuman(view) {
288
282
  console.log('');
289
283
  console.log(color.bold('Design fitness'));
290
284
  if (designSmells.length === 0) {
291
- line(analysisComplete ? ok : warn, designFitness.label);
285
+ if (analysisComplete) citedGreen(line, { ok, warn }, designFitness.label, ['ark.config.json']);
286
+ else line(warn, designFitness.label);
292
287
  } else {
293
288
  line(designFitness.designWeak ? warn : warn, designFitness.label);
294
289
  for (const smell of designSmells.slice(0, 5)) {
@@ -355,15 +350,16 @@ export function printDoctorDetailsHuman(view) {
355
350
  } else if (designFitness.designWeak) {
356
351
  line(warn, `None on checked imports — import rules match the config; leftover design work remains (${modeTitle}). Not healthy finished.`);
357
352
  } else {
358
- line(ok, 'None — the code matches the contract on checked edges');
353
+ citedGreen(line, { ok, warn }, 'None — the code matches the contract on checked edges', [
354
+ 'ark.config.json',
355
+ ]);
359
356
  }
360
357
  } else {
361
358
  const typeNote = summary.typeOnlyCount > 0 ? ` (${summary.valueCount} value · ${summary.typeOnlyCount} type-only)` : '';
362
359
  const supNote = suppressed > 0 ? `, ${suppressed} frozen` : '';
363
- line(
364
- activeCount > 0 ? warn : ok,
365
- `${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
366
- );
360
+ const violClaim = `${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`;
361
+ if (activeCount > 0) line(warn, violClaim);
362
+ else citedGreen(line, { ok, warn }, violClaim, ['.ark-baseline.json']);
367
363
  for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
368
364
  if (summary.concentrated && typeof summary.dominant === 'string' && summary.dominant.includes(' → ')) {
369
365
  line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
@@ -387,7 +383,17 @@ export function printDoctorDetailsHuman(view) {
387
383
  : bad;
388
384
  line(' ', `Active host: ${writePath.activeHost}`);
389
385
  line(' ', `Supported profile: ${writePath.supportSummary}`);
390
- line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
386
+ const modeLine = `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`;
387
+ if (wpMark === ok) {
388
+ citedGreen(
389
+ line,
390
+ { ok, warn },
391
+ modeLine,
392
+ writePathGreenCites(writePath)
393
+ );
394
+ } else {
395
+ line(wpMark, modeLine);
396
+ }
391
397
  if (writePathHonesty.message) line(warn, writePathHonesty.message);
392
398
  if (writePath.sessionNote) {
393
399
  line(warn, writePath.sessionNote);
@@ -397,14 +403,21 @@ export function printDoctorDetailsHuman(view) {
397
403
  const supportCaps = writePath.support?.capabilities || {};
398
404
  const repairReinjection = supportCaps['repair-reinjection-guaranteed'] === true;
399
405
  const repairEnvelope = supportCaps['repair-envelope-emitted'] === true || supportCaps['repair-payload'] === true;
400
- line(
401
- repairReinjection ? ok : warn,
402
- repairReinjection
403
- ? 'Repair: envelope + reinjection guaranteed on hard path when installed + trusted'
404
- : repairEnvelope
406
+ if (repairReinjection) {
407
+ citedGreen(
408
+ line,
409
+ { ok, warn },
410
+ 'Repair: envelope + reinjection guaranteed on hard path when installed + trusted',
411
+ writePathGreenCites(writePath)
412
+ );
413
+ } else {
414
+ line(
415
+ warn,
416
+ repairEnvelope
405
417
  ? 'Repair: envelope may emit (`--hook-repair`); reinjection not guaranteed (advisory host)'
406
418
  : 'Repair: no hard-boundary payload'
407
- );
419
+ );
420
+ }
408
421
  if (writePath.gap) {
409
422
  line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
410
423
  if (writePath.gap.fix) {
@@ -415,7 +428,12 @@ export function printDoctorDetailsHuman(view) {
415
428
  console.log('');
416
429
  console.log(color.bold('Gates & skills'));
417
430
  if (listedMissing.length === 0 && !skippableCi) {
418
- line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
431
+ citedGreen(
432
+ line,
433
+ { ok, warn },
434
+ 'Shared gate artifacts found on disk; runtime activation is reported separately',
435
+ foundGateCites(view)
436
+ );
419
437
  } else {
420
438
  if (listedMissing.length > 0) line(bad, `Missing gates: ${listedMissing.join(', ')}`);
421
439
  if (skippableCi) line(warn, skippableCi);
@@ -430,7 +448,11 @@ export function printDoctorDetailsHuman(view) {
430
448
  );
431
449
  const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
432
450
  const remStale = remainingGaps.reduce((s, g) => s + g.stale, 0);
433
- if (remMiss + remStale === 0 && !legacyCodex) line(ok, '/ark-* skills current for detected tools');
451
+ if (remMiss + remStale === 0 && !legacyCodex) {
452
+ citedGreen(line, { ok, warn }, '/ark-* skills current for detected tools', [
453
+ '.agents/skills',
454
+ ]);
455
+ }
434
456
  if (legacyCodex) {
435
457
  line(warn, 'Codex: legacy flat .codex/prompts only (not a loadable skill catalog)');
436
458
  }
@@ -480,10 +502,17 @@ export function printDoctorDetailsHuman(view) {
480
502
  console.log('');
481
503
  console.log(color.bold('Baseline'));
482
504
  if (!baseline.exists) {
483
- line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline --force --contract-session --author <steward>' : 'No baseline (nothing to freeze)');
505
+ const none = !analysisComplete
506
+ ? 'No baseline — current violations were not fully evaluated'
507
+ : violations.length > 0
508
+ ? 'No baseline — adopting a dirty repo? freeze with --update-baseline --force --contract-session --author <steward>'
509
+ : 'No baseline (nothing to freeze)';
510
+ if (!analysisComplete || violations.length > 0) line(warn, none);
511
+ else line(' ', none);
484
512
  } else {
485
- const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
486
- line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
513
+ const baseClaim = `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`;
514
+ if (!analysisComplete || baselineHonesty.dirtyBaselineRisk) line(warn, baseClaim);
515
+ else citedGreen(line, { ok, warn }, baseClaim, ['.ark-baseline.json']);
487
516
  if (analysisComplete && baselineHonesty.dirtyBaselineRisk) {
488
517
  line(warn, baselineHonesty.message);
489
518
  }
@@ -494,7 +523,9 @@ export function printDoctorDetailsHuman(view) {
494
523
 
495
524
  console.log('');
496
525
  console.log(color.bold('Command runners'));
497
- if (staleRunners.length === 0) line(ok, 'Emitted commands match the package manager');
526
+ if (staleRunners.length === 0) {
527
+ citedGreen(line, { ok, warn }, 'Emitted commands match the package manager', ['package.json']);
528
+ }
498
529
  else {
499
530
  line(warn, `Stale runner in ${staleRunners.join(', ')}`);
500
531
  }
@@ -502,9 +533,11 @@ export function printDoctorDetailsHuman(view) {
502
533
  console.log('');
503
534
  console.log(color.bold('Adoption (separate from fitness score)'));
504
535
  if (adoption.gaps.length === 0 && !adoption.layerBalance) {
505
- line(
506
- ok,
507
- 'Hosts, MCP argv, core optionality, origin report, baseline policy, and deploy-path lint/types look complete'
536
+ citedGreen(
537
+ line,
538
+ { ok, warn },
539
+ 'Hosts, MCP argv, core optionality, origin report, baseline policy, and deploy-path lint/types look complete',
540
+ ['AGENTS.md', 'ark.config.json']
508
541
  );
509
542
  } else {
510
543
  for (const gap of adoption.gaps) {
@@ -553,7 +586,9 @@ export function printDoctorDetailsHuman(view) {
553
586
  ['Rules with peerIsolation: false', safety.disabledPeerIsolationRules],
554
587
  ];
555
588
  for (const [label, entries] of rows) {
556
- line(entries.length === 0 ? ok : warn, `${label}: ${entries.length}`);
589
+ const claim = `${label}: ${entries.length}`;
590
+ if (entries.length === 0) line(' ', claim);
591
+ else line(warn, claim);
557
592
  }
558
593
  }
559
594
  }
@@ -56,6 +56,15 @@ export function collectDoctorNextActions(ctx) {
56
56
  if (ctx.noDomainFrontend?.nextAction) {
57
57
  actions.push(ctx.noDomainFrontend.nextAction);
58
58
  }
59
+ if (ctx.prototypeShortcuts?.nextAction) {
60
+ actions.push(ctx.prototypeShortcuts.nextAction);
61
+ }
62
+ if (ctx.invariantTestsPath?.missing && ctx.invariantTestsPath.nextAction) {
63
+ actions.push(ctx.invariantTestsPath.nextAction);
64
+ }
65
+ if (ctx.invariantCoverageRoots?.missing && ctx.invariantCoverageRoots.nextAction) {
66
+ actions.push(ctx.invariantCoverageRoots.nextAction);
67
+ }
59
68
  if (!ctx.analysisComplete) actions.push('restore complete analysis, then rerun ark-check --doctor');
60
69
  if (ctx.designSmells.length > 0 && ctx.postGreenPath) actions.push(ctx.postGreenPath.action);
61
70
  if (ctx.coverageHonesty.greenIsNotEnforcement && ctx.coverageHonesty.worseThanNoGate) {
@@ -20,6 +20,7 @@ import { collectAdrPresenceResidual, printAdrPresenceHint } from './adr-presence
20
20
  import { collectStatesTransitionsResidual } from './states-transitions-presence.mjs';
21
21
  import { collectStatusTransitionCatalogResidual } from './status-transition-catalog.mjs';
22
22
  import { collectNoDomainFrontendResidual } from './no-domain-frontend.mjs';
23
+ import { collectInvariantCoverageResiduals } from './invariant-tests-path.mjs';
23
24
  export { printAdrPresenceHint };
24
25
  export { printDoctorCompactHuman, printDoctorDetailsHuman };
25
26
  export { summarizeRulesUnderContract };
@@ -73,6 +74,7 @@ import { writeCiMergeBoundary } from './ci-merge-boundary.mjs';
73
74
  import {
74
75
  classifyAdopted,
75
76
  githubEvidenceForCiMergeBoundary,
77
+ isAdopted,
76
78
  readAdoptionStance,
77
79
  NOT_ADOPTED_NEXT_ACTION,
78
80
  } from './adoption-stance.mjs';
@@ -650,6 +652,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
650
652
  const statesTransitions = collectStatesTransitionsResidual({ root });
651
653
  const statusTransitionCatalog = collectStatusTransitionCatalogResidual({ root, config, files, statesTransitions });
652
654
  const noDomainFrontend = collectNoDomainFrontendResidual({ config, coverage: cov, designSmells });
655
+ const { invariantCoverageRoots, invariantTestsPath } = collectInvariantCoverageResiduals({ adopted: isAdopted(adopted) || options.requireGates === true, coverage: config?.coverage, config, root });
653
656
  const { coverageHonesty, baselineHonesty, writePathHonesty, productHonesty } =
654
657
  computeDoctorEnforcementHonesty({
655
658
  governedPercent: cov.governed.percent,
@@ -782,7 +785,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
782
785
  ...(layerOwners ? { layerOwners } : {}),
783
786
  ...(adrPresence ? { adrPresence } : {}),
784
787
  ...(statesTransitions ? { statesTransitions } : {}),
785
- ...(statusTransitionCatalog ? { statusTransitionCatalog } : {}), ...(noDomainFrontend ? { noDomainFrontend } : {}),
788
+ ...(statusTransitionCatalog ? { statusTransitionCatalog } : {}), ...(noDomainFrontend ? { noDomainFrontend } : {}), ...(invariantTestsPath ? { invariantTestsPath } : {}), ...(invariantCoverageRoots ? { invariantCoverageRoots } : {}),
786
789
  emptyLayers: cov.emptyLayers,
787
790
  layersWithoutRules: cov.layersWithoutRules,
788
791
  ungovernedDirs: cov.suggestions.length,
@@ -927,7 +930,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
927
930
  layerOwners,
928
931
  adrPresence,
929
932
  statesTransitions,
930
- statusTransitionCatalog, noDomainFrontend,
933
+ statusTransitionCatalog, noDomainFrontend, prototypeShortcuts: doctorAdvisories.prototypeShortcuts, invariantTestsPath, invariantCoverageRoots,
931
934
  });
932
935
  const humanView = {
933
936
  root,
@@ -937,7 +940,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
937
940
  layerOwners,
938
941
  adrPresence,
939
942
  statesTransitions,
940
- statusTransitionCatalog, noDomainFrontend,
943
+ statusTransitionCatalog, noDomainFrontend, prototypeShortcuts: doctorAdvisories.prototypeShortcuts, invariantTestsPath, invariantCoverageRoots,
941
944
  operatingMode,
942
945
  designFitness,
943
946
  adopted,