arkgate 3.9.1 → 4.0.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 +111 -0
- package/README.md +16 -4
- package/bin/ark-check-runtime.mjs +75 -3
- package/bin/ark-mcp-runtime.mjs +94 -0
- package/bin/lib/adapter-contract.mjs +14 -1
- package/bin/lib/ambient-state.mjs +64 -8
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/architecture-scan.mjs +35 -2
- package/bin/lib/arkrule-file-hints.mjs +71 -0
- package/bin/lib/arkrules-contract.mjs +382 -0
- package/bin/lib/arkrules-sensors.mjs +411 -0
- package/bin/lib/config-contract.mjs +85 -6
- package/bin/lib/doctor-advisories.mjs +22 -5
- package/bin/lib/doctor-plan.mjs +68 -13
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/enforcement-honesty.mjs +225 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +254 -0
- package/bin/lib/html-report-advisories.mjs +29 -3
- package/bin/lib/install-migrate.mjs +20 -2
- package/bin/lib/invariant-coverage-io.mjs +157 -0
- package/bin/lib/invariant-coverage.mjs +127 -0
- package/bin/lib/pilot-loop.mjs +19 -0
- package/bin/lib/policy-delta-io.mjs +33 -0
- package/bin/lib/post-green-path.mjs +22 -1
- package/bin/lib/presets.mjs +241 -1
- package/bin/lib/remediation.mjs +28 -0
- package/bin/lib/resolved-candidate-facts.mjs +14 -1
- package/bin/lib/rules-inventory.mjs +144 -0
- package/bin/lib/rules-under-contract.mjs +66 -0
- package/bin/lib/start-preview.mjs +24 -7
- package/bin/lib/upgrade-command.mjs +48 -2
- package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +14 -7
- package/dist/index.d.ts +615 -20
- package/dist/index.js +13 -6
- package/docs/README.md +4 -3
- package/docs/agent-guide.md +7 -3
- package/docs/ai-gates.md +6 -1
- package/docs/brownfield-adoption.md +20 -0
- package/docs/configuration.md +37 -4
- package/docs/develop.md +8 -2
- package/docs/enthusiast/README.md +11 -0
- package/docs/package-surface.md +13 -11
- package/docs/product-voice.md +9 -2
- package/docs/use.md +9 -0
- package/package.json +4 -17
- package/schemas/ark.analysis-result.schema.json +9 -1
- package/schemas/ark.arkrules.schema.json +141 -0
- package/schemas/ark.config.schema.json +10 -2
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/arkrules/ApplicationOrchestration.json +14 -0
- package/templates/arkrules/DomainModel.json +32 -0
- package/templates/arkrules/PersistenceAdapters.json +14 -0
- package/templates/arkrules/PresentationAdapters.json +14 -0
- package/templates/skills/ark-adopt.md +28 -1
- package/templates/skills/ark-architect.md +23 -0
- package/templates/skills/ark-autopilot.md +27 -1
- package/templates/skills/ark-contract.md +27 -1
- package/templates/skills/ark-coverage.md +30 -0
- package/templates/skills/ark-explain.md +23 -0
- package/templates/skills/ark-explore.md +30 -2
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +30 -0
- package/templates/skills/ark-runtime.md +4 -0
- package/templates/skills/ark-think.md +24 -1
- package/templates/skills/ark-upgrade.md +23 -0
- package/compat/nestjs.cjs +0 -2
- package/compat/nestjs.d.ts +0 -2
- package/compat/nestjs.js +0 -1
- package/compat/runtime.cjs +0 -2
- package/compat/runtime.d.ts +0 -2
- package/compat/runtime.js +0 -1
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
resolveOperatingMode,
|
|
10
10
|
shouldShowNewHereNudge,
|
|
11
11
|
} from '../ark-shared.mjs';
|
|
12
|
+
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
13
|
+
import { describePackageVersionDualTruth } from './field-install.mjs';
|
|
14
|
+
export { summarizeRulesUnderContract };
|
|
12
15
|
import {
|
|
13
16
|
collectAdoptionGaps,
|
|
14
17
|
detectSkillGaps,
|
|
@@ -35,8 +38,12 @@ import {
|
|
|
35
38
|
buildPostGreenNextAction,
|
|
36
39
|
mergePostGreenTopActions,
|
|
37
40
|
isDoctorHealthyNothingToDo,
|
|
41
|
+
DESIGN_WEAK_HONESTY_FLAGS,
|
|
38
42
|
} from './post-green-path.mjs';
|
|
39
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
buildCoverageHonesty,
|
|
45
|
+
computeDoctorEnforcementHonesty,
|
|
46
|
+
} from './enforcement-honesty.mjs';
|
|
40
47
|
import {
|
|
41
48
|
computePureLayerOptInNudge,
|
|
42
49
|
loadGoldenPattern,
|
|
@@ -129,10 +136,12 @@ export function runCoverage(root, config, files, rules, asJson) {
|
|
|
129
136
|
if (files.length > 0 && governed.percent < 50) {
|
|
130
137
|
console.log('');
|
|
131
138
|
console.log(
|
|
132
|
-
`⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check
|
|
139
|
+
`⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check on ~${governed.percent}%`
|
|
140
|
+
);
|
|
141
|
+
console.log(
|
|
142
|
+
' is worse than no gate — it looks safe while most code is ungoverned. Classify the'
|
|
133
143
|
);
|
|
134
|
-
console.log('
|
|
135
|
-
console.log(' below to actually cover it.');
|
|
144
|
+
console.log(' directories below before treating green as enforcement.');
|
|
136
145
|
}
|
|
137
146
|
if (suggestions.length > 0) {
|
|
138
147
|
console.log('');
|
|
@@ -236,6 +245,11 @@ export function buildRemediationPlan(
|
|
|
236
245
|
patternBets,
|
|
237
246
|
designSmells,
|
|
238
247
|
});
|
|
248
|
+
const coverageHonesty = buildCoverageHonesty({
|
|
249
|
+
percent: governedPercent,
|
|
250
|
+
totalFiles,
|
|
251
|
+
emptyScope,
|
|
252
|
+
});
|
|
239
253
|
|
|
240
254
|
let statement =
|
|
241
255
|
activeViolations.length > 0
|
|
@@ -264,6 +278,7 @@ export function buildRemediationPlan(
|
|
|
264
278
|
? {
|
|
265
279
|
designWeakLabel:
|
|
266
280
|
'ENFORCE · design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
|
|
281
|
+
...DESIGN_WEAK_HONESTY_FLAGS,
|
|
267
282
|
}
|
|
268
283
|
: {}),
|
|
269
284
|
...(governedPercent != null ? { governedPercent } : {}),
|
|
@@ -282,6 +297,7 @@ export function buildRemediationPlan(
|
|
|
282
297
|
designSmells,
|
|
283
298
|
// Q04: one-pilot loop step (extraction card); never mechanical-safe.
|
|
284
299
|
pilotLoop,
|
|
300
|
+
coverageHonesty,
|
|
285
301
|
};
|
|
286
302
|
}
|
|
287
303
|
|
|
@@ -397,6 +413,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
397
413
|
}
|
|
398
414
|
const gatesMissing = missingGates(root);
|
|
399
415
|
const skillGaps = detectSkillGaps(root);
|
|
416
|
+
// Dual-truth: CLI version vs package.json pin (field residual after upgrade --no-install).
|
|
417
|
+
const packageVersionTruth = describePackageVersionDualTruth(root);
|
|
400
418
|
const staleRunners = staleRunnerGateFiles(root);
|
|
401
419
|
const adoption = collectAdoptionGaps(root, config, cov);
|
|
402
420
|
// Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
|
|
@@ -435,6 +453,18 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
435
453
|
designSmells,
|
|
436
454
|
});
|
|
437
455
|
const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth);
|
|
456
|
+
const { coverageHonesty, baselineHonesty, writePathHonesty } = computeDoctorEnforcementHonesty({
|
|
457
|
+
governedPercent: cov.governed.percent,
|
|
458
|
+
totalFiles: cov.governed.totalFiles,
|
|
459
|
+
emptyScope: cov.emptyScope === true || cov.governed.totalFiles === 0,
|
|
460
|
+
baselineExists: baseline.exists,
|
|
461
|
+
frozenKeys: baseline.exists ? baseline.keys.size : 0,
|
|
462
|
+
activeViolations: activeCount,
|
|
463
|
+
suppressed,
|
|
464
|
+
totalViolations: violations.length,
|
|
465
|
+
activeHost: writePath.activeHost,
|
|
466
|
+
hardWriteActive: writePath.capabilities?.['hard-write'] === true,
|
|
467
|
+
});
|
|
438
468
|
|
|
439
469
|
if (asJson) {
|
|
440
470
|
(options.writeJson ?? console.log)(
|
|
@@ -464,10 +494,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
464
494
|
// Q01: primary next action when Shape residual dominates (null if not design-weak).
|
|
465
495
|
postGreenPath,
|
|
466
496
|
...(postGreenPath
|
|
467
|
-
? {
|
|
468
|
-
primaryNextAction: postGreenPath.action,
|
|
469
|
-
healthyFinishedForbidden: true,
|
|
470
|
-
}
|
|
497
|
+
? { primaryNextAction: postGreenPath.action, ...DESIGN_WEAK_HONESTY_FLAGS }
|
|
471
498
|
: {}),
|
|
472
499
|
// Q03: advisory golden for new-code placement (absent = no claim).
|
|
473
500
|
goldenPattern,
|
|
@@ -475,10 +502,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
475
502
|
pureLayerOptIn,
|
|
476
503
|
// Q04: one-pilot loop (extraction card → re-doctor).
|
|
477
504
|
pilotLoop,
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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
|
+
// Dual-truth: managed CLI vs package.json pin (not a gate fail).
|
|
513
|
+
packageVersionTruth,
|
|
514
|
+
// Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
|
|
480
515
|
...doctorAdvisories,
|
|
481
516
|
governed: cov.governed,
|
|
517
|
+
coverageHonesty,
|
|
482
518
|
emptyLayers: cov.emptyLayers,
|
|
483
519
|
layersWithoutRules: cov.layersWithoutRules,
|
|
484
520
|
ungovernedDirs: cov.suggestions.length,
|
|
@@ -497,6 +533,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
497
533
|
frozen: baseline.exists ? baseline.keys.size : 0,
|
|
498
534
|
stale: analysisComplete ? staleBaseline : null,
|
|
499
535
|
policy: adoption.baseline,
|
|
536
|
+
honesty: baselineHonesty,
|
|
500
537
|
},
|
|
501
538
|
gatesMissing,
|
|
502
539
|
skillGaps,
|
|
@@ -517,6 +554,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
517
554
|
hookRepair: writePath.hookRepair,
|
|
518
555
|
mcpPresent: writePath.mcpPresent,
|
|
519
556
|
evidence: writePath.evidence,
|
|
557
|
+
honesty: writePathHonesty,
|
|
520
558
|
...(writePath.sessionNote ? { sessionNote: writePath.sessionNote } : {}),
|
|
521
559
|
...(writePath.gap
|
|
522
560
|
? {
|
|
@@ -688,6 +726,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
688
726
|
? ok
|
|
689
727
|
: warn;
|
|
690
728
|
line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
|
|
729
|
+
if (coverageHonesty.greenIsNotEnforcement) {
|
|
730
|
+
line(coverageHonesty.worseThanNoGate ? bad : warn, coverageHonesty.message);
|
|
731
|
+
if (coverageHonesty.worseThanNoGate) {
|
|
732
|
+
actions.push('raise governed coverage above a minority slice before treating green as enforcement');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
691
735
|
if (cov.suggestions.length > 0) {
|
|
692
736
|
line(warn, `${cov.suggestions.length} ungoverned director(y/ies) — proposals: ${arkCommand(root, 'ark-check', '--coverage')}`);
|
|
693
737
|
actions.push('classify the ungoverned directories (/ark-contract)');
|
|
@@ -696,6 +740,13 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
696
740
|
if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
|
|
697
741
|
if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
|
|
698
742
|
|
|
743
|
+
if (packageVersionTruth?.dualTruth) {
|
|
744
|
+
console.log('');
|
|
745
|
+
console.log(color.bold('Package pin (dual-truth)'));
|
|
746
|
+
line(warn, packageVersionTruth.note);
|
|
747
|
+
actions.push('bump package.json arkgate pin to match this CLI (or install without --no-install)');
|
|
748
|
+
}
|
|
749
|
+
|
|
699
750
|
if (showNewHere) {
|
|
700
751
|
console.log('');
|
|
701
752
|
console.log(color.bold('New here?'));
|
|
@@ -770,8 +821,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
770
821
|
line(' ', `Active host: ${writePath.activeHost}`);
|
|
771
822
|
line(' ', `Supported profile: ${writePath.supportSummary}`);
|
|
772
823
|
line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
|
|
773
|
-
|
|
774
|
-
if (honestyLine) line(warn, honestyLine);
|
|
824
|
+
if (writePathHonesty.message) line(warn, writePathHonesty.message);
|
|
775
825
|
if (writePath.sessionNote) {
|
|
776
826
|
line(warn, writePath.sessionNote);
|
|
777
827
|
}
|
|
@@ -850,7 +900,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
850
900
|
} else {
|
|
851
901
|
// Baseline keys are line-agnostic, so N keys can suppress ≥N violations — label as keys
|
|
852
902
|
// to avoid an apparent mismatch with the "frozen" violation count above.
|
|
853
|
-
|
|
903
|
+
const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
|
|
904
|
+
line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
|
|
905
|
+
if (analysisComplete && baselineHonesty.dirtyBaselineRisk) {
|
|
906
|
+
line(warn, baselineHonesty.message);
|
|
907
|
+
actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
|
|
908
|
+
}
|
|
854
909
|
if (analysisComplete && staleBaseline > 0) {
|
|
855
910
|
line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
|
|
856
911
|
actions.push('tighten the baseline (--update-baseline)');
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooling adapter: load Effective Contract from disk for a root config.
|
|
3
|
+
* Pure resolution lives in Domain (`resolveEffectiveContract`); this module owns I/O.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
emptyEffectiveArkRules,
|
|
9
|
+
buildEffectiveArkRules,
|
|
10
|
+
loadArkRulesContract,
|
|
11
|
+
} from './arkrules-contract.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {Record<string, unknown>} config loaded ark.config.json object
|
|
16
|
+
* @param {{ observeInput?: (abs: string, kind: string) => void }} [opts]
|
|
17
|
+
* @returns {{ arkRules: ReturnType<typeof emptyEffectiveArkRules>, warnings: Array<{path:string,message:string,severity:string}>, errors: Array<{path:string,message:string}> }}
|
|
18
|
+
*/
|
|
19
|
+
export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
20
|
+
const refs = config?.arkRules;
|
|
21
|
+
if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) {
|
|
22
|
+
return { arkRules: emptyEffectiveArkRules(), warnings: [], errors: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const layerNames = new Set(
|
|
26
|
+
Array.isArray(config.layers) ? config.layers.map((layer) => layer.name) : []
|
|
27
|
+
);
|
|
28
|
+
const errors = [];
|
|
29
|
+
const warnings = [];
|
|
30
|
+
const parts = [];
|
|
31
|
+
const referenced = new Set();
|
|
32
|
+
|
|
33
|
+
for (const layer of Object.keys(refs).sort()) {
|
|
34
|
+
const relRaw = refs[layer];
|
|
35
|
+
const pathKey = `$.arkRules[${JSON.stringify(layer)}]`;
|
|
36
|
+
if (typeof relRaw !== 'string' || relRaw.length === 0) {
|
|
37
|
+
errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (relRaw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(relRaw)) {
|
|
41
|
+
errors.push({
|
|
42
|
+
path: pathKey,
|
|
43
|
+
message: 'must be a project-relative path (absolute paths are not allowed)',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!layerNames.has(layer)) {
|
|
48
|
+
errors.push({
|
|
49
|
+
path: pathKey,
|
|
50
|
+
message: `layer ${JSON.stringify(layer)} is not declared in layers[]`,
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
56
|
+
referenced.add(rel);
|
|
57
|
+
const absolute = path.resolve(root, rel);
|
|
58
|
+
opts.observeInput?.(absolute, 'arkrules');
|
|
59
|
+
if (!fs.existsSync(absolute)) {
|
|
60
|
+
errors.push({
|
|
61
|
+
path: pathKey,
|
|
62
|
+
message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(absolute, 'utf8');
|
|
69
|
+
} catch (error) {
|
|
70
|
+
errors.push({
|
|
71
|
+
path: pathKey,
|
|
72
|
+
message: `referenced ArkRules file ${JSON.stringify(rel)} could not be read: ${
|
|
73
|
+
error instanceof Error ? error.message : String(error)
|
|
74
|
+
}`,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const loaded = loadArkRulesContract(JSON.parse(content), rel, layer);
|
|
80
|
+
parts.push({ layer, sourceFile: rel, file: loaded.config });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
errors.push({
|
|
83
|
+
path: pathKey,
|
|
84
|
+
message:
|
|
85
|
+
error instanceof Error
|
|
86
|
+
? error.message
|
|
87
|
+
: `referenced ArkRules file ${JSON.stringify(rel)} failed to load`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Drift: unreferenced files under arkrules/
|
|
93
|
+
const arkrulesDir = path.join(root, 'arkrules');
|
|
94
|
+
if (fs.existsSync(arkrulesDir) && fs.statSync(arkrulesDir).isDirectory()) {
|
|
95
|
+
for (const name of fs.readdirSync(arkrulesDir).sort()) {
|
|
96
|
+
if (!name.endsWith('.json')) continue;
|
|
97
|
+
const rel = `arkrules/${name}`;
|
|
98
|
+
if (!referenced.has(rel)) {
|
|
99
|
+
warnings.push({
|
|
100
|
+
path: rel,
|
|
101
|
+
message: `ArkRules file ${JSON.stringify(rel)} is not referenced by arkRules and will not be enforced`,
|
|
102
|
+
severity: 'advisory',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (errors.length > 0) {
|
|
109
|
+
return { arkRules: emptyEffectiveArkRules(), warnings, errors };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
arkRules: buildEffectiveArkRules(parts),
|
|
113
|
+
warnings,
|
|
114
|
+
errors: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -201,6 +201,110 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
|
|
|
201
201
|
return { changed, skipped };
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Read declared arkgate pin from consumer package.json (deps or devDeps).
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {string|null}
|
|
208
|
+
*/
|
|
209
|
+
export function readDeclaredArkgatePin(root) {
|
|
210
|
+
const pkgPath = path.join(root, 'package.json');
|
|
211
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
212
|
+
try {
|
|
213
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
214
|
+
const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
|
|
215
|
+
const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
|
|
216
|
+
if (typeof deps.arkgate === 'string') return deps.arkgate;
|
|
217
|
+
if (typeof dev.arkgate === 'string') return dev.arkgate;
|
|
218
|
+
return null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Dual-truth: CLI/package-shipped version vs consumer package.json pin.
|
|
226
|
+
* Used by doctor + upgrade so agents never confuse managed-asset CLI with CI pin.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} root
|
|
229
|
+
* @param {{ cliVersion?: string|null }} [opts]
|
|
230
|
+
* @returns {{
|
|
231
|
+
* dualTruth: boolean,
|
|
232
|
+
* cliVersion: string|null,
|
|
233
|
+
* declaredPin: string|null,
|
|
234
|
+
* code: 'PACKAGE_PIN_BEHIND_CLI' | 'PACKAGE_PIN_MATCHES' | 'PACKAGE_PIN_ABSENT' | 'CLI_VERSION_UNKNOWN',
|
|
235
|
+
* note: string
|
|
236
|
+
* }}
|
|
237
|
+
*/
|
|
238
|
+
export function describePackageVersionDualTruth(root, opts = {}) {
|
|
239
|
+
const cliVersion =
|
|
240
|
+
typeof opts.cliVersion === 'string' && opts.cliVersion
|
|
241
|
+
? opts.cliVersion
|
|
242
|
+
: arkPackageVersion();
|
|
243
|
+
const declaredPin = readDeclaredArkgatePin(root);
|
|
244
|
+
if (!cliVersion) {
|
|
245
|
+
return {
|
|
246
|
+
dualTruth: false,
|
|
247
|
+
cliVersion: null,
|
|
248
|
+
declaredPin,
|
|
249
|
+
code: 'CLI_VERSION_UNKNOWN',
|
|
250
|
+
note: 'Could not read shipped arkgate package version for this CLI.',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (!declaredPin) {
|
|
254
|
+
return {
|
|
255
|
+
dualTruth: false,
|
|
256
|
+
cliVersion,
|
|
257
|
+
declaredPin: null,
|
|
258
|
+
code: 'PACKAGE_PIN_ABSENT',
|
|
259
|
+
note: 'No arkgate pin in package.json; CI/npx may not resolve this CLI version.',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Normalize ^x.y.z / ~x.y.z / x.y.z for comparison of leading version token.
|
|
263
|
+
const pinCore = String(declaredPin).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0];
|
|
264
|
+
const matches =
|
|
265
|
+
pinCore === cliVersion ||
|
|
266
|
+
pinCore.startsWith(`${cliVersion}.`) ||
|
|
267
|
+
cliVersion.startsWith(pinCore.split('.').slice(0, 3).join('.'));
|
|
268
|
+
// Dual-truth when declared pin is clearly older major/minor than CLI, or different major.
|
|
269
|
+
const pinParts = pinCore.split('.').map((p) => Number.parseInt(p, 10));
|
|
270
|
+
const cliParts = cliVersion.split('.').map((p) => Number.parseInt(p, 10));
|
|
271
|
+
let behind = false;
|
|
272
|
+
if (
|
|
273
|
+
pinParts.length >= 1 &&
|
|
274
|
+
cliParts.length >= 1 &&
|
|
275
|
+
pinParts.every((n) => Number.isFinite(n)) &&
|
|
276
|
+
cliParts.every((n) => Number.isFinite(n))
|
|
277
|
+
) {
|
|
278
|
+
for (let i = 0; i < 3; i += 1) {
|
|
279
|
+
const p = pinParts[i] ?? 0;
|
|
280
|
+
const c = cliParts[i] ?? 0;
|
|
281
|
+
if (p < c) {
|
|
282
|
+
behind = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
if (p > c) break;
|
|
286
|
+
}
|
|
287
|
+
} else if (!matches) {
|
|
288
|
+
behind = true;
|
|
289
|
+
}
|
|
290
|
+
if (behind) {
|
|
291
|
+
return {
|
|
292
|
+
dualTruth: true,
|
|
293
|
+
cliVersion,
|
|
294
|
+
declaredPin,
|
|
295
|
+
code: 'PACKAGE_PIN_BEHIND_CLI',
|
|
296
|
+
note: `Managed CLI is arkgate@${cliVersion} but package.json pins ${declaredPin}. Bump the pin or re-run install so CI resolves the same version (common after upgrade --no-install).`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
dualTruth: false,
|
|
301
|
+
cliVersion,
|
|
302
|
+
declaredPin,
|
|
303
|
+
code: 'PACKAGE_PIN_MATCHES',
|
|
304
|
+
note: `package.json pin ${declaredPin} is aligned with CLI arkgate@${cliVersion}.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
204
308
|
/**
|
|
205
309
|
* Pin `arkgate` in package.json devDependencies (no package manager network call).
|
|
206
310
|
*
|