arkgate 2.12.0 → 3.0.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +90 -51
  3. package/bin/ark-check.mjs +156 -39
  4. package/bin/ark-mcp.mjs +119 -6
  5. package/bin/ark-shared.mjs +216 -129
  6. package/bin/ark.mjs +134 -34
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +13 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -127
  11. package/bin/lib/ci-and-commands.mjs +40 -3
  12. package/bin/lib/codex-home.mjs +7 -0
  13. package/bin/lib/config-contract.mjs +331 -0
  14. package/bin/lib/config-warnings.mjs +7 -205
  15. package/bin/lib/doctor-plan.mjs +43 -16
  16. package/bin/lib/enforcement-profiles.mjs +97 -0
  17. package/bin/lib/field-install.mjs +67 -10
  18. package/bin/lib/gate-files.mjs +42 -3
  19. package/bin/lib/graph-cycles.mjs +4 -54
  20. package/bin/lib/hook-templates.mjs +33 -1
  21. package/bin/lib/host-support-matrix.mjs +83 -0
  22. package/bin/lib/install-migrate.mjs +99 -30
  23. package/bin/lib/mcp-adoption.mjs +35 -3
  24. package/bin/lib/open-html.mjs +75 -0
  25. package/bin/lib/presets.mjs +45 -4
  26. package/bin/lib/safety-diagnostics.mjs +36 -15
  27. package/bin/lib/scan-files.mjs +12 -1
  28. package/bin/lib/skill-install.mjs +72 -1
  29. package/bin/lib/source-policy.mjs +36 -0
  30. package/bin/lib/start-preview.mjs +271 -0
  31. package/bin/lib/ts-resolve.mjs +13 -3
  32. package/bin/lib/weakest-link.mjs +417 -0
  33. package/bin/lib/write-path-capabilities.mjs +186 -0
  34. package/bin/lib/write-path-detect.mjs +62 -99
  35. package/compat/nestjs.cjs +2 -0
  36. package/compat/nestjs.d.ts +2 -0
  37. package/compat/nestjs.js +1 -0
  38. package/compat/runtime.cjs +2 -0
  39. package/compat/runtime.d.ts +2 -0
  40. package/compat/runtime.js +1 -0
  41. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  42. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  43. package/dist/eslint/index.cjs +500 -61
  44. package/dist/eslint/index.d.cts +36 -20
  45. package/dist/eslint/index.d.ts +36 -20
  46. package/dist/eslint/index.js +500 -61
  47. package/dist/index.cjs +1349 -2741
  48. package/dist/index.d.cts +449 -483
  49. package/dist/index.d.ts +449 -483
  50. package/dist/index.js +1325 -2687
  51. package/docs/agent-guide.md +58 -34
  52. package/docs/ai-gates.md +79 -21
  53. package/docs/configuration.md +97 -0
  54. package/docs/enthusiast/README.md +3 -3
  55. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  56. package/docs/migrate-from-ark-runtime-kernel.md +5 -3
  57. package/docs/package-surface.md +19 -19
  58. package/docs/production-hardening.md +31 -5
  59. package/docs/threat-model.md +65 -0
  60. package/docs/typescript-support.md +30 -3
  61. package/package.json +46 -11
  62. package/schemas/ark.analysis-result.schema.json +91 -0
  63. package/schemas/ark.config.schema.json +750 -0
  64. package/server.json +2 -2
  65. package/templates/hooks/pre-commit-ark +37 -0
  66. package/templates/skills/ark-architect.md +3 -2
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
  71. package/dist/eslint/index.cjs.map +0 -1
  72. package/dist/eslint/index.js.map +0 -1
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/nestjs/index.cjs +0 -2498
  76. package/dist/nestjs/index.cjs.map +0 -1
  77. package/dist/nestjs/index.d.cts +0 -22
  78. package/dist/nestjs/index.d.ts +0 -22
  79. package/dist/nestjs/index.js +0 -2474
  80. package/dist/nestjs/index.js.map +0 -1
  81. package/dist/runtime/index.cjs +0 -3352
  82. package/dist/runtime/index.cjs.map +0 -1
  83. package/dist/runtime/index.d.cts +0 -2
  84. package/dist/runtime/index.d.ts +0 -2
  85. package/dist/runtime/index.js +0 -3270
  86. package/dist/runtime/index.js.map +0 -1
  87. package/dist/types-BZ17b9i5.d.cts +0 -1068
  88. package/dist/types-BZ17b9i5.d.ts +0 -1068
@@ -1,12 +1,17 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import {
5
+ DEFAULT_ARK_CONFIG_RULES,
6
+ withArkConfigMetadata,
7
+ } from './lib/config-contract.mjs';
8
+ import { collectForbiddenCapabilityUses } from './lib/analysis-engine.mjs';
9
+ import { looksLikeArkIntent } from './lib/source-policy.mjs';
4
10
 
5
11
  /**
6
- * Default layer rule matrix + intent-prefix map, shared by both CLIs and by the ark-mcp
7
- * write-path gate so they enforce identically. These mirror the elevenLayerProfile in
8
- * src/kernel/layers/ArchitectureProfile.ts; kept here (not imported from dist) because the
9
- * CLIs run standalone with only `typescript` present, no build step.
12
+ * Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
13
+ * matrix comes from the generated Domain config contract above. Prefixes mirror the runtime
14
+ * profile but stay in this standalone CLI module because the CLIs run without a build step.
10
15
  */
11
16
  export const DEFAULT_INTENT_PREFIXES = [
12
17
  { layer: 'DomainModel', prefixes: ['Domain.'] },
@@ -41,35 +46,7 @@ export const DEFAULT_LAYER_DIRECTORIES = {
41
46
  Kernel: ['kernel'],
42
47
  };
43
48
 
44
- const DEFAULT_ALLOWED_FLOWS = [
45
- { from: 'PresentationAdapters', to: 'ApplicationOrchestration' },
46
- { from: 'ApplicationOrchestration', to: 'DomainModel' },
47
- { from: 'WorkflowSagaEngine', to: 'ApplicationOrchestration' },
48
- { from: 'WorkflowSagaEngine', to: 'DomainModel' },
49
- { from: 'BackgroundJobsScheduling', to: 'ApplicationOrchestration' },
50
- ];
51
-
52
- function flowKey(from, to) {
53
- return `${from}->${to}`;
54
- }
55
-
56
- function createStrictDenyRules(layers, allowedFlows) {
57
- const allowed = new Set(allowedFlows.map((flow) => flowKey(flow.from, flow.to)));
58
- const rules = [];
59
- for (const from of layers) {
60
- for (const to of layers) {
61
- if (from.layer === to.layer) continue;
62
- if (allowed.has(flowKey(from.layer, to.layer))) continue;
63
- rules.push({ from: from.layer, to: to.layer, allowed: false });
64
- }
65
- }
66
- return rules;
67
- }
68
-
69
- export const DEFAULT_RULES = createStrictDenyRules(
70
- DEFAULT_INTENT_PREFIXES,
71
- DEFAULT_ALLOWED_FLOWS
72
- );
49
+ export const DEFAULT_RULES = DEFAULT_ARK_CONFIG_RULES;
73
50
 
74
51
  /**
75
52
  * Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
@@ -82,7 +59,7 @@ export function createElevenLayerConfig(options = {}) {
82
59
  const rootDir = options.rootDir ?? 'src';
83
60
  const optional = options.optionalLayers ?? true;
84
61
  const prefix = rootDir === '.' ? '' : `${rootDir}/`;
85
- const config = {
62
+ const config = withArkConfigMetadata({
86
63
  include: options.include ?? [rootDir],
87
64
  layers: DEFAULT_INTENT_PREFIXES.map((entry) => ({
88
65
  name: entry.layer,
@@ -96,7 +73,7 @@ export function createElevenLayerConfig(options = {}) {
96
73
  : {}),
97
74
  })),
98
75
  rules: DEFAULT_RULES,
99
- };
76
+ });
100
77
  // When a project root is known, overlay Nest/Next/express filename conventions so a
101
78
  // flat framework starter is governed on day one (not "0% governed / false green").
102
79
  if (options.root) return applyFrameworkLayoutOverlays(config, options.root);
@@ -134,7 +111,7 @@ export function applyFrameworkLayoutOverlays(config, root) {
134
111
  try {
135
112
  signals = collectRepoShapeSignals(root);
136
113
  } catch {
137
- return config;
114
+ return withArkConfigMetadata(config);
138
115
  }
139
116
 
140
117
  const next = {
@@ -155,6 +132,40 @@ export function applyFrameworkLayoutOverlays(config, root) {
155
132
  }
156
133
  };
157
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
+
158
169
  if (signals.nestFramework) {
159
170
  ensureInclude('src');
160
171
  // Nest flat + modular conventions (controllers/services next to modules).
@@ -337,14 +348,33 @@ export function applyFrameworkLayoutOverlays(config, root) {
337
348
  if (signals.libraryOnly && !signals.nestFramework && !signals.nextFramework) {
338
349
  ensureInclude('src');
339
350
  ensureInclude('lib');
340
- 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
+ ]);
341
371
  // Prefer domain over application for a single-folder lib: only domain if no split.
342
372
  next.frameworkOverlay = next.frameworkOverlay
343
373
  ? `${next.frameworkOverlay}+library`
344
374
  : 'library';
345
375
  }
346
376
 
347
- return next;
377
+ return withArkConfigMetadata(next);
348
378
  }
349
379
 
350
380
  /**
@@ -398,45 +428,8 @@ export function resolveOperatingMode({
398
428
  return 'suggest';
399
429
  }
400
430
 
401
- /**
402
- * Find uses of forbidden ambient globals in a TypeScript source file.
403
- *
404
- * Detection is deliberately positional, not scope-aware (kept in sync with
405
- * `collectForbiddenGlobalUses` in src/kernel/ai-gate/AICodeGate.ts — the CLIs must not
406
- * import from dist):
407
- * - a dotted entry ("Date.now") flags `Date.now` property accesses
408
- * - a bare entry ("console", "fetch") flags property accesses on it (`console.log`),
409
- * direct calls (`fetch(...)`), and constructions (`new WebSocket(...)`)
410
- * Bare identifier mentions in other positions (types, shadowed locals, import names) are
411
- * NOT flagged, trading a little recall for near-zero false positives without a type checker.
412
- *
413
- * Returns [{ name, node }] where `name` is the matched forbidden entry.
414
- */
415
431
  export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
416
- const entries = new Set(forbidden ?? []);
417
- if (entries.size === 0) return [];
418
- const uses = [];
419
-
420
- const visit = (node) => {
421
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
422
- const dotted = `${node.expression.text}.${node.name.text}`;
423
- if (entries.has(dotted)) {
424
- uses.push({ name: dotted, node });
425
- } else if (entries.has(node.expression.text)) {
426
- uses.push({ name: node.expression.text, node });
427
- }
428
- } else if (
429
- (ts.isCallExpression(node) || ts.isNewExpression(node)) &&
430
- node.expression &&
431
- ts.isIdentifier(node.expression) &&
432
- entries.has(node.expression.text)
433
- ) {
434
- uses.push({ name: node.expression.text, node });
435
- }
436
- ts.forEachChild(node, visit);
437
- };
438
- visit(sourceFile);
439
- return uses;
432
+ return collectForbiddenCapabilityUses(ts, sourceFile, forbidden ?? []);
440
433
  }
441
434
 
442
435
  // Layer glob matching — generated from canonical src/domain/layerMatch.ts (see generate:layer-match).
@@ -479,17 +472,8 @@ export function resolveIntentLayer(intent, layers) {
479
472
  return sorted.find((layer) => layer.prefixes.some((prefix) => intent.startsWith(prefix)))?.name;
480
473
  }
481
474
 
482
- /**
483
- * Intent-name recognizer. Kept deliberately in sync with `looksLikeIntentName` in
484
- * src/kernel/ai-gate/AICodeGate.ts: the two live in separate layers on purpose — the
485
- * CLIs run standalone (with only `typescript` present, no build), so they must not
486
- * import from the compiled library. Update both if the layer prefixes change.
487
- */
488
- const INTENT_NAME =
489
- /^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/;
490
-
491
475
  export function looksLikeIntent(value) {
492
- return INTENT_NAME.test(value);
476
+ return looksLikeArkIntent(value);
493
477
  }
494
478
 
495
479
  /**
@@ -938,6 +922,85 @@ function isSourceFile(name) {
938
922
  return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
939
923
  }
940
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
+
941
1004
  function walkSourceFiles(dir, files = [], depth = 0) {
942
1005
  if (depth > 12) return files;
943
1006
  const stat = fs.statSync(dir, { throwIfNoEntry: false });
@@ -1016,36 +1079,9 @@ function countTsxFiles(files) {
1016
1079
  */
1017
1080
  export function collectAggregatedDeps(root) {
1018
1081
  const deps = {};
1019
- const mergePkg = (pkg) => {
1020
- if (!pkg || typeof pkg !== 'object') return;
1021
- for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
1022
- const block = pkg[key];
1023
- if (block && typeof block === 'object') Object.assign(deps, block);
1024
- }
1025
- };
1026
- mergePkg(readPackageJson(root));
1027
- let entries = [];
1028
- try {
1029
- entries = fs.readdirSync(root, { withFileTypes: true });
1030
- } catch {
1031
- return deps;
1032
- }
1033
- for (const entry of entries) {
1034
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
1035
- const childRoot = path.join(root, entry.name);
1036
- mergePkg(readPackageJson(childRoot));
1037
- // One level under conventional multi-package roots
1038
- if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
1039
- try {
1040
- for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
1041
- if (sub.isDirectory() && !sub.name.startsWith('.')) {
1042
- mergePkg(readPackageJson(path.join(childRoot, sub.name)));
1043
- }
1044
- }
1045
- } catch {
1046
- /* ignore */
1047
- }
1048
- }
1082
+ for (const unit of discoverRepoUnits(root)) {
1083
+ if (['docs', 'example', 'test'].includes(unit.role)) continue;
1084
+ Object.assign(deps, unit.productionDeps);
1049
1085
  }
1050
1086
  return deps;
1051
1087
  }
@@ -1056,27 +1092,32 @@ export function collectAggregatedDeps(root) {
1056
1092
  */
1057
1093
  export function collectRepoShapeSignals(root) {
1058
1094
  const pkg = readPackageJson(root);
1095
+ const repoUnits = discoverRepoUnits(root);
1059
1096
  const workspaceDirs = detectWorkspaces(root);
1060
1097
  const workspaces = workspaceDirs.length > 0;
1061
1098
  // Include frontend/web/client — common Next monorepo app folders.
1062
- const candidateScanDirs = [
1063
- 'src',
1064
- 'lib',
1065
- 'api',
1066
- 'packages',
1067
- 'apps',
1068
- 'frontend',
1069
- 'web',
1070
- 'client',
1071
- 'app',
1072
- ...workspaceDirs,
1073
- ];
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
+ ));
1074
1104
  const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
1075
1105
  fs.existsSync(path.join(root, d))
1076
1106
  );
1077
1107
  const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
1078
- const sourceFiles = scanRoots.flatMap((dir) => walkSourceFiles(dir));
1108
+ const sourceFiles = [...new Set(scanRoots.flatMap((dir) => walkSourceFiles(dir)))];
1079
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);
1080
1121
  const tinyTree = sourceFileCount < 3;
1081
1122
 
1082
1123
  // Nested package.json deps (not root-only) — critical for monorepo Next under frontend/
@@ -1214,6 +1255,8 @@ export function collectRepoShapeSignals(root) {
1214
1255
  }
1215
1256
 
1216
1257
  return {
1258
+ repoUnits,
1259
+ discoveredRoots: srcDirs,
1217
1260
  workspaces,
1218
1261
  workspaceDirs,
1219
1262
  ui,
@@ -1231,6 +1274,7 @@ export function collectRepoShapeSignals(root) {
1231
1274
  libraryOnly,
1232
1275
  tinyTree,
1233
1276
  sourceFileCount,
1277
+ projectedGovernedCoverage,
1234
1278
  domain,
1235
1279
  application,
1236
1280
  domainHeavy,
@@ -1317,6 +1361,16 @@ export function whyFromMatchedSignals(signals, matched) {
1317
1361
  return why;
1318
1362
  }
1319
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
+
1320
1374
  export function defaultPlaybookPath() {
1321
1375
  return path.join(
1322
1376
  path.dirname(fileURLToPath(import.meta.url)),
@@ -1401,6 +1455,11 @@ export function scoreArchetypes(signals, playbook) {
1401
1455
  label: fallback.label,
1402
1456
  preset: fallback.preset,
1403
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
+ ],
1404
1463
  phases: fallback.phases,
1405
1464
  analogy: fallback.analogy,
1406
1465
  antiPatterns: fallback.antiPatterns,
@@ -1422,6 +1481,10 @@ export function scoreArchetypes(signals, playbook) {
1422
1481
  if (thinTs) {
1423
1482
  confidence = Math.min(confidence, 0.28);
1424
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);
1425
1488
 
1426
1489
  return {
1427
1490
  ranked: scored,
@@ -1436,6 +1499,12 @@ export function scoreArchetypes(signals, playbook) {
1436
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.',
1437
1500
  }
1438
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
+ ],
1439
1508
  phases: top.phases,
1440
1509
  analogy: top.analogy,
1441
1510
  antiPatterns: top.antiPatterns,
@@ -1473,6 +1542,8 @@ export function buildArchitectureRecommendation(root, options = {}) {
1473
1542
  label: result.label,
1474
1543
  preset: result.preset,
1475
1544
  confidence: result.confidence,
1545
+ requiresConfirmation: result.requiresConfirmation ?? false,
1546
+ confirmationReasons: result.confirmationReasons ?? [],
1476
1547
  ...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
1477
1548
  phases: result.phases,
1478
1549
  adoptInOrder,
@@ -1480,6 +1551,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
1480
1551
  antiPatterns: result.antiPatterns,
1481
1552
  books: result.books,
1482
1553
  why: whyFromMatchedSignals(signals, result.matched),
1554
+ evidence: evidenceFromMatchedSignals(signals, result.matched),
1483
1555
  matchedSignals: result.matched,
1484
1556
  runnerUp: result.runnerUp,
1485
1557
  toolHints: signals.toolHints,
@@ -1487,6 +1559,15 @@ export function buildArchitectureRecommendation(root, options = {}) {
1487
1559
  policyPack: policyPackId,
1488
1560
  signals: {
1489
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
+ })),
1490
1571
  workspaces: signals.workspaces,
1491
1572
  ui: signals.ui,
1492
1573
  apiSurface: signals.apiSurface,
@@ -1583,6 +1664,10 @@ export function formatArchitectureRecommendationHuman(recommendation) {
1583
1664
  lines.push('');
1584
1665
  lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
1585
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
+ }
1586
1671
  if (recommendation.galleryStarter) {
1587
1672
  lines.push(`Gallery starter: ${recommendation.galleryStarter}`);
1588
1673
  }
@@ -1692,6 +1777,8 @@ export function buildAdoptionPlanDocument(recommendation) {
1692
1777
  label: recommendation.label,
1693
1778
  preset: recommendation.preset,
1694
1779
  confidence: recommendation.confidence,
1780
+ requiresConfirmation: recommendation.requiresConfirmation ?? false,
1781
+ confirmationReasons: recommendation.confirmationReasons ?? [],
1695
1782
  phases: recommendation.phases,
1696
1783
  adoptInOrder: recommendation.adoptInOrder,
1697
1784
  matchedSignals: recommendation.matchedSignals ?? [],