arkgate 3.0.5 → 3.2.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +58 -21
  3. package/bin/ark-check.mjs +46 -4
  4. package/bin/ark-mcp.mjs +267 -26
  5. package/bin/ark.mjs +47 -0
  6. package/bin/lib/adapter-contract.mjs +27 -1
  7. package/bin/lib/analysis-engine.mjs +7 -1169
  8. package/bin/lib/ci-and-commands.mjs +4 -0
  9. package/bin/lib/contract-smells.mjs +514 -0
  10. package/bin/lib/doctor-plan.mjs +15 -4
  11. package/bin/lib/host-support-matrix.mjs +6 -2
  12. package/bin/lib/policy-delta-io.mjs +161 -0
  13. package/bin/lib/prepare-change.mjs +186 -0
  14. package/bin/lib/remediation.mjs +24 -0
  15. package/bin/lib/violations.mjs +2 -2
  16. package/bin/lib/write-path-capabilities.mjs +67 -1
  17. package/bin/lib/write-path-detect.mjs +4 -3
  18. package/dist/eslint/index.cjs +3 -977
  19. package/dist/eslint/index.js +3 -931
  20. package/dist/index.cjs +6 -1960
  21. package/dist/index.d.cts +152 -5
  22. package/dist/index.d.ts +152 -5
  23. package/dist/index.js +6 -1908
  24. package/docs/agent-guide.md +39 -5
  25. package/docs/ai-gates.md +17 -15
  26. package/docs/configuration.md +44 -0
  27. package/docs/demos/01-write-gate-self-correction.md +2 -2
  28. package/docs/enthusiast/README.md +5 -1
  29. package/docs/enthusiast/how-to-agent-gates.md +3 -5
  30. package/docs/enthusiast/how-to-policy-pack.md +4 -1
  31. package/docs/enthusiast/reference-archetypes.md +8 -1
  32. package/docs/enthusiast/reference-commands.md +8 -2
  33. package/docs/package-surface.md +12 -2
  34. package/docs/threat-model.md +10 -6
  35. package/package.json +7 -6
  36. package/schemas/ark.analysis-result.schema.json +5 -1
  37. package/schemas/ark.change-map.schema.json +77 -0
  38. package/server.json +3 -3
  39. package/docs/ark-check-example.json +0 -87
  40. package/docs/demos/03-copilot-autopilot.md +0 -93
  41. package/docs/migrate-from-ark-runtime-kernel.md +0 -174
  42. package/docs/production-hardening.md +0 -100
@@ -417,6 +417,8 @@ jobs:
417
417
  steps:
418
418
  - name: Checkout
419
419
  uses: actions/checkout@v4
420
+ with:
421
+ fetch-depth: 0
420
422
  ${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
421
423
  uses: actions/setup-node@v4
422
424
  with:
@@ -425,6 +427,8 @@ ${nodeSetup}
425
427
  - name: Install dependencies
426
428
  run: ${pm.install}
427
429
  ${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
430
+ env:
431
+ ARK_POLICY_BASE_REF: \${{ github.event.pull_request.base.sha || github.event.before }}
428
432
  run: ${pm.run}
429
433
  `;
430
434
  }
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Deterministic contract smells (Phase W / W01) — meta-lint of ark.config.json itself.
3
+ *
4
+ * ArkGate validates code against the contract; these sensors validate the contract
5
+ * against known contract anti-patterns: rule shapes that permit future degradation
6
+ * even at 0 violations. Advisory only — they never change a pass/fail verdict, never
7
+ * feed designWeak/patternBets, and never block a gate. Complements `soft-contract`
8
+ * (missing rules) by detecting *permissive or unused* rules.
9
+ *
10
+ * Acknowledgments live in an optional sidecar (`.ark/contract-smell-acks.json`,
11
+ * Q03 golden-pattern precedent) so the versioned config contract is untouched.
12
+ * A malformed ack file (or a malformed edge inside it) never suppresses a smell.
13
+ *
14
+ * Known limit (documented, deliberate): layer roles are inferred from layer NAMES
15
+ * via substring heuristics — a name like "Auditorium" reads as audit-ish. The
16
+ * surface is advisory, so a miss costs a warning line, never a verdict.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ /** Stable contract-smell ids (doctor JSON `contractHealth` + skills). */
22
+ export const CONTRACT_SMELL_IDS = Object.freeze([
23
+ 'contract-bidirectional-allow',
24
+ 'contract-peripheral-depends-core',
25
+ 'contract-lateral-adapter-allow',
26
+ 'contract-dead-rule',
27
+ ]);
28
+
29
+ /** Plain-language outcome per id (Q02 pattern: outcome first, technical detail in message). */
30
+ export const CONTRACT_SMELL_OUTCOMES = Object.freeze({
31
+ 'contract-bidirectional-allow':
32
+ 'Two layers may depend on each other in both directions — nothing stops a dependency cycle from growing there. Keep one direction, or acknowledge the loop explicitly with the reason.',
33
+ 'contract-peripheral-depends-core':
34
+ 'An observability/audit-style layer is allowed to reach into orchestration or persistence — it can quietly become a second orchestrator. Keep periphery consuming events/ports, not core internals.',
35
+ 'contract-lateral-adapter-allow':
36
+ 'One adapter layer may import another adapter family directly — shared mappers/aliases will pile up in the wrong place. Move shared shapes into Domain (or a shared kernel) instead of adapter-to-adapter reach.',
37
+ 'contract-dead-rule':
38
+ 'A rule enforces nothing: it points at a layer that matches no files or does not exist, or both sides are the same layer. Fix the layer patterns or delete the rule.',
39
+ });
40
+
41
+ export const CONTRACT_SMELL_ACKS_PATH = '.ark/contract-smell-acks.json';
42
+
43
+ /** Hostile-input bounds (mirrors design-smells MAX_FILE_BYTES discipline). */
44
+ const MAX_ACK_BYTES = 64 * 1024;
45
+ const MAX_ACK_ENTRIES = 200;
46
+ const MAX_EVIDENCE = 12;
47
+ const MAX_MESSAGE_EDGES = 6;
48
+
49
+ const PERIPHERAL_LAYER_RE = /observab|audit|telemetry|monitor|logging|metric|tracing/i;
50
+ const CORE_TARGET_RE = /application|orchestr|persist|repositor/i;
51
+ const ADAPTER_LAYER_RE = /adapter|persist|integrat|infra|gateway/i;
52
+
53
+ /** Collision-safe internal key for a directed edge (layer names are arbitrary strings). */
54
+ function directedKey(from, to) {
55
+ return JSON.stringify([from, to]);
56
+ }
57
+
58
+ /** Display + ack pair key. Only meaningful when neither name embeds the delimiter. */
59
+ function pairLabel(a, b) {
60
+ return [a, b].sort().join('<->');
61
+ }
62
+
63
+ /** Names embedding the arrow delimiter cannot be matched safely from ack strings. */
64
+ function ackMatchable(...names) {
65
+ return names.every((n) => typeof n === 'string' && !n.includes('->'));
66
+ }
67
+
68
+ /**
69
+ * Load the optional acknowledgment sidecar. Bounded and fail-loud:
70
+ * non-file, oversized, unparsable, or wrong-shaped content → `invalid: true`
71
+ * with `acks: []` (a broken file never suppresses anything).
72
+ *
73
+ * @param {string} root
74
+ * @returns {{ path: string, exists: boolean, invalid?: boolean, error?: string, acks: Array<{id: string, edge: string, reason?: string}> }}
75
+ */
76
+ export function loadContractSmellAcks(root) {
77
+ const relPath = CONTRACT_SMELL_ACKS_PATH;
78
+ const abs = path.join(root, relPath);
79
+ let stats;
80
+ try {
81
+ stats = fs.statSync(abs);
82
+ } catch {
83
+ return { path: relPath, exists: false, acks: [] };
84
+ }
85
+ const invalid = (error) => ({ path: relPath, exists: true, invalid: true, error, acks: [] });
86
+ if (!stats.isFile()) return invalid('not a regular file');
87
+ if (stats.size > MAX_ACK_BYTES) return invalid(`larger than ${MAX_ACK_BYTES} bytes`);
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
91
+ } catch (error) {
92
+ return invalid(error instanceof Error ? error.message : 'unreadable JSON');
93
+ }
94
+ const acks = Array.isArray(parsed?.acks) ? parsed.acks : null;
95
+ if (!acks) return invalid('expected { acks: [{ id, edge, reason? }] }');
96
+ if (acks.length > MAX_ACK_ENTRIES) return invalid(`more than ${MAX_ACK_ENTRIES} entries`);
97
+ const wellFormed = acks.every(
98
+ (a) =>
99
+ a !== null &&
100
+ typeof a === 'object' &&
101
+ typeof a.id === 'string' &&
102
+ typeof a.edge === 'string' &&
103
+ a.edge.trim().length > 0
104
+ );
105
+ if (!wellFormed) return invalid('every ack needs string id and non-empty string edge');
106
+ return { path: relPath, exists: true, acks };
107
+ }
108
+
109
+ /**
110
+ * Normalize an ack edge string; returns null (never matches) for malformed grammar,
111
+ * e.g. `A<->B<->C` — a sloppy edge must not suppress a real smell.
112
+ */
113
+ function normalizeAckEdge(id, edge) {
114
+ const raw = String(edge).trim();
115
+ if (id === 'contract-bidirectional-allow') {
116
+ if (!raw.includes('<->')) return null;
117
+ const parts = raw.split('<->').map((s) => s.trim());
118
+ if (parts.length !== 2 || parts.some((p) => p.length === 0)) return null;
119
+ return pairLabel(parts[0], parts[1]);
120
+ }
121
+ return raw;
122
+ }
123
+
124
+ function isAcknowledged(ackState, id, canonicalEdge) {
125
+ if (!ackState || ackState.invalid || !Array.isArray(ackState.acks)) return false;
126
+ if (canonicalEdge == null) return false;
127
+ return ackState.acks.some((a) => a.id === id && normalizeAckEdge(id, a.edge) === canonicalEdge);
128
+ }
129
+
130
+ /**
131
+ * Core analysis: smells plus the count of ack entries that actually matched a
132
+ * detected edge (stale/typo acks match nothing and count nothing).
133
+ *
134
+ * @param {object} config ark.config (layers; rules unless overridden)
135
+ * @param {object|null} coverage computeCoverage result (layer file counts); optional
136
+ * @param {object} [ackState] result of loadContractSmellAcks
137
+ * @param {object[]|null} [effectiveRules] rules actually in force (e.g. manifest rules); defaults to config.rules
138
+ */
139
+ export function analyzeContractSmells(
140
+ config,
141
+ coverage = null,
142
+ ackState = { exists: false, acks: [] },
143
+ effectiveRules = null
144
+ ) {
145
+ const layers = Array.isArray(config?.layers) ? config.layers : [];
146
+ const rules = wellFormedRules(config, effectiveRules);
147
+ const layerByName = new Map();
148
+ for (const l of layers) {
149
+ if (l && typeof l.name === 'string') layerByName.set(l.name, l);
150
+ }
151
+ const filesPerLayer = new Map();
152
+ for (const row of coverage?.layers ?? []) {
153
+ if (row && typeof row.name === 'string') filesPerLayer.set(row.name, row.files ?? 0);
154
+ }
155
+
156
+ const explicitAllows = rules.filter((r) => r.allowed === true && r.from !== r.to);
157
+
158
+ /** Per-id findings: { edge (canonical, for acks; null = unmatchable), detail (display) }. */
159
+ const findings = {};
160
+ const add = (id, edge, detail) => {
161
+ (findings[id] ??= []).push({ edge, detail });
162
+ };
163
+
164
+ // 1) Explicitly bidirectional allowed edges (permits future cycles by declaration).
165
+ const allowKeys = new Set(explicitAllows.map((r) => directedKey(r.from, r.to)));
166
+ const seenPairs = new Set();
167
+ for (const r of explicitAllows) {
168
+ if (!allowKeys.has(directedKey(r.to, r.from))) continue;
169
+ const key = directedKey(...[r.from, r.to].sort());
170
+ if (seenPairs.has(key)) continue;
171
+ seenPairs.add(key);
172
+ const label = pairLabel(r.from, r.to);
173
+ add(
174
+ 'contract-bidirectional-allow',
175
+ ackMatchable(r.from, r.to) ? label : null,
176
+ `edge:${label}`
177
+ );
178
+ }
179
+
180
+ // 2) Peripheral layers explicitly allowed into orchestration/persistence cores.
181
+ for (const r of explicitAllows) {
182
+ if (PERIPHERAL_LAYER_RE.test(r.from) && CORE_TARGET_RE.test(r.to)) {
183
+ add(
184
+ 'contract-peripheral-depends-core',
185
+ ackMatchable(r.from, r.to) ? `${r.from}->${r.to}` : null,
186
+ `edge:${r.from}->${r.to}`
187
+ );
188
+ }
189
+ }
190
+
191
+ // 3) Lateral adapter-to-adapter explicit allows. Skip only edges the peripheral
192
+ // sensor already flagged (peripheral source AND core-ish target).
193
+ for (const r of explicitAllows) {
194
+ if (PERIPHERAL_LAYER_RE.test(r.from) && CORE_TARGET_RE.test(r.to)) continue;
195
+ if (ADAPTER_LAYER_RE.test(r.from) && ADAPTER_LAYER_RE.test(r.to)) {
196
+ add(
197
+ 'contract-lateral-adapter-allow',
198
+ ackMatchable(r.from, r.to) ? `${r.from}->${r.to}` : null,
199
+ `edge:${r.from}->${r.to}`
200
+ );
201
+ }
202
+ }
203
+
204
+ // 4) Dead rules: self edges (the gate ignores same-layer rules), unknown layers,
205
+ // or — when coverage is known — layers matching zero files (optional layers exempt).
206
+ for (const r of rules) {
207
+ const edge = `${r.from}->${r.to}`;
208
+ const ackEdge = ackMatchable(r.from, r.to) ? edge : null;
209
+ if (r.from === r.to) {
210
+ add('contract-dead-rule', ackEdge, `rule:${edge} (self edge has no effect)`);
211
+ continue;
212
+ }
213
+ for (const side of [r.from, r.to]) {
214
+ if (side.length === 0) continue;
215
+ const layer = layerByName.get(side);
216
+ if (!layer) {
217
+ add('contract-dead-rule', ackEdge, `rule:${edge} (unknown layer: ${side})`);
218
+ } else if (filesPerLayer.get(side) === 0 && layer.optional !== true) {
219
+ add('contract-dead-rule', ackEdge, `rule:${edge} (empty layer: ${side})`);
220
+ }
221
+ }
222
+ }
223
+
224
+ const smells = [];
225
+ let matchedAcks = 0;
226
+ for (const id of CONTRACT_SMELL_IDS) {
227
+ const entries = findings[id];
228
+ if (!entries || entries.length === 0) continue;
229
+ const kept = [];
230
+ let acknowledgedEdges = 0;
231
+ const seenDetail = new Set();
232
+ for (const entry of entries) {
233
+ if (seenDetail.has(entry.detail)) continue;
234
+ seenDetail.add(entry.detail);
235
+ if (isAcknowledged(ackState, id, entry.edge)) {
236
+ acknowledgedEdges += 1;
237
+ continue;
238
+ }
239
+ kept.push(entry);
240
+ }
241
+ matchedAcks += acknowledgedEdges;
242
+ // Fully acknowledged ids emit no smell; the summary reports applied acks.
243
+ if (kept.length === 0) continue;
244
+ // Deterministic output independent of rule declaration order.
245
+ kept.sort((a, b) => (a.detail < b.detail ? -1 : a.detail > b.detail ? 1 : 0));
246
+ const evidence = kept.slice(0, MAX_EVIDENCE).map((e) => e.detail);
247
+ if (kept.length > MAX_EVIDENCE) evidence.push(`…(+${kept.length - MAX_EVIDENCE} more)`);
248
+ smells.push({
249
+ id,
250
+ severity: 'warn',
251
+ message: messageFor(id, kept),
252
+ outcome: CONTRACT_SMELL_OUTCOMES[id],
253
+ evidence,
254
+ fix: fixFor(id),
255
+ acknowledgedEdges,
256
+ });
257
+ }
258
+ return { smells, matchedAcks };
259
+ }
260
+
261
+ /**
262
+ * Detect contract smells (compat wrapper over analyzeContractSmells).
263
+ * @returns {Array<{id: string, severity: 'warn', message: string, outcome: string, evidence: string[], fix: string, acknowledgedEdges: number}>}
264
+ */
265
+ export function detectContractSmells(
266
+ config,
267
+ coverage = null,
268
+ ackState = { exists: false, acks: [] },
269
+ effectiveRules = null
270
+ ) {
271
+ return analyzeContractSmells(config, coverage, ackState, effectiveRules).smells;
272
+ }
273
+
274
+ function messageFor(id, entries) {
275
+ const shown = entries.slice(0, MAX_MESSAGE_EDGES).map((e) => e.detail.replace(/^(edge|rule):/, ''));
276
+ const more = entries.length > shown.length ? `, …(+${entries.length - shown.length} more)` : '';
277
+ const list = `${shown.join(', ')}${more}`;
278
+ switch (id) {
279
+ case 'contract-bidirectional-allow':
280
+ return `Both directions are explicitly allowed between ${entries.length} pair(s): ${list}. No cycle exists yet, but the contract permits one by declaration.`;
281
+ case 'contract-peripheral-depends-core':
282
+ return `Peripheral (audit/observability) layers are explicitly allowed into core layers (${entries.length} edge(s)): ${list}. Observability stops being fully peripheral.`;
283
+ case 'contract-lateral-adapter-allow':
284
+ return `Adapter layers are explicitly allowed to import sibling adapter layers (${entries.length} edge(s)): ${list}. Shared mappers/aliases tend to accumulate on this edge.`;
285
+ case 'contract-dead-rule':
286
+ return `${entries.length} rule(s) enforce nothing: ${list}.`;
287
+ default:
288
+ return `Contract smell on: ${list}.`;
289
+ }
290
+ }
291
+
292
+ function fixFor(id) {
293
+ switch (id) {
294
+ case 'contract-bidirectional-allow':
295
+ return `Keep one direction (edit via /ark-contract), or record the deliberate loop in ${CONTRACT_SMELL_ACKS_PATH} with a reason.`;
296
+ case 'contract-peripheral-depends-core':
297
+ return `Invert the edge: core emits events/ports the peripheral layer consumes (/ark-contract), or acknowledge with a reason in ${CONTRACT_SMELL_ACKS_PATH}.`;
298
+ case 'contract-lateral-adapter-allow':
299
+ return `Move shared shapes into Domain/shared kernel and drop the lateral allow (/ark-contract), or acknowledge with a reason in ${CONTRACT_SMELL_ACKS_PATH}.`;
300
+ case 'contract-dead-rule':
301
+ return 'Fix the layer patterns so the layer matches real files, or delete the stale/self rule via /ark-contract.';
302
+ default:
303
+ return 'Review the contract edge via /ark-contract; never weaken the gate to silence a smell.';
304
+ }
305
+ }
306
+
307
+ /**
308
+ * W02 — fixed comparative wording per band. Facts + a note; explicitly never a
309
+ * score, ranking, or gate input. Heavy wording must never suggest deleting layers.
310
+ */
311
+ export const GOVERNANCE_WEIGHT_NOTES = Object.freeze({
312
+ heavy:
313
+ 'Heavier than typical for the governed tree size. Not a defect and not a score — but before adding another layer or rule, ask for demonstrated pressure (repeated violations or acknowledgments on one edge). Do not delete working layers to change this number.',
314
+ light:
315
+ 'Lighter than typical for the governed tree size — a large tree with few boundaries. Consider whether a new boundary is justified where violations or churn concentrate.',
316
+ typical: 'Within the typical band for the governed tree size.',
317
+ unknown: 'Not enough governed files (or declared layers) to describe governance weight.',
318
+ });
319
+
320
+ /**
321
+ * Fixed banding thresholds (stated in docs/package-surface.md; deterministic, not tunables).
322
+ * heavy: fewer than 25 governed files per layer AND (6+ layers OR 4+ rules per layer) —
323
+ * both signals are size-relative, so a large tree with a dense but proportionate rule
324
+ * matrix never reads heavy. light: at most 2 layers over 150+ governed files.
325
+ */
326
+ const HEAVY_FILES_PER_LAYER_BELOW = 25;
327
+ const HEAVY_MIN_LAYERS = 6;
328
+ const HEAVY_RULES_PER_LAYER = 4;
329
+ const LIGHT_MAX_LAYERS = 2;
330
+ const LIGHT_MIN_FILES = 150;
331
+
332
+ /** Rules in force, filtered to well-formed entries (string from/to, boolean allowed). */
333
+ function wellFormedRules(config, effectiveRules) {
334
+ const rules = (Array.isArray(effectiveRules) ? effectiveRules : config?.rules) ?? [];
335
+ return rules.filter(
336
+ (r) =>
337
+ r !== null &&
338
+ typeof r === 'object' &&
339
+ typeof r.from === 'string' &&
340
+ typeof r.to === 'string' &&
341
+ typeof r.allowed === 'boolean'
342
+ );
343
+ }
344
+
345
+ /**
346
+ * W02 — descriptive governance-weight facts for a contract over a governed tree.
347
+ * Raw counts and ratios with a fixed comparative note. Advisory only.
348
+ *
349
+ * @param {object} config ark.config (layers; rules unless overridden)
350
+ * @param {object|null} coverage computeCoverage result
351
+ * @param {object[]|null} [effectiveRules]
352
+ */
353
+ export function computeGovernanceWeight(config, coverage = null, effectiveRules = null) {
354
+ const layers = Array.isArray(config?.layers) ? config.layers : [];
355
+ const rules = wellFormedRules(config, effectiveRules);
356
+ const declaredLayers = layers.filter((l) => l && typeof l.name === 'string').length;
357
+ const governedFiles = coverage?.governed?.classifiedFiles ?? 0;
358
+ const populatedLayers = (coverage?.layers ?? []).filter((r) => r && (r.files ?? 0) > 0).length;
359
+ const deniedEdges = rules.filter((r) => r.allowed === false).length;
360
+ const allowedEdges = rules.filter((r) => r.allowed === true).length;
361
+ const round1 = (n) => Math.round(n * 10) / 10;
362
+ const base = {
363
+ declaredLayers,
364
+ populatedLayers,
365
+ governedFiles,
366
+ rules: rules.length,
367
+ deniedEdges,
368
+ allowedEdges,
369
+ notAScore: true,
370
+ };
371
+ if (declaredLayers === 0 || !(Number.isFinite(governedFiles) && governedFiles > 0)) {
372
+ return {
373
+ ...base,
374
+ governedFiles: Number.isFinite(governedFiles) ? governedFiles : 0,
375
+ filesPerLayer: null,
376
+ rulesPerLayer: null,
377
+ weight: 'unknown',
378
+ note: GOVERNANCE_WEIGHT_NOTES.unknown,
379
+ };
380
+ }
381
+ // Band on the raw ratios; the rounded values are for display only.
382
+ const rawFilesPerLayer = governedFiles / declaredLayers;
383
+ const rawRulesPerLayer = rules.length / declaredLayers;
384
+ let weight = 'typical';
385
+ if (
386
+ rawFilesPerLayer < HEAVY_FILES_PER_LAYER_BELOW &&
387
+ (declaredLayers >= HEAVY_MIN_LAYERS || rawRulesPerLayer >= HEAVY_RULES_PER_LAYER)
388
+ ) {
389
+ weight = 'heavy';
390
+ } else if (declaredLayers <= LIGHT_MAX_LAYERS && governedFiles >= LIGHT_MIN_FILES) {
391
+ weight = 'light';
392
+ }
393
+ return {
394
+ ...base,
395
+ filesPerLayer: round1(rawFilesPerLayer),
396
+ rulesPerLayer: round1(rawRulesPerLayer),
397
+ weight,
398
+ note: GOVERNANCE_WEIGHT_NOTES[weight],
399
+ };
400
+ }
401
+
402
+ /**
403
+ * One-call compute for doctor: acks + smells + governance weight + JSON-ready summary.
404
+ * `rules` should be the rules actually in force (manifest-aware callers pass them).
405
+ *
406
+ * @param {string} root
407
+ * @param {object} config
408
+ * @param {object|null} coverage
409
+ * @param {object[]|null} [rules]
410
+ */
411
+ export function computeContractHealth(root, config, coverage, rules = null) {
412
+ const ackState = loadContractSmellAcks(root);
413
+ const { smells, matchedAcks } = analyzeContractSmells(config, coverage, ackState, rules);
414
+ return {
415
+ ...summarizeContractHealth(smells, ackState, matchedAcks),
416
+ governanceWeight: computeGovernanceWeight(config, coverage, rules),
417
+ smells,
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Print the human doctor section (advisory). No output when there is nothing to say.
423
+ * @param {ReturnType<typeof computeContractHealth>} health
424
+ * @param {{ line: (mark: string, text: string) => void, warn: string, color: { bold: (s: string) => string, dim: (s: string) => string } }} io
425
+ */
426
+ export function printContractHealthSection(health, io) {
427
+ const rows = formatContractHealthLines(health?.smells ?? [], health);
428
+ if (rows.length === 0) return;
429
+ console.log('');
430
+ console.log(io.color.bold('Contract health (advisory)'));
431
+ for (const row of rows) {
432
+ io.line(row.mark === 'warn' ? io.warn : ' ', row.mark === 'dim' ? io.color.dim(row.text) : row.text);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Human doctor lines for the contract-health section (advisory).
438
+ * Returns `{ mark, text }` rows with mark `'warn' | 'dim'`; doctor prints them.
439
+ * Empty array when there is nothing to say: no smells, a valid/absent ack file,
440
+ * and a non-noteworthy governance weight (only `heavy`/`light` print).
441
+ *
442
+ * @param {ReturnType<typeof detectContractSmells>} smells
443
+ * @param {ReturnType<typeof computeContractHealth>} health also read: `health.governanceWeight`
444
+ */
445
+ export function formatContractHealthLines(smells, health) {
446
+ const rows = [];
447
+ const list = smells ?? [];
448
+ const gw = health?.governanceWeight;
449
+ const weightNoteworthy = gw?.weight === 'heavy' || gw?.weight === 'light';
450
+ if (list.length === 0 && !health?.ackFile?.invalid && !weightNoteworthy) return rows;
451
+ if (health?.ackFile?.invalid) {
452
+ rows.push({
453
+ mark: 'warn',
454
+ text: `${health.ackFile.path} is present but invalid — acknowledgments are ignored, not silently applied.`,
455
+ });
456
+ }
457
+ for (const smell of list.slice(0, 5)) {
458
+ rows.push({ mark: 'warn', text: `[${smell.id}] ${smell.outcome}` });
459
+ rows.push({ mark: 'dim', text: `detail: ${smell.message}` });
460
+ if (smell.evidence?.length) {
461
+ const shown = smell.evidence.slice(0, 4);
462
+ const more = smell.evidence.length > 4 ? ` …(+${smell.evidence.length - 4} more)` : '';
463
+ rows.push({ mark: 'dim', text: `evidence: ${shown.join(', ')}${more}` });
464
+ }
465
+ rows.push({ mark: 'dim', text: `fix: ${smell.fix}` });
466
+ }
467
+ if (list.length > 5) {
468
+ rows.push({ mark: 'dim', text: `…(+${list.length - 5} more contract smell(s) in doctor JSON)` });
469
+ }
470
+ if ((health?.acknowledged ?? 0) > 0) {
471
+ rows.push({ mark: 'dim', text: `acknowledged edges applied: ${health.acknowledged}` });
472
+ }
473
+ if (weightNoteworthy) {
474
+ rows.push({
475
+ mark: 'warn',
476
+ text: `governance weight: ${gw.weight} — ${gw.declaredLayers} layer(s), ${gw.rules} rule(s), ${gw.governedFiles} governed file(s) (${gw.filesPerLayer} files/layer)`,
477
+ });
478
+ rows.push({ mark: 'dim', text: gw.note });
479
+ }
480
+ rows.push({
481
+ mark: 'dim',
482
+ text: 'advisory only — the gate verdict and design fitness are unchanged',
483
+ });
484
+ return rows;
485
+ }
486
+
487
+ /**
488
+ * Contract-health summary for doctor JSON / human output. Advisory only.
489
+ * `acknowledged` counts ack entries that MATCHED a detected edge (stale acks count 0).
490
+ *
491
+ * @param {ReturnType<typeof detectContractSmells>} smells
492
+ * @param {ReturnType<typeof loadContractSmellAcks>} ackState
493
+ * @param {number} [matchedAcks]
494
+ */
495
+ export function summarizeContractHealth(smells, ackState = { exists: false, acks: [] }, matchedAcks = 0) {
496
+ const list = Array.isArray(smells) ? smells : [];
497
+ return {
498
+ status: list.length > 0 ? 'contract-smells' : 'ok',
499
+ smellCount: list.length,
500
+ ids: list.map((s) => s.id),
501
+ acknowledged: ackState?.invalid ? 0 : matchedAcks,
502
+ advisory: true,
503
+ label:
504
+ list.length > 0
505
+ ? `Contract health: ${list.length} contract smell(s) — advisory; the gate verdict is unchanged`
506
+ : 'Contract health: no contract smells detected',
507
+ ackFile: {
508
+ path: ackState?.path ?? CONTRACT_SMELL_ACKS_PATH,
509
+ present: ackState?.exists === true,
510
+ invalid: ackState?.invalid === true,
511
+ ...(ackState?.invalid ? { error: ackState.error ?? 'invalid' } : {}),
512
+ },
513
+ };
514
+ }
@@ -40,6 +40,7 @@ import {
40
40
  } from './post-green-path.mjs';
41
41
  import { loadGoldenPattern, summarizeGoldenPattern } from './golden-pattern.mjs';
42
42
  import { summarizePilotLoop } from './pilot-loop.mjs';
43
+ import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
43
44
 
44
45
  const color = {
45
46
  green: (s) => `\x1b[32m${s}\x1b[0m`,
@@ -424,6 +425,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
424
425
  patternBets: patternBetsForLoop,
425
426
  designSmells,
426
427
  });
428
+ // W01 — contract meta-lint over the rules in force. Advisory; never feeds any verdict.
429
+ const contractHealth = computeContractHealth(root, config, cov, rules);
427
430
 
428
431
  if (asJson) {
429
432
  console.log(
@@ -460,6 +463,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
460
463
  goldenPattern,
461
464
  // Q04: one-pilot loop (extraction card → re-doctor).
462
465
  pilotLoop,
466
+ // W01: contract-health meta-lint (advisory; verdict unchanged).
467
+ contractHealth,
463
468
  governed: cov.governed,
464
469
  emptyLayers: cov.emptyLayers,
465
470
  layersWithoutRules: cov.layersWithoutRules,
@@ -491,6 +496,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
491
496
  capabilities: writePath.capabilities,
492
497
  capabilityEvidence: writePath.capabilityEvidence,
493
498
  inventory: writePath.inventory,
499
+ enforcementLadder: writePath.enforcementLadder,
494
500
  mode: writePath.mode,
495
501
  prepareWrite: writePath.prepareWrite,
496
502
  autoPatch: writePath.autoPatch,
@@ -640,6 +646,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
640
646
  );
641
647
  }
642
648
 
649
+ // W01 — contract health (advisory; verdict unchanged).
650
+ printContractHealthSection(contractHealth, { line, warn, color });
651
+
643
652
  console.log('');
644
653
  console.log(color.bold('Coverage'));
645
654
  const govMark =
@@ -738,17 +747,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
738
747
  line(' ', `Active host: ${writePath.activeHost}`);
739
748
  line(' ', `Supported profile: ${writePath.supportSummary}`);
740
749
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
750
+ const ladder = writePath.enforcementLadder;
751
+ const state = (value) => value === true ? 'yes' : value === false ? 'no' : String(value);
741
752
  line(
742
- capabilities['hard-write'] ? ok : warn,
743
- `Hard write boundary: ${capabilities['hard-write'] ? 'yes' : 'no'}`
753
+ ladder.localWrite.installed ? ok : warn,
754
+ `Hard hook — supported: ${state(ladder.localWrite.supported)} · installed: ${state(ladder.localWrite.installed)} · active/trusted: ${state(ladder.localWrite.active)} · bypassable: ${state(ladder.localWrite.bypassable)}`
744
755
  );
745
756
  line(
746
757
  warn,
747
- `Advisory write tools (MCP): ${capabilities['advisory-write'] ? 'yes' : 'no'}`
758
+ `Advisory MCP supported: ${state(ladder.advisoryMcp.supported)} · installed: ${state(ladder.advisoryMcp.installed)} · active: ${state(ladder.advisoryMcp.active)} · bypassable: ${state(ladder.advisoryMcp.bypassable)}`
748
759
  );
749
760
  line(
750
761
  capabilities['merge-gate'] ? ok : bad,
751
- `CI check (--strict-merge): ${capabilities['merge-gate'] ? 'yes' : 'no'} (merge blocking requires a required status)`
762
+ `Merge gate — supported: ${state(ladder.ciMerge.supported)} · installed: ${state(ladder.ciMerge.installed)} · active: ${state(ladder.ciMerge.active)} · bypassable: ${state(ladder.ciMerge.bypassable)} · required status: ${state(ladder.ciMerge.requiredStatus)}`
752
763
  );
753
764
  line(
754
765
  capabilities['repair-payload'] ? ok : warn,
@@ -6,11 +6,12 @@
6
6
  * reported separately by write-path-capabilities.mjs.
7
7
  */
8
8
 
9
- function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
9
+ function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, repairPayload) {
10
10
  return Object.freeze({
11
11
  label,
12
12
  hookPath,
13
13
  hookSurface,
14
+ hookOperations: Object.freeze(hookOperations),
14
15
  capabilities: Object.freeze({
15
16
  'hard-write': hardWrite,
16
17
  'advisory-write': true,
@@ -25,6 +26,7 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
25
26
  'Claude Code',
26
27
  '.claude/settings.json',
27
28
  'PreToolUse `Write` / `Edit` / `MultiEdit`',
29
+ ['Write', 'Edit', 'MultiEdit'],
28
30
  true,
29
31
  true
30
32
  ),
@@ -32,14 +34,16 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
32
34
  'Grok Build',
33
35
  '.grok/hooks/ark-write-gate.json',
34
36
  'PreToolUse `write` / `search_replace` (plus aliases)',
37
+ ['write', 'search_replace'],
35
38
  true,
36
39
  true
37
40
  ),
38
- cursor: hostProfile('Cursor', null, null, false, false),
41
+ cursor: hostProfile('Cursor', null, null, [], false, false),
39
42
  codex: hostProfile(
40
43
  'OpenAI Codex',
41
44
  '.codex/hooks.json',
42
45
  'Best-effort PreToolUse `apply_patch`; Code Mode hosts may bypass the event',
46
+ ['apply_patch'],
43
47
  false,
44
48
  false
45
49
  ),