arkgate 2.4.0 → 2.6.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 +46 -0
- package/README.md +2 -1
- package/bin/ark-check.mjs +194 -3867
- package/bin/ark-layer-match.mjs +168 -0
- package/bin/ark-shared.mjs +8 -131
- package/bin/lib/agent-gates.mjs +1550 -0
- package/bin/lib/doctor-plan.mjs +503 -0
- package/bin/lib/html-report.mjs +1301 -0
- package/bin/lib/presets.mjs +244 -0
- package/bin/lib/suggestions.mjs +109 -0
- package/bin/lib/violations.mjs +170 -0
- package/dist/eslint/index.cjs +263 -23
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +54 -1
- package/dist/eslint/index.d.ts +54 -1
- package/dist/eslint/index.js +245 -22
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/docs/agent-guide.md +4 -3
- package/docs/ai-gates.md +15 -29
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage, plan, and doctor CLI surfaces (roadmap #11).
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
arkCommand,
|
|
8
|
+
buildArchitectureRecommendation,
|
|
9
|
+
classifyRemediation,
|
|
10
|
+
layerForFile,
|
|
11
|
+
resolveOperatingMode,
|
|
12
|
+
shouldShowNewHereNudge,
|
|
13
|
+
} from '../ark-shared.mjs';
|
|
14
|
+
import {
|
|
15
|
+
collectAdoptionGaps,
|
|
16
|
+
detectSkillGaps,
|
|
17
|
+
missingGates,
|
|
18
|
+
staleRunnerGateFiles,
|
|
19
|
+
} from './agent-gates.mjs';
|
|
20
|
+
import {
|
|
21
|
+
baselineKey,
|
|
22
|
+
readBaseline,
|
|
23
|
+
summarizeViolations,
|
|
24
|
+
violationEdge,
|
|
25
|
+
} from './violations.mjs';
|
|
26
|
+
import { buildUnclassifiedSuggestions } from './suggestions.mjs';
|
|
27
|
+
|
|
28
|
+
const color = {
|
|
29
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
30
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
31
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
32
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
33
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function normalize(value) {
|
|
37
|
+
return String(value).split(path.sep).join('/');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
export function computeCoverage(root, config, files, rules) {
|
|
43
|
+
const layers = config.layers ?? [];
|
|
44
|
+
const counts = new Map(layers.map((layer) => [layer.name, 0]));
|
|
45
|
+
const unclassified = [];
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const layer = layerForFile(root, file, layers);
|
|
48
|
+
if (layer && counts.has(layer)) counts.set(layer, counts.get(layer) + 1);
|
|
49
|
+
else unclassified.push(normalize(path.relative(root, file)));
|
|
50
|
+
}
|
|
51
|
+
unclassified.sort();
|
|
52
|
+
const layerRows = layers.map((layer) => ({
|
|
53
|
+
name: layer.name,
|
|
54
|
+
patterns: layer.patterns ?? [],
|
|
55
|
+
files: counts.get(layer.name) ?? 0,
|
|
56
|
+
}));
|
|
57
|
+
// A layer whose patterns match zero files is dead config — it enforces nothing, usually a
|
|
58
|
+
// wrong glob (the #1 monorepo mistake). A layer with no rule edge can import anything.
|
|
59
|
+
const emptyLayers = layerRows.filter((row) => row.files === 0).map((row) => row.name);
|
|
60
|
+
const layersWithoutRules = layerRows
|
|
61
|
+
.map((row) => row.name)
|
|
62
|
+
.filter((name) => !rules.some((rule) => rule.from === name || rule.to === name));
|
|
63
|
+
const classifiedFiles = files.length - unclassified.length;
|
|
64
|
+
// Empty scope is NOT "100% governed" — that was a false-green for monorepos/mis-includes
|
|
65
|
+
// (0/0 → ENFORCE). Zero files means the contract is not checking anything yet.
|
|
66
|
+
const fraction = files.length > 0 ? classifiedFiles / files.length : 0;
|
|
67
|
+
return {
|
|
68
|
+
include: config.include ?? [],
|
|
69
|
+
totalFiles: files.length,
|
|
70
|
+
emptyScope: files.length === 0,
|
|
71
|
+
governed: { classifiedFiles, totalFiles: files.length, percent: Math.round(fraction * 100) },
|
|
72
|
+
layers: layerRows,
|
|
73
|
+
unclassified: { count: unclassified.length, files: unclassified },
|
|
74
|
+
suggestions: buildUnclassifiedSuggestions(unclassified),
|
|
75
|
+
emptyLayers,
|
|
76
|
+
layersWithoutRules,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function runCoverage(root, config, files, rules, asJson) {
|
|
81
|
+
const cov = computeCoverage(root, config, files, rules);
|
|
82
|
+
if (asJson) {
|
|
83
|
+
console.log(JSON.stringify({ ok: true, coverage: cov }, null, 2));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const { governed, layers: layerRows, suggestions, layersWithoutRules } = cov;
|
|
87
|
+
const classifiedFiles = governed.classifiedFiles;
|
|
88
|
+
const unclassified = cov.unclassified.files;
|
|
89
|
+
|
|
90
|
+
const nameWidth = Math.max(
|
|
91
|
+
'Layer'.length,
|
|
92
|
+
'(unclassified)'.length,
|
|
93
|
+
...layerRows.map((row) => row.name.length)
|
|
94
|
+
);
|
|
95
|
+
const pad = (value) => value.padEnd(nameWidth);
|
|
96
|
+
console.log(`Ark coverage (include: ${(config.include ?? []).join(', ') || '.'}):`);
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(` ${pad('Layer')} Files`);
|
|
99
|
+
for (const row of layerRows) {
|
|
100
|
+
const flag = row.files === 0 ? ' (pattern matches nothing)' : '';
|
|
101
|
+
console.log(` ${pad(row.name)} ${String(row.files).padStart(5)}${flag}`);
|
|
102
|
+
}
|
|
103
|
+
console.log(` ${pad('(unclassified)')} ${String(unclassified.length).padStart(5)}`);
|
|
104
|
+
console.log('');
|
|
105
|
+
console.log(
|
|
106
|
+
`${files.length} source file(s) in scope; ${unclassified.length} not matched by any layer.`
|
|
107
|
+
);
|
|
108
|
+
console.log(`Governed: ${governed.percent}% (${classifiedFiles}/${files.length} files).`);
|
|
109
|
+
if (files.length > 0 && governed.percent < 50) {
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log(
|
|
112
|
+
`⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check here does NOT`
|
|
113
|
+
);
|
|
114
|
+
console.log(' mean the codebase is checked — the rest is ungoverned. Classify the directories');
|
|
115
|
+
console.log(' below to actually cover it.');
|
|
116
|
+
}
|
|
117
|
+
if (suggestions.length > 0) {
|
|
118
|
+
console.log('');
|
|
119
|
+
console.log('Ungoverned directories (proposed layer — from the 11-layer profile + presets):');
|
|
120
|
+
for (const s of suggestions) {
|
|
121
|
+
const count = `(${s.files})`.padStart(6);
|
|
122
|
+
if (s.unrecognized) {
|
|
123
|
+
console.log(` ${count} ${s.dir}/ — unrecognized, you classify`);
|
|
124
|
+
} else {
|
|
125
|
+
const alt = s.alternatives ? ` (or ${s.alternatives.join(' / ')})` : '';
|
|
126
|
+
console.log(` ${count} ${s.dir}/ → ${s.layer}${alt}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log('Apply these via /ark-contract (adds the layer patterns to ark.config.json).');
|
|
131
|
+
}
|
|
132
|
+
if (layersWithoutRules.length > 0) {
|
|
133
|
+
console.log('');
|
|
134
|
+
console.log(`Layers with no rule edge (can import anything): ${layersWithoutRules.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --doctor: one consolidated health view — coverage, violations, gates, skills, baseline,
|
|
139
|
+
// and command runners — each with the exact command to fix it. Folds the data the other
|
|
140
|
+
// modes already produce so a team sees "what state is my Ark adoption in?" at a glance.
|
|
141
|
+
// Co-pilot Phase F — turn active violations into a classified, ordered remediation PLAN with an
|
|
142
|
+
// embedded GOAL. This is the `plan` primitive the future apply-loop (Phase H, `loop`) consumes
|
|
143
|
+
// and the autopilot (Phase I) drives toward the `goal`. Read-only: it changes no files.
|
|
144
|
+
export function buildRemediationPlan(root, activeViolations, governedPercent = null, totalFiles = null) {
|
|
145
|
+
// A plan with 0 violations but ~0% governed (or ZERO files in scope) is a FALSE green:
|
|
146
|
+
// nothing is actually being checked. Treat as "not done — classify / fix include first."
|
|
147
|
+
const governedLow = governedPercent != null && governedPercent < 50;
|
|
148
|
+
const emptyScope = totalFiles === 0;
|
|
149
|
+
const notHonestlyEnforced = governedLow || emptyScope;
|
|
150
|
+
const steps = activeViolations.map((v, index) => {
|
|
151
|
+
const verdict = classifyRemediation(v);
|
|
152
|
+
return {
|
|
153
|
+
id: `${v.ruleId}:${v.file}:${v.line ?? 0}:${index}`,
|
|
154
|
+
class: verdict.class,
|
|
155
|
+
confidence: verdict.confidence,
|
|
156
|
+
rationale: verdict.rationale,
|
|
157
|
+
ruleId: v.ruleId,
|
|
158
|
+
edge: violationEdge(v),
|
|
159
|
+
file: v.file,
|
|
160
|
+
...(v.line ? { line: v.line } : {}),
|
|
161
|
+
...(v.target ? { target: v.target } : {}),
|
|
162
|
+
...(v.typeOnly ? { typeOnly: true } : {}),
|
|
163
|
+
...(v.targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
|
|
164
|
+
...(v.sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
|
|
165
|
+
...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
// Order: auto-applicable first (quick, safe wins), then human decisions, then deferred.
|
|
169
|
+
const rank = { 'mechanical-safe': 0, judgment: 1, deferred: 2 };
|
|
170
|
+
steps.sort((a, b) => rank[a.class] - rank[b.class]);
|
|
171
|
+
const countOf = (cls) => steps.filter((s) => s.class === cls).length;
|
|
172
|
+
const counts = {
|
|
173
|
+
mechanicalSafe: countOf('mechanical-safe'),
|
|
174
|
+
judgment: countOf('judgment'),
|
|
175
|
+
deferred: countOf('deferred'),
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
version: '1',
|
|
179
|
+
goal: {
|
|
180
|
+
statement:
|
|
181
|
+
activeViolations.length > 0
|
|
182
|
+
? `Resolve ${activeViolations.length} architecture violation(s) without weakening the contract.`
|
|
183
|
+
: emptyScope
|
|
184
|
+
? 'No source files matched the contract include paths — this "clean" result checks nothing. Fix include/layers (monorepo → apps/packages, or /ark-adopt) so Ark has real code to govern.'
|
|
185
|
+
: governedLow
|
|
186
|
+
? `No violations — but Ark governs only ${governedPercent}% of your code, so this "clean" result checks almost nothing. Classify the rest (ark-check --coverage, then /ark-adopt) so it's actually enforced.`
|
|
187
|
+
: 'No active violations — the architecture already meets its contract.',
|
|
188
|
+
// The loop's termination signal (Phase H): nothing left to remediate AND the contract
|
|
189
|
+
// actually governs real code. Empty scope or low coverage is not "met".
|
|
190
|
+
met: activeViolations.length === 0 && !notHonestlyEnforced,
|
|
191
|
+
...(governedPercent != null ? { governedPercent } : {}),
|
|
192
|
+
...(totalFiles != null ? { totalFiles } : {}),
|
|
193
|
+
...(emptyScope ? { emptyScope: true } : {}),
|
|
194
|
+
activeViolations: activeViolations.length,
|
|
195
|
+
autoApplicable: counts.mechanicalSafe,
|
|
196
|
+
needsDecision: counts.judgment,
|
|
197
|
+
deferred: counts.deferred,
|
|
198
|
+
},
|
|
199
|
+
counts,
|
|
200
|
+
steps,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// `--plan`: print the classified remediation plan. Dual-focus output — a one-line headline
|
|
205
|
+
// anyone can read, then the per-step detail a developer acts on. Read-only.
|
|
206
|
+
export function runPlan(root, activeViolations, asJson, governedPercent = null, totalFiles = null) {
|
|
207
|
+
const plan = buildRemediationPlan(root, activeViolations, governedPercent, totalFiles);
|
|
208
|
+
// Honesty: a zero-violation plan with almost nothing governed is NOT "ok".
|
|
209
|
+
const planOk = plan.goal.met === true;
|
|
210
|
+
if (asJson) {
|
|
211
|
+
console.log(JSON.stringify({ ok: planOk, plan }, null, 2));
|
|
212
|
+
return plan;
|
|
213
|
+
}
|
|
214
|
+
console.log(color.bold(`Ark plan — ${path.basename(path.resolve(root)) || '.'}`));
|
|
215
|
+
console.log('');
|
|
216
|
+
console.log(plan.goal.statement);
|
|
217
|
+
if (governedPercent != null) {
|
|
218
|
+
const pctLabel =
|
|
219
|
+
governedPercent < 50
|
|
220
|
+
? color.yellow(`Governed: ${governedPercent}% of in-scope files`)
|
|
221
|
+
: color.dim(`Governed: ${governedPercent}% of in-scope files`);
|
|
222
|
+
console.log(pctLabel);
|
|
223
|
+
}
|
|
224
|
+
if (activeViolations.length === 0) return plan;
|
|
225
|
+
console.log('');
|
|
226
|
+
console.log(
|
|
227
|
+
` ${color.green(`${plan.counts.mechanicalSafe} safe to auto-apply`)} · ` +
|
|
228
|
+
`${color.yellow(`${plan.counts.judgment} need your decision`)} · ` +
|
|
229
|
+
`${color.dim(`${plan.counts.deferred} deferred`)}`
|
|
230
|
+
);
|
|
231
|
+
console.log('');
|
|
232
|
+
const tag = {
|
|
233
|
+
'mechanical-safe': color.green('auto '),
|
|
234
|
+
judgment: color.yellow('decide'),
|
|
235
|
+
deferred: color.dim('defer '),
|
|
236
|
+
};
|
|
237
|
+
for (const step of plan.steps) {
|
|
238
|
+
const where = `${step.file}${step.line ? `:${step.line}` : ''}`;
|
|
239
|
+
console.log(` [${tag[step.class]}] ${step.edge} ${color.dim(where)}`);
|
|
240
|
+
console.log(color.dim(` ${step.rationale}`));
|
|
241
|
+
}
|
|
242
|
+
console.log('');
|
|
243
|
+
console.log(
|
|
244
|
+
color.dim(
|
|
245
|
+
'Plan only — no files changed. "auto" = an agent can safely apply it; "decide" = your call.'
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
return plan;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function runDoctor(root, config, files, rules, violations, asJson, options = {}) {
|
|
252
|
+
const cov = computeCoverage(root, config, files, rules);
|
|
253
|
+
const summary = summarizeViolations(violations);
|
|
254
|
+
const configPath = options.configPath ?? path.join(root, 'ark.config.json');
|
|
255
|
+
const configMissing = options.configMissing ?? !fs.existsSync(configPath);
|
|
256
|
+
const showNewHere = shouldShowNewHereNudge(root, configPath, cov.governed.percent, configMissing);
|
|
257
|
+
let recommendation;
|
|
258
|
+
if (showNewHere) {
|
|
259
|
+
try {
|
|
260
|
+
recommendation = buildArchitectureRecommendation(root);
|
|
261
|
+
} catch {
|
|
262
|
+
recommendation = undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const gatesMissing = missingGates(root);
|
|
266
|
+
const skillGaps = detectSkillGaps(root);
|
|
267
|
+
const staleRunners = staleRunnerGateFiles(root);
|
|
268
|
+
const adoption = collectAdoptionGaps(root, config, cov);
|
|
269
|
+
const baseline = readBaseline(root, '.ark-baseline.json');
|
|
270
|
+
const currentKeys = new Set(violations.map(baselineKey));
|
|
271
|
+
const suppressed = baseline.exists
|
|
272
|
+
? violations.filter((v) => baseline.keys.has(baselineKey(v))).length
|
|
273
|
+
: 0;
|
|
274
|
+
const staleBaseline = baseline.exists
|
|
275
|
+
? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
|
|
276
|
+
: 0;
|
|
277
|
+
const activeCount = violations.length - suppressed;
|
|
278
|
+
const missingSkills = skillGaps.reduce((sum, gap) => sum + gap.missing, 0);
|
|
279
|
+
const staleSkills = skillGaps.reduce((sum, gap) => sum + gap.stale, 0);
|
|
280
|
+
|
|
281
|
+
if (asJson) {
|
|
282
|
+
console.log(
|
|
283
|
+
JSON.stringify(
|
|
284
|
+
{
|
|
285
|
+
ok: true,
|
|
286
|
+
doctor: {
|
|
287
|
+
operatingMode: resolveOperatingMode({
|
|
288
|
+
governedPercent: cov.governed.percent,
|
|
289
|
+
planMet: activeCount === 0 && cov.governed.percent >= 50,
|
|
290
|
+
mature: cov.governed.totalFiles >= 150,
|
|
291
|
+
}),
|
|
292
|
+
governed: cov.governed,
|
|
293
|
+
emptyLayers: cov.emptyLayers,
|
|
294
|
+
layersWithoutRules: cov.layersWithoutRules,
|
|
295
|
+
ungovernedDirs: cov.suggestions.length,
|
|
296
|
+
violations: {
|
|
297
|
+
total: violations.length,
|
|
298
|
+
active: activeCount,
|
|
299
|
+
suppressed,
|
|
300
|
+
value: summary.valueCount,
|
|
301
|
+
typeOnly: summary.typeOnlyCount,
|
|
302
|
+
concentrated: summary.concentrated,
|
|
303
|
+
dominant: summary.dominant,
|
|
304
|
+
topEdges: summary.edges.slice(0, 5),
|
|
305
|
+
},
|
|
306
|
+
baseline: {
|
|
307
|
+
exists: baseline.exists,
|
|
308
|
+
frozen: baseline.exists ? baseline.keys.size : 0,
|
|
309
|
+
stale: staleBaseline,
|
|
310
|
+
policy: adoption.baseline,
|
|
311
|
+
},
|
|
312
|
+
gatesMissing,
|
|
313
|
+
skillGaps,
|
|
314
|
+
staleRunnerFiles: staleRunners,
|
|
315
|
+
adoption,
|
|
316
|
+
newHere: showNewHere
|
|
317
|
+
? {
|
|
318
|
+
show: true,
|
|
319
|
+
archetype: recommendation?.archetype,
|
|
320
|
+
label: recommendation?.label,
|
|
321
|
+
preset: recommendation?.preset,
|
|
322
|
+
recommendCommand: arkCommand(root, 'ark-check', '--recommend'),
|
|
323
|
+
initCommand: recommendation?.archetype
|
|
324
|
+
? arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)
|
|
325
|
+
: undefined,
|
|
326
|
+
}
|
|
327
|
+
: { show: false },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
null,
|
|
331
|
+
2
|
|
332
|
+
)
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const ok = color.green('✓');
|
|
338
|
+
const warn = color.yellow('!');
|
|
339
|
+
const bad = color.red('✗');
|
|
340
|
+
const actions = [];
|
|
341
|
+
const line = (mark, text) => console.log(` ${mark} ${text}`);
|
|
342
|
+
|
|
343
|
+
console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
|
|
344
|
+
|
|
345
|
+
const emptyScope = cov.governed.totalFiles === 0;
|
|
346
|
+
const mode = resolveOperatingMode({
|
|
347
|
+
governedPercent: emptyScope ? 0 : cov.governed.percent,
|
|
348
|
+
planMet:
|
|
349
|
+
activeCount === 0 && !emptyScope && cov.governed.percent >= 50,
|
|
350
|
+
mature: cov.governed.totalFiles >= 150,
|
|
351
|
+
});
|
|
352
|
+
console.log('');
|
|
353
|
+
console.log(color.bold('Operating mode'));
|
|
354
|
+
const modeMark = mode === 'enforce' ? ok : mode === 'adapt' ? warn : warn;
|
|
355
|
+
const modeHelp = {
|
|
356
|
+
suggest: 'starter shape / thin tree — expand layers as you grow',
|
|
357
|
+
adapt: 'contract still needs to match real layout or raise coverage',
|
|
358
|
+
enforce: 'contract governs enough code; gates can honestly hold the line',
|
|
359
|
+
};
|
|
360
|
+
line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
|
|
361
|
+
if (emptyScope) {
|
|
362
|
+
line(
|
|
363
|
+
bad,
|
|
364
|
+
'Empty scope: include paths match 0 source files — a green check is meaningless until include/layers match the tree (monorepo → apps/packages, or /ark-adopt).'
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
console.log('');
|
|
369
|
+
console.log(color.bold('Coverage'));
|
|
370
|
+
const govMark =
|
|
371
|
+
emptyScope || cov.governed.percent < 50
|
|
372
|
+
? bad
|
|
373
|
+
: cov.governed.percent >= 80
|
|
374
|
+
? ok
|
|
375
|
+
: warn;
|
|
376
|
+
line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
|
|
377
|
+
if (cov.suggestions.length > 0) {
|
|
378
|
+
line(warn, `${cov.suggestions.length} ungoverned director(y/ies) — proposals: ${arkCommand(root, 'ark-check', '--coverage')}`);
|
|
379
|
+
actions.push('classify the ungoverned directories (/ark-contract)');
|
|
380
|
+
}
|
|
381
|
+
if (cov.emptyLayers.length > 0) line(warn, `Empty layers (pattern matches nothing): ${cov.emptyLayers.join(', ')}`);
|
|
382
|
+
if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
|
|
383
|
+
if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
|
|
384
|
+
|
|
385
|
+
if (showNewHere) {
|
|
386
|
+
console.log('');
|
|
387
|
+
console.log(color.bold('New here?'));
|
|
388
|
+
if (recommendation) {
|
|
389
|
+
line(warn, `Suggested application shape: ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
|
|
390
|
+
} else {
|
|
391
|
+
line(warn, 'Low governed coverage or fresh config — pick an application shape before adding code.');
|
|
392
|
+
}
|
|
393
|
+
line(ok, `See the plan: ${arkCommand(root, 'ark-check', '--recommend')}`);
|
|
394
|
+
if (recommendation?.archetype) {
|
|
395
|
+
line(ok, `Quick setup: ${arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)}`);
|
|
396
|
+
}
|
|
397
|
+
actions.unshift('run ark-check --recommend or /ark-architect to choose your application shape');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log('');
|
|
401
|
+
console.log(color.bold('Violations'));
|
|
402
|
+
if (violations.length === 0) {
|
|
403
|
+
// Avoid false confidence when the contract barely covers the tree.
|
|
404
|
+
if (emptyScope || cov.governed.percent < 50) {
|
|
405
|
+
line(
|
|
406
|
+
warn,
|
|
407
|
+
'No active violations — coverage is still thin, so green is not yet honest enforcement'
|
|
408
|
+
);
|
|
409
|
+
} else {
|
|
410
|
+
line(ok, 'None — the code matches the contract');
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
const typeNote = summary.typeOnlyCount > 0 ? ` (${summary.valueCount} value · ${summary.typeOnlyCount} type-only)` : '';
|
|
414
|
+
const supNote = suppressed > 0 ? `, ${suppressed} frozen` : '';
|
|
415
|
+
line(
|
|
416
|
+
activeCount > 0 ? warn : ok,
|
|
417
|
+
`${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
|
|
418
|
+
);
|
|
419
|
+
for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
|
|
420
|
+
if (summary.concentrated) {
|
|
421
|
+
line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
|
|
422
|
+
}
|
|
423
|
+
if (activeCount > 0) {
|
|
424
|
+
actions.push(
|
|
425
|
+
`resolve the non-baselined violations — see the classified plan (${arkCommand(root, 'ark-check', '--plan')}), then /ark-fix`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
console.log('');
|
|
431
|
+
console.log(color.bold('Gates & skills'));
|
|
432
|
+
if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
|
|
433
|
+
else {
|
|
434
|
+
line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
|
|
435
|
+
actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
|
|
436
|
+
}
|
|
437
|
+
if (missingSkills + staleSkills === 0) line(ok, '/ark-* skills current for detected tools');
|
|
438
|
+
else {
|
|
439
|
+
line(warn, `${missingSkills} missing / ${staleSkills} outdated /ark-* skill(s) for ${skillGaps.map((g) => g.tool).join(', ')}`);
|
|
440
|
+
actions.push('refresh /ark-* skills (--install-agent-gates --skills-only --force)');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
console.log('');
|
|
444
|
+
console.log(color.bold('Baseline'));
|
|
445
|
+
if (!baseline.exists) {
|
|
446
|
+
line(violations.length > 0 ? warn : ok, violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline' : 'No baseline (nothing to freeze)');
|
|
447
|
+
} else {
|
|
448
|
+
// Baseline keys are line-agnostic, so N keys can suppress ≥N violations — label as keys
|
|
449
|
+
// to avoid an apparent mismatch with the "frozen" violation count above.
|
|
450
|
+
line(ok, `${baseline.keys.size} frozen key(s)`);
|
|
451
|
+
if (staleBaseline > 0) {
|
|
452
|
+
line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
|
|
453
|
+
actions.push('tighten the baseline (--update-baseline)');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
console.log('');
|
|
458
|
+
console.log(color.bold('Command runners'));
|
|
459
|
+
if (staleRunners.length === 0) line(ok, 'Emitted commands match the package manager');
|
|
460
|
+
else {
|
|
461
|
+
line(warn, `Stale runner in ${staleRunners.join(', ')}`);
|
|
462
|
+
actions.push(`migrate command runners (${arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands')})`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Adoption completeness (hosts, MCP health, codex home, core optionality, origin, baseline policy)
|
|
466
|
+
console.log('');
|
|
467
|
+
console.log(color.bold('Adoption (separate from fitness score)'));
|
|
468
|
+
if (adoption.gaps.length === 0 && !adoption.layerBalance) {
|
|
469
|
+
line(ok, 'Hosts, MCP argv, core optionality, origin report, and baseline policy look complete');
|
|
470
|
+
} else {
|
|
471
|
+
for (const gap of adoption.gaps) {
|
|
472
|
+
const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
|
|
473
|
+
line(mark, gap.message);
|
|
474
|
+
if (gap.fix) line(' ', color.dim(`Fix: ${gap.fix}`));
|
|
475
|
+
actions.push(gap.fix || gap.message);
|
|
476
|
+
}
|
|
477
|
+
if (adoption.layerBalance) {
|
|
478
|
+
line(warn, color.dim(adoption.layerBalance.educational));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (adoption.baseline) {
|
|
482
|
+
line(
|
|
483
|
+
' ',
|
|
484
|
+
color.dim(
|
|
485
|
+
`Baseline policy: ${adoption.baseline.signal}` +
|
|
486
|
+
(adoption.baseline.primaryPathUsesBaseline
|
|
487
|
+
? ' · primary path uses --baseline'
|
|
488
|
+
: ' · primary path does not use --baseline')
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (adoption.originReport.present) {
|
|
493
|
+
line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
console.log('');
|
|
497
|
+
if (actions.length === 0) {
|
|
498
|
+
console.log(color.green('✔ Healthy — nothing to do.'));
|
|
499
|
+
} else {
|
|
500
|
+
console.log(color.bold(`Top actions (${actions.length}):`));
|
|
501
|
+
actions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
|
|
502
|
+
}
|
|
503
|
+
}
|