arkgate 4.8.14 → 4.8.15
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 +133 -0
- package/README.md +13 -5
- package/bin/ark-check-runtime.mjs +17 -49
- package/bin/ark-mcp-runtime.mjs +111 -2
- package/bin/ark-shared.mjs +140 -11
- package/bin/ark.mjs +7 -25
- package/bin/lib/adr-presence.mjs +97 -0
- package/bin/lib/agent-skills-package.mjs +179 -1
- package/bin/lib/analysis-completeness.mjs +38 -2
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +26 -4
- package/bin/lib/ark-run-doctor.mjs +6 -0
- package/bin/lib/ark-run-report.mjs +6 -2
- package/bin/lib/arkrules-sensors.mjs +81 -0
- package/bin/lib/baseline-key.mjs +4 -1
- package/bin/lib/check-args.mjs +52 -2
- package/bin/lib/config-contract.mjs +64 -1
- package/bin/lib/diagnostic-catalog.mjs +3 -1
- package/bin/lib/doctor-advisories.mjs +94 -10
- package/bin/lib/doctor-human.mjs +18 -5
- package/bin/lib/doctor-next-actions.mjs +14 -0
- package/bin/lib/doctor-plan.mjs +32 -2
- package/bin/lib/enforcement-honesty.mjs +47 -2
- package/bin/lib/first-run-help.mjs +8 -1
- package/bin/lib/host-support-matrix.mjs +9 -1
- package/bin/lib/html-report-depth.mjs +2 -0
- package/bin/lib/html-report.mjs +13 -2
- package/bin/lib/import-resolve.mjs +74 -13
- package/bin/lib/improvement-compass-doctor.mjs +6 -1
- package/bin/lib/improvement-compass-map.mjs +3 -1
- package/bin/lib/layer-description.mjs +90 -8
- package/bin/lib/mcp-hook-payload.mjs +56 -0
- package/bin/lib/no-domain-frontend.mjs +91 -0
- package/bin/lib/presets.mjs +3 -4
- package/bin/lib/product-copy.mjs +15 -0
- package/bin/lib/projected-governed-coverage.mjs +114 -0
- package/bin/lib/recommend-cli.mjs +54 -0
- package/bin/lib/remediation.mjs +4 -0
- package/bin/lib/resolved-candidate-facts.mjs +83 -66
- package/bin/lib/rules-under-contract.mjs +117 -2
- package/bin/lib/snippet-analysis.mjs +14 -8
- package/bin/lib/start-preview.mjs +5 -2
- package/bin/lib/states-transitions-presence.mjs +212 -0
- package/bin/lib/status-command.mjs +2 -0
- package/bin/lib/status-transition-catalog.mjs +410 -0
- package/bin/lib/team-parliament-io.mjs +10 -0
- package/bin/lib/violations.mjs +13 -1
- package/bin/lib/write-path-capabilities.mjs +20 -5
- package/bin/lib/write-path-detect.mjs +27 -2
- package/dist/{configTypes-j7so8B4O.d.ts → configTypes-Dt3DpVbd.d.ts} +19 -0
- package/dist/{diagnosticCatalog-DVx_2RmF.d.ts → diagnosticCatalog-BEg85XlE.d.ts} +3 -3
- package/dist/eslint/index.cjs +4 -4
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.js +4 -4
- package/dist/index.cjs +31 -31
- package/dist/index.d.ts +70 -9
- package/dist/index.js +31 -31
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +15 -15
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +15 -15
- package/dist/{types-Djbs3KjE.d.ts → types-CN9tVMPz.d.ts} +3 -1
- package/dist/{types-tGhZUiGX.d.ts → types-TBiv0WHL.d.ts} +1 -1
- package/docs/README.md +1 -1
- package/docs/agent-guide.md +6 -4
- package/docs/ai-gates.md +10 -3
- package/docs/brownfield-adoption.md +4 -1
- package/docs/configuration.md +55 -7
- package/docs/develop.md +1 -0
- package/docs/diagnostics.md +23 -3
- package/docs/enthusiast/how-to-agent-gates.md +5 -0
- package/docs/enthusiast/how-to-pick-shape.md +1 -1
- package/docs/package-surface.md +7 -5
- package/docs/use.md +5 -0
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +23 -2
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +82 -1
- package/templates/agent-skills/ark-autopilot/SKILL.md +6 -2
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
- package/templates/agent-skills/ark-explain/SKILL.md +4 -2
- package/templates/agent-skills/ark-explore/SKILL.md +14 -1
- package/templates/agent-skills/ark-place/SKILL.md +38 -3
- package/templates/agent-skills/ark-runtime/SKILL.md +4 -2
- package/templates/agent-skills/ark-upgrade/SKILL.md +1 -1
- package/templates/arkrules/DomainModel.json +14 -1
- package/templates/skills/ark-adopt.md +82 -1
- package/templates/skills/ark-autopilot.md +6 -2
- package/templates/skills/ark-coverage.md +1 -1
- package/templates/skills/ark-explain.md +4 -2
- package/templates/skills/ark-explore.md +14 -1
- package/templates/skills/ark-place.md +38 -3
- package/templates/skills/ark-runtime.md +4 -2
- package/templates/skills/ark-upgrade.md +1 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Summary includes per-layer + structure/invariant detail so showcase HTML /ark-explain
|
|
5
5
|
* can teach what is under contract, not only aggregate numbers.
|
|
6
6
|
*/
|
|
7
|
+
import path from 'node:path';
|
|
7
8
|
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
8
9
|
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
9
10
|
import {
|
|
@@ -16,6 +17,15 @@ import {
|
|
|
16
17
|
composeMergePlanesHonesty,
|
|
17
18
|
demoteExtraPlaneTeethUnderClassificationFloor,
|
|
18
19
|
} from './extra-merge-teeth.mjs';
|
|
20
|
+
import { collectEmptyInvariantCatalogFindings } from './arkrules-sensors.mjs';
|
|
21
|
+
import { layerForRelativePath } from '../ark-layer-match.mjs';
|
|
22
|
+
import {
|
|
23
|
+
ARKRULES_EMPTY_CATALOG_NEXT,
|
|
24
|
+
ARKRULES_FIRST_CONTACT_NEXT,
|
|
25
|
+
ARKRULES_ONE_BREATH,
|
|
26
|
+
} from './product-copy.mjs';
|
|
27
|
+
|
|
28
|
+
export { ARKRULES_EMPTY_CATALOG_NEXT, ARKRULES_FIRST_CONTACT_NEXT, ARKRULES_ONE_BREATH };
|
|
19
29
|
|
|
20
30
|
/**
|
|
21
31
|
* Cap long catalogs in doctor JSON (and HTML, which consumes the same summary).
|
|
@@ -27,6 +37,18 @@ const UNCOVERED_CATALOG_MAX = 30;
|
|
|
27
37
|
|
|
28
38
|
export { EXTRA_MERGE_TEETH_GOVERNED_FLOOR };
|
|
29
39
|
|
|
40
|
+
/** Doctor walk often hands absolute paths; layer globs are project-relative. */
|
|
41
|
+
function projectRelativePath(root, filePath) {
|
|
42
|
+
const posix = String(filePath).replace(/\\/g, '/');
|
|
43
|
+
if (path.isAbsolute(posix) && typeof root === 'string' && root.length > 0) {
|
|
44
|
+
const relative = path.relative(root, posix).replace(/\\/g, '/');
|
|
45
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
46
|
+
return relative;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return posix.replace(/^\.\//, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
/**
|
|
31
53
|
* P1M / extraMergeTeeth: under the classification floor, demote enforced ArkRules
|
|
32
54
|
* and ArkRun findings so merge matches doctor stamp (layer graph only).
|
|
@@ -186,6 +208,25 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
186
208
|
const invariantAdvisory = invariants - invariantEnforced;
|
|
187
209
|
const coveredInvariants = coverage.coverage.filter((c) => c.covered).length;
|
|
188
210
|
const uncoveredInvariants = coverage.coverage.filter((c) => !c.covered).length;
|
|
211
|
+
const catalogFiles = Array.isArray(facts?.files)
|
|
212
|
+
? facts.files
|
|
213
|
+
.map((entry) => {
|
|
214
|
+
const raw = typeof entry?.path === 'string' ? entry.path : '';
|
|
215
|
+
if (!raw) return null;
|
|
216
|
+
const relative = projectRelativePath(root, raw);
|
|
217
|
+
return {
|
|
218
|
+
path: relative,
|
|
219
|
+
layer: layerForRelativePath(relative, config.layers ?? []),
|
|
220
|
+
};
|
|
221
|
+
})
|
|
222
|
+
.filter(Boolean)
|
|
223
|
+
: [];
|
|
224
|
+
const emptyCatalogFinding = collectEmptyInvariantCatalogFindings({
|
|
225
|
+
arkRulesActive: true,
|
|
226
|
+
arkRules: loaded.arkRules,
|
|
227
|
+
layers: config.layers ?? [],
|
|
228
|
+
files: catalogFiles,
|
|
229
|
+
})[0];
|
|
189
230
|
const mergePlanes = composeMergePlanesHonesty({
|
|
190
231
|
classification,
|
|
191
232
|
arkRules: {
|
|
@@ -221,6 +262,14 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
221
262
|
mergePlanes,
|
|
222
263
|
notAScore: true,
|
|
223
264
|
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.',
|
|
265
|
+
...(emptyCatalogFinding
|
|
266
|
+
? {
|
|
267
|
+
emptyInvariantCatalog: true,
|
|
268
|
+
catalogFillPath: emptyCatalogFinding.arkruleSource,
|
|
269
|
+
catalogFillLayer: emptyCatalogFinding.fromLayer,
|
|
270
|
+
catalogFailsStrict: emptyCatalogFinding.failsStrict === true,
|
|
271
|
+
}
|
|
272
|
+
: { emptyInvariantCatalog: false }),
|
|
224
273
|
};
|
|
225
274
|
} catch (error) {
|
|
226
275
|
return {
|
|
@@ -231,6 +280,61 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
231
280
|
}
|
|
232
281
|
}
|
|
233
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Compact / details doctor lines. Empty when the map is off — absence is silent.
|
|
285
|
+
* @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
|
|
286
|
+
* @returns {string[]}
|
|
287
|
+
*/
|
|
288
|
+
export function formatArkRulesDoctorLines(section) {
|
|
289
|
+
if (!section || typeof section !== 'object') return [];
|
|
290
|
+
if (section.active !== true) return [];
|
|
291
|
+
|
|
292
|
+
if (Array.isArray(section.loadErrors) && section.loadErrors.length > 0) {
|
|
293
|
+
const first = section.loadErrors[0];
|
|
294
|
+
const detail =
|
|
295
|
+
typeof first?.message === 'string' && first.message.length > 0
|
|
296
|
+
? first.message
|
|
297
|
+
: 'Fix the path in arkRules.';
|
|
298
|
+
return [
|
|
299
|
+
ARKRULES_ONE_BREATH,
|
|
300
|
+
`ArkRules: the rules file failed to load — a full check will refuse. ${detail}`,
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const structure = Number(section.structureRules) || 0;
|
|
305
|
+
const invariants = Number(section.invariants) || 0;
|
|
306
|
+
const uncovered = Number(section.uncoveredInvariants) || 0;
|
|
307
|
+
const enforced =
|
|
308
|
+
(Number(section.mergePlanes?.structureSensors?.enforced) || 0) +
|
|
309
|
+
(Number(section.mergePlanes?.invariants?.enforced) || 0);
|
|
310
|
+
const teeth =
|
|
311
|
+
enforced > 0
|
|
312
|
+
? 'some enforced'
|
|
313
|
+
: 'advisory only — does not fail the merge';
|
|
314
|
+
const lines = [
|
|
315
|
+
ARKRULES_ONE_BREATH,
|
|
316
|
+
`ArkRules: on · structure=${structure} · invariants=${invariants} · uncovered=${uncovered} · ${teeth} · not a score`,
|
|
317
|
+
];
|
|
318
|
+
if (section.emptyInvariantCatalog === true) {
|
|
319
|
+
const fill =
|
|
320
|
+
typeof section.catalogFillPath === 'string' && section.catalogFillPath.length > 0
|
|
321
|
+
? section.catalogFillPath
|
|
322
|
+
: 'arkrules/<Domain>.json';
|
|
323
|
+
const layer =
|
|
324
|
+
typeof section.catalogFillLayer === 'string' && section.catalogFillLayer.length > 0
|
|
325
|
+
? section.catalogFillLayer
|
|
326
|
+
: 'Domain';
|
|
327
|
+
lines.push(
|
|
328
|
+
`ArkRules: ${layer} has code, but invariants[] is empty — add 1–2 short phrases in ${fill}.`
|
|
329
|
+
);
|
|
330
|
+
lines.push(ARKRULES_EMPTY_CATALOG_NEXT);
|
|
331
|
+
} else if (uncovered > 0) lines.push(ARKRULES_FIRST_CONTACT_NEXT);
|
|
332
|
+
else if (typeof section.note === 'string' && /failed/i.test(section.note)) {
|
|
333
|
+
lines.push(`ArkRules: ${section.note}`);
|
|
334
|
+
}
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
|
|
234
338
|
/**
|
|
235
339
|
* Showcase HTML for the ArkRules plane (used by html-report-advisories).
|
|
236
340
|
* @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
|
|
@@ -347,12 +451,23 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
347
451
|
uncoveredOverflow > 0
|
|
348
452
|
? `<p class="muted">…(+${uncoveredOverflow} more uncovered)</p>`
|
|
349
453
|
: '';
|
|
454
|
+
const emptyCatalogBlock =
|
|
455
|
+
section.emptyInvariantCatalog === true
|
|
456
|
+
? `<p class="tag warn" style="margin-top:.55rem">Domain has code, but <code>invariants[]</code> is empty — that is not done.
|
|
457
|
+
Add 1–2 short phrases in <code>${escape(section.catalogFillPath || 'arkrules/<Domain>.json')}</code>.
|
|
458
|
+
${
|
|
459
|
+
section.catalogFailsStrict === true
|
|
460
|
+
? 'A domain structure rule is already enforced, so <code>--strict-merge</code> can refuse.'
|
|
461
|
+
: 'Advisory — does not fail the merge until a domain structure rule is enforced.'
|
|
462
|
+
}</p>`
|
|
463
|
+
: '';
|
|
350
464
|
const uncoveredBlock =
|
|
465
|
+
emptyCatalogBlock ||
|
|
351
466
|
// Aggregate total (not the truncated array length) decides "all covered".
|
|
352
|
-
Number(section.uncoveredInvariants) === 0 && uncovered.length === 0
|
|
467
|
+
(Number(section.uncoveredInvariants) === 0 && uncovered.length === 0
|
|
353
468
|
? `<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>`
|
|
354
469
|
: `<h3 style="margin-top:.9rem;font-size:.95rem">Uncovered invariants</h3>
|
|
355
|
-
<ul class="senior-list">${uncoveredItems}</ul>${uncoveredMore}
|
|
470
|
+
<ul class="senior-list">${uncoveredItems}</ul>${uncoveredMore}`);
|
|
356
471
|
|
|
357
472
|
const coveredItems = coveredSample
|
|
358
473
|
.map(
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
import { layerForRelativePath } from '../ark-layer-match.mjs';
|
|
3
3
|
import { ANALYSIS_COMPLETENESS } from './analysis-completeness.mjs';
|
|
4
4
|
import { evaluateArkRunEditorSensorsFromSource } from './ark-run-sensors.mjs';
|
|
5
|
+
import { getDiagnosticCatalogEntry } from './diagnostic-catalog.mjs';
|
|
6
|
+
|
|
7
|
+
function lexicalEvidenceIncompleteMessage(file) {
|
|
8
|
+
const entry = getDiagnosticCatalogEntry('LEXICAL_EVIDENCE_INCOMPLETE');
|
|
9
|
+
const text = [entry?.why, entry?.fix].filter(Boolean).join(' ');
|
|
10
|
+
return {
|
|
11
|
+
code: 'LEXICAL_EVIDENCE_INCOMPLETE',
|
|
12
|
+
message:
|
|
13
|
+
text ||
|
|
14
|
+
'This check only saw one file, so it cannot fully prove how the import resolves. The result is provisional — `ark-check` on the project is the authority. Run `npx arkgate-check --root . --config ark.config.json` to confirm. Do not call ark_prepare_change from a hook deny.',
|
|
15
|
+
...(file ? { file } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
5
18
|
|
|
6
19
|
export function flattenTsParseDiagnostics(ts, diagnostics, sourceFile) {
|
|
7
20
|
if (!Array.isArray(diagnostics) || !ts) return [];
|
|
@@ -156,14 +169,7 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
|
156
169
|
valid: false,
|
|
157
170
|
lexicalValid: base.valid,
|
|
158
171
|
completeness: ANALYSIS_COMPLETENESS.partial,
|
|
159
|
-
completenessReasons: [
|
|
160
|
-
{
|
|
161
|
-
code: 'LEXICAL_EVIDENCE_INCOMPLETE',
|
|
162
|
-
message:
|
|
163
|
-
'Single-file validation cannot prove project module resolution. The write hook is already the verdict, or re-run `npx arkgate-check --root . --config ark.config.json`. Do not call ark_prepare_change from a hook deny.',
|
|
164
|
-
...(file ? { file } : {}),
|
|
165
|
-
},
|
|
166
|
-
],
|
|
172
|
+
completenessReasons: [lexicalEvidenceIncompleteMessage(file)],
|
|
167
173
|
};
|
|
168
174
|
} catch {
|
|
169
175
|
return {
|
|
@@ -3,7 +3,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { arkCommand, buildArchitectureRecommendation } from '../ark-shared.mjs';
|
|
6
|
+
import { arkCommand, buildArchitectureRecommendation, START_APPLY_REFUSE_FOOTER } from '../ark-shared.mjs';
|
|
7
|
+
import { withProjectedGovernedCoverage } from './projected-governed-coverage.mjs';
|
|
7
8
|
import { ARKORDER_FIRST_CONTACT_NEXT, ARKORDER_ONE_BREATH } from './ark-order-doctor.mjs';
|
|
8
9
|
import { compactAgentInstructions, instructionRule, mcpJson } from './ci-and-commands.mjs';
|
|
9
10
|
import {
|
|
@@ -166,6 +167,7 @@ export function renderStartPreview(preview, options = {}) {
|
|
|
166
167
|
console.log('Optional extras stay off. This start is layers only — they stop bad imports.');
|
|
167
168
|
console.log(ARKORDER_ONE_BREATH);
|
|
168
169
|
console.log(ARKORDER_FIRST_CONTACT_NEXT);
|
|
170
|
+
console.log(START_APPLY_REFUSE_FOOTER);
|
|
169
171
|
}
|
|
170
172
|
if (preview.runtimeActivation) {
|
|
171
173
|
console.log('Host: Codex is configured but not verified yet. Restart the host, then confirm this project.');
|
|
@@ -325,12 +327,13 @@ export async function planStart(args, helpers) {
|
|
|
325
327
|
};
|
|
326
328
|
} else {
|
|
327
329
|
try {
|
|
328
|
-
const rec = buildArchitectureRecommendation(root);
|
|
330
|
+
const rec = withProjectedGovernedCoverage(buildArchitectureRecommendation(root), root);
|
|
329
331
|
recommendation = {
|
|
330
332
|
archetype: rec.archetype,
|
|
331
333
|
label: rec.label,
|
|
332
334
|
confidence: rec.confidence,
|
|
333
335
|
mature: rec.mature,
|
|
336
|
+
projectedGovernedCoverage: rec.signals?.projectedGovernedCoverage,
|
|
334
337
|
};
|
|
335
338
|
} catch {
|
|
336
339
|
recommendation = null;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft states/transitions artifact guidance (P2 §2 / Guiar).
|
|
3
|
+
* Tooling I/O. Never a gate fail. Silent unless a conventional domain
|
|
4
|
+
* (or dedicated states) markdown home is already in play.
|
|
5
|
+
* Does not walk the tree. Does not scan code for status fields.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** Product-domain / domain-model homes. Closed list — no tree walk. */
|
|
12
|
+
export const STATES_TRANSITIONS_DOMAIN_HOMES = Object.freeze([
|
|
13
|
+
'docs/domain.md',
|
|
14
|
+
'docs/data-model.md',
|
|
15
|
+
'docs/product-domain.md',
|
|
16
|
+
'docs/modelo-de-dominio.md',
|
|
17
|
+
'docs/producto-y-dominio.md',
|
|
18
|
+
'docs/architecture.md',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** Evolved dedicated homes. Adopt if present; do not force a filename. */
|
|
22
|
+
export const STATES_TRANSITIONS_DEDICATED_HOMES = Object.freeze([
|
|
23
|
+
'docs/states.md',
|
|
24
|
+
'docs/transitions.md',
|
|
25
|
+
'docs/states-transitions.md',
|
|
26
|
+
'docs/lifecycle.md',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const STATES_TRANSITIONS_ASK_MISSING =
|
|
30
|
+
'A domain doc is in play, but there is no states → transitions map yet.';
|
|
31
|
+
|
|
32
|
+
export const STATES_TRANSITIONS_ASK_INCOMPLETE =
|
|
33
|
+
'The states heading is there, but it is not a map yet — no table or link.';
|
|
34
|
+
|
|
35
|
+
export const STATES_TRANSITIONS_NEXT =
|
|
36
|
+
'Add a short table (entity · states · allowed from → to) — or one link — on the domain doc. Names from the code. No flag soup.';
|
|
37
|
+
|
|
38
|
+
function readMarkdown(abs) {
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
|
|
41
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
42
|
+
return text.trim().length > 0 ? text : '';
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cellIsStateVocab(cell) {
|
|
49
|
+
return (
|
|
50
|
+
cell === 'state' ||
|
|
51
|
+
cell === 'states' ||
|
|
52
|
+
cell.startsWith('state ') ||
|
|
53
|
+
cell.endsWith(' state') ||
|
|
54
|
+
cell.endsWith(' states')
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cellIsTransitionVocab(cell) {
|
|
59
|
+
return (
|
|
60
|
+
cell.includes('transition') ||
|
|
61
|
+
cell === 'from' ||
|
|
62
|
+
cell === 'to' ||
|
|
63
|
+
cell === 'from → to' ||
|
|
64
|
+
cell === 'from -> to'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cellIsEntityVocab(cell) {
|
|
69
|
+
return cell === 'entity' || cell === 'entities';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Closed table: entity | states | allowed transitions (or from → to). */
|
|
73
|
+
export function markdownHasStatesTable(text) {
|
|
74
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
75
|
+
for (const line of text.split('\n')) {
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
if (!trimmed.startsWith('|')) continue;
|
|
78
|
+
const cells = trimmed
|
|
79
|
+
.split('|')
|
|
80
|
+
.map((cell) => cell.trim().toLowerCase())
|
|
81
|
+
.filter((cell) => cell.length > 0 && !/^[-:]+$/.test(cell));
|
|
82
|
+
if (cells.length < 2) continue;
|
|
83
|
+
const hasEntity = cells.some((cell) => cellIsEntityVocab(cell));
|
|
84
|
+
const hasState = cells.some((cell) => cellIsStateVocab(cell));
|
|
85
|
+
const hasTransition = cells.some((cell) => cellIsTransitionVocab(cell));
|
|
86
|
+
if ((hasState && hasTransition) || (hasEntity && (hasState || hasTransition))) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mentionsStatesTopic(value) {
|
|
92
|
+
return (
|
|
93
|
+
value.includes('transition') ||
|
|
94
|
+
value.includes('lifecycle') ||
|
|
95
|
+
value.includes('states/transitions') ||
|
|
96
|
+
value.includes('states-transitions') ||
|
|
97
|
+
value.includes('state machine')
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One link to the captain’s existing authority. */
|
|
102
|
+
export function markdownHasStatesLink(text) {
|
|
103
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
104
|
+
let from = 0;
|
|
105
|
+
while (from < text.length) {
|
|
106
|
+
const open = text.indexOf('[', from);
|
|
107
|
+
if (open < 0) break;
|
|
108
|
+
const mid = text.indexOf('](', open + 1);
|
|
109
|
+
if (mid < 0) break;
|
|
110
|
+
const close = text.indexOf(')', mid + 2);
|
|
111
|
+
if (close < 0) break;
|
|
112
|
+
const label = text.slice(open + 1, mid).toLowerCase();
|
|
113
|
+
const href = text.slice(mid + 2, close).toLowerCase();
|
|
114
|
+
if (mentionsStatesTopic(label) || mentionsStatesTopic(href)) return true;
|
|
115
|
+
from = close + 1;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function markdownHasStatesHeading(text) {
|
|
121
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
122
|
+
for (const line of text.split('\n')) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (!trimmed.startsWith('#')) continue;
|
|
125
|
+
const title = trimmed.replace(/^#+\s*/, '').toLowerCase();
|
|
126
|
+
if (
|
|
127
|
+
title === 'state' ||
|
|
128
|
+
title === 'states' ||
|
|
129
|
+
title === 'transition' ||
|
|
130
|
+
title === 'transitions' ||
|
|
131
|
+
title === 'lifecycle' ||
|
|
132
|
+
title.includes('states/transitions') ||
|
|
133
|
+
title.includes('states and transitions')
|
|
134
|
+
) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scoreMarkdown(text) {
|
|
142
|
+
if (text == null) return 'absent';
|
|
143
|
+
if (text === '') return 'incomplete';
|
|
144
|
+
if (markdownHasStatesTable(text) || markdownHasStatesLink(text)) return 'present';
|
|
145
|
+
if (markdownHasStatesHeading(text)) return 'incomplete';
|
|
146
|
+
return 'missing';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function existingHomes(root, relatives) {
|
|
150
|
+
const found = [];
|
|
151
|
+
for (const rel of relatives) {
|
|
152
|
+
const text = readMarkdown(path.join(root, rel));
|
|
153
|
+
if (text == null) continue;
|
|
154
|
+
found.push({ rel, score: scoreMarkdown(text) });
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* First conventional home already on disk (domain doc or dedicated file).
|
|
161
|
+
* Empty / missing files do not count as a home.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} root
|
|
164
|
+
* @returns {string | null}
|
|
165
|
+
*/
|
|
166
|
+
export function findStatesTransitionsHome(root) {
|
|
167
|
+
if (typeof root !== 'string' || root.length === 0) return null;
|
|
168
|
+
const homes = [
|
|
169
|
+
...existingHomes(root, STATES_TRANSITIONS_DOMAIN_HOMES),
|
|
170
|
+
...existingHomes(root, STATES_TRANSITIONS_DEDICATED_HOMES),
|
|
171
|
+
];
|
|
172
|
+
return homes[0]?.rel ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function nextActionFor(home) {
|
|
176
|
+
if (!home) return STATES_TRANSITIONS_NEXT;
|
|
177
|
+
return `Add a short table (entity · states · allowed from → to) — or one link — to ${home}. Names from the code. No flag soup.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Soft residual when a domain (or dedicated states) doc is in play and the
|
|
182
|
+
* map is missing or only a heading. Off / no such doc → null (silent).
|
|
183
|
+
*
|
|
184
|
+
* @param {{ root?: string }} [input]
|
|
185
|
+
* @returns {{ kind: 'missing' | 'incomplete', home: string, ask: string, nextAction: string } | null}
|
|
186
|
+
*/
|
|
187
|
+
export function collectStatesTransitionsResidual(input = {}) {
|
|
188
|
+
const root = input.root ?? '';
|
|
189
|
+
if (typeof root !== 'string' || root.length === 0) return null;
|
|
190
|
+
const homes = [
|
|
191
|
+
...existingHomes(root, STATES_TRANSITIONS_DOMAIN_HOMES),
|
|
192
|
+
...existingHomes(root, STATES_TRANSITIONS_DEDICATED_HOMES),
|
|
193
|
+
];
|
|
194
|
+
if (homes.length === 0) return null;
|
|
195
|
+
if (homes.some((row) => row.score === 'present')) return null;
|
|
196
|
+
const incomplete = homes.find((row) => row.score === 'incomplete');
|
|
197
|
+
const home = (incomplete ?? homes[0]).rel;
|
|
198
|
+
if (incomplete) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'incomplete',
|
|
201
|
+
home,
|
|
202
|
+
ask: STATES_TRANSITIONS_ASK_INCOMPLETE,
|
|
203
|
+
nextAction: nextActionFor(home),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
kind: 'missing',
|
|
208
|
+
home,
|
|
209
|
+
ask: STATES_TRANSITIONS_ASK_MISSING,
|
|
210
|
+
nextAction: nextActionFor(home),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -306,6 +306,8 @@ export function collectStatusFacts(options = {}) {
|
|
|
306
306
|
const softWriteHost = Boolean(matrix && matrix.capabilities?.['hard-write'] !== true);
|
|
307
307
|
const writePathHonesty = buildWritePathHonesty(hostKey, hardWriteActive, {
|
|
308
308
|
packageInstalled: writePath?.enforcementState?.localWrite?.installed !== false,
|
|
309
|
+
nativeFailClosed: writePath?.nativeFailClosed,
|
|
310
|
+
nativeFailClosedPolicy: writePath?.nativeFailClosedPolicy,
|
|
309
311
|
});
|
|
310
312
|
|
|
311
313
|
const latestPath = path.join(reportsDir(resolvedRoot), 'latest.json');
|