ark-runtime-kernel 1.3.0 → 1.4.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 +87 -7
- package/bin/ark-check.mjs +103 -9
- 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/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,7 @@ 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
|
|
164
207
|
|
|
165
208
|
Violations come with the layer edge, the resolved target, and a fix hint:
|
|
166
209
|
|
|
@@ -171,6 +214,35 @@ Violations come with the layer edge, the resolved target, and a fix hint:
|
|
|
171
214
|
fix: Depend on a port/interface owned by an inner layer instead, or move this code.
|
|
172
215
|
```
|
|
173
216
|
|
|
217
|
+
### Domain purity: `forbiddenGlobals`
|
|
218
|
+
|
|
219
|
+
Import rules can't catch code that reaches for an ambient global — an agent can call
|
|
220
|
+
`fetch()` or `Date.now()` in your domain layer without importing anything. Declare the
|
|
221
|
+
globals a layer must not touch and both the write gate and CI enforce it:
|
|
222
|
+
|
|
223
|
+
```jsonc
|
|
224
|
+
// ark.config.json
|
|
225
|
+
{
|
|
226
|
+
"name": "DomainModel",
|
|
227
|
+
"patterns": ["src/domain/**"],
|
|
228
|
+
"intentPrefixes": ["Domain."],
|
|
229
|
+
"forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"]
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
✖ FORBIDDEN_GLOBAL src/domain/order.ts:12
|
|
235
|
+
DomainModel must not use the ambient global "Date.now".
|
|
236
|
+
fix: Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort).
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Entries are either dotted (`"Date.now"` flags exactly that property access) or bare
|
|
240
|
+
(`"console"` flags `console.*`, `fetch(...)`, `new WebSocket(...)`). Detection is
|
|
241
|
+
positional, not scope-aware: mentions in types or import names are never flagged.
|
|
242
|
+
`npx ark init` seeds the domain layer with `["fetch", "process", "Date.now", "Math.random"]`
|
|
243
|
+
(a pure domain does no I/O and is deterministic); add `"console"` or any other global per
|
|
244
|
+
project. Violations participate in the `--baseline` ratchet like every other rule.
|
|
245
|
+
|
|
174
246
|
### GitHub Action
|
|
175
247
|
|
|
176
248
|
```yaml
|
|
@@ -189,7 +261,15 @@ import ark from 'ark-runtime-kernel/eslint';
|
|
|
189
261
|
export default [ark.configs.recommended];
|
|
190
262
|
```
|
|
191
263
|
|
|
192
|
-
Rules: `ark/no-domain-infra-imports`, `ark/no-raw-event-publish`, `ark/require-publish-source
|
|
264
|
+
Rules: `ark/no-domain-infra-imports`, `ark/no-raw-event-publish`, `ark/require-publish-source`,
|
|
265
|
+
`ark/no-forbidden-globals` (not in `recommended` — scope it to your layer directories):
|
|
266
|
+
|
|
267
|
+
```js
|
|
268
|
+
{
|
|
269
|
+
files: ['src/domain/**'],
|
|
270
|
+
rules: { 'ark/no-forbidden-globals': ['error', { globals: ['fetch', 'process', 'Date.now', 'Math.random'] }] },
|
|
271
|
+
}
|
|
272
|
+
```
|
|
193
273
|
|
|
194
274
|
## The Runtime Kernel (opt-in)
|
|
195
275
|
|
package/bin/ark-check.mjs
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {
|
|
5
|
+
DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
5
6
|
DEFAULT_INTENT_PREFIXES,
|
|
6
7
|
DEFAULT_LAYER_DIRECTORIES,
|
|
7
8
|
DEFAULT_RULES,
|
|
9
|
+
collectForbiddenGlobalUses,
|
|
8
10
|
createElevenLayerConfig,
|
|
9
11
|
globToRegExp,
|
|
10
12
|
layerForFile,
|
|
@@ -96,7 +98,8 @@ function usage() {
|
|
|
96
98
|
'{',
|
|
97
99
|
' "include": ["src"],',
|
|
98
100
|
' "layers": [',
|
|
99
|
-
' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."]
|
|
101
|
+
' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
|
|
102
|
+
' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
|
|
100
103
|
' ],',
|
|
101
104
|
' "rules": [{ "from": "DomainModel", "to": "PersistenceAdapters", "allowed": false }]',
|
|
102
105
|
'}',
|
|
@@ -109,9 +112,13 @@ function usage() {
|
|
|
109
112
|
'with --strict-config to enforce gate presence and architecture in one run.',
|
|
110
113
|
'',
|
|
111
114
|
'--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
|
|
112
|
-
'project, plus tool-specific templates.
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
+
'project, plus tool-specific templates. Known tools: claude, cursor, codex (full',
|
|
116
|
+
'MCP/hook gates) and windsurf, cline, copilot, kiro (instruction-tier rule files',
|
|
117
|
+
'derived from the same contract; Gemini CLI needs no template — it reads AGENTS.md).',
|
|
118
|
+
'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
|
|
119
|
+
'from their config directories (.claude/, .cursor/, .codex/, .windsurf/, .clinerules/,',
|
|
120
|
+
'.kiro/; copilot is explicit-only). claude+cursor+codex are written when nothing is',
|
|
121
|
+
'detected.',
|
|
115
122
|
'',
|
|
116
123
|
'Generate a starter 11-layer config:',
|
|
117
124
|
' ark-check --print-config eleven-layer > ark.config.json',
|
|
@@ -192,6 +199,9 @@ function detectConfig(root) {
|
|
|
192
199
|
name: entry.layer,
|
|
193
200
|
patterns: directories.map((directory) => `${normalize(path.join(srcDir, directory))}/**`),
|
|
194
201
|
intentPrefixes: entry.prefixes,
|
|
202
|
+
...(entry.layer === 'DomainModel'
|
|
203
|
+
? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
|
|
204
|
+
: {}),
|
|
195
205
|
});
|
|
196
206
|
}
|
|
197
207
|
|
|
@@ -415,6 +425,25 @@ args = ["ark-mcp", "--root", ".", "--config", "ark.config.json"]
|
|
|
415
425
|
`;
|
|
416
426
|
}
|
|
417
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Compact always-on rule for instruction-tier hosts (Windsurf, Cline, GitHub Copilot,
|
|
430
|
+
* Kiro, ...): agents that read a project rule file but have no MCP tools or hooks.
|
|
431
|
+
* Derived from the same AGENT_CONTRACT as AGENTS.md and the Cursor rule so the steps
|
|
432
|
+
* can never drift; points at AGENTS.md for the full placement table.
|
|
433
|
+
*/
|
|
434
|
+
function instructionRule() {
|
|
435
|
+
const steps = AGENT_CONTRACT.steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
|
|
436
|
+
return `# Ark architecture contract
|
|
437
|
+
|
|
438
|
+
This project's architecture is governed by Ark (\`ark.config.json\` is authoritative).
|
|
439
|
+
Before writing or editing TypeScript or JavaScript source files:
|
|
440
|
+
|
|
441
|
+
${steps}
|
|
442
|
+
|
|
443
|
+
See \`AGENTS.md\` for the full contract and the layer placement table.
|
|
444
|
+
`;
|
|
445
|
+
}
|
|
446
|
+
|
|
418
447
|
function cursorRule() {
|
|
419
448
|
return `---
|
|
420
449
|
description: Ark architecture contract
|
|
@@ -460,6 +489,20 @@ ${setupSteps ? `${setupSteps}\n` : ''} - run: ${pm.install}
|
|
|
460
489
|
function claudeSettings() {
|
|
461
490
|
return `${JSON.stringify({
|
|
462
491
|
hooks: {
|
|
492
|
+
// Inject the contract at session start so the agent knows the architecture from
|
|
493
|
+
// the first token. Project-scoped by design; --session-context is also a silent
|
|
494
|
+
// no-op when no ark.config.json exists, so it can never leak into other projects.
|
|
495
|
+
SessionStart: [
|
|
496
|
+
{
|
|
497
|
+
hooks: [
|
|
498
|
+
{
|
|
499
|
+
type: 'command',
|
|
500
|
+
command:
|
|
501
|
+
'npx ark-mcp --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json',
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
],
|
|
463
506
|
PreToolUse: [
|
|
464
507
|
{
|
|
465
508
|
matcher: 'Write|Edit|MultiEdit',
|
|
@@ -484,10 +527,17 @@ function resolveTools(args) {
|
|
|
484
527
|
const detected = new Set();
|
|
485
528
|
if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
|
|
486
529
|
if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
|
|
487
|
-
if (fs.existsSync(path.join(root, '.codex')))
|
|
488
|
-
|
|
530
|
+
if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
|
|
531
|
+
if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
|
|
532
|
+
// .clinerules can also be a single FILE (older Cline convention); only a directory
|
|
533
|
+
// can receive .clinerules/ark.md, so a file must not trigger detection.
|
|
534
|
+
if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
|
|
535
|
+
detected.add('cline');
|
|
489
536
|
}
|
|
490
|
-
|
|
537
|
+
if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
|
|
538
|
+
// copilot has no reliable directory signal (.github exists in most repos),
|
|
539
|
+
// so it is explicit-only via --tools.
|
|
540
|
+
// No signal at all: fall back to writing the primary tools' templates so a fresh
|
|
491
541
|
// project still gets a complete, reviewable starter set.
|
|
492
542
|
if (detected.size === 0) {
|
|
493
543
|
return new Set(['claude', 'cursor', 'codex']);
|
|
@@ -495,7 +545,7 @@ function resolveTools(args) {
|
|
|
495
545
|
return detected;
|
|
496
546
|
}
|
|
497
547
|
|
|
498
|
-
const KNOWN_TOOLS = ['claude', 'cursor', 'codex'];
|
|
548
|
+
const KNOWN_TOOLS = ['claude', 'cursor', 'codex', 'windsurf', 'cline', 'copilot', 'kiro'];
|
|
499
549
|
|
|
500
550
|
function runInstallAgentGates(args) {
|
|
501
551
|
const root = args.root;
|
|
@@ -529,6 +579,19 @@ function runInstallAgentGates(args) {
|
|
|
529
579
|
if (tools.has('codex')) {
|
|
530
580
|
templates.push(['docs/ark-codex-config.toml', codexTomlSnippet()]);
|
|
531
581
|
}
|
|
582
|
+
// Instruction-tier hosts: one shared rule text, host-specific path.
|
|
583
|
+
if (tools.has('windsurf')) {
|
|
584
|
+
templates.push(['.windsurf/rules/ark.md', instructionRule()]);
|
|
585
|
+
}
|
|
586
|
+
if (tools.has('cline')) {
|
|
587
|
+
templates.push(['.clinerules/ark.md', instructionRule()]);
|
|
588
|
+
}
|
|
589
|
+
if (tools.has('copilot')) {
|
|
590
|
+
templates.push(['.github/copilot-instructions.md', instructionRule()]);
|
|
591
|
+
}
|
|
592
|
+
if (tools.has('kiro')) {
|
|
593
|
+
templates.push(['.kiro/steering/ark.md', instructionRule()]);
|
|
594
|
+
}
|
|
532
595
|
|
|
533
596
|
const results = templates.map(([relativePath, content]) =>
|
|
534
597
|
writeTemplate(root, relativePath, content, args.force)
|
|
@@ -652,6 +715,20 @@ function collectConfigWarnings(root, config, files, rules, manifest) {
|
|
|
652
715
|
if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
|
|
653
716
|
seenLayers.add(layer.name);
|
|
654
717
|
|
|
718
|
+
if (
|
|
719
|
+
layer.forbiddenGlobals !== undefined &&
|
|
720
|
+
(!Array.isArray(layer.forbiddenGlobals) ||
|
|
721
|
+
layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
|
|
722
|
+
) {
|
|
723
|
+
warnings.push(
|
|
724
|
+
configWarning(
|
|
725
|
+
'CONFIG_INVALID_FORBIDDEN_GLOBALS',
|
|
726
|
+
`Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
|
|
727
|
+
{ layer: layer.name }
|
|
728
|
+
)
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
655
732
|
const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
|
|
656
733
|
if (patterns.length === 0) {
|
|
657
734
|
warnings.push(
|
|
@@ -931,7 +1008,7 @@ function publishHasSource(ts, node) {
|
|
|
931
1008
|
);
|
|
932
1009
|
}
|
|
933
1010
|
|
|
934
|
-
//
|
|
1011
|
+
// Baseline keys exclude the line number so unrelated edits that shift lines
|
|
935
1012
|
// don't resurrect frozen violations; the trade-off is that N identical violations in one
|
|
936
1013
|
// file collapse to one key.
|
|
937
1014
|
function baselineKey(violation) {
|
|
@@ -981,6 +1058,8 @@ const FIX_HINTS = {
|
|
|
981
1058
|
'Add metadata.source (the publishing intent name) to the publish call.',
|
|
982
1059
|
PUBLISH_SOURCE_LAYER_MISMATCH:
|
|
983
1060
|
'Use a source intent that belongs to the same layer as the publishing file, or move the file.',
|
|
1061
|
+
FORBIDDEN_GLOBAL:
|
|
1062
|
+
'Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort) instead of reaching for the ambient global.',
|
|
984
1063
|
};
|
|
985
1064
|
|
|
986
1065
|
function printViolation(violation) {
|
|
@@ -1093,6 +1172,21 @@ async function main() {
|
|
|
1093
1172
|
const sourceLayer = layerForFile(root, file, config.layers);
|
|
1094
1173
|
if (!sourceLayer) continue;
|
|
1095
1174
|
|
|
1175
|
+
const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
|
|
1176
|
+
const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
|
|
1177
|
+
? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
|
|
1178
|
+
: [];
|
|
1179
|
+
for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
|
|
1180
|
+
violations.push({
|
|
1181
|
+
ruleId: 'FORBIDDEN_GLOBAL',
|
|
1182
|
+
file: normalize(path.relative(root, file)),
|
|
1183
|
+
line: lineOf(sourceFile, use.node.getStart(sourceFile)),
|
|
1184
|
+
fromLayer: sourceLayer,
|
|
1185
|
+
target: use.name,
|
|
1186
|
+
message: `${sourceLayer} must not use the ambient global "${use.name}".`,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1096
1190
|
const checkModuleEdge = (specifier, node, kind) => {
|
|
1097
1191
|
const target = resolveImport(ts, specifier, file, compilerOptions, moduleHost, root);
|
|
1098
1192
|
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|
package/bin/ark-mcp.mjs
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* stdin, validates the file content a Write/Edit/MultiEdit is about to produce, and exits
|
|
22
22
|
* 2 with the violations on stderr when the write must be blocked (0 otherwise). This is
|
|
23
23
|
* the copy-paste integration for agent runtimes whose hooks run shell commands.
|
|
24
|
+
*
|
|
25
|
+
* --session-context runs one-shot and prints a compact contract summary (layers, rule
|
|
26
|
+
* count, forbidden globals, baseline state, check command) to stdout. Bind it to a
|
|
27
|
+
* SessionStart hook so the agent has the architecture in context from the first token,
|
|
28
|
+
* instead of learning it by rejection.
|
|
24
29
|
*/
|
|
25
30
|
import fs from 'node:fs';
|
|
26
31
|
import path from 'node:path';
|
|
@@ -39,10 +44,12 @@ function parseArgs(argv) {
|
|
|
39
44
|
configExplicit: false,
|
|
40
45
|
manifest: undefined,
|
|
41
46
|
hook: false,
|
|
47
|
+
sessionContext: false,
|
|
42
48
|
};
|
|
43
49
|
for (let i = 2; i < argv.length; i += 1) {
|
|
44
50
|
const a = argv[i];
|
|
45
51
|
if (a === '--hook') args.hook = true;
|
|
52
|
+
else if (a === '--session-context') args.sessionContext = true;
|
|
46
53
|
else if (a === '--root') args.root = path.resolve(argv[++i]);
|
|
47
54
|
else if (a === '--config') {
|
|
48
55
|
args.config = argv[++i];
|
|
@@ -168,7 +175,27 @@ function runHook(gate, config, args) {
|
|
|
168
175
|
const result = gate.validate(source, { layer, filePath });
|
|
169
176
|
if (result.valid) return;
|
|
170
177
|
|
|
171
|
-
|
|
178
|
+
// Ratchet semantics (same philosophy as ark-check --baseline): an edit is blocked only
|
|
179
|
+
// when it ADDS violations relative to the file's current on-disk state. Otherwise a
|
|
180
|
+
// pre-existing violation — frozen in a baseline or predating Ark adoption — would make
|
|
181
|
+
// every subsequent edit to that file un-writable while CI passes. Keys ignore line
|
|
182
|
+
// numbers (edits shift them) and collapse duplicates, mirroring ark-check's baselineKey.
|
|
183
|
+
const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
|
|
184
|
+
let existingKeys = new Set();
|
|
185
|
+
try {
|
|
186
|
+
const current = fs.readFileSync(filePath, 'utf8');
|
|
187
|
+
existingKeys = new Set(
|
|
188
|
+
gate.validate(current, { layer, filePath }).violations.map(violationKey)
|
|
189
|
+
);
|
|
190
|
+
} catch {
|
|
191
|
+
// New file: nothing pre-exists, every violation is new.
|
|
192
|
+
}
|
|
193
|
+
const newViolations = result.violations.filter(
|
|
194
|
+
(violation) => !existingKeys.has(violationKey(violation))
|
|
195
|
+
);
|
|
196
|
+
if (newViolations.length === 0) return;
|
|
197
|
+
|
|
198
|
+
const lines = newViolations.map(
|
|
172
199
|
(violation) =>
|
|
173
200
|
`- [${violation.ruleId}] ${violation.message}${violation.line ? ` (line ${violation.line})` : ''}`
|
|
174
201
|
);
|
|
@@ -182,12 +209,65 @@ function runHook(gate, config, args) {
|
|
|
182
209
|
process.exitCode = 2;
|
|
183
210
|
}
|
|
184
211
|
|
|
212
|
+
/**
|
|
213
|
+
* One-shot SessionStart context: a compact summary of the contract on stdout so the
|
|
214
|
+
* agent starts the session already knowing the architecture. Advisory — never blocks
|
|
215
|
+
* and never exits non-zero for missing optional inputs (e.g. no baseline file).
|
|
216
|
+
*/
|
|
217
|
+
function printSessionContext(config, profile, forbiddenGlobals, args) {
|
|
218
|
+
const lines = ['Ark architecture contract governs this project (ark.config.json is authoritative).'];
|
|
219
|
+
|
|
220
|
+
const configLayers = Array.isArray(config.layers) ? config.layers : [];
|
|
221
|
+
if (configLayers.length > 0) {
|
|
222
|
+
lines.push('Layers:');
|
|
223
|
+
for (const layer of configLayers) {
|
|
224
|
+
const globals = forbiddenGlobals[layer.name];
|
|
225
|
+
const globalsNote = globals ? ` — forbidden globals: ${globals.join(', ')}` : '';
|
|
226
|
+
lines.push(` - ${layer.name}: ${(layer.patterns ?? []).join(', ')}${globalsNote}`);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
lines.push(
|
|
230
|
+
`Layers: none configured — the default 11-layer profile applies to intent references.`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const denied = (profile.rules ?? []).filter((rule) => !rule.allowed).length;
|
|
235
|
+
lines.push(
|
|
236
|
+
`Rules: ${denied} denied layer edge(s). Full contract: ark://manifest MCP resource.`
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Advisory output: a malformed baseline must not abort the summary.
|
|
240
|
+
let baseline;
|
|
241
|
+
try {
|
|
242
|
+
baseline = readJson(path.join(args.root, '.ark-baseline.json'));
|
|
243
|
+
} catch {
|
|
244
|
+
baseline = undefined;
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(baseline?.violations)) {
|
|
247
|
+
lines.push(
|
|
248
|
+
`Baseline: ${baseline.violations.length} frozen violation(s) — only NEW violations fail; do not add to them.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
lines.push('After edits run: npx ark-check --root . --config ark.config.json --strict-config');
|
|
253
|
+
lines.push('If Ark reports violations, fix the architecture instead of weakening the gate.');
|
|
254
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
255
|
+
}
|
|
256
|
+
|
|
185
257
|
async function main() {
|
|
186
258
|
const args = parseArgs(process.argv);
|
|
259
|
+
const configPath = resolveInRoot(args.root, args.config);
|
|
260
|
+
|
|
261
|
+
// SessionStart contract injection is only meaningful in Ark-governed projects. Bail
|
|
262
|
+
// out silently (before loading dist) when there is no config, so the hook is safe
|
|
263
|
+
// even if a user installs it in their GLOBAL settings instead of per-project.
|
|
264
|
+
if (args.sessionContext && !(configPath && fs.existsSync(configPath))) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
187
268
|
const ark = await loadArk();
|
|
188
269
|
const ts = await loadOptionalTypeScript();
|
|
189
270
|
|
|
190
|
-
const configPath = resolveInRoot(args.root, args.config);
|
|
191
271
|
const config =
|
|
192
272
|
(configPath ? readJson(configPath, { required: args.configExplicit }) : undefined) ?? {
|
|
193
273
|
include: ['src'],
|
|
@@ -248,11 +328,28 @@ async function main() {
|
|
|
248
328
|
});
|
|
249
329
|
}
|
|
250
330
|
|
|
331
|
+
// Layer → forbidden ambient globals, straight from ark.config.json. Enforced by the
|
|
332
|
+
// gate only when the target file's layer is known (same data ark-check enforces in CI).
|
|
333
|
+
const forbiddenGlobals = Object.fromEntries(
|
|
334
|
+
configLayers
|
|
335
|
+
.filter(
|
|
336
|
+
(layer) =>
|
|
337
|
+
layer.name &&
|
|
338
|
+
Array.isArray(layer.forbiddenGlobals) &&
|
|
339
|
+
layer.forbiddenGlobals.some((entry) => typeof entry === 'string')
|
|
340
|
+
)
|
|
341
|
+
.map((layer) => [
|
|
342
|
+
layer.name,
|
|
343
|
+
layer.forbiddenGlobals.filter((entry) => typeof entry === 'string'),
|
|
344
|
+
])
|
|
345
|
+
);
|
|
346
|
+
|
|
251
347
|
const gate = ark.createAICodeGate({
|
|
252
348
|
architectureProfile: profile,
|
|
253
349
|
intents,
|
|
254
350
|
enforceIntentAllowlist: intents.length > 0,
|
|
255
351
|
typescript: ts,
|
|
352
|
+
forbiddenGlobals,
|
|
256
353
|
});
|
|
257
354
|
|
|
258
355
|
if (args.hook) {
|
|
@@ -260,6 +357,11 @@ async function main() {
|
|
|
260
357
|
return;
|
|
261
358
|
}
|
|
262
359
|
|
|
360
|
+
if (args.sessionContext) {
|
|
361
|
+
printSessionContext(config, profile, forbiddenGlobals, args);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
263
365
|
const SERVER_INFO = { name: 'ark-runtime-kernel', version: ark.version };
|
|
264
366
|
const DEFAULT_PROTOCOL = '2024-11-05';
|
|
265
367
|
|
|
@@ -342,6 +444,7 @@ async function main() {
|
|
|
342
444
|
name: profile.name,
|
|
343
445
|
layers: profile.layers,
|
|
344
446
|
rules: profile.rules,
|
|
447
|
+
...(Object.keys(forbiddenGlobals).length > 0 ? { forbiddenGlobals } : {}),
|
|
345
448
|
...(suggestions.length > 0
|
|
346
449
|
? {
|
|
347
450
|
suggestedLayers: suggestions,
|
package/bin/ark-shared.mjs
CHANGED
|
@@ -69,6 +69,13 @@ export const DEFAULT_RULES = createStrictDenyRules(
|
|
|
69
69
|
DEFAULT_ALLOWED_FLOWS
|
|
70
70
|
);
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
|
|
74
|
+
* deterministic. `console` is deliberately omitted (too common during adoption); add it per
|
|
75
|
+
* project via the layer's `forbiddenGlobals` in ark.config.json.
|
|
76
|
+
*/
|
|
77
|
+
export const DEFAULT_DOMAIN_FORBIDDEN_GLOBALS = ['fetch', 'process', 'Date.now', 'Math.random'];
|
|
78
|
+
|
|
72
79
|
export function createElevenLayerConfig(options = {}) {
|
|
73
80
|
const rootDir = options.rootDir ?? 'src';
|
|
74
81
|
const optional = options.optionalLayers ?? true;
|
|
@@ -82,11 +89,55 @@ export function createElevenLayerConfig(options = {}) {
|
|
|
82
89
|
),
|
|
83
90
|
intentPrefixes: entry.prefixes,
|
|
84
91
|
optional,
|
|
92
|
+
...(entry.layer === 'DomainModel'
|
|
93
|
+
? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
|
|
94
|
+
: {}),
|
|
85
95
|
})),
|
|
86
96
|
rules: DEFAULT_RULES,
|
|
87
97
|
};
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Find uses of forbidden ambient globals in a TypeScript source file.
|
|
102
|
+
*
|
|
103
|
+
* Detection is deliberately positional, not scope-aware (kept in sync with
|
|
104
|
+
* `collectForbiddenGlobalUses` in src/kernel/ai-gate/AICodeGate.ts — the CLIs must not
|
|
105
|
+
* import from dist):
|
|
106
|
+
* - a dotted entry ("Date.now") flags `Date.now` property accesses
|
|
107
|
+
* - a bare entry ("console", "fetch") flags property accesses on it (`console.log`),
|
|
108
|
+
* direct calls (`fetch(...)`), and constructions (`new WebSocket(...)`)
|
|
109
|
+
* Bare identifier mentions in other positions (types, shadowed locals, import names) are
|
|
110
|
+
* NOT flagged, trading a little recall for near-zero false positives without a type checker.
|
|
111
|
+
*
|
|
112
|
+
* Returns [{ name, node }] where `name` is the matched forbidden entry.
|
|
113
|
+
*/
|
|
114
|
+
export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
|
|
115
|
+
const entries = new Set(forbidden ?? []);
|
|
116
|
+
if (entries.size === 0) return [];
|
|
117
|
+
const uses = [];
|
|
118
|
+
|
|
119
|
+
const visit = (node) => {
|
|
120
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
121
|
+
const dotted = `${node.expression.text}.${node.name.text}`;
|
|
122
|
+
if (entries.has(dotted)) {
|
|
123
|
+
uses.push({ name: dotted, node });
|
|
124
|
+
} else if (entries.has(node.expression.text)) {
|
|
125
|
+
uses.push({ name: node.expression.text, node });
|
|
126
|
+
}
|
|
127
|
+
} else if (
|
|
128
|
+
(ts.isCallExpression(node) || ts.isNewExpression(node)) &&
|
|
129
|
+
node.expression &&
|
|
130
|
+
ts.isIdentifier(node.expression) &&
|
|
131
|
+
entries.has(node.expression.text)
|
|
132
|
+
) {
|
|
133
|
+
uses.push({ name: node.expression.text, node });
|
|
134
|
+
}
|
|
135
|
+
ts.forEachChild(node, visit);
|
|
136
|
+
};
|
|
137
|
+
visit(sourceFile);
|
|
138
|
+
return uses;
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
const _regexpCache = new Map();
|
|
91
142
|
|
|
92
143
|
function escapeLiteral(ch) {
|
package/dist/eslint/index.cjs
CHANGED
|
@@ -122,10 +122,57 @@ var requirePublishSource = {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
};
|
|
125
|
+
var DEFAULT_FORBIDDEN_GLOBALS = ["fetch", "process", "Date.now", "Math.random"];
|
|
126
|
+
var noForbiddenGlobals = {
|
|
127
|
+
meta: {
|
|
128
|
+
type: "problem",
|
|
129
|
+
docs: {
|
|
130
|
+
description: 'Disallow ambient globals (e.g. fetch, Date.now) in architecture-governed code; scope the rule to layer directories via ESLint "files" patterns.'
|
|
131
|
+
},
|
|
132
|
+
messages: {
|
|
133
|
+
forbiddenGlobal: 'Ambient global "{{name}}" is forbidden here; inject the capability through a port instead.'
|
|
134
|
+
},
|
|
135
|
+
schema: [
|
|
136
|
+
{
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {
|
|
139
|
+
globals: { type: "array", items: { type: "string" } }
|
|
140
|
+
},
|
|
141
|
+
additionalProperties: false
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
create(context) {
|
|
146
|
+
const option = context.options?.[0];
|
|
147
|
+
const globals = new Set(option?.globals ?? DEFAULT_FORBIDDEN_GLOBALS);
|
|
148
|
+
const report = (node, name) => context.report({ node, messageId: "forbiddenGlobal", data: { name } });
|
|
149
|
+
return {
|
|
150
|
+
// Same positional detection as ark-check's FORBIDDEN_GLOBAL: property accesses on a
|
|
151
|
+
// forbidden base (console.log, Date.now), direct calls, and constructions. Bare
|
|
152
|
+
// identifier mentions elsewhere are not flagged (avoids shadowed-local false positives).
|
|
153
|
+
MemberExpression(node) {
|
|
154
|
+
const base = node.object?.type === "Identifier" ? node.object.name : void 0;
|
|
155
|
+
if (!base) return;
|
|
156
|
+
const dotted = `${base}.${propertyName(node.property) ?? ""}`;
|
|
157
|
+
if (globals.has(dotted)) report(node, dotted);
|
|
158
|
+
else if (globals.has(base)) report(node, base);
|
|
159
|
+
},
|
|
160
|
+
CallExpression(node) {
|
|
161
|
+
const callee = node.callee?.type === "Identifier" ? node.callee.name : void 0;
|
|
162
|
+
if (callee && globals.has(callee)) report(node, callee);
|
|
163
|
+
},
|
|
164
|
+
NewExpression(node) {
|
|
165
|
+
const callee = node.callee?.type === "Identifier" ? node.callee.name : void 0;
|
|
166
|
+
if (callee && globals.has(callee)) report(node, callee);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
};
|
|
125
171
|
var rules = {
|
|
126
172
|
"no-domain-infra-imports": noDomainInfraImports,
|
|
127
173
|
"no-raw-event-publish": noRawEventPublish,
|
|
128
|
-
"require-publish-source": requirePublishSource
|
|
174
|
+
"require-publish-source": requirePublishSource,
|
|
175
|
+
"no-forbidden-globals": noForbiddenGlobals
|
|
129
176
|
};
|
|
130
177
|
var plugin = { rules };
|
|
131
178
|
plugin.configs = {
|
|
@@ -142,6 +189,7 @@ var eslint_default = plugin;
|
|
|
142
189
|
|
|
143
190
|
exports.default = eslint_default;
|
|
144
191
|
exports.noDomainInfraImports = noDomainInfraImports;
|
|
192
|
+
exports.noForbiddenGlobals = noForbiddenGlobals;
|
|
145
193
|
exports.noRawEventPublish = noRawEventPublish;
|
|
146
194
|
exports.plugin = plugin;
|
|
147
195
|
exports.requirePublishSource = requirePublishSource;
|