arkgate 2.11.0 → 2.13.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 (77) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +70 -41
  3. package/bin/ark-check.mjs +95 -36
  4. package/bin/ark-mcp.mjs +11 -5
  5. package/bin/ark-shared.mjs +88 -56
  6. package/bin/ark.mjs +97 -29
  7. package/bin/lib/agent-gates.mjs +79 -2093
  8. package/bin/lib/architecture-scan.mjs +8 -0
  9. package/bin/lib/ci-and-commands.mjs +392 -0
  10. package/bin/lib/codex-home.mjs +7 -0
  11. package/bin/lib/config-contract.mjs +331 -0
  12. package/bin/lib/deploy-path.mjs +205 -0
  13. package/bin/lib/doctor-plan.mjs +43 -16
  14. package/bin/lib/enforcement-profiles.mjs +97 -0
  15. package/bin/lib/gate-files.mjs +223 -0
  16. package/bin/lib/hook-templates.mjs +99 -0
  17. package/bin/lib/host-support-matrix.mjs +77 -0
  18. package/bin/lib/install-migrate.mjs +473 -0
  19. package/bin/lib/mcp-adoption.mjs +455 -0
  20. package/bin/lib/open-html.mjs +75 -0
  21. package/bin/lib/presets.mjs +6 -2
  22. package/bin/lib/safety-diagnostics.mjs +31 -11
  23. package/bin/lib/skill-install.mjs +323 -0
  24. package/bin/lib/ts-resolve.mjs +2 -1
  25. package/bin/lib/typescript-host.mjs +88 -0
  26. package/bin/lib/weakest-link.mjs +417 -0
  27. package/bin/lib/write-path-capabilities.mjs +182 -0
  28. package/bin/lib/write-path-detect.mjs +101 -0
  29. package/dist/configContract-iBLxx5Tz.d.cts +53 -0
  30. package/dist/configContract-iBLxx5Tz.d.ts +53 -0
  31. package/dist/eslint/index.cjs +375 -13
  32. package/dist/eslint/index.cjs.map +1 -1
  33. package/dist/eslint/index.d.cts +30 -20
  34. package/dist/eslint/index.d.ts +30 -20
  35. package/dist/eslint/index.js +375 -13
  36. package/dist/eslint/index.js.map +1 -1
  37. package/dist/index.cjs +723 -61
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +95 -5
  40. package/dist/index.d.ts +95 -5
  41. package/dist/index.js +716 -61
  42. package/dist/index.js.map +1 -1
  43. package/dist/nestjs/index.cjs +150 -42
  44. package/dist/nestjs/index.cjs.map +1 -1
  45. package/dist/nestjs/index.d.cts +2 -1
  46. package/dist/nestjs/index.d.ts +2 -1
  47. package/dist/nestjs/index.js +150 -42
  48. package/dist/nestjs/index.js.map +1 -1
  49. package/dist/runtime/index.cjs +723 -61
  50. package/dist/runtime/index.cjs.map +1 -1
  51. package/dist/runtime/index.d.cts +3 -2
  52. package/dist/runtime/index.d.ts +3 -2
  53. package/dist/runtime/index.js +716 -61
  54. package/dist/runtime/index.js.map +1 -1
  55. package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
  56. package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
  57. package/docs/agent-guide.md +43 -21
  58. package/docs/ai-gates.md +53 -18
  59. package/docs/configuration.md +97 -0
  60. package/docs/enthusiast/README.md +3 -3
  61. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  62. package/docs/migrate-from-ark-runtime-kernel.md +3 -0
  63. package/docs/package-surface.md +22 -10
  64. package/docs/production-hardening.md +15 -2
  65. package/docs/threat-model.md +65 -0
  66. package/docs/typescript-support.md +3 -3
  67. package/package.json +15 -2
  68. package/schemas/ark.config.schema.json +750 -0
  69. package/server.json +2 -2
  70. package/templates/hooks/pre-commit-ark +37 -0
  71. package/templates/skills/ark-autopilot.md +77 -45
  72. package/templates/skills/ark-coverage.md +2 -2
  73. package/templates/skills/ark-explain.md +2 -1
  74. package/templates/skills/ark-explore.md +135 -34
  75. package/templates/skills/ark-runtime.md +8 -5
  76. package/templates/skills/ark-upgrade.md +36 -16
  77. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
package/bin/ark-check.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  loadTypeScript,
34
34
  detectSkillGaps,
35
35
  detectCodexHomeGap,
36
+ detectActiveAgentHost,
36
37
  missingGates,
37
38
  staleRunnerGateFiles,
38
39
  brokenMcpGateFiles,
@@ -56,6 +57,7 @@ import {
56
57
  reportsDir,
57
58
  readJsonSafe,
58
59
  } from './lib/html-report.mjs';
60
+ import { shouldOpenHtmlReport, openHtmlInBrowser } from './lib/open-html.mjs';
59
61
  import {
60
62
  computeCoverage,
61
63
  runCoverage,
@@ -81,6 +83,7 @@ import {
81
83
  import {
82
84
  ARCHITECTURE_PRESETS,
83
85
  } from './lib/presets.mjs';
86
+ import { loadArkConfigContract, parseArkConfigJson } from './lib/config-contract.mjs';
84
87
 
85
88
  import {
86
89
  collectGovernedFiles,
@@ -91,6 +94,7 @@ import {
91
94
  configWarning,
92
95
  } from './lib/config-warnings.mjs';
93
96
  import { runArchitectureScan } from './lib/architecture-scan.mjs';
97
+ import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
94
98
 
95
99
 
96
100
  function parseArgs(argv) {
@@ -101,9 +105,9 @@ function parseArgs(argv) {
101
105
  printConfig: undefined,
102
106
  tsconfig: undefined,
103
107
  json: false,
104
- strict: false,
105
108
  strictConfig: false,
106
109
  requireGates: false,
110
+ requireWriteHook: undefined,
107
111
  init: false,
108
112
  installAgentGates: false,
109
113
  tools: undefined,
@@ -122,6 +126,8 @@ function parseArgs(argv) {
122
126
  applyPolicyPack: undefined,
123
127
  watch: false,
124
128
  beginner: false,
129
+ openReport: false,
130
+ noOpenReport: false,
125
131
  version: false,
126
132
  help: false,
127
133
  };
@@ -135,13 +141,15 @@ function parseArgs(argv) {
135
141
  for (let i = 2; i < argv.length; i += 1) {
136
142
  const arg = argv[i];
137
143
  if (arg === '--json') args.json = true;
138
- else if (arg === '--strict') {
139
- args.strict = true;
144
+ else if (arg === '--strict' || arg === '--strict-merge') {
140
145
  args.strictConfig = true;
141
146
  args.requireGates = true;
142
147
  }
143
148
  else if (arg === '--strict-config') args.strictConfig = true;
144
149
  else if (arg === '--require-gates') args.requireGates = true;
150
+ else if (arg === '--require-write-hook') {
151
+ args.requireWriteHook = requireValue(arg, i++).trim().toLowerCase();
152
+ }
145
153
  else if (arg === '--init') args.init = true;
146
154
  else if (arg === '--preset') args.preset = requireValue(arg, i++);
147
155
  else if (arg === '--install-agent-gates') args.installAgentGates = true;
@@ -183,6 +191,8 @@ function parseArgs(argv) {
183
191
  }
184
192
  else if (arg === '--reset-origin') args.resetOrigin = true;
185
193
  else if (arg === '--no-archive') args.noArchive = true;
194
+ else if (arg === '--open') args.openReport = true;
195
+ else if (arg === '--no-open') args.noOpenReport = true;
186
196
  else if (arg === '--baseline' || arg === '--update-baseline') {
187
197
  if (arg === '--update-baseline') args.updateBaseline = true;
188
198
  // optional path value: consume the next arg only when it isn't another flag
@@ -214,7 +224,7 @@ function usage() {
214
224
  return [
215
225
  'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
216
226
  ' ark-check --version',
217
- ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict | --strict-config] [--require-gates] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
227
+ ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-merge | --strict | --strict-config] [--require-gates] [--require-write-hook <host>] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
218
228
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
219
229
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
220
230
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
@@ -224,10 +234,11 @@ function usage() {
224
234
  ' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
225
235
  ' ark-check --ratchet-cores when raw graph is green (0 violations; baseline ignored), set optional:false on populated cores only (writes ark.config.json)',
226
236
  ' ark-check --watch re-run the check when governed files change (debounced)',
227
- ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
237
+ ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive] [--open|--no-open]',
228
238
  ' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
239
+ ' Best-effort open in browser (local TTY). No-op if open fails. --no-open / ARK_NO_OPEN_REPORT=1 to skip; --open forces open.',
229
240
  ' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture] [--force]',
230
- ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok] [--skills-only] [--codex-home] [--force]',
241
+ ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok] [--require-write-hook <host>] [--skills-only] [--codex-home] [--force]',
231
242
  ' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
232
243
  ' ark-check --print-config eleven-layer',
233
244
  '',
@@ -267,8 +278,12 @@ function usage() {
267
278
  '',
268
279
  'Config warnings are advisory by default and are included in JSON output.',
269
280
  'Use --strict-config to make config warnings fail the check.',
270
- 'Use --strict for the fail-closed CI profile: --strict-config + --require-gates',
271
- 'plus the security diagnostics surfaced by doctor.',
281
+ 'Use --strict-merge for the fail-closed CI profile: --strict-config + --require-gates',
282
+ 'plus the security diagnostics surfaced by doctor. --strict is a compatibility alias.',
283
+ 'This merge profile never depends on an editor/agent hook.',
284
+ 'Add --require-write-hook claude|grok to validate a hard local write boundary for that',
285
+ 'specific host. Cursor and Codex expose advisory MCP tools plus the shared CI check;',
286
+ 'merge blocking requires repository policy to make that status required.',
272
287
  '',
273
288
  '--require-gates fails the check when AGENTS.md, .mcp.json, or the generated CI',
274
289
  'workflow is missing, so "installed but never configured" is a red CI. Combine it',
@@ -276,7 +291,8 @@ function usage() {
276
291
  '',
277
292
  '--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
278
293
  'project, plus tool-specific templates. Known tools: claude, cursor, codex, grok',
279
- '(full MCP/hook gates) and windsurf, cline, copilot, kiro, roo, continue, gemini',
294
+ '(Claude/Grok hard-write hooks; Cursor/Codex advisory MCP; shared CI check for all) and',
295
+ 'windsurf, cline, copilot, kiro, roo, continue, gemini',
280
296
  '(instruction-tier rule files derived from the same contract).',
281
297
  'It also installs the /ark-* skills shipped in templates/skills/ into each',
282
298
  'detected tool\'s command location (.claude/skills/, .cursor/commands/,',
@@ -290,7 +306,7 @@ function usage() {
290
306
  'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
291
307
  'from their config directories (.claude/, .cursor/, .codex/, .grok/, .windsurf/,',
292
308
  '.clinerules/, .kiro/, .roo/, .continue/, .gemini/; copilot is explicit-only).',
293
- 'claude+cursor+codex are written when nothing is detected.',
309
+ 'claude+cursor+codex+grok are written when nothing is detected.',
294
310
  '',
295
311
  'Generate a starter 11-layer config:',
296
312
  ' ark-check --print-config eleven-layer > ark.config.json',
@@ -305,25 +321,12 @@ function readConfig(root, configPath) {
305
321
  ? configPath
306
322
  : path.join(root, configPath);
307
323
  if (!fs.existsSync(fullPath)) {
308
- return {
309
- include: ['src'],
310
- layers: [],
311
- rules: DEFAULT_RULES,
312
- };
324
+ return loadArkConfigContract(
325
+ { include: ['src'], layers: [], rules: DEFAULT_RULES },
326
+ fullPath
327
+ ).config;
313
328
  }
314
- const raw = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
315
- return {
316
- include: raw.include ?? ['src'],
317
- layers: raw.layers ?? [],
318
- rules: raw.rules ?? DEFAULT_RULES,
319
- ...(raw.exclude ? { exclude: raw.exclude } : {}),
320
- ...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
321
- ...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
322
- ...(raw.dynamicImportAllowlist
323
- ? { dynamicImportAllowlist: raw.dynamicImportAllowlist }
324
- : {}),
325
- ...(raw.safety ? { safety: raw.safety } : {}),
326
- };
329
+ return parseArkConfigJson(fs.readFileSync(fullPath, 'utf8'), fullPath).config;
327
330
  }
328
331
 
329
332
  /**
@@ -976,16 +979,41 @@ async function main() {
976
979
  return;
977
980
  }
978
981
 
979
- if (args.requireGates) {
980
- const missing = missingGates(args.root);
981
- if (args.strict && !detectWritePathCapabilities(args.root).hookPresent) {
982
- missing.push('PreToolUse write hook');
982
+ if (args.requireGates || args.requireWriteHook) {
983
+ let writeRequest = null;
984
+ if (args.requireWriteHook) {
985
+ writeRequest = validateHardWriteRequest({
986
+ root: args.root,
987
+ host: args.requireWriteHook,
988
+ tools: [args.requireWriteHook],
989
+ force: true,
990
+ });
991
+ if (!writeRequest.ok) {
992
+ const payload = {
993
+ ok: false,
994
+ error: 'unsupported-enforcement-profile',
995
+ message: writeRequest.error,
996
+ };
997
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
998
+ else console.error(writeRequest.error);
999
+ process.exitCode = 2;
1000
+ return;
1001
+ }
1002
+ }
1003
+
1004
+ const missing = args.requireGates ? missingGates(args.root) : [];
1005
+ if (
1006
+ writeRequest?.host &&
1007
+ !detectWritePathCapabilities(args.root, writeRequest.host).capabilities['hard-write']
1008
+ ) {
1009
+ missing.push(`${writeRequest.host} hard-write hook`);
983
1010
  }
984
1011
  if (missing.length > 0) {
985
1012
  const payload = {
986
1013
  ok: false,
987
1014
  error: 'missing-gates',
988
1015
  missing,
1016
+ ...(writeRequest?.host ? { writeHost: writeRequest.host } : {}),
989
1017
  };
990
1018
  if (args.json) {
991
1019
  console.log(JSON.stringify(payload, null, 2));
@@ -994,7 +1022,12 @@ async function main() {
994
1022
  for (const relativePath of missing) {
995
1023
  console.error(` - ${relativePath}`);
996
1024
  }
997
- console.error(`\nRun \`${arkCommand(args.root, 'ark', 'init')}\` (or \`ark-check --install-agent-gates\`) to configure enforcement.`);
1025
+ const installArgs = writeRequest?.host
1026
+ ? `--install-agent-gates --tools ${writeRequest.host} --require-write-hook ${writeRequest.host}`
1027
+ : '--install-agent-gates';
1028
+ console.error(
1029
+ `\nRun \`${arkCommand(args.root, 'ark', 'init')}\` (or \`${arkCommand(args.root, 'ark-check', installArgs)}\`) to configure enforcement.`
1030
+ );
998
1031
  }
999
1032
  process.exitCode = 1;
1000
1033
  return;
@@ -1004,7 +1037,12 @@ async function main() {
1004
1037
  // When --require-gates is the only intent (no config/architecture run needed),
1005
1038
  // callers still get a clear signal from the exit code and the human-mode line.
1006
1039
  if (!args.json) {
1007
- console.log('Ark gates present: ' + REQUIRED_GATE_FILES.join(', '));
1040
+ if (args.requireGates) {
1041
+ console.log('Ark gates present (merge profile): ' + REQUIRED_GATE_FILES.join(', '));
1042
+ }
1043
+ if (writeRequest?.host) {
1044
+ console.log(`Ark hard-write hook present for ${writeRequest.host}.`);
1045
+ }
1008
1046
  }
1009
1047
  }
1010
1048
 
@@ -1277,6 +1315,18 @@ async function main() {
1277
1315
  }
1278
1316
  }
1279
1317
  }
1318
+
1319
+ // Best-effort: open the report in the default browser. If it opens, fine;
1320
+ // if not (headless, no GUI, spawn error), do nothing — never fail the check.
1321
+ // Skipped in CI / Vitest / ARK_NO_OPEN_REPORT; --open / --no-open override.
1322
+ if (
1323
+ shouldOpenHtmlReport({
1324
+ force: Boolean(args.openReport),
1325
+ noOpen: Boolean(args.noOpenReport) || Boolean(args.json),
1326
+ })
1327
+ ) {
1328
+ openHtmlInBrowser(reportPath);
1329
+ }
1280
1330
  }
1281
1331
 
1282
1332
  if (args.json) {
@@ -1385,10 +1435,19 @@ async function main() {
1385
1435
  const parts = [];
1386
1436
  if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
1387
1437
  if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} outdated`);
1438
+ // Advisory always; when session host is known and not Codex, say so so
1439
+ // /ark-upgrade does not chase home prompts as Incomplete.
1440
+ const activeHost = detectActiveAgentHost();
1441
+ const deferredNote =
1442
+ activeHost != null && activeHost !== 'codex'
1443
+ ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
1444
+ : ' ';
1388
1445
  console.log(
1389
1446
  color.dim(
1390
- `/ark-* skills in ${codexPromptsDir()} are behind this Ark (${parts.join(', ')}). ` +
1391
- `Codex loads them from there, not the repo. Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`
1447
+ `/ark-* skills in ${codexPromptsDir()} are behind this Ark (${parts.join(', ')}).` +
1448
+ deferredNote +
1449
+ `Codex loads them from $CODEX_HOME/prompts, not the repo. ` +
1450
+ `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`
1392
1451
  )
1393
1452
  );
1394
1453
  }
package/bin/ark-mcp.mjs CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  import { createImportTargetResolver } from './lib/import-resolve.mjs';
59
59
  import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
60
60
  import { composePrepareWrite } from './lib/prepare-write.mjs';
61
+ import { loadArkConfigContract } from './lib/config-contract.mjs';
61
62
 
62
63
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
63
64
 
@@ -120,6 +121,11 @@ function readJson(file, { required } = {}) {
120
121
  }
121
122
  }
122
123
 
124
+ function readArkConfig(file, { required } = {}) {
125
+ const raw = readJson(file, { required });
126
+ return raw === undefined ? undefined : loadArkConfigContract(raw, file).config;
127
+ }
128
+
123
129
  function resolveInRoot(root, maybePath) {
124
130
  if (!maybePath) return undefined;
125
131
  return path.isAbsolute(maybePath) ? maybePath : path.join(root, maybePath);
@@ -460,11 +466,11 @@ async function main() {
460
466
  const ts = await loadOptionalTypeScript();
461
467
 
462
468
  const config =
463
- (configPath ? readJson(configPath, { required: args.configExplicit }) : undefined) ?? {
464
- include: ['src'],
465
- layers: [],
466
- rules: [],
467
- };
469
+ (configPath ? readArkConfig(configPath, { required: args.configExplicit }) : undefined) ??
470
+ loadArkConfigContract(
471
+ { include: ['src'], layers: [], rules: DEFAULT_RULES },
472
+ configPath ?? 'ark.config.json'
473
+ ).config;
468
474
  if (!config.layers || config.layers.length === 0) {
469
475
  process.stderr.write(
470
476
  '[ark-mcp] warning: no layers configured — file→layer inference from config patterns ' +
@@ -1,12 +1,15 @@
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';
4
8
 
5
9
  /**
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.
10
+ * Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
11
+ * matrix comes from the generated Domain config contract above. Prefixes mirror the runtime
12
+ * profile but stay in this standalone CLI module because the CLIs run without a build step.
10
13
  */
11
14
  export const DEFAULT_INTENT_PREFIXES = [
12
15
  { layer: 'DomainModel', prefixes: ['Domain.'] },
@@ -41,35 +44,7 @@ export const DEFAULT_LAYER_DIRECTORIES = {
41
44
  Kernel: ['kernel'],
42
45
  };
43
46
 
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
- );
47
+ export const DEFAULT_RULES = DEFAULT_ARK_CONFIG_RULES;
73
48
 
74
49
  /**
75
50
  * Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
@@ -82,7 +57,7 @@ export function createElevenLayerConfig(options = {}) {
82
57
  const rootDir = options.rootDir ?? 'src';
83
58
  const optional = options.optionalLayers ?? true;
84
59
  const prefix = rootDir === '.' ? '' : `${rootDir}/`;
85
- const config = {
60
+ const config = withArkConfigMetadata({
86
61
  include: options.include ?? [rootDir],
87
62
  layers: DEFAULT_INTENT_PREFIXES.map((entry) => ({
88
63
  name: entry.layer,
@@ -96,7 +71,7 @@ export function createElevenLayerConfig(options = {}) {
96
71
  : {}),
97
72
  })),
98
73
  rules: DEFAULT_RULES,
99
- };
74
+ });
100
75
  // When a project root is known, overlay Nest/Next/express filename conventions so a
101
76
  // flat framework starter is governed on day one (not "0% governed / false green").
102
77
  if (options.root) return applyFrameworkLayoutOverlays(config, options.root);
@@ -134,7 +109,7 @@ export function applyFrameworkLayoutOverlays(config, root) {
134
109
  try {
135
110
  signals = collectRepoShapeSignals(root);
136
111
  } catch {
137
- return config;
112
+ return withArkConfigMetadata(config);
138
113
  }
139
114
 
140
115
  const next = {
@@ -344,7 +319,7 @@ export function applyFrameworkLayoutOverlays(config, root) {
344
319
  : 'library';
345
320
  }
346
321
 
347
- return next;
322
+ return withArkConfigMetadata(next);
348
323
  }
349
324
 
350
325
  /**
@@ -398,40 +373,97 @@ export function resolveOperatingMode({
398
373
  return 'suggest';
399
374
  }
400
375
 
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
+
401
422
  /**
402
423
  * Find uses of forbidden ambient globals in a TypeScript source file.
403
424
  *
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.
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.
412
429
  *
430
+ * Kept in sync with `analyzeForbiddenGlobals` in
431
+ * src/kernel/ai-gate/AICodeGate.ts — the standalone CLIs must not import from dist.
413
432
  * Returns [{ name, node }] where `name` is the matched forbidden entry.
414
433
  */
415
434
  export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
416
435
  const entries = new Set(forbidden ?? []);
417
436
  if (entries.size === 0) return [];
437
+ const checker = singleFileTypeChecker(ts, sourceFile);
418
438
  const uses = [];
419
439
 
420
440
  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 });
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 });
427
459
  }
428
460
  } else if (
429
- (ts.isCallExpression(node) || ts.isNewExpression(node)) &&
430
- node.expression &&
431
- ts.isIdentifier(node.expression) &&
432
- entries.has(node.expression.text)
461
+ ts.isIdentifier(node) &&
462
+ entries.has(node.text) &&
463
+ isRuntimeIdentifierReference(ts, node) &&
464
+ !hasLocalDeclaration(ts, checker, sourceFile, node)
433
465
  ) {
434
- uses.push({ name: node.expression.text, node });
466
+ uses.push({ name: node.text, node });
435
467
  }
436
468
  ts.forEachChild(node, visit);
437
469
  };