arkgate 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ in the immutable pre-2.0 archive linked below.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 4.0.1 — 2026-07-24
9
+
10
+ **Patch** over 4.0.0. Field footgun fix for stale global CLI on upgrade, ArkRules HTML/doctor
11
+ catalog honesty, dual-plane product wording, and internal coverage attribution.
12
+ **No required config migration.** Does not weaken write gate or CI. Does not close Z09 / RB-11.
13
+
14
+ ### Improved
15
+
16
+ - **HTML / doctor ArkRules showcase:** `rulesUnderContract` is no longer counts-only.
17
+ When `arkRules` is active, the report lists **per-layer** structure/invariant totals,
18
+ **structure sensors** (id · sensor · mode · description), **uncovered** invariants,
19
+ and a **covered sample** — so `/ark-explain` has real [ArkRules] content to walk.
20
+ Skill text updated to open `arkrules/*` and name real IDs.
21
+ - **HTML advisory cards:** doctor advisory blocks (contract health, ambient, cohesion,
22
+ parse health, graph blind spots, ArkRules) render with `class="section card"` so they
23
+ match the white panel styling of Layers / Violations / Enforcement (were bare
24
+ `<section data-advisory>` on the page background).
25
+ - **Dual-plane honesty in product surfaces:** configuration / use / product-voice /
26
+ `/ark-explain` and the ArkRules HTML strip state that structure sensors are heuristics,
27
+ invariants are catalog + coverage evidence (not a business runtime), and the two planes
28
+ never merge into one architecture score.
29
+ - **Coverage floors (internal):** branch threshold 83.0 → **82.5** after Fase 0 surface
30
+ growth (measured ~83% clean candidate); unit tests for upgrade refuse/HTML catalog use
31
+ ESM imports so V8 attributes hits (createRequire was under-counting).
32
+
33
+ ### Fixed
34
+
35
+ - **Stale global CLI vs project `arkgate` (upgrade footgun):** `ark upgrade` /
36
+ `ark update` now **fail closed** when the running CLI package root is outside
37
+ the project's `node_modules/arkgate` **and** the running version is older than
38
+ the installed project package. Recovery prefers the package-manager runner
39
+ (`npx` / `pnpm exec` / `yarn` + `arkgate`) so hoisted monorepos without a
40
+ nested shallow path still work; shallow
41
+ `node node_modules/arkgate/bin/ark.mjs` is install-root secondary advice.
42
+ With `--json`, refuse also emits a machine-readable
43
+ `{ refused, reason, message, cliVersion, projectVersion, nextCommand }` on
44
+ stdout (exit 2). Does not block project-local CLI, newer globals, or projects
45
+ with no local install yet. Field context: global Homebrew **2.x** mutative
46
+ upgrade next to 3.8+/4.0 projects (see
47
+ [4.0.0 release notes](docs/releases/4.0.0.md#field-footgun--global-arkgate-2x-on-path)).
48
+ - **`/ark-upgrade` skill:** procedure step 1 resolves the **project-local** CLI
49
+ first, probes for managed upgrade (`--plan-digest`), and aborts when only an
50
+ old PATH binary is available.
51
+ - **Doctor `rulesUnderContract` catalog caps:** structure sensors and uncovered
52
+ invariants are truncated in doctor JSON (with `structureTruncated` /
53
+ `uncoveredTruncated`) the same way as the covered sample — HTML announces
54
+ overflow instead of implying the full catalog is always embedded.
55
+
8
56
  ## 4.0.0 — 2026-07-24
9
57
 
10
58
  **Major** over 3.9.2. **Breaking:** deprecated root subpaths `arkgate/runtime` and `arkgate/nestjs`
@@ -77,6 +125,10 @@ case-study docs are scaffolding, not a closed field gate.
77
125
  - **Dual-truth residual:** `ark upgrade --no-install` can refresh managed assets while leaving
78
126
  package.json on an older pin — doctor exposes `packageVersionTruth` and upgrade JSON/human notes
79
127
  when the pin is behind the CLI.
128
+ - **Field note — global 2.x PATH:** bare `ark upgrade` from a global **arkgate 2.x** install is
129
+ mutative (pre managed content-identity) and unsafe next to 3.8+/4.0 projects — prefer
130
+ `npx arkgate upgrade …`. Documented in [4.0.0 release notes](docs/releases/4.0.0.md#field-footgun--global-arkgate-2x-on-path);
131
+ CLI fail-closed guard ships under Unreleased.
80
132
 
81
133
  ## 3.9.2 — 2026-07-23
82
134
 
package/README.md CHANGED
@@ -16,9 +16,10 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 4.0.0** is prepared on this line (ArkRules opt-in + AR04 breaking forwarder removal).
20
- > Last npm `latest` remains **3.9.2** until publish. [4.0.0 notes](docs/releases/4.0.0.md) ·
21
- > [3.9.2](docs/releases/3.9.2.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
19
+ > **ArkGate 4.0.1** is the next prepared patch (stale global CLI upgrade guard + ArkRules
20
+ > HTML/docs honesty). **npm `latest` is still 4.0.0** until publication succeeds.
21
+ > [4.0.1 notes](docs/releases/4.0.1.md) · [4.0.0](docs/releases/4.0.0.md) ·
22
+ > [Docs hub](docs/README.md)
22
23
 
23
24
  ---
24
25
 
@@ -186,8 +187,8 @@ for real systems. Details: [docs/production-hardening.md](docs/production-harden
186
187
  | Config · package surface · TS | [configuration](docs/configuration.md) · [package-surface](docs/package-surface.md) · [typescript-support](docs/typescript-support.md) |
187
188
  | Brownfield | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
188
189
  | Security | [SECURITY.md](SECURITY.md) |
189
- | Latest prepared (4.0.0) | [docs/releases/4.0.0.md](docs/releases/4.0.0.md) · [CHANGELOG](CHANGELOG.md) |
190
- | Last published (3.9.2) | [docs/releases/3.9.2.md](docs/releases/3.9.2.md) |
190
+ | Latest release (4.0.0 on npm; 4.0.1 prepared) | [4.0.1](docs/releases/4.0.1.md) · [4.0.0](docs/releases/4.0.0.md) · [CHANGELOG](CHANGELOG.md) |
191
+ | Previous (3.9.2) | [docs/releases/3.9.2.md](docs/releases/3.9.2.md) |
191
192
  | History / maintainer evidence | [docs/archive/](docs/archive/README.md) |
192
193
 
193
194
  ---
@@ -247,7 +247,7 @@ export function graphBlindSpotsHtml(state, esc = (v) => String(v)) {
247
247
  body = `<p><span class="tag warn">${state.count} unresolvable</span> dynamic edge(s) — graph incomplete (${state.templateInterpolationCount ?? 0} template-interpolation).</p><ul>${list}</ul>${more}<p class="muted">Advisory only — does not change the architecture verdict; edges are blind, not clean.</p>`;
248
248
  }
249
249
  return `
250
- <section data-advisory="graphBlindSpots">
250
+ <section class="section card" data-advisory="graphBlindSpots">
251
251
  <h2>Graph blind spots <span class="muted">(advisory — incomplete graph honesty; never a hard verdict)</span></h2>
252
252
  ${body}
253
253
  </section>`;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { effectiveCapabilityDeny } from './analysis-engine.mjs';
11
11
  import { graphBlindSpotsHtml } from './graph-blind.mjs';
12
+ import { formatRulesUnderContractHtml } from './rules-under-contract.mjs';
12
13
 
13
14
  // htmlEscape is injected by the caller (html-report.mjs) — importing it back
14
15
  // would be a dependency cycle, and the repo's own gate blocks that. The
@@ -114,7 +115,7 @@ function contractHealthHtml(health) {
114
115
  })
115
116
  .join('\n');
116
117
  return `
117
- <section data-advisory="contractHealth">
118
+ <section class="section card" data-advisory="contractHealth">
118
119
  <h2>Contract health <span class="muted">(advisory — meta-lint of the contract itself; never changes the verdict)</span></h2>
119
120
  ${invalid}
120
121
  ${body}
@@ -128,7 +129,7 @@ function ambientStateHtml(state) {
128
129
  if (!state) return '';
129
130
  if (state.available === false) {
130
131
  return `
131
- <section data-advisory="ambientState">
132
+ <section class="section card" data-advisory="ambientState">
132
133
  <h2>Ambient state <span class="muted">(advisory)</span></h2>
133
134
  <p class="muted">${esc(state.note ?? 'Sensor unavailable in this run.')}</p>
134
135
  </section>`;
@@ -147,7 +148,7 @@ function ambientStateHtml(state) {
147
148
  (state.findingCount > 10 ? `<p class="muted">…(+${state.findingCount - 10} more in doctor JSON)</p>` : '') +
148
149
  (state.acknowledged > 0 ? `<p class="muted">acknowledged module state: ${state.acknowledged}</p>` : '');
149
150
  return `
150
- <section data-advisory="ambientState">
151
+ <section class="section card" data-advisory="ambientState">
151
152
  <h2>Ambient state <span class="muted">(advisory — opt-in via pure layers; blocker-grade Y07 parked)</span></h2>
152
153
  ${body}
153
154
  </section>`;
@@ -211,7 +212,7 @@ function physicalCohesionHtml(pc) {
211
212
  ? `<p class="muted">next pilot (proposed, never applied): ${esc(pc.reshapePilot.nextPilot.pilotTarget)} — one pilot at a time via /ark-loop; merges are judgment cards only.</p>`
212
213
  : '';
213
214
  return `
214
- <section data-advisory="physicalCohesion">
215
+ <section class="section card" data-advisory="physicalCohesion">
215
216
  <h2>Physical cohesion <span class="muted">(advisory — facts, not a score; the verdict is unchanged)</span></h2>
216
217
  ${body}
217
218
  ${reshapeDecisionsHtml(pc.reshapeDecisions)}
@@ -230,7 +231,7 @@ function parseHealthHtml(health) {
230
231
  `<ul>${files.map((f) => `<li><code>${esc(f.file)}</code> — ${f.diagnosticCount} parse diagnostic(s)</li>`).join('')}</ul>` +
231
232
  (health.truncated > 0 ? `<p class="muted">…(+${health.truncated} more affected file(s); doctor list capped)</p>` : '');
232
233
  return `
233
- <section data-advisory="parseHealth">
234
+ <section class="section card" data-advisory="parseHealth">
234
235
  <h2>Parse health <span class="muted">(completeness evidence — affected syntax makes analysis partial)</span></h2>
235
236
  ${body}
236
237
  </section>`;
@@ -242,26 +243,8 @@ function parseHealthHtml(health) {
242
243
  * @param escape injected HTML escaper (dependency points html-report → here only)
243
244
  */
244
245
  function rulesUnderContractHtml(section) {
245
- if (!section || typeof section !== 'object') return '';
246
- const note = section.note ? `<p class="muted">${esc(section.note)}</p>` : '';
247
- if (section.active === false) {
248
- return `
249
- <section data-advisory="rulesUnderContract">
250
- <h2>Rules under contract <span class="muted">(ArkRules opt-in)</span></h2>
251
- ${note}
252
- </section>`;
253
- }
254
- return `
255
- <section data-advisory="rulesUnderContract">
256
- <h2>Rules under contract <span class="muted">(counts — not a score)</span></h2>
257
- <ul>
258
- <li>Structure rules: <strong>${Number(section.structureRules) || 0}</strong></li>
259
- <li>Invariants: <strong>${Number(section.invariants) || 0}</strong></li>
260
- <li>Covered invariants: <strong>${Number(section.coveredInvariants) || 0}</strong></li>
261
- <li>Uncovered invariants: <strong>${Number(section.uncoveredInvariants) || 0}</strong></li>
262
- </ul>
263
- ${note}
264
- </section>`;
246
+ // Detail lives in rules-under-contract.mjs so this file stays under LOC budget.
247
+ return formatRulesUnderContractHtml(section, esc);
265
248
  }
266
249
 
267
250
  export function renderAdvisorySections(advisories, escape) {
@@ -689,5 +689,5 @@ export function renderManagedUpgrade(plan, options = {}) {
689
689
  }
690
690
  console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
691
691
  if (options.next) console.log(options.next);
692
- else console.log('Apply the exact preview with: ark upgrade --apply --no-install');
692
+ else console.log('Apply the exact preview with: npx arkgate upgrade --apply --no-install');
693
693
  }
@@ -1,11 +1,21 @@
1
1
  /**
2
- * AR12 — doctor/HTML "Rules under contract" counts (not a score).
2
+ * AR12 — doctor/HTML "Rules under contract" (ArkRules plane — counts, never a score).
3
3
  * Uses real file I/O for coverage evidence (never empty-fileContents stub).
4
+ * Summary includes per-layer + structure/invariant detail so showcase HTML /ark-explain
5
+ * can teach what is under contract, not only aggregate numbers.
4
6
  */
5
7
  import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
6
8
  import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
7
9
  import { loadInvariantCoverageInputs } from './invariant-coverage-io.mjs';
8
10
 
11
+ /**
12
+ * Cap long catalogs in doctor JSON (and HTML, which consumes the same summary).
13
+ * Covered is a sample; structure/uncovered are truncated with *Truncated counters.
14
+ */
15
+ const COVERED_SAMPLE_MAX = 24;
16
+ const STRUCTURE_CATALOG_MAX = 40;
17
+ const UNCOVERED_CATALOG_MAX = 30;
18
+
9
19
  /**
10
20
  * @param {string} root
11
21
  * @param {Record<string, unknown>} config
@@ -45,6 +55,63 @@ export function summarizeRulesUnderContract(root, config, facts) {
45
55
  testFiles: coverageInputs.testFiles,
46
56
  testGlobsMissing: coverageInputs.testGlobsMissing,
47
57
  });
58
+ const covById = new Map(
59
+ (coverage.coverage ?? []).map((row) => [row.invariantId, row])
60
+ );
61
+ const byLayer = loaded.arkRules.byLayer ?? {};
62
+ const layers = Object.keys(byLayer)
63
+ .sort((a, b) => a.localeCompare(b))
64
+ .map((name) => {
65
+ const part = byLayer[name] ?? {};
66
+ const layerInvariants = part.invariants ?? [];
67
+ let covered = 0;
68
+ for (const inv of layerInvariants) {
69
+ if (covById.get(inv.id)?.covered) covered += 1;
70
+ }
71
+ return {
72
+ name,
73
+ sourceFile: part.sourceFile ?? null,
74
+ structureRules: (part.structure ?? []).length,
75
+ invariants: layerInvariants.length,
76
+ coveredInvariants: covered,
77
+ uncoveredInvariants: layerInvariants.length - covered,
78
+ };
79
+ });
80
+
81
+ const structureAll = (loaded.arkRules.structure ?? []).map((entry) => ({
82
+ id: entry.id,
83
+ sensor: entry.sensor,
84
+ mode: entry.mode ?? 'advisory',
85
+ layer: entry.provenance?.layer ?? null,
86
+ description: entry.description ?? null,
87
+ sourceFile: entry.provenance?.sourceFile ?? null,
88
+ }));
89
+ const structureTruncated = Math.max(0, structureAll.length - STRUCTURE_CATALOG_MAX);
90
+ const structure = structureAll.slice(0, STRUCTURE_CATALOG_MAX);
91
+
92
+ const uncoveredAll = (coverage.coverage ?? [])
93
+ .filter((row) => !row.covered)
94
+ .map((row) => ({
95
+ id: row.invariantId,
96
+ layer: row.layer ?? null,
97
+ mode: row.mode ?? null,
98
+ description: row.description ?? null,
99
+ sourceFile: row.sourceFile ?? null,
100
+ }));
101
+ const uncoveredTruncated = Math.max(0, uncoveredAll.length - UNCOVERED_CATALOG_MAX);
102
+ const uncovered = uncoveredAll.slice(0, UNCOVERED_CATALOG_MAX);
103
+
104
+ const coveredAll = (coverage.coverage ?? [])
105
+ .filter((row) => row.covered)
106
+ .map((row) => ({
107
+ id: row.invariantId,
108
+ layer: row.layer ?? null,
109
+ mode: row.mode ?? null,
110
+ description: row.description ?? null,
111
+ }));
112
+ const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
113
+ const coveredSample = coveredAll.slice(0, COVERED_SAMPLE_MAX);
114
+
48
115
  return {
49
116
  active: true,
50
117
  structureRules,
@@ -53,8 +120,15 @@ export function summarizeRulesUnderContract(root, config, facts) {
53
120
  uncoveredInvariants: coverage.coverage.filter((c) => !c.covered).length,
54
121
  partialCoverage: coverage.partial,
55
122
  testFilesScanned: coverageInputs.testFiles.length,
123
+ layers,
124
+ structure,
125
+ structureTruncated,
126
+ uncovered,
127
+ uncoveredTruncated,
128
+ coveredSample,
129
+ coveredTruncated,
56
130
  notAScore: true,
57
- note: 'Counts only — never a score. Green with uncovered residual must say so.',
131
+ note: 'ArkRules plane (intra-layer) counts and catalog, never a score. Green with uncovered residual must say so.',
58
132
  };
59
133
  } catch (error) {
60
134
  return {
@@ -64,3 +138,183 @@ export function summarizeRulesUnderContract(root, config, facts) {
64
138
  };
65
139
  }
66
140
  }
141
+
142
+ /**
143
+ * Showcase HTML for the ArkRules plane (used by html-report-advisories).
144
+ * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
145
+ * @param {(v: unknown) => string} esc
146
+ */
147
+ export function formatRulesUnderContractHtml(section, esc) {
148
+ if (!section || typeof section !== 'object') return '';
149
+ const escape = typeof esc === 'function' ? esc : (v) => String(v);
150
+ const note = section.note ? `<p class="muted">${escape(section.note)}</p>` : '';
151
+
152
+ if (section.active === false) {
153
+ return `
154
+ <section class="section card" data-advisory="rulesUnderContract">
155
+ <h2>Rules under contract <span class="muted">(ArkRules opt-in)</span></h2>
156
+ <p class="dim" style="margin:.15rem 0 .55rem;font-size:.88rem">
157
+ Intra-layer plane (structure sensors + domain invariants as data). Separate from inter-layer import edges.
158
+ </p>
159
+ ${note}
160
+ </section>`;
161
+ }
162
+
163
+ if (Array.isArray(section.loadErrors) && section.loadErrors.length) {
164
+ const errs = section.loadErrors
165
+ .slice(0, 8)
166
+ .map((e) => `<li><code>${escape(e.path ?? '')}</code> — ${escape(e.message ?? e)}</li>`)
167
+ .join('');
168
+ return `
169
+ <section class="section card" data-advisory="rulesUnderContract">
170
+ <h2>Rules under contract <span class="muted">(load errors)</span></h2>
171
+ ${note}
172
+ <ul class="senior-list">${errs}</ul>
173
+ </section>`;
174
+ }
175
+
176
+ const layers = Array.isArray(section.layers) ? section.layers : [];
177
+ const structure = Array.isArray(section.structure) ? section.structure : [];
178
+ const uncovered = Array.isArray(section.uncovered) ? section.uncovered : [];
179
+ const coveredSample = Array.isArray(section.coveredSample) ? section.coveredSample : [];
180
+ const coveredTruncated = Number(section.coveredTruncated) || 0;
181
+ const structureTruncated = Number(section.structureTruncated) || 0;
182
+ const uncoveredTruncated = Number(section.uncoveredTruncated) || 0;
183
+
184
+ const layerRows = layers
185
+ .map((row) => {
186
+ const cov =
187
+ row.invariants > 0
188
+ ? `${row.coveredInvariants}/${row.invariants} inv covered`
189
+ : 'no invariants';
190
+ return `<tr>
191
+ <td class="ln">${escape(row.name)}${
192
+ row.sourceFile ? `<div class="tags"><span class="tag"><code>${escape(row.sourceFile)}</code></span></div>` : ''
193
+ }</td>
194
+ <td class="num">${Number(row.structureRules) || 0}</td>
195
+ <td class="num">${Number(row.invariants) || 0}</td>
196
+ <td>${escape(cov)}${
197
+ row.uncoveredInvariants > 0
198
+ ? ` <span class="tag warn">${row.uncoveredInvariants} uncovered</span>`
199
+ : ''
200
+ }</td>
201
+ </tr>`;
202
+ })
203
+ .join('\n');
204
+
205
+ const layerTable = layers.length
206
+ ? `<table class="layers" style="margin-top:.55rem">
207
+ <thead><tr><th>Layer</th><th>Structure</th><th>Invariants</th><th>Coverage</th></tr></thead>
208
+ <tbody>${layerRows}</tbody>
209
+ </table>`
210
+ : '';
211
+
212
+ // Doctor JSON already caps catalogs; slice again only if a caller passed untruncated arrays.
213
+ const structureShown = structure.slice(0, STRUCTURE_CATALOG_MAX);
214
+ const structureOverflow =
215
+ structureTruncated > 0
216
+ ? structureTruncated
217
+ : Math.max(0, structure.length - STRUCTURE_CATALOG_MAX);
218
+ const structureItems = structureShown
219
+ .map((s) => {
220
+ const mode = s.mode === 'enforced' ? 'enforced' : s.mode === 'advisory' ? 'advisory' : String(s.mode ?? '');
221
+ const modeTag =
222
+ mode === 'enforced'
223
+ ? '<span class="tag">enforced</span>'
224
+ : `<span class="tag warn">${escape(mode || 'mode?')}</span>`;
225
+ return `<li>
226
+ <code>${escape(s.id)}</code>
227
+ ${modeTag}
228
+ <span class="dim">· ${escape(s.layer || '?')} · sensor <code>${escape(s.sensor || '')}</code></span>
229
+ ${s.description ? `<div class="msg">${escape(s.description)}</div>` : ''}
230
+ </li>`;
231
+ })
232
+ .join('\n');
233
+ const structureMore =
234
+ structureOverflow > 0
235
+ ? `<p class="muted">…(+${structureOverflow} more structure rule(s) in arkrules/*)</p>`
236
+ : '';
237
+
238
+ const uncoveredShown = uncovered.slice(0, UNCOVERED_CATALOG_MAX);
239
+ const uncoveredOverflow =
240
+ uncoveredTruncated > 0
241
+ ? uncoveredTruncated
242
+ : Math.max(0, uncovered.length - UNCOVERED_CATALOG_MAX);
243
+ const uncoveredItems = uncoveredShown
244
+ .map(
245
+ (u) => `<li>
246
+ <code>${escape(u.id)}</code>
247
+ <span class="tag warn">uncovered</span>
248
+ <span class="dim">· ${escape(u.layer || '?')}</span>
249
+ ${u.description ? `<div class="msg">${escape(u.description)}</div>` : ''}
250
+ </li>`
251
+ )
252
+ .join('\n');
253
+ const uncoveredMore =
254
+ uncoveredOverflow > 0
255
+ ? `<p class="muted">…(+${uncoveredOverflow} more uncovered)</p>`
256
+ : '';
257
+ const uncoveredBlock =
258
+ // Aggregate total (not the truncated array length) decides "all covered".
259
+ Number(section.uncoveredInvariants) === 0 && uncovered.length === 0
260
+ ? `<p class="clean-body" style="margin-top:.55rem">All catalogued invariants have coverage evidence (test/symbol scan) — residual inventory may still suggest new candidates via <code>--rules-inventory</code>.</p>`
261
+ : `<h3 style="margin-top:.9rem;font-size:.95rem">Uncovered invariants</h3>
262
+ <ul class="senior-list">${uncoveredItems}</ul>${uncoveredMore}`;
263
+
264
+ const coveredItems = coveredSample
265
+ .map(
266
+ (c) => `<li>
267
+ <code>${escape(c.id)}</code>
268
+ <span class="tag">covered</span>
269
+ <span class="dim">· ${escape(c.layer || '?')}</span>
270
+ ${c.description ? `<div class="msg">${escape(c.description)}</div>` : ''}
271
+ </li>`
272
+ )
273
+ .join('\n');
274
+ const coveredBlock =
275
+ coveredSample.length === 0
276
+ ? ''
277
+ : `<h3 style="margin-top:.9rem;font-size:.95rem">Covered invariants${
278
+ coveredTruncated > 0 ? ` <span class="dim">(sample of ${coveredSample.length})</span>` : ''
279
+ }</h3>
280
+ <ul class="senior-list">${coveredItems}</ul>
281
+ ${
282
+ coveredTruncated > 0
283
+ ? `<p class="muted">…(+${coveredTruncated} more covered — full catalog in <code>arkrules/*</code>)</p>`
284
+ : ''
285
+ }`;
286
+
287
+ return `
288
+ <section class="section card" data-advisory="rulesUnderContract">
289
+ <h2>Rules under contract <span class="muted">(ArkRules — not a score)</span></h2>
290
+ <p class="dim" style="margin:.15rem 0 .55rem;font-size:.88rem">
291
+ <b>[ArkRules]</b> Intra-layer plane — separate from <b>[Layer]</b> import edges above.
292
+ <b>Structure</b> = module-shape heuristics (not proof of Domain extraction).
293
+ <b>Invariants</b> = named policies + coverage evidence (symbol/test), not a business runtime
294
+ and not a fitness score.
295
+ </p>
296
+ <div class="kpis" style="margin-bottom:.55rem">
297
+ <div class="kpi"><b>${Number(section.structureRules) || 0}</b><span>Structure rules</span></div>
298
+ <div class="kpi"><b>${Number(section.invariants) || 0}</b><span>Invariants</span></div>
299
+ <div class="kpi"><b>${Number(section.coveredInvariants) || 0}</b><span>Covered</span></div>
300
+ <div class="kpi"><b>${Number(section.uncoveredInvariants) || 0}</b><span>Uncovered</span></div>
301
+ </div>
302
+ ${layers.length ? `<p class="dim" style="margin:0 0 .35rem;font-size:.86rem">${layers.length} layer(s) with an <code>arkRules</code> map entry · tests scanned: ${Number(section.testFilesScanned) || 0}</p>` : ''}
303
+ ${layerTable}
304
+ ${
305
+ structure.length
306
+ ? `<h3 style="margin-top:.9rem;font-size:.95rem">Structure sensors</h3>
307
+ <p class="muted" style="margin:.15rem 0 .4rem;font-size:.84rem">Heuristics of module shape. Enforced fails the check; it does not prove extraction to Domain.</p>
308
+ <ul class="senior-list">${structureItems}</ul>${structureMore}`
309
+ : '<p class="muted" style="margin-top:.55rem">No structure sensors in loaded ArkRules files.</p>'
310
+ }
311
+ ${uncoveredBlock}
312
+ ${coveredBlock}
313
+ ${
314
+ coveredSample.length || uncovered.length
315
+ ? `<p class="muted" style="margin-top:.65rem;font-size:.84rem">Covered = catalog evidence found (symbol and/or test title). Not a claim that business semantics are fully proven end-to-end.</p>`
316
+ : ''
317
+ }
318
+ ${note}
319
+ </section>`;
320
+ }