arkgate 2.13.0 → 3.0.1
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 +59 -0
- package/README.md +53 -36
- package/bin/ark-check.mjs +72 -6
- package/bin/ark-mcp.mjs +108 -1
- package/bin/ark-shared.mjs +204 -149
- package/bin/ark.mjs +90 -25
- package/bin/lib/adapter-contract.mjs +93 -0
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/analysis-engine.mjs +1171 -0
- package/bin/lib/architecture-scan.mjs +84 -135
- package/bin/lib/ci-and-commands.mjs +51 -15
- package/bin/lib/config-warnings.mjs +7 -205
- package/bin/lib/design-smells.mjs +434 -0
- package/bin/lib/doctor-plan.mjs +149 -16
- package/bin/lib/field-install.mjs +67 -10
- package/bin/lib/gate-files.mjs +42 -3
- package/bin/lib/graph-cycles.mjs +4 -54
- package/bin/lib/hook-templates.mjs +33 -1
- package/bin/lib/host-support-matrix.mjs +7 -1
- package/bin/lib/install-migrate.mjs +54 -16
- package/bin/lib/presets.mjs +42 -2
- package/bin/lib/safety-diagnostics.mjs +18 -17
- package/bin/lib/scan-files.mjs +12 -1
- package/bin/lib/skill-install.mjs +8 -1
- package/bin/lib/source-policy.mjs +36 -0
- package/bin/lib/start-preview.mjs +271 -0
- package/bin/lib/ts-resolve.mjs +11 -2
- package/bin/lib/write-path-capabilities.mjs +4 -0
- package/compat/nestjs.cjs +2 -0
- package/compat/nestjs.d.ts +2 -0
- package/compat/nestjs.js +1 -0
- package/compat/runtime.cjs +2 -0
- package/compat/runtime.d.ts +2 -0
- package/compat/runtime.js +1 -0
- package/dist/configContract-BxSIwVRo.d.cts +259 -0
- package/dist/configContract-BxSIwVRo.d.ts +259 -0
- package/dist/eslint/index.cjs +125 -48
- package/dist/eslint/index.d.cts +7 -1
- package/dist/eslint/index.d.ts +7 -1
- package/dist/eslint/index.js +125 -48
- package/dist/index.cjs +1248 -3302
- package/dist/index.d.cts +359 -483
- package/dist/index.d.ts +359 -483
- package/dist/index.js +1231 -3248
- package/docs/agent-guide.md +34 -16
- package/docs/ai-gates.md +30 -7
- package/docs/brownfield-adoption.md +52 -1
- package/docs/migrate-from-ark-runtime-kernel.md +2 -3
- package/docs/package-surface.md +10 -13
- package/docs/production-hardening.md +17 -4
- package/docs/typescript-support.md +27 -0
- package/package.json +33 -11
- package/schemas/ark.analysis-result.schema.json +91 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +26 -3
- package/templates/skills/ark-architect.md +10 -2
- package/templates/skills/ark-autopilot.md +37 -20
- package/templates/skills/ark-contract.md +7 -0
- package/templates/skills/ark-coverage.md +44 -45
- package/templates/skills/ark-explain.md +8 -0
- package/templates/skills/ark-explore.md +117 -47
- package/templates/skills/ark-fix.md +22 -0
- package/templates/skills/ark-loop.md +15 -1
- package/templates/skills/ark-place.md +7 -0
- package/templates/skills/ark-think.md +24 -20
- package/dist/configContract-iBLxx5Tz.d.cts +0 -53
- package/dist/configContract-iBLxx5Tz.d.ts +0 -53
- package/dist/eslint/index.cjs.map +0 -1
- package/dist/eslint/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nestjs/index.cjs +0 -2606
- package/dist/nestjs/index.cjs.map +0 -1
- package/dist/nestjs/index.d.cts +0 -23
- package/dist/nestjs/index.d.ts +0 -23
- package/dist/nestjs/index.js +0 -2582
- package/dist/nestjs/index.js.map +0 -1
- package/dist/runtime/index.cjs +0 -4014
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/runtime/index.d.cts +0 -3
- package/dist/runtime/index.d.ts +0 -3
- package/dist/runtime/index.js +0 -3925
- package/dist/runtime/index.js.map +0 -1
- package/dist/types-BxBwnBpC.d.cts +0 -1041
- package/dist/types-Wcs_l1_J.d.ts +0 -1041
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic design-smell sensors (Phase P / P02).
|
|
3
|
+
*
|
|
4
|
+
* Pure-ish filesystem heuristics: contract edges can be clean while lived design
|
|
5
|
+
* is weak (god modules, I/O in routes, concurrent layouts). Never invents
|
|
6
|
+
* mechanical-safe remediations — smells feed doctor honesty + plan B only.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { layerForFile } from '../ark-shared.mjs';
|
|
11
|
+
import { detectContractFalseGreenRisk } from './field-install.mjs';
|
|
12
|
+
|
|
13
|
+
/** Stable smell ids (doctor JSON + plan B + skills). */
|
|
14
|
+
export const DESIGN_SMELL_IDS = Object.freeze([
|
|
15
|
+
'io-under-application',
|
|
16
|
+
'handler-in-persistence',
|
|
17
|
+
'god-module',
|
|
18
|
+
'domain-logic-in-ui',
|
|
19
|
+
'facade-sql-in-routes',
|
|
20
|
+
'mixed-pattern-cluster',
|
|
21
|
+
'soft-contract',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const IO_IMPORT_RE =
|
|
25
|
+
/\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm|typeorm|knex|mongodb|pg|mysql2|better-sqlite3|ioredis|redis)['"]|require\(\s*['"](?:@?prisma\/client|pg|knex|typeorm)/;
|
|
26
|
+
const HANDLER_CONTENT_RE =
|
|
27
|
+
/\b(?:@Controller|@Get|@Post|@Put|@Delete|Router\(\)|createRouter|express\.Router|fastify\.(?:get|post)|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b|export\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=)/;
|
|
28
|
+
const DOMAIN_LOGIC_UI_RE =
|
|
29
|
+
/\b(?:export\s+)?(?:async\s+)?function\s+(?:can|calculate|compute|should)[A-Z]\w*|\b(?:export\s+)?const\s+(?:can|calculate|compute|should)[A-Z]\w*\s*=/;
|
|
30
|
+
const EXPORT_RE =
|
|
31
|
+
/\bexport\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum|default)\b|\bexport\s*\{/g;
|
|
32
|
+
|
|
33
|
+
const PERSISTENCE_PATH_RE =
|
|
34
|
+
/(?:^|\/)(?:repositories?|persistence|infra\/(?:db|data|persistence)|adapters\/(?:persistence|repository)|data-access)(?:\/|$)/i;
|
|
35
|
+
const UI_PATH_RE =
|
|
36
|
+
/(?:^|\/)(?:components?|pages|hooks|ui|views|screens|app\/(?:\(.*\)\/)?[^/]+\/page\.|app\/.*\/page\.)/i;
|
|
37
|
+
const ROUTE_PATH_RE =
|
|
38
|
+
/(?:^|\/)(?:routes?|controllers?|api\/|pages\/api\/|app\/api\/|handlers?)(?:\/|$)|(?:route|controller|handler)\.(?:ts|tsx|js|jsx)$/i;
|
|
39
|
+
|
|
40
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
41
|
+
const MAX_SCAN_FILES = 800;
|
|
42
|
+
const GOD_LOC = 400;
|
|
43
|
+
const GOD_EXPORTS = 12;
|
|
44
|
+
|
|
45
|
+
function normalizeRel(root, filePath) {
|
|
46
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
47
|
+
let rel = path.relative(root, abs).split(path.sep).join('/');
|
|
48
|
+
if (rel.startsWith('./')) rel = rel.slice(2);
|
|
49
|
+
return rel;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readTextLimited(absPath) {
|
|
53
|
+
try {
|
|
54
|
+
const st = fs.statSync(absPath);
|
|
55
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) return null;
|
|
56
|
+
return fs.readFileSync(absPath, 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function countExports(source) {
|
|
63
|
+
if (!source) return 0;
|
|
64
|
+
const matches = source.match(EXPORT_RE);
|
|
65
|
+
return matches ? matches.length : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function countLines(source) {
|
|
69
|
+
if (!source) return 0;
|
|
70
|
+
let n = 1;
|
|
71
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
72
|
+
if (source.charCodeAt(i) === 10) n += 1;
|
|
73
|
+
}
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function layerNameFor(root, rel, config) {
|
|
78
|
+
try {
|
|
79
|
+
// CLI layerForFile(root, file, layers) — file may be absolute or relative.
|
|
80
|
+
return layerForFile(root, rel, config?.layers ?? []) ?? null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isApplicationLayer(name) {
|
|
87
|
+
return typeof name === 'string' && /application|orchestr/i.test(name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPresentationLayer(name) {
|
|
91
|
+
return typeof name === 'string' && /presentation|ui|view/i.test(name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isPersistenceLayer(name) {
|
|
95
|
+
return (
|
|
96
|
+
typeof name === 'string' &&
|
|
97
|
+
(/persist|repository|infra|data.?access/i.test(name) || name === 'PersistenceAdapters')
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @typedef {object} DesignSmell
|
|
103
|
+
* @property {string} id
|
|
104
|
+
* @property {'warn'|'info'} severity
|
|
105
|
+
* @property {string} message
|
|
106
|
+
* @property {string[]} evidence
|
|
107
|
+
* @property {string} fix
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Detect design smells for a project tree.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} root
|
|
114
|
+
* @param {object} config ark.config
|
|
115
|
+
* @param {string[]} files absolute or root-relative source paths
|
|
116
|
+
* @param {object|null} coverage computeCoverage result (optional)
|
|
117
|
+
* @returns {DesignSmell[]}
|
|
118
|
+
*/
|
|
119
|
+
export function detectDesignSmells(root, config, files = [], coverage = null) {
|
|
120
|
+
const smells = [];
|
|
121
|
+
const resolvedRoot = path.resolve(root);
|
|
122
|
+
const relFiles = [];
|
|
123
|
+
for (const f of files.slice(0, MAX_SCAN_FILES)) {
|
|
124
|
+
const rel = normalizeRel(resolvedRoot, f);
|
|
125
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
126
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel)) continue;
|
|
127
|
+
if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
|
|
128
|
+
relFiles.push(rel);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// soft-contract: layers with files but no rule edges
|
|
132
|
+
const withoutRules = Array.isArray(coverage?.layersWithoutRules)
|
|
133
|
+
? coverage.layersWithoutRules
|
|
134
|
+
: [];
|
|
135
|
+
if (withoutRules.length > 0) {
|
|
136
|
+
smells.push({
|
|
137
|
+
id: 'soft-contract',
|
|
138
|
+
severity: 'warn',
|
|
139
|
+
message: `Layers classify files but have no deny/allow rule edges: ${withoutRules.join(', ')}. Soft green — peer leaks may go unchecked.`,
|
|
140
|
+
evidence: withoutRules.map((n) => `layer:${n}`),
|
|
141
|
+
fix: 'Add rules via /ark-contract (or a policy pack) so every populated layer participates in enforcement.',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Classic false-green I/O under Application (reuse detector when coverage present)
|
|
146
|
+
const falseGreen = detectContractFalseGreenRisk(resolvedRoot, config, coverage ?? {});
|
|
147
|
+
if (falseGreen?.risk) {
|
|
148
|
+
smells.push({
|
|
149
|
+
id: 'io-under-application',
|
|
150
|
+
severity: 'warn',
|
|
151
|
+
message: falseGreen.message,
|
|
152
|
+
evidence: (falseGreen.ioPaths || []).slice(0, 12),
|
|
153
|
+
fix: falseGreen.fix,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const godEvidence = [];
|
|
158
|
+
const handlerInPersist = [];
|
|
159
|
+
const domainInUi = [];
|
|
160
|
+
const facadeSql = [];
|
|
161
|
+
const ioUnderAppFiles = [];
|
|
162
|
+
|
|
163
|
+
let hasFeaturesLayout = false;
|
|
164
|
+
let hasFlatServices = false;
|
|
165
|
+
let hasHexPorts = false;
|
|
166
|
+
|
|
167
|
+
for (const rel of relFiles) {
|
|
168
|
+
if (/\/features\/[^/]+\//.test(rel) || /^features\//.test(rel)) hasFeaturesLayout = true;
|
|
169
|
+
if (/\/(?:services|modules)\/[^/]+\//.test(rel) || /(?:^|\/)services\/[^/]+\.(?:ts|tsx)$/.test(rel)) {
|
|
170
|
+
hasFlatServices = true;
|
|
171
|
+
}
|
|
172
|
+
if (/\/(?:domain|application|infrastructure|adapters)\//.test(rel)) hasHexPorts = true;
|
|
173
|
+
|
|
174
|
+
const abs = path.join(resolvedRoot, rel);
|
|
175
|
+
const source = readTextLimited(abs);
|
|
176
|
+
if (source == null) continue;
|
|
177
|
+
|
|
178
|
+
const layer = layerNameFor(resolvedRoot, rel, config);
|
|
179
|
+
const loc = countLines(source);
|
|
180
|
+
const exportsCount = countExports(source);
|
|
181
|
+
|
|
182
|
+
if (loc >= GOD_LOC && exportsCount >= GOD_EXPORTS) {
|
|
183
|
+
godEvidence.push(rel);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
(PERSISTENCE_PATH_RE.test(rel) || isPersistenceLayer(layer)) &&
|
|
188
|
+
HANDLER_CONTENT_RE.test(source)
|
|
189
|
+
) {
|
|
190
|
+
handlerInPersist.push(rel);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && DOMAIN_LOGIC_UI_RE.test(source)) {
|
|
194
|
+
domainInUi.push(rel);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (ROUTE_PATH_RE.test(rel) && IO_IMPORT_RE.test(source)) {
|
|
198
|
+
facadeSql.push(rel);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
!falseGreen?.risk &&
|
|
203
|
+
isApplicationLayer(layer) &&
|
|
204
|
+
IO_IMPORT_RE.test(source) &&
|
|
205
|
+
!/port|adapter|repository/i.test(path.basename(rel))
|
|
206
|
+
) {
|
|
207
|
+
ioUnderAppFiles.push(rel);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!falseGreen?.risk && ioUnderAppFiles.length > 0) {
|
|
212
|
+
smells.push({
|
|
213
|
+
id: 'io-under-application',
|
|
214
|
+
severity: 'warn',
|
|
215
|
+
message: `Application-layer files import database/client SDKs directly (${ioUnderAppFiles.length} file(s)). Prefer ports in Domain + adapters outside Application.`,
|
|
216
|
+
evidence: ioUnderAppFiles.slice(0, 12),
|
|
217
|
+
fix: 'Extract a port + adapter (extraction card); do not weaken ark.config to silence the smell.',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (handlerInPersist.length > 0) {
|
|
222
|
+
smells.push({
|
|
223
|
+
id: 'handler-in-persistence',
|
|
224
|
+
severity: 'warn',
|
|
225
|
+
message: `HTTP/route handler shape found under persistence/repository paths (${handlerInPersist.length} file(s)) — semantic false-green risk.`,
|
|
226
|
+
evidence: handlerInPersist.slice(0, 12),
|
|
227
|
+
fix: 'Move handlers to Presentation/API; keep Persistence as data access only (/ark-explore shape-focus).',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (godEvidence.length > 0) {
|
|
232
|
+
smells.push({
|
|
233
|
+
id: 'god-module',
|
|
234
|
+
severity: 'warn',
|
|
235
|
+
message: `God-module candidates: large files with wide export surfaces (${godEvidence.length} file(s), ≥${GOD_LOC} LOC and ≥${GOD_EXPORTS} exports).`,
|
|
236
|
+
evidence: godEvidence.slice(0, 12),
|
|
237
|
+
fix: 'Split by concern with a pilot cluster; keep gate rules; use dual-plan B extraction card.',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (domainInUi.length > 0) {
|
|
242
|
+
smells.push({
|
|
243
|
+
id: 'domain-logic-in-ui',
|
|
244
|
+
severity: 'warn',
|
|
245
|
+
message: `Business-style can*/calculate*/compute* helpers live under UI/presentation paths (${domainInUi.length} file(s)).`,
|
|
246
|
+
evidence: domainInUi.slice(0, 12),
|
|
247
|
+
fix: 'Move pure rules into Domain (or shared pure module under Domain globs) and import from UI.',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (facadeSql.length > 0) {
|
|
252
|
+
smells.push({
|
|
253
|
+
id: 'facade-sql-in-routes',
|
|
254
|
+
severity: 'warn',
|
|
255
|
+
message: `Route/controller files import ORM/SQL clients directly (${facadeSql.length} file(s)).`,
|
|
256
|
+
evidence: facadeSql.slice(0, 12),
|
|
257
|
+
fix: 'Relocate query bytes into a repository/adapter; routes call a port — extraction card; no schema rewrite.',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// mixed-pattern: vertical-slice features coexisting with flat services and/or hex folders
|
|
262
|
+
const patternHits = [hasFeaturesLayout, hasFlatServices, hasHexPorts].filter(Boolean).length;
|
|
263
|
+
if (patternHits >= 2 && relFiles.length >= 8) {
|
|
264
|
+
const evidence = [];
|
|
265
|
+
if (hasFeaturesLayout) evidence.push('layout:features/*');
|
|
266
|
+
if (hasFlatServices) evidence.push('layout:services/*');
|
|
267
|
+
if (hasHexPorts) evidence.push('layout:hex-domain-application-infra');
|
|
268
|
+
smells.push({
|
|
269
|
+
id: 'mixed-pattern-cluster',
|
|
270
|
+
severity: 'info',
|
|
271
|
+
message:
|
|
272
|
+
'Concurrent design patterns detected in the tree (slice features vs flat services vs hex folders). Pick a golden pattern and pilot migrate-on-touch.',
|
|
273
|
+
evidence,
|
|
274
|
+
fix: 'Run /ark-explore shape-focus; mark golden vs legacy; dual-plan B with pilot + kill-switch.',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Stable order by id for snapshots
|
|
279
|
+
const order = new Map(DESIGN_SMELL_IDS.map((id, i) => [id, i]));
|
|
280
|
+
smells.sort((a, b) => (order.get(a.id) ?? 99) - (order.get(b.id) ?? 99));
|
|
281
|
+
return smells;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether edge-clean ENFORCE should still report design-weak residual.
|
|
286
|
+
*
|
|
287
|
+
* @param {DesignSmell[]} smells
|
|
288
|
+
* @param {{ activeViolations?: number, governedPercent?: number|null, totalFiles?: number|null }} ctx
|
|
289
|
+
*/
|
|
290
|
+
export function isDesignWeak(smells, ctx = {}) {
|
|
291
|
+
const active = ctx.activeViolations ?? 0;
|
|
292
|
+
const total = ctx.totalFiles ?? null;
|
|
293
|
+
const gov = ctx.governedPercent ?? null;
|
|
294
|
+
if (active > 0) return false;
|
|
295
|
+
if (total === 0) return false;
|
|
296
|
+
if (gov != null && gov < 50) return false;
|
|
297
|
+
return Array.isArray(smells) && smells.length > 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Design fitness summary for doctor JSON / human.
|
|
302
|
+
*/
|
|
303
|
+
export function summarizeDesignFitness(smells, ctx = {}) {
|
|
304
|
+
const designWeak = isDesignWeak(smells, ctx);
|
|
305
|
+
return {
|
|
306
|
+
status: designWeak ? 'design-weak' : smells.length > 0 ? 'smells-with-open-edges' : 'ok',
|
|
307
|
+
designWeak,
|
|
308
|
+
smellCount: Array.isArray(smells) ? smells.length : 0,
|
|
309
|
+
ids: (smells || []).map((s) => s.id),
|
|
310
|
+
label: designWeak
|
|
311
|
+
? 'ENFORCE · design-weak — edges clean; Shape residual remains (see designSmells / plan B)'
|
|
312
|
+
: smells.length > 0
|
|
313
|
+
? 'Design smells present alongside open edge debt'
|
|
314
|
+
: 'No deterministic design smells detected',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Build plan-B pattern bets from smells (P03). Never mechanical-safe.
|
|
320
|
+
*
|
|
321
|
+
* @param {DesignSmell[]} smells
|
|
322
|
+
* @returns {object[]}
|
|
323
|
+
*/
|
|
324
|
+
export function buildPatternBetsFromSmells(smells = []) {
|
|
325
|
+
const bets = [];
|
|
326
|
+
for (const smell of smells) {
|
|
327
|
+
const pilot =
|
|
328
|
+
(smell.evidence || []).find((e) => e && !e.startsWith('layer:') && !e.startsWith('layout:')) ||
|
|
329
|
+
(smell.evidence || [])[0] ||
|
|
330
|
+
'src/**';
|
|
331
|
+
bets.push({
|
|
332
|
+
id: `pattern-b:${smell.id}`,
|
|
333
|
+
smellId: smell.id,
|
|
334
|
+
pilot: typeof pilot === 'string' ? pilot.replace(/\/[^/]+$/, '/**') : 'src/**',
|
|
335
|
+
evidence: (smell.evidence || []).slice(0, 8),
|
|
336
|
+
successSignal: successSignalFor(smell.id),
|
|
337
|
+
killSwitch: killSwitchFor(smell.id),
|
|
338
|
+
neverMechanicalSafe: true,
|
|
339
|
+
class: 'judgment',
|
|
340
|
+
fix: smell.fix,
|
|
341
|
+
message: smell.message,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Cap at 5 (explore dual-plan B limit)
|
|
345
|
+
return bets.slice(0, 5);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function successSignalFor(id) {
|
|
349
|
+
switch (id) {
|
|
350
|
+
case 'io-under-application':
|
|
351
|
+
return '0 Application-layer files import prisma/supabase/drizzle/pg clients; I/O behind ports';
|
|
352
|
+
case 'handler-in-persistence':
|
|
353
|
+
return '0 HTTP handler shapes under persistence/repository globs';
|
|
354
|
+
case 'god-module':
|
|
355
|
+
return 'Pilot god module split; fan-in and export surface reduced without new edge violations';
|
|
356
|
+
case 'domain-logic-in-ui':
|
|
357
|
+
return 'can*/calculate* pure rules live under Domain; UI imports them only';
|
|
358
|
+
case 'facade-sql-in-routes':
|
|
359
|
+
return '0 route/controller files import ORM/SQL clients; queries in adapters';
|
|
360
|
+
case 'mixed-pattern-cluster':
|
|
361
|
+
return 'Golden pattern named; pilot cluster migrated; legacy migrate-on-touch';
|
|
362
|
+
case 'soft-contract':
|
|
363
|
+
return 'Every populated layer has at least one rule edge in ark.config.json';
|
|
364
|
+
default:
|
|
365
|
+
return 'Smell evidence paths cleared on pilot without weakening the contract';
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function killSwitchFor(id) {
|
|
370
|
+
switch (id) {
|
|
371
|
+
case 'mixed-pattern-cluster':
|
|
372
|
+
return 'If pilot does not reduce confusion in 2 real PRs, keep one layout without adding a layer wall';
|
|
373
|
+
case 'god-module':
|
|
374
|
+
return 'If split increases coupling, stop after one pilot and prefer seam extraction only';
|
|
375
|
+
default:
|
|
376
|
+
return 'If pilot increases edge violations without design clarity, stop and re-map with /ark-explore';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Honesty guard (P03/P04): refuse “healthy finished” claims when design residual remains.
|
|
382
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
383
|
+
*/
|
|
384
|
+
export function assertNotHealthyFinishedIgnoringDesign(planOrDoctor) {
|
|
385
|
+
const designWeak =
|
|
386
|
+
planOrDoctor?.goal?.designWeak === true ||
|
|
387
|
+
planOrDoctor?.designFitness?.designWeak === true;
|
|
388
|
+
const bets =
|
|
389
|
+
planOrDoctor?.patternBets?.length ??
|
|
390
|
+
planOrDoctor?.goal?.patternBetCount ??
|
|
391
|
+
0;
|
|
392
|
+
const smells =
|
|
393
|
+
planOrDoctor?.designSmells?.length ?? planOrDoctor?.designFitness?.smellCount ?? 0;
|
|
394
|
+
const edgesMet =
|
|
395
|
+
planOrDoctor?.goal?.met === true ||
|
|
396
|
+
(planOrDoctor?.operatingMode === 'enforce' &&
|
|
397
|
+
(planOrDoctor?.violations?.active ?? 0) === 0);
|
|
398
|
+
|
|
399
|
+
if (edgesMet && (designWeak || bets > 0 || smells > 0)) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
error:
|
|
403
|
+
'Cannot claim architecture healthy finished: edge goal.met/ENFORCE coexists with design-weak residual (designSmells / patternBets). Use dual-plan B; never auto-apply pattern bets.',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return { ok: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* patternBets must never appear as mechanical-safe kinds (loop / autoPatch).
|
|
411
|
+
* @param {object[]} patternBets
|
|
412
|
+
* @param {string[]} mechanicalSafeKinds from remediation.MECHANICAL_SAFE_KINDS
|
|
413
|
+
*/
|
|
414
|
+
export function assertPatternBetsNeverMechanicalSafe(patternBets, mechanicalSafeKinds = []) {
|
|
415
|
+
const safe = new Set(mechanicalSafeKinds);
|
|
416
|
+
for (const bet of patternBets || []) {
|
|
417
|
+
if (bet.neverMechanicalSafe !== true) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
error: `patternBet ${bet.id} missing neverMechanicalSafe: true`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (bet.class === 'mechanical-safe') {
|
|
424
|
+
return { ok: false, error: `patternBet ${bet.id} has class mechanical-safe` };
|
|
425
|
+
}
|
|
426
|
+
if (bet.remediationKind && safe.has(bet.remediationKind)) {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
error: `patternBet ${bet.id} uses mechanical-safe remediationKind ${bet.remediationKind}`,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { ok: true };
|
|
434
|
+
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -25,6 +25,12 @@ import {
|
|
|
25
25
|
violationEdge,
|
|
26
26
|
} from './violations.mjs';
|
|
27
27
|
import { buildUnclassifiedSuggestions } from './suggestions.mjs';
|
|
28
|
+
import {
|
|
29
|
+
detectDesignSmells,
|
|
30
|
+
buildPatternBetsFromSmells,
|
|
31
|
+
summarizeDesignFitness,
|
|
32
|
+
isDesignWeak,
|
|
33
|
+
} from './design-smells.mjs';
|
|
28
34
|
|
|
29
35
|
const color = {
|
|
30
36
|
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
@@ -142,7 +148,25 @@ export function runCoverage(root, config, files, rules, asJson) {
|
|
|
142
148
|
// Co-pilot Phase F — turn active violations into a classified, ordered remediation PLAN with an
|
|
143
149
|
// embedded GOAL. This is the `plan` primitive the future apply-loop (Phase H, `loop`) consumes
|
|
144
150
|
// and the autopilot (Phase I) drives toward the `goal`. Read-only: it changes no files.
|
|
145
|
-
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} root
|
|
153
|
+
* @param {object[]} activeViolations
|
|
154
|
+
* @param {number|null} [governedPercent]
|
|
155
|
+
* @param {number|null} [totalFiles]
|
|
156
|
+
* @param {object} [options]
|
|
157
|
+
* @param {object[]} [options.designSmells]
|
|
158
|
+
* @param {object[]} [options.patternBets]
|
|
159
|
+
* @param {object} [options.config]
|
|
160
|
+
* @param {string[]} [options.files]
|
|
161
|
+
* @param {object} [options.coverage]
|
|
162
|
+
*/
|
|
163
|
+
export function buildRemediationPlan(
|
|
164
|
+
root,
|
|
165
|
+
activeViolations,
|
|
166
|
+
governedPercent = null,
|
|
167
|
+
totalFiles = null,
|
|
168
|
+
options = {}
|
|
169
|
+
) {
|
|
146
170
|
// A plan with 0 violations but ~0% governed (or ZERO files in scope) is a FALSE green:
|
|
147
171
|
// nothing is actually being checked. Treat as "not done — classify / fix include first."
|
|
148
172
|
const governedLow = governedPercent != null && governedPercent < 50;
|
|
@@ -177,20 +201,54 @@ export function buildRemediationPlan(root, activeViolations, governedPercent = n
|
|
|
177
201
|
judgment: countOf('judgment'),
|
|
178
202
|
deferred: countOf('deferred'),
|
|
179
203
|
};
|
|
204
|
+
|
|
205
|
+
// Plan B (pattern bets) — never mechanical-safe; additive within major (P03).
|
|
206
|
+
let designSmells = options.designSmells;
|
|
207
|
+
if (!designSmells && options.config && options.files) {
|
|
208
|
+
designSmells = detectDesignSmells(
|
|
209
|
+
root,
|
|
210
|
+
options.config,
|
|
211
|
+
options.files,
|
|
212
|
+
options.coverage ?? null
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
designSmells = designSmells ?? [];
|
|
216
|
+
const patternBets =
|
|
217
|
+
options.patternBets ?? buildPatternBetsFromSmells(designSmells);
|
|
218
|
+
const edgesMet = activeViolations.length === 0 && !notHonestlyEnforced;
|
|
219
|
+
const designWeak = isDesignWeak(designSmells, {
|
|
220
|
+
activeViolations: activeViolations.length,
|
|
221
|
+
governedPercent,
|
|
222
|
+
totalFiles,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
let statement =
|
|
226
|
+
activeViolations.length > 0
|
|
227
|
+
? `Resolve ${activeViolations.length} architecture violation(s) without weakening the contract.`
|
|
228
|
+
: emptyScope
|
|
229
|
+
? '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.'
|
|
230
|
+
: governedLow
|
|
231
|
+
? `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.`
|
|
232
|
+
: 'No active violations — the architecture already meets its contract.';
|
|
233
|
+
if (designWeak) {
|
|
234
|
+
statement =
|
|
235
|
+
'No active edge violations — contract edges are clean, but design smells remain (ENFORCE · design-weak). Shape residual is plan B only; not healthy finished.';
|
|
236
|
+
}
|
|
237
|
+
|
|
180
238
|
return {
|
|
181
239
|
version: '1',
|
|
182
240
|
goal: {
|
|
183
|
-
statement
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
241
|
+
statement,
|
|
242
|
+
// Edge remediation termination (Phase H). Design-weak does NOT flip met false
|
|
243
|
+
// (would break loop semantics) — it is reported separately for honesty.
|
|
244
|
+
met: edgesMet,
|
|
245
|
+
designWeak,
|
|
246
|
+
...(designWeak
|
|
247
|
+
? {
|
|
248
|
+
designWeakLabel:
|
|
249
|
+
'ENFORCE · design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
|
|
250
|
+
}
|
|
251
|
+
: {}),
|
|
194
252
|
...(governedPercent != null ? { governedPercent } : {}),
|
|
195
253
|
...(totalFiles != null ? { totalFiles } : {}),
|
|
196
254
|
...(emptyScope ? { emptyScope: true } : {}),
|
|
@@ -198,17 +256,38 @@ export function buildRemediationPlan(root, activeViolations, governedPercent = n
|
|
|
198
256
|
autoApplicable: counts.mechanicalSafe,
|
|
199
257
|
needsDecision: counts.judgment,
|
|
200
258
|
deferred: counts.deferred,
|
|
259
|
+
patternBetCount: patternBets.length,
|
|
201
260
|
},
|
|
202
261
|
counts,
|
|
203
262
|
steps,
|
|
263
|
+
// Additive: pattern evolution bets derived from design smells (never auto).
|
|
264
|
+
patternBets,
|
|
265
|
+
designSmells,
|
|
204
266
|
};
|
|
205
267
|
}
|
|
206
268
|
|
|
207
269
|
// `--plan`: print the classified remediation plan. Dual-focus output — a one-line headline
|
|
208
270
|
// anyone can read, then the per-step detail a developer acts on. Read-only.
|
|
209
|
-
|
|
210
|
-
|
|
271
|
+
/**
|
|
272
|
+
* @param {object} [options] optional { config, files, coverage, designSmells, patternBets }
|
|
273
|
+
*/
|
|
274
|
+
export function runPlan(
|
|
275
|
+
root,
|
|
276
|
+
activeViolations,
|
|
277
|
+
asJson,
|
|
278
|
+
governedPercent = null,
|
|
279
|
+
totalFiles = null,
|
|
280
|
+
options = {}
|
|
281
|
+
) {
|
|
282
|
+
const plan = buildRemediationPlan(
|
|
283
|
+
root,
|
|
284
|
+
activeViolations,
|
|
285
|
+
governedPercent,
|
|
286
|
+
totalFiles,
|
|
287
|
+
options
|
|
288
|
+
);
|
|
211
289
|
// Honesty: a zero-violation plan with almost nothing governed is NOT "ok".
|
|
290
|
+
// design-weak still ok:true for edge goal.met, but JSON carries designWeak + patternBets.
|
|
212
291
|
const planOk = plan.goal.met === true;
|
|
213
292
|
if (asJson) {
|
|
214
293
|
console.log(JSON.stringify({ ok: planOk, plan }, null, 2));
|
|
@@ -217,6 +296,13 @@ export function runPlan(root, activeViolations, asJson, governedPercent = null,
|
|
|
217
296
|
console.log(color.bold(`Ark plan — ${path.basename(path.resolve(root)) || '.'}`));
|
|
218
297
|
console.log('');
|
|
219
298
|
console.log(plan.goal.statement);
|
|
299
|
+
if (plan.goal.designWeak) {
|
|
300
|
+
console.log(
|
|
301
|
+
color.yellow(
|
|
302
|
+
` ENFORCE · design-weak — ${plan.patternBets?.length ?? 0} pattern bet(s) (never auto-apply)`
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
220
306
|
if (governedPercent != null) {
|
|
221
307
|
const pctLabel =
|
|
222
308
|
governedPercent < 50
|
|
@@ -224,6 +310,14 @@ export function runPlan(root, activeViolations, asJson, governedPercent = null,
|
|
|
224
310
|
: color.dim(`Governed: ${governedPercent}% of in-scope files`);
|
|
225
311
|
console.log(pctLabel);
|
|
226
312
|
}
|
|
313
|
+
if (plan.patternBets?.length && activeViolations.length === 0) {
|
|
314
|
+
console.log('');
|
|
315
|
+
console.log(color.bold('Pattern bets (B) — judgment only'));
|
|
316
|
+
for (const bet of plan.patternBets.slice(0, 5)) {
|
|
317
|
+
console.log(` [decide] ${bet.smellId} ${color.dim(bet.pilot)}`);
|
|
318
|
+
console.log(color.dim(` success: ${bet.successSignal}`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
227
321
|
if (activeViolations.length === 0) return plan;
|
|
228
322
|
console.log('');
|
|
229
323
|
console.log(
|
|
@@ -245,7 +339,7 @@ export function runPlan(root, activeViolations, asJson, governedPercent = null,
|
|
|
245
339
|
console.log('');
|
|
246
340
|
console.log(
|
|
247
341
|
color.dim(
|
|
248
|
-
'Plan only — no files changed. "auto" = an agent can safely apply it; "decide" = your call.'
|
|
342
|
+
'Plan only — no files changed. "auto" = an agent can safely apply it; "decide" = your call. patternBets are never auto.'
|
|
249
343
|
)
|
|
250
344
|
);
|
|
251
345
|
return plan;
|
|
@@ -283,6 +377,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
283
377
|
const activeCount = violations.length - suppressed;
|
|
284
378
|
const missingSkills = skillGaps.reduce((sum, gap) => sum + gap.missing, 0);
|
|
285
379
|
const staleSkills = skillGaps.reduce((sum, gap) => sum + gap.stale, 0);
|
|
380
|
+
const designSmells = detectDesignSmells(root, config, files, cov);
|
|
381
|
+
const designFitness = summarizeDesignFitness(designSmells, {
|
|
382
|
+
activeViolations: activeCount,
|
|
383
|
+
governedPercent: cov.governed.percent,
|
|
384
|
+
totalFiles: cov.governed.totalFiles,
|
|
385
|
+
});
|
|
286
386
|
|
|
287
387
|
if (asJson) {
|
|
288
388
|
console.log(
|
|
@@ -304,6 +404,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
304
404
|
return p ? p.files / total : null;
|
|
305
405
|
})(),
|
|
306
406
|
}),
|
|
407
|
+
// Path-correct ENFORCE can still be design-weak (P02).
|
|
408
|
+
designFitness,
|
|
409
|
+
designSmells,
|
|
307
410
|
governed: cov.governed,
|
|
308
411
|
emptyLayers: cov.emptyLayers,
|
|
309
412
|
layersWithoutRules: cov.layersWithoutRules,
|
|
@@ -412,7 +515,18 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
412
515
|
enforce:
|
|
413
516
|
'Guard — contract coverage is honest and checked edges are clean. You do not pick this mode; you arrived here. Next: keep the host-appropriate write path and CI check on; only NEW violations should fail.',
|
|
414
517
|
};
|
|
415
|
-
|
|
518
|
+
const modeTitle =
|
|
519
|
+
mode === 'enforce' && designFitness.designWeak
|
|
520
|
+
? 'ENFORCE · design-weak'
|
|
521
|
+
: mode.toUpperCase();
|
|
522
|
+
line(
|
|
523
|
+
modeMark,
|
|
524
|
+
`${modeTitle} — ${
|
|
525
|
+
designFitness.designWeak
|
|
526
|
+
? 'Guard on edges is honest, but design smells remain (Shape residual). You do not pick this mode. Next: /ark-explore dual-plan B or /ark-autopilot for pattern bets — never treat empty plan A as healthy finished.'
|
|
527
|
+
: modeHelp[mode]
|
|
528
|
+
}`
|
|
529
|
+
);
|
|
416
530
|
if (emptyScope) {
|
|
417
531
|
line(
|
|
418
532
|
bad,
|
|
@@ -420,6 +534,25 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
420
534
|
);
|
|
421
535
|
}
|
|
422
536
|
|
|
537
|
+
console.log('');
|
|
538
|
+
console.log(color.bold('Design fitness'));
|
|
539
|
+
if (designSmells.length === 0) {
|
|
540
|
+
line(ok, designFitness.label);
|
|
541
|
+
} else {
|
|
542
|
+
line(designFitness.designWeak ? warn : warn, designFitness.label);
|
|
543
|
+
for (const smell of designSmells.slice(0, 5)) {
|
|
544
|
+
line(' ', color.dim(`[${smell.id}] ${smell.message}`));
|
|
545
|
+
if (smell.evidence?.length) {
|
|
546
|
+
line(' ', color.dim(`evidence: ${smell.evidence.slice(0, 4).join(', ')}`));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (designFitness.designWeak) {
|
|
550
|
+
actions.push(
|
|
551
|
+
'shape residual: /ark-explore (shape-focus) or /ark-autopilot dual-plan B — pattern bets are never mechanical-safe'
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
423
556
|
console.log('');
|
|
424
557
|
console.log(color.bold('Coverage'));
|
|
425
558
|
const govMark =
|