arkgate 3.3.0 → 3.5.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 CHANGED
@@ -4,6 +4,81 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.5.0 — 2026-07-16
8
+
9
+ Field-feedback release (Phase X, from the amarilla adoption session): the HTML report reaches
10
+ parity with the doctor and stays there by an executable rule, contract-smell acknowledgments gain
11
+ a lifecycle so migration acks cannot fossilize, and the lateral-adapter smell stops firing on a
12
+ family's own infrastructure base. Everything remains **advisory** — no verdict, `designFitness`,
13
+ or gate behavior changes. **No breaking** CLI or `ark.config.json` changes. **No gate weaken.**
14
+
15
+ ### Added
16
+
17
+ - **Report parity (X01):** `ark-check --report` now renders every doctor advisory — contract
18
+ health (smells with evidence/fix, acknowledgment honesty, invalid-sidecar warning), governance
19
+ weight, ambient state (idle/clean/findings with honest overflow), and capability-wall badges
20
+ (`pure` / `walls: …`) in the layers table. The rule is **executable**: `reportParity.test.ts`
21
+ enumerates the advisory keys `computeDoctorAdvisories` returns and fails CI when any key lacks
22
+ a `data-advisory` section — the report can never silently fall behind the product again.
23
+ - **Acknowledgment lifecycle (X02):** a contract-smell ack may carry an optional `reviewBy`
24
+ (`YYYY-MM-DD`, strict round-trip validation). Past that date the ack **stops applying** and the
25
+ smell returns with `(ack expired …)` annotated evidence; a re-ack with a fresh date wins over a
26
+ dead entry, and once any dated ack exists for an edge the dated entries govern — a leftover
27
+ undated duplicate cannot resurrect an expired exception.
28
+ Undated acks keep applying (backward compatible) but are counted and surfaced —
29
+ doctor line, report note — even when every smell is suppressed. Malformed dates never apply
30
+ (fail-loud, like a sloppy edge); non-string `reviewBy` invalidates the file. Doctor JSON gains
31
+ `contractHealth.ackLifecycle` (`{ undated, malformed, expiredCount, expired[] }`).
32
+
33
+ ### Changed
34
+
35
+ - **Lateral-adapter smell (X03):** `contract-lateral-adapter-allow` no longer fires when an
36
+ adapter layer reaches its **own family's infra base** (same leading name token and every
37
+ remaining target token an infra word — `Infra`/`Base`/`Core`/`Shared`/`Common`/`Kernel`/
38
+ `Platform`/`Foundation` — e.g. `PaymentsAdapters -> PaymentsInfra`; `PaymentsCoreAdapters` is
39
+ still a sibling). Cross-family edges, same-family non-infra siblings, and the reverse
40
+ direction (base → member) still fire.
41
+
42
+ ## 3.4.0 — 2026-07-16
43
+
44
+ Understandable execution, second slice (Phase U: U04–U07): the capability evidence shipped in
45
+ 3.3 becomes **opt-in enforcement** across every adapter, plus the advisory ambient-state sensor
46
+ and the measured pre-tool path. Everything remains opt-in — a config without `capabilities` /
47
+ `pure` keys behaves exactly as before. **No breaking** CLI or `ark.config.json` changes.
48
+ **No gate weaken.**
49
+
50
+ ### Added
51
+
52
+ - **Capability walls (U04):** a layer may declare `capabilities: { deny: [...] }` (seven-id enum
53
+ in the versioned schema) or the casual shorthand `pure: true` (denies all seven). Enforcement
54
+ is judgment-class `CAPABILITY_VIOLATION` — never mechanical-safe, never auto-patched — with a
55
+ port-injection `nextAction`, across the CLI scan (ambient + import evidence), the pure IR
56
+ engine and atomic preflight (a multi-file batch cannot hide a denied capability), the real
57
+ PreToolUse hook and MCP gate (`capabilityWalls`), and ESLint
58
+ (`ark/no-denied-capabilities`, import dimension, in the recommended config). One violation,
59
+ one voice: an ambient use covered by the layer's `forbiddenGlobals` reports only
60
+ `FORBIDDEN_GLOBAL`.
61
+ - **Coverage-atom policy delta (U04/D6):** T01 classifies the ambient/wall surface on coverage
62
+ atoms (`ambient:<entry>` prefix-expanded + `import:<capability>`): any lost atom is weakening
63
+ (`fetch`→`XMLHttpRequest`, `Date`→`Date.now`, wall→`forbiddenGlobals` all require the
64
+ hash-bound acknowledgment); migrating `forbiddenGlobals` to an equivalent-or-stronger wall
65
+ never needs one.
66
+ - **Ambient-state sensor (U05, advisory + opt-in):** `doctor.ambientState` flags module-scope
67
+ `let`/`var` in `pure: true` layers only, with bounded sidecar acknowledgments at
68
+ `.ark/ambient-state-acks.json`. `declare` ambients and `using` bindings never count; skipped
69
+ oversized files are reported. No strict mode exists.
70
+ - **Measured pre-tool path (U06):** `npm run bench:hook-path` measures the complete
71
+ hook/doctor child-process paths; `eval/performance/hook-budgets.v1.json` locks the D5 method
72
+ (Linux baseline first, ceilings = baseline + fixed headroom, recording mode until then); CI
73
+ runs the bench. Dual-depth remediation everywhere: plain port hints for casual users, stable
74
+ `ruleId`/`capability`/`fixClass`/`nextAction` JSON for tooling.
75
+
76
+ ### Fixed
77
+
78
+ - The scan cache is version-bumped (v8) so a warm cache from an older ArkGate cannot miss wall
79
+ verdicts; template-literal text and `require()` handling in the pure scanner are
80
+ capability-correct (templates skipped; require counts as evidence, never as a graph edge).
81
+
7
82
  ## 3.3.0 — 2026-07-16
8
83
 
9
84
  Understandable execution, first slice (Phase U: U01–U03): typed effect capabilities as
package/README.md CHANGED
@@ -16,9 +16,10 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 3.3.0** is current stable: typed effect-capability evidence (seven closed ids,
20
- > evidence-only until walls opt in), on top of 3.2's contract health and 3.1's change-integrity
21
- > loop. [Release notes](docs/releases/3.3.0.md).
19
+ > **ArkGate 3.4.0** is current stable: opt-in capability walls (`pure: true` or
20
+ > `capabilities.deny` per layer, enforced on every adapter), the advisory ambient-state sensor,
21
+ > and the measured pre-tool path — completing 3.3's evidence slice.
22
+ > [Release notes](docs/releases/3.4.0.md).
22
23
 
23
24
  ---
24
25
 
package/bin/ark-check.mjs CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  reportsDir,
60
60
  readJsonSafe,
61
61
  } from './lib/html-report.mjs';
62
+ import { computeDoctorAdvisories } from './lib/doctor-advisories.mjs';
62
63
  import { buildReportDepthPayload } from './lib/html-report-depth.mjs';
63
64
  import { shouldOpenHtmlReport, openHtmlInBrowser } from './lib/open-html.mjs';
64
65
  import {
@@ -101,7 +102,6 @@ import { runArchitectureScan } from './lib/architecture-scan.mjs';
101
102
  import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
102
103
  import { analyzePolicyTransition } from './lib/policy-delta-io.mjs';
103
104
 
104
-
105
105
  function parseArgs(argv) {
106
106
  const args = {
107
107
  root: process.cwd(),
@@ -1148,6 +1148,7 @@ async function main() {
1148
1148
  configPath: path.isAbsolute(args.config) ? args.config : path.join(root, args.config),
1149
1149
  configMissing: !fs.existsSync(path.isAbsolute(args.config) ? args.config : path.join(root, args.config)),
1150
1150
  safety,
1151
+ ts,
1151
1152
  });
1152
1153
  return;
1153
1154
  }
@@ -1319,6 +1320,7 @@ async function main() {
1319
1320
  originJustCreated: !existingOrigin,
1320
1321
  adoption: adoptionForReport,
1321
1322
  designDepth,
1323
+ advisories: computeDoctorAdvisories(root, config, coverage, rules, files, ts),
1322
1324
  };
1323
1325
  const html = args.beginner
1324
1326
  ? renderBeginnerHtmlReport(reportPayload)
package/bin/ark-mcp.mjs CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  detectTsPackageRoots,
59
59
  resolveIncludeRoots,
60
60
  } from './ark-shared.mjs';
61
+ import { effectiveCapabilityDeny } from './lib/analysis-engine.mjs';
61
62
  import { createImportTargetResolver } from './lib/import-resolve.mjs';
62
63
  import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
63
64
  import { composePrepareWrite } from './lib/prepare-write.mjs';
@@ -765,6 +766,14 @@ async function main() {
765
766
  ])
766
767
  );
767
768
 
769
+ // Layer → effective capability deny set (U04 walls). Same opt-in surface the
770
+ // CLI enforces; the gate applies it whenever the target file's layer is known.
771
+ const capabilityWalls = Object.fromEntries(
772
+ configLayers
773
+ .map((layer) => [layer.name, effectiveCapabilityDeny(layer)])
774
+ .filter(([name, deny]) => name && deny.length > 0)
775
+ );
776
+
768
777
  // Layers explicitly flagged as infrastructure in ark.config.json may import
769
778
  // infrastructure — the built-in infra-import heuristics skip them (in addition
770
779
  // to layers whose name conventionally signals an infra role). Lets a project
@@ -779,6 +788,7 @@ async function main() {
779
788
  enforceIntentAllowlist: intents.length > 0,
780
789
  typescript: ts,
781
790
  forbiddenGlobals,
791
+ capabilityWalls,
782
792
  infrastructureLayers,
783
793
  // Contract-first: one resolve step yields layer + relPath for rules + peerIsolation.
784
794
  resolveImportTarget: createImportTargetResolver(ts, args.root, config),
@@ -30,6 +30,9 @@ function nextActionForDiagnostic(ruleId, evidence, violation) {
30
30
  if (ruleId === 'FORBIDDEN_GLOBAL') {
31
31
  return `Inject ${evidence.target ?? 'the capability'} through a port, then preflight again.`;
32
32
  }
33
+ if (ruleId === 'CAPABILITY_VIOLATION') {
34
+ return `Define a ${text(violation.capability) ?? 'capability'} port in ${evidence.fromLayer ?? 'the walled layer'}, bind the implementation outside it, then preflight again.`;
35
+ }
33
36
  if (ruleId === 'CIRCULAR_DEPENDENCY') {
34
37
  return 'Extract the shared dependency into a third module, then preflight again.';
35
38
  }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * U05 — ambient mutable-state sensor (ADR 0009 D4 / A5).
3
+ *
4
+ * Advisory and OPT-IN: only layers declared `pure: true` are scanned; the MVP
5
+ * shape is module-scope `let`/`var`. Legitimate registries/caches are
6
+ * acknowledged in a bounded `.ark/` sidecar (W01 precedent) — a malformed file
7
+ * suppresses nothing. Doctor-only: no strict default may be introduced from
8
+ * this sensor until the fixed corpus proves blocker-grade precision (A5).
9
+ *
10
+ * Documented envelope: only top-level statements are walked — `let` inside a
11
+ * `namespace` body (real runtime state on the namespace object) is out of the
12
+ * MVP shape; `declare` ambients and `using` bindings never count (no state /
13
+ * not reassignable).
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { layerForFile } from '../ark-shared.mjs';
18
+
19
+ export const AMBIENT_STATE_ACKS_PATH = '.ark/ambient-state-acks.json';
20
+
21
+ const MAX_ACK_BYTES = 64 * 1024;
22
+ const MAX_ACK_ENTRIES = 200;
23
+ const MAX_FILE_BYTES = 256 * 1024;
24
+ const MAX_FINDINGS = 50;
25
+
26
+ /** Bounded, fail-loud sidecar loader (same discipline as contract-smell acks). */
27
+ export function loadAmbientStateAcks(root) {
28
+ const relPath = AMBIENT_STATE_ACKS_PATH;
29
+ const abs = path.join(root, relPath);
30
+ let stats;
31
+ try {
32
+ stats = fs.statSync(abs);
33
+ } catch {
34
+ return { path: relPath, exists: false, acks: [] };
35
+ }
36
+ const invalid = (error) => ({ path: relPath, exists: true, invalid: true, error, acks: [] });
37
+ if (!stats.isFile()) return invalid('not a regular file');
38
+ if (stats.size > MAX_ACK_BYTES) return invalid(`larger than ${MAX_ACK_BYTES} bytes`);
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
42
+ } catch (error) {
43
+ return invalid(error instanceof Error ? error.message : 'unreadable JSON');
44
+ }
45
+ const acks = Array.isArray(parsed?.acks) ? parsed.acks : null;
46
+ if (!acks) return invalid('expected { acks: [{ file, name, reason? }] }');
47
+ if (acks.length > MAX_ACK_ENTRIES) return invalid(`more than ${MAX_ACK_ENTRIES} entries`);
48
+ const wellFormed = acks.every(
49
+ (a) =>
50
+ a !== null &&
51
+ typeof a === 'object' &&
52
+ typeof a.file === 'string' &&
53
+ a.file.length > 0 &&
54
+ typeof a.name === 'string' &&
55
+ a.name.length > 0
56
+ );
57
+ if (!wellFormed) return invalid('every ack needs string file and name');
58
+ // Normalize separators so a Windows-authored ack file still matches.
59
+ const normalized = acks.map((a) => ({ ...a, file: a.file.replace(/\\/g, '/') }));
60
+ return { path: relPath, exists: true, acks: normalized };
61
+ }
62
+
63
+ function isAcknowledged(ackState, file, name) {
64
+ if (!ackState || ackState.invalid || !Array.isArray(ackState.acks)) return false;
65
+ return ackState.acks.some((a) => a.file === file && a.name === name);
66
+ }
67
+
68
+ function bindingIdentifiers(ts, name, out) {
69
+ if (ts.isIdentifier(name)) {
70
+ out.push(name.text);
71
+ return;
72
+ }
73
+ if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
74
+ for (const element of name.elements) {
75
+ if (element && !ts.isOmittedExpression(element) && element.name) {
76
+ bindingIdentifiers(ts, element.name, out);
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Detect module-scope mutable state in `pure: true` layers.
84
+ *
85
+ * @returns {{ active: boolean, findings: Array<{file: string, line: number, name: string, kind: 'module-let'|'module-var'}>, acknowledgedCount: number, truncated: number }}
86
+ */
87
+ export function detectAmbientState(ts, root, config, files, ackState = { exists: false, acks: [] }) {
88
+ const layers = Array.isArray(config?.layers) ? config.layers : [];
89
+ const pureLayers = new Set(
90
+ layers.filter((layer) => layer?.pure === true).map((layer) => layer.name)
91
+ );
92
+ if (pureLayers.size === 0) return { active: false, findings: [], acknowledgedCount: 0, truncated: 0 };
93
+
94
+ const resolvedRoot = path.resolve(root);
95
+ const findings = [];
96
+ const acknowledgedPairs = new Set();
97
+ let skippedFiles = 0;
98
+ for (const file of files) {
99
+ const layer = layerForFile(root, file, layers);
100
+ if (!layer || !pureLayers.has(layer)) continue;
101
+ const rel = path.relative(resolvedRoot, path.resolve(file)).split(path.sep).join('/');
102
+ let source;
103
+ try {
104
+ const stats = fs.statSync(file);
105
+ if (!stats.isFile() || stats.size === 0) continue;
106
+ if (stats.size > MAX_FILE_BYTES) {
107
+ skippedFiles += 1;
108
+ continue;
109
+ }
110
+ source = fs.readFileSync(file, 'utf8');
111
+ } catch {
112
+ continue;
113
+ }
114
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
115
+ for (const statement of sourceFile.statements) {
116
+ if (!ts.isVariableStatement(statement)) continue;
117
+ // `declare` ambients allocate no runtime state.
118
+ if (
119
+ statement.modifiers?.some(
120
+ (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword
121
+ )
122
+ ) {
123
+ continue;
124
+ }
125
+ const flags = statement.declarationList.flags;
126
+ // const never reassigns; `using`/`await using` bindings are not reassignable.
127
+ const immutableFlags =
128
+ ts.NodeFlags.Const | (ts.NodeFlags.Using ?? 0) | (ts.NodeFlags.AwaitUsing ?? 0);
129
+ if ((flags & immutableFlags) !== 0) continue;
130
+ const kind = (flags & ts.NodeFlags.Let) !== 0 ? 'module-let' : 'module-var';
131
+ for (const declaration of statement.declarationList.declarations) {
132
+ const names = [];
133
+ bindingIdentifiers(ts, declaration.name, names);
134
+ for (const name of names) {
135
+ if (isAcknowledged(ackState, rel, name)) {
136
+ acknowledgedPairs.add(`${rel}\u0000${name}`);
137
+ continue;
138
+ }
139
+ const line =
140
+ sourceFile.getLineAndCharacterOfPosition(declaration.getStart(sourceFile)).line + 1;
141
+ findings.push({ file: rel, line, name, kind });
142
+ }
143
+ }
144
+ }
145
+ }
146
+ const acknowledgedCount = acknowledgedPairs.size;
147
+ findings.sort(
148
+ (left, right) =>
149
+ left.file.localeCompare(right.file) ||
150
+ left.line - right.line ||
151
+ left.name.localeCompare(right.name)
152
+ );
153
+ const truncated = Math.max(0, findings.length - MAX_FINDINGS);
154
+ return {
155
+ active: true,
156
+ findings: findings.slice(0, MAX_FINDINGS),
157
+ acknowledgedCount,
158
+ truncated,
159
+ skippedFiles,
160
+ };
161
+ }
162
+
163
+ /** JSON summary for doctor. Advisory only — never a verdict input. */
164
+ export function summarizeAmbientState(result, ackState = { exists: false, acks: [] }) {
165
+ return {
166
+ available: true,
167
+ active: result.active,
168
+ advisory: true,
169
+ findingCount: result.findings.length,
170
+ acknowledged: ackState?.invalid ? 0 : result.acknowledgedCount,
171
+ ...(result.truncated > 0 ? { truncated: result.truncated } : {}),
172
+ ...(result.skippedFiles > 0 ? { skippedFiles: result.skippedFiles } : {}),
173
+ note: result.active
174
+ ? 'Module-scope mutable state in pure layers — advisory only; acknowledge deliberate registries in the sidecar or move the state behind a port.'
175
+ : 'No pure: true layer opted in; the sensor is idle.',
176
+ ackFile: {
177
+ path: ackState?.path ?? AMBIENT_STATE_ACKS_PATH,
178
+ present: ackState?.exists === true,
179
+ invalid: ackState?.invalid === true,
180
+ ...(ackState?.invalid ? { error: ackState.error ?? 'invalid' } : {}),
181
+ },
182
+ };
183
+ }
184
+
185
+ /** One-call compute for doctor; `ts` may be absent (report unavailable honestly). */
186
+ export function computeAmbientState(ts, root, config, files) {
187
+ if (!ts) {
188
+ return {
189
+ available: false,
190
+ active: false,
191
+ advisory: true,
192
+ findings: [],
193
+ findingCount: 0,
194
+ acknowledged: 0,
195
+ note: 'TypeScript was not available to the doctor run; the ambient-state sensor did not execute.',
196
+ };
197
+ }
198
+ const ackState = loadAmbientStateAcks(root);
199
+ const result = detectAmbientState(ts, root, config, files, ackState);
200
+ return { ...summarizeAmbientState(result, ackState), findings: result.findings };
201
+ }
202
+
203
+ /** Human doctor section (advisory); silent when idle and healthy. */
204
+ export function printAmbientStateSection(state, io) {
205
+ if (!state.available || (!state.findingCount && !state.ackFile?.invalid)) return;
206
+ console.log('');
207
+ console.log(io.color.bold('Ambient state (advisory)'));
208
+ if (state.ackFile?.invalid) {
209
+ io.line(io.warn, `${state.ackFile.path} is present but invalid — acknowledgments are ignored.`);
210
+ }
211
+ for (const finding of state.findings.slice(0, 5)) {
212
+ io.line(io.warn, `[${finding.kind}] ${finding.file}:${finding.line} — \`${finding.name}\``);
213
+ }
214
+ if (state.findingCount > 5) {
215
+ io.line(' ', io.color.dim(`…(+${state.findingCount - 5} more in doctor JSON)`));
216
+ }
217
+ if (state.acknowledged > 0) {
218
+ io.line(' ', io.color.dim(`acknowledged module state: ${state.acknowledged}`));
219
+ }
220
+ io.line(' ', io.color.dim('advisory only — never blocks; move state behind a port or acknowledge it'));
221
+ }