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 +75 -0
- package/README.md +4 -3
- package/bin/ark-check.mjs +3 -1
- package/bin/ark-mcp.mjs +10 -0
- package/bin/lib/adapter-contract.mjs +3 -0
- package/bin/lib/ambient-state.mjs +221 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +31 -0
- package/bin/lib/config-contract.mjs +25 -0
- package/bin/lib/contract-smells.mjs +154 -18
- package/bin/lib/doctor-advisories.mjs +20 -0
- package/bin/lib/doctor-plan.mjs +5 -5
- package/bin/lib/html-report-advisories.mjs +149 -0
- package/bin/lib/html-report.mjs +6 -0
- package/bin/lib/remediation.mjs +14 -0
- package/bin/lib/ts-resolve.mjs +2 -1
- package/bin/lib/violations.mjs +2 -0
- package/dist/{configTypes-CVWWhBoe.d.cts → configTypes-DAPvBqK6.d.cts} +7 -0
- package/dist/{configTypes-CVWWhBoe.d.ts → configTypes-DAPvBqK6.d.ts} +7 -0
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.cts +3 -2
- package/dist/eslint/index.d.ts +3 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +7 -7
- package/dist/index.d.cts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +6 -6
- package/docs/agent-guide.md +21 -5
- package/docs/configuration.md +3 -0
- package/docs/package-surface.md +7 -3
- package/package.json +3 -2
- package/schemas/ark.config.schema.json +25 -0
- package/server.json +2 -2
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.
|
|
20
|
-
>
|
|
21
|
-
>
|
|
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
|
+
}
|