arkgate 2.12.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +90 -51
  3. package/bin/ark-check.mjs +156 -39
  4. package/bin/ark-mcp.mjs +119 -6
  5. package/bin/ark-shared.mjs +216 -129
  6. package/bin/ark.mjs +134 -34
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +13 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -127
  11. package/bin/lib/ci-and-commands.mjs +40 -3
  12. package/bin/lib/codex-home.mjs +7 -0
  13. package/bin/lib/config-contract.mjs +331 -0
  14. package/bin/lib/config-warnings.mjs +7 -205
  15. package/bin/lib/doctor-plan.mjs +43 -16
  16. package/bin/lib/enforcement-profiles.mjs +97 -0
  17. package/bin/lib/field-install.mjs +67 -10
  18. package/bin/lib/gate-files.mjs +42 -3
  19. package/bin/lib/graph-cycles.mjs +4 -54
  20. package/bin/lib/hook-templates.mjs +33 -1
  21. package/bin/lib/host-support-matrix.mjs +83 -0
  22. package/bin/lib/install-migrate.mjs +99 -30
  23. package/bin/lib/mcp-adoption.mjs +35 -3
  24. package/bin/lib/open-html.mjs +75 -0
  25. package/bin/lib/presets.mjs +45 -4
  26. package/bin/lib/safety-diagnostics.mjs +36 -15
  27. package/bin/lib/scan-files.mjs +12 -1
  28. package/bin/lib/skill-install.mjs +72 -1
  29. package/bin/lib/source-policy.mjs +36 -0
  30. package/bin/lib/start-preview.mjs +271 -0
  31. package/bin/lib/ts-resolve.mjs +13 -3
  32. package/bin/lib/weakest-link.mjs +417 -0
  33. package/bin/lib/write-path-capabilities.mjs +186 -0
  34. package/bin/lib/write-path-detect.mjs +62 -99
  35. package/compat/nestjs.cjs +2 -0
  36. package/compat/nestjs.d.ts +2 -0
  37. package/compat/nestjs.js +1 -0
  38. package/compat/runtime.cjs +2 -0
  39. package/compat/runtime.d.ts +2 -0
  40. package/compat/runtime.js +1 -0
  41. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  42. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  43. package/dist/eslint/index.cjs +500 -61
  44. package/dist/eslint/index.d.cts +36 -20
  45. package/dist/eslint/index.d.ts +36 -20
  46. package/dist/eslint/index.js +500 -61
  47. package/dist/index.cjs +1349 -2741
  48. package/dist/index.d.cts +449 -483
  49. package/dist/index.d.ts +449 -483
  50. package/dist/index.js +1325 -2687
  51. package/docs/agent-guide.md +58 -34
  52. package/docs/ai-gates.md +79 -21
  53. package/docs/configuration.md +97 -0
  54. package/docs/enthusiast/README.md +3 -3
  55. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  56. package/docs/migrate-from-ark-runtime-kernel.md +5 -3
  57. package/docs/package-surface.md +19 -19
  58. package/docs/production-hardening.md +31 -5
  59. package/docs/threat-model.md +65 -0
  60. package/docs/typescript-support.md +30 -3
  61. package/package.json +46 -11
  62. package/schemas/ark.analysis-result.schema.json +91 -0
  63. package/schemas/ark.config.schema.json +750 -0
  64. package/server.json +2 -2
  65. package/templates/hooks/pre-commit-ark +37 -0
  66. package/templates/skills/ark-architect.md +3 -2
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
  71. package/dist/eslint/index.cjs.map +0 -1
  72. package/dist/eslint/index.js.map +0 -1
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/nestjs/index.cjs +0 -2498
  76. package/dist/nestjs/index.cjs.map +0 -1
  77. package/dist/nestjs/index.d.cts +0 -22
  78. package/dist/nestjs/index.d.ts +0 -22
  79. package/dist/nestjs/index.js +0 -2474
  80. package/dist/nestjs/index.js.map +0 -1
  81. package/dist/runtime/index.cjs +0 -3352
  82. package/dist/runtime/index.cjs.map +0 -1
  83. package/dist/runtime/index.d.cts +0 -2
  84. package/dist/runtime/index.d.ts +0 -2
  85. package/dist/runtime/index.js +0 -3270
  86. package/dist/runtime/index.js.map +0 -1
  87. package/dist/types-BZ17b9i5.d.cts +0 -1068
  88. package/dist/types-BZ17b9i5.d.ts +0 -1068
@@ -5,16 +5,13 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import {
8
- collectForbiddenGlobalUses,
9
8
  layerForFile,
10
9
  looksLikeIntent,
11
10
  } from '../ark-shared.mjs';
12
11
  import {
13
- isTypeOnlyModuleReference,
14
12
  isArkPublishCandidate,
15
13
  isPublishCall,
16
14
  lineOf,
17
- moduleSpecifierFromCall,
18
15
  namedModuleBindings,
19
16
  objectHasProperty,
20
17
  publishHasSource,
@@ -22,7 +19,6 @@ import {
22
19
  sourceFileExportsOnlyTypes,
23
20
  sourceFileHasTopLevelSideEffects,
24
21
  stringLiteralText,
25
- textOfModuleSpecifier,
26
22
  typeOnlyExportNames,
27
23
  } from './ast-scan.mjs';
28
24
  import { provePortProofInject } from './port-proof.mjs';
@@ -32,9 +28,14 @@ import {
32
28
  isBlocked,
33
29
  collectConfigWarnings,
34
30
  } from './config-warnings.mjs';
35
- import { detectCycles } from './graph-cycles.mjs';
31
+ import {
32
+ collectForbiddenCapabilityUses,
33
+ evaluateArchitectureGraph,
34
+ extractSemanticDependencies,
35
+ } from './analysis-engine.mjs';
36
36
  import { normalize } from './scan-files.mjs';
37
37
  import { collectSafetyDiagnostics } from './safety-diagnostics.mjs';
38
+ import { classifyPublishFacts } from './source-policy.mjs';
38
39
  import {
39
40
  createCompilerOptionsLookup,
40
41
  createModuleResolutionHost,
@@ -57,11 +58,11 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
57
58
  const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
58
59
  ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
59
60
  : [];
60
- for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
61
+ for (const use of collectForbiddenCapabilityUses(ts, sourceFile, forbiddenGlobals)) {
61
62
  violations.push({
62
63
  ruleId: 'FORBIDDEN_GLOBAL',
63
64
  file: normalize(path.relative(root, file)),
64
- line: lineOf(sourceFile, use.node.getStart(sourceFile)),
65
+ line: use.line,
65
66
  fromLayer: sourceLayer,
66
67
  target: use.name,
67
68
  message: `${sourceLayer} must not use the ambient global "${use.name}".`,
@@ -79,48 +80,37 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
79
80
  });
80
81
  };
81
82
 
82
- const visit = (node) => {
83
- if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
84
- const specifier = textOfModuleSpecifier(node);
85
- if (specifier) {
86
- checkModuleEdge(
87
- specifier,
88
- node,
89
- ts.isImportDeclaration(node) ? 'import' : 'export',
90
- isTypeOnlyModuleReference(ts, node)
91
- );
92
- }
93
- }
83
+ for (const dependency of extractSemanticDependencies(ts, sourceFile)) {
84
+ if (!dependency.specifier) continue;
85
+ checkModuleEdge(
86
+ dependency.specifier,
87
+ dependency.node,
88
+ dependency.kind,
89
+ dependency.typeOnly
90
+ );
91
+ }
94
92
 
93
+ const needsPolicyWalk =
94
+ /\bpublish\s*\(|\bintent\b/.test(source) ||
95
+ /['"`]\s*[A-Z][A-Za-z0-9_]*\./.test(source);
96
+ const visit = (node) => {
95
97
  if (ts.isCallExpression(node)) {
96
- const moduleCall = moduleSpecifierFromCall(ts, node);
97
- if (moduleCall) {
98
- checkModuleEdge(moduleCall.value, node, moduleCall.kind);
99
- }
100
-
101
98
  if (isPublishCall(ts, node)) {
102
99
  const firstArg = node.arguments[0];
103
100
  const rawIntent = stringLiteralText(ts, firstArg);
104
- if (
105
- (rawIntent && looksLikeIntent(rawIntent)) ||
106
- objectHasProperty(ts, firstArg, 'intent')
107
- ) {
101
+ for (const finding of classifyPublishFacts({
102
+ publishCall: true,
103
+ rawIntentName: rawIntent,
104
+ objectHasIntent: objectHasProperty(ts, firstArg, 'intent'),
105
+ arkPublishCandidate: isArkPublishCandidate(ts, node),
106
+ hasSource: publishHasSource(ts, node),
107
+ })) {
108
108
  violations.push({
109
- ruleId: 'RAW_EVENT_PUBLISH',
109
+ ruleId: finding.ruleId,
110
110
  file: normalize(path.relative(root, file)),
111
111
  line: lineOf(sourceFile, node.getStart(sourceFile)),
112
- message:
113
- 'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
114
- });
115
- }
116
-
117
- if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
118
- violations.push({
119
- ruleId: 'PUBLISH_MISSING_SOURCE',
120
- file: normalize(path.relative(root, file)),
121
- line: lineOf(sourceFile, node.getStart(sourceFile)),
122
- fromLayer: sourceLayer,
123
- message: 'Strict Ark publish calls must include metadata.source.',
112
+ ...(finding.ruleId === 'PUBLISH_MISSING_SOURCE' ? { fromLayer: sourceLayer } : {}),
113
+ message: finding.message,
124
114
  });
125
115
  }
126
116
 
@@ -167,7 +157,7 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
167
157
 
168
158
  ts.forEachChild(node, visit);
169
159
  };
170
- visit(sourceFile);
160
+ if (needsPolicyWalk) visit(sourceFile);
171
161
  return {
172
162
  contentViolations: violations,
173
163
  edges,
@@ -186,7 +176,6 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
186
176
  const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
187
177
  const moduleHost = createModuleResolutionHost(ts);
188
178
 
189
- const violations = [];
190
179
  const warnings = collectConfigWarnings(root, config, files, rules, manifest);
191
180
  const safety = collectSafetyDiagnostics(ts, root, config, files);
192
181
  warnings.push(...safety.warnings);
@@ -194,13 +183,11 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
194
183
  const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
195
184
  const nextCacheFiles = {};
196
185
 
197
- const importGraph = new Map();
198
186
  const scanned = [];
199
187
  for (const file of files) {
200
188
  const sourceLayer = layerForFile(root, file, config.layers);
201
189
  if (!sourceLayer) continue;
202
190
  const relFile = normalize(path.relative(root, file));
203
- if (!importGraph.has(relFile)) importGraph.set(relFile, new Set());
204
191
  const stat = fs.statSync(file);
205
192
  const fileKey = `${stat.mtimeMs}:${stat.size}`;
206
193
  const cached = cachedFiles?.[relFile];
@@ -223,8 +210,8 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
223
210
  scanned.push({ file, sourceLayer, relFile, entry });
224
211
  }
225
212
 
213
+ const engineEdges = [];
226
214
  for (const { file, sourceLayer, relFile, entry } of scanned) {
227
- violations.push(...entry.contentViolations);
228
215
  for (const edge of entry.edges) {
229
216
  const target = resolveImport(
230
217
  ts,
@@ -235,99 +222,69 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
235
222
  root
236
223
  );
237
224
  const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
238
- if (target && targetLayer) {
239
- const relTarget = normalize(path.relative(root, target));
240
- if (relTarget !== relFile && !edge.typeOnly) {
241
- importGraph.get(relFile).add(relTarget);
242
- }
243
- }
244
225
  const relTarget = target ? normalize(path.relative(root, target)) : undefined;
245
- const rule = targetLayer
226
+ const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
227
+ const staticEdge = edge.kind === 'import' || edge.kind === 'export';
228
+ const targetTypeOnlyExports =
229
+ staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
230
+ const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
231
+ const targetTypeNames = new Set(targetCached?.typeOnlyExportNames || []);
232
+ const named = edge.namedBindings;
233
+ const namedBindingsTypeOnly =
234
+ staticEdge &&
235
+ Array.isArray(named) &&
236
+ named.length > 0 &&
237
+ targetTypeNames.size > 0 &&
238
+ !targetCached?.hasTopLevelSideEffects &&
239
+ named.every((name) => targetTypeNames.has(name));
240
+ const deniedRule = targetLayer
246
241
  ? isBlocked(rules, sourceLayer, targetLayer, {
247
242
  fromPath: relFile,
248
243
  toPath: relTarget,
249
244
  layers: config.layers,
250
245
  })
251
246
  : undefined;
252
- if (rule) {
253
- const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
254
- const staticEdge = edge.kind === 'import' || edge.kind === 'export';
255
- const targetTypeOnlyExports =
256
- staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
257
- const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
258
- // R6: every named binding is a type-only export of the target (mixed modules OK).
259
- // Conservative: no dual-space value names, no top-level side effects on target
260
- // (import type would skip evaluation), no default/namespace/side-effect/export*.
261
- const targetTypeNames = new Set(targetCached?.typeOnlyExportNames || []);
262
- const named = edge.namedBindings;
263
- const namedBindingsTypeOnly =
264
- staticEdge &&
265
- Array.isArray(named) &&
266
- named.length > 0 &&
267
- targetTypeNames.size > 0 &&
268
- !targetCached?.hasTopLevelSideEffects &&
269
- named.every((n) => targetTypeNames.has(n));
270
- const peerIsolation = Boolean(rule.peerIsolation);
271
- // W6: port-proof eligibility (value import only; fail-closed static proof).
272
- let portProofEligible = false;
273
- if (
274
- !edge.typeOnly &&
275
- !peerIsolation &&
276
- edge.kind === 'import' &&
277
- !targetTypeOnlyExports &&
278
- !namedBindingsTypeOnly
279
- ) {
280
- try {
281
- const srcText = fs.readFileSync(file, 'utf8');
282
- const proof = provePortProofInject(ts, srcText, { filePath: file });
283
- portProofEligible = Boolean(proof.eligible);
284
- } catch {
285
- portProofEligible = false;
286
- }
247
+ let portProofEligible = false;
248
+ if (
249
+ deniedRule &&
250
+ !deniedRule.peerIsolation &&
251
+ !edge.typeOnly &&
252
+ edge.kind === 'import' &&
253
+ !targetTypeOnlyExports &&
254
+ !namedBindingsTypeOnly
255
+ ) {
256
+ try {
257
+ const source = fs.readFileSync(file, 'utf8');
258
+ portProofEligible = Boolean(provePortProofInject(ts, source, { filePath: file }).eligible);
259
+ } catch {
260
+ portProofEligible = false;
287
261
  }
288
- violations.push({
289
- ruleId: 'LAYER_IMPORT_VIOLATION',
290
- file: relFile,
291
- line: edge.line,
292
- fromLayer: sourceLayer,
293
- toLayer: targetLayer,
294
- target: relTarget,
295
- ...(edge.typeOnly ? { typeOnly: true } : {}),
296
- ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
297
- ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
298
- ...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
299
- ...(portProofEligible ? { portProofEligible: true } : {}),
300
- ...(edge.kind ? { edgeKind: edge.kind } : {}),
301
- ...(peerIsolation ? { peerIsolation: true } : {}),
302
- message:
303
- rule.message ??
304
- (peerIsolation
305
- ? `${sourceLayer} must not ${edge.kind} another slice of ${targetLayer} (${relFile} → ${relTarget}). Extract shared code or use events/ports across slices.`
306
- : `${sourceLayer} must not ${edge.kind} ${targetLayer}.`),
307
- });
308
262
  }
263
+ engineEdges.push({
264
+ from: relFile,
265
+ fromLayer: sourceLayer,
266
+ to: relTarget,
267
+ toLayer: targetLayer,
268
+ line: edge.line,
269
+ kind: edge.kind,
270
+ typeOnly: edge.typeOnly,
271
+ targetTypeOnlyExports,
272
+ sourcePureTypeModule,
273
+ namedBindingsTypeOnly,
274
+ portProofEligible,
275
+ });
309
276
  }
310
277
  }
311
278
 
312
279
  if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
313
280
 
314
- const cyclePolicy = String(config.cyclePolicy || 'strict').toLowerCase();
315
- if (cyclePolicy !== 'off') {
316
- const cycles = detectCycles(importGraph);
317
- if (cyclePolicy === 'soft' || cyclePolicy === 'framework-soft') {
318
- for (const c of cycles) {
319
- warnings.push({
320
- ruleId: 'CIRCULAR_DEPENDENCY',
321
- message: `${c.message} (soft cycle policy — advisory only; set cyclePolicy: "strict" to fail the check)`,
322
- file: c.file,
323
- target: c.target,
324
- failsStrict: false,
325
- });
326
- }
327
- } else {
328
- violations.push(...cycles);
329
- }
330
- }
331
-
332
- return { violations, warnings, safety: safety.report };
281
+ return evaluateArchitectureGraph({
282
+ config,
283
+ rules,
284
+ files: scanned.map(({ relFile }) => relFile),
285
+ contentViolations: scanned.flatMap(({ entry }) => entry.contentViolations),
286
+ edges: engineEdges,
287
+ warnings,
288
+ safety: safety.report,
289
+ });
333
290
  }
@@ -12,6 +12,7 @@ import {
12
12
  DEFAULT_LAYER_DIRECTORIES,
13
13
  } from '../ark-shared.mjs';
14
14
  import { falseGreenAdoptionGap } from './field-install.mjs';
15
+ import { renderHostSupportMatrixMarkdown } from './host-support-matrix.mjs';
15
16
  import { PREFERRED_MCP_BIN } from './hook-templates.mjs';
16
17
  import { readPackageJson } from './gate-files.mjs';
17
18
 
@@ -30,7 +31,7 @@ export function checkArgsForRoot(root, { requireGates = false } = {}) {
30
31
  const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
31
32
  ? ' --baseline .ark-baseline.json'
32
33
  : '';
33
- const profile = requireGates ? '--strict' : '--strict-config';
34
+ const profile = requireGates ? '--strict-merge' : '--strict-config';
34
35
  return `--root . --config ark.config.json ${profile}${baselineFlag}`;
35
36
  }
36
37
 
@@ -140,6 +141,10 @@ export function agentInstructions(root) {
140
141
  Skills are **dual-engine**: deterministic CLI sensors + exploratory read of *this* repo — not JSON-only wrappers.
141
142
  When a skill says **STOP — do not continue this skill as complete**, stop and invoke the named handoff skill.
142
143
 
144
+ ## Host enforcement support
145
+
146
+ ${renderHostSupportMatrixMarkdown()}
147
+
143
148
  ### Subagent fan-out
144
149
  If the host supports **parallel subagents**, skills may ask you to fan out **read-only**
145
150
  scouts (disjoint path scopes) and merge in the parent. If the host does **not**,
@@ -177,8 +182,40 @@ an ungoverned location:
177
182
 
178
183
  ${layerPlacementTable()}
179
184
 
180
- The project is only considered Ark-enforced when the write gate and CI gate pass
181
- (runtime path only if this project opted into the kernel).
185
+ The project is only considered Ark-enforced when its host-appropriate write path is configured
186
+ and the CI check passes. Only Claude/Grok provide a hard local write boundary; Cursor/Codex use
187
+ advisory MCP plus CI. The experimental runtime is not required.
188
+ `;
189
+ }
190
+
191
+ /**
192
+ * Compact onboarding uses one project router instead of copied slash-command
193
+ * skills. The package and ark MCP resources remain the canonical capability
194
+ * source; the marker makes the selected host verifiable by the strict gate.
195
+ */
196
+ export function compactAgentInstructions(root, host = null) {
197
+ const selectedHost = host || 'none';
198
+ const checkCmd = arkCheckCommand(root);
199
+ const doctorCmd = arkCommand(root, 'ark-check', '--doctor');
200
+ const installSkills = arkCommand(
201
+ root,
202
+ 'ark-check',
203
+ `--install-agent-gates --skills-only --tools ${selectedHost === 'none' ? '<host>' : selectedHost}`
204
+ );
205
+ return `# Ark Enforcement
206
+
207
+ <!-- arkgate:compact-router host=${selectedHost} -->
208
+ ## Compact router
209
+
210
+ This project uses the ArkGate package and its \`ark\` MCP resources as its one
211
+ agent router. Before editing TypeScript or JavaScript, read \`ark://manifest\`
212
+ when available; use \`ark_place\` for new files and \`validate_code\` after edits.
213
+ If MCP is unavailable, inspect \`ark.config.json\` and run \`${checkCmd}\`.
214
+
215
+ For architecture status, run \`${doctorCmd}\`. The selected host is
216
+ \`${selectedHost}\`; its host registration and CI gate are installed alongside
217
+ this file. Full \`/ark-*\` guided workflows are optional and can be added later
218
+ with \`${installSkills}\`.
182
219
  `;
183
220
  }
184
221
 
@@ -22,6 +22,13 @@ export function codexConfigPath() {
22
22
  return path.join(base, 'config.toml');
23
23
  }
24
24
 
25
+ /** True when CODEX_HOME is unset/empty or resolves to the real default ~/.codex. */
26
+ export function usesDefaultCodexHome(env = process.env, homeDir = os.homedir()) {
27
+ const configured = env?.CODEX_HOME;
28
+ if (typeof configured !== 'string' || configured.trim() === '') return true;
29
+ return path.resolve(configured) === path.resolve(homeDir, '.codex');
30
+ }
31
+
25
32
  /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
26
33
  export function isTempOrUpgradeRoot(p) {
27
34
  if (!p || typeof p !== 'string') return false;