arkgate 3.9.0 → 3.9.2

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 CHANGED
@@ -5,6 +5,64 @@ in the immutable pre-2.0 archive linked below.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 3.9.2 — 2026-07-23
9
+
10
+ **Patch** over 3.9.1. Product honesty for post-validity coaching, coverage/host write paths, and
11
+ advisory analysis precision — **no required config migration**, no gate weakening, no hard-write
12
+ claims on soft hosts. Y07 / Y09 remain **parked** (not promoted).
13
+
14
+ ### Added
15
+
16
+ - **Enforcement honesty helpers** (`bin/lib/enforcement-honesty.mjs`): coverage honesty
17
+ (empty / weak <50% worse-than-no-gate / partial / strong; `greenIsNotEnforcement` until 100%;
18
+ `wholeTreeGoverned`), baseline dirty-freeze risk, write-path honesty with soft hosts derived from
19
+ `HOST_SUPPORT_MATRIX` (fail-closed: soft never `hardWriteActive`).
20
+ - **Graph blind spots** (`bin/lib/graph-blind.mjs`): advisory scan for unresolvable dynamic
21
+ import/require edges (template-interpolation + non-literals + import-equals). Never a hard
22
+ architecture verdict; Y09 direction only.
23
+ - Doctor / plan JSON: `coverageHonesty`, `baseline.honesty`, `writePath.honesty`,
24
+ `graphBlindSpots`, design-weak honesty flags (`healthyFinishedForbidden`,
25
+ `multiPilotBatchForbidden`, `autoApplyForbidden` / `autoApplyPlanBForbidden`).
26
+ - Focused unit suite `tests/unit/static-check/enforcementHonesty.test.ts`.
27
+
28
+ ### Changed
29
+
30
+ - **Post-green path:** placement coaching + shared design-weak honesty flags.
31
+ - **Pilot loop:** one-at-a-time queue (`queuedBets` / `queueNote`); multi-pilot batch and silent
32
+ plan-B auto-apply forbidden on all return paths.
33
+ - **Ambient sensor (Y07 honesty only):** status vocabulary (`idle` / `active-clean` /
34
+ `active-findings` / `unavailable`), `blockerGrade: false`, `strictDiagnostics: 'parked-Y07'`;
35
+ idle/clean/unavailable print honesty lines (not silence-as-done).
36
+ - **Skills** (`ark-coverage`, `ark-explore`, `ark-place`): deepen honesty / one-pilot routing
37
+ without new skill basenames.
38
+ - **HTML advisories:** graphBlindSpots X01 parity; ambient h2 parked-Y07 wording.
39
+
40
+ ### Notes
41
+
42
+ - Soft write hosts (Cursor / Codex / OpenCode) remain advisory at write; required CI status is the
43
+ hard merge boundary.
44
+ - Z09 / residual `RB-11` remain open. Y07 / Y09 not marked done.
45
+
46
+ ## 3.9.1 — 2026-07-23
47
+
48
+ **Patch** over 3.9.0. Repo hygiene and CI honesty only — **no required config migration**, no gate
49
+ weakening, no product API changes.
50
+
51
+ ### Fixed
52
+
53
+ - **Onboarding matrix (`o04`):** `ark start --install` may rewrite package-manager lockfiles when the
54
+ published package is installed; tests compare product mutation paths only (lockfile drift allowed).
55
+ - **Docs post-3.9.0 publish:** CONTRIBUTING / README / release notes / migrate guide match npm truth
56
+ for the published line (updated again for this patch after 3.9.1 lands on `latest`).
57
+ - **Supply-chain hygiene:** `fast-uri` **3.1.4** (transitive via `ajv`) closes host-confusion advisory
58
+ GHSA-v2hh-gcrm-f6hx. Eval fixtures pin **Next.js 15.5.21** (patched) so Dependabot Next alerts on
59
+ `eval/cases/**` corpus close without shipping Next in the `arkgate` npm tarball.
60
+
61
+ ### Notes
62
+
63
+ - Next bumps are **eval fixture hygiene**, not a consumer-runtime CVE in the published package.
64
+ - Z09 / residual `RB-11` remain open.
65
+
8
66
  ## 3.9.0 — 2026-07-23
9
67
 
10
68
  **Beautiful Path** minor: one primary flow, doctor as control plane, progressive disclosure, and
package/README.md CHANGED
@@ -16,9 +16,10 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 3.9.0** is the next prepared release (**Beautiful Path**). **npm `latest` is still
20
- > 3.8.3** until publication succeeds.
21
- > [Release notes](docs/releases/3.9.0.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
19
+ > **ArkGate 3.9.2** is the next prepared patch (enforcement honesty: coverage/host write path,
20
+ > design-weak one-pilot coaching, advisory graph-blind). **npm `latest` is still 3.9.1** until
21
+ > publication succeeds. [3.9.2 notes](docs/releases/3.9.2.md) · [3.9.1](docs/releases/3.9.1.md) ·
22
+ > [Docs hub](docs/README.md)
22
23
 
23
24
  ---
24
25
 
@@ -175,7 +176,7 @@ for real systems. Details: [docs/production-hardening.md](docs/production-harden
175
176
  | Config · package surface · TS | [configuration](docs/configuration.md) · [package-surface](docs/package-surface.md) · [typescript-support](docs/typescript-support.md) |
176
177
  | Brownfield | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
177
178
  | Security | [SECURITY.md](SECURITY.md) |
178
- | Latest release (3.9.0) | [docs/releases/3.9.0.md](docs/releases/3.9.0.md) · [CHANGELOG](CHANGELOG.md) |
179
+ | Latest release (3.9.1 on npm; 3.9.2 prepared) | [3.9.2](docs/releases/3.9.2.md) · [3.9.1](docs/releases/3.9.1.md) · [CHANGELOG](CHANGELOG.md) |
179
180
  | History / maintainer evidence | [docs/archive/](docs/archive/README.md) |
180
181
 
181
182
  ---
@@ -160,19 +160,37 @@ export function detectAmbientState(ts, root, config, files, ackState = { exists:
160
160
  };
161
161
  }
162
162
 
163
+ /**
164
+ * Status vocabulary for ambient sensor honesty (Y07 strict stays parked).
165
+ * @param {{ active?: boolean, findingCount?: number }} result
166
+ */
167
+ export function ambientSensorStatus(result) {
168
+ if (!result?.active) return 'idle';
169
+ return (result.findingCount ?? 0) > 0 ? 'active-findings' : 'active-clean';
170
+ }
171
+
163
172
  /** JSON summary for doctor. Advisory only — never a verdict input. */
164
173
  export function summarizeAmbientState(result, ackState = { exists: false, acks: [] }) {
174
+ const findingCount = result.findings.length;
175
+ const status = ambientSensorStatus({ active: result.active, findingCount });
165
176
  return {
166
177
  available: true,
167
178
  active: result.active,
179
+ status,
168
180
  advisory: true,
169
- findingCount: result.findings.length,
181
+ // Strict (blocker-grade) ambient diagnostics remain parked (Y07) until a real pure corpus.
182
+ blockerGrade: false,
183
+ strictDiagnostics: 'parked-Y07',
184
+ findingCount,
170
185
  acknowledged: ackState?.invalid ? 0 : result.acknowledgedCount,
171
186
  ...(result.truncated > 0 ? { truncated: result.truncated } : {}),
172
187
  ...(result.skippedFiles > 0 ? { skippedFiles: result.skippedFiles } : {}),
173
- note: result.active
174
- ? 'Module-scope mutable state in pure layers — advisory only; acknowledge deliberate registries in the sidecar or move the state behind a port.'
175
- : 'No pure: true layer opted in; the sensor is idle.',
188
+ note:
189
+ status === 'idle'
190
+ ? 'Idle: no pure: true layer opted in. Sensor stays advisory; blocker-grade ambient diagnostics are parked (Y07) until a real pure-layer field corpus exists. Opt in via layer pure: true when ready.'
191
+ : status === 'active-findings'
192
+ ? 'Module-scope mutable state in pure layers — advisory only (never a hard verdict). Acknowledge deliberate registries in the sidecar or move state behind a port. Strict diagnostics remain parked (Y07).'
193
+ : 'Pure layers opted in; no module-scope let/var findings in the MVP envelope. Advisory sensor only — not a pass for blocker-grade ambient enforcement (Y07 parked).',
176
194
  ackFile: {
177
195
  path: ackState?.path ?? AMBIENT_STATE_ACKS_PATH,
178
196
  present: ackState?.exists === true,
@@ -188,11 +206,14 @@ export function computeAmbientState(ts, root, config, files) {
188
206
  return {
189
207
  available: false,
190
208
  active: false,
209
+ status: 'unavailable',
191
210
  advisory: true,
211
+ blockerGrade: false,
212
+ strictDiagnostics: 'parked-Y07',
192
213
  findings: [],
193
214
  findingCount: 0,
194
215
  acknowledged: 0,
195
- note: 'TypeScript was not available to the doctor run; the ambient-state sensor did not execute.',
216
+ note: 'TypeScript was not available to the doctor run; the ambient-state sensor did not execute. Advisory only; Y07 strict remains parked.',
196
217
  };
197
218
  }
198
219
  const ackState = loadAmbientStateAcks(root);
@@ -200,9 +221,41 @@ export function computeAmbientState(ts, root, config, files) {
200
221
  return { ...summarizeAmbientState(result, ackState), findings: result.findings };
201
222
  }
202
223
 
203
- /** Human doctor section (advisory); silent when idle and healthy. */
224
+ /**
225
+ * Human doctor section (advisory).
226
+ * Idle prints a single dim honesty line so silence is not misread as "ambient done."
227
+ */
204
228
  export function printAmbientStateSection(state, io) {
205
- if (!state.available || (!state.findingCount && !state.ackFile?.invalid)) return;
229
+ if (!state) return;
230
+ if (!state.available) {
231
+ console.log('');
232
+ console.log(io.color.bold('Ambient state (advisory)'));
233
+ io.line(' ', io.color.dim(state.note || 'Ambient sensor unavailable.'));
234
+ return;
235
+ }
236
+ if (state.status === 'idle' && !state.ackFile?.invalid) {
237
+ console.log('');
238
+ console.log(io.color.bold('Ambient state (advisory)'));
239
+ io.line(
240
+ ' ',
241
+ io.color.dim(
242
+ 'Idle (no pure: true layer) — advisory sensor only; blocker-grade ambient diagnostics parked (Y07).'
243
+ )
244
+ );
245
+ return;
246
+ }
247
+ if (state.status === 'active-clean' && !state.ackFile?.invalid && !state.findingCount) {
248
+ console.log('');
249
+ console.log(io.color.bold('Ambient state (advisory)'));
250
+ io.line(
251
+ ' ',
252
+ io.color.dim(
253
+ 'Pure layers clean under MVP envelope — still advisory; not Y07 blocker-grade pass.'
254
+ )
255
+ );
256
+ return;
257
+ }
258
+ if (!state.findingCount && !state.ackFile?.invalid) return;
206
259
  console.log('');
207
260
  console.log(io.color.bold('Ambient state (advisory)'));
208
261
  if (state.ackFile?.invalid) {
@@ -217,5 +270,8 @@ export function printAmbientStateSection(state, io) {
217
270
  if (state.acknowledged > 0) {
218
271
  io.line(' ', io.color.dim(`acknowledged module state: ${state.acknowledged}`));
219
272
  }
220
- io.line(' ', io.color.dim('advisory only — never blocks; move state behind a port or acknowledge it'));
273
+ io.line(
274
+ ' ',
275
+ io.color.dim('advisory only — never blocks; move state behind a port or acknowledge it (Y07 strict parked)')
276
+ );
221
277
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Doctor's advisory sensors, aggregated (W01 contract health, U05 ambient
3
- * state, X04 physical cohesion, Y03 parse health). These sensors do not create
4
- * architecture violations or designFitness findings; Z02 separately maps
5
- * parse-health evidence to analysis completeness and fail-closed exits. One
6
- * seam keeps doctor-plan.mjs inside its module budget as new surfaces land.
3
+ * state, X04 physical cohesion, Y03 parse health, graph-blind template edges).
4
+ * These sensors do not create architecture violations or designFitness findings;
5
+ * Z02 separately maps parse-health evidence to analysis completeness and
6
+ * fail-closed exits. One seam keeps doctor-plan.mjs inside its module budget.
7
7
  */
8
8
  import { computeAmbientState, printAmbientStateSection } from './ambient-state.mjs';
9
9
  import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
@@ -17,6 +17,7 @@ import {
17
17
  printReshapeDecisionsSection,
18
18
  } from './reshape-decisions.mjs';
19
19
  import { printParseHealthSection, summarizeParseHealth } from './parse-health.mjs';
20
+ import { detectGraphBlindSpots, printGraphBlindSection } from './graph-blind.mjs';
20
21
 
21
22
  export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth) {
22
23
  const physicalCohesion = computePhysicalCohesion(root, files);
@@ -33,6 +34,8 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
33
34
  ambientState: computeAmbientState(ts, root, config, files),
34
35
  physicalCohesion,
35
36
  parseHealth: parseHealth ?? summarizeParseHealth(),
37
+ // Y09 direction: advisory graph-blind spots (template-interpolation); never hard verdict.
38
+ graphBlindSpots: detectGraphBlindSpots(ts, root, files),
36
39
  };
37
40
  }
38
41
 
@@ -46,4 +49,5 @@ export function printDoctorAdvisories(advisories, io) {
46
49
  );
47
50
  printReshapeDecisionsSection(advisories.physicalCohesion?.reshapeDecisions, io);
48
51
  printParseHealthSection(advisories.parseHealth, io);
52
+ printGraphBlindSection(advisories.graphBlindSpots, io);
49
53
  }
@@ -35,8 +35,12 @@ import {
35
35
  buildPostGreenNextAction,
36
36
  mergePostGreenTopActions,
37
37
  isDoctorHealthyNothingToDo,
38
+ DESIGN_WEAK_HONESTY_FLAGS,
38
39
  } from './post-green-path.mjs';
39
- import { doctorWritePathHonestyMessage } from './host-support-matrix.mjs';
40
+ import {
41
+ buildCoverageHonesty,
42
+ computeDoctorEnforcementHonesty,
43
+ } from './enforcement-honesty.mjs';
40
44
  import {
41
45
  computePureLayerOptInNudge,
42
46
  loadGoldenPattern,
@@ -129,10 +133,12 @@ export function runCoverage(root, config, files, rules, asJson) {
129
133
  if (files.length > 0 && governed.percent < 50) {
130
134
  console.log('');
131
135
  console.log(
132
- `⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check here does NOT`
136
+ `⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check on ~${governed.percent}%`
137
+ );
138
+ console.log(
139
+ ' is worse than no gate — it looks safe while most code is ungoverned. Classify the'
133
140
  );
134
- console.log(' mean the codebase is checked the rest is ungoverned. Classify the directories');
135
- console.log(' below to actually cover it.');
141
+ console.log(' directories below before treating green as enforcement.');
136
142
  }
137
143
  if (suggestions.length > 0) {
138
144
  console.log('');
@@ -236,6 +242,11 @@ export function buildRemediationPlan(
236
242
  patternBets,
237
243
  designSmells,
238
244
  });
245
+ const coverageHonesty = buildCoverageHonesty({
246
+ percent: governedPercent,
247
+ totalFiles,
248
+ emptyScope,
249
+ });
239
250
 
240
251
  let statement =
241
252
  activeViolations.length > 0
@@ -264,6 +275,7 @@ export function buildRemediationPlan(
264
275
  ? {
265
276
  designWeakLabel:
266
277
  'ENFORCE · design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
278
+ ...DESIGN_WEAK_HONESTY_FLAGS,
267
279
  }
268
280
  : {}),
269
281
  ...(governedPercent != null ? { governedPercent } : {}),
@@ -282,6 +294,7 @@ export function buildRemediationPlan(
282
294
  designSmells,
283
295
  // Q04: one-pilot loop step (extraction card); never mechanical-safe.
284
296
  pilotLoop,
297
+ coverageHonesty,
285
298
  };
286
299
  }
287
300
 
@@ -435,6 +448,18 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
435
448
  designSmells,
436
449
  });
437
450
  const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth);
451
+ const { coverageHonesty, baselineHonesty, writePathHonesty } = computeDoctorEnforcementHonesty({
452
+ governedPercent: cov.governed.percent,
453
+ totalFiles: cov.governed.totalFiles,
454
+ emptyScope: cov.emptyScope === true || cov.governed.totalFiles === 0,
455
+ baselineExists: baseline.exists,
456
+ frozenKeys: baseline.exists ? baseline.keys.size : 0,
457
+ activeViolations: activeCount,
458
+ suppressed,
459
+ totalViolations: violations.length,
460
+ activeHost: writePath.activeHost,
461
+ hardWriteActive: writePath.capabilities?.['hard-write'] === true,
462
+ });
438
463
 
439
464
  if (asJson) {
440
465
  (options.writeJson ?? console.log)(
@@ -464,10 +489,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
464
489
  // Q01: primary next action when Shape residual dominates (null if not design-weak).
465
490
  postGreenPath,
466
491
  ...(postGreenPath
467
- ? {
468
- primaryNextAction: postGreenPath.action,
469
- healthyFinishedForbidden: true,
470
- }
492
+ ? { primaryNextAction: postGreenPath.action, ...DESIGN_WEAK_HONESTY_FLAGS }
471
493
  : {}),
472
494
  // Q03: advisory golden for new-code placement (absent = no claim).
473
495
  goldenPattern,
@@ -475,10 +497,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
475
497
  pureLayerOptIn,
476
498
  // Q04: one-pilot loop (extraction card → re-doctor).
477
499
  pilotLoop,
478
- // Advisories, never a verdict: W01 contract health, U05 ambient state,
479
- // X04 physical cohesion/reshape pilot, Y03 parse health.
500
+ // Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
480
501
  ...doctorAdvisories,
481
502
  governed: cov.governed,
503
+ coverageHonesty,
482
504
  emptyLayers: cov.emptyLayers,
483
505
  layersWithoutRules: cov.layersWithoutRules,
484
506
  ungovernedDirs: cov.suggestions.length,
@@ -497,6 +519,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
497
519
  frozen: baseline.exists ? baseline.keys.size : 0,
498
520
  stale: analysisComplete ? staleBaseline : null,
499
521
  policy: adoption.baseline,
522
+ honesty: baselineHonesty,
500
523
  },
501
524
  gatesMissing,
502
525
  skillGaps,
@@ -517,6 +540,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
517
540
  hookRepair: writePath.hookRepair,
518
541
  mcpPresent: writePath.mcpPresent,
519
542
  evidence: writePath.evidence,
543
+ honesty: writePathHonesty,
520
544
  ...(writePath.sessionNote ? { sessionNote: writePath.sessionNote } : {}),
521
545
  ...(writePath.gap
522
546
  ? {
@@ -688,6 +712,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
688
712
  ? ok
689
713
  : warn;
690
714
  line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
715
+ if (coverageHonesty.greenIsNotEnforcement) {
716
+ line(coverageHonesty.worseThanNoGate ? bad : warn, coverageHonesty.message);
717
+ if (coverageHonesty.worseThanNoGate) {
718
+ actions.push('raise governed coverage above a minority slice before treating green as enforcement');
719
+ }
720
+ }
691
721
  if (cov.suggestions.length > 0) {
692
722
  line(warn, `${cov.suggestions.length} ungoverned director(y/ies) — proposals: ${arkCommand(root, 'ark-check', '--coverage')}`);
693
723
  actions.push('classify the ungoverned directories (/ark-contract)');
@@ -770,8 +800,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
770
800
  line(' ', `Active host: ${writePath.activeHost}`);
771
801
  line(' ', `Supported profile: ${writePath.supportSummary}`);
772
802
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
773
- const honestyLine = doctorWritePathHonestyMessage(writePath.activeHost, capabilities['hard-write']);
774
- if (honestyLine) line(warn, honestyLine);
803
+ if (writePathHonesty.message) line(warn, writePathHonesty.message);
775
804
  if (writePath.sessionNote) {
776
805
  line(warn, writePath.sessionNote);
777
806
  }
@@ -850,7 +879,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
850
879
  } else {
851
880
  // Baseline keys are line-agnostic, so N keys can suppress ≥N violations — label as keys
852
881
  // to avoid an apparent mismatch with the "frozen" violation count above.
853
- line(analysisComplete ? ok : warn, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
882
+ const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
883
+ line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
884
+ if (analysisComplete && baselineHonesty.dirtyBaselineRisk) {
885
+ line(warn, baselineHonesty.message);
886
+ actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
887
+ }
854
888
  if (analysisComplete && staleBaseline > 0) {
855
889
  line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
856
890
  actions.push('tighten the baseline (--update-baseline)');
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Product honesty helpers — weak coverage, dirty baseline, soft write hosts.
3
+ *
4
+ * Pure / fail-closed: never invent hard write guarantees; never paint thin
5
+ * coverage or a dirty freeze as "done." Advisory labels only.
6
+ */
7
+ import {
8
+ doctorWritePathHonestyMessage,
9
+ HOST_SUPPORT_MATRIX,
10
+ HOST_SUPPORT_HOSTS,
11
+ } from './host-support-matrix.mjs';
12
+
13
+ /** Soft = matrix hard-write false; hard-capable = matrix hard-write true. Single source of truth. */
14
+ function hostWriteClassSets() {
15
+ const soft = new Set();
16
+ const hard = new Set();
17
+ for (const host of HOST_SUPPORT_HOSTS) {
18
+ const profile = HOST_SUPPORT_MATRIX[host];
19
+ if (profile?.capabilities?.['hard-write']) hard.add(host);
20
+ else soft.add(host);
21
+ }
22
+ return { soft, hard };
23
+ }
24
+
25
+ const { soft: SOFT_WRITE_HOSTS, hard: HARD_WRITE_HOSTS } = hostWriteClassSets();
26
+
27
+ /**
28
+ * Coverage honesty: green on a minority of the tree is worse than no gate.
29
+ * `greenIsNotEnforcement` stays true until the whole in-scope tree is governed (100%).
30
+ * @param {{ percent?: number|null, totalFiles?: number|null, emptyScope?: boolean }} input
31
+ */
32
+ export function buildCoverageHonesty(input = {}) {
33
+ const total = Number(input.totalFiles) || 0;
34
+ const empty = input.emptyScope === true || total === 0;
35
+ const percent = Number.isFinite(Number(input.percent)) ? Number(input.percent) : 0;
36
+
37
+ if (empty) {
38
+ return {
39
+ status: 'empty-scope',
40
+ advisory: true,
41
+ greenIsNotEnforcement: true,
42
+ worseThanNoGate: true,
43
+ wholeTreeGoverned: false,
44
+ governedPercent: 0,
45
+ // Always 0 when empty-scope so payload cannot contradict the message.
46
+ totalFiles: 0,
47
+ message:
48
+ 'Empty scope: a green check matches 0 files and is worse than no gate until include/layers cover real code.',
49
+ };
50
+ }
51
+ if (percent < 50) {
52
+ return {
53
+ status: 'weak',
54
+ advisory: true,
55
+ greenIsNotEnforcement: true,
56
+ worseThanNoGate: true,
57
+ wholeTreeGoverned: false,
58
+ governedPercent: percent,
59
+ totalFiles: total,
60
+ message: `Weak coverage (${percent}%): a green check on a minority of the tree is worse than no gate — most code is ungoverned.`,
61
+ };
62
+ }
63
+ if (percent < 80) {
64
+ return {
65
+ status: 'partial',
66
+ advisory: true,
67
+ greenIsNotEnforcement: true,
68
+ worseThanNoGate: false,
69
+ wholeTreeGoverned: false,
70
+ governedPercent: percent,
71
+ totalFiles: total,
72
+ message: `Partial coverage (${percent}%): green means edges on the governed slice only, not the whole tree.`,
73
+ };
74
+ }
75
+ const wholeTree = percent >= 100;
76
+ return {
77
+ status: 'strong',
78
+ advisory: true,
79
+ // Strong slice ≠ full-tree enforcement; boolean consumers must not oversell.
80
+ greenIsNotEnforcement: !wholeTree,
81
+ worseThanNoGate: false,
82
+ wholeTreeGoverned: wholeTree,
83
+ governedPercent: percent,
84
+ totalFiles: total,
85
+ message: wholeTree
86
+ ? `Governed 100% of in-scope files — green is meaningful for the full in-scope tree.`
87
+ : `Governed ${percent}% of in-scope files — green is meaningful only for that governed slice; ${100 - percent}% remains ungoverned.`,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Baseline honesty: a large freeze that zeros active violations can look safe
93
+ * while hiding false-positive debt.
94
+ * @param {{
95
+ * exists?: boolean,
96
+ * frozenKeys?: number,
97
+ * activeViolations?: number,
98
+ * suppressed?: number,
99
+ * totalViolations?: number,
100
+ * }} input
101
+ */
102
+ export function buildBaselineHonesty(input = {}) {
103
+ const exists = input.exists === true;
104
+ const frozen = Number(input.frozenKeys) || 0;
105
+ const active = Number(input.activeViolations) || 0;
106
+ const suppressed = Number(input.suppressed) || 0;
107
+ const total = Number(input.totalViolations) || 0;
108
+
109
+ if (!exists) {
110
+ return {
111
+ status: total > 0 ? 'missing-with-debt' : 'absent',
112
+ advisory: true,
113
+ dirtyBaselineRisk: false,
114
+ frozenKeys: 0,
115
+ activeViolations: active,
116
+ suppressed: 0,
117
+ message:
118
+ total > 0
119
+ ? 'No baseline while violations exist — freeze only real debt after the contract is honest.'
120
+ : 'No baseline (nothing to freeze).',
121
+ };
122
+ }
123
+
124
+ const suppressShare = total > 0 ? suppressed / total : 0;
125
+ // Dirty: green-via-freeze with material frozen debt (false-positive risk).
126
+ const dirty =
127
+ active === 0 &&
128
+ frozen > 0 &&
129
+ (frozen >= 10 || (frozen >= 5 && suppressed > 0) || (total >= 3 && suppressShare >= 0.5));
130
+
131
+ if (dirty) {
132
+ return {
133
+ status: 'dirty-freeze',
134
+ advisory: true,
135
+ dirtyBaselineRisk: true,
136
+ frozenKeys: frozen,
137
+ activeViolations: active,
138
+ suppressed,
139
+ message: `Baseline freezes ${frozen} key(s) while active violations are ${active} — green may hide false-positive debt. Prefer contract fixes over a dirty freeze.`,
140
+ };
141
+ }
142
+ if (frozen > 0) {
143
+ return {
144
+ status: 'active-freeze',
145
+ advisory: true,
146
+ dirtyBaselineRisk: false,
147
+ frozenKeys: frozen,
148
+ activeViolations: active,
149
+ suppressed,
150
+ message: `${frozen} frozen key(s); new distinct violations still fail. Ratchet down; do not reopen.`,
151
+ };
152
+ }
153
+ return {
154
+ status: 'empty-freeze',
155
+ advisory: true,
156
+ dirtyBaselineRisk: false,
157
+ frozenKeys: 0,
158
+ activeViolations: active,
159
+ suppressed,
160
+ message: 'Baseline present with 0 frozen keys — every violation is active (honest).',
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Write-path honesty for the active host (fail-closed).
166
+ * Soft hosts never claim hard local write; hard hosts without proof stay unverified.
167
+ * @param {string|null|undefined} activeHost
168
+ * @param {boolean} hardWriteActive
169
+ */
170
+ export function buildWritePathHonesty(activeHost, hardWriteActive = false) {
171
+ const host = typeof activeHost === 'string' ? activeHost.trim().toLowerCase() : '';
172
+ const softWriteHost = SOFT_WRITE_HOSTS.has(host);
173
+ const hardCapable = HARD_WRITE_HOSTS.has(host);
174
+ const message = doctorWritePathHonestyMessage(host, hardWriteActive);
175
+
176
+ return {
177
+ advisory: true,
178
+ activeHost: host || null,
179
+ softWriteHost,
180
+ hardWriteSupported: hardCapable,
181
+ hardWriteActive: Boolean(hardWriteActive) && hardCapable && !softWriteHost,
182
+ hardWriteUnverified: hardCapable && !hardWriteActive,
183
+ hardMergeBoundary: 'required-ci-status (arkgate-check --strict-merge)',
184
+ message,
185
+ // Explicit product rule for soft hosts.
186
+ ...(softWriteHost
187
+ ? {
188
+ note: 'Local write is advisory / best-effort — not a hard PreToolUse boundary. Required CI status is the hard merge boundary.',
189
+ }
190
+ : {}),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * One-shot doctor honesty bundle (coverage + baseline + write path).
196
+ * Keeps doctor-plan.mjs under its module budget.
197
+ */
198
+ export function computeDoctorEnforcementHonesty({
199
+ governedPercent,
200
+ totalFiles,
201
+ emptyScope,
202
+ baselineExists,
203
+ frozenKeys,
204
+ activeViolations,
205
+ suppressed,
206
+ totalViolations,
207
+ activeHost,
208
+ hardWriteActive,
209
+ } = {}) {
210
+ return {
211
+ coverageHonesty: buildCoverageHonesty({
212
+ percent: governedPercent,
213
+ totalFiles,
214
+ emptyScope,
215
+ }),
216
+ baselineHonesty: buildBaselineHonesty({
217
+ exists: baselineExists,
218
+ frozenKeys,
219
+ activeViolations,
220
+ suppressed,
221
+ totalViolations,
222
+ }),
223
+ writePathHonesty: buildWritePathHonesty(activeHost, hardWriteActive),
224
+ };
225
+ }