arkgate 4.0.0 → 4.1.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 (57) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/graph-blind.mjs +1 -1
  20. package/bin/lib/html-report-advisories.mjs +8 -25
  21. package/bin/lib/html-report-depth.mjs +167 -3
  22. package/bin/lib/html-report.mjs +12 -5
  23. package/bin/lib/install-migrate.mjs +109 -6
  24. package/bin/lib/managed-upgrade.mjs +100 -1
  25. package/bin/lib/presets.mjs +314 -46
  26. package/bin/lib/project-root.mjs +268 -0
  27. package/bin/lib/remediation.mjs +12 -11
  28. package/bin/lib/rules-inventory.mjs +71 -29
  29. package/bin/lib/rules-under-contract.mjs +389 -5
  30. package/bin/lib/start-preview.mjs +48 -14
  31. package/bin/lib/suggestions.mjs +118 -3
  32. package/bin/lib/unavailable-analysis.mjs +2 -0
  33. package/bin/lib/upgrade-command.mjs +325 -14
  34. package/bin/lib/write-path-capabilities.mjs +38 -9
  35. package/dist/eslint/index.cjs +2 -2
  36. package/dist/eslint/index.d.ts +27 -2
  37. package/dist/eslint/index.js +2 -2
  38. package/dist/index.cjs +16 -14
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +16 -14
  41. package/docs/README.md +3 -2
  42. package/docs/ai-gates.md +15 -11
  43. package/docs/brownfield-adoption.md +38 -0
  44. package/docs/configuration.md +59 -7
  45. package/docs/package-surface.md +3 -2
  46. package/docs/product-voice.md +10 -1
  47. package/docs/typescript-support.md +9 -5
  48. package/docs/use.md +7 -5
  49. package/package.json +3 -1
  50. package/server.json +3 -3
  51. package/templates/architecture-playbook.json +3 -0
  52. package/templates/layers/shared-types.starter.json +29 -0
  53. package/templates/skills/ark-adopt.md +2 -0
  54. package/templates/skills/ark-explain.md +23 -5
  55. package/templates/skills/ark-explore.md +21 -1
  56. package/templates/skills/ark-fix.md +16 -5
  57. package/templates/skills/ark-upgrade.md +57 -11
@@ -1,17 +1,69 @@
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
+
19
+ /** Minimum governed % before enforced ArkRules may arm extra merge teeth (P1M / FG-EXTRATEETH). */
20
+ export const EXTRA_MERGE_TEETH_GOVERNED_FLOOR = 50;
21
+
22
+ /**
23
+ * P1M / extraMergeTeeth: under the classification floor, demote enforced ArkRules
24
+ * structure/invariant findings so merge matches doctor stamp (layer graph only).
25
+ * Unknown classification (null/null) → do not demote (contract-only callers).
26
+ *
27
+ * @param {object[]} violations
28
+ * @param {{ governedPercent?: number|null, populatedLayerCount?: number|null }} classification
29
+ * @returns {object[]}
30
+ */
31
+ export function demoteArkRuleTeethUnderClassificationFloor(violations, classification = {}) {
32
+ if (!Array.isArray(violations)) return violations;
33
+ const governed =
34
+ typeof classification.governedPercent === 'number' ? classification.governedPercent : null;
35
+ const populated =
36
+ typeof classification.populatedLayerCount === 'number'
37
+ ? classification.populatedLayerCount
38
+ : null;
39
+ if (governed == null && populated == null) return violations;
40
+ const allowsTeeth =
41
+ (governed ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR && (populated ?? 0) >= 1;
42
+ if (allowsTeeth) return violations;
43
+ for (const v of violations) {
44
+ const isArkRule =
45
+ v?.arkruleId != null ||
46
+ (typeof v?.ruleId === 'string' &&
47
+ (v.ruleId.startsWith('ARKRULE') || v.ruleId.startsWith('arkrule')));
48
+ if (isArkRule && v.failsStrict !== false) {
49
+ v.failsStrict = false;
50
+ if (v.severity === 'error') v.severity = 'warning';
51
+ }
52
+ }
53
+ return violations;
54
+ }
55
+
9
56
  /**
10
57
  * @param {string} root
11
58
  * @param {Record<string, unknown>} config
12
59
  * @param {{ files?: Array<{ path: string }> }} [facts] optional facts for path set
60
+ * @param {{
61
+ * governedPercent?: number | null,
62
+ * populatedLayerCount?: number | null,
63
+ * classifiedFiles?: number | null,
64
+ * }} [classification] layer-plane coverage (when known)
13
65
  */
14
- export function summarizeRulesUnderContract(root, config, facts) {
66
+ export function summarizeRulesUnderContract(root, config, facts, classification) {
15
67
  if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
16
68
  return {
17
69
  active: false,
@@ -45,16 +97,157 @@ export function summarizeRulesUnderContract(root, config, facts) {
45
97
  testFiles: coverageInputs.testFiles,
46
98
  testGlobsMissing: coverageInputs.testGlobsMissing,
47
99
  });
100
+ const covById = new Map(
101
+ (coverage.coverage ?? []).map((row) => [row.invariantId, row])
102
+ );
103
+ const byLayer = loaded.arkRules.byLayer ?? {};
104
+ const layers = Object.keys(byLayer)
105
+ .sort((a, b) => a.localeCompare(b))
106
+ .map((name) => {
107
+ const part = byLayer[name] ?? {};
108
+ const layerInvariants = part.invariants ?? [];
109
+ let covered = 0;
110
+ for (const inv of layerInvariants) {
111
+ if (covById.get(inv.id)?.covered) covered += 1;
112
+ }
113
+ return {
114
+ name,
115
+ sourceFile: part.sourceFile ?? null,
116
+ structureRules: (part.structure ?? []).length,
117
+ invariants: layerInvariants.length,
118
+ coveredInvariants: covered,
119
+ uncoveredInvariants: layerInvariants.length - covered,
120
+ };
121
+ });
122
+
123
+ const structureAll = (loaded.arkRules.structure ?? []).map((entry) => ({
124
+ id: entry.id,
125
+ sensor: entry.sensor,
126
+ mode: entry.mode ?? 'advisory',
127
+ layer: entry.provenance?.layer ?? null,
128
+ description: entry.description ?? null,
129
+ sourceFile: entry.provenance?.sourceFile ?? null,
130
+ }));
131
+ const structureTruncated = Math.max(0, structureAll.length - STRUCTURE_CATALOG_MAX);
132
+ const structure = structureAll.slice(0, STRUCTURE_CATALOG_MAX);
133
+
134
+ const uncoveredAll = (coverage.coverage ?? [])
135
+ .filter((row) => !row.covered)
136
+ .map((row) => ({
137
+ id: row.invariantId,
138
+ layer: row.layer ?? null,
139
+ mode: row.mode ?? null,
140
+ description: row.description ?? null,
141
+ sourceFile: row.sourceFile ?? null,
142
+ }));
143
+ const uncoveredTruncated = Math.max(0, uncoveredAll.length - UNCOVERED_CATALOG_MAX);
144
+ const uncovered = uncoveredAll.slice(0, UNCOVERED_CATALOG_MAX);
145
+
146
+ const coveredAll = (coverage.coverage ?? [])
147
+ .filter((row) => row.covered)
148
+ .map((row) => ({
149
+ id: row.invariantId,
150
+ layer: row.layer ?? null,
151
+ mode: row.mode ?? null,
152
+ description: row.description ?? null,
153
+ }));
154
+ const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
155
+ const coveredSample = coveredAll.slice(0, COVERED_SAMPLE_MAX);
156
+
157
+ const structureEnforced = structureAll.filter((s) => s.mode === 'enforced').length;
158
+ const structureAdvisory = structureAll.length - structureEnforced;
159
+ const invariantEnforced = (loaded.arkRules.invariants ?? []).filter(
160
+ (inv) => inv.mode === 'enforced'
161
+ ).length;
162
+ const invariantAdvisory = invariants - invariantEnforced;
163
+ const coveredInvariants = coverage.coverage.filter((c) => c.covered).length;
164
+ const uncoveredInvariants = coverage.coverage.filter((c) => !c.covered).length;
165
+ const hasEnforcedTeeth = structureEnforced > 0 || invariantEnforced > 0;
166
+ // P1M-EXTRATEETH-EMPTY-GRAPH / FG-EXTRATEETH-EMPTY-CLASSIFICATION:
167
+ // Do not arm structure/invariant merge teeth when the layer plane is empty or
168
+ // barely classified (e.g. 0% governed). Classification unknown → allow teeth
169
+ // (contract-only callers / unit tests without coverage).
170
+ const governedPercent =
171
+ classification && typeof classification.governedPercent === 'number'
172
+ ? classification.governedPercent
173
+ : null;
174
+ const populatedLayerCount =
175
+ classification && typeof classification.populatedLayerCount === 'number'
176
+ ? classification.populatedLayerCount
177
+ : classification && typeof classification.classifiedFiles === 'number'
178
+ ? classification.classifiedFiles > 0
179
+ ? 1
180
+ : 0
181
+ : null;
182
+ const classificationKnown = governedPercent != null || populatedLayerCount != null;
183
+ const classificationAllowsTeeth = !classificationKnown
184
+ ? true
185
+ : (governedPercent ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR &&
186
+ (populatedLayerCount ?? 0) >= 1;
187
+ const extraMergeTeeth = hasEnforcedTeeth && classificationAllowsTeeth;
188
+ const teethDeferredForClassification =
189
+ hasEnforcedTeeth && classificationKnown && !classificationAllowsTeeth;
190
+ // P1-M — which plane can fail merge (layers vs enforced structure vs invariants).
191
+ const mergePlanes = {
192
+ layers: {
193
+ role: 'inter-layer-edges',
194
+ alwaysOnGate: true,
195
+ note: 'Import/export layer graph — the default merge plane. Absent arkRules changes nothing here.',
196
+ },
197
+ structureSensors: {
198
+ role: 'intra-layer-heuristics',
199
+ total: structureRules,
200
+ enforced: structureEnforced,
201
+ advisory: structureAdvisory,
202
+ note: 'Structure sensors are heuristics (prefer false negatives). Only mode:enforced fails merge; noisy sensors stay advisory by default. Advisory-only packs never add merge teeth (FG-ARKRULES-ADVISORY-ONLY).',
203
+ },
204
+ invariants: {
205
+ role: 'catalog-plus-coverage',
206
+ total: invariants,
207
+ enforced: invariantEnforced,
208
+ advisory: invariantAdvisory,
209
+ covered: coveredInvariants,
210
+ uncovered: uncoveredInvariants,
211
+ note: 'Invariants are catalog + coverage evidence, not a business runtime. Enforced + proven-uncovered fails merge; absence of enforced rules adds no extra teeth.',
212
+ },
213
+ dualPlaneStamp:
214
+ 'Structure = heuristics; invariants = catalog+coverage evidence (not business runtime). The two planes never merge into one architecture score. Advisory ArkRules ≠ merge teeth.',
215
+ extraMergeTeeth,
216
+ ...(classificationKnown
217
+ ? {
218
+ classificationGate: {
219
+ governedPercent: governedPercent ?? null,
220
+ populatedLayerCount: populatedLayerCount ?? null,
221
+ floorPercent: EXTRA_MERGE_TEETH_GOVERNED_FLOOR,
222
+ allowsTeeth: classificationAllowsTeeth,
223
+ },
224
+ }
225
+ : {}),
226
+ failMergeWhen: extraMergeTeeth
227
+ ? 'Layer graph failures plus enforced structure/invariant findings (advisory sensors never fail merge alone).'
228
+ : teethDeferredForClassification
229
+ ? `Layer graph only — enforced ArkRules structure/invariant findings are demoted under the teeth floor (need ≥${EXTRA_MERGE_TEETH_GOVERNED_FLOOR}% governed and ≥1 populated layer); they do not merge-block until classification is honest.`
230
+ : 'Layer graph only — no enforced ArkRules structure/invariant teeth on this tree. Advisory packs do not arm merge teeth.',
231
+ };
232
+
48
233
  return {
49
234
  active: true,
50
235
  structureRules,
51
236
  invariants,
52
- coveredInvariants: coverage.coverage.filter((c) => c.covered).length,
53
- uncoveredInvariants: coverage.coverage.filter((c) => !c.covered).length,
237
+ coveredInvariants,
238
+ uncoveredInvariants,
54
239
  partialCoverage: coverage.partial,
55
240
  testFilesScanned: coverageInputs.testFiles.length,
241
+ layers,
242
+ structure,
243
+ structureTruncated,
244
+ uncovered,
245
+ uncoveredTruncated,
246
+ coveredSample,
247
+ coveredTruncated,
248
+ mergePlanes,
56
249
  notAScore: true,
57
- note: 'Counts only — never a score. Green with uncovered residual must say so.',
250
+ note: 'ArkRules plane (intra-layer) counts and catalog, never a score. Green with uncovered residual must say so. Structure sensors are heuristics; invariants are catalog+coverage evidence, not a business runtime.',
58
251
  };
59
252
  } catch (error) {
60
253
  return {
@@ -64,3 +257,194 @@ export function summarizeRulesUnderContract(root, config, facts) {
64
257
  };
65
258
  }
66
259
  }
260
+
261
+ /**
262
+ * Showcase HTML for the ArkRules plane (used by html-report-advisories).
263
+ * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
264
+ * @param {(v: unknown) => string} esc
265
+ */
266
+ export function formatRulesUnderContractHtml(section, esc) {
267
+ if (!section || typeof section !== 'object') return '';
268
+ const escape = typeof esc === 'function' ? esc : (v) => String(v);
269
+ const note = section.note ? `<p class="muted">${escape(section.note)}</p>` : '';
270
+
271
+ if (section.active === false) {
272
+ return `
273
+ <section class="section card" data-advisory="rulesUnderContract">
274
+ <h2>Rules under contract <span class="muted">(ArkRules opt-in)</span></h2>
275
+ <p class="dim" style="margin:.15rem 0 .55rem;font-size:.88rem">
276
+ Intra-layer plane (structure sensors + domain invariants as data). Separate from inter-layer import edges.
277
+ Absence of arkRules adds no extra merge teeth beyond the layer graph.
278
+ </p>
279
+ ${note}
280
+ </section>`;
281
+ }
282
+
283
+ if (Array.isArray(section.loadErrors) && section.loadErrors.length) {
284
+ const errs = section.loadErrors
285
+ .slice(0, 8)
286
+ .map((e) => `<li><code>${escape(e.path ?? '')}</code> — ${escape(e.message ?? e)}</li>`)
287
+ .join('');
288
+ return `
289
+ <section class="section card" data-advisory="rulesUnderContract">
290
+ <h2>Rules under contract <span class="muted">(load errors)</span></h2>
291
+ ${note}
292
+ <ul class="senior-list">${errs}</ul>
293
+ </section>`;
294
+ }
295
+
296
+ const layers = Array.isArray(section.layers) ? section.layers : [];
297
+ const structure = Array.isArray(section.structure) ? section.structure : [];
298
+ const uncovered = Array.isArray(section.uncovered) ? section.uncovered : [];
299
+ const coveredSample = Array.isArray(section.coveredSample) ? section.coveredSample : [];
300
+ const coveredTruncated = Number(section.coveredTruncated) || 0;
301
+ const structureTruncated = Number(section.structureTruncated) || 0;
302
+ const uncoveredTruncated = Number(section.uncoveredTruncated) || 0;
303
+
304
+ const layerRows = layers
305
+ .map((row) => {
306
+ const cov =
307
+ row.invariants > 0
308
+ ? `${row.coveredInvariants}/${row.invariants} inv covered`
309
+ : 'no invariants';
310
+ return `<tr>
311
+ <td class="ln">${escape(row.name)}${
312
+ row.sourceFile ? `<div class="tags"><span class="tag"><code>${escape(row.sourceFile)}</code></span></div>` : ''
313
+ }</td>
314
+ <td class="num">${Number(row.structureRules) || 0}</td>
315
+ <td class="num">${Number(row.invariants) || 0}</td>
316
+ <td>${escape(cov)}${
317
+ row.uncoveredInvariants > 0
318
+ ? ` <span class="tag warn">${row.uncoveredInvariants} uncovered</span>`
319
+ : ''
320
+ }</td>
321
+ </tr>`;
322
+ })
323
+ .join('\n');
324
+
325
+ const layerTable = layers.length
326
+ ? `<table class="layers" style="margin-top:.55rem">
327
+ <thead><tr><th>Layer</th><th>Structure</th><th>Invariants</th><th>Coverage</th></tr></thead>
328
+ <tbody>${layerRows}</tbody>
329
+ </table>`
330
+ : '';
331
+
332
+ // Doctor JSON already caps catalogs; slice again only if a caller passed untruncated arrays.
333
+ const structureShown = structure.slice(0, STRUCTURE_CATALOG_MAX);
334
+ const structureOverflow =
335
+ structureTruncated > 0
336
+ ? structureTruncated
337
+ : Math.max(0, structure.length - STRUCTURE_CATALOG_MAX);
338
+ const structureItems = structureShown
339
+ .map((s) => {
340
+ const mode = s.mode === 'enforced' ? 'enforced' : s.mode === 'advisory' ? 'advisory' : String(s.mode ?? '');
341
+ const modeTag =
342
+ mode === 'enforced'
343
+ ? '<span class="tag">enforced</span>'
344
+ : `<span class="tag warn">${escape(mode || 'mode?')}</span>`;
345
+ return `<li>
346
+ <code>${escape(s.id)}</code>
347
+ ${modeTag}
348
+ <span class="dim">· ${escape(s.layer || '?')} · sensor <code>${escape(s.sensor || '')}</code></span>
349
+ ${s.description ? `<div class="msg">${escape(s.description)}</div>` : ''}
350
+ </li>`;
351
+ })
352
+ .join('\n');
353
+ const structureMore =
354
+ structureOverflow > 0
355
+ ? `<p class="muted">…(+${structureOverflow} more structure rule(s) in arkrules/*)</p>`
356
+ : '';
357
+
358
+ const uncoveredShown = uncovered.slice(0, UNCOVERED_CATALOG_MAX);
359
+ const uncoveredOverflow =
360
+ uncoveredTruncated > 0
361
+ ? uncoveredTruncated
362
+ : Math.max(0, uncovered.length - UNCOVERED_CATALOG_MAX);
363
+ const uncoveredItems = uncoveredShown
364
+ .map(
365
+ (u) => `<li>
366
+ <code>${escape(u.id)}</code>
367
+ <span class="tag warn">uncovered</span>
368
+ <span class="dim">· ${escape(u.layer || '?')}</span>
369
+ ${u.description ? `<div class="msg">${escape(u.description)}</div>` : ''}
370
+ </li>`
371
+ )
372
+ .join('\n');
373
+ const uncoveredMore =
374
+ uncoveredOverflow > 0
375
+ ? `<p class="muted">…(+${uncoveredOverflow} more uncovered)</p>`
376
+ : '';
377
+ const uncoveredBlock =
378
+ // Aggregate total (not the truncated array length) decides "all covered".
379
+ Number(section.uncoveredInvariants) === 0 && uncovered.length === 0
380
+ ? `<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>`
381
+ : `<h3 style="margin-top:.9rem;font-size:.95rem">Uncovered invariants</h3>
382
+ <ul class="senior-list">${uncoveredItems}</ul>${uncoveredMore}`;
383
+
384
+ const coveredItems = coveredSample
385
+ .map(
386
+ (c) => `<li>
387
+ <code>${escape(c.id)}</code>
388
+ <span class="tag">covered</span>
389
+ <span class="dim">· ${escape(c.layer || '?')}</span>
390
+ ${c.description ? `<div class="msg">${escape(c.description)}</div>` : ''}
391
+ </li>`
392
+ )
393
+ .join('\n');
394
+ const coveredBlock =
395
+ coveredSample.length === 0
396
+ ? ''
397
+ : `<h3 style="margin-top:.9rem;font-size:.95rem">Covered invariants${
398
+ coveredTruncated > 0 ? ` <span class="dim">(sample of ${coveredSample.length})</span>` : ''
399
+ }</h3>
400
+ <ul class="senior-list">${coveredItems}</ul>
401
+ ${
402
+ coveredTruncated > 0
403
+ ? `<p class="muted">…(+${coveredTruncated} more covered — full catalog in <code>arkrules/*</code>)</p>`
404
+ : ''
405
+ }`;
406
+
407
+ const mergePlanes = section.mergePlanes;
408
+ const mergeHtml =
409
+ mergePlanes && typeof mergePlanes === 'object'
410
+ ? `<p class="muted" style="margin:.35rem 0 .55rem;font-size:.86rem">
411
+ <b>Merge planes:</b> ${escape(mergePlanes.failMergeWhen || '')}
412
+ ${mergePlanes.dualPlaneStamp ? `<br/>${escape(mergePlanes.dualPlaneStamp)}` : ''}
413
+ </p>`
414
+ : '';
415
+
416
+ return `
417
+ <section class="section card" data-advisory="rulesUnderContract">
418
+ <h2>Rules under contract <span class="muted">(ArkRules — not a score)</span></h2>
419
+ <p class="dim" style="margin:.15rem 0 .55rem;font-size:.88rem">
420
+ <b>[ArkRules]</b> Intra-layer plane — separate from <b>[Layer]</b> import edges above.
421
+ <b>Structure</b> = module-shape heuristics (not proof of Domain extraction).
422
+ <b>Invariants</b> = named policies + coverage evidence (symbol/test), not a business runtime
423
+ and not a fitness score.
424
+ </p>
425
+ ${mergeHtml}
426
+ <div class="kpis" style="margin-bottom:.55rem">
427
+ <div class="kpi"><b>${Number(section.structureRules) || 0}</b><span>Structure rules</span></div>
428
+ <div class="kpi"><b>${Number(section.invariants) || 0}</b><span>Invariants</span></div>
429
+ <div class="kpi"><b>${Number(section.coveredInvariants) || 0}</b><span>Covered</span></div>
430
+ <div class="kpi"><b>${Number(section.uncoveredInvariants) || 0}</b><span>Uncovered</span></div>
431
+ </div>
432
+ ${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>` : ''}
433
+ ${layerTable}
434
+ ${
435
+ structure.length
436
+ ? `<h3 style="margin-top:.9rem;font-size:.95rem">Structure sensors</h3>
437
+ <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>
438
+ <ul class="senior-list">${structureItems}</ul>${structureMore}`
439
+ : '<p class="muted" style="margin-top:.55rem">No structure sensors in loaded ArkRules files.</p>'
440
+ }
441
+ ${uncoveredBlock}
442
+ ${coveredBlock}
443
+ ${
444
+ coveredSample.length || uncovered.length
445
+ ? `<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>`
446
+ : ''
447
+ }
448
+ ${note}
449
+ </section>`;
450
+ }
@@ -122,8 +122,21 @@ function commands(root, args, helpers) {
122
122
  return result;
123
123
  }
124
124
 
125
- export function renderStartPreview(preview) {
126
- console.log('Ark start preview no files were changed.');
125
+ /**
126
+ * @param {object} preview
127
+ * @param {{ applying?: boolean }} [options] when applying=true, do not claim “no files were changed”
128
+ */
129
+ export function renderStartPreview(preview, options = {}) {
130
+ const applying = options.applying === true;
131
+ if (applying) {
132
+ console.log(
133
+ preview.changes.length === 0
134
+ ? 'Ark start apply — reviewing plan (no mutations pending).'
135
+ : `Ark start apply — writing ${preview.changes.length} planned mutation(s).`
136
+ );
137
+ } else {
138
+ console.log('Ark start preview — no files were changed.');
139
+ }
127
140
  if (preview.analysis) {
128
141
  console.log(`Your project looks like: ${preview.analysis.label} (${preview.analysis.archetype}, confidence ${preview.analysis.confidence}).`);
129
142
  }
@@ -135,20 +148,24 @@ export function renderStartPreview(preview) {
135
148
  console.log(
136
149
  `Compact setup budget: ${gateCount}/${budget.maxFiles} gate files${arkrulesNote}, ${budget.bytes}/${budget.maxBytes} bytes${budget.ok ? '' : ' (exceeded)'}.`
137
150
  );
138
- console.log('Files to create/edit/delete:');
151
+ console.log(applying ? 'Files create/edit/delete:' : 'Files to create/edit/delete:');
139
152
  if (preview.changes.length === 0) console.log(' (none)');
140
153
  for (const change of preview.changes) {
141
154
  console.log(` ${change.action.padEnd(6)} ${change.path} ${change.afterHash ?? '(deleted)'}`);
142
155
  }
143
- console.log('Commands in the approved setup plan:');
144
- for (const command of preview.commands) console.log(` ${command}`);
156
+ if (!applying) {
157
+ console.log('Commands in the approved setup plan:');
158
+ for (const command of preview.commands) console.log(` ${command}`);
159
+ }
145
160
  console.log('Host guarantees:');
146
161
  for (const guarantee of preview.hostGuarantees) console.log(` ${guarantee}`);
147
162
  if (preview.unresolvedDecisions.length > 0) {
148
163
  console.log('Unresolved decisions:');
149
164
  for (const decision of preview.unresolvedDecisions) console.log(` ${decision}`);
150
165
  }
151
- console.log('Review complete file contents with --json. Apply this plan with: ark start --apply');
166
+ if (!applying) {
167
+ console.log('Review complete file contents with --json. Apply this plan with: ark start --apply');
168
+ }
152
169
  }
153
170
 
154
171
  export function applyStartPreview(root, preview) {
@@ -224,16 +241,30 @@ export async function planStart(args, helpers) {
224
241
  const root = args.root;
225
242
  const before = treeFiles(root);
226
243
  let recommendation = null;
227
- try {
228
- const rec = buildArchitectureRecommendation(root);
244
+ // Wire --archetype / --preset into the plan so apply locks the chosen shape (not just recommend).
245
+ if (args.archetype || args.preset) {
229
246
  recommendation = {
230
- archetype: rec.archetype,
231
- label: rec.label,
232
- confidence: rec.confidence,
233
- mature: rec.mature,
247
+ archetype: args.archetype || args.preset,
248
+ label: args.archetype
249
+ ? `explicit archetype ${args.archetype}`
250
+ : `explicit preset ${args.preset}`,
251
+ confidence: 1,
252
+ mature: false,
253
+ explicitShape: true,
254
+ preset: args.preset || null,
234
255
  };
235
- } catch {
236
- recommendation = null;
256
+ } else {
257
+ try {
258
+ const rec = buildArchitectureRecommendation(root);
259
+ recommendation = {
260
+ archetype: rec.archetype,
261
+ label: rec.label,
262
+ confidence: rec.confidence,
263
+ mature: rec.mature,
264
+ };
265
+ } catch {
266
+ recommendation = null;
267
+ }
237
268
  }
238
269
  const shadowRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ark-start-preview-'));
239
270
  try {
@@ -253,6 +284,9 @@ export async function planStart(args, helpers) {
253
284
  else childArgs.push('--install');
254
285
  if (args.tools) childArgs.push('--tools', args.tools);
255
286
  if (args.requireWriteHook) childArgs.push('--require-write-hook', args.requireWriteHook);
287
+ // Lock archetype/preset into the shadow apply so the plan matches the gate bypass.
288
+ if (args.archetype) childArgs.push('--archetype', args.archetype);
289
+ if (args.preset) childArgs.push('--preset', args.preset);
256
290
  const planned = spawnSync(process.execPath, [helpers.cliPath, ...childArgs], {
257
291
  cwd: shadowRoot,
258
292
  encoding: 'utf8',
@@ -15,6 +15,68 @@ export function dirSegmentsFromGlob(pattern) {
15
15
  .filter((segment) => segment && !segment.includes('*'));
16
16
  }
17
17
 
18
+ /**
19
+ * Basename / path tokens that score as Persistence (data clients, auth, CRM).
20
+ * Used so adopt never maps lib/turso → Presentation (NEW-ADOPT-LIB-AS-PRESENTATION).
21
+ */
22
+ export const PERSISTENCE_PATH_TOKENS = Object.freeze([
23
+ 'turso',
24
+ 'prisma',
25
+ 'supabase',
26
+ 'airtable',
27
+ 'drizzle',
28
+ 'kysely',
29
+ 'mongoose',
30
+ 'mongodb',
31
+ 'firebase',
32
+ 'firestore',
33
+ 'planetscale',
34
+ 'neon',
35
+ 'pipedrive',
36
+ 'repository',
37
+ 'repositories',
38
+ 'persistence',
39
+ 'infrastructure',
40
+ 'infra',
41
+ 'db',
42
+ 'auth',
43
+ 'session',
44
+ ]);
45
+
46
+ /** True when a path/basename looks like a data or auth client (not UI). */
47
+ export function isPersistenceClientPath(relPath) {
48
+ const posix = String(relPath || '')
49
+ .split(/[/\\]/)
50
+ .filter(Boolean)
51
+ .join('/');
52
+ if (!posix) return false;
53
+ const lower = posix.toLowerCase();
54
+ const base = path.posix.basename(lower).replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/i, '');
55
+ for (const token of PERSISTENCE_PATH_TOKENS) {
56
+ if (base === token || base.startsWith(`${token}.`) || base.startsWith(`${token}-`) || base.startsWith(`${token}_`)) {
57
+ return true;
58
+ }
59
+ if (lower.includes(`/${token}/`) || lower.endsWith(`/${token}`) || lower.startsWith(`${token}/`)) {
60
+ return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /** True when a path looks like pure domain folders. */
67
+ export function isDomainPath(relPath) {
68
+ const posix = String(relPath || '')
69
+ .split(/[/\\]/)
70
+ .filter(Boolean)
71
+ .join('/')
72
+ .toLowerCase();
73
+ return (
74
+ /(?:^|\/)domain(?:\/|$)/.test(posix) ||
75
+ /(?:^|\/)entities(?:\/|$)/.test(posix) ||
76
+ /(?:^|\/)kernel\/domain(?:\/|$)/.test(posix)
77
+ );
78
+ }
79
+
18
80
  let _layerByDir;
19
81
  // Map<dirBasename, string[] layers>. A basename mapping to >1 layer (e.g. `app` — Application
20
82
  // orchestration in the 11-layer defaults, but Presentation in the monorepo/Next preset) is
@@ -58,11 +120,64 @@ export function suggestLayerForDir(name) {
58
120
 
59
121
  // Suggest a layer for a directory PATH by finding the deepest segment Ark recognizes, so
60
122
  // `src/lib/repositories` proposes PersistenceAdapters even though `lib` itself is unknown.
123
+ // Next App Router / Pages API shells (`…/app/api`, `…/pages/api`) are Application, not Presentation.
124
+ // Never map bare `lib` alone to Presentation (NEW-ADOPT-LIB-AS-PRESENTATION).
61
125
  export function suggestLayerForPath(relDir) {
62
- const segments = relDir.split('/').filter(Boolean);
126
+ const posix = String(relDir || '')
127
+ .split(/[/\\]/)
128
+ .filter(Boolean)
129
+ .join('/');
130
+ // Prefer the API orchestration shell over a bare `app` / `pages` Presentation match.
131
+ // Direct api/ and route-group shells: app/(marketing)/api/**
132
+ if (
133
+ /(?:^|\/)(?:src\/)?app(?:\/[^/]+)*\/api(?:\/|$)/.test(posix) ||
134
+ /(?:^|\/)(?:src\/)?pages(?:\/[^/]+)*\/api(?:\/|$)/.test(posix)
135
+ ) {
136
+ return {
137
+ layer: 'ApplicationOrchestration',
138
+ alternatives: [],
139
+ matchedDir: posix.includes('pages') && posix.includes('/api') ? 'pages/…/api' : 'app/…/api',
140
+ };
141
+ }
142
+ // Root / Vercel serverless api handlers (SPA layout).
143
+ if (/(?:^|\/)api(?:\/|$)/.test(posix) && !/(?:^|\/)app(?:\/|$)/.test(posix)) {
144
+ return {
145
+ layer: 'ApplicationOrchestration',
146
+ alternatives: [],
147
+ matchedDir: 'api',
148
+ };
149
+ }
150
+ if (isDomainPath(posix)) {
151
+ return { layer: 'DomainModel', alternatives: [], matchedDir: 'domain' };
152
+ }
153
+ if (isPersistenceClientPath(posix)) {
154
+ return {
155
+ layer: 'PersistenceAdapters',
156
+ alternatives: [],
157
+ matchedDir: path.posix.basename(posix) || 'persistence',
158
+ };
159
+ }
160
+ const segments = posix.split('/').filter(Boolean);
63
161
  for (let i = segments.length - 1; i >= 0; i -= 1) {
64
- const hit = suggestLayerForDir(segments[i]);
65
- if (hit) return { ...hit, matchedDir: segments[i] };
162
+ const seg = segments[i];
163
+ // Bare `lib` is not Presentation leave unrecognized so agents classify children.
164
+ if (seg === 'lib' || seg === 'src') continue;
165
+ const hit = suggestLayerForDir(seg);
166
+ if (hit) {
167
+ // Never promote Presentation solely because a parent was `lib`.
168
+ if (hit.layer === 'PresentationAdapters' && segments.includes('lib')) {
169
+ // Prefer Persistence when any sibling token smells like data; else skip.
170
+ if (isPersistenceClientPath(posix)) {
171
+ return {
172
+ layer: 'PersistenceAdapters',
173
+ alternatives: [],
174
+ matchedDir: seg,
175
+ };
176
+ }
177
+ continue;
178
+ }
179
+ return { ...hit, matchedDir: seg };
180
+ }
66
181
  }
67
182
  return null;
68
183
  }
@@ -41,6 +41,8 @@ export function reportUnavailableAnalysis({
41
41
  runDoctor(root, config, files, rules, [], args.json, {
42
42
  configPath,
43
43
  configMissing: !fs.existsSync(configPath),
44
+ configRoot: args.configRoot ?? root,
45
+ configWalkedUp: args.configWalkedUp === true,
44
46
  parseHealth,
45
47
  completeness,
46
48
  });