arkgate 3.0.5 → 3.2.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 +92 -1
- package/README.md +58 -21
- package/bin/ark-check.mjs +46 -4
- package/bin/ark-mcp.mjs +267 -26
- package/bin/ark.mjs +47 -0
- package/bin/lib/adapter-contract.mjs +27 -1
- package/bin/lib/analysis-engine.mjs +7 -1169
- package/bin/lib/ci-and-commands.mjs +4 -0
- package/bin/lib/contract-smells.mjs +514 -0
- package/bin/lib/doctor-plan.mjs +15 -4
- package/bin/lib/host-support-matrix.mjs +6 -2
- package/bin/lib/policy-delta-io.mjs +161 -0
- package/bin/lib/prepare-change.mjs +186 -0
- package/bin/lib/remediation.mjs +24 -0
- package/bin/lib/violations.mjs +2 -2
- package/bin/lib/write-path-capabilities.mjs +67 -1
- package/bin/lib/write-path-detect.mjs +4 -3
- package/dist/eslint/index.cjs +3 -977
- package/dist/eslint/index.js +3 -931
- package/dist/index.cjs +6 -1960
- package/dist/index.d.cts +152 -5
- package/dist/index.d.ts +152 -5
- package/dist/index.js +6 -1908
- package/docs/agent-guide.md +39 -5
- package/docs/ai-gates.md +17 -15
- package/docs/configuration.md +44 -0
- package/docs/demos/01-write-gate-self-correction.md +2 -2
- package/docs/enthusiast/README.md +5 -1
- package/docs/enthusiast/how-to-agent-gates.md +3 -5
- package/docs/enthusiast/how-to-policy-pack.md +4 -1
- package/docs/enthusiast/reference-archetypes.md +8 -1
- package/docs/enthusiast/reference-commands.md +8 -2
- package/docs/package-surface.md +12 -2
- package/docs/threat-model.md +10 -6
- package/package.json +7 -6
- package/schemas/ark.analysis-result.schema.json +5 -1
- package/schemas/ark.change-map.schema.json +77 -0
- package/server.json +3 -3
- package/docs/ark-check-example.json +0 -87
- package/docs/demos/03-copilot-autopilot.md +0 -93
- package/docs/migrate-from-ark-runtime-kernel.md +0 -174
- package/docs/production-hardening.md +0 -100
|
@@ -417,6 +417,8 @@ jobs:
|
|
|
417
417
|
steps:
|
|
418
418
|
- name: Checkout
|
|
419
419
|
uses: actions/checkout@v4
|
|
420
|
+
with:
|
|
421
|
+
fetch-depth: 0
|
|
420
422
|
${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
|
|
421
423
|
uses: actions/setup-node@v4
|
|
422
424
|
with:
|
|
@@ -425,6 +427,8 @@ ${nodeSetup}
|
|
|
425
427
|
- name: Install dependencies
|
|
426
428
|
run: ${pm.install}
|
|
427
429
|
${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
|
|
430
|
+
env:
|
|
431
|
+
ARK_POLICY_BASE_REF: \${{ github.event.pull_request.base.sha || github.event.before }}
|
|
428
432
|
run: ${pm.run}
|
|
429
433
|
`;
|
|
430
434
|
}
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic contract smells (Phase W / W01) — meta-lint of ark.config.json itself.
|
|
3
|
+
*
|
|
4
|
+
* ArkGate validates code against the contract; these sensors validate the contract
|
|
5
|
+
* against known contract anti-patterns: rule shapes that permit future degradation
|
|
6
|
+
* even at 0 violations. Advisory only — they never change a pass/fail verdict, never
|
|
7
|
+
* feed designWeak/patternBets, and never block a gate. Complements `soft-contract`
|
|
8
|
+
* (missing rules) by detecting *permissive or unused* rules.
|
|
9
|
+
*
|
|
10
|
+
* Acknowledgments live in an optional sidecar (`.ark/contract-smell-acks.json`,
|
|
11
|
+
* Q03 golden-pattern precedent) so the versioned config contract is untouched.
|
|
12
|
+
* A malformed ack file (or a malformed edge inside it) never suppresses a smell.
|
|
13
|
+
*
|
|
14
|
+
* Known limit (documented, deliberate): layer roles are inferred from layer NAMES
|
|
15
|
+
* via substring heuristics — a name like "Auditorium" reads as audit-ish. The
|
|
16
|
+
* surface is advisory, so a miss costs a warning line, never a verdict.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** Stable contract-smell ids (doctor JSON `contractHealth` + skills). */
|
|
22
|
+
export const CONTRACT_SMELL_IDS = Object.freeze([
|
|
23
|
+
'contract-bidirectional-allow',
|
|
24
|
+
'contract-peripheral-depends-core',
|
|
25
|
+
'contract-lateral-adapter-allow',
|
|
26
|
+
'contract-dead-rule',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Plain-language outcome per id (Q02 pattern: outcome first, technical detail in message). */
|
|
30
|
+
export const CONTRACT_SMELL_OUTCOMES = Object.freeze({
|
|
31
|
+
'contract-bidirectional-allow':
|
|
32
|
+
'Two layers may depend on each other in both directions — nothing stops a dependency cycle from growing there. Keep one direction, or acknowledge the loop explicitly with the reason.',
|
|
33
|
+
'contract-peripheral-depends-core':
|
|
34
|
+
'An observability/audit-style layer is allowed to reach into orchestration or persistence — it can quietly become a second orchestrator. Keep periphery consuming events/ports, not core internals.',
|
|
35
|
+
'contract-lateral-adapter-allow':
|
|
36
|
+
'One adapter layer may import another adapter family directly — shared mappers/aliases will pile up in the wrong place. Move shared shapes into Domain (or a shared kernel) instead of adapter-to-adapter reach.',
|
|
37
|
+
'contract-dead-rule':
|
|
38
|
+
'A rule enforces nothing: it points at a layer that matches no files or does not exist, or both sides are the same layer. Fix the layer patterns or delete the rule.',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const CONTRACT_SMELL_ACKS_PATH = '.ark/contract-smell-acks.json';
|
|
42
|
+
|
|
43
|
+
/** Hostile-input bounds (mirrors design-smells MAX_FILE_BYTES discipline). */
|
|
44
|
+
const MAX_ACK_BYTES = 64 * 1024;
|
|
45
|
+
const MAX_ACK_ENTRIES = 200;
|
|
46
|
+
const MAX_EVIDENCE = 12;
|
|
47
|
+
const MAX_MESSAGE_EDGES = 6;
|
|
48
|
+
|
|
49
|
+
const PERIPHERAL_LAYER_RE = /observab|audit|telemetry|monitor|logging|metric|tracing/i;
|
|
50
|
+
const CORE_TARGET_RE = /application|orchestr|persist|repositor/i;
|
|
51
|
+
const ADAPTER_LAYER_RE = /adapter|persist|integrat|infra|gateway/i;
|
|
52
|
+
|
|
53
|
+
/** Collision-safe internal key for a directed edge (layer names are arbitrary strings). */
|
|
54
|
+
function directedKey(from, to) {
|
|
55
|
+
return JSON.stringify([from, to]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Display + ack pair key. Only meaningful when neither name embeds the delimiter. */
|
|
59
|
+
function pairLabel(a, b) {
|
|
60
|
+
return [a, b].sort().join('<->');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Names embedding the arrow delimiter cannot be matched safely from ack strings. */
|
|
64
|
+
function ackMatchable(...names) {
|
|
65
|
+
return names.every((n) => typeof n === 'string' && !n.includes('->'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load the optional acknowledgment sidecar. Bounded and fail-loud:
|
|
70
|
+
* non-file, oversized, unparsable, or wrong-shaped content → `invalid: true`
|
|
71
|
+
* with `acks: []` (a broken file never suppresses anything).
|
|
72
|
+
*
|
|
73
|
+
* @param {string} root
|
|
74
|
+
* @returns {{ path: string, exists: boolean, invalid?: boolean, error?: string, acks: Array<{id: string, edge: string, reason?: string}> }}
|
|
75
|
+
*/
|
|
76
|
+
export function loadContractSmellAcks(root) {
|
|
77
|
+
const relPath = CONTRACT_SMELL_ACKS_PATH;
|
|
78
|
+
const abs = path.join(root, relPath);
|
|
79
|
+
let stats;
|
|
80
|
+
try {
|
|
81
|
+
stats = fs.statSync(abs);
|
|
82
|
+
} catch {
|
|
83
|
+
return { path: relPath, exists: false, acks: [] };
|
|
84
|
+
}
|
|
85
|
+
const invalid = (error) => ({ path: relPath, exists: true, invalid: true, error, acks: [] });
|
|
86
|
+
if (!stats.isFile()) return invalid('not a regular file');
|
|
87
|
+
if (stats.size > MAX_ACK_BYTES) return invalid(`larger than ${MAX_ACK_BYTES} bytes`);
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return invalid(error instanceof Error ? error.message : 'unreadable JSON');
|
|
93
|
+
}
|
|
94
|
+
const acks = Array.isArray(parsed?.acks) ? parsed.acks : null;
|
|
95
|
+
if (!acks) return invalid('expected { acks: [{ id, edge, reason? }] }');
|
|
96
|
+
if (acks.length > MAX_ACK_ENTRIES) return invalid(`more than ${MAX_ACK_ENTRIES} entries`);
|
|
97
|
+
const wellFormed = acks.every(
|
|
98
|
+
(a) =>
|
|
99
|
+
a !== null &&
|
|
100
|
+
typeof a === 'object' &&
|
|
101
|
+
typeof a.id === 'string' &&
|
|
102
|
+
typeof a.edge === 'string' &&
|
|
103
|
+
a.edge.trim().length > 0
|
|
104
|
+
);
|
|
105
|
+
if (!wellFormed) return invalid('every ack needs string id and non-empty string edge');
|
|
106
|
+
return { path: relPath, exists: true, acks };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Normalize an ack edge string; returns null (never matches) for malformed grammar,
|
|
111
|
+
* e.g. `A<->B<->C` — a sloppy edge must not suppress a real smell.
|
|
112
|
+
*/
|
|
113
|
+
function normalizeAckEdge(id, edge) {
|
|
114
|
+
const raw = String(edge).trim();
|
|
115
|
+
if (id === 'contract-bidirectional-allow') {
|
|
116
|
+
if (!raw.includes('<->')) return null;
|
|
117
|
+
const parts = raw.split('<->').map((s) => s.trim());
|
|
118
|
+
if (parts.length !== 2 || parts.some((p) => p.length === 0)) return null;
|
|
119
|
+
return pairLabel(parts[0], parts[1]);
|
|
120
|
+
}
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isAcknowledged(ackState, id, canonicalEdge) {
|
|
125
|
+
if (!ackState || ackState.invalid || !Array.isArray(ackState.acks)) return false;
|
|
126
|
+
if (canonicalEdge == null) return false;
|
|
127
|
+
return ackState.acks.some((a) => a.id === id && normalizeAckEdge(id, a.edge) === canonicalEdge);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Core analysis: smells plus the count of ack entries that actually matched a
|
|
132
|
+
* detected edge (stale/typo acks match nothing and count nothing).
|
|
133
|
+
*
|
|
134
|
+
* @param {object} config ark.config (layers; rules unless overridden)
|
|
135
|
+
* @param {object|null} coverage computeCoverage result (layer file counts); optional
|
|
136
|
+
* @param {object} [ackState] result of loadContractSmellAcks
|
|
137
|
+
* @param {object[]|null} [effectiveRules] rules actually in force (e.g. manifest rules); defaults to config.rules
|
|
138
|
+
*/
|
|
139
|
+
export function analyzeContractSmells(
|
|
140
|
+
config,
|
|
141
|
+
coverage = null,
|
|
142
|
+
ackState = { exists: false, acks: [] },
|
|
143
|
+
effectiveRules = null
|
|
144
|
+
) {
|
|
145
|
+
const layers = Array.isArray(config?.layers) ? config.layers : [];
|
|
146
|
+
const rules = wellFormedRules(config, effectiveRules);
|
|
147
|
+
const layerByName = new Map();
|
|
148
|
+
for (const l of layers) {
|
|
149
|
+
if (l && typeof l.name === 'string') layerByName.set(l.name, l);
|
|
150
|
+
}
|
|
151
|
+
const filesPerLayer = new Map();
|
|
152
|
+
for (const row of coverage?.layers ?? []) {
|
|
153
|
+
if (row && typeof row.name === 'string') filesPerLayer.set(row.name, row.files ?? 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const explicitAllows = rules.filter((r) => r.allowed === true && r.from !== r.to);
|
|
157
|
+
|
|
158
|
+
/** Per-id findings: { edge (canonical, for acks; null = unmatchable), detail (display) }. */
|
|
159
|
+
const findings = {};
|
|
160
|
+
const add = (id, edge, detail) => {
|
|
161
|
+
(findings[id] ??= []).push({ edge, detail });
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// 1) Explicitly bidirectional allowed edges (permits future cycles by declaration).
|
|
165
|
+
const allowKeys = new Set(explicitAllows.map((r) => directedKey(r.from, r.to)));
|
|
166
|
+
const seenPairs = new Set();
|
|
167
|
+
for (const r of explicitAllows) {
|
|
168
|
+
if (!allowKeys.has(directedKey(r.to, r.from))) continue;
|
|
169
|
+
const key = directedKey(...[r.from, r.to].sort());
|
|
170
|
+
if (seenPairs.has(key)) continue;
|
|
171
|
+
seenPairs.add(key);
|
|
172
|
+
const label = pairLabel(r.from, r.to);
|
|
173
|
+
add(
|
|
174
|
+
'contract-bidirectional-allow',
|
|
175
|
+
ackMatchable(r.from, r.to) ? label : null,
|
|
176
|
+
`edge:${label}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2) Peripheral layers explicitly allowed into orchestration/persistence cores.
|
|
181
|
+
for (const r of explicitAllows) {
|
|
182
|
+
if (PERIPHERAL_LAYER_RE.test(r.from) && CORE_TARGET_RE.test(r.to)) {
|
|
183
|
+
add(
|
|
184
|
+
'contract-peripheral-depends-core',
|
|
185
|
+
ackMatchable(r.from, r.to) ? `${r.from}->${r.to}` : null,
|
|
186
|
+
`edge:${r.from}->${r.to}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3) Lateral adapter-to-adapter explicit allows. Skip only edges the peripheral
|
|
192
|
+
// sensor already flagged (peripheral source AND core-ish target).
|
|
193
|
+
for (const r of explicitAllows) {
|
|
194
|
+
if (PERIPHERAL_LAYER_RE.test(r.from) && CORE_TARGET_RE.test(r.to)) continue;
|
|
195
|
+
if (ADAPTER_LAYER_RE.test(r.from) && ADAPTER_LAYER_RE.test(r.to)) {
|
|
196
|
+
add(
|
|
197
|
+
'contract-lateral-adapter-allow',
|
|
198
|
+
ackMatchable(r.from, r.to) ? `${r.from}->${r.to}` : null,
|
|
199
|
+
`edge:${r.from}->${r.to}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 4) Dead rules: self edges (the gate ignores same-layer rules), unknown layers,
|
|
205
|
+
// or — when coverage is known — layers matching zero files (optional layers exempt).
|
|
206
|
+
for (const r of rules) {
|
|
207
|
+
const edge = `${r.from}->${r.to}`;
|
|
208
|
+
const ackEdge = ackMatchable(r.from, r.to) ? edge : null;
|
|
209
|
+
if (r.from === r.to) {
|
|
210
|
+
add('contract-dead-rule', ackEdge, `rule:${edge} (self edge has no effect)`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
for (const side of [r.from, r.to]) {
|
|
214
|
+
if (side.length === 0) continue;
|
|
215
|
+
const layer = layerByName.get(side);
|
|
216
|
+
if (!layer) {
|
|
217
|
+
add('contract-dead-rule', ackEdge, `rule:${edge} (unknown layer: ${side})`);
|
|
218
|
+
} else if (filesPerLayer.get(side) === 0 && layer.optional !== true) {
|
|
219
|
+
add('contract-dead-rule', ackEdge, `rule:${edge} (empty layer: ${side})`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const smells = [];
|
|
225
|
+
let matchedAcks = 0;
|
|
226
|
+
for (const id of CONTRACT_SMELL_IDS) {
|
|
227
|
+
const entries = findings[id];
|
|
228
|
+
if (!entries || entries.length === 0) continue;
|
|
229
|
+
const kept = [];
|
|
230
|
+
let acknowledgedEdges = 0;
|
|
231
|
+
const seenDetail = new Set();
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (seenDetail.has(entry.detail)) continue;
|
|
234
|
+
seenDetail.add(entry.detail);
|
|
235
|
+
if (isAcknowledged(ackState, id, entry.edge)) {
|
|
236
|
+
acknowledgedEdges += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
kept.push(entry);
|
|
240
|
+
}
|
|
241
|
+
matchedAcks += acknowledgedEdges;
|
|
242
|
+
// Fully acknowledged ids emit no smell; the summary reports applied acks.
|
|
243
|
+
if (kept.length === 0) continue;
|
|
244
|
+
// Deterministic output independent of rule declaration order.
|
|
245
|
+
kept.sort((a, b) => (a.detail < b.detail ? -1 : a.detail > b.detail ? 1 : 0));
|
|
246
|
+
const evidence = kept.slice(0, MAX_EVIDENCE).map((e) => e.detail);
|
|
247
|
+
if (kept.length > MAX_EVIDENCE) evidence.push(`…(+${kept.length - MAX_EVIDENCE} more)`);
|
|
248
|
+
smells.push({
|
|
249
|
+
id,
|
|
250
|
+
severity: 'warn',
|
|
251
|
+
message: messageFor(id, kept),
|
|
252
|
+
outcome: CONTRACT_SMELL_OUTCOMES[id],
|
|
253
|
+
evidence,
|
|
254
|
+
fix: fixFor(id),
|
|
255
|
+
acknowledgedEdges,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return { smells, matchedAcks };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Detect contract smells (compat wrapper over analyzeContractSmells).
|
|
263
|
+
* @returns {Array<{id: string, severity: 'warn', message: string, outcome: string, evidence: string[], fix: string, acknowledgedEdges: number}>}
|
|
264
|
+
*/
|
|
265
|
+
export function detectContractSmells(
|
|
266
|
+
config,
|
|
267
|
+
coverage = null,
|
|
268
|
+
ackState = { exists: false, acks: [] },
|
|
269
|
+
effectiveRules = null
|
|
270
|
+
) {
|
|
271
|
+
return analyzeContractSmells(config, coverage, ackState, effectiveRules).smells;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function messageFor(id, entries) {
|
|
275
|
+
const shown = entries.slice(0, MAX_MESSAGE_EDGES).map((e) => e.detail.replace(/^(edge|rule):/, ''));
|
|
276
|
+
const more = entries.length > shown.length ? `, …(+${entries.length - shown.length} more)` : '';
|
|
277
|
+
const list = `${shown.join(', ')}${more}`;
|
|
278
|
+
switch (id) {
|
|
279
|
+
case 'contract-bidirectional-allow':
|
|
280
|
+
return `Both directions are explicitly allowed between ${entries.length} pair(s): ${list}. No cycle exists yet, but the contract permits one by declaration.`;
|
|
281
|
+
case 'contract-peripheral-depends-core':
|
|
282
|
+
return `Peripheral (audit/observability) layers are explicitly allowed into core layers (${entries.length} edge(s)): ${list}. Observability stops being fully peripheral.`;
|
|
283
|
+
case 'contract-lateral-adapter-allow':
|
|
284
|
+
return `Adapter layers are explicitly allowed to import sibling adapter layers (${entries.length} edge(s)): ${list}. Shared mappers/aliases tend to accumulate on this edge.`;
|
|
285
|
+
case 'contract-dead-rule':
|
|
286
|
+
return `${entries.length} rule(s) enforce nothing: ${list}.`;
|
|
287
|
+
default:
|
|
288
|
+
return `Contract smell on: ${list}.`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function fixFor(id) {
|
|
293
|
+
switch (id) {
|
|
294
|
+
case 'contract-bidirectional-allow':
|
|
295
|
+
return `Keep one direction (edit via /ark-contract), or record the deliberate loop in ${CONTRACT_SMELL_ACKS_PATH} with a reason.`;
|
|
296
|
+
case 'contract-peripheral-depends-core':
|
|
297
|
+
return `Invert the edge: core emits events/ports the peripheral layer consumes (/ark-contract), or acknowledge with a reason in ${CONTRACT_SMELL_ACKS_PATH}.`;
|
|
298
|
+
case 'contract-lateral-adapter-allow':
|
|
299
|
+
return `Move shared shapes into Domain/shared kernel and drop the lateral allow (/ark-contract), or acknowledge with a reason in ${CONTRACT_SMELL_ACKS_PATH}.`;
|
|
300
|
+
case 'contract-dead-rule':
|
|
301
|
+
return 'Fix the layer patterns so the layer matches real files, or delete the stale/self rule via /ark-contract.';
|
|
302
|
+
default:
|
|
303
|
+
return 'Review the contract edge via /ark-contract; never weaken the gate to silence a smell.';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* W02 — fixed comparative wording per band. Facts + a note; explicitly never a
|
|
309
|
+
* score, ranking, or gate input. Heavy wording must never suggest deleting layers.
|
|
310
|
+
*/
|
|
311
|
+
export const GOVERNANCE_WEIGHT_NOTES = Object.freeze({
|
|
312
|
+
heavy:
|
|
313
|
+
'Heavier than typical for the governed tree size. Not a defect and not a score — but before adding another layer or rule, ask for demonstrated pressure (repeated violations or acknowledgments on one edge). Do not delete working layers to change this number.',
|
|
314
|
+
light:
|
|
315
|
+
'Lighter than typical for the governed tree size — a large tree with few boundaries. Consider whether a new boundary is justified where violations or churn concentrate.',
|
|
316
|
+
typical: 'Within the typical band for the governed tree size.',
|
|
317
|
+
unknown: 'Not enough governed files (or declared layers) to describe governance weight.',
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Fixed banding thresholds (stated in docs/package-surface.md; deterministic, not tunables).
|
|
322
|
+
* heavy: fewer than 25 governed files per layer AND (6+ layers OR 4+ rules per layer) —
|
|
323
|
+
* both signals are size-relative, so a large tree with a dense but proportionate rule
|
|
324
|
+
* matrix never reads heavy. light: at most 2 layers over 150+ governed files.
|
|
325
|
+
*/
|
|
326
|
+
const HEAVY_FILES_PER_LAYER_BELOW = 25;
|
|
327
|
+
const HEAVY_MIN_LAYERS = 6;
|
|
328
|
+
const HEAVY_RULES_PER_LAYER = 4;
|
|
329
|
+
const LIGHT_MAX_LAYERS = 2;
|
|
330
|
+
const LIGHT_MIN_FILES = 150;
|
|
331
|
+
|
|
332
|
+
/** Rules in force, filtered to well-formed entries (string from/to, boolean allowed). */
|
|
333
|
+
function wellFormedRules(config, effectiveRules) {
|
|
334
|
+
const rules = (Array.isArray(effectiveRules) ? effectiveRules : config?.rules) ?? [];
|
|
335
|
+
return rules.filter(
|
|
336
|
+
(r) =>
|
|
337
|
+
r !== null &&
|
|
338
|
+
typeof r === 'object' &&
|
|
339
|
+
typeof r.from === 'string' &&
|
|
340
|
+
typeof r.to === 'string' &&
|
|
341
|
+
typeof r.allowed === 'boolean'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* W02 — descriptive governance-weight facts for a contract over a governed tree.
|
|
347
|
+
* Raw counts and ratios with a fixed comparative note. Advisory only.
|
|
348
|
+
*
|
|
349
|
+
* @param {object} config ark.config (layers; rules unless overridden)
|
|
350
|
+
* @param {object|null} coverage computeCoverage result
|
|
351
|
+
* @param {object[]|null} [effectiveRules]
|
|
352
|
+
*/
|
|
353
|
+
export function computeGovernanceWeight(config, coverage = null, effectiveRules = null) {
|
|
354
|
+
const layers = Array.isArray(config?.layers) ? config.layers : [];
|
|
355
|
+
const rules = wellFormedRules(config, effectiveRules);
|
|
356
|
+
const declaredLayers = layers.filter((l) => l && typeof l.name === 'string').length;
|
|
357
|
+
const governedFiles = coverage?.governed?.classifiedFiles ?? 0;
|
|
358
|
+
const populatedLayers = (coverage?.layers ?? []).filter((r) => r && (r.files ?? 0) > 0).length;
|
|
359
|
+
const deniedEdges = rules.filter((r) => r.allowed === false).length;
|
|
360
|
+
const allowedEdges = rules.filter((r) => r.allowed === true).length;
|
|
361
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
362
|
+
const base = {
|
|
363
|
+
declaredLayers,
|
|
364
|
+
populatedLayers,
|
|
365
|
+
governedFiles,
|
|
366
|
+
rules: rules.length,
|
|
367
|
+
deniedEdges,
|
|
368
|
+
allowedEdges,
|
|
369
|
+
notAScore: true,
|
|
370
|
+
};
|
|
371
|
+
if (declaredLayers === 0 || !(Number.isFinite(governedFiles) && governedFiles > 0)) {
|
|
372
|
+
return {
|
|
373
|
+
...base,
|
|
374
|
+
governedFiles: Number.isFinite(governedFiles) ? governedFiles : 0,
|
|
375
|
+
filesPerLayer: null,
|
|
376
|
+
rulesPerLayer: null,
|
|
377
|
+
weight: 'unknown',
|
|
378
|
+
note: GOVERNANCE_WEIGHT_NOTES.unknown,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// Band on the raw ratios; the rounded values are for display only.
|
|
382
|
+
const rawFilesPerLayer = governedFiles / declaredLayers;
|
|
383
|
+
const rawRulesPerLayer = rules.length / declaredLayers;
|
|
384
|
+
let weight = 'typical';
|
|
385
|
+
if (
|
|
386
|
+
rawFilesPerLayer < HEAVY_FILES_PER_LAYER_BELOW &&
|
|
387
|
+
(declaredLayers >= HEAVY_MIN_LAYERS || rawRulesPerLayer >= HEAVY_RULES_PER_LAYER)
|
|
388
|
+
) {
|
|
389
|
+
weight = 'heavy';
|
|
390
|
+
} else if (declaredLayers <= LIGHT_MAX_LAYERS && governedFiles >= LIGHT_MIN_FILES) {
|
|
391
|
+
weight = 'light';
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
...base,
|
|
395
|
+
filesPerLayer: round1(rawFilesPerLayer),
|
|
396
|
+
rulesPerLayer: round1(rawRulesPerLayer),
|
|
397
|
+
weight,
|
|
398
|
+
note: GOVERNANCE_WEIGHT_NOTES[weight],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* One-call compute for doctor: acks + smells + governance weight + JSON-ready summary.
|
|
404
|
+
* `rules` should be the rules actually in force (manifest-aware callers pass them).
|
|
405
|
+
*
|
|
406
|
+
* @param {string} root
|
|
407
|
+
* @param {object} config
|
|
408
|
+
* @param {object|null} coverage
|
|
409
|
+
* @param {object[]|null} [rules]
|
|
410
|
+
*/
|
|
411
|
+
export function computeContractHealth(root, config, coverage, rules = null) {
|
|
412
|
+
const ackState = loadContractSmellAcks(root);
|
|
413
|
+
const { smells, matchedAcks } = analyzeContractSmells(config, coverage, ackState, rules);
|
|
414
|
+
return {
|
|
415
|
+
...summarizeContractHealth(smells, ackState, matchedAcks),
|
|
416
|
+
governanceWeight: computeGovernanceWeight(config, coverage, rules),
|
|
417
|
+
smells,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Print the human doctor section (advisory). No output when there is nothing to say.
|
|
423
|
+
* @param {ReturnType<typeof computeContractHealth>} health
|
|
424
|
+
* @param {{ line: (mark: string, text: string) => void, warn: string, color: { bold: (s: string) => string, dim: (s: string) => string } }} io
|
|
425
|
+
*/
|
|
426
|
+
export function printContractHealthSection(health, io) {
|
|
427
|
+
const rows = formatContractHealthLines(health?.smells ?? [], health);
|
|
428
|
+
if (rows.length === 0) return;
|
|
429
|
+
console.log('');
|
|
430
|
+
console.log(io.color.bold('Contract health (advisory)'));
|
|
431
|
+
for (const row of rows) {
|
|
432
|
+
io.line(row.mark === 'warn' ? io.warn : ' ', row.mark === 'dim' ? io.color.dim(row.text) : row.text);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Human doctor lines for the contract-health section (advisory).
|
|
438
|
+
* Returns `{ mark, text }` rows with mark `'warn' | 'dim'`; doctor prints them.
|
|
439
|
+
* Empty array when there is nothing to say: no smells, a valid/absent ack file,
|
|
440
|
+
* and a non-noteworthy governance weight (only `heavy`/`light` print).
|
|
441
|
+
*
|
|
442
|
+
* @param {ReturnType<typeof detectContractSmells>} smells
|
|
443
|
+
* @param {ReturnType<typeof computeContractHealth>} health also read: `health.governanceWeight`
|
|
444
|
+
*/
|
|
445
|
+
export function formatContractHealthLines(smells, health) {
|
|
446
|
+
const rows = [];
|
|
447
|
+
const list = smells ?? [];
|
|
448
|
+
const gw = health?.governanceWeight;
|
|
449
|
+
const weightNoteworthy = gw?.weight === 'heavy' || gw?.weight === 'light';
|
|
450
|
+
if (list.length === 0 && !health?.ackFile?.invalid && !weightNoteworthy) return rows;
|
|
451
|
+
if (health?.ackFile?.invalid) {
|
|
452
|
+
rows.push({
|
|
453
|
+
mark: 'warn',
|
|
454
|
+
text: `${health.ackFile.path} is present but invalid — acknowledgments are ignored, not silently applied.`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
for (const smell of list.slice(0, 5)) {
|
|
458
|
+
rows.push({ mark: 'warn', text: `[${smell.id}] ${smell.outcome}` });
|
|
459
|
+
rows.push({ mark: 'dim', text: `detail: ${smell.message}` });
|
|
460
|
+
if (smell.evidence?.length) {
|
|
461
|
+
const shown = smell.evidence.slice(0, 4);
|
|
462
|
+
const more = smell.evidence.length > 4 ? ` …(+${smell.evidence.length - 4} more)` : '';
|
|
463
|
+
rows.push({ mark: 'dim', text: `evidence: ${shown.join(', ')}${more}` });
|
|
464
|
+
}
|
|
465
|
+
rows.push({ mark: 'dim', text: `fix: ${smell.fix}` });
|
|
466
|
+
}
|
|
467
|
+
if (list.length > 5) {
|
|
468
|
+
rows.push({ mark: 'dim', text: `…(+${list.length - 5} more contract smell(s) in doctor JSON)` });
|
|
469
|
+
}
|
|
470
|
+
if ((health?.acknowledged ?? 0) > 0) {
|
|
471
|
+
rows.push({ mark: 'dim', text: `acknowledged edges applied: ${health.acknowledged}` });
|
|
472
|
+
}
|
|
473
|
+
if (weightNoteworthy) {
|
|
474
|
+
rows.push({
|
|
475
|
+
mark: 'warn',
|
|
476
|
+
text: `governance weight: ${gw.weight} — ${gw.declaredLayers} layer(s), ${gw.rules} rule(s), ${gw.governedFiles} governed file(s) (${gw.filesPerLayer} files/layer)`,
|
|
477
|
+
});
|
|
478
|
+
rows.push({ mark: 'dim', text: gw.note });
|
|
479
|
+
}
|
|
480
|
+
rows.push({
|
|
481
|
+
mark: 'dim',
|
|
482
|
+
text: 'advisory only — the gate verdict and design fitness are unchanged',
|
|
483
|
+
});
|
|
484
|
+
return rows;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Contract-health summary for doctor JSON / human output. Advisory only.
|
|
489
|
+
* `acknowledged` counts ack entries that MATCHED a detected edge (stale acks count 0).
|
|
490
|
+
*
|
|
491
|
+
* @param {ReturnType<typeof detectContractSmells>} smells
|
|
492
|
+
* @param {ReturnType<typeof loadContractSmellAcks>} ackState
|
|
493
|
+
* @param {number} [matchedAcks]
|
|
494
|
+
*/
|
|
495
|
+
export function summarizeContractHealth(smells, ackState = { exists: false, acks: [] }, matchedAcks = 0) {
|
|
496
|
+
const list = Array.isArray(smells) ? smells : [];
|
|
497
|
+
return {
|
|
498
|
+
status: list.length > 0 ? 'contract-smells' : 'ok',
|
|
499
|
+
smellCount: list.length,
|
|
500
|
+
ids: list.map((s) => s.id),
|
|
501
|
+
acknowledged: ackState?.invalid ? 0 : matchedAcks,
|
|
502
|
+
advisory: true,
|
|
503
|
+
label:
|
|
504
|
+
list.length > 0
|
|
505
|
+
? `Contract health: ${list.length} contract smell(s) — advisory; the gate verdict is unchanged`
|
|
506
|
+
: 'Contract health: no contract smells detected',
|
|
507
|
+
ackFile: {
|
|
508
|
+
path: ackState?.path ?? CONTRACT_SMELL_ACKS_PATH,
|
|
509
|
+
present: ackState?.exists === true,
|
|
510
|
+
invalid: ackState?.invalid === true,
|
|
511
|
+
...(ackState?.invalid ? { error: ackState.error ?? 'invalid' } : {}),
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from './post-green-path.mjs';
|
|
41
41
|
import { loadGoldenPattern, summarizeGoldenPattern } from './golden-pattern.mjs';
|
|
42
42
|
import { summarizePilotLoop } from './pilot-loop.mjs';
|
|
43
|
+
import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
|
|
43
44
|
|
|
44
45
|
const color = {
|
|
45
46
|
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
@@ -424,6 +425,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
424
425
|
patternBets: patternBetsForLoop,
|
|
425
426
|
designSmells,
|
|
426
427
|
});
|
|
428
|
+
// W01 — contract meta-lint over the rules in force. Advisory; never feeds any verdict.
|
|
429
|
+
const contractHealth = computeContractHealth(root, config, cov, rules);
|
|
427
430
|
|
|
428
431
|
if (asJson) {
|
|
429
432
|
console.log(
|
|
@@ -460,6 +463,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
460
463
|
goldenPattern,
|
|
461
464
|
// Q04: one-pilot loop (extraction card → re-doctor).
|
|
462
465
|
pilotLoop,
|
|
466
|
+
// W01: contract-health meta-lint (advisory; verdict unchanged).
|
|
467
|
+
contractHealth,
|
|
463
468
|
governed: cov.governed,
|
|
464
469
|
emptyLayers: cov.emptyLayers,
|
|
465
470
|
layersWithoutRules: cov.layersWithoutRules,
|
|
@@ -491,6 +496,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
491
496
|
capabilities: writePath.capabilities,
|
|
492
497
|
capabilityEvidence: writePath.capabilityEvidence,
|
|
493
498
|
inventory: writePath.inventory,
|
|
499
|
+
enforcementLadder: writePath.enforcementLadder,
|
|
494
500
|
mode: writePath.mode,
|
|
495
501
|
prepareWrite: writePath.prepareWrite,
|
|
496
502
|
autoPatch: writePath.autoPatch,
|
|
@@ -640,6 +646,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
640
646
|
);
|
|
641
647
|
}
|
|
642
648
|
|
|
649
|
+
// W01 — contract health (advisory; verdict unchanged).
|
|
650
|
+
printContractHealthSection(contractHealth, { line, warn, color });
|
|
651
|
+
|
|
643
652
|
console.log('');
|
|
644
653
|
console.log(color.bold('Coverage'));
|
|
645
654
|
const govMark =
|
|
@@ -738,17 +747,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
738
747
|
line(' ', `Active host: ${writePath.activeHost}`);
|
|
739
748
|
line(' ', `Supported profile: ${writePath.supportSummary}`);
|
|
740
749
|
line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
|
|
750
|
+
const ladder = writePath.enforcementLadder;
|
|
751
|
+
const state = (value) => value === true ? 'yes' : value === false ? 'no' : String(value);
|
|
741
752
|
line(
|
|
742
|
-
|
|
743
|
-
`Hard
|
|
753
|
+
ladder.localWrite.installed ? ok : warn,
|
|
754
|
+
`Hard hook — supported: ${state(ladder.localWrite.supported)} · installed: ${state(ladder.localWrite.installed)} · active/trusted: ${state(ladder.localWrite.active)} · bypassable: ${state(ladder.localWrite.bypassable)}`
|
|
744
755
|
);
|
|
745
756
|
line(
|
|
746
757
|
warn,
|
|
747
|
-
`Advisory
|
|
758
|
+
`Advisory MCP — supported: ${state(ladder.advisoryMcp.supported)} · installed: ${state(ladder.advisoryMcp.installed)} · active: ${state(ladder.advisoryMcp.active)} · bypassable: ${state(ladder.advisoryMcp.bypassable)}`
|
|
748
759
|
);
|
|
749
760
|
line(
|
|
750
761
|
capabilities['merge-gate'] ? ok : bad,
|
|
751
|
-
`
|
|
762
|
+
`Merge gate — supported: ${state(ladder.ciMerge.supported)} · installed: ${state(ladder.ciMerge.installed)} · active: ${state(ladder.ciMerge.active)} · bypassable: ${state(ladder.ciMerge.bypassable)} · required status: ${state(ladder.ciMerge.requiredStatus)}`
|
|
752
763
|
);
|
|
753
764
|
line(
|
|
754
765
|
capabilities['repair-payload'] ? ok : warn,
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* reported separately by write-path-capabilities.mjs.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
|
|
9
|
+
function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, repairPayload) {
|
|
10
10
|
return Object.freeze({
|
|
11
11
|
label,
|
|
12
12
|
hookPath,
|
|
13
13
|
hookSurface,
|
|
14
|
+
hookOperations: Object.freeze(hookOperations),
|
|
14
15
|
capabilities: Object.freeze({
|
|
15
16
|
'hard-write': hardWrite,
|
|
16
17
|
'advisory-write': true,
|
|
@@ -25,6 +26,7 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
|
|
|
25
26
|
'Claude Code',
|
|
26
27
|
'.claude/settings.json',
|
|
27
28
|
'PreToolUse `Write` / `Edit` / `MultiEdit`',
|
|
29
|
+
['Write', 'Edit', 'MultiEdit'],
|
|
28
30
|
true,
|
|
29
31
|
true
|
|
30
32
|
),
|
|
@@ -32,14 +34,16 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
|
|
|
32
34
|
'Grok Build',
|
|
33
35
|
'.grok/hooks/ark-write-gate.json',
|
|
34
36
|
'PreToolUse `write` / `search_replace` (plus aliases)',
|
|
37
|
+
['write', 'search_replace'],
|
|
35
38
|
true,
|
|
36
39
|
true
|
|
37
40
|
),
|
|
38
|
-
cursor: hostProfile('Cursor', null, null, false, false),
|
|
41
|
+
cursor: hostProfile('Cursor', null, null, [], false, false),
|
|
39
42
|
codex: hostProfile(
|
|
40
43
|
'OpenAI Codex',
|
|
41
44
|
'.codex/hooks.json',
|
|
42
45
|
'Best-effort PreToolUse `apply_patch`; Code Mode hosts may bypass the event',
|
|
46
|
+
['apply_patch'],
|
|
43
47
|
false,
|
|
44
48
|
false
|
|
45
49
|
),
|