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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -2
- package/SECURITY.md +3 -4
- package/bin/ark-check.mjs +41 -16
- package/bin/ark-mcp.mjs +54 -10
- package/bin/ark.mjs +35 -5
- package/bin/lib/agent-gates.mjs +9 -5
- package/bin/lib/architecture-scan.mjs +4 -1
- package/bin/lib/baseline-key.mjs +17 -0
- package/bin/lib/config-warnings.mjs +22 -0
- package/bin/lib/core-layers.mjs +7 -0
- package/bin/lib/core-ratchet.mjs +3 -7
- package/bin/lib/doctor-plan.mjs +29 -5
- package/bin/lib/safety-diagnostics.mjs +263 -0
- package/bin/lib/scan-files.mjs +51 -6
- package/bin/lib/violations.mjs +3 -3
- package/dist/index.cjs +103 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +103 -8
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +18 -5
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +18 -5
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +103 -8
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +103 -8
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
- package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
- package/docs/agent-guide.md +1 -1
- package/docs/ai-gates.md +20 -2
- package/docs/package-surface.md +2 -2
- package/docs/production-hardening.md +5 -0
- package/package.json +5 -2
- 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
|
|
228
|
-
# or: uses: pedroknigge/arkgate
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
178
|
-
else if (arg === '--config') args.config =
|
|
179
|
-
else if (arg === '--manifest') args.manifest =
|
|
180
|
-
else if (arg === '--print-config') args.printConfig =
|
|
181
|
-
else if (arg === '--tsconfig') args.tsconfig =
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
|
277
|
+
let existingCounts = new Map();
|
|
277
278
|
try {
|
|
278
279
|
const current = fs.readFileSync(filePath, 'utf8');
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
47
|
-
else if (arg === '--archetype') args.archetype =
|
|
48
|
-
else if (arg === '--tools') args.tools =
|
|
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
|
-
|
|
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;
|
package/bin/lib/agent-gates.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
createElevenLayerConfig,
|
|
23
23
|
applyFrameworkLayoutOverlays,
|
|
24
24
|
} from '../ark-shared.mjs';
|
|
25
|
-
import { CORE_LAYER_NAMES } from './core-
|
|
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
|
|
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
|
|
366
|
-
return `--root . --config ark.config.json
|
|
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
|
}
|
package/bin/lib/baseline-key.mjs
CHANGED
|
@@ -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
|
package/bin/lib/core-ratchet.mjs
CHANGED
|
@@ -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.
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
staleRunnerGateFiles,
|
|
20
20
|
} from './agent-gates.mjs';
|
|
21
21
|
import {
|
|
22
|
-
|
|
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
|
|
275
|
+
const occurrenceKeys = baselineOccurrenceKeys(violations);
|
|
276
|
+
const currentKeys = new Set(occurrenceKeys);
|
|
276
277
|
const suppressed = baseline.exists
|
|
277
|
-
?
|
|
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
|
-
|
|
600
|
-
|
|
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
|
}
|