arkgate 3.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.2.0 — 2026-07-15
8
+
9
+ Contract health (Phase W): ArkGate now also meta-lints the contract itself and describes its
10
+ governance weight, and the docs name the enforcement-boundary trade-off explicitly. Everything in
11
+ this release is **advisory only** — no verdict, `designFitness`, `patternBets`, or gate result
12
+ changes. **No breaking** CLI or `ark.config.json` changes. **No gate weaken.**
13
+
14
+ ### Added
15
+
16
+ - **W01 contract smells:** `ark-check --doctor --json` gains `doctor.contractHealth` with four
17
+ stable, deterministic smell ids that lint the contract rather than the code:
18
+ `contract-bidirectional-allow` (both directions explicitly allowed between two layers),
19
+ `contract-peripheral-depends-core` (audit/observability layer allowed into
20
+ orchestration/persistence), `contract-lateral-adapter-allow` (adapter layer allowed into a
21
+ sibling adapter layer), and `contract-dead-rule` (rule referencing an unknown or empty layer, or
22
+ a same-layer no-op; `optional: true` layers are exempt). Each smell carries `severity`,
23
+ sorted `evidence[]` with honest `…(+N more)` truncation, technical `message`, plain-language
24
+ `outcome`, and `fix`. Human doctor prints a "Contract health (advisory)" section.
25
+ - **Acknowledgment sidecar:** deliberate edges are recorded in an optional
26
+ `.ark/contract-smell-acks.json` (`{ acks: [{ id, edge, reason }] }`; bidirectional edges
27
+ order-insensitive) — the versioned `ark.config.json` schema is untouched. The file is bounded
28
+ (≤64 KB, ≤200 entries); a malformed file or edge grammar is reported via `ackFile.invalid` and
29
+ never suppresses a smell. `contractHealth.acknowledged` counts applied acks only.
30
+ - **W02 governance weight:** `doctor.contractHealth.governanceWeight` reports raw facts
31
+ (declared/populated layers, governed files, rules, denied/allowed edges, files-per-layer,
32
+ rules-per-layer) plus a fixed comparative band — `heavy` (fewer than 25 governed files per layer
33
+ AND 6+ layers or 4+ rules per layer), `light` (≤2 layers over 150+ governed files), `typical`,
34
+ or `unknown` — with fixed wording and an explicit `notAScore: true`. Banding uses raw ratios;
35
+ reported ratios are rounded for display. The heavy note asks to justify NEW layers with
36
+ demonstrated pressure and never suggests deleting working ones.
37
+ - **W03 enforcement-boundary positioning:** README explains why the hard guarantee lives at the
38
+ required merge status ("deliberate trade-off, not a gap"; the contract doubles as a pressure
39
+ sensor), and `docs/ai-gates.md` / `docs/agent-guide.md` carry the same framing next to the
40
+ canonical host support matrix. A docs regression pins the wording without strengthening any
41
+ guarantee claim.
42
+
43
+ ### Fixed
44
+
45
+ - Contract meta-lint reads the rules actually in force (manifest-aware), not only `config.rules`.
46
+ - Hostile ack-file inputs (FIFO/symlink targets, oversized files, sloppy edge strings) can no
47
+ longer hang `--doctor`, exhaust memory, or silently suppress findings; null rule entries and
48
+ malformed coverage rows no longer throw.
49
+ - Governance-weight banding is size-relative in both clauses: a large tree with a proportionate
50
+ dense rule matrix never reads `heavy`, and NaN/negative counts read `unknown`.
51
+
7
52
  ## 3.1.0 — 2026-07-15
8
53
 
9
54
  Deterministic change integrity. **No breaking** CLI or `ark.config.json` changes. **No gate
package/README.md CHANGED
@@ -16,8 +16,9 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 3.0** passed its independent beta-exit audit. The npm badge reports the version
20
- > currently available in the registry.
19
+ > **ArkGate 3.2.0** is current stable: contract health (advisory meta-lint of the contract +
20
+ > governance weight), on top of 3.1's policy-transition checks, atomic multi-file preflight, and
21
+ > optional structural convergence. [Release notes](docs/releases/3.2.0.md).
21
22
 
22
23
  ---
23
24
 
@@ -156,7 +157,7 @@ Aliases `ark` / `ark-check` / `ark-mcp` still work. **npm / pnpm / yarn**. No in
156
157
  <details>
157
158
  <summary>What <code>/ark-autopilot</code> does under the hood (optional detail)</summary>
158
159
 
159
- 1. Setup if needed (`ark start` — compact contract + active-host router + CI gate; create an HTML/origin report explicitly when you need one).
160
+ 1. Setup if needed (`ark start` previews; review, then `ark start --apply`).
160
161
  2. **Explore pass** (decision-grade map of *this* product; field path when demos exist).
161
162
  3. **Dual plan:** **A** remediation from `--plan` (mechanical-safe only by default); **B** pattern/evolution bets (never auto-applied as mechanical-safe). Empty plan ≠ “healthy” without explore/B.
162
163
  4. Apply A → re-check; judgment only when you ask for full apply.
@@ -243,6 +244,22 @@ Brownfield phases: **Align** (honest contract) → **Stabilize** (real baseline)
243
244
  This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair payloads never write code silently: the host must re-inject the candidate and ArkGate revalidates it. Run `arkgate-check --doctor` for the evidence actually detected in the current repository.
244
245
  <!-- arkgate-host-support:end -->
245
246
 
247
+ #### Why the hard guarantee lives at the merge gate
248
+
249
+ The split above is a deliberate trade-off, not a gap. ArkGate validates at the earliest boundary
250
+ each host offers and enforces at the earliest boundary a repository can make non-bypassable: the
251
+ required merge status. Hard hooks (Claude Code, Grok Build) deny the listed write operations at
252
+ write time; advisory surfaces (MCP, rules) coach the agent while it works. But any local boundary
253
+ can be routed around — another tool, a direct filesystem write, a human edit — so the only
254
+ guarantee ArkGate claims for every path is the `arkgate-check --strict-merge` check, and only when
255
+ the repository makes that status required. Local checks optimize feedback speed; the merge gate
256
+ owns correctness.
257
+
258
+ A useful consequence: the contract doubles as a pressure sensor. Recurring violations or baseline
259
+ exceptions concentrated on one layer edge are evidence that the current design stopped fitting the
260
+ code — a reason to reshape the contract deliberately (start with `/ark-explore`), never to weaken
261
+ the gate.
262
+
246
263
  Detailed setup: [docs/ai-gates.md](docs/ai-gates.md).
247
264
 
248
265
  ---
@@ -264,7 +281,9 @@ ark.config.json
264
281
  finding ids. See the [configuration and editor guide](docs/configuration.md).
265
282
  - **Frameworks:** Nest / Next / express / library layouts get sensible globs on init so day-one coverage is real.
266
283
  - **Brownfield:** baseline ratchet, refuse to freeze a wrong contract, `/ark-adopt` for mature trees.
267
- - **Agents:** `ark start` asks for (or detects) one active host and writes one compact router, not copied skill packs, in at most five project files and 25 KB. Use `ark-check --install-agent-gates --skills-only --tools <host>` later when you explicitly want the full `/ark-*` skill set. Reports are opt-in with `ark-check --report`.
284
+ - **Agents:** `ark start` previews one compact active-host router (≤5 files / 25 KB); `--apply`
285
+ writes those exact bytes. Full skills remain explicit via `--install-agent-gates --skills-only`;
286
+ reports remain opt-in via `ark-check --report`.
268
287
  - **Write protocol (2.10 / Track W):** mechanical-safe **autoPatch** on the write gate (`import type`); MCP **`ark_prepare_write`** (place + validate + patch + judgmentBrief); opt-in hook **`--hook-repair`** (`ARK_REPAIR_JSON`); doctor **`writePath`** (repair vs reject-only); loop-cost eval (`npm run eval:loop-cost`). Port-proof inject is **judgment** (arity change), not silent auto-apply.
269
288
  - **Enforcement ladder (Phase T):** doctor JSON exposes `writePath.enforcementLadder` with separate
270
289
  `supported`, `installed`, `active`, `bypassable`, evidence, operation coverage, and required-status
@@ -274,11 +293,8 @@ ark.config.json
274
293
  casts, InMemory runtime defaults, and disabled peer isolation. `--strict` is a compatibility
275
294
  alias. Neither requires an editor hook; use `--require-write-hook claude|grok` when that local
276
295
  guarantee is part of the check.
277
- - **Trust / coverage (3.0 release baseline):** package unit-test floors on the broad product surface
278
- (statements/lines **≥80%**, branches/functions **≥85%**; enforcement-critical modules **≥95%**
279
- branch). The V05 beta-exit audit passed with 12 pinned public adoptions, 97% median governed
280
- coverage, and zero P0/P1 findings. Stable publication remains the signed-tag, GitHub Release,
281
- and provenance-backed npm workflow documented in [the 3.0.0 release notes](docs/releases/3.0.0.md).
296
+ - **Release evidence:** independent 3.0 audit baseline plus signed-tag, GitHub Release, and
297
+ provenance-backed npm publication; see the [3.1.0 release notes](docs/releases/3.1.0.md).
282
298
  - **TypeScript:** project compilers 5.x / 6.x / 7.x — gate falls back to a nested JS-API TypeScript when TS 7’s main export is version-only ([docs/typescript-support.md](docs/typescript-support.md)).
283
299
 
284
300
  ### Why not only ESLint / dependency-cruiser / Nx?
@@ -357,16 +373,17 @@ for real systems. Details: [production-hardening.md](https://github.com/pedrokni
357
373
  | Audience | Link |
358
374
  |----------|------|
359
375
  | New builders (plain language) | [docs/enthusiast/](docs/enthusiast/README.md) |
360
- | **Package surface (stable vs experimental)** | [docs/package-surface.md](docs/package-surface.md) |
361
- | Wire Claude / Cursor / Codex / Grok + **ESLint (CI-parity)** | [docs/ai-gates.md](docs/ai-gates.md) |
376
+ | **Package surface and configuration** | [package policy](docs/package-surface.md) · [contract](docs/configuration.md) |
377
+ | Wire agents + **ESLint (CI-parity)** | [docs/ai-gates.md](docs/ai-gates.md) · [threat model](docs/threat-model.md) |
362
378
  | **TypeScript 5 / 6 / 7 support** | [docs/typescript-support.md](docs/typescript-support.md) |
363
379
  | Migrate from `ark-runtime-kernel` | [docs/migrate-from-ark-runtime-kernel.md](https://github.com/pedroknigge/arkgate/blob/main/docs/migrate-from-ark-runtime-kernel.md) |
364
380
  | Messy existing repo | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
365
381
  | Agent / MCP tools | [docs/agent-guide.md](docs/agent-guide.md) |
382
+ | Security reporting | [SECURITY.md](SECURITY.md) |
366
383
  | Demos | [docs/demos/](docs/demos/) |
367
384
  | Examples | [examples/](examples/README.md) |
368
385
  | Latest release (3.1.0) | [release notes](docs/releases/3.1.0.md) · [3.0.0 baseline](docs/releases/3.0.0.md) |
369
- | Roadmap | [ROADMAP.md](ROADMAP.md) · [Changelog](CHANGELOG.md) |
386
+ | Roadmap and decisions | [ROADMAP.md](ROADMAP.md) · [ADRs](docs/adr/) · [Changelog](CHANGELOG.md) |
370
387
 
371
388
  ---
372
389
 
@@ -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
+ }