arkgate 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +6 -5
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +148 -8
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +277 -59
- package/bin/lib/enforcement-honesty.mjs +351 -26
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/html-report-depth.mjs +167 -3
- package/bin/lib/html-report.mjs +12 -5
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +99 -0
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +134 -4
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +3 -3
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +36 -0
- package/docs/configuration.md +36 -0
- package/docs/package-surface.md +3 -3
- package/docs/product-voice.md +7 -0
- package/docs/typescript-support.md +9 -5
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +5 -0
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
package/bin/ark-layer-match.mjs
CHANGED
|
@@ -107,12 +107,87 @@ export function globToRegExp(pattern) {
|
|
|
107
107
|
regexpCache.set(pattern, re);
|
|
108
108
|
return re;
|
|
109
109
|
}
|
|
110
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Concrete (non-wildcard) path segments in a glob, left-to-right.
|
|
112
|
+
* Used for path-anchored ranking so a domain folder glob can beat a broad
|
|
113
|
+
* Application bag like src/lib when the file actually sits under domain/.
|
|
114
|
+
*/
|
|
115
|
+
export function concreteGlobSegments(pattern) {
|
|
116
|
+
const glob = normalizeGlobSeparators(String(pattern));
|
|
117
|
+
return glob
|
|
118
|
+
.split('/')
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.filter((seg) => seg !== '**' &&
|
|
121
|
+
seg !== '*' &&
|
|
122
|
+
!seg.includes('*') &&
|
|
123
|
+
!seg.includes('?') &&
|
|
124
|
+
!seg.includes('{') &&
|
|
125
|
+
!seg.includes('['));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Rank competing layer globs.
|
|
129
|
+
*
|
|
130
|
+
* Without a path: concrete-segment count + literal length (historical shape).
|
|
131
|
+
* With a path: last matched concrete segment depth dominates so interior
|
|
132
|
+
* domain/persistence folders beat Application/Presentation scatter bags
|
|
133
|
+
* (DL-DOMAIN-SPECIFICITY / NEW-APP-VACUUM-LIB).
|
|
134
|
+
*/
|
|
135
|
+
export function patternSpecificity(pattern, relPath) {
|
|
111
136
|
const glob = normalizeGlobSeparators(String(pattern));
|
|
112
|
-
const
|
|
113
|
-
const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
|
|
137
|
+
const concrete = concreteGlobSegments(glob);
|
|
114
138
|
const literalLength = glob.replace(/\*/g, '').length;
|
|
115
|
-
|
|
139
|
+
const base = concrete.length * 10000 + literalLength;
|
|
140
|
+
if (relPath === undefined || relPath === null || relPath === '')
|
|
141
|
+
return base;
|
|
142
|
+
const pathParts = String(relPath)
|
|
143
|
+
.split(/[/\\]/)
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
if (concrete.length === 0) {
|
|
146
|
+
// Pure wildcards (`**`, `*`) — weakest possible match.
|
|
147
|
+
return literalLength;
|
|
148
|
+
}
|
|
149
|
+
let searchFrom = 0;
|
|
150
|
+
let lastIdx = -1;
|
|
151
|
+
for (const seg of concrete) {
|
|
152
|
+
let found = -1;
|
|
153
|
+
for (let i = searchFrom; i < pathParts.length; i += 1) {
|
|
154
|
+
if (pathParts[i] === seg) {
|
|
155
|
+
found = i;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (found < 0) {
|
|
160
|
+
// Glob matched but segments could not be placed (braces / exotic globs) — base only.
|
|
161
|
+
return base;
|
|
162
|
+
}
|
|
163
|
+
lastIdx = found;
|
|
164
|
+
searchFrom = found + 1;
|
|
165
|
+
}
|
|
166
|
+
// Depth of last concrete segment dominates; then segment count; then length.
|
|
167
|
+
return (lastIdx + 1) * 1_000_000 + concrete.length * 10000 + literalLength;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* All layers whose patterns match the path (excludes applied), with best score per layer.
|
|
171
|
+
* Used for dual-membership coverage signals (P0A-DUAL-MATCH).
|
|
172
|
+
*/
|
|
173
|
+
export function matchingLayersForRelativePath(relPath, layers) {
|
|
174
|
+
const rel = String(relPath).split(/[/\\]/).join('/');
|
|
175
|
+
const byLayer = new Map();
|
|
176
|
+
for (const layer of layers ?? []) {
|
|
177
|
+
if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (const pattern of layer.patterns ?? []) {
|
|
181
|
+
if (!globToRegExp(pattern).test(rel))
|
|
182
|
+
continue;
|
|
183
|
+
const score = patternSpecificity(pattern, rel);
|
|
184
|
+
const prev = byLayer.get(layer.name);
|
|
185
|
+
if (!prev || score > prev.score) {
|
|
186
|
+
byLayer.set(layer.name, { layer: layer.name, pattern, score });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...byLayer.values()].sort((a, b) => b.score - a.score || a.layer.localeCompare(b.layer));
|
|
116
191
|
}
|
|
117
192
|
export function layerForRelativePath(relPath, layers) {
|
|
118
193
|
// File paths (not globs): any OS separator → posix relative.
|
|
@@ -125,7 +200,7 @@ export function layerForRelativePath(relPath, layers) {
|
|
|
125
200
|
}
|
|
126
201
|
for (const pattern of layer.patterns ?? []) {
|
|
127
202
|
if (globToRegExp(pattern).test(rel)) {
|
|
128
|
-
const score = patternSpecificity(pattern);
|
|
203
|
+
const score = patternSpecificity(pattern, rel);
|
|
129
204
|
if (score > bestScore) {
|
|
130
205
|
bestScore = score;
|
|
131
206
|
bestName = layer.name;
|
package/bin/ark-shared.mjs
CHANGED
|
@@ -129,16 +129,19 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
129
129
|
// claiming a framework that package.json does not declare.
|
|
130
130
|
if (signals.apiSurface && !signals.nestFramework && !signals.expressLike) {
|
|
131
131
|
ensureInclude('src');
|
|
132
|
+
// Controllers/routes/http stay Presentation. Do NOT add bare `src/**/api/**` —
|
|
133
|
+
// that swallows Application bags like `src/core/api/**` and Next `app/api/**`
|
|
134
|
+
// (P0-A API shell). API shells live on Application via NEXT_API / core patterns.
|
|
132
135
|
mergeLayerPatterns(next, 'PresentationAdapters', [
|
|
133
136
|
'src/**/routes/**',
|
|
134
137
|
'src/**/controllers/**',
|
|
135
138
|
'src/**/http/**',
|
|
136
|
-
'src/**/api/**',
|
|
137
139
|
]);
|
|
138
140
|
mergeLayerPatterns(next, 'ApplicationOrchestration', [
|
|
139
141
|
'src/**/services/**',
|
|
140
142
|
'src/**/use-cases/**',
|
|
141
143
|
'src/**/usecases/**',
|
|
144
|
+
'src/**/api/**',
|
|
142
145
|
]);
|
|
143
146
|
}
|
|
144
147
|
|
|
@@ -218,8 +221,9 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
218
221
|
'src/**/layout.ts',
|
|
219
222
|
'src/**/loading.tsx',
|
|
220
223
|
'src/**/error.tsx',
|
|
221
|
-
|
|
222
|
-
|
|
224
|
+
// Do NOT add bare src/**/route.ts here — that outranks app/api Application shells
|
|
225
|
+
// under path-anchored specificity (P0-A / DL-P0A-RETROFIT). Non-API route handlers
|
|
226
|
+
// still match via src/app/** Presentation; API route.ts is Application via app/api/**.
|
|
223
227
|
// Next middleware edge entry (classic + Next 16 proxy rename)
|
|
224
228
|
'src/middleware.ts',
|
|
225
229
|
'src/middleware.js',
|
|
@@ -235,18 +239,43 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
235
239
|
'src/server/**',
|
|
236
240
|
'src/services/**',
|
|
237
241
|
'src/use-cases/**',
|
|
238
|
-
|
|
239
|
-
//
|
|
242
|
+
// NEVER bare src/lib/** — that Application vacuum mis-layers Domain/Persistence
|
|
243
|
+
// (NEW-APP-VACUUM-LIB / DL-DOMAIN-SPECIFICITY). Prefer specific orchestration bags.
|
|
244
|
+
'src/lib/actions/**',
|
|
245
|
+
'src/lib/services/**',
|
|
246
|
+
'src/lib/server/**',
|
|
247
|
+
'src/lib/use-cases/**',
|
|
248
|
+
'src/lib/api-handlers/**',
|
|
249
|
+
'src/lib/handlers/**',
|
|
250
|
+
// Common Next "app core" bags — not the whole lib tree, not UI routes.
|
|
240
251
|
// Without these, monorepos like */src/core/** stay ungoverned and produce false greens.
|
|
241
252
|
'src/core/**',
|
|
242
253
|
'**/core/**',
|
|
243
254
|
'src/actions/**',
|
|
244
255
|
'src/**/actions.ts',
|
|
245
256
|
'src/**/actions.tsx',
|
|
257
|
+
// Next API routes are use-case / orchestration shells (not Presentation UI).
|
|
258
|
+
// Higher-specificity than src/app/** / app/** so they win over PresentationAdapters.
|
|
259
|
+
// Include route-group transparent shells: app/(marketing)/api/**
|
|
260
|
+
'src/app/api/**',
|
|
261
|
+
'app/api/**',
|
|
262
|
+
'src/pages/api/**',
|
|
263
|
+
'pages/api/**',
|
|
264
|
+
'**/app/api/**',
|
|
265
|
+
'**/pages/api/**',
|
|
266
|
+
'**/app/**/api/**',
|
|
267
|
+
'**/pages/**/api/**',
|
|
268
|
+
'src/app/**/api/**',
|
|
269
|
+
'app/**/api/**',
|
|
246
270
|
]);
|
|
247
271
|
mergeLayerPatterns(next, 'DomainModel', [
|
|
248
272
|
'src/domain/**',
|
|
249
273
|
'src/entities/**',
|
|
274
|
+
'**/domain/**',
|
|
275
|
+
'**/entities/**',
|
|
276
|
+
'**/kernel/domain/**',
|
|
277
|
+
'src/**/domain/**',
|
|
278
|
+
'src/**/entities/**',
|
|
250
279
|
'src/**/model/**',
|
|
251
280
|
'src/**/models/**',
|
|
252
281
|
]);
|
|
@@ -259,9 +288,10 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
259
288
|
'src/lib/db/**',
|
|
260
289
|
'src/lib/prisma/**',
|
|
261
290
|
'src/server/db/**',
|
|
262
|
-
// Conventional client data bags under lib/ (
|
|
291
|
+
// Conventional client data bags under lib/ (path-anchored specificity beats vacuum bags).
|
|
263
292
|
'src/lib/supabase/**',
|
|
264
293
|
'src/lib/airtable/**',
|
|
294
|
+
'src/lib/turso/**',
|
|
265
295
|
'src/lib/firebase/**',
|
|
266
296
|
'src/lib/firestore/**',
|
|
267
297
|
'src/lib/mongodb/**',
|
|
@@ -270,10 +300,17 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
270
300
|
'src/lib/kysely/**',
|
|
271
301
|
'src/lib/planetscale/**',
|
|
272
302
|
'src/lib/neon/**',
|
|
303
|
+
'src/lib/auth/**',
|
|
273
304
|
'**/lib/supabase/**',
|
|
274
305
|
'**/lib/airtable/**',
|
|
275
306
|
'**/lib/prisma/**',
|
|
307
|
+
'**/lib/turso/**',
|
|
276
308
|
'**/lib/db/**',
|
|
309
|
+
'**/lib/auth/**',
|
|
310
|
+
'**/repositories/**',
|
|
311
|
+
'**/db/**',
|
|
312
|
+
'**/supabase/**',
|
|
313
|
+
'**/airtable/**',
|
|
277
314
|
]);
|
|
278
315
|
// Demo assets, generated public output, and tool configs are not architecture surface.
|
|
279
316
|
const nextExcludes = [
|
|
@@ -428,6 +465,8 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
|
|
|
428
465
|
export {
|
|
429
466
|
globToRegExp,
|
|
430
467
|
patternSpecificity,
|
|
468
|
+
concreteGlobSegments,
|
|
469
|
+
matchingLayersForRelativePath,
|
|
431
470
|
layerForFile,
|
|
432
471
|
layerForRelativePath,
|
|
433
472
|
isEdgeDenied,
|
|
@@ -1272,6 +1311,25 @@ export function collectRepoShapeSignals(root) {
|
|
|
1272
1311
|
fs.existsSync(path.join(root, 'src', 'contexts')) ||
|
|
1273
1312
|
fs.existsSync(path.join(root, 'src', 'bounded-contexts'));
|
|
1274
1313
|
|
|
1314
|
+
// Vite SPA + root Vercel api/ + lib/ (NEW-SPA-DEFAULT-LAYOUT / superinsights-class).
|
|
1315
|
+
const viteDependency = Object.keys(deps).some(
|
|
1316
|
+
(name) => name === 'vite' || name.startsWith('vite/') || name === '@vitejs/plugin-react'
|
|
1317
|
+
);
|
|
1318
|
+
const viteConfigPresent = [
|
|
1319
|
+
'vite.config.ts',
|
|
1320
|
+
'vite.config.js',
|
|
1321
|
+
'vite.config.mjs',
|
|
1322
|
+
'vite.config.mts',
|
|
1323
|
+
].some((name) => fs.existsSync(path.join(root, name)));
|
|
1324
|
+
const rootApiDir = fs.existsSync(path.join(root, 'api'));
|
|
1325
|
+
const rootLibDir = fs.existsSync(path.join(root, 'lib'));
|
|
1326
|
+
const viteVercelSpaLayout =
|
|
1327
|
+
!nextFramework &&
|
|
1328
|
+
!nestFramework &&
|
|
1329
|
+
(viteDependency || viteConfigPresent) &&
|
|
1330
|
+
(rootApiDir || rootLibDir) &&
|
|
1331
|
+
(ui || hasUiFramework || rootApiDir);
|
|
1332
|
+
|
|
1275
1333
|
const hasBin = Boolean(pkg?.bin);
|
|
1276
1334
|
const hasExports = Boolean(pkg?.exports);
|
|
1277
1335
|
const hasMain = Boolean(pkg?.main || pkg?.module);
|
|
@@ -1308,6 +1366,7 @@ export function collectRepoShapeSignals(root) {
|
|
|
1308
1366
|
toolHints.push('@nestjs/*');
|
|
1309
1367
|
}
|
|
1310
1368
|
if (nextFramework && !toolHints.includes('next')) toolHints.push('next');
|
|
1369
|
+
if (viteVercelSpaLayout && !toolHints.includes('vite')) toolHints.push('vite');
|
|
1311
1370
|
|
|
1312
1371
|
// Turborepo / Nx markers (monorepo tooling — maps to monorepo preset, not separate engines).
|
|
1313
1372
|
const monorepoTooling = [];
|
|
@@ -1353,6 +1412,7 @@ export function collectRepoShapeSignals(root) {
|
|
|
1353
1412
|
featureSlicedLayout,
|
|
1354
1413
|
verticalSliceLayout,
|
|
1355
1414
|
dddBoundedContextsLayout,
|
|
1415
|
+
viteVercelSpaLayout,
|
|
1356
1416
|
// null when absent so scoreArchetypes `if (signals.x)` is false for empty tooling
|
|
1357
1417
|
monorepoTooling: monorepoTooling.length > 0 ? monorepoTooling : null,
|
|
1358
1418
|
fullStackProduct,
|
|
@@ -1469,11 +1529,95 @@ export function loadArchitecturePlaybook(playbookPath = defaultPlaybookPath()) {
|
|
|
1469
1529
|
}
|
|
1470
1530
|
|
|
1471
1531
|
function resolvePreset(archetypeDef, signals) {
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1532
|
+
const alts = archetypeDef.presetAlternatives ?? {};
|
|
1533
|
+
// SPA layout first — more specific than feature-sliced / default layered.
|
|
1534
|
+
const spaAlt = alts['vite-vercel-spa'];
|
|
1535
|
+
if (spaAlt?.whenSignal && signals[spaAlt.whenSignal]) return 'vite-vercel-spa';
|
|
1536
|
+
// Also honor signal even when playbook omits the alternative key (older playbooks).
|
|
1537
|
+
if (signals.viteVercelSpaLayout) return 'vite-vercel-spa';
|
|
1538
|
+
const fsdAlt = alts['feature-sliced'];
|
|
1539
|
+
if (fsdAlt?.whenSignal && signals[fsdAlt.whenSignal]) return 'feature-sliced';
|
|
1474
1540
|
return archetypeDef.preset;
|
|
1475
1541
|
}
|
|
1476
1542
|
|
|
1543
|
+
/**
|
|
1544
|
+
* Refuse start --apply when shape is too uncertain (NEW-START-LOW-CONFIDENCE-SHAPE).
|
|
1545
|
+
* Applies to **all** apply paths (not only --yes). Explicit --archetype / --preset / --force bypasses.
|
|
1546
|
+
* Fail-closed when confidence or projected coverage metrics are null/unknown.
|
|
1547
|
+
*
|
|
1548
|
+
* Rules:
|
|
1549
|
+
* - empty greenfield (totalFiles === 0) → allow (scaffold only; nothing to mis-classify yet)
|
|
1550
|
+
* - null confidence or null projected coverage → refuse (fail-closed)
|
|
1551
|
+
* - projected coverage < 50% → refuse (wrong include / vacuum)
|
|
1552
|
+
* - confidence < 0.6 AND projected coverage < 80% → refuse (weak shape + incomplete cover)
|
|
1553
|
+
* - confidence < 0.6 with coverage ≥ 80% → allow (thin-library / capped confidence but contract covers)
|
|
1554
|
+
*
|
|
1555
|
+
* @returns {{ ok: true } | { ok: false, reasons: string[], confidence: number|null, projectedCoverage: number|null }}
|
|
1556
|
+
*/
|
|
1557
|
+
export function evaluateStartShapeConfidenceGate({
|
|
1558
|
+
confidence,
|
|
1559
|
+
projectedCoveragePercent,
|
|
1560
|
+
totalFiles,
|
|
1561
|
+
explicitShape = false,
|
|
1562
|
+
force = false,
|
|
1563
|
+
minConfidence = 0.6,
|
|
1564
|
+
minProjectedCoverage = 50,
|
|
1565
|
+
strongCoverageFloor = 80,
|
|
1566
|
+
} = {}) {
|
|
1567
|
+
if (force || explicitShape) return { ok: true, bypassed: true };
|
|
1568
|
+
// Day-zero empty tree: contract + gates scaffold; coverage is vacuously fine.
|
|
1569
|
+
if (typeof totalFiles === 'number' && totalFiles === 0) {
|
|
1570
|
+
return {
|
|
1571
|
+
ok: true,
|
|
1572
|
+
emptyGreenfield: true,
|
|
1573
|
+
confidence: typeof confidence === 'number' ? confidence : null,
|
|
1574
|
+
projectedCoverage: 100,
|
|
1575
|
+
totalFiles: 0,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
const reasons = [];
|
|
1579
|
+
const conf =
|
|
1580
|
+
typeof confidence === 'number' && Number.isFinite(confidence) ? confidence : null;
|
|
1581
|
+
const cov =
|
|
1582
|
+
typeof projectedCoveragePercent === 'number' && Number.isFinite(projectedCoveragePercent)
|
|
1583
|
+
? projectedCoveragePercent
|
|
1584
|
+
: null;
|
|
1585
|
+
// Fail-closed on unknown metrics (null analysis must not silently apply).
|
|
1586
|
+
if (cov === null) {
|
|
1587
|
+
reasons.push(
|
|
1588
|
+
'projected governed coverage is unknown — refuse apply without measured coverage (fail-closed)'
|
|
1589
|
+
);
|
|
1590
|
+
} else if (cov < minProjectedCoverage) {
|
|
1591
|
+
reasons.push(
|
|
1592
|
+
`projected governed coverage ${cov}% is below ${minProjectedCoverage}% — refuse apply`
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
// Thin packages often cap confidence (e.g. 0.28) while covering 100% — do not block those.
|
|
1596
|
+
const coverageStrong = cov !== null && cov >= strongCoverageFloor;
|
|
1597
|
+
if (conf === null) {
|
|
1598
|
+
reasons.push(
|
|
1599
|
+
'archetype confidence is unknown — refuse apply without shape analysis (fail-closed); pass --archetype/--preset or --force'
|
|
1600
|
+
);
|
|
1601
|
+
} else if (conf < minConfidence && !coverageStrong) {
|
|
1602
|
+
reasons.push(
|
|
1603
|
+
`archetype confidence ${conf} is below ${minConfidence} with projected coverage ${cov ?? 'unknown'}% (< ${strongCoverageFloor}%) — refuse apply without an explicit shape`
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
if (reasons.length === 0) return { ok: true, confidence: conf, projectedCoverage: cov };
|
|
1607
|
+
return {
|
|
1608
|
+
ok: false,
|
|
1609
|
+
reasons,
|
|
1610
|
+
confidence: conf,
|
|
1611
|
+
projectedCoverage: cov,
|
|
1612
|
+
choices: [
|
|
1613
|
+
'Pass --archetype <id> or --preset <name> to lock the shape deliberately',
|
|
1614
|
+
'Pass --force to apply anyway after reviewing the preview',
|
|
1615
|
+
'Run without --apply for a read-only preview',
|
|
1616
|
+
'Run ark-check --recommend to inspect ranked shapes',
|
|
1617
|
+
],
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1477
1621
|
/**
|
|
1478
1622
|
* Score playbook archetypes against collected repo shape signals.
|
|
1479
1623
|
* Returns sorted matches (highest score first) with confidence in [0, 1].
|
|
@@ -1656,6 +1800,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1656
1800
|
expressLike: signals.expressLike,
|
|
1657
1801
|
verticalSliceLayout: signals.verticalSliceLayout,
|
|
1658
1802
|
dddBoundedContextsLayout: signals.dddBoundedContextsLayout,
|
|
1803
|
+
viteVercelSpaLayout: signals.viteVercelSpaLayout,
|
|
1659
1804
|
monorepoTooling: signals.monorepoTooling,
|
|
1660
1805
|
},
|
|
1661
1806
|
// A repo past this size is not greenfield: `ark init` would scaffold a starter that governs
|
|
@@ -1711,9 +1856,25 @@ export function mapWizardChoiceToArchetype(choiceKey) {
|
|
|
1711
1856
|
|
|
1712
1857
|
const NEW_HERE_GOVERNED_THRESHOLD = 50;
|
|
1713
1858
|
|
|
1714
|
-
/**
|
|
1859
|
+
/**
|
|
1860
|
+
* Show onboarding nudge when config is missing or start has not finished.
|
|
1861
|
+
*
|
|
1862
|
+
* After `ark start --apply` (config + AGENTS.md present), low coverage is an
|
|
1863
|
+
* adopt/coverage problem — not “finish ark start”. Field: NEW-DOCTOR-STALE-FINISH-START.
|
|
1864
|
+
*/
|
|
1715
1865
|
export function shouldShowNewHereNudge(root, configPath, governedPercent, configMissing) {
|
|
1716
1866
|
if (configMissing) return true;
|
|
1867
|
+
// Start already applied: never primary-action “finish start”.
|
|
1868
|
+
try {
|
|
1869
|
+
if (
|
|
1870
|
+
fs.existsSync(configPath) &&
|
|
1871
|
+
fs.existsSync(path.join(root, 'AGENTS.md'))
|
|
1872
|
+
) {
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1875
|
+
} catch {
|
|
1876
|
+
/* fall through */
|
|
1877
|
+
}
|
|
1717
1878
|
if (typeof governedPercent === 'number' && governedPercent < NEW_HERE_GOVERNED_THRESHOLD) {
|
|
1718
1879
|
return true;
|
|
1719
1880
|
}
|
package/bin/ark.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildArchitectureRecommendation,
|
|
10
10
|
detectPackageManager,
|
|
11
11
|
detectWorkspaces,
|
|
12
|
+
evaluateStartShapeConfidenceGate,
|
|
12
13
|
resolveIncludeRoots,
|
|
13
14
|
detectTsPackageRoots,
|
|
14
15
|
INIT_WIZARD_CHOICES,
|
|
@@ -422,11 +423,52 @@ async function start(args) {
|
|
|
422
423
|
cliVersion,
|
|
423
424
|
packageInstallArgv,
|
|
424
425
|
});
|
|
426
|
+
// NEW-START-LOW-CONFIDENCE-SHAPE: refuse apply on all apply paths when shape is weak.
|
|
427
|
+
if (args.apply) {
|
|
428
|
+
const gate = evaluateStartShapeConfidenceGate({
|
|
429
|
+
confidence: preview.analysis?.confidence,
|
|
430
|
+
projectedCoveragePercent: preview.projectedCoverage?.percent,
|
|
431
|
+
totalFiles: preview.projectedCoverage?.totalFiles,
|
|
432
|
+
explicitShape: Boolean(args.archetype || args.preset),
|
|
433
|
+
force: Boolean(args.force),
|
|
434
|
+
});
|
|
435
|
+
if (!gate.ok) {
|
|
436
|
+
if (args.json) {
|
|
437
|
+
console.log(
|
|
438
|
+
JSON.stringify(
|
|
439
|
+
{
|
|
440
|
+
ok: false,
|
|
441
|
+
error: 'start-shape-confidence-gate',
|
|
442
|
+
...gate,
|
|
443
|
+
preview,
|
|
444
|
+
},
|
|
445
|
+
null,
|
|
446
|
+
2
|
|
447
|
+
)
|
|
448
|
+
);
|
|
449
|
+
} else {
|
|
450
|
+
console.error('Refusing ark start --apply: shape confidence / coverage gate failed.');
|
|
451
|
+
for (const reason of gate.reasons ?? []) console.error(` • ${reason}`);
|
|
452
|
+
console.error('Choices:');
|
|
453
|
+
for (const choice of gate.choices ?? []) console.error(` • ${choice}`);
|
|
454
|
+
renderStartPreview(preview);
|
|
455
|
+
}
|
|
456
|
+
return 2;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
425
459
|
if (args.json) console.log(JSON.stringify(preview, null, 2));
|
|
426
|
-
else renderStartPreview(preview);
|
|
460
|
+
else if (!args.apply) renderStartPreview(preview);
|
|
461
|
+
else renderStartPreview(preview, { applying: true });
|
|
427
462
|
if (!args.apply) return 0;
|
|
428
463
|
applyStartPreview(args.root, preview);
|
|
429
|
-
|
|
464
|
+
// DL-START-APPLY-MESSAGE: single honest summary (do not claim preview-no-write after apply).
|
|
465
|
+
if (!args.json) {
|
|
466
|
+
if (preview.changes.length === 0) {
|
|
467
|
+
console.log('Start apply complete — nothing to change (already set up).');
|
|
468
|
+
} else {
|
|
469
|
+
console.log(`Applied ${preview.changes.length} start mutation(s).`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
430
472
|
// After applying exact preview bytes, install the pinned package when requested
|
|
431
473
|
// (preview itself never runs the package manager — field: start left pin without node_modules).
|
|
432
474
|
if (
|
|
@@ -548,8 +590,12 @@ async function start(args) {
|
|
|
548
590
|
fs.existsSync(path.join(root, 'lerna.json')) ||
|
|
549
591
|
fs.existsSync(path.join(root, 'apps')) ||
|
|
550
592
|
fs.existsSync(path.join(root, 'packages'));
|
|
551
|
-
//
|
|
552
|
-
if (
|
|
593
|
+
// SPA (Vite + root api/lib) wins over monorepo heuristics (NEW-SPA-DEFAULT-LAYOUT).
|
|
594
|
+
if (rec?.preset === 'vite-vercel-spa' || preset === 'vite-vercel-spa') {
|
|
595
|
+
initArgs.push('--preset', 'vite-vercel-spa');
|
|
596
|
+
console.log(' Vite/Vercel SPA layout detected — include src,api,lib; api→Application; db clients→Persistence.');
|
|
597
|
+
} else if (looksLikeMonorepo && (rec?.mature || includeRoots.length > 0 || tsPackages.length > 0)) {
|
|
598
|
+
// Mature multi-package / nested-TS trees must NOT get a thin src/** starter (0 files).
|
|
553
599
|
// UI-heavy TS packages (Remotion/Vite) prefer ui-surface patterns when recommend says so.
|
|
554
600
|
const useUi =
|
|
555
601
|
rec?.preset === 'feature-sliced' ||
|
|
@@ -562,7 +608,8 @@ async function start(args) {
|
|
|
562
608
|
? ` Multi-package / TS package layout detected — profile include: ${shown.join(', ')}.`
|
|
563
609
|
: ' Multi-package layout detected — using monorepo profile.'
|
|
564
610
|
);
|
|
565
|
-
} else if (
|
|
611
|
+
} else if (preset) {
|
|
612
|
+
// Prefer recommended preset even on mature single-package trees (avoid vacuum hexagonal).
|
|
566
613
|
initArgs.push('--preset', preset);
|
|
567
614
|
}
|
|
568
615
|
const status = runArkCheck(initArgs, { cwd: root });
|
|
@@ -53,7 +53,12 @@ function nextActionForDiagnostic(ruleId, evidence, violation) {
|
|
|
53
53
|
}
|
|
54
54
|
export function toAdapterDiagnostic(violation, fallbackSeverity = 'error') {
|
|
55
55
|
const ruleId = text(violation.ruleId) ?? text(violation.code) ?? 'ARK_UNKNOWN';
|
|
56
|
-
|
|
56
|
+
// Type-only placement debt (failsStrict:false / typeOnly non-peer) is warning severity.
|
|
57
|
+
const severity = violation.severity === 'warning' ||
|
|
58
|
+
violation.failsStrict === false ||
|
|
59
|
+
(violation.typeOnly === true && violation.peerIsolation !== true)
|
|
60
|
+
? 'warning'
|
|
61
|
+
: fallbackSeverity;
|
|
57
62
|
const evidence = {
|
|
58
63
|
...(text(violation.target) ? { target: text(violation.target) } : {}),
|
|
59
64
|
...(text(violation.fromLayer) ? { fromLayer: text(violation.fromLayer) } : {}),
|
|
@@ -129,6 +134,7 @@ export function createAdapterResult(input) {
|
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
const diagnostics = [
|
|
137
|
+
// toAdapterDiagnostic maps failsStrict:false / typeOnly non-peer → warning severity.
|
|
132
138
|
...(input.violations ?? []).map((item) => toAdapterDiagnostic(item, 'error')),
|
|
133
139
|
...(input.warnings ?? []).map((item) => toAdapterDiagnostic(item, 'warning')),
|
|
134
140
|
];
|
package/bin/lib/agent-gates.mjs
CHANGED