arkgate 3.0.0 → 3.0.2
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 +29 -1
- package/README.md +16 -14
- package/bin/ark-check.mjs +10 -2
- package/bin/lib/ci-and-commands.mjs +20 -15
- package/bin/lib/design-smells.mjs +434 -0
- package/bin/lib/doctor-plan.mjs +149 -16
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/agent-guide.md +22 -1
- package/docs/ai-gates.md +7 -0
- package/docs/brownfield-adoption.md +52 -1
- package/docs/demos/03-copilot-autopilot.md +14 -7
- package/docs/package-surface.md +3 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +26 -3
- package/templates/skills/ark-architect.md +7 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,35 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
-
No changes are scheduled after
|
|
7
|
+
No changes are scheduled after 3.0.2.
|
|
8
|
+
|
|
9
|
+
## 3.0.2 — 2026-07-13
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
- Sync consumer-facing agent docs with 3.0.1 design-depth skills: `designFitness` /
|
|
14
|
+
`patternBets`, extraction cards, dual-plan B honesty in agent-guide, AI gates, and the
|
|
15
|
+
autopilot demo. Release note status for 3.0.1 marked published.
|
|
16
|
+
|
|
17
|
+
## 3.0.1 — 2026-07-13
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Design fitness (doctor):** deterministic `designSmells` and `designFitness` on
|
|
22
|
+
`ark-check --doctor --json` / human doctor. Edge-clean ENFORCE can report
|
|
23
|
+
**ENFORCE · design-weak** when lived design residual remains (e.g. facade SQL in routes,
|
|
24
|
+
handlers in persistence, god modules, domain logic in UI, soft contract, mixed patterns).
|
|
25
|
+
- **Plan pattern B:** `ark-check --plan --json` includes `patternBets[]` with pilot, success
|
|
26
|
+
signal, kill-switch, and `neverMechanicalSafe: true`. Never auto-applied by loop/autoPatch;
|
|
27
|
+
`goal.met` remains edge honesty only.
|
|
28
|
+
- **Skills (Phase P):** clearer When/not when routing; explore Shape ladder and dual-plan B;
|
|
29
|
+
coverage narrowed to Ark fitness; adopt/autopilot seed Shape residual; extraction-card
|
|
30
|
+
template in brownfield docs and skills.
|
|
31
|
+
- **Fixture:** `tests/fixtures/design-weak-enforce` for ENFORCE + design-weak honesty.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Module budget for `bin/lib/doctor-plan.mjs` raised to match the design-depth surface.
|
|
8
36
|
|
|
9
37
|
## 3.0.0 — 2026-07-13
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -171,20 +171,22 @@ constrain + validate + optional `autoPatch` + `judgmentBrief`). PreToolUse hooks
|
|
|
171
171
|
never silent write). Cursor/Codex MCP calls remain advisory. See
|
|
172
172
|
[docs/ai-gates.md](docs/ai-gates.md).
|
|
173
173
|
|
|
174
|
-
| Need | Skill |
|
|
175
|
-
|
|
176
|
-
| Only the apply loop
|
|
177
|
-
| Empty greenfield shape/scaffold | `/ark-architect` |
|
|
178
|
-
|
|
|
179
|
-
|
|
|
180
|
-
| New file placement | `/ark-place` |
|
|
181
|
-
| Gate violation on a change | `/ark-fix` |
|
|
182
|
-
|
|
|
183
|
-
| Edit `ark.config.json` safely | `/ark-contract` |
|
|
184
|
-
| Plain-language tour
|
|
185
|
-
|
|
|
186
|
-
| Evaluate
|
|
187
|
-
| Bump ArkGate + refresh active host
|
|
174
|
+
| Need | Skill | Not |
|
|
175
|
+
|------|--------|-----|
|
|
176
|
+
| Only the apply loop for plan **A** (edges) | `/ark-loop` | empty A + design residual → explore |
|
|
177
|
+
| Empty greenfield shape/scaffold | `/ark-architect` | brownfield → adopt |
|
|
178
|
+
| Brownfield contract match / baseline / manifest | `/ark-adopt` | map-only → explore |
|
|
179
|
+
| Map / dual-plan **seed** / spaghetti Shape plan (no apply) | `/ark-explore` | fitness-only → coverage |
|
|
180
|
+
| New file placement | `/ark-place` | — |
|
|
181
|
+
| Gate violation on a change | `/ark-fix` | bulk → loop/autopilot |
|
|
182
|
+
| One design decision (2–3 options) | `/ark-think` | full dual-plan → explore |
|
|
183
|
+
| Edit `ark.config.json` safely | `/ark-contract` | — |
|
|
184
|
+
| Plain-language tour / HTML report | `/ark-explain` | recon → explore |
|
|
185
|
+
| Ark **fitness** (governed%, gates, install gaps) | `/ark-coverage` | full recon → explore |
|
|
186
|
+
| Evaluate experimental runtime | `/ark-runtime` | — |
|
|
187
|
+
| Bump ArkGate + refresh active host | `/ark-upgrade` | — |
|
|
188
|
+
|
|
189
|
+
Brownfield phases: **Align** (honest contract) → **Stabilize** (real baseline) → **Shape** (golden pattern + pilot). ENFORCE with empty plan A can still be **design-weak** — that residual is explore/autopilot **B**, not “done.”
|
|
188
190
|
|
|
189
191
|
### Host enforcement support
|
|
190
192
|
|
package/bin/ark-check.mjs
CHANGED
|
@@ -1083,7 +1083,11 @@ async function main() {
|
|
|
1083
1083
|
)
|
|
1084
1084
|
);
|
|
1085
1085
|
}
|
|
1086
|
-
runPlan(root, [], args.json, cov.governed.percent, cov.governed.totalFiles
|
|
1086
|
+
runPlan(root, [], args.json, cov.governed.percent, cov.governed.totalFiles, {
|
|
1087
|
+
config,
|
|
1088
|
+
files,
|
|
1089
|
+
coverage: cov,
|
|
1090
|
+
});
|
|
1087
1091
|
return;
|
|
1088
1092
|
}
|
|
1089
1093
|
console.error(
|
|
@@ -1214,7 +1218,11 @@ async function main() {
|
|
|
1214
1218
|
|
|
1215
1219
|
if (args.plan) {
|
|
1216
1220
|
const cov = computeCoverage(root, config, files, rules);
|
|
1217
|
-
runPlan(root, activeViolations, args.json, cov.governed.percent, cov.governed.totalFiles
|
|
1221
|
+
runPlan(root, activeViolations, args.json, cov.governed.percent, cov.governed.totalFiles, {
|
|
1222
|
+
config,
|
|
1223
|
+
files,
|
|
1224
|
+
coverage: cov,
|
|
1225
|
+
});
|
|
1218
1226
|
return;
|
|
1219
1227
|
}
|
|
1220
1228
|
|
|
@@ -153,21 +153,26 @@ files; never weaken the gate via subagents.
|
|
|
153
153
|
|
|
154
154
|
## Skill routing (triggers → skill)
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
|
159
|
-
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
|
|
|
163
|
-
|
|
|
164
|
-
|
|
|
165
|
-
|
|
|
166
|
-
|
|
|
167
|
-
|
|
|
168
|
-
|
|
|
169
|
-
|
|
|
170
|
-
|
|
|
156
|
+
Do **not** run overlapping skills for the same job. Pick **one** primary skill from the table.
|
|
157
|
+
|
|
158
|
+
| When | Invoke | Not this |
|
|
159
|
+
|------|--------|----------|
|
|
160
|
+
| Unsure / make architecture sound (apply path) | **/ark-autopilot** (default) | explore-only, coverage-only |
|
|
161
|
+
| Map / residual / dual-plan **seed** / spaghetti Shape plan (no apply) | \`/ark-explore\` | coverage (fitness only), think (one decision) |
|
|
162
|
+
| ENFORCE but design-weak / concurrent patterns / god modules | \`/ark-explore\` shape-focus or dual-plan seed → then autopilot for apply | loop (empty A is not done) |
|
|
163
|
+
| Greenfield shape / empty tree | \`/ark-architect\` | adopt |
|
|
164
|
+
| Brownfield / wrong contract / false-green | \`/ark-adopt\` then \`/ark-contract\` if globs wrong | architect |
|
|
165
|
+
| Edit \`ark.config.json\` layers/rules/intents | \`/ark-contract\` | fix/loop for config |
|
|
166
|
+
| New file “where does this go?” | \`/ark-place\` | architect (unless greenfield shape missing) |
|
|
167
|
+
| Gate violation on a change (small cluster) | \`/ark-fix\` | loop/autopilot unless bulk |
|
|
168
|
+
| Drive plan **A** to goal.met | \`/ark-loop\` | explore (unless A empty + design residual) |
|
|
169
|
+
| Ark **fitness** only (governed%, gates, baseline, install gaps) | \`/ark-coverage\` | explore (full recon) |
|
|
170
|
+
| One design decision, 2–3 options | \`/ark-think\` | explore (full dual-plan) |
|
|
171
|
+
| Explain / HTML report tour | \`/ark-explain\` | explore |
|
|
172
|
+
| Bump arkgate + refresh hosts | \`/ark-upgrade\` | — |
|
|
173
|
+
| Optional runtime kernel evaluate | \`/ark-runtime\` | — |
|
|
174
|
+
|
|
175
|
+
**Phases (brownfield honesty):** Align (contract truth) → Stabilize (real baseline) → Shape (golden pattern + pilot). Empty plan A after Stabilize still leaves Shape work — that is explore/autopilot **B**, not “healthy finished.”
|
|
171
176
|
|
|
172
177
|
## Before editing TypeScript or JavaScript source files
|
|
173
178
|
|
|
@@ -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
|
+
}
|