ark-runtime-kernel 1.3.0 → 1.5.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/README.md CHANGED
@@ -3,7 +3,9 @@
3
3
  # 🏛️ Ark — Architectural Runtime Kernel
4
4
 
5
5
  **Stop AI agents (and humans) from quietly breaking your architecture.**<br/>
6
- One machine-readable contract — enforced at write time, merge time, and (optionally) runtime.
6
+ One machine-readable contract — enforced at write time, merge time, and (optionally) runtime.<br/>
7
+ Ships a complete 11-layer architecture you can adopt one layer at a time.
8
+ Gates **Claude Code, Cursor, and Codex** natively — plus rule files for Windsurf, Cline, Copilot, Kiro, and Gemini CLI.
7
9
 
8
10
  [![CI](https://github.com/pedroknigge/ark-runtime-kernel/actions/workflows/ci.yml/badge.svg)](https://github.com/pedroknigge/ark-runtime-kernel/actions/workflows/ci.yml)
9
11
  [![npm](https://img.shields.io/npm/v/ark-runtime-kernel?color=cb3837&label=npm)](https://www.npmjs.com/package/ark-runtime-kernel)
@@ -12,7 +14,7 @@ One machine-readable contract — enforced at write time, merge time, and (optio
12
14
  ![TypeScript](https://img.shields.io/badge/TypeScript-first-3178c6?logo=typescript&logoColor=white)
13
15
  ![Zero deps](https://img.shields.io/badge/dependencies-0-success)
14
16
 
15
- [2-Minute Setup](#2-minute-setup) · [Why Ark](#why-ark-and-not-just-a-linter) · [AI Write Gate](#the-ai-write-gate) · [CI Gate](#ark-check--the-ci-gate) · [Runtime Kernel](#the-runtime-kernel-opt-in) · [Docs](#documentation)
17
+ [2-Minute Setup](#2-minute-setup) · [Why Ark](#why-ark-and-not-just-a-linter) · [11 Layers](#batteries-included-the-11-layer-profile-all-optional) · [AI Write Gate](#the-ai-write-gate) · [CI Gate](#ark-check--the-ci-gate) · [Runtime Kernel](#the-runtime-kernel-opt-in) · [Docs](#documentation)
16
18
 
17
19
  </div>
18
20
 
@@ -124,6 +126,8 @@ If you only need import-boundary linting in CI, [dependency-cruiser](https://git
124
126
  | Cross-layer import checks in CI | ✅ (TS resolver) | ✅ | ✅ | ✅ |
125
127
  | Blocks AI agents **before** code lands (MCP + hook) | ✅ | ❌ | ❌ | ❌ |
126
128
  | Machine-readable contract for agents (`ark://manifest`) | ✅ | ❌ | ❌ | ❌ |
129
+ | Injects the contract into agent context at session start | ✅ | ❌ | ❌ | ❌ |
130
+ | Forbidden ambient globals per layer (`Date.now` in domain, ...) | ✅ | ❌ | ➖ (generic ESLint) | ❌ |
127
131
  | Event/intent governance (who may publish what) | ✅ | ❌ | ❌ | ❌ |
128
132
  | Baseline ratchet for existing codebases | ✅ | ❌ | ➖ (via ESLint) | ❌ |
129
133
  | Optional runtime enforcement | ✅ | ❌ | ❌ | ❌ |
@@ -133,19 +137,57 @@ If you only need import-boundary linting in CI, [dependency-cruiser](https://git
133
137
 
134
138
  | Gate | Tool | When it runs | What it enforces |
135
139
  |--------------|---------------|-------------------------------|-----------------------------------------------|
136
- | **Write** | `ark-mcp` | Agent PreToolUse (Write/Edit) | Layer rules, unknown intents, forbidden patterns |
137
- | **Merge** | `ark-check` | CI (GitHub Actions etc.) | Cross-layer imports + intent references (real TS resolver) |
140
+ | **Write** | `ark-mcp` | Agent PreToolUse (Write/Edit) | Layer rules, unknown intents, forbidden patterns + globals |
141
+ | **Merge** | `ark-check` | CI (GitHub Actions etc.) | Cross-layer imports + intent references (real TS resolver) + forbidden globals |
138
142
  | **Runtime** | `createArkKernel()` | Running process (opt-in) | Intent registry, event contracts, observed layer flow, policies |
139
143
 
144
+ ## Batteries included: the 11-layer profile (all optional)
145
+
146
+ You don't have to design a layer model before adopting Ark — it ships a complete,
147
+ production-shaped division for Hexagonal + Event-Driven + DDD systems. Every layer is
148
+ **optional by design**: on a fresh project the strict check passes immediately, and each
149
+ layer starts being enforced the moment its directory gains source files. Adopt two
150
+ layers or all eleven; `ark.config.json` is always authoritative and you can rename,
151
+ remove, or re-map any of it.
152
+
153
+ | Layer | Conventional directories | Intent prefixes |
154
+ |-------|--------------------------|-----------------|
155
+ | DomainModel | `domain/` | `Domain.` |
156
+ | ApplicationOrchestration | `application/`, `app/` | `Application.` |
157
+ | PersistenceAdapters | `adapters/persistence/`, `repositories/`, ... | `Adapter.Persistence.`, `Adapter.Repository.` |
158
+ | IntegrationAdapters | `adapters/integration/`, `integrations/`, ... | `Adapter.Integration.`, `Adapter.External.` |
159
+ | WorkflowSagaEngine | `workflows/`, `sagas/` | `Workflow.` |
160
+ | BackgroundJobsScheduling | `jobs/`, `schedules/` | `Job.` |
161
+ | PresentationAdapters | `presentation/`, `adapters/api/`, ... | `Presentation.`, `Adapter.Api.`, ... |
162
+ | ReportingReadModels | `reporting/`, `read-models/`, `projections/` | `Reporting.` |
163
+ | ExtensibilityMetadata | `metadata/`, `extensions/` | `Metadata.` |
164
+ | SecurityAuditObservability | `security/`, `audit/`, `observability/` | `Security.`, `Audit.`, `Observability.` |
165
+ | Kernel | `kernel/` | `Kernel.` |
166
+
167
+ The default rule matrix is strict-deny: only the classic flows are open
168
+ (Presentation→Application, Application→Domain, Workflow→Application/Domain,
169
+ Jobs→Application) and everything else is a violation until you allow it explicitly.
170
+ The profile isn't just for the linter — agents get it too: `ark://manifest` lists the
171
+ layers your project hasn't adopted yet as `suggestedLayers`, so when an agent needs to
172
+ create its first saga or background job, it puts it in the conventional place and adds
173
+ the layer to the config instead of inventing an ungoverned location.
174
+
175
+ ```bash
176
+ npx ark-check --print-config eleven-layer > ark.config.json # the full profile, ready to edit
177
+ ```
178
+
140
179
  ## The AI Write Gate
141
180
 
142
181
  `ark-mcp` is a zero-dependency MCP server + one-shot hook:
143
182
 
144
183
  - **`ark-mcp --hook`** — PreToolUse gate: computes the **post-edit** file content, validates it against your layers, exits 2 with the violations when the write must be blocked. The agent self-corrects.
184
+ - **`ark-mcp --session-context`** — SessionStart injection: prints a compact contract summary (layers, forbidden globals, baseline state) into the agent's context, so it knows the architecture from the first token instead of learning by rejection. Silent no-op outside Ark projects, so it can't leak into other repos.
145
185
  - **`validate_code` tool** — on-demand validation of a snippet, for runtimes without hooks.
146
- - **`ark://manifest` resource** — the architecture as JSON, so agents read the rules *before* generating code instead of learning by rejection.
186
+ - **`ark://manifest` resource** — the architecture as JSON, so agents read the rules *before* generating code.
147
187
 
148
- Copy-paste setups for **Claude Code, Cursor, and OpenAI Codex**: [docs/ai-gates.md](docs/ai-gates.md).
188
+ Copy-paste setups for **Claude Code, Cursor, and OpenAI Codex**, plus instruction-tier
189
+ rule files for **Windsurf, Cline, GitHub Copilot, and Kiro** (Gemini CLI reads the
190
+ generated `AGENTS.md` directly): [docs/ai-gates.md](docs/ai-gates.md).
149
191
 
150
192
  ## `ark-check` — The CI Gate
151
193
 
@@ -161,6 +203,17 @@ npx ark-check --baseline # ratchet mode
161
203
  - String intent references across forbidden layers
162
204
  - Raw `publish()` calls that bypass registered intent creators
163
205
  - Missing / mismatched publish `source` metadata
206
+ - Forbidden ambient globals per layer (`fetch`, `Date.now`, `Math.random`, ...) — see below
207
+
208
+ **Fast on repeat runs, monorepo-ready:**
209
+
210
+ - Per-file scan cache in `node_modules/.cache/ark-check.json` (keyed by mtime+size and
211
+ the config/manifest contents). Unchanged files skip the TypeScript parse; import edges
212
+ are always re-resolved against the live filesystem, so the cache can never hide a new
213
+ violation. Disable with `--no-cache`.
214
+ - Path aliases resolve against the **nearest** `tsconfig.json` above each source file
215
+ (like `tsc`), so a monorepo with per-package alias maps runs under a single `--root`.
216
+ Pass `--tsconfig <path>` to force one config for every file.
164
217
 
165
218
  Violations come with the layer edge, the resolved target, and a fix hint:
166
219
 
@@ -171,6 +224,35 @@ Violations come with the layer edge, the resolved target, and a fix hint:
171
224
  fix: Depend on a port/interface owned by an inner layer instead, or move this code.
172
225
  ```
173
226
 
227
+ ### Domain purity: `forbiddenGlobals`
228
+
229
+ Import rules can't catch code that reaches for an ambient global — an agent can call
230
+ `fetch()` or `Date.now()` in your domain layer without importing anything. Declare the
231
+ globals a layer must not touch and both the write gate and CI enforce it:
232
+
233
+ ```jsonc
234
+ // ark.config.json
235
+ {
236
+ "name": "DomainModel",
237
+ "patterns": ["src/domain/**"],
238
+ "intentPrefixes": ["Domain."],
239
+ "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"]
240
+ }
241
+ ```
242
+
243
+ ```
244
+ ✖ FORBIDDEN_GLOBAL src/domain/order.ts:12
245
+ DomainModel must not use the ambient global "Date.now".
246
+ fix: Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort).
247
+ ```
248
+
249
+ Entries are either dotted (`"Date.now"` flags exactly that property access) or bare
250
+ (`"console"` flags `console.*`, `fetch(...)`, `new WebSocket(...)`). Detection is
251
+ positional, not scope-aware: mentions in types or import names are never flagged.
252
+ `npx ark init` seeds the domain layer with `["fetch", "process", "Date.now", "Math.random"]`
253
+ (a pure domain does no I/O and is deterministic); add `"console"` or any other global per
254
+ project. Violations participate in the `--baseline` ratchet like every other rule.
255
+
174
256
  ### GitHub Action
175
257
 
176
258
  ```yaml
@@ -189,7 +271,15 @@ import ark from 'ark-runtime-kernel/eslint';
189
271
  export default [ark.configs.recommended];
190
272
  ```
191
273
 
192
- Rules: `ark/no-domain-infra-imports`, `ark/no-raw-event-publish`, `ark/require-publish-source`.
274
+ Rules: `ark/no-domain-infra-imports`, `ark/no-raw-event-publish`, `ark/require-publish-source`,
275
+ `ark/no-forbidden-globals` (not in `recommended` — scope it to your layer directories):
276
+
277
+ ```js
278
+ {
279
+ files: ['src/domain/**'],
280
+ rules: { 'ark/no-forbidden-globals': ['error', { globals: ['fetch', 'process', 'Date.now', 'Math.random'] }] },
281
+ }
282
+ ```
193
283
 
194
284
  ## The Runtime Kernel (opt-in)
195
285
 
package/bin/ark-check.mjs CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import {
6
+ DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
5
7
  DEFAULT_INTENT_PREFIXES,
6
8
  DEFAULT_LAYER_DIRECTORIES,
7
9
  DEFAULT_RULES,
10
+ collectForbiddenGlobalUses,
8
11
  createElevenLayerConfig,
9
12
  globToRegExp,
10
13
  layerForFile,
@@ -28,6 +31,7 @@ function parseArgs(argv) {
28
31
  force: false,
29
32
  baseline: undefined,
30
33
  updateBaseline: false,
34
+ noCache: false,
31
35
  };
32
36
  for (let i = 2; i < argv.length; i += 1) {
33
37
  const arg = argv[i];
@@ -51,6 +55,7 @@ function parseArgs(argv) {
51
55
  }
52
56
  }
53
57
  else if (arg === '--force') args.force = true;
58
+ else if (arg === '--no-cache') args.noCache = true;
54
59
  else if (arg === '--baseline' || arg === '--update-baseline') {
55
60
  if (arg === '--update-baseline') args.updateBaseline = true;
56
61
  // optional path value: consume the next arg only when it isn't another flag
@@ -69,7 +74,7 @@ function parseArgs(argv) {
69
74
 
70
75
  function usage() {
71
76
  return [
72
- 'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]]',
77
+ 'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]] [--no-cache]',
73
78
  ' ark-check --init [--force]',
74
79
  ' ark-check --install-agent-gates [--tools claude,cursor,codex] [--force]',
75
80
  ' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
@@ -89,14 +94,21 @@ function usage() {
89
94
  '',
90
95
  'Resolves relative, tsconfig path-alias, and package imports via the TypeScript',
91
96
  'module resolver, then checks each resolved cross-layer import against the rules.',
92
- 'If no tsconfig is found, path aliases are unavailable but relative/package imports',
93
- 'still resolve.',
97
+ 'Path aliases resolve against the NEAREST tsconfig.json above each source file, so',
98
+ 'monorepo packages with per-package configs work under a single --root. Pass',
99
+ '--tsconfig to force one config for every file. If no tsconfig is found, path',
100
+ 'aliases are unavailable but relative/package imports still resolve.',
101
+ '',
102
+ 'Parsed files are cached in node_modules/.cache/ark-check.json (keyed by mtime+size',
103
+ 'and the config/manifest contents); import edges are always re-resolved against the',
104
+ 'live filesystem, so the cache can never hide a new violation. --no-cache disables it.',
94
105
  '',
95
106
  'Config shape:',
96
107
  '{',
97
108
  ' "include": ["src"],',
98
109
  ' "layers": [',
99
- ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."] }',
110
+ ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
111
+ ' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
100
112
  ' ],',
101
113
  ' "rules": [{ "from": "DomainModel", "to": "PersistenceAdapters", "allowed": false }]',
102
114
  '}',
@@ -109,9 +121,13 @@ function usage() {
109
121
  'with --strict-config to enforce gate presence and architecture in one run.',
110
122
  '',
111
123
  '--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
112
- 'project, plus tool-specific templates. Pass --tools claude,cursor,codex to pick',
113
- 'which tool configs to write; otherwise they are auto-detected from .claude/, .cursor/,',
114
- 'and .codex/ (all are written when nothing is detected).',
124
+ 'project, plus tool-specific templates. Known tools: claude, cursor, codex (full',
125
+ 'MCP/hook gates) and windsurf, cline, copilot, kiro (instruction-tier rule files',
126
+ 'derived from the same contract; Gemini CLI needs no template — it reads AGENTS.md).',
127
+ 'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
128
+ 'from their config directories (.claude/, .cursor/, .codex/, .windsurf/, .clinerules/,',
129
+ '.kiro/; copilot is explicit-only). claude+cursor+codex are written when nothing is',
130
+ 'detected.',
115
131
  '',
116
132
  'Generate a starter 11-layer config:',
117
133
  ' ark-check --print-config eleven-layer > ark.config.json',
@@ -192,6 +208,9 @@ function detectConfig(root) {
192
208
  name: entry.layer,
193
209
  patterns: directories.map((directory) => `${normalize(path.join(srcDir, directory))}/**`),
194
210
  intentPrefixes: entry.prefixes,
211
+ ...(entry.layer === 'DomainModel'
212
+ ? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
213
+ : {}),
195
214
  });
196
215
  }
197
216
 
@@ -415,6 +434,25 @@ args = ["ark-mcp", "--root", ".", "--config", "ark.config.json"]
415
434
  `;
416
435
  }
417
436
 
437
+ /**
438
+ * Compact always-on rule for instruction-tier hosts (Windsurf, Cline, GitHub Copilot,
439
+ * Kiro, ...): agents that read a project rule file but have no MCP tools or hooks.
440
+ * Derived from the same AGENT_CONTRACT as AGENTS.md and the Cursor rule so the steps
441
+ * can never drift; points at AGENTS.md for the full placement table.
442
+ */
443
+ function instructionRule() {
444
+ const steps = AGENT_CONTRACT.steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
445
+ return `# Ark architecture contract
446
+
447
+ This project's architecture is governed by Ark (\`ark.config.json\` is authoritative).
448
+ Before writing or editing TypeScript or JavaScript source files:
449
+
450
+ ${steps}
451
+
452
+ See \`AGENTS.md\` for the full contract and the layer placement table.
453
+ `;
454
+ }
455
+
418
456
  function cursorRule() {
419
457
  return `---
420
458
  description: Ark architecture contract
@@ -460,6 +498,20 @@ ${setupSteps ? `${setupSteps}\n` : ''} - run: ${pm.install}
460
498
  function claudeSettings() {
461
499
  return `${JSON.stringify({
462
500
  hooks: {
501
+ // Inject the contract at session start so the agent knows the architecture from
502
+ // the first token. Project-scoped by design; --session-context is also a silent
503
+ // no-op when no ark.config.json exists, so it can never leak into other projects.
504
+ SessionStart: [
505
+ {
506
+ hooks: [
507
+ {
508
+ type: 'command',
509
+ command:
510
+ 'npx ark-mcp --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json',
511
+ },
512
+ ],
513
+ },
514
+ ],
463
515
  PreToolUse: [
464
516
  {
465
517
  matcher: 'Write|Edit|MultiEdit',
@@ -484,10 +536,17 @@ function resolveTools(args) {
484
536
  const detected = new Set();
485
537
  if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
486
538
  if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
487
- if (fs.existsSync(path.join(root, '.codex'))) {
488
- detected.add('codex');
539
+ if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
540
+ if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
541
+ // .clinerules can also be a single FILE (older Cline convention); only a directory
542
+ // can receive .clinerules/ark.md, so a file must not trigger detection.
543
+ if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
544
+ detected.add('cline');
489
545
  }
490
- // No signal at all: fall back to writing every tool's templates so a fresh
546
+ if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
547
+ // copilot has no reliable directory signal (.github exists in most repos),
548
+ // so it is explicit-only via --tools.
549
+ // No signal at all: fall back to writing the primary tools' templates so a fresh
491
550
  // project still gets a complete, reviewable starter set.
492
551
  if (detected.size === 0) {
493
552
  return new Set(['claude', 'cursor', 'codex']);
@@ -495,7 +554,7 @@ function resolveTools(args) {
495
554
  return detected;
496
555
  }
497
556
 
498
- const KNOWN_TOOLS = ['claude', 'cursor', 'codex'];
557
+ const KNOWN_TOOLS = ['claude', 'cursor', 'codex', 'windsurf', 'cline', 'copilot', 'kiro'];
499
558
 
500
559
  function runInstallAgentGates(args) {
501
560
  const root = args.root;
@@ -529,6 +588,19 @@ function runInstallAgentGates(args) {
529
588
  if (tools.has('codex')) {
530
589
  templates.push(['docs/ark-codex-config.toml', codexTomlSnippet()]);
531
590
  }
591
+ // Instruction-tier hosts: one shared rule text, host-specific path.
592
+ if (tools.has('windsurf')) {
593
+ templates.push(['.windsurf/rules/ark.md', instructionRule()]);
594
+ }
595
+ if (tools.has('cline')) {
596
+ templates.push(['.clinerules/ark.md', instructionRule()]);
597
+ }
598
+ if (tools.has('copilot')) {
599
+ templates.push(['.github/copilot-instructions.md', instructionRule()]);
600
+ }
601
+ if (tools.has('kiro')) {
602
+ templates.push(['.kiro/steering/ark.md', instructionRule()]);
603
+ }
532
604
 
533
605
  const results = templates.map(([relativePath, content]) =>
534
606
  writeTemplate(root, relativePath, content, args.force)
@@ -652,6 +724,20 @@ function collectConfigWarnings(root, config, files, rules, manifest) {
652
724
  if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
653
725
  seenLayers.add(layer.name);
654
726
 
727
+ if (
728
+ layer.forbiddenGlobals !== undefined &&
729
+ (!Array.isArray(layer.forbiddenGlobals) ||
730
+ layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
731
+ ) {
732
+ warnings.push(
733
+ configWarning(
734
+ 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
735
+ `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
736
+ { layer: layer.name }
737
+ )
738
+ );
739
+ }
740
+
655
741
  const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
656
742
  if (patterns.length === 0) {
657
743
  warnings.push(
@@ -759,19 +845,91 @@ function createModuleResolutionHost(ts) {
759
845
  };
760
846
  }
761
847
 
762
- function loadCompilerOptions(ts, root, tsconfigArg) {
763
- const configPath = tsconfigArg
764
- ? path.isAbsolute(tsconfigArg)
765
- ? tsconfigArg
766
- : path.join(root, tsconfigArg)
767
- : ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
768
- if (!configPath || !fs.existsSync(configPath)) return {};
848
+ function parseTsconfig(ts, configPath) {
769
849
  const read = ts.readConfigFile(configPath, ts.sys.readFile);
770
850
  if (read.error) return {};
771
851
  const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
772
852
  return parsed.options;
773
853
  }
774
854
 
855
+ /**
856
+ * Compiler options for a given source file. With --tsconfig every file uses that one
857
+ * config; otherwise each file uses the NEAREST tsconfig.json above it (like tsc does),
858
+ * so monorepo packages with per-package path aliases resolve correctly under one --root.
859
+ */
860
+ function createCompilerOptionsLookup(ts, root, tsconfigArg) {
861
+ if (tsconfigArg) {
862
+ const configPath = path.isAbsolute(tsconfigArg) ? tsconfigArg : path.join(root, tsconfigArg);
863
+ const options = fs.existsSync(configPath) ? parseTsconfig(ts, configPath) : {};
864
+ return () => options;
865
+ }
866
+ const byDir = new Map();
867
+ const byConfig = new Map();
868
+ return (file) => {
869
+ const dir = path.dirname(file);
870
+ if (byDir.has(dir)) return byDir.get(dir);
871
+ const configPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json');
872
+ let options = {};
873
+ if (configPath) {
874
+ if (!byConfig.has(configPath)) byConfig.set(configPath, parseTsconfig(ts, configPath));
875
+ options = byConfig.get(configPath);
876
+ }
877
+ byDir.set(dir, options);
878
+ return options;
879
+ };
880
+ }
881
+
882
+ /**
883
+ * Per-file scan cache. A cache entry stores the parsed file's content-derived results:
884
+ * content violations (forbidden globals, publish checks, intent references) and the list
885
+ * of module-edge specifiers. Edges are NEVER cached as violations — they are re-resolved
886
+ * against the live filesystem every run, because resolution depends on files and tsconfigs
887
+ * outside the cached file. The whole cache is keyed by the config+manifest contents, so
888
+ * any rule change invalidates everything.
889
+ */
890
+ function scanCachePath(root) {
891
+ return path.join(root, 'node_modules', '.cache', 'ark-check.json');
892
+ }
893
+
894
+ function scanCacheKey(root, args) {
895
+ const read = (p) => {
896
+ try {
897
+ return fs.readFileSync(p, 'utf8');
898
+ } catch {
899
+ return '';
900
+ }
901
+ };
902
+ const configPath = path.isAbsolute(args.config) ? args.config : path.join(root, args.config);
903
+ const manifestPath = args.manifest
904
+ ? path.isAbsolute(args.manifest)
905
+ ? args.manifest
906
+ : path.join(root, args.manifest)
907
+ : undefined;
908
+ return crypto
909
+ .createHash('sha1')
910
+ .update(`ark-check-cache-v1\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
911
+ .digest('hex');
912
+ }
913
+
914
+ function loadScanCache(root, key) {
915
+ try {
916
+ const data = JSON.parse(fs.readFileSync(scanCachePath(root), 'utf8'));
917
+ return data.key === key && data.files && typeof data.files === 'object' ? data.files : undefined;
918
+ } catch {
919
+ return undefined;
920
+ }
921
+ }
922
+
923
+ function saveScanCache(root, key, files) {
924
+ try {
925
+ const target = scanCachePath(root);
926
+ fs.mkdirSync(path.dirname(target), { recursive: true });
927
+ fs.writeFileSync(target, JSON.stringify({ key, files }));
928
+ } catch {
929
+ // cache is best-effort: read-only filesystems just re-parse every run
930
+ }
931
+ }
932
+
775
933
  /**
776
934
  * Fallback resolver for extensionless relative imports whose on-disk target uses an
777
935
  * extension `ts.resolveModuleName` won't resolve without a matching tsconfig
@@ -816,8 +974,9 @@ function resolveRelativeFallback(fromFile, specifier) {
816
974
  * path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
817
975
  * segment. Using the root-relative path (not an absolute substring) means a project that
818
976
  * itself lives under a node_modules segment is still governed, while a broad catch-all
819
- * pattern (`**`) can't false-flag vendored deps or files outside the project. For monorepos,
820
- * run ark-check per package rather than reaching across package roots.
977
+ * pattern (`**`) can't false-flag vendored deps or files outside the project. Monorepos can
978
+ * run under a single --root (per-package tsconfigs are honored via the nearest-tsconfig
979
+ * lookup); edges that resolve outside the root are still skipped.
821
980
  */
822
981
  function resolveImport(ts, specifier, containingFile, options, host, root) {
823
982
  const res = ts.resolveModuleName(specifier, containingFile, options, host);
@@ -931,7 +1090,7 @@ function publishHasSource(ts, node) {
931
1090
  );
932
1091
  }
933
1092
 
934
- // ponytail: baseline keys exclude the line number so unrelated edits that shift lines
1093
+ // Baseline keys exclude the line number so unrelated edits that shift lines
935
1094
  // don't resurrect frozen violations; the trade-off is that N identical violations in one
936
1095
  // file collapse to one key.
937
1096
  function baselineKey(violation) {
@@ -981,6 +1140,8 @@ const FIX_HINTS = {
981
1140
  'Add metadata.source (the publishing intent name) to the publish call.',
982
1141
  PUBLISH_SOURCE_LAYER_MISMATCH:
983
1142
  'Use a source intent that belongs to the same layer as the publishing file, or move the file.',
1143
+ FORBIDDEN_GLOBAL:
1144
+ 'Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort) instead of reaching for the ambient global.',
984
1145
  };
985
1146
 
986
1147
  function printViolation(violation) {
@@ -1081,35 +1242,41 @@ async function main() {
1081
1242
  const manifest = readManifest(root, args.manifest);
1082
1243
  const rules = manifest?.architecture?.rules ?? config.rules;
1083
1244
  const manifestIntentLayers = intentLayersFromManifest(manifest);
1084
- const compilerOptions = loadCompilerOptions(ts, root, args.tsconfig);
1245
+ const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
1085
1246
  const moduleHost = createModuleResolutionHost(ts);
1086
1247
  const files = config.include.flatMap((entry) => walk(path.join(root, entry)));
1087
1248
  const violations = [];
1088
1249
  const warnings = collectConfigWarnings(root, config, files, rules, manifest);
1089
-
1090
- for (const file of files) {
1250
+ const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
1251
+ const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
1252
+ const nextCacheFiles = {};
1253
+
1254
+ // Parses one file and returns its cacheable scan result: violations derived purely from
1255
+ // the file's content (+config/manifest, hashed into the cache key) and the module-edge
1256
+ // specifiers found, which the driver loop below resolves fresh on every run.
1257
+ function scanSourceFile(file, sourceLayer) {
1091
1258
  const source = fs.readFileSync(file, 'utf8');
1092
1259
  const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
1093
- const sourceLayer = layerForFile(root, file, config.layers);
1094
- if (!sourceLayer) continue;
1260
+ const violations = [];
1261
+ const edges = [];
1262
+
1263
+ const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
1264
+ const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
1265
+ ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
1266
+ : [];
1267
+ for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
1268
+ violations.push({
1269
+ ruleId: 'FORBIDDEN_GLOBAL',
1270
+ file: normalize(path.relative(root, file)),
1271
+ line: lineOf(sourceFile, use.node.getStart(sourceFile)),
1272
+ fromLayer: sourceLayer,
1273
+ target: use.name,
1274
+ message: `${sourceLayer} must not use the ambient global "${use.name}".`,
1275
+ });
1276
+ }
1095
1277
 
1096
1278
  const checkModuleEdge = (specifier, node, kind) => {
1097
- const target = resolveImport(ts, specifier, file, compilerOptions, moduleHost, root);
1098
- const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
1099
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1100
- if (rule) {
1101
- violations.push({
1102
- ruleId: 'LAYER_IMPORT_VIOLATION',
1103
- file: normalize(path.relative(root, file)),
1104
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1105
- fromLayer: sourceLayer,
1106
- toLayer: targetLayer,
1107
- target: normalize(path.relative(root, target)),
1108
- message:
1109
- rule.message ??
1110
- `${sourceLayer} must not ${kind} ${targetLayer}.`,
1111
- });
1112
- }
1279
+ edges.push({ specifier, line: lineOf(sourceFile, node.getStart(sourceFile)), kind });
1113
1280
  };
1114
1281
 
1115
1282
  const visit = (node) => {
@@ -1196,8 +1363,43 @@ async function main() {
1196
1363
  ts.forEachChild(node, visit);
1197
1364
  };
1198
1365
  visit(sourceFile);
1366
+ return { contentViolations: violations, edges };
1367
+ }
1368
+
1369
+ for (const file of files) {
1370
+ const sourceLayer = layerForFile(root, file, config.layers);
1371
+ if (!sourceLayer) continue;
1372
+ const relFile = normalize(path.relative(root, file));
1373
+ const stat = fs.statSync(file);
1374
+ const fileKey = `${stat.mtimeMs}:${stat.size}`;
1375
+ const cached = cachedFiles?.[relFile];
1376
+ const entry =
1377
+ cached && cached.fileKey === fileKey
1378
+ ? cached
1379
+ : { fileKey, ...scanSourceFile(file, sourceLayer) };
1380
+ nextCacheFiles[relFile] = entry;
1381
+
1382
+ violations.push(...entry.contentViolations);
1383
+ for (const edge of entry.edges) {
1384
+ const target = resolveImport(ts, edge.specifier, file, compilerOptionsFor(file), moduleHost, root);
1385
+ const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
1386
+ const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1387
+ if (rule) {
1388
+ violations.push({
1389
+ ruleId: 'LAYER_IMPORT_VIOLATION',
1390
+ file: relFile,
1391
+ line: edge.line,
1392
+ fromLayer: sourceLayer,
1393
+ toLayer: targetLayer,
1394
+ target: normalize(path.relative(root, target)),
1395
+ message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
1396
+ });
1397
+ }
1398
+ }
1199
1399
  }
1200
1400
 
1401
+ if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
1402
+
1201
1403
  if (args.updateBaseline) {
1202
1404
  const { fullPath, count } = writeBaseline(root, args.baseline, violations);
1203
1405
  console.log(`Wrote ${fullPath} with ${count} frozen violation key(s).`);