arkgate 2.10.0 → 2.11.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 (42) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -2
  3. package/SECURITY.md +3 -4
  4. package/bin/ark-check.mjs +41 -16
  5. package/bin/ark-mcp.mjs +54 -10
  6. package/bin/ark.mjs +35 -5
  7. package/bin/lib/agent-gates.mjs +9 -5
  8. package/bin/lib/architecture-scan.mjs +4 -1
  9. package/bin/lib/baseline-key.mjs +17 -0
  10. package/bin/lib/config-warnings.mjs +22 -0
  11. package/bin/lib/core-layers.mjs +7 -0
  12. package/bin/lib/core-ratchet.mjs +3 -7
  13. package/bin/lib/doctor-plan.mjs +29 -5
  14. package/bin/lib/safety-diagnostics.mjs +263 -0
  15. package/bin/lib/scan-files.mjs +51 -6
  16. package/bin/lib/violations.mjs +3 -3
  17. package/dist/index.cjs +103 -8
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +5 -3
  20. package/dist/index.d.ts +5 -3
  21. package/dist/index.js +103 -8
  22. package/dist/index.js.map +1 -1
  23. package/dist/nestjs/index.cjs +18 -5
  24. package/dist/nestjs/index.cjs.map +1 -1
  25. package/dist/nestjs/index.d.cts +1 -1
  26. package/dist/nestjs/index.d.ts +1 -1
  27. package/dist/nestjs/index.js +18 -5
  28. package/dist/nestjs/index.js.map +1 -1
  29. package/dist/runtime/index.cjs +103 -8
  30. package/dist/runtime/index.cjs.map +1 -1
  31. package/dist/runtime/index.d.cts +1 -1
  32. package/dist/runtime/index.d.ts +1 -1
  33. package/dist/runtime/index.js +103 -8
  34. package/dist/runtime/index.js.map +1 -1
  35. package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
  36. package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
  37. package/docs/agent-guide.md +1 -1
  38. package/docs/ai-gates.md +20 -2
  39. package/docs/package-surface.md +2 -2
  40. package/docs/production-hardening.md +5 -0
  41. package/package.json +5 -2
  42. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.11.0 — 2026-07-10
8
+
9
+ Fail-closed enforcement hardening: `--strict` now combines contract coverage, installed-gate
10
+ checks, write-hook presence, and bypass diagnostics in one CI profile. The GitHub Action runs
11
+ the exact checked-out revision by default, and runtime workflows cancel timed-out work
12
+ cooperatively.
13
+
14
+ ### Added
15
+
16
+ - **Strict CI profile:** `arkgate-check --strict` enables strict config validation, requires
17
+ generated gates plus a PreToolUse write hook, and fails on configured safety diagnostics.
18
+ - **Bypass diagnostics:** new `dynamicImportAllowlist` and `safety` config fields detect
19
+ non-literal dynamic imports, TypeScript suppression directives, explicit `any` casts,
20
+ production InMemory defaults, and disabled or omitted peer isolation. `--doctor --json`
21
+ exposes the same evidence under `doctor.safety`.
22
+ - **Release-quality CI:** JavaScript syntax validation, enforced coverage thresholds, and a
23
+ Node 18/20/22/24 compatibility matrix now run before merge.
24
+
25
+ ### Changed
26
+
27
+ - **Pinned GitHub Action execution:** `uses: pedroknigge/arkgate@<tag-or-SHA>` now runs that
28
+ checked-out ArkGate source. The `version` input remains available only as an explicit exact
29
+ npm compatibility override.
30
+ - **Complete MCP contract:** `ark://manifest` exposes every configured file layer separately
31
+ from runtime intent layers, plus reviewed dynamic-import and safety policy.
32
+ - **Workflow cancellation contract:** workflow steps receive an `AbortSignal` as their third
33
+ argument. `timeoutMs` aborts that signal, clears the active step, and rejects duplicate step
34
+ names before execution can corrupt compensation order.
35
+ - **Filesystem confinement:** source scans follow internal symlinks once and reject symlinks
36
+ that escape the project root.
37
+
38
+ ### Fixed
39
+
40
+ - **Baseline duplicate honesty:** repeated violations now receive stable per-occurrence keys,
41
+ so adding a second identical violation is new debt instead of being hidden by one baseline
42
+ entry.
43
+ - **Write-hook duplicate honesty:** proposed writes compare violation counts, preventing a new
44
+ duplicate from being mistaken for an already-existing violation.
45
+ - **CLI argument safety:** unknown flags and missing flag values fail with usage guidance
46
+ instead of silently weakening enforcement or throwing an internal error.
47
+ - **Action gate detection:** repositories using the ArkGate composite Action satisfy the CI
48
+ gate check without needing a separate literal `ark-check` command.
49
+
7
50
  ## 2.10.0 — 2026-07-10
8
51
 
9
52
  Track W — **Constrained write → verified repair**: write-boundary autoPatch, prepare_write,
package/README.md CHANGED
@@ -190,6 +190,9 @@ ark.config.json
190
190
  - **Brownfield:** baseline ratchet, refuse to freeze a wrong contract, `/ark-adopt` for mature trees.
191
191
  - **Agents:** skills install into Claude / Cursor / Codex / Grok; `ark start` freezes an origin report under `.ark/reports/`.
192
192
  - **Write protocol (2.10 / Track W):** mechanical-safe **autoPatch** on the write gate (`import type`); MCP **`ark_prepare_write`** (place + validate + patch + judgmentBrief); opt-in hook **`--hook-repair`** (`ARK_REPAIR_JSON`); doctor **`writePath`** (repair vs reject-only); loop-cost eval (`npm run eval:loop-cost`). Port-proof inject is **judgment** (arity change), not silent auto-apply.
193
+ - **Fail-closed CI (2.11):** `--strict` combines config coverage, required CI/MCP/write gates,
194
+ PreToolUse hook presence, and bypass diagnostics for dynamic imports, TypeScript suppressions,
195
+ explicit `any` casts, InMemory runtime defaults, and disabled peer isolation.
193
196
  - **TypeScript:** project compilers 5.x / 6.x / 7.x — gate falls back to a nested JS-API TypeScript when TS 7’s main export is version-only ([docs/typescript-support.md](docs/typescript-support.md)).
194
197
 
195
198
  ### Why not only ESLint / dependency-cruiser / Nx?
@@ -214,6 +217,7 @@ ark.config.json
214
217
  npx arkgate start # guided setup + plan + origin report
215
218
  npx arkgate-check --doctor # health + Adoption gaps (not just fitness)
216
219
  npx arkgate-check --doctor --json # machine-readable doctor.adoption
220
+ npx arkgate-check --strict # fail-closed CI + installed-gate/safety checks
217
221
  npx arkgate-check --plan # safe-to-auto-fix vs your call
218
222
  npx arkgate-check --coverage # Governed: N%
219
223
  npx arkgate-check --report ark-report.html # showcase HTML + Adoption card + origin/latest
@@ -224,8 +228,8 @@ npx arkgate upgrade # package + gates/skills + MCP/Codex n
224
228
  CI (example):
225
229
 
226
230
  ```yaml
227
- - run: npx arkgate-check --root . --config ark.config.json --strict-config
228
- # or: uses: pedroknigge/arkgate@main
231
+ - run: npx arkgate-check --root . --config ark.config.json --strict
232
+ # or: uses: pedroknigge/arkgate@<tag-or-SHA> # runs that checked-out revision
229
233
  ```
230
234
 
231
235
  ---
package/SECURITY.md CHANGED
@@ -28,10 +28,9 @@ a private security contact without including exploit details.
28
28
  ArkGate releases are GitHub-first:
29
29
 
30
30
  1. Changes land on GitHub and must pass CI plus the dedicated security workflow.
31
- 2. A GitHub Release is created from an annotated `vX.Y.Z` tag. Tag verification is
32
- **fail-closed** by default for unsigned tags unless
33
- `ARK_ALLOW_UNSIGNED_RELEASE_TAG=true` is set (CI publish sets this until GPG signing is
34
- wired). Signed tags can later require `ARK_REQUIRE_SIGNED_RELEASE_TAG=true`.
31
+ 2. A GitHub Release is created from a signed `vX.Y.Z` tag (`git tag -s`). The publish
32
+ workflow verifies the signature and fails closed for unsigned tags. The local override
33
+ exists only for explicit emergency use and is not enabled in CI.
35
34
  3. The manual `Publish npm` workflow verifies the tag, requires the GitHub Release to
36
35
  exist, reruns release checks, publishes npm with provenance, and uploads a SHA-256
37
36
  checksum for the npm tarball to the GitHub Release.
package/bin/ark-check.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  arkPackageVersion,
44
44
  REQUIRED_GATE_FILES,
45
45
  codexPromptsDir,
46
+ detectWritePathCapabilities,
46
47
  } from './lib/agent-gates.mjs';
47
48
  import { syncBaselineIntoCheckSurfaces } from './lib/field-install.mjs';
48
49
  import {
@@ -64,6 +65,7 @@ import {
64
65
  import { runRatchetCores } from './lib/core-ratchet.mjs';
65
66
  import {
66
67
  baselineKey,
68
+ baselineOccurrenceKeys,
67
69
  readBaseline,
68
70
  summarizeViolations,
69
71
  writeBaseline,
@@ -99,6 +101,7 @@ function parseArgs(argv) {
99
101
  printConfig: undefined,
100
102
  tsconfig: undefined,
101
103
  json: false,
104
+ strict: false,
102
105
  strictConfig: false,
103
106
  requireGates: false,
104
107
  init: false,
@@ -122,13 +125,25 @@ function parseArgs(argv) {
122
125
  version: false,
123
126
  help: false,
124
127
  };
128
+ const requireValue = (flag, index) => {
129
+ const value = argv[index + 1];
130
+ if (value === undefined || value.startsWith('-')) {
131
+ throw new Error(`Missing value for ${flag}. Run ark-check --help for usage.`);
132
+ }
133
+ return value;
134
+ };
125
135
  for (let i = 2; i < argv.length; i += 1) {
126
136
  const arg = argv[i];
127
137
  if (arg === '--json') args.json = true;
138
+ else if (arg === '--strict') {
139
+ args.strict = true;
140
+ args.strictConfig = true;
141
+ args.requireGates = true;
142
+ }
128
143
  else if (arg === '--strict-config') args.strictConfig = true;
129
144
  else if (arg === '--require-gates') args.requireGates = true;
130
145
  else if (arg === '--init') args.init = true;
131
- else if (arg === '--preset') args.preset = argv[++i];
146
+ else if (arg === '--preset') args.preset = requireValue(arg, i++);
132
147
  else if (arg === '--install-agent-gates') args.installAgentGates = true;
133
148
  else if (arg === '--tools') {
134
149
  // Consume the next arg only when it isn't another flag (same rule as --baseline),
@@ -152,7 +167,7 @@ function parseArgs(argv) {
152
167
  else if (arg === '--recommend') args.recommend = true;
153
168
  else if (arg === '--write-plan') args.writePlan = true;
154
169
  else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
155
- else if (arg === '--apply-policy-pack') args.applyPolicyPack = argv[++i];
170
+ else if (arg === '--apply-policy-pack') args.applyPolicyPack = requireValue(arg, i++);
156
171
  else if (arg === '--suggest-include') args.suggestInclude = true;
157
172
  else if (arg === '--adopt-contract') args.adoptContract = true;
158
173
  else if (arg === '--ratchet-cores') args.ratchetCores = true;
@@ -174,13 +189,14 @@ function parseArgs(argv) {
174
189
  const next = argv[i + 1];
175
190
  args.baseline = next && !next.startsWith('-') ? argv[++i] : '.ark-baseline.json';
176
191
  }
177
- else if (arg === '--root') args.root = path.resolve(argv[++i]);
178
- else if (arg === '--config') args.config = argv[++i];
179
- else if (arg === '--manifest') args.manifest = argv[++i];
180
- else if (arg === '--print-config') args.printConfig = argv[++i];
181
- else if (arg === '--tsconfig') args.tsconfig = argv[++i];
192
+ else if (arg === '--root') args.root = path.resolve(requireValue(arg, i++));
193
+ else if (arg === '--config') args.config = requireValue(arg, i++);
194
+ else if (arg === '--manifest') args.manifest = requireValue(arg, i++);
195
+ else if (arg === '--print-config') args.printConfig = requireValue(arg, i++);
196
+ else if (arg === '--tsconfig') args.tsconfig = requireValue(arg, i++);
182
197
  else if (arg === '--help' || arg === '-h') args.help = true;
183
198
  else if (arg === '--version' || arg === '-V') args.version = true;
199
+ else throw new Error(`Unknown argument: ${arg}. Run ark-check --help for usage.`);
184
200
  }
185
201
  return args;
186
202
  }
@@ -198,7 +214,7 @@ function usage() {
198
214
  return [
199
215
  'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
200
216
  ' ark-check --version',
201
- ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
217
+ ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict | --strict-config] [--require-gates] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
202
218
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
203
219
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
204
220
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
@@ -251,6 +267,8 @@ function usage() {
251
267
  '',
252
268
  'Config warnings are advisory by default and are included in JSON output.',
253
269
  'Use --strict-config to make config warnings fail the check.',
270
+ 'Use --strict for the fail-closed CI profile: --strict-config + --require-gates',
271
+ 'plus the security diagnostics surfaced by doctor.',
254
272
  '',
255
273
  '--require-gates fails the check when AGENTS.md, .mcp.json, or the generated CI',
256
274
  'workflow is missing, so "installed but never configured" is a red CI. Combine it',
@@ -301,6 +319,10 @@ function readConfig(root, configPath) {
301
319
  ...(raw.exclude ? { exclude: raw.exclude } : {}),
302
320
  ...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
303
321
  ...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
322
+ ...(raw.dynamicImportAllowlist
323
+ ? { dynamicImportAllowlist: raw.dynamicImportAllowlist }
324
+ : {}),
325
+ ...(raw.safety ? { safety: raw.safety } : {}),
304
326
  };
305
327
  }
306
328
 
@@ -316,7 +338,7 @@ function detectConfig(root) {
316
338
 
317
339
  for (const entry of DEFAULT_INTENT_PREFIXES) {
318
340
  const directories = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? []).filter(
319
- (directory) => walk(path.join(root, srcDir, directory)).length > 0
341
+ (directory) => walk(path.join(root, srcDir, directory), [], { root }).length > 0
320
342
  );
321
343
  if (directories.length === 0) continue;
322
344
  layers.push({
@@ -784,7 +806,7 @@ function runInit(args) {
784
806
  }
785
807
  // The starter profile only governs src/. Existing source elsewhere would make the
786
808
  // gate silently green, so surface it instead of pretending the project is covered.
787
- const outside = walk(args.root)
809
+ const outside = walk(args.root, [], { root: args.root })
788
810
  .map((file) => normalize(path.relative(args.root, file)))
789
811
  .filter((rel) => !rel.startsWith('src/') && !rel.split('/').some((s) => s.startsWith('.')));
790
812
  if (outside.length > 0) {
@@ -956,6 +978,9 @@ async function main() {
956
978
 
957
979
  if (args.requireGates) {
958
980
  const missing = missingGates(args.root);
981
+ if (args.strict && !detectWritePathCapabilities(args.root).hookPresent) {
982
+ missing.push('PreToolUse write hook');
983
+ }
959
984
  if (missing.length > 0) {
960
985
  const payload = {
961
986
  ok: false,
@@ -1032,7 +1057,7 @@ async function main() {
1032
1057
  );
1033
1058
  }
1034
1059
 
1035
- const { violations, warnings } = runArchitectureScan({
1060
+ const { violations, warnings, safety } = runArchitectureScan({
1036
1061
  root,
1037
1062
  config,
1038
1063
  manifest,
@@ -1046,6 +1071,7 @@ async function main() {
1046
1071
  runDoctor(root, config, files, rules, violations, args.json, {
1047
1072
  configPath: path.isAbsolute(args.config) ? args.config : path.join(root, args.config),
1048
1073
  configMissing: !fs.existsSync(path.isAbsolute(args.config) ? args.config : path.join(root, args.config)),
1074
+ safety,
1049
1075
  });
1050
1076
  return;
1051
1077
  }
@@ -1118,11 +1144,10 @@ async function main() {
1118
1144
  if (args.baseline) {
1119
1145
  const baseline = readBaseline(root, args.baseline);
1120
1146
  if (baseline.exists) {
1121
- suppressed = violations.filter((violation) => baseline.keys.has(baselineKey(violation)));
1122
- activeViolations = violations.filter(
1123
- (violation) => !baseline.keys.has(baselineKey(violation))
1124
- );
1125
- const currentKeys = new Set(violations.map(baselineKey));
1147
+ const occurrenceKeys = baselineOccurrenceKeys(violations);
1148
+ suppressed = violations.filter((_, index) => baseline.keys.has(occurrenceKeys[index]));
1149
+ activeViolations = violations.filter((_, index) => !baseline.keys.has(occurrenceKeys[index]));
1150
+ const currentKeys = new Set(occurrenceKeys);
1126
1151
  staleBaselineKeys = [...baseline.keys].filter((key) => !currentKeys.has(key)).length;
1127
1152
  } else {
1128
1153
  warnings.push(
package/bin/ark-mcp.mjs CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  DEFAULT_LAYER_DIRECTORIES,
49
49
  DEFAULT_RULES,
50
50
  arkCommand,
51
+ globToRegExp,
51
52
  layerForFile,
52
53
  shouldShowNewHereNudge,
53
54
  detectWorkspaces,
@@ -273,18 +274,23 @@ function runHook(gate, config, args, ts) {
273
274
  // line numbers (edits shift them); simpler than full baselineKey (no file/layer fields
274
275
  // needed — this file is fixed).
275
276
  const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
276
- let existingKeys = new Set();
277
+ let existingCounts = new Map();
277
278
  try {
278
279
  const current = fs.readFileSync(filePath, 'utf8');
279
- existingKeys = new Set(
280
- gate.validate(current, { layer, filePath }).violations.map(violationKey)
281
- );
280
+ for (const violation of gate.validate(current, { layer, filePath }).violations) {
281
+ const key = violationKey(violation);
282
+ existingCounts.set(key, (existingCounts.get(key) ?? 0) + 1);
283
+ }
282
284
  } catch {
283
285
  // New file: nothing pre-exists, every violation is new.
284
286
  }
285
- const newViolations = (result.violations ?? []).filter(
286
- (violation) => !existingKeys.has(violationKey(violation))
287
- );
287
+ const newViolations = (result.violations ?? []).filter((violation) => {
288
+ const key = violationKey(violation);
289
+ const remaining = existingCounts.get(key) ?? 0;
290
+ if (remaining === 0) return true;
291
+ existingCounts.set(key, remaining - 1);
292
+ return false;
293
+ });
288
294
  if (newViolations.length === 0) return;
289
295
 
290
296
  const lines = newViolations.map(
@@ -368,8 +374,14 @@ function runArkCheckJsonFromRoot(root, config, extraArgs, manifest) {
368
374
  const result = spawnSync(
369
375
  process.execPath,
370
376
  [arkCheckBin, '--root', root, '--config', config, ...manifestArgs, '--json', ...extraArgs],
371
- { encoding: 'utf8' }
377
+ { encoding: 'utf8', timeout: 120_000, maxBuffer: 20 * 1024 * 1024 }
372
378
  );
379
+ if (result.error) {
380
+ return {
381
+ data: null,
382
+ raw: `ark-check failed to execute: ${result.error.message}`,
383
+ };
384
+ }
373
385
  const stdout = result.stdout ?? '';
374
386
  try {
375
387
  return { data: JSON.parse(stdout), raw: stdout };
@@ -544,6 +556,18 @@ async function main() {
544
556
  name: layer.name,
545
557
  patterns: layer.patterns,
546
558
  })),
559
+ allowNonLiteralDynamicImport: (filePath) => {
560
+ if (!filePath || !Array.isArray(config.dynamicImportAllowlist)) return false;
561
+ const rel = path.relative(args.root, path.resolve(args.root, filePath)).split(path.sep).join('/');
562
+ return config.dynamicImportAllowlist.some((pattern) => {
563
+ if (typeof pattern !== 'string') return false;
564
+ try {
565
+ return globToRegExp(pattern).test(rel);
566
+ } catch {
567
+ return false;
568
+ }
569
+ });
570
+ },
547
571
  });
548
572
 
549
573
  if (args.hook) {
@@ -706,7 +730,10 @@ async function main() {
706
730
  // DomainModel there would tell the agent to create a second layer for the same
707
731
  // prefix, making longest-prefix resolution ambiguous.
708
732
  function suggestedLayers() {
709
- const activeNames = new Set(profile.layers.map((layer) => layer.name));
733
+ const activeNames = new Set([
734
+ ...configLayers.map((layer) => layer.name),
735
+ ...profile.layers.map((layer) => layer.name),
736
+ ]);
710
737
  const claimedPrefixes = new Set(
711
738
  profile.layers.flatMap((layer) =>
712
739
  (layer.prefixes ?? []).map((p) => (p.endsWith('.') ? p : `${p}.`))
@@ -732,13 +759,30 @@ async function main() {
732
759
  );
733
760
  }
734
761
  const suggestions = suggestedLayers();
762
+ const contractLayers = usedProjectConfig
763
+ ? configLayers.map((layer) => ({
764
+ ...layer,
765
+ prefixes: Array.isArray(layer.intentPrefixes) ? layer.intentPrefixes : [],
766
+ }))
767
+ : profile.layers;
735
768
  return JSON.stringify(
736
769
  {
737
770
  source: profile === ark.elevenLayerProfile ? 'strictDefaultElevenLayerProfile' : 'project',
738
771
  name: profile.name,
739
- layers: profile.layers,
772
+ // File placement contract: every configured layer, including layers that do not
773
+ // own intent prefixes (e.g. Tooling / FrameworkAdapters).
774
+ layers: contractLayers,
775
+ // Runtime/intent resolution profile kept explicit so consumers never have to infer
776
+ // why a prefix-less file layer is absent from intent resolution.
777
+ intentLayers: profile.layers,
740
778
  rules: profile.rules,
741
779
  ...(Object.keys(forbiddenGlobals).length > 0 ? { forbiddenGlobals } : {}),
780
+ ...(Array.isArray(config.dynamicImportAllowlist)
781
+ ? { dynamicImportAllowlist: config.dynamicImportAllowlist }
782
+ : {}),
783
+ ...(config.safety && typeof config.safety === 'object'
784
+ ? { safety: config.safety }
785
+ : {}),
742
786
  ...(suggestions.length > 0
743
787
  ? {
744
788
  suggestedLayers: suggestions,
package/bin/ark.mjs CHANGED
@@ -32,22 +32,33 @@ function parseArgs(argv) {
32
32
  strict: true,
33
33
  install: true,
34
34
  help: false,
35
+ version: false,
36
+ };
37
+
38
+ const requireValue = (flag, index) => {
39
+ const value = argv[index + 1];
40
+ if (value === undefined || value.startsWith('-')) {
41
+ throw new Error(`Missing value for ${flag}. Run ark --help for usage.`);
42
+ }
43
+ return value;
35
44
  };
36
45
 
37
46
  // Scan from the first user token (index 2) so a leading flag like `ark --help` is
38
47
  // recognized: the command is the first NON-dash argument, not blindly argv[2].
39
48
  for (let i = 2; i < argv.length; i += 1) {
40
49
  const arg = argv[i];
41
- if (arg === '--root') args.root = path.resolve(argv[++i]);
50
+ if (arg === '--root') args.root = path.resolve(requireValue(arg, i++));
42
51
  else if (arg === '--yes' || arg === '-y') args.yes = true;
43
52
  else if (arg === '--force') args.force = true;
44
53
  else if (arg === '--no-strict') args.strict = false;
45
54
  else if (arg === '--no-install') args.install = false;
46
- else if (arg === '--preset') args.preset = argv[++i];
47
- else if (arg === '--archetype') args.archetype = argv[++i];
48
- else if (arg === '--tools') args.tools = argv[++i];
55
+ else if (arg === '--preset') args.preset = requireValue(arg, i++);
56
+ else if (arg === '--archetype') args.archetype = requireValue(arg, i++);
57
+ else if (arg === '--tools') args.tools = requireValue(arg, i++);
49
58
  else if (arg === '--help' || arg === '-h' || arg === 'help') args.help = true;
59
+ else if (arg === '--version' || arg === '-V') args.version = true;
50
60
  else if (!arg.startsWith('-') && args.command === undefined) args.command = arg;
61
+ else throw new Error(`Unknown argument: ${arg}. Run ark --help for usage.`);
51
62
  }
52
63
 
53
64
  return args;
@@ -88,6 +99,15 @@ Non-interactive (no TTY): uses the same defaults as --yes — never calls readli
88
99
  `;
89
100
  }
90
101
 
102
+ function cliVersion() {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
105
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
106
+ } catch {
107
+ return 'unknown';
108
+ }
109
+ }
110
+
91
111
  // The package-manager command that adds arkgate as a dev dependency.
92
112
  // Prefer an explicit version/range when pin already chose one (avoid pin=^2.9.0 then
93
113
  // `npm i arkgate@latest` rewriting package.json to a different range).
@@ -627,7 +647,17 @@ async function start(args) {
627
647
  }
628
648
 
629
649
  async function main() {
630
- const args = parseArgs(process.argv);
650
+ let args;
651
+ try {
652
+ args = parseArgs(process.argv);
653
+ } catch (error) {
654
+ console.error(error instanceof Error ? error.message : String(error));
655
+ return 2;
656
+ }
657
+ if (args.version) {
658
+ console.log(cliVersion());
659
+ return 0;
660
+ }
631
661
  if (args.help || !args.command) {
632
662
  console.log(usage());
633
663
  return 0;
@@ -22,7 +22,7 @@ import {
22
22
  createElevenLayerConfig,
23
23
  applyFrameworkLayoutOverlays,
24
24
  } from '../ark-shared.mjs';
25
- import { CORE_LAYER_NAMES } from './core-ratchet.mjs';
25
+ import { CORE_LAYER_NAMES } from './core-layers.mjs';
26
26
  import { falseGreenAdoptionGap } from './field-install.mjs';
27
27
  import {
28
28
  assessCodexHomeMcp,
@@ -184,7 +184,11 @@ export function hasArkWorkflow(root) {
184
184
  .some((file) => {
185
185
  try {
186
186
  const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
187
- return /\bark-check\b/.test(content) || /\bcheck:architecture\b/.test(content);
187
+ return (
188
+ /\bark-check\b/.test(content) ||
189
+ /\bcheck:architecture\b/.test(content) ||
190
+ /\buses\s*:\s*['"]?[^'"\s#]+\/arkgate@/i.test(content)
191
+ );
188
192
  } catch {
189
193
  return false;
190
194
  }
@@ -362,8 +366,8 @@ export function checkArgsForRoot(root, { requireGates = false } = {}) {
362
366
  const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
363
367
  ? ' --baseline .ark-baseline.json'
364
368
  : '';
365
- const gatesFlag = requireGates ? ' --require-gates' : '';
366
- return `--root . --config ark.config.json --strict-config${baselineFlag}${gatesFlag}`;
369
+ const profile = requireGates ? '--strict' : '--strict-config';
370
+ return `--root . --config ark.config.json ${profile}${baselineFlag}`;
367
371
  }
368
372
 
369
373
  // Field-install helpers live in field-install.mjs (keep agent-gates scannable).
@@ -2122,4 +2126,4 @@ export function runInstallAgentGates(args) {
2122
2126
  }
2123
2127
  }
2124
2128
  warnLockfileConflict(root);
2125
- }
2129
+ }
@@ -34,6 +34,7 @@ import {
34
34
  } from './config-warnings.mjs';
35
35
  import { detectCycles } from './graph-cycles.mjs';
36
36
  import { normalize } from './scan-files.mjs';
37
+ import { collectSafetyDiagnostics } from './safety-diagnostics.mjs';
37
38
  import {
38
39
  createCompilerOptionsLookup,
39
40
  createModuleResolutionHost,
@@ -187,6 +188,8 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
187
188
 
188
189
  const violations = [];
189
190
  const warnings = collectConfigWarnings(root, config, files, rules, manifest);
191
+ const safety = collectSafetyDiagnostics(ts, root, config, files);
192
+ warnings.push(...safety.warnings);
190
193
  const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
191
194
  const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
192
195
  const nextCacheFiles = {};
@@ -326,5 +329,5 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
326
329
  }
327
330
  }
328
331
 
329
- return { violations, warnings };
332
+ return { violations, warnings, safety: safety.report };
330
333
  }
@@ -21,3 +21,20 @@ export function baselineKey(violation) {
21
21
  violation.target ?? '',
22
22
  ].join('|');
23
23
  }
24
+ /**
25
+ * Stable per-occurrence keys for a list of violations.
26
+ *
27
+ * The first occurrence keeps the historical v1 key so existing baselines remain
28
+ * compatible. Repeated violations with the same identity gain a `#N` suffix;
29
+ * adding a second identical violation is therefore new debt instead of being
30
+ * silently suppressed by the first occurrence's key.
31
+ */
32
+ export function baselineOccurrenceKeys(violations) {
33
+ const counts = new Map();
34
+ return violations.map((violation) => {
35
+ const base = baselineKey(violation);
36
+ const occurrence = (counts.get(base) ?? 0) + 1;
37
+ counts.set(base, occurrence);
38
+ return occurrence === 1 ? base : `${base}#${occurrence}`;
39
+ });
40
+ }
@@ -55,6 +55,28 @@ export function configWarning(ruleId, message, extra = {}) {
55
55
 
56
56
  export function collectConfigWarnings(root, config, files, rules, manifest) {
57
57
  const warnings = [];
58
+ if (
59
+ config.dynamicImportAllowlist !== undefined &&
60
+ (!Array.isArray(config.dynamicImportAllowlist) ||
61
+ config.dynamicImportAllowlist.some((entry) => typeof entry !== 'string'))
62
+ ) {
63
+ warnings.push(
64
+ configWarning(
65
+ 'CONFIG_INVALID_DYNAMIC_IMPORT_ALLOWLIST',
66
+ 'dynamicImportAllowlist must be an array of file globs.'
67
+ )
68
+ );
69
+ }
70
+ if (config.safety !== undefined && (config.safety === null || typeof config.safety !== 'object' || Array.isArray(config.safety))) {
71
+ warnings.push(configWarning('CONFIG_INVALID_SAFETY', 'safety must be an object.'));
72
+ } else if (config.safety) {
73
+ for (const key of ['maxTsSuppressions', 'maxAnyCasts']) {
74
+ const value = config.safety[key];
75
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
76
+ warnings.push(configWarning('CONFIG_INVALID_SAFETY_THRESHOLD', `safety.${key} must be a non-negative integer.`));
77
+ }
78
+ }
79
+ }
58
80
  const layers = Array.isArray(config.layers) ? config.layers : [];
59
81
  const manifestLayers = Array.isArray(manifest?.architecture?.layers)
60
82
  ? manifest.architecture.layers
@@ -0,0 +1,7 @@
1
+ /** Core layers whose optionality matters once they match files. */
2
+ export const CORE_LAYER_NAMES = new Set([
3
+ 'DomainModel',
4
+ 'ApplicationOrchestration',
5
+ 'PresentationAdapters',
6
+ 'PersistenceAdapters',
7
+ ]);
@@ -6,18 +6,14 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { arkCommand } from '../ark-shared.mjs';
8
8
  import { computeCoverage } from './doctor-plan.mjs';
9
+ import { CORE_LAYER_NAMES } from './core-layers.mjs';
10
+
11
+ export { CORE_LAYER_NAMES } from './core-layers.mjs';
9
12
 
10
13
  /**
11
14
  * Core layers whose optionality matters once they match files (presets share these names).
12
15
  * Used by doctor adoption gaps and `--ratchet-cores`.
13
16
  */
14
- export const CORE_LAYER_NAMES = new Set([
15
- 'DomainModel',
16
- 'ApplicationOrchestration',
17
- 'PresentationAdapters',
18
- 'PersistenceAdapters',
19
- ]);
20
-
21
17
  /**
22
18
  * Plan a ratchet of optional→required for core layers that already match files.
23
19
  * Empty cores stay optional (avoids false ENFORCE theatre). Pure — does not write disk.
@@ -19,7 +19,7 @@ import {
19
19
  staleRunnerGateFiles,
20
20
  } from './agent-gates.mjs';
21
21
  import {
22
- baselineKey,
22
+ baselineOccurrenceKeys,
23
23
  readBaseline,
24
24
  summarizeViolations,
25
25
  violationEdge,
@@ -272,9 +272,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
272
272
  // Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
273
273
  const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
274
274
  const baseline = readBaseline(root, '.ark-baseline.json');
275
- const currentKeys = new Set(violations.map(baselineKey));
275
+ const occurrenceKeys = baselineOccurrenceKeys(violations);
276
+ const currentKeys = new Set(occurrenceKeys);
276
277
  const suppressed = baseline.exists
277
- ? violations.filter((v) => baseline.keys.has(baselineKey(v))).length
278
+ ? occurrenceKeys.filter((key) => baseline.keys.has(key)).length
278
279
  : 0;
279
280
  const staleBaseline = baseline.exists
280
281
  ? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
@@ -347,6 +348,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
347
348
  : { gap: null }),
348
349
  },
349
350
  adoption,
351
+ safety: options.safety,
350
352
  newHere: showNewHere
351
353
  ? {
352
354
  show: true,
@@ -592,11 +594,33 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
592
594
  line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
593
595
  }
594
596
 
597
+ console.log('');
598
+ console.log(color.bold('Safety / bypass resistance'));
599
+ const safety = options.safety;
600
+ if (!safety) {
601
+ line(warn, 'Safety diagnostics unavailable');
602
+ } else {
603
+ const rows = [
604
+ ['Non-literal dynamic imports', safety.nonLiteralDynamicImports],
605
+ ['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
606
+ ['Explicit any casts', safety.anyCasts],
607
+ ['InMemory stores in production source', safety.inMemoryProductionStores],
608
+ ['Rules with peerIsolation: false', safety.disabledPeerIsolationRules],
609
+ ];
610
+ for (const [label, entries] of rows) {
611
+ line(entries.length === 0 ? ok : warn, `${label}: ${entries.length}`);
612
+ }
613
+ if (rows.some(([, entries]) => entries.length > 0)) {
614
+ actions.push('resolve strict safety diagnostics before treating CI as enforcement');
615
+ }
616
+ }
617
+
595
618
  console.log('');
596
619
  if (actions.length === 0) {
597
620
  console.log(color.green('✔ Healthy — nothing to do.'));
598
621
  } else {
599
- console.log(color.bold(`Top actions (${actions.length}):`));
600
- actions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
622
+ const uniqueActions = [...new Set(actions.filter(Boolean))];
623
+ console.log(color.bold(`Top actions (${uniqueActions.length}):`));
624
+ uniqueActions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
601
625
  }
602
626
  }