ark-runtime-kernel 1.2.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 +132 -8
- package/bin/ark-check.mjs +355 -48
- package/bin/ark-mcp.mjs +147 -3
- package/bin/ark-shared.mjs +53 -1
- 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 +50 -2
- 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 +50 -2
- 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 -0
- package/docs/ai-gates.md +76 -0
- package/package.json +8 -2
- package/server.json +7 -7
- package/docs/blog/how-i-stopped-claude-from-breaking-my-architecture.md +0 -85
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,
|
|
@@ -21,8 +23,10 @@ function parseArgs(argv) {
|
|
|
21
23
|
tsconfig: undefined,
|
|
22
24
|
json: false,
|
|
23
25
|
strictConfig: false,
|
|
26
|
+
requireGates: false,
|
|
24
27
|
init: false,
|
|
25
28
|
installAgentGates: false,
|
|
29
|
+
tools: undefined,
|
|
26
30
|
force: false,
|
|
27
31
|
baseline: undefined,
|
|
28
32
|
updateBaseline: false,
|
|
@@ -31,8 +35,23 @@ function parseArgs(argv) {
|
|
|
31
35
|
const arg = argv[i];
|
|
32
36
|
if (arg === '--json') args.json = true;
|
|
33
37
|
else if (arg === '--strict-config') args.strictConfig = true;
|
|
38
|
+
else if (arg === '--require-gates') args.requireGates = true;
|
|
34
39
|
else if (arg === '--init') args.init = true;
|
|
35
40
|
else if (arg === '--install-agent-gates') args.installAgentGates = true;
|
|
41
|
+
else if (arg === '--tools') {
|
|
42
|
+
// Consume the next arg only when it isn't another flag (same rule as --baseline),
|
|
43
|
+
// so `--tools --force` can't silently eat --force as a "tool name".
|
|
44
|
+
const next = argv[i + 1];
|
|
45
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
46
|
+
i += 1;
|
|
47
|
+
args.tools = next
|
|
48
|
+
.split(',')
|
|
49
|
+
.map((tool) => tool.trim().toLowerCase())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
} else {
|
|
52
|
+
args.tools = []; // flag without a value — rejected in runInstallAgentGates
|
|
53
|
+
}
|
|
54
|
+
}
|
|
36
55
|
else if (arg === '--force') args.force = true;
|
|
37
56
|
else if (arg === '--baseline' || arg === '--update-baseline') {
|
|
38
57
|
if (arg === '--update-baseline') args.updateBaseline = true;
|
|
@@ -52,9 +71,9 @@ function parseArgs(argv) {
|
|
|
52
71
|
|
|
53
72
|
function usage() {
|
|
54
73
|
return [
|
|
55
|
-
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--json] [--baseline [file]]',
|
|
74
|
+
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]]',
|
|
56
75
|
' ark-check --init [--force]',
|
|
57
|
-
' ark-check --install-agent-gates [--force]',
|
|
76
|
+
' ark-check --install-agent-gates [--tools claude,cursor,codex] [--force]',
|
|
58
77
|
' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
|
|
59
78
|
' ark-check --print-config eleven-layer',
|
|
60
79
|
'',
|
|
@@ -65,6 +84,10 @@ function usage() {
|
|
|
65
84
|
'--init scans the project for the built-in layer directory conventions (src/domain,',
|
|
66
85
|
'src/application, src/adapters/persistence, ...) and writes an ark.config.json covering',
|
|
67
86
|
'only the layers that actually exist, with the default rules filtered to those layers.',
|
|
87
|
+
'Undetected profile layers are printed as suggestions with their conventional',
|
|
88
|
+
'directories. When nothing is detected, the full 11-layer starter profile is written',
|
|
89
|
+
'instead (all layers optional, anchored at src/), so the strict check passes today and',
|
|
90
|
+
'each layer starts being enforced as soon as its directory gains source files.',
|
|
68
91
|
'',
|
|
69
92
|
'Resolves relative, tsconfig path-alias, and package imports via the TypeScript',
|
|
70
93
|
'module resolver, then checks each resolved cross-layer import against the rules.',
|
|
@@ -75,7 +98,8 @@ function usage() {
|
|
|
75
98
|
'{',
|
|
76
99
|
' "include": ["src"],',
|
|
77
100
|
' "layers": [',
|
|
78
|
-
' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."]
|
|
101
|
+
' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
|
|
102
|
+
' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
|
|
79
103
|
' ],',
|
|
80
104
|
' "rules": [{ "from": "DomainModel", "to": "PersistenceAdapters", "allowed": false }]',
|
|
81
105
|
'}',
|
|
@@ -83,6 +107,19 @@ function usage() {
|
|
|
83
107
|
'Config warnings are advisory by default and are included in JSON output.',
|
|
84
108
|
'Use --strict-config to make config warnings fail the check.',
|
|
85
109
|
'',
|
|
110
|
+
'--require-gates fails the check when AGENTS.md, .mcp.json, or the generated CI',
|
|
111
|
+
'workflow is missing, so "installed but never configured" is a red CI. Combine it',
|
|
112
|
+
'with --strict-config to enforce gate presence and architecture in one run.',
|
|
113
|
+
'',
|
|
114
|
+
'--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
|
|
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.',
|
|
122
|
+
'',
|
|
86
123
|
'Generate a starter 11-layer config:',
|
|
87
124
|
' ark-check --print-config eleven-layer > ark.config.json',
|
|
88
125
|
'',
|
|
@@ -95,6 +132,35 @@ function readJson(file) {
|
|
|
95
132
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
96
133
|
}
|
|
97
134
|
|
|
135
|
+
function readPackageJson(root) {
|
|
136
|
+
const file = path.join(root, 'package.json');
|
|
137
|
+
if (!fs.existsSync(file)) return null;
|
|
138
|
+
return readJson(file);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasCheckArchitectureScript(root) {
|
|
142
|
+
const pkg = readPackageJson(root);
|
|
143
|
+
return Boolean(pkg?.scripts?.['check:architecture']);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const REQUIRED_GATE_FILES = [
|
|
147
|
+
'AGENTS.md',
|
|
148
|
+
'.mcp.json',
|
|
149
|
+
'.github/workflows/ark-check.yml',
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
function missingGates(root) {
|
|
153
|
+
return REQUIRED_GATE_FILES.filter(
|
|
154
|
+
(relativePath) => !fs.existsSync(path.join(root, relativePath))
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function checkArchitectureScriptSnippet() {
|
|
159
|
+
// npx resolves the installed package binary; `node bin/ark-check.mjs` only works
|
|
160
|
+
// inside Ark's own repo.
|
|
161
|
+
return '"check:architecture": "npx ark-check --root . --config ark.config.json --strict-config"';
|
|
162
|
+
}
|
|
163
|
+
|
|
98
164
|
function readConfig(root, configPath) {
|
|
99
165
|
const fullPath = path.isAbsolute(configPath)
|
|
100
166
|
? configPath
|
|
@@ -133,6 +199,9 @@ function detectConfig(root) {
|
|
|
133
199
|
name: entry.layer,
|
|
134
200
|
patterns: directories.map((directory) => `${normalize(path.join(srcDir, directory))}/**`),
|
|
135
201
|
intentPrefixes: entry.prefixes,
|
|
202
|
+
...(entry.layer === 'DomainModel'
|
|
203
|
+
? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
|
|
204
|
+
: {}),
|
|
136
205
|
});
|
|
137
206
|
}
|
|
138
207
|
|
|
@@ -176,39 +245,71 @@ function runInit(args) {
|
|
|
176
245
|
}
|
|
177
246
|
|
|
178
247
|
const { srcDir, config } = detectConfig(args.root);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
].join('\n')
|
|
187
|
-
);
|
|
188
|
-
process.exitCode = 1;
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
248
|
+
const greenfield = config.layers.length === 0;
|
|
249
|
+
// Greenfield: anchor the starter profile at src/ (the convention a fresh project will
|
|
250
|
+
// scaffold under) even when src/ doesn't exist yet — the layers are optional, so the
|
|
251
|
+
// check passes today and governance switches on the moment src/domain/ etc. appear.
|
|
252
|
+
const finalConfig = greenfield
|
|
253
|
+
? createElevenLayerConfig({ rootDir: srcDir === '.' ? 'src' : srcDir })
|
|
254
|
+
: config;
|
|
191
255
|
|
|
192
|
-
fs.writeFileSync(configPath, `${JSON.stringify(
|
|
256
|
+
fs.writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`);
|
|
193
257
|
|
|
194
258
|
console.log(`Wrote ${configPath}`);
|
|
195
259
|
console.log('');
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
console.log(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
)
|
|
260
|
+
if (greenfield) {
|
|
261
|
+
console.log('No conventional layer directories found — generated the full 11-layer starter');
|
|
262
|
+
console.log('profile instead. Every layer is marked optional, so the strict check passes now');
|
|
263
|
+
console.log('and each layer starts being enforced as soon as its directory gains source files:');
|
|
264
|
+
for (const layer of finalConfig.layers) {
|
|
265
|
+
console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
|
|
266
|
+
}
|
|
267
|
+
// The starter profile only governs src/. Existing source elsewhere would make the
|
|
268
|
+
// gate silently green, so surface it instead of pretending the project is covered.
|
|
269
|
+
const outside = walk(args.root)
|
|
270
|
+
.map((file) => normalize(path.relative(args.root, file)))
|
|
271
|
+
.filter((rel) => !rel.startsWith('src/') && !rel.split('/').some((s) => s.startsWith('.')));
|
|
272
|
+
if (outside.length > 0) {
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(`WARNING: ${outside.length} source file(s) live outside src/ and are NOT governed`);
|
|
275
|
+
console.log(`by this config (e.g. ${outside.slice(0, 3).join(', ')}).`);
|
|
276
|
+
console.log('Move them under src/, or edit the "include" and layer patterns to match your layout.');
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
console.log('Detected layers:');
|
|
280
|
+
for (const layer of finalConfig.layers) {
|
|
281
|
+
console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
|
|
282
|
+
}
|
|
283
|
+
const detected = new Set(finalConfig.layers.map((layer) => layer.name));
|
|
284
|
+
const suggested = DEFAULT_INTENT_PREFIXES.filter((entry) => !detected.has(entry.layer));
|
|
285
|
+
if (suggested.length > 0) {
|
|
286
|
+
console.log('');
|
|
287
|
+
console.log('Suggested layers from the 11-layer profile (not detected — conventional');
|
|
288
|
+
console.log('directories shown; create one and re-run --init, or add the layer by hand):');
|
|
289
|
+
for (const entry of suggested) {
|
|
290
|
+
const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
|
|
291
|
+
.map((directory) => `${srcDir}/${directory}`)
|
|
292
|
+
.join(', ');
|
|
293
|
+
console.log(` ${entry.layer}: ${dirs}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const uncovered = uncoveredDirectories(args.root, srcDir, finalConfig.layers);
|
|
297
|
+
if (uncovered.length > 0) {
|
|
298
|
+
console.log('');
|
|
299
|
+
console.log(
|
|
300
|
+
`Not covered by any layer (add patterns for these or they stay ungoverned): ${uncovered.join(', ')}`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
206
303
|
}
|
|
207
304
|
console.log('');
|
|
208
305
|
console.log('Next steps:');
|
|
209
306
|
console.log(' 1. CI gate: npx ark-check --root . --config ark.config.json --strict-config');
|
|
210
307
|
console.log(' 2. AI write gate: npx ark-mcp --root . --config ark.config.json');
|
|
211
308
|
console.log(' (bind its validate_code tool to your agent\'s pre-write hook — see README)');
|
|
309
|
+
if (!hasCheckArchitectureScript(args.root)) {
|
|
310
|
+
console.log(' 3. Add the npm alias if you want `npm run check:architecture`:');
|
|
311
|
+
console.log(` ${checkArchitectureScriptSnippet()}`);
|
|
312
|
+
}
|
|
212
313
|
}
|
|
213
314
|
|
|
214
315
|
function ensureDirForFile(file) {
|
|
@@ -220,9 +321,13 @@ function writeTemplate(root, relativePath, content, force) {
|
|
|
220
321
|
if (fs.existsSync(fullPath) && !force) {
|
|
221
322
|
return { relativePath, status: 'skipped' };
|
|
222
323
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
324
|
+
try {
|
|
325
|
+
ensureDirForFile(fullPath);
|
|
326
|
+
fs.writeFileSync(fullPath, content);
|
|
327
|
+
return { relativePath, status: 'written' };
|
|
328
|
+
} catch {
|
|
329
|
+
return { relativePath, status: 'failed' };
|
|
330
|
+
}
|
|
226
331
|
}
|
|
227
332
|
|
|
228
333
|
function packageManager(root) {
|
|
@@ -231,7 +336,7 @@ function packageManager(root) {
|
|
|
231
336
|
cache: 'pnpm',
|
|
232
337
|
setup: ['corepack enable'],
|
|
233
338
|
install: 'pnpm install --frozen-lockfile',
|
|
234
|
-
run: 'pnpm exec ark-check --root . --config ark.config.json --strict-config',
|
|
339
|
+
run: 'pnpm exec ark-check --root . --config ark.config.json --strict-config --require-gates',
|
|
235
340
|
};
|
|
236
341
|
}
|
|
237
342
|
if (fs.existsSync(path.join(root, 'yarn.lock'))) {
|
|
@@ -239,27 +344,63 @@ function packageManager(root) {
|
|
|
239
344
|
cache: 'yarn',
|
|
240
345
|
setup: ['corepack enable'],
|
|
241
346
|
install: 'yarn install --frozen-lockfile',
|
|
242
|
-
run: 'yarn ark-check --root . --config ark.config.json --strict-config',
|
|
347
|
+
run: 'yarn ark-check --root . --config ark.config.json --strict-config --require-gates',
|
|
243
348
|
};
|
|
244
349
|
}
|
|
245
350
|
return {
|
|
246
351
|
cache: 'npm',
|
|
247
352
|
setup: [],
|
|
248
353
|
install: fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm install',
|
|
249
|
-
run: 'npx ark-check --root . --config ark.config.json --strict-config',
|
|
354
|
+
run: 'npx ark-check --root . --config ark.config.json --strict-config --require-gates',
|
|
250
355
|
};
|
|
251
356
|
}
|
|
252
357
|
|
|
358
|
+
const ARK_CHECK_COMMAND = 'npx ark-check --root . --config ark.config.json --strict-config';
|
|
359
|
+
|
|
360
|
+
// Canonical agent contract. AGENTS.md and the Cursor rule both derive from this
|
|
361
|
+
// single source so the steps can never drift out of sync between the two files.
|
|
362
|
+
const AGENT_CONTRACT = {
|
|
363
|
+
manifestResource: 'ark://manifest',
|
|
364
|
+
steps: [
|
|
365
|
+
`Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
|
|
366
|
+
`Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
|
|
367
|
+
`Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
|
|
368
|
+
`After edits, run \`${ARK_CHECK_COMMAND}\`.`,
|
|
369
|
+
`If Ark reports violations, fix the architecture instead of weakening the gate.`,
|
|
370
|
+
],
|
|
371
|
+
// Cursor-only guidance: the write-time validate_code tool is available in
|
|
372
|
+
// Cursor's runtime but has no equivalent in a plain AGENTS.md read.
|
|
373
|
+
cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
function layerPlacementTable() {
|
|
377
|
+
const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
|
|
378
|
+
const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
|
|
379
|
+
.map((directory) => `\`${directory}/\``)
|
|
380
|
+
.join(', ');
|
|
381
|
+
return `| ${entry.layer} | ${dirs} | ${entry.prefixes.map((p) => `\`${p}\``).join(', ')} |`;
|
|
382
|
+
}).join('\n');
|
|
383
|
+
return `| Layer | Conventional directories (under the source root) | Intent prefixes |
|
|
384
|
+
|-------|---------------------------------------------------|-----------------|
|
|
385
|
+
${rows}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
253
388
|
function agentInstructions() {
|
|
389
|
+
const steps = AGENT_CONTRACT.steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
|
|
254
390
|
return `# Ark Enforcement
|
|
255
391
|
|
|
256
392
|
Before editing TypeScript or JavaScript source files:
|
|
257
393
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
394
|
+
${steps}
|
|
395
|
+
|
|
396
|
+
## Where new code belongs
|
|
397
|
+
|
|
398
|
+
\`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
|
|
399
|
+
that no existing layer covers (a saga, a background job, a read model, ...), use the
|
|
400
|
+
default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
|
|
401
|
+
an ungoverned location:
|
|
402
|
+
|
|
403
|
+
${layerPlacementTable()}
|
|
263
404
|
|
|
264
405
|
The project is only considered Ark-enforced when the write gate, CI gate, and runtime path all pass.
|
|
265
406
|
`;
|
|
@@ -284,6 +425,25 @@ args = ["ark-mcp", "--root", ".", "--config", "ark.config.json"]
|
|
|
284
425
|
`;
|
|
285
426
|
}
|
|
286
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
|
+
|
|
287
447
|
function cursorRule() {
|
|
288
448
|
return `---
|
|
289
449
|
description: Ark architecture contract
|
|
@@ -291,13 +451,12 @@ alwaysApply: true
|
|
|
291
451
|
---
|
|
292
452
|
|
|
293
453
|
Before writing or editing TypeScript or JavaScript source files, read the
|
|
294
|
-
\`
|
|
454
|
+
\`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
|
|
295
455
|
|
|
296
|
-
|
|
297
|
-
writing whenever your runtime supports it. After edits, run:
|
|
456
|
+
${AGENT_CONTRACT.cursorValidateStep} After edits, run:
|
|
298
457
|
|
|
299
458
|
\`\`\`bash
|
|
300
|
-
|
|
459
|
+
${ARK_CHECK_COMMAND}
|
|
301
460
|
\`\`\`
|
|
302
461
|
|
|
303
462
|
If Ark reports violations, fix the architecture instead of bypassing the gate.
|
|
@@ -330,6 +489,20 @@ ${setupSteps ? `${setupSteps}\n` : ''} - run: ${pm.install}
|
|
|
330
489
|
function claudeSettings() {
|
|
331
490
|
return `${JSON.stringify({
|
|
332
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
|
+
],
|
|
333
506
|
PreToolUse: [
|
|
334
507
|
{
|
|
335
508
|
matcher: 'Write|Edit|MultiEdit',
|
|
@@ -346,18 +519,79 @@ function claudeSettings() {
|
|
|
346
519
|
}, null, 2)}\n`;
|
|
347
520
|
}
|
|
348
521
|
|
|
522
|
+
function resolveTools(args) {
|
|
523
|
+
if (args.tools && args.tools.length > 0) {
|
|
524
|
+
return new Set(args.tools);
|
|
525
|
+
}
|
|
526
|
+
const root = args.root;
|
|
527
|
+
const detected = new Set();
|
|
528
|
+
if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
|
|
529
|
+
if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
|
|
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');
|
|
536
|
+
}
|
|
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
|
|
541
|
+
// project still gets a complete, reviewable starter set.
|
|
542
|
+
if (detected.size === 0) {
|
|
543
|
+
return new Set(['claude', 'cursor', 'codex']);
|
|
544
|
+
}
|
|
545
|
+
return detected;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const KNOWN_TOOLS = ['claude', 'cursor', 'codex', 'windsurf', 'cline', 'copilot', 'kiro'];
|
|
549
|
+
|
|
349
550
|
function runInstallAgentGates(args) {
|
|
350
551
|
const root = args.root;
|
|
552
|
+
if (args.tools) {
|
|
553
|
+
const unknown = args.tools.filter((tool) => !KNOWN_TOOLS.includes(tool));
|
|
554
|
+
if (args.tools.length === 0 || unknown.length > 0) {
|
|
555
|
+
console.error(
|
|
556
|
+
`--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
|
|
557
|
+
(unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
|
|
558
|
+
);
|
|
559
|
+
process.exitCode = 2;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
351
563
|
const pm = packageManager(root);
|
|
564
|
+
const hasCheckScript = hasCheckArchitectureScript(root);
|
|
565
|
+
const tools = resolveTools(args);
|
|
352
566
|
const templates = [
|
|
567
|
+
// Base gates: tool-agnostic contract + CI backstop, always written.
|
|
353
568
|
['AGENTS.md', agentInstructions()],
|
|
354
569
|
['.mcp.json', mcpJson()],
|
|
355
|
-
['.cursor/mcp.json', mcpJson()],
|
|
356
|
-
['.cursor/rules/ark.mdc', cursorRule()],
|
|
357
|
-
['.claude/settings.json', claudeSettings()],
|
|
358
570
|
['.github/workflows/ark-check.yml', githubWorkflow(pm)],
|
|
359
|
-
['docs/ark-codex-config.toml', codexTomlSnippet()],
|
|
360
571
|
];
|
|
572
|
+
if (tools.has('cursor')) {
|
|
573
|
+
templates.push(['.cursor/mcp.json', mcpJson()]);
|
|
574
|
+
templates.push(['.cursor/rules/ark.mdc', cursorRule()]);
|
|
575
|
+
}
|
|
576
|
+
if (tools.has('claude')) {
|
|
577
|
+
templates.push(['.claude/settings.json', claudeSettings()]);
|
|
578
|
+
}
|
|
579
|
+
if (tools.has('codex')) {
|
|
580
|
+
templates.push(['docs/ark-codex-config.toml', codexTomlSnippet()]);
|
|
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
|
+
}
|
|
361
595
|
|
|
362
596
|
const results = templates.map(([relativePath, content]) =>
|
|
363
597
|
writeTemplate(root, relativePath, content, args.force)
|
|
@@ -365,14 +599,27 @@ function runInstallAgentGates(args) {
|
|
|
365
599
|
|
|
366
600
|
console.log('Ark agent gate templates:');
|
|
367
601
|
for (const result of results) {
|
|
368
|
-
const marker =
|
|
602
|
+
const marker =
|
|
603
|
+
result.status === 'written' ? 'wrote' : result.status === 'failed' ? 'FAILED' : 'skipped';
|
|
369
604
|
console.log(` ${marker.padEnd(7)} ${result.relativePath}`);
|
|
370
605
|
}
|
|
606
|
+
const failed = results.filter((result) => result.status === 'failed');
|
|
607
|
+
if (failed.length > 0) {
|
|
608
|
+
console.error(`\nFailed to write ${failed.length} template(s).`);
|
|
609
|
+
process.exitCode = 1;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
371
612
|
console.log('');
|
|
372
613
|
console.log('Next steps:');
|
|
373
614
|
console.log(' 1. Review the generated files and commit the ones that match your tools.');
|
|
374
615
|
console.log(' 2. Run: npx ark-check --root . --config ark.config.json --strict-config');
|
|
375
|
-
|
|
616
|
+
if (!hasCheckScript) {
|
|
617
|
+
console.log(' 3. Add the npm alias if you want `npm run check:architecture`:');
|
|
618
|
+
console.log(` ${checkArchitectureScriptSnippet()}`);
|
|
619
|
+
console.log(' 4. If you use Codex in this project, wire it now so `ark://manifest` is available from the first edit.');
|
|
620
|
+
} else {
|
|
621
|
+
console.log(' 3. If you use Codex in this project, wire it now so `ark://manifest` is available from the first edit.');
|
|
622
|
+
}
|
|
376
623
|
}
|
|
377
624
|
|
|
378
625
|
function readManifest(root, manifestPath) {
|
|
@@ -468,6 +715,20 @@ function collectConfigWarnings(root, config, files, rules, manifest) {
|
|
|
468
715
|
if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
|
|
469
716
|
seenLayers.add(layer.name);
|
|
470
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
|
+
|
|
471
732
|
const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
|
|
472
733
|
if (patterns.length === 0) {
|
|
473
734
|
warnings.push(
|
|
@@ -747,7 +1008,7 @@ function publishHasSource(ts, node) {
|
|
|
747
1008
|
);
|
|
748
1009
|
}
|
|
749
1010
|
|
|
750
|
-
//
|
|
1011
|
+
// Baseline keys exclude the line number so unrelated edits that shift lines
|
|
751
1012
|
// don't resurrect frozen violations; the trade-off is that N identical violations in one
|
|
752
1013
|
// file collapse to one key.
|
|
753
1014
|
function baselineKey(violation) {
|
|
@@ -797,6 +1058,8 @@ const FIX_HINTS = {
|
|
|
797
1058
|
'Add metadata.source (the publishing intent name) to the publish call.',
|
|
798
1059
|
PUBLISH_SOURCE_LAYER_MISMATCH:
|
|
799
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.',
|
|
800
1063
|
};
|
|
801
1064
|
|
|
802
1065
|
function printViolation(violation) {
|
|
@@ -854,6 +1117,35 @@ async function main() {
|
|
|
854
1117
|
return;
|
|
855
1118
|
}
|
|
856
1119
|
|
|
1120
|
+
if (args.requireGates) {
|
|
1121
|
+
const missing = missingGates(args.root);
|
|
1122
|
+
if (missing.length > 0) {
|
|
1123
|
+
const payload = {
|
|
1124
|
+
ok: false,
|
|
1125
|
+
error: 'missing-gates',
|
|
1126
|
+
missing,
|
|
1127
|
+
};
|
|
1128
|
+
if (args.json) {
|
|
1129
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1130
|
+
} else {
|
|
1131
|
+
console.error('Ark gates are not installed. Missing:');
|
|
1132
|
+
for (const relativePath of missing) {
|
|
1133
|
+
console.error(` - ${relativePath}`);
|
|
1134
|
+
}
|
|
1135
|
+
console.error('\nRun `npx ark init` (or `ark-check --install-agent-gates`) to configure enforcement.');
|
|
1136
|
+
}
|
|
1137
|
+
process.exitCode = 1;
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
// Gates present. This is a precondition, not a standalone report: stay quiet
|
|
1141
|
+
// in --json mode so the architecture check below owns the single JSON output.
|
|
1142
|
+
// When --require-gates is the only intent (no config/architecture run needed),
|
|
1143
|
+
// callers still get a clear signal from the exit code and the human-mode line.
|
|
1144
|
+
if (!args.json) {
|
|
1145
|
+
console.log('Ark gates present: ' + REQUIRED_GATE_FILES.join(', '));
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
857
1149
|
let ts;
|
|
858
1150
|
try {
|
|
859
1151
|
ts = await import('typescript');
|
|
@@ -880,6 +1172,21 @@ async function main() {
|
|
|
880
1172
|
const sourceLayer = layerForFile(root, file, config.layers);
|
|
881
1173
|
if (!sourceLayer) continue;
|
|
882
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
|
+
|
|
883
1190
|
const checkModuleEdge = (specifier, node, kind) => {
|
|
884
1191
|
const target = resolveImport(ts, specifier, file, compilerOptions, moduleHost, root);
|
|
885
1192
|
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|