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
@@ -5,6 +5,8 @@ import {
5
5
  DEFAULT_ARK_CONFIG_RULES,
6
6
  withArkConfigMetadata,
7
7
  } from './lib/config-contract.mjs';
8
+ import { collectForbiddenCapabilityUses } from './lib/analysis-engine.mjs';
9
+ import { looksLikeArkIntent } from './lib/source-policy.mjs';
8
10
 
9
11
  /**
10
12
  * Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
@@ -130,6 +132,40 @@ export function applyFrameworkLayoutOverlays(config, root) {
130
132
  }
131
133
  };
132
134
 
135
+ // Framework-free application layouts are common in clean-room starters and
136
+ // brownfield products. Govern their explicit source conventions without
137
+ // claiming a framework that package.json does not declare.
138
+ if (signals.apiSurface && !signals.nestFramework && !signals.expressLike) {
139
+ ensureInclude('src');
140
+ mergeLayerPatterns(next, 'PresentationAdapters', [
141
+ 'src/**/routes/**',
142
+ 'src/**/controllers/**',
143
+ 'src/**/http/**',
144
+ 'src/**/api/**',
145
+ ]);
146
+ mergeLayerPatterns(next, 'ApplicationOrchestration', [
147
+ 'src/**/services/**',
148
+ 'src/**/use-cases/**',
149
+ 'src/**/usecases/**',
150
+ ]);
151
+ }
152
+
153
+ if (signals.ui && !signals.nextFramework) {
154
+ ensureInclude('src');
155
+ mergeLayerPatterns(next, 'PresentationAdapters', [
156
+ 'src/**/components/**',
157
+ 'src/**/layouts/**',
158
+ 'src/**/ui/**',
159
+ 'src/**/routes/**',
160
+ 'src/**/pages/**',
161
+ ]);
162
+ // Match the existing ui-surface profile: UI components may consume their
163
+ // display-domain models, while the domain still cannot depend on UI.
164
+ next.rules = next.rules.filter(
165
+ (rule) => !(rule.from === 'PresentationAdapters' && rule.to === 'DomainModel' && rule.allowed === false)
166
+ );
167
+ }
168
+
133
169
  if (signals.nestFramework) {
134
170
  ensureInclude('src');
135
171
  // Nest flat + modular conventions (controllers/services next to modules).
@@ -312,7 +348,26 @@ export function applyFrameworkLayoutOverlays(config, root) {
312
348
  if (signals.libraryOnly && !signals.nestFramework && !signals.nextFramework) {
313
349
  ensureInclude('src');
314
350
  ensureInclude('lib');
315
- mergeLayerPatterns(next, 'DomainModel', ['src/**/*.ts', 'src/**/*.tsx', 'lib/**/*.ts']);
351
+ // Published libraries commonly expose a root entrypoint instead of src/. Include that
352
+ // real surface, but not test files: a public package entrypoint is application code and
353
+ // must not remain outside the contract simply because it is JavaScript or root-level.
354
+ ensureInclude('.');
355
+ mergeLayerPatterns(next, 'DomainModel', [
356
+ 'src/**/*.ts',
357
+ 'src/**/*.tsx',
358
+ 'src/**/*.js',
359
+ 'src/**/*.mjs',
360
+ 'src/**/*.cjs',
361
+ 'lib/**/*.ts',
362
+ 'lib/**/*.js',
363
+ 'lib/**/*.mjs',
364
+ 'lib/**/*.cjs',
365
+ '*.ts',
366
+ '*.tsx',
367
+ '*.js',
368
+ '*.mjs',
369
+ '*.cjs',
370
+ ]);
316
371
  // Prefer domain over application for a single-folder lib: only domain if no split.
317
372
  next.frameworkOverlay = next.frameworkOverlay
318
373
  ? `${next.frameworkOverlay}+library`
@@ -373,102 +428,8 @@ export function resolveOperatingMode({
373
428
  return 'suggest';
374
429
  }
375
430
 
376
- function singleFileTypeChecker(ts, sourceFile) {
377
- const options = {
378
- noLib: true,
379
- noResolve: true,
380
- target: ts.ScriptTarget.Latest,
381
- };
382
- const host = ts.createCompilerHost(options, true);
383
- host.getSourceFile = (fileName) =>
384
- fileName === sourceFile.fileName ? sourceFile : undefined;
385
- host.fileExists = (fileName) => fileName === sourceFile.fileName;
386
- host.readFile = (fileName) =>
387
- fileName === sourceFile.fileName ? sourceFile.text : undefined;
388
- return ts.createProgram([sourceFile.fileName], options, host).getTypeChecker();
389
- }
390
-
391
- function propertyAccessPath(ts, node) {
392
- const segments = [];
393
- let current = node;
394
- while (ts.isPropertyAccessExpression(current)) {
395
- segments.unshift(current.name.text);
396
- current = current.expression;
397
- }
398
- if (!ts.isIdentifier(current)) return undefined;
399
- segments.unshift(current.text);
400
- return { root: current, segments };
401
- }
402
-
403
- function isRuntimeIdentifierReference(ts, node) {
404
- if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) return false;
405
- return (
406
- (ts.isExpressionNode(node) && !ts.isInTypeQuery(node)) ||
407
- (ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node)
408
- );
409
- }
410
-
411
- function hasLocalDeclaration(ts, checker, sourceFile, node) {
412
- const shorthand =
413
- ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node;
414
- const symbol = shorthand
415
- ? checker.getShorthandAssignmentValueSymbol(node.parent)
416
- : checker.getSymbolAtLocation(node);
417
- return Boolean(
418
- symbol?.declarations?.some((declaration) => declaration.getSourceFile() === sourceFile)
419
- );
420
- }
421
-
422
- /**
423
- * Find uses of forbidden ambient globals in a TypeScript source file.
424
- *
425
- * A no-lib, no-resolution TypeScript program binds declarations in this file only. An
426
- * identifier with a symbol is therefore local (parameter, variable, import, etc.); an
427
- * unbound runtime identifier is ambient. Dotted entries use AST property chains and
428
- * explicit `globalThis` access is normalized to the configured global name.
429
- *
430
- * Kept in sync with `analyzeForbiddenGlobals` in
431
- * src/kernel/ai-gate/AICodeGate.ts — the standalone CLIs must not import from dist.
432
- * Returns [{ name, node }] where `name` is the matched forbidden entry.
433
- */
434
431
  export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
435
- const entries = new Set(forbidden ?? []);
436
- if (entries.size === 0) return [];
437
- const checker = singleFileTypeChecker(ts, sourceFile);
438
- const uses = [];
439
-
440
- const visit = (node) => {
441
- const nestedPropertyAccess =
442
- ts.isPropertyAccessExpression(node) &&
443
- ts.isPropertyAccessExpression(node.parent) &&
444
- node.parent.expression === node;
445
- if (ts.isPropertyAccessExpression(node) && !nestedPropertyAccess) {
446
- const path = propertyAccessPath(ts, node);
447
- if (path && !hasLocalDeclaration(ts, checker, sourceFile, path.root)) {
448
- const explicitGlobalThis = path.segments[0] === 'globalThis';
449
- const normalized = explicitGlobalThis ? path.segments.slice(1) : path.segments;
450
- let match;
451
- for (let length = normalized.length; length >= (explicitGlobalThis ? 1 : 2); length -= 1) {
452
- const candidate = normalized.slice(0, length).join('.');
453
- if (entries.has(candidate)) {
454
- match = candidate;
455
- break;
456
- }
457
- }
458
- if (match) uses.push({ name: match, node });
459
- }
460
- } else if (
461
- ts.isIdentifier(node) &&
462
- entries.has(node.text) &&
463
- isRuntimeIdentifierReference(ts, node) &&
464
- !hasLocalDeclaration(ts, checker, sourceFile, node)
465
- ) {
466
- uses.push({ name: node.text, node });
467
- }
468
- ts.forEachChild(node, visit);
469
- };
470
- visit(sourceFile);
471
- return uses;
432
+ return collectForbiddenCapabilityUses(ts, sourceFile, forbidden ?? []);
472
433
  }
473
434
 
474
435
  // Layer glob matching — generated from canonical src/domain/layerMatch.ts (see generate:layer-match).
@@ -511,17 +472,8 @@ export function resolveIntentLayer(intent, layers) {
511
472
  return sorted.find((layer) => layer.prefixes.some((prefix) => intent.startsWith(prefix)))?.name;
512
473
  }
513
474
 
514
- /**
515
- * Intent-name recognizer. Kept deliberately in sync with `looksLikeIntentName` in
516
- * src/kernel/ai-gate/AICodeGate.ts: the two live in separate layers on purpose — the
517
- * CLIs run standalone (with only `typescript` present, no build), so they must not
518
- * import from the compiled library. Update both if the layer prefixes change.
519
- */
520
- const INTENT_NAME =
521
- /^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/;
522
-
523
475
  export function looksLikeIntent(value) {
524
- return INTENT_NAME.test(value);
476
+ return looksLikeArkIntent(value);
525
477
  }
526
478
 
527
479
  /**
@@ -970,6 +922,85 @@ function isSourceFile(name) {
970
922
  return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
971
923
  }
972
924
 
925
+ function readJsonc(file) {
926
+ try {
927
+ const text = fs
928
+ .readFileSync(file, 'utf8')
929
+ .replace(/\/\*[\s\S]*?\*\//g, '')
930
+ .replace(/^\s*\/\/.*$/gm, '')
931
+ .replace(/,\s*([}\]])/g, '$1');
932
+ return JSON.parse(text);
933
+ } catch {
934
+ return null;
935
+ }
936
+ }
937
+
938
+ function packageRole(rel, pkg) {
939
+ const tokens = rel.toLowerCase().split('/');
940
+ if (tokens.some((token) => ['docs', 'documentation', 'website', 'examples', 'example', 'tests', 'test', 'fixtures'].includes(token))) {
941
+ return tokens.some((token) => token.startsWith('doc') || token === 'website')
942
+ ? 'docs'
943
+ : tokens.some((token) => token.startsWith('test') || token === 'fixtures')
944
+ ? 'test'
945
+ : 'example';
946
+ }
947
+ if (pkg?.bin) return 'cli';
948
+ if (pkg?.exports || pkg?.main || pkg?.module || pkg?.types || pkg?.typings) return 'library';
949
+ return 'application';
950
+ }
951
+
952
+ function entrypointDirs(pkg) {
953
+ const values = [];
954
+ const add = (value) => {
955
+ if (typeof value === 'string' && !value.includes('*')) values.push(value);
956
+ else if (value && typeof value === 'object') Object.values(value).forEach(add);
957
+ };
958
+ for (const key of ['main', 'module', 'types', 'typings', 'exports']) add(pkg?.[key]);
959
+ return values.map((value) => path.posix.dirname(value.replace(/^\.\//, ''))).filter((dir) => dir !== '.');
960
+ }
961
+
962
+ /** Package/import units and their explicit source roots, before framework inference. */
963
+ export function discoverRepoUnits(root) {
964
+ const packageRels = new Set(['.']);
965
+ for (const rel of detectTsPackageRoots(root)) packageRels.add(rel);
966
+ const units = [];
967
+ for (const rel of packageRels) {
968
+ const abs = rel === '.' ? root : path.join(root, rel);
969
+ const pkg = readPackageJson(abs);
970
+ if (!pkg && rel !== '.') continue;
971
+ const roots = new Set();
972
+ for (const name of ['src', 'source']) {
973
+ if (fs.existsSync(path.join(abs, name))) roots.add(name);
974
+ }
975
+ if (rel === '.') {
976
+ for (const name of ['api', 'lib', 'app', 'frontend', 'web', 'client']) {
977
+ if (dirHasTsSources(path.join(abs, name), 3)) roots.add(name);
978
+ }
979
+ }
980
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
981
+ const config = readJsonc(path.join(abs, name));
982
+ const rootDir = config?.compilerOptions?.rootDir;
983
+ if (typeof rootDir === 'string') roots.add(normalizeRel(rootDir));
984
+ for (const ref of config?.references ?? []) {
985
+ if (typeof ref?.path === 'string') roots.add(normalizeRel(ref.path).replace(/^\.\//, ''));
986
+ }
987
+ }
988
+ for (const dir of entrypointDirs(pkg)) {
989
+ if (!['dist', 'build', 'lib'].includes(dir.split('/')[0])) roots.add(normalizeRel(dir));
990
+ }
991
+ if (roots.size === 0 && dirHasTsSources(abs, 2)) roots.add('.');
992
+ const productionDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.peerDependencies ?? {}), ...(pkg?.optionalDependencies ?? {}) };
993
+ units.push({
994
+ root: rel,
995
+ role: packageRole(rel, pkg),
996
+ sourceRoots: [...roots],
997
+ productionDeps,
998
+ devDependencies: { ...(pkg?.devDependencies ?? {}) },
999
+ });
1000
+ }
1001
+ return units;
1002
+ }
1003
+
973
1004
  function walkSourceFiles(dir, files = [], depth = 0) {
974
1005
  if (depth > 12) return files;
975
1006
  const stat = fs.statSync(dir, { throwIfNoEntry: false });
@@ -1048,36 +1079,9 @@ function countTsxFiles(files) {
1048
1079
  */
1049
1080
  export function collectAggregatedDeps(root) {
1050
1081
  const deps = {};
1051
- const mergePkg = (pkg) => {
1052
- if (!pkg || typeof pkg !== 'object') return;
1053
- for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
1054
- const block = pkg[key];
1055
- if (block && typeof block === 'object') Object.assign(deps, block);
1056
- }
1057
- };
1058
- mergePkg(readPackageJson(root));
1059
- let entries = [];
1060
- try {
1061
- entries = fs.readdirSync(root, { withFileTypes: true });
1062
- } catch {
1063
- return deps;
1064
- }
1065
- for (const entry of entries) {
1066
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
1067
- const childRoot = path.join(root, entry.name);
1068
- mergePkg(readPackageJson(childRoot));
1069
- // One level under conventional multi-package roots
1070
- if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
1071
- try {
1072
- for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
1073
- if (sub.isDirectory() && !sub.name.startsWith('.')) {
1074
- mergePkg(readPackageJson(path.join(childRoot, sub.name)));
1075
- }
1076
- }
1077
- } catch {
1078
- /* ignore */
1079
- }
1080
- }
1082
+ for (const unit of discoverRepoUnits(root)) {
1083
+ if (['docs', 'example', 'test'].includes(unit.role)) continue;
1084
+ Object.assign(deps, unit.productionDeps);
1081
1085
  }
1082
1086
  return deps;
1083
1087
  }
@@ -1088,27 +1092,32 @@ export function collectAggregatedDeps(root) {
1088
1092
  */
1089
1093
  export function collectRepoShapeSignals(root) {
1090
1094
  const pkg = readPackageJson(root);
1095
+ const repoUnits = discoverRepoUnits(root);
1091
1096
  const workspaceDirs = detectWorkspaces(root);
1092
1097
  const workspaces = workspaceDirs.length > 0;
1093
1098
  // Include frontend/web/client — common Next monorepo app folders.
1094
- const candidateScanDirs = [
1095
- 'src',
1096
- 'lib',
1097
- 'api',
1098
- 'packages',
1099
- 'apps',
1100
- 'frontend',
1101
- 'web',
1102
- 'client',
1103
- 'app',
1104
- ...workspaceDirs,
1105
- ];
1099
+ const candidateScanDirs = repoUnits
1100
+ .filter((unit) => !['docs', 'example', 'test'].includes(unit.role))
1101
+ .flatMap((unit) => unit.sourceRoots.map((sourceRoot) =>
1102
+ normalizeRel(path.posix.join(unit.root === '.' ? '' : unit.root, sourceRoot))
1103
+ ));
1106
1104
  const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
1107
1105
  fs.existsSync(path.join(root, d))
1108
1106
  );
1109
1107
  const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
1110
- const sourceFiles = scanRoots.flatMap((dir) => walkSourceFiles(dir));
1108
+ const sourceFiles = [...new Set(scanRoots.flatMap((dir) => walkSourceFiles(dir)))];
1111
1109
  const sourceFileCount = sourceFiles.length;
1110
+ const excludedUnitRoots = repoUnits
1111
+ .filter((unit) => ['docs', 'example', 'test'].includes(unit.role) && unit.root !== '.')
1112
+ .map((unit) => `${unit.root}/`);
1113
+ const productionFiles = walkSourceFiles(root).filter((file) => {
1114
+ const rel = path.relative(root, file).split(path.sep).join('/');
1115
+ return !excludedUnitRoots.some((prefix) => rel.startsWith(prefix));
1116
+ });
1117
+ const discoveredFileSet = new Set(sourceFiles.map((file) => path.resolve(file)));
1118
+ const projectedGovernedCoverage = productionFiles.length === 0
1119
+ ? 0
1120
+ : Math.round((productionFiles.filter((file) => discoveredFileSet.has(path.resolve(file))).length / productionFiles.length) * 100);
1112
1121
  const tinyTree = sourceFileCount < 3;
1113
1122
 
1114
1123
  // Nested package.json deps (not root-only) — critical for monorepo Next under frontend/
@@ -1246,6 +1255,8 @@ export function collectRepoShapeSignals(root) {
1246
1255
  }
1247
1256
 
1248
1257
  return {
1258
+ repoUnits,
1259
+ discoveredRoots: srcDirs,
1249
1260
  workspaces,
1250
1261
  workspaceDirs,
1251
1262
  ui,
@@ -1263,6 +1274,7 @@ export function collectRepoShapeSignals(root) {
1263
1274
  libraryOnly,
1264
1275
  tinyTree,
1265
1276
  sourceFileCount,
1277
+ projectedGovernedCoverage,
1266
1278
  domain,
1267
1279
  application,
1268
1280
  domainHeavy,
@@ -1349,6 +1361,16 @@ export function whyFromMatchedSignals(signals, matched) {
1349
1361
  return why;
1350
1362
  }
1351
1363
 
1364
+ function evidenceFromMatchedSignals(signals, matched) {
1365
+ return (matched ?? []).flatMap((token) => {
1366
+ const negative = token.startsWith('!');
1367
+ const signal = negative ? token.slice(1) : token;
1368
+ const label = negative ? NEGATIVE_SIGNAL_WHY[signal] : SIGNAL_WHY[signal];
1369
+ if (!label || !signals[signal]) return [];
1370
+ return [{ signal, effect: negative ? 'negative' : 'positive', explanation: label(signals) }];
1371
+ });
1372
+ }
1373
+
1352
1374
  export function defaultPlaybookPath() {
1353
1375
  return path.join(
1354
1376
  path.dirname(fileURLToPath(import.meta.url)),
@@ -1433,6 +1455,11 @@ export function scoreArchetypes(signals, playbook) {
1433
1455
  label: fallback.label,
1434
1456
  preset: fallback.preset,
1435
1457
  confidence,
1458
+ requiresConfirmation: true,
1459
+ confirmationReasons: [
1460
+ `projected governed coverage is ${signals.projectedGovernedCoverage ?? 0}% (below 90%)`,
1461
+ 'no archetype received a positive score',
1462
+ ],
1436
1463
  phases: fallback.phases,
1437
1464
  analogy: fallback.analogy,
1438
1465
  antiPatterns: fallback.antiPatterns,
@@ -1454,6 +1481,10 @@ export function scoreArchetypes(signals, playbook) {
1454
1481
  if (thinTs) {
1455
1482
  confidence = Math.min(confidence, 0.28);
1456
1483
  }
1484
+ const closeRecommendations = Boolean(second && top.score - second.score <= 2);
1485
+ const lowProjectedCoverage = (signals.projectedGovernedCoverage ?? 0) < 90;
1486
+ if (closeRecommendations) confidence = Math.min(confidence, 0.49);
1487
+ if (lowProjectedCoverage) confidence = Math.min(confidence, 0.49);
1457
1488
 
1458
1489
  return {
1459
1490
  ranked: scored,
@@ -1468,6 +1499,12 @@ export function scoreArchetypes(signals, playbook) {
1468
1499
  'TypeScript/JS surface is thin or missing — treat the archetype as a weak hint. Prefer ark-check --suggest-include / --adopt-contract on the real package roots before scaffolding.',
1469
1500
  }
1470
1501
  : {}),
1502
+ requiresConfirmation: closeRecommendations || thinTs || lowProjectedCoverage,
1503
+ confirmationReasons: [
1504
+ ...(closeRecommendations ? ['top recommendations are within 2 score points'] : []),
1505
+ ...(lowProjectedCoverage ? [`projected governed coverage is ${signals.projectedGovernedCoverage}% (below 90%)`] : []),
1506
+ ...(thinTs ? ['the discovered source surface is thin'] : []),
1507
+ ],
1471
1508
  phases: top.phases,
1472
1509
  analogy: top.analogy,
1473
1510
  antiPatterns: top.antiPatterns,
@@ -1505,6 +1542,8 @@ export function buildArchitectureRecommendation(root, options = {}) {
1505
1542
  label: result.label,
1506
1543
  preset: result.preset,
1507
1544
  confidence: result.confidence,
1545
+ requiresConfirmation: result.requiresConfirmation ?? false,
1546
+ confirmationReasons: result.confirmationReasons ?? [],
1508
1547
  ...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
1509
1548
  phases: result.phases,
1510
1549
  adoptInOrder,
@@ -1512,6 +1551,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
1512
1551
  antiPatterns: result.antiPatterns,
1513
1552
  books: result.books,
1514
1553
  why: whyFromMatchedSignals(signals, result.matched),
1554
+ evidence: evidenceFromMatchedSignals(signals, result.matched),
1515
1555
  matchedSignals: result.matched,
1516
1556
  runnerUp: result.runnerUp,
1517
1557
  toolHints: signals.toolHints,
@@ -1519,6 +1559,15 @@ export function buildArchitectureRecommendation(root, options = {}) {
1519
1559
  policyPack: policyPackId,
1520
1560
  signals: {
1521
1561
  sourceFileCount: signals.sourceFileCount,
1562
+ projectedGovernedCoverage: signals.projectedGovernedCoverage,
1563
+ discoveredRoots: signals.discoveredRoots,
1564
+ packageUnits: signals.repoUnits.map((unit) => ({
1565
+ root: unit.root,
1566
+ role: unit.role,
1567
+ sourceRoots: unit.sourceRoots,
1568
+ productionDependencies: Object.keys(unit.productionDeps).sort(),
1569
+ devOnlyDependencies: Object.keys(unit.devDependencies).sort(),
1570
+ })),
1522
1571
  workspaces: signals.workspaces,
1523
1572
  ui: signals.ui,
1524
1573
  apiSurface: signals.apiSurface,
@@ -1615,6 +1664,10 @@ export function formatArchitectureRecommendationHuman(recommendation) {
1615
1664
  lines.push('');
1616
1665
  lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
1617
1666
  lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
1667
+ if (recommendation.requiresConfirmation) {
1668
+ lines.push('⚠ Confirmation required before applying this recommendation.');
1669
+ for (const reason of recommendation.confirmationReasons ?? []) lines.push(` - ${reason}`);
1670
+ }
1618
1671
  if (recommendation.galleryStarter) {
1619
1672
  lines.push(`Gallery starter: ${recommendation.galleryStarter}`);
1620
1673
  }
@@ -1724,6 +1777,8 @@ export function buildAdoptionPlanDocument(recommendation) {
1724
1777
  label: recommendation.label,
1725
1778
  preset: recommendation.preset,
1726
1779
  confidence: recommendation.confidence,
1780
+ requiresConfirmation: recommendation.requiresConfirmation ?? false,
1781
+ confirmationReasons: recommendation.confirmationReasons ?? [],
1727
1782
  phases: recommendation.phases,
1728
1783
  adoptInOrder: recommendation.adoptInOrder,
1729
1784
  matchedSignals: recommendation.matchedSignals ?? [],