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.
- package/CHANGELOG.md +142 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +148 -8
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +277 -59
- package/bin/lib/enforcement-honesty.mjs +351 -26
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/graph-blind.mjs +1 -1
- package/bin/lib/html-report-advisories.mjs +8 -25
- package/bin/lib/html-report-depth.mjs +167 -3
- package/bin/lib/html-report.mjs +12 -5
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +100 -1
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +389 -5
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/upgrade-command.mjs +325 -14
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +3 -2
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +38 -0
- package/docs/configuration.md +59 -7
- package/docs/package-surface.md +3 -2
- package/docs/product-voice.md +10 -1
- package/docs/typescript-support.md +9 -5
- package/docs/use.md +7 -5
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +23 -5
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
- package/templates/skills/ark-upgrade.md +57 -11
|
@@ -1,17 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AR12 — doctor/HTML "Rules under contract" counts
|
|
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
|
|
53
|
-
uncoveredInvariants
|
|
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: '
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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:
|
|
231
|
-
label:
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
}
|
|
236
|
-
|
|
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',
|
package/bin/lib/suggestions.mjs
CHANGED
|
@@ -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
|
|
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
|
|
65
|
-
|
|
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
|
});
|