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 +97 -7
- package/bin/ark-check.mjs +244 -42
- package/bin/ark-mcp.mjs +105 -2
- package/bin/ark-shared.mjs +51 -0
- package/dist/eslint/index.cjs +49 -1
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +4 -1
- package/dist/eslint/index.d.ts +4 -1
- package/dist/eslint/index.js +49 -2
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +48 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +48 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/docs/agent-guide.md +9 -4
- package/docs/ai-gates.md +68 -0
- package/package.json +8 -2
- package/server.json +7 -7
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
|
[](https://github.com/pedroknigge/ark-runtime-kernel/actions/workflows/ci.yml)
|
|
9
11
|
[](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
|

|
|
13
15
|

|
|
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
|
|
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
|
|
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
|
-
'
|
|
93
|
-
'
|
|
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.
|
|
113
|
-
'
|
|
114
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
820
|
-
* run
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
1094
|
-
|
|
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
|
-
|
|
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).`);
|