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.
Files changed (85) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +53 -36
  3. package/bin/ark-check.mjs +72 -6
  4. package/bin/ark-mcp.mjs +108 -1
  5. package/bin/ark-shared.mjs +204 -149
  6. package/bin/ark.mjs +90 -25
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +1 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -135
  11. package/bin/lib/ci-and-commands.mjs +51 -15
  12. package/bin/lib/config-warnings.mjs +7 -205
  13. package/bin/lib/design-smells.mjs +434 -0
  14. package/bin/lib/doctor-plan.mjs +149 -16
  15. package/bin/lib/field-install.mjs +67 -10
  16. package/bin/lib/gate-files.mjs +42 -3
  17. package/bin/lib/graph-cycles.mjs +4 -54
  18. package/bin/lib/hook-templates.mjs +33 -1
  19. package/bin/lib/host-support-matrix.mjs +7 -1
  20. package/bin/lib/install-migrate.mjs +54 -16
  21. package/bin/lib/presets.mjs +42 -2
  22. package/bin/lib/safety-diagnostics.mjs +18 -17
  23. package/bin/lib/scan-files.mjs +12 -1
  24. package/bin/lib/skill-install.mjs +8 -1
  25. package/bin/lib/source-policy.mjs +36 -0
  26. package/bin/lib/start-preview.mjs +271 -0
  27. package/bin/lib/ts-resolve.mjs +11 -2
  28. package/bin/lib/write-path-capabilities.mjs +4 -0
  29. package/compat/nestjs.cjs +2 -0
  30. package/compat/nestjs.d.ts +2 -0
  31. package/compat/nestjs.js +1 -0
  32. package/compat/runtime.cjs +2 -0
  33. package/compat/runtime.d.ts +2 -0
  34. package/compat/runtime.js +1 -0
  35. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  36. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  37. package/dist/eslint/index.cjs +125 -48
  38. package/dist/eslint/index.d.cts +7 -1
  39. package/dist/eslint/index.d.ts +7 -1
  40. package/dist/eslint/index.js +125 -48
  41. package/dist/index.cjs +1248 -3302
  42. package/dist/index.d.cts +359 -483
  43. package/dist/index.d.ts +359 -483
  44. package/dist/index.js +1231 -3248
  45. package/docs/agent-guide.md +34 -16
  46. package/docs/ai-gates.md +30 -7
  47. package/docs/brownfield-adoption.md +52 -1
  48. package/docs/migrate-from-ark-runtime-kernel.md +2 -3
  49. package/docs/package-surface.md +10 -13
  50. package/docs/production-hardening.md +17 -4
  51. package/docs/typescript-support.md +27 -0
  52. package/package.json +33 -11
  53. package/schemas/ark.analysis-result.schema.json +91 -0
  54. package/server.json +2 -2
  55. package/templates/skills/ark-adopt.md +26 -3
  56. package/templates/skills/ark-architect.md +10 -2
  57. package/templates/skills/ark-autopilot.md +37 -20
  58. package/templates/skills/ark-contract.md +7 -0
  59. package/templates/skills/ark-coverage.md +44 -45
  60. package/templates/skills/ark-explain.md +8 -0
  61. package/templates/skills/ark-explore.md +117 -47
  62. package/templates/skills/ark-fix.md +22 -0
  63. package/templates/skills/ark-loop.md +15 -1
  64. package/templates/skills/ark-place.md +7 -0
  65. package/templates/skills/ark-think.md +24 -20
  66. package/dist/configContract-iBLxx5Tz.d.cts +0 -53
  67. package/dist/configContract-iBLxx5Tz.d.ts +0 -53
  68. package/dist/eslint/index.cjs.map +0 -1
  69. package/dist/eslint/index.js.map +0 -1
  70. package/dist/index.cjs.map +0 -1
  71. package/dist/index.js.map +0 -1
  72. package/dist/nestjs/index.cjs +0 -2606
  73. package/dist/nestjs/index.cjs.map +0 -1
  74. package/dist/nestjs/index.d.cts +0 -23
  75. package/dist/nestjs/index.d.ts +0 -23
  76. package/dist/nestjs/index.js +0 -2582
  77. package/dist/nestjs/index.js.map +0 -1
  78. package/dist/runtime/index.cjs +0 -4014
  79. package/dist/runtime/index.cjs.map +0 -1
  80. package/dist/runtime/index.d.cts +0 -3
  81. package/dist/runtime/index.d.ts +0 -3
  82. package/dist/runtime/index.js +0 -3925
  83. package/dist/runtime/index.js.map +0 -1
  84. package/dist/types-BxBwnBpC.d.cts +0 -1041
  85. 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
+ }
@@ -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
- export function buildRemediationPlan(root, activeViolations, governedPercent = null, totalFiles = null) {
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
- activeViolations.length > 0
185
- ? `Resolve ${activeViolations.length} architecture violation(s) without weakening the contract.`
186
- : emptyScope
187
- ? '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.'
188
- : governedLow
189
- ? `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.`
190
- : 'No active violations — the architecture already meets its contract.',
191
- // The loop's termination signal (Phase H): nothing left to remediate AND the contract
192
- // actually governs real code. Empty scope or low coverage is not "met".
193
- met: activeViolations.length === 0 && !notHonestlyEnforced,
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
- export function runPlan(root, activeViolations, asJson, governedPercent = null, totalFiles = null) {
210
- const plan = buildRemediationPlan(root, activeViolations, governedPercent, totalFiles);
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
- line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
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 =