arkgate 4.8.14 → 4.8.16
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 +172 -0
- package/README.md +17 -7
- package/bin/ark-check-runtime.mjs +17 -49
- package/bin/ark-mcp-runtime.mjs +111 -2
- package/bin/ark-shared.mjs +142 -11
- package/bin/ark.mjs +24 -33
- 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 +43 -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 +6 -1
- package/bin/lib/check-args.mjs +52 -2
- package/bin/lib/config-contract.mjs +65 -2
- package/bin/lib/diagnostic-catalog.mjs +5 -1
- package/bin/lib/doctor-advisories.mjs +120 -10
- package/bin/lib/doctor-green-cite.mjs +139 -0
- package/bin/lib/doctor-human.mjs +115 -67
- package/bin/lib/doctor-next-actions.mjs +23 -0
- package/bin/lib/doctor-plan.mjs +35 -2
- package/bin/lib/enforcement-honesty.mjs +47 -2
- package/bin/lib/field-install.mjs +47 -7
- package/bin/lib/first-run-help.mjs +9 -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 +8 -1
- package/bin/lib/improvement-compass-map.mjs +5 -1
- package/bin/lib/invariant-coverage.mjs +121 -0
- package/bin/lib/invariant-tests-path.mjs +212 -0
- 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/package-manager.mjs +8 -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/prototype-shortcuts.mjs +224 -0
- package/bin/lib/recommend-cli.mjs +54 -0
- package/bin/lib/remediation.mjs +15 -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 +24 -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-VD0qcubY.d.ts} +21 -1
- package/dist/{diagnosticCatalog-DVx_2RmF.d.ts → diagnosticCatalog-KWvGLI1U.d.ts} +24 -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 +34 -34
- package/dist/index.d.ts +71 -10
- package/dist/index.js +34 -34
- 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-tGhZUiGX.d.ts → types-BSzRy2X1.d.ts} +1 -1
- package/dist/{types-Djbs3KjE.d.ts → types-D5GT5ZT8.d.ts} +3 -1
- package/docs/README.md +1 -1
- package/docs/agent-guide.md +8 -4
- package/docs/ai-gates.md +10 -3
- package/docs/brownfield-adoption.md +4 -1
- package/docs/configuration.md +66 -10
- package/docs/develop.md +4 -1
- package/docs/diagnostics.md +48 -4
- 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 +10 -3
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +24 -3
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +97 -1
- package/templates/agent-skills/ark-autopilot/SKILL.md +12 -3
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -2
- package/templates/agent-skills/ark-explain/SKILL.md +6 -3
- package/templates/agent-skills/ark-explore/SKILL.md +33 -1
- package/templates/agent-skills/ark-place/SKILL.md +42 -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 +97 -1
- package/templates/skills/ark-autopilot.md +12 -3
- package/templates/skills/ark-coverage.md +2 -2
- package/templates/skills/ark-explain.md +6 -3
- package/templates/skills/ark-explore.md +33 -1
- package/templates/skills/ark-place.md +42 -3
- package/templates/skills/ark-runtime.md +4 -2
- package/templates/skills/ark-upgrade.md +1 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft prototype-shortcut residual (P2 §3 / Guiar).
|
|
3
|
+
* Tooling I/O. Never a gate fail.
|
|
4
|
+
*
|
|
5
|
+
* SQLite / JSON-file stores standing in for a declared Persistence home,
|
|
6
|
+
* and admin / god-mode literals sitting outside Domain or an auth tag.
|
|
7
|
+
* Silent when the contract does not imply those houses, the shortcut is
|
|
8
|
+
* already in the right house, or the tree has no such markers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { layerForFile } from '../ark-shared.mjs';
|
|
14
|
+
import { isDomainRoleLayerName } from './arkrules-sensors.mjs';
|
|
15
|
+
import { isNonProductionPilotPath } from './design-smells.mjs';
|
|
16
|
+
|
|
17
|
+
export const PROTOTYPE_KIND = Object.freeze({
|
|
18
|
+
SQLITE: 'sqlite',
|
|
19
|
+
JSON_FILE: 'json-file',
|
|
20
|
+
ADMIN: 'admin-literals',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const PROTOTYPE_SQLITE_ASK =
|
|
24
|
+
'SQLite is the standing store, and it is not in Persistence.';
|
|
25
|
+
|
|
26
|
+
export const PROTOTYPE_JSON_ASK =
|
|
27
|
+
'A JSON file is standing in for Persistence.';
|
|
28
|
+
|
|
29
|
+
export const PROTOTYPE_ADMIN_ASK =
|
|
30
|
+
'Admin / god-mode literals sit outside Domain (or an auth tag).';
|
|
31
|
+
|
|
32
|
+
export const PROTOTYPE_PACK_ASK =
|
|
33
|
+
'Prototype shortcuts are still standing in for Persistence or Domain policy.';
|
|
34
|
+
|
|
35
|
+
export const PROTOTYPE_PERSISTENCE_NEXT =
|
|
36
|
+
'Create a Persistence adapter (/ark-place), then move the file store behind it (/ark-autopilot). Do not harden the shortcut.';
|
|
37
|
+
|
|
38
|
+
export const PROTOTYPE_ADMIN_NEXT =
|
|
39
|
+
'Move admin / god-mode literals behind Domain or an auth tag (/ark-place). Then one small refactor with /ark-autopilot.';
|
|
40
|
+
|
|
41
|
+
export const PROTOTYPE_PACK_NEXT =
|
|
42
|
+
'Create a Persistence adapter (/ark-place) for the file store, and move admin literals behind Domain or an auth tag. Then one small refactor with /ark-autopilot.';
|
|
43
|
+
|
|
44
|
+
const SOURCE_EXT = /\.(?:ts|tsx|js|jsx|mts|cts)$/;
|
|
45
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
46
|
+
const MAX_SCAN_FILES = 800;
|
|
47
|
+
|
|
48
|
+
const SQLITE_IMPORT_RE =
|
|
49
|
+
/\b(?:from|require\()\s*['"](?:better-sqlite3|sqlite3|sql\.js|bun:sqlite|node:sqlite)['"]/;
|
|
50
|
+
|
|
51
|
+
const JSON_DB_IMPORT_RE = /\b(?:from|require\()\s*['"](?:lowdb|node-json-db)['"]/;
|
|
52
|
+
|
|
53
|
+
const JSON_DB_PATH_RE =
|
|
54
|
+
/['"`](?:[^'"`]*[\\/])?(?:db|database|store|data-store|dump)\.json['"`]/i;
|
|
55
|
+
|
|
56
|
+
const FS_OR_JSON_RE = /\b(?:readFile|writeFile|readFileSync|writeFileSync|JSON\.parse|JSON\.stringify)\b/;
|
|
57
|
+
|
|
58
|
+
const PRIVILEGE_LITERAL_RE =
|
|
59
|
+
/['"](?:admin|superadmin|super-admin|superuser|god|god-mode|godmode)['"]/i;
|
|
60
|
+
|
|
61
|
+
const PRIVILEGE_CONTEXT_RE =
|
|
62
|
+
/\b(?:role|roles|privilege|permission|permissions|acl|isAdmin|isSuperuser|godMode|GOD_MODE)\b/;
|
|
63
|
+
|
|
64
|
+
const HARDCODED_ADMIN_RE = /\bisAdmin\s*=\s*true\b/;
|
|
65
|
+
|
|
66
|
+
const GOD_IDENT_RE = /\b(?:isGodMode|godMode|GOD_MODE|isSuperuser)\b/;
|
|
67
|
+
|
|
68
|
+
/** Persistence-role house by name. Align with design-smells persistence heuristic. */
|
|
69
|
+
export function isPersistenceRoleLayerName(name) {
|
|
70
|
+
return typeof name === 'string' && /persist|repository|data.?access/i.test(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function configLayers(input) {
|
|
74
|
+
return Array.isArray(input.config?.layers) ? input.config.layers : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stripComments(source) {
|
|
78
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readTextLimited(absPath) {
|
|
82
|
+
try {
|
|
83
|
+
const st = fs.statSync(absPath);
|
|
84
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) return null;
|
|
85
|
+
return fs.readFileSync(absPath, 'utf8');
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeRel(root, filePath) {
|
|
92
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
93
|
+
let rel = path.relative(root, abs).split(path.sep).join('/');
|
|
94
|
+
if (rel.startsWith('./')) rel = rel.slice(2);
|
|
95
|
+
return { abs, rel };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasSqliteShortcut(source) {
|
|
99
|
+
return SQLITE_IMPORT_RE.test(source);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasJsonFileShortcut(source) {
|
|
103
|
+
if (JSON_DB_IMPORT_RE.test(source)) return true;
|
|
104
|
+
return JSON_DB_PATH_RE.test(source) && FS_OR_JSON_RE.test(source);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasAdminLiteral(source) {
|
|
108
|
+
if (HARDCODED_ADMIN_RE.test(source) || GOD_IDENT_RE.test(source)) return true;
|
|
109
|
+
return PRIVILEGE_LITERAL_RE.test(source) && PRIVILEGE_CONTEXT_RE.test(source);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function layerNameFor(root, rel, config) {
|
|
113
|
+
try {
|
|
114
|
+
return layerForFile(root, rel, config?.layers ?? []) ?? null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function layerByName(config, name) {
|
|
121
|
+
return configLayers({ config }).find((layer) => layer.name === name) ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isTrustPolicyLayer(layer) {
|
|
125
|
+
const tag = layer?.trustBoundary;
|
|
126
|
+
return tag === 'auth' || tag === 'admin';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function composeAsk(kinds) {
|
|
130
|
+
if (kinds.length === 1) {
|
|
131
|
+
if (kinds[0] === PROTOTYPE_KIND.SQLITE) return PROTOTYPE_SQLITE_ASK;
|
|
132
|
+
if (kinds[0] === PROTOTYPE_KIND.JSON_FILE) return PROTOTYPE_JSON_ASK;
|
|
133
|
+
return PROTOTYPE_ADMIN_ASK;
|
|
134
|
+
}
|
|
135
|
+
return PROTOTYPE_PACK_ASK;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function composeNext(kinds) {
|
|
139
|
+
const persist = kinds.includes(PROTOTYPE_KIND.SQLITE) || kinds.includes(PROTOTYPE_KIND.JSON_FILE);
|
|
140
|
+
const admin = kinds.includes(PROTOTYPE_KIND.ADMIN);
|
|
141
|
+
if (persist && admin) return PROTOTYPE_PACK_NEXT;
|
|
142
|
+
if (admin) return PROTOTYPE_ADMIN_NEXT;
|
|
143
|
+
return PROTOTYPE_PERSISTENCE_NEXT;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Soft residual when prototype stores or privilege literals are standing in
|
|
148
|
+
* for a declared Persistence / Domain (or auth) house. Never flips valid.
|
|
149
|
+
*
|
|
150
|
+
* @param {{ root?: string, config?: object, coverage?: object, files?: readonly string[] }} [input]
|
|
151
|
+
* @returns {{ kinds: string[], ask: string, nextAction: string, evidence: string[], persistenceLayers: string[], domainLayers: string[] } | null}
|
|
152
|
+
*/
|
|
153
|
+
export function collectPrototypeShortcutsResidual(input = {}) {
|
|
154
|
+
const declared = configLayers(input);
|
|
155
|
+
const persistenceLayers = declared.filter((layer) => isPersistenceRoleLayerName(layer.name));
|
|
156
|
+
const domainLayers = declared.filter((layer) =>
|
|
157
|
+
isDomainRoleLayerName(layer.name, layer.intentPrefixes ?? [])
|
|
158
|
+
);
|
|
159
|
+
const trustPolicyLayers = declared.filter((layer) => isTrustPolicyLayer(layer));
|
|
160
|
+
const persistEligible = persistenceLayers.length > 0;
|
|
161
|
+
const adminEligible = domainLayers.length > 0 || trustPolicyLayers.length > 0;
|
|
162
|
+
if (!persistEligible && !adminEligible) return null;
|
|
163
|
+
|
|
164
|
+
const root = typeof input.root === 'string' ? input.root : '';
|
|
165
|
+
const files = Array.isArray(input.files) ? input.files : [];
|
|
166
|
+
if (files.length === 0) return null;
|
|
167
|
+
|
|
168
|
+
const persistNames = new Set(persistenceLayers.map((layer) => layer.name));
|
|
169
|
+
const domainNames = new Set(domainLayers.map((layer) => layer.name));
|
|
170
|
+
|
|
171
|
+
const sqliteHits = [];
|
|
172
|
+
const jsonHits = [];
|
|
173
|
+
const adminHits = [];
|
|
174
|
+
let scanned = 0;
|
|
175
|
+
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
if (scanned >= MAX_SCAN_FILES) break;
|
|
178
|
+
const { abs, rel } = normalizeRel(root, file);
|
|
179
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
180
|
+
if (!SOURCE_EXT.test(rel)) continue;
|
|
181
|
+
if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
|
|
182
|
+
if (isNonProductionPilotPath(rel)) continue;
|
|
183
|
+
const source = readTextLimited(abs);
|
|
184
|
+
if (source == null) continue;
|
|
185
|
+
scanned += 1;
|
|
186
|
+
const code = stripComments(source);
|
|
187
|
+
const layer = layerNameFor(root, rel, input.config);
|
|
188
|
+
const inPersistence = Boolean(layer && persistNames.has(layer));
|
|
189
|
+
const policyHome =
|
|
190
|
+
Boolean(layer && domainNames.has(layer)) || isTrustPolicyLayer(layerByName(input.config, layer));
|
|
191
|
+
|
|
192
|
+
if (persistEligible && !inPersistence) {
|
|
193
|
+
if (hasSqliteShortcut(code)) sqliteHits.push(rel);
|
|
194
|
+
if (hasJsonFileShortcut(code)) jsonHits.push(rel);
|
|
195
|
+
}
|
|
196
|
+
if (adminEligible && !policyHome && hasAdminLiteral(code)) adminHits.push(rel);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const kinds = [];
|
|
200
|
+
const evidence = [];
|
|
201
|
+
if (persistEligible && sqliteHits.length > 0) {
|
|
202
|
+
kinds.push(PROTOTYPE_KIND.SQLITE);
|
|
203
|
+
evidence.push(...sqliteHits);
|
|
204
|
+
}
|
|
205
|
+
if (persistEligible && jsonHits.length > 0) {
|
|
206
|
+
kinds.push(PROTOTYPE_KIND.JSON_FILE);
|
|
207
|
+
evidence.push(...jsonHits);
|
|
208
|
+
}
|
|
209
|
+
if (adminEligible && adminHits.length > 0) {
|
|
210
|
+
kinds.push(PROTOTYPE_KIND.ADMIN);
|
|
211
|
+
evidence.push(...adminHits);
|
|
212
|
+
}
|
|
213
|
+
if (kinds.length === 0) return null;
|
|
214
|
+
|
|
215
|
+
const uniqueEvidence = [...new Set(evidence)].slice(0, 12);
|
|
216
|
+
return {
|
|
217
|
+
kinds,
|
|
218
|
+
ask: composeAsk(kinds),
|
|
219
|
+
nextAction: composeNext(kinds),
|
|
220
|
+
evidence: uniqueEvidence,
|
|
221
|
+
persistenceLayers: persistenceLayers.map((layer) => layer.name),
|
|
222
|
+
domainLayers: domainLayers.map((layer) => layer.name),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--recommend` presentation. Lives here so ark-check-runtime stays under the
|
|
3
|
+
* orchestration LOC budget and doctor does not parse this path.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
ADOPTION_PLAN_FILENAME,
|
|
8
|
+
buildArchitectureRecommendation,
|
|
9
|
+
formatArchitectureRecommendationHuman,
|
|
10
|
+
writeAdoptionPlan,
|
|
11
|
+
} from '../ark-shared.mjs';
|
|
12
|
+
import { withProjectedGovernedCoverage } from './projected-governed-coverage.mjs';
|
|
13
|
+
|
|
14
|
+
export function runRecommend(args) {
|
|
15
|
+
try {
|
|
16
|
+
const recommendation = withProjectedGovernedCoverage(
|
|
17
|
+
buildArchitectureRecommendation(args.root),
|
|
18
|
+
args.root
|
|
19
|
+
);
|
|
20
|
+
let planWritten;
|
|
21
|
+
if (args.writePlan) {
|
|
22
|
+
const result = writeAdoptionPlan(args.root, recommendation);
|
|
23
|
+
planWritten = result.path;
|
|
24
|
+
}
|
|
25
|
+
if (args.json) {
|
|
26
|
+
console.log(
|
|
27
|
+
JSON.stringify(
|
|
28
|
+
{
|
|
29
|
+
...recommendation,
|
|
30
|
+
...(planWritten
|
|
31
|
+
? { adoptionPlanPath: path.relative(args.root, planWritten) || ADOPTION_PLAN_FILENAME }
|
|
32
|
+
: {}),
|
|
33
|
+
},
|
|
34
|
+
null,
|
|
35
|
+
2
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(formatArchitectureRecommendationHuman(recommendation));
|
|
40
|
+
if (planWritten) {
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log(`Wrote ${path.relative(args.root, planWritten) || ADOPTION_PLAN_FILENAME}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
+
if (args.json) {
|
|
48
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
49
|
+
} else {
|
|
50
|
+
console.error(`ark-check --recommend failed: ${message}`);
|
|
51
|
+
}
|
|
52
|
+
process.exitCode = 2;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -216,6 +216,14 @@ export function deterministicNextAction(violation) {
|
|
|
216
216
|
return 'Add metadata.source to the publish call, then run Ark again.';
|
|
217
217
|
case 'INVARIANT_COVERAGE_OUTSIDE_ROOTS':
|
|
218
218
|
return `Move the covering test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json, then run Ark again.`;
|
|
219
|
+
case 'INVARIANT_CATALOG_EMPTY':
|
|
220
|
+
return typeof violation.file === 'string' && violation.file.length > 0
|
|
221
|
+
? `Add 1–2 short phrases to invariants[] in ${violation.file}, then run ark-check --doctor. Starters show the shape.`
|
|
222
|
+
: 'Add 1–2 short phrases to invariants[] in arkrules/<Domain>.json, then run ark-check --doctor. Starters show the shape.';
|
|
223
|
+
case 'INVARIANT_TESTS_PATH_MISSING':
|
|
224
|
+
return 'Add coverage.testGlobs or coverage.coverageRoots in ark.config.json pointing at a real tests folder, then re-run. Adopted mode fails closed until that path is present.';
|
|
225
|
+
case 'INVARIANT_COVERAGE_ROOTS_MISSING':
|
|
226
|
+
return 'Add coverage.coverageRoots in ark.config.json pointing at the folder the test runner uses, then re-run. An enforced invariant fails closed until that path is present.';
|
|
219
227
|
case 'ARKRULE_STRUCTURE':
|
|
220
228
|
case 'ARKRULE_INVARIANT':
|
|
221
229
|
case 'INVARIANT_UNCOVERED':
|
|
@@ -360,6 +368,7 @@ export function classifyRemediation(violation) {
|
|
|
360
368
|
ruleId === 'ARKRULE_INVARIANT' ||
|
|
361
369
|
ruleId === 'INVARIANT_UNCOVERED' ||
|
|
362
370
|
ruleId === 'INVARIANT_COVERAGE_OUTSIDE_ROOTS' ||
|
|
371
|
+
ruleId === 'INVARIANT_COVERAGE_ROOTS_MISSING' ||
|
|
363
372
|
(typeof ruleId === 'string' && ruleId.startsWith('ARKRULE_'))) {
|
|
364
373
|
return {
|
|
365
374
|
class: 'judgment',
|
|
@@ -490,6 +499,12 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
490
499
|
enriched.enthusiastHint =
|
|
491
500
|
'The covering test lives where the project says its runner does not go. Move it, or declare that root in coverage.coverageRoots.';
|
|
492
501
|
break;
|
|
502
|
+
case 'INVARIANT_COVERAGE_ROOTS_MISSING':
|
|
503
|
+
enriched.fixClass = 'review-contract';
|
|
504
|
+
enriched.effort = 'small';
|
|
505
|
+
enriched.enthusiastHint =
|
|
506
|
+
'An invariant is enforced, but the project never said where tests run. Add coverage.coverageRoots so coverage cannot tick a test no runner uses.';
|
|
507
|
+
break;
|
|
493
508
|
case 'ARKRULE_STRUCTURE':
|
|
494
509
|
case 'ARKRULE_INVARIANT':
|
|
495
510
|
case 'INVARIANT_UNCOVERED':
|
|
@@ -1168,9 +1168,17 @@ export function resolveCandidateFacts({
|
|
|
1168
1168
|
const arkOrderXiFieldWrites = [];
|
|
1169
1169
|
const arkOrderIngestWritesXi = [];
|
|
1170
1170
|
const arkOrderReleaseKeyCounts = [];
|
|
1171
|
-
const
|
|
1172
|
-
const
|
|
1173
|
-
const
|
|
1171
|
+
const arkRunActive = config.arkRun != null && typeof config.arkRun === 'object';
|
|
1172
|
+
const arkOrderActive = config.arkOrder != null && typeof config.arkOrder === 'object';
|
|
1173
|
+
const arkRulesActive =
|
|
1174
|
+
config.arkRules != null &&
|
|
1175
|
+
typeof config.arkRules === 'object' &&
|
|
1176
|
+
Object.keys(config.arkRules).length > 0;
|
|
1177
|
+
const xiKeys = arkOrderActive ? [...(config.arkOrder?.xiKeys ?? [])] : [];
|
|
1178
|
+
const compositionRootPatterns = arkRunActive
|
|
1179
|
+
? [...(config.arkRun?.compositionRoots ?? [])]
|
|
1180
|
+
: [];
|
|
1181
|
+
const planeRootPatterns = arkOrderActive ? [...(config.arkOrder?.planeRoots ?? [])] : [];
|
|
1174
1182
|
|
|
1175
1183
|
const seedPathSet = new Set(candidateFiles.map((file) => file.path));
|
|
1176
1184
|
const ingest = (candidate, fullExtract) => {
|
|
@@ -1256,60 +1264,67 @@ export function resolveCandidateFacts({
|
|
|
1256
1264
|
);
|
|
1257
1265
|
// Class-shape extraction is text-conservative (false negatives over false positives).
|
|
1258
1266
|
// Only TS/TSX candidates; sensors consume the same shape via facts.classShapes.
|
|
1267
|
+
// Extra-plane and ArkRules extractors stay silent when that plane is off (#212).
|
|
1259
1268
|
if (/\.(tsx?|mts|cts)$/i.test(candidate.path)) {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
arkRunKernelCalls.push(
|
|
1267
|
-
...extractArkRunKernelCallsFromSource(candidate.path, candidate.content)
|
|
1268
|
-
);
|
|
1269
|
-
} catch {
|
|
1270
|
-
// Never fail the resolver for ArkRun call extraction.
|
|
1271
|
-
}
|
|
1272
|
-
try {
|
|
1273
|
-
arkRunDeclarations.push(
|
|
1274
|
-
...extractArkRunDeclarationsFromSource(candidate.path, candidate.content)
|
|
1275
|
-
);
|
|
1276
|
-
} catch {
|
|
1277
|
-
// Never fail the resolver for ArkRun declaration extraction.
|
|
1278
|
-
}
|
|
1279
|
-
try {
|
|
1280
|
-
arkOrderPlaneCalls.push(
|
|
1281
|
-
...extractArkOrderPlaneCallsFromSource(candidate.path, candidate.content)
|
|
1282
|
-
);
|
|
1283
|
-
} catch {
|
|
1284
|
-
// Never fail the resolver for ArkOrder factory extraction.
|
|
1285
|
-
}
|
|
1286
|
-
try {
|
|
1287
|
-
arkOrderGenericUpdates.push(
|
|
1288
|
-
...extractArkOrderGenericUpdatesFromSource(candidate.path, candidate.content)
|
|
1289
|
-
);
|
|
1290
|
-
} catch {
|
|
1291
|
-
// Never fail the resolver for ArkOrder generic-update extraction.
|
|
1292
|
-
}
|
|
1293
|
-
try {
|
|
1294
|
-
arkOrderXiFieldWrites.push(
|
|
1295
|
-
...extractArkOrderXiFieldWritesFromSource(candidate.path, candidate.content, xiKeys)
|
|
1296
|
-
);
|
|
1297
|
-
} catch {
|
|
1298
|
-
// Never fail the resolver for ArkOrder xi-field-write extraction.
|
|
1269
|
+
if (arkRulesActive || arkRunActive) {
|
|
1270
|
+
try {
|
|
1271
|
+
classShapes.push(...extractClassShapesFromSource(candidate.path, candidate.content));
|
|
1272
|
+
} catch {
|
|
1273
|
+
// Never fail the resolver for shape extraction; sensors stay silent on this file.
|
|
1274
|
+
}
|
|
1299
1275
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1276
|
+
if (arkRunActive) {
|
|
1277
|
+
try {
|
|
1278
|
+
arkRunKernelCalls.push(
|
|
1279
|
+
...extractArkRunKernelCallsFromSource(candidate.path, candidate.content)
|
|
1280
|
+
);
|
|
1281
|
+
} catch {
|
|
1282
|
+
// Never fail the resolver for ArkRun call extraction.
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
arkRunDeclarations.push(
|
|
1286
|
+
...extractArkRunDeclarationsFromSource(candidate.path, candidate.content)
|
|
1287
|
+
);
|
|
1288
|
+
} catch {
|
|
1289
|
+
// Never fail the resolver for ArkRun declaration extraction.
|
|
1290
|
+
}
|
|
1306
1291
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1292
|
+
if (arkOrderActive) {
|
|
1293
|
+
try {
|
|
1294
|
+
arkOrderPlaneCalls.push(
|
|
1295
|
+
...extractArkOrderPlaneCallsFromSource(candidate.path, candidate.content)
|
|
1296
|
+
);
|
|
1297
|
+
} catch {
|
|
1298
|
+
// Never fail the resolver for ArkOrder factory extraction.
|
|
1299
|
+
}
|
|
1300
|
+
try {
|
|
1301
|
+
arkOrderGenericUpdates.push(
|
|
1302
|
+
...extractArkOrderGenericUpdatesFromSource(candidate.path, candidate.content)
|
|
1303
|
+
);
|
|
1304
|
+
} catch {
|
|
1305
|
+
// Never fail the resolver for ArkOrder generic-update extraction.
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
arkOrderXiFieldWrites.push(
|
|
1309
|
+
...extractArkOrderXiFieldWritesFromSource(candidate.path, candidate.content, xiKeys)
|
|
1310
|
+
);
|
|
1311
|
+
} catch {
|
|
1312
|
+
// Never fail the resolver for ArkOrder xi-field-write extraction.
|
|
1313
|
+
}
|
|
1314
|
+
try {
|
|
1315
|
+
arkOrderIngestWritesXi.push(
|
|
1316
|
+
...extractArkOrderIngestWritesXiFromSource(candidate.path, candidate.content)
|
|
1317
|
+
);
|
|
1318
|
+
} catch {
|
|
1319
|
+
// Never fail the resolver for ArkOrder ingest-writes-ξ extraction.
|
|
1320
|
+
}
|
|
1321
|
+
try {
|
|
1322
|
+
arkOrderReleaseKeyCounts.push(
|
|
1323
|
+
...extractArkOrderReleaseKeyCountsFromSource(candidate.path, candidate.content)
|
|
1324
|
+
);
|
|
1325
|
+
} catch {
|
|
1326
|
+
// Never fail the resolver for ArkOrder release key-count extraction.
|
|
1327
|
+
}
|
|
1313
1328
|
}
|
|
1314
1329
|
}
|
|
1315
1330
|
};
|
|
@@ -1355,18 +1370,20 @@ export function resolveCandidateFacts({
|
|
|
1355
1370
|
? candidateFiles.filter((file) => seedPathSet.has(file.path))
|
|
1356
1371
|
: candidateFiles;
|
|
1357
1372
|
const admittedTypeNames = new Set(classShapes.map((shape) => shape.className));
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1373
|
+
if (arkRunActive) {
|
|
1374
|
+
for (const candidate of extractCandidates) {
|
|
1375
|
+
if (!/\.(tsx?|mts|cts)$/i.test(candidate.path)) continue;
|
|
1376
|
+
try {
|
|
1377
|
+
arkRunManagedNews.push(
|
|
1378
|
+
...extractArkRunManagedNewsFromSource(
|
|
1379
|
+
candidate.path,
|
|
1380
|
+
candidate.content,
|
|
1381
|
+
admittedTypeNames
|
|
1382
|
+
)
|
|
1383
|
+
);
|
|
1384
|
+
} catch {
|
|
1385
|
+
// Never fail the resolver for managed-new extraction.
|
|
1386
|
+
}
|
|
1370
1387
|
}
|
|
1371
1388
|
}
|
|
1372
1389
|
|
|
@@ -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(
|