Skip to content

Commit 1b401d5

Browse files
committed
test: close wire-surface overclaim and make the closure fail closed (#1432)
Addresses both review P1s on #1717. P1 — the manifest materially overclaimed ADR 0006 coverage. It quoted all four bullets while digesting only the payload TYPES, so the producer and consumer seams could break a skewed peer without moving a listed digest. Now listed on both sides of every boundary: JSON-RPC method sets and the projections that turn each method's params into a DaemonRequest, createRpcError/sendJson/ writeRpcResponseEnvelope, resolveToken and the auth-hook types, upload preflight/finalize/308 handlers and the resumable ticket shape, artifact route and download/inventory framing, REST error mapping, and the client's own payload builder, lease-method mapping, response parser and error projection. 57 -> 117 declarations. What stays out is now named rather than implied: createDaemonHttpServer's dispatch wiring and the /health and /rpc literals inside it. Everything it dispatches WITH is digested individually, and a moved route 404s at connect time rather than misparsing — the loud failure, not the silent one. P1 — imported and re-exported payload shapes escaped the closure. declarationHomes() scanned only the manifest's own files and the walk continued silently when a name could not be placed, so a listed type could gain foo?: ImportedShape from a new module and stay green. Resolution is now explicit and fails closed: relative imports, workspace specifiers (through the owning package's own exports map, so a re-pointed export cannot drop a type), and facade re-export chains. Every referenced name must land on a listed declaration, a waiver with a written reason, a declared external module, or the TS/Node global set. Fixed two extractor blind spots the walk exposed: a declaration's own generic parameters and `as const` were being reported as references. Planted-red proofs (wire-mutations.test.ts): 13 cases independently mutate method naming, response serialization, response parsing, auth projection, upload ticket shape, 308 framing, artifact framing, REST error mapping, and progress framing, each asserting the digest moves; 3 probes prove the closure really reaches across a package boundary, a facade re-export, and a plain relative import. Mutations apply inside the declaration's own span — a whole-file replace silently hit a sibling sharing the substring, which is how the first draft of one case passed vacuously. The largest waiver pair (InternalRequestOptions, CommandFlags) rests on ADR 0006's own additive rule: they reach the peer inside DaemonRequest's untyped flags/input bags, and the decision says a new flag needs no bump. Digesting them would fire the gate on every new CLI flag and train reviewers to rubber-stamp acks.
1 parent ac1e121 commit 1b401d5

10 files changed

Lines changed: 961 additions & 103 deletions

test/wire-compat/README.md

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,26 @@ interoperate" — `agent-device proxy`, cloud/limrun providers, and a remote mac
2323
skewed on purpose, and package version is explicitly *not* the compatibility gate there. The one
2424
place version skew is designed in was the one place with no gate.
2525

26-
## The two gates
26+
## The three gates
2727

2828
| | Runs | Answers |
2929
| --- | --- | --- |
30-
| `wire-compat.test.ts` (`unit-core`) | every PR, offline, shallow-safe | does the ledger still match the source it describes? |
30+
| `wire-compat.test.ts` (`unit-core`) | every PR, offline, shallow-safe | does the ledger still match the source it describes, and is the manifest closed? |
31+
| `wire-mutations.test.ts` (`unit-core`) | every PR | does the gate actually **catch** each class of wire break? |
3132
| `pnpm check:daemon-wire-compat` (`Released-Surface Compatibility`) | own `fetch-depth: 0` job | did the drift **since the last released tag** come with a bump or an ack? |
3233

34+
The mutation lane exists because the first two answer "the manifest describes
35+
today's source", never "the manifest describes the *right* source". A gate can
36+
list 117 declarations, pass every check, and still miss the seam that breaks a
37+
skewed peer — which is exactly what review found in the first version of this
38+
directory. Each case there mutates one real listed declaration the way a real
39+
break would (method renamed, envelope unframed, auth header dropped, upload
40+
ticket field renamed, 308 downgraded, artifact route narrowed) and asserts the
41+
digest moves. Two guards keep them honest: the mutation is applied **inside the
42+
declaration's own span** so it cannot silently hit a sibling that shares the
43+
substring, and the unmutated digest must equal the ledger's so the case is
44+
pinned to the declaration the gate really watches.
45+
3346
The split is forced, not stylistic. From a single commit a bumped ledger and an unbumped one are
3447
both just an edited file, so only a baseline read out of git can tell them apart — the same reason
3548
the replay-compat corpus splits its provenance verifier out of the unit lane.
@@ -75,20 +88,46 @@ bullet is only partly digestible the group carries an `uncovered` note saying wh
7588
reviewer-owned and why — a gate that implies coverage it does not provide is worse than one that
7689
admits the gap (AGENTS.md, "a registry claim is not a semantic check").
7790

78-
The one such gap today: the `/health` and `/rpc` path literals sit inside `http-server.ts` request
79-
handlers, whose bodies churn for reasons that are not protocol changes. They stay reviewer-owned
80-
because their failure mode is the loud one — a moved route 404s at connect time, before any payload
81-
is exchanged. Everything digested here can misparse *silently*, which is the whole point.
91+
**Both sides of every boundary are listed.** The first version of this directory digested only the
92+
payload *types* while quoting all four bullets, which review correctly called an overclaim: method
93+
sets, response serialization, auth projection, upload ticket and 308 framing, artifact framing, and
94+
the client's own parsers could all break a skewed peer without moving a listed digest. A
95+
client-only change breaks an older daemon just as surely as the reverse, so producer and consumer
96+
seams are both here.
97+
98+
The one remaining gap: `createDaemonHttpServer`, the 200-line dispatcher, and the `/health` and
99+
`/rpc` path literals inside it. Everything it dispatches *with* is digested individually, so what is
100+
uncovered is the wiring — and its failure mode is the loud one, a 404 at connect time before any
101+
payload is exchanged. Everything digested here can misparse *silently*, which is the whole point.
82102

83103
Digests ignore comments and formatting, so reflowing a type or rewriting the prose above a field
84104
does not move them; only the declaration's tokens do.
85105

86-
## Adding to the surface
106+
## The closure, and why it fails closed
107+
108+
`wire-compat.test.ts` walks each listed declaration's AST and resolves every type name it
109+
references — through relative imports, workspace specifiers (via the owning package's own `exports`
110+
map), and façade re-export chains. Every name must land somewhere someone wrote down:
87111

88-
The manifest's closure is checked, not asserted: `wire-compat.test.ts` walks each digested
89-
declaration's AST and fails if it references a type declared in a manifest file that the manifest
90-
itself omits. So adding `foo?: NewShape` to a wire type tells you to list `NewShape` rather than
91-
letting the field that decides what the peer parses sit outside the gate.
112+
- a **listed** declaration, or
113+
- a **waiver** in `closure-policy.ts` — a repo declaration that is genuinely not payload, with the
114+
reason it cannot change what a peer parses, or
115+
- a **declared external module** (`node:http`, `undici`) with no declaration site here, or
116+
- the TypeScript/Node global set.
117+
118+
Anything else fails. The earlier version skipped names it could not place, which made the whole
119+
claim hollow: a listed type could grow `foo?: ImportedShape` from an unlisted module and stay green.
120+
Three probes in `wire-mutations.test.ts` prove the walk really reaches across each boundary form —
121+
drop a listed declaration from the claimed set and the closure must report it.
122+
123+
Challenge the waivers first when reviewing this directory; they are where coverage is traded away.
124+
The largest pair (`InternalRequestOptions`, `CommandFlags`) rests on ADR 0006's own additive rule:
125+
those reach the peer inside `DaemonRequest`'s untyped `flags`/`input` bags, and the decision says a
126+
new flag needs no bump. If a flag ever becomes a typed field, list the type instead of widening the
127+
waiver.
128+
129+
## Adding to the surface
92130

93-
To add a declaration by hand, put it in the group whose ADR 0006 bullet it serves and paste its
94-
digest from the failure message.
131+
Put the declaration in the group whose ADR 0006 bullet it serves and paste its digest from the
132+
failure message. If it introduces a new break class, add a case to `wire-mutations.test.ts` — a
133+
listed declaration with no proof that mutating it fails is a claim, not a gate.

test/wire-compat/closure-policy.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* What the wire-surface closure is allowed to stop at (#1432, review P1).
3+
*
4+
* The closure walk fails closed: every type name a listed declaration
5+
* references must resolve to a listed declaration, or to an entry here. There
6+
* is no silent skip, because the silent skip WAS the defect — a listed type
7+
* could grow `foo?: ImportedShape` from an unlisted module and stay green.
8+
*
9+
* Two kinds of stop, kept apart because they are different claims:
10+
*
11+
* - an EXTERNAL MODULE is outside this repository, so there is no declaration
12+
* to digest and no way for a change to it to ship in our tarball;
13+
* - a WAIVER is a repo declaration the walk reaches that is genuinely not
14+
* payload — transport plumbing, a local error class, a callback type. Each
15+
* one is a claim someone has to defend in review, which is the point.
16+
*
17+
* A waiver stops the walk. If a waived type later starts carrying payload,
18+
* moving it into `surface.ts` is the fix, not widening this file.
19+
*/
20+
21+
/**
22+
* Module specifiers whose types have no declaration site in this repo. Prefix
23+
* match, so `node:http` covers `node:http2` only if spelled out — keep them
24+
* exact enough to mean something.
25+
*/
26+
const WIRE_EXTERNAL_MODULES: Readonly<Record<string, string>> = {
27+
'node:http': 'Node HTTP request/response objects are the transport, not the payload on it.',
28+
'node:stream': 'Stream plumbing carries bytes; the bytes themselves are digested elsewhere.',
29+
undici: 'HTTP client types belong to the transport library, not to our wire contract.',
30+
};
31+
32+
/**
33+
* Repo declarations the closure reaches that are deliberately not wire
34+
* surface. Key is `<file>#<name>`; the value has to say why a change to it
35+
* cannot change what a skewed peer parses.
36+
*/
37+
export const WIRE_CLOSURE_WAIVERS: Readonly<Record<string, string>> = {
38+
'packages/kernel/src/errors.ts#AppError':
39+
'The local error CLASS. Only its projection onto the wire is contractual, and that projection is DaemonError, which is listed.',
40+
'packages/kernel/src/errors.ts#AppErrorCode':
41+
'Error-code vocabulary is carried as a plain string in DaemonError.code; adding a code cannot change the envelope a peer parses.',
42+
'packages/kernel/src/errors.ts#AppErrorDetails':
43+
'Local detail bag behind AppError; the wire form is DaemonError.details, typed as Record<string, unknown> and listed.',
44+
'src/daemon/client/daemon-client-metadata.ts#DaemonInfo':
45+
'Client-side record of where a daemon is listening (pid, ports, state dir). Never serialized into a request or response.',
46+
'src/daemon/types.ts#DaemonRequest':
47+
'Re-export alias of the listed kernel DaemonRequest; the declaration that fixes the shape is packages/kernel/src/contracts.ts#DaemonRequest.',
48+
'src/daemon/types.ts#DaemonResponse':
49+
'Re-export alias of the listed kernel DaemonResponse; the shape is fixed by packages/kernel/src/contracts.ts#DaemonResponse.',
50+
'packages/contracts/src/request-progress.ts#RequestProgressSink':
51+
'A callback type (event) => void. The event it receives is RequestProgressEvent, which is listed; the sink itself never crosses the wire.',
52+
53+
// The CLI flag/option vocabulary embedded in DaemonCommandRequest. This is
54+
// the one waiver pair that stops a large subtree, so it is the one to
55+
// challenge first — the reasoning is ADR 0006's own, not convenience.
56+
//
57+
// These types are the CLI-SIDE projection. They do not cross the boundary as
58+
// typed fields: DaemonRequest carries `input?: Record<string, unknown>` and
59+
// `flags?: Record<string, unknown>`, both listed, and the flag vocabulary
60+
// lands inside those bags as plain keys. ADR 0006 then classifies a new flag
61+
// as additive ("new optional request fields or flags that older daemons can
62+
// ignore or reject with a normal command error"), explicitly NOT a bump. So
63+
// digesting them would fire the gate on every new CLI flag for a change the
64+
// decision says needs no acknowledgment — and a gate that cries wolf gets
65+
// its acks rubber-stamped, which costs more than the coverage buys.
66+
//
67+
// If a flag ever becomes a typed field on DaemonRequest, that changes
68+
// DaemonRequest's own digest, and the right fix is to list the type here
69+
// rather than widen this waiver.
70+
'packages/contracts/src/client-request.ts#InternalRequestOptions':
71+
'CLI-side option projection; reaches the peer inside DaemonRequest.input/flags (Record<string, unknown>, both listed), and ADR 0006 calls new flags additive.',
72+
'packages/contracts/src/command-flags.ts#CommandFlags':
73+
'CLI-side flag vocabulary; reaches the peer inside DaemonRequest.flags (Record<string, unknown>, listed), and ADR 0006 calls new flags additive.',
74+
};
75+
76+
export function isExternalWireSpecifier(specifier: string): boolean {
77+
return Object.keys(WIRE_EXTERNAL_MODULES).some(
78+
(prefix) => specifier === prefix || specifier.startsWith(`${prefix}/`),
79+
);
80+
}

test/wire-compat/closure.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* The closure walk: does every type a listed wire declaration references land
3+
* somewhere someone wrote down? (#1432, review P1.)
4+
*
5+
* Extracted from the gate test so the planted-red proofs in
6+
* `wire-mutations.test.ts` exercise the SAME walk the gate runs. A red proof
7+
* against a second copy of the logic proves nothing about the gate.
8+
*/
9+
10+
import { readTopLevelDeclarationNames, readTypeReferences } from './declaration-digest.ts';
11+
import { createOriginResolver } from './module-resolution.ts';
12+
import type { WireDeclarationRef } from './surface.ts';
13+
14+
export type ClosureGaps = {
15+
/** Repo declarations reached by the walk that are neither listed nor waived. */
16+
omitted: string[];
17+
/** Names nothing could place — the fail-closed case. */
18+
unresolved: string[];
19+
};
20+
21+
export function findClosureGaps(options: {
22+
repoRoot: string;
23+
readSource: (file: string) => string;
24+
declarations: readonly WireDeclarationRef[];
25+
/** Keys (`<file>#<name>`) treated as covered; normally every listed declaration. */
26+
claimed: ReadonlySet<string>;
27+
waivers: Readonly<Record<string, string>>;
28+
isExternalSpecifier: (specifier: string) => boolean;
29+
}): ClosureGaps {
30+
const { repoRoot, readSource, declarations, claimed, waivers, isExternalSpecifier } = options;
31+
32+
const declaredNames = new Map<string, Set<string>>();
33+
const declaresLocally = (file: string, name: string): boolean => {
34+
let names = declaredNames.get(file);
35+
if (!names) {
36+
names = new Set(readTopLevelDeclarationNames(file, readSource(file)));
37+
declaredNames.set(file, names);
38+
}
39+
return names.has(name);
40+
};
41+
42+
const resolveOrigin = createOriginResolver({
43+
repoRoot,
44+
readSource,
45+
declaresLocally,
46+
isExternalSpecifier,
47+
});
48+
49+
const omitted = new Set<string>();
50+
const unresolved = new Set<string>();
51+
for (const ref of declarations) {
52+
for (const name of readTypeReferences(ref.file, readSource(ref.file), ref.name)) {
53+
const origin = resolveOrigin(ref.file, name);
54+
if (origin.kind === 'global' || origin.kind === 'external') continue;
55+
if (origin.kind === 'unresolved') {
56+
unresolved.add(`${name} (referenced by ${ref.name} in ${ref.file})`);
57+
continue;
58+
}
59+
const key = `${origin.file}#${origin.name}`;
60+
if (claimed.has(key) || key in waivers) continue;
61+
omitted.add(`${key} (reached from ${ref.name} in ${ref.file})`);
62+
}
63+
}
64+
65+
return { omitted: [...omitted].sort(), unresolved: [...unresolved].sort() };
66+
}

test/wire-compat/declaration-digest.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,35 @@ export function digestDeclaration(file: string, source: string, name: string): s
129129
.digest('hex')}`;
130130
}
131131

132+
/**
133+
* Replaces `from` with `to` **inside `name`'s declaration only**, returning the
134+
* whole mutated source.
135+
*
136+
* The planted-red proofs need this rather than `source.replace`: sibling
137+
* declarations in the same file share substrings (two functions in
138+
* `request-progress-protocol.ts` both end their template with the same framing),
139+
* so a whole-file replace silently mutates the FIRST match and leaves the
140+
* declaration under test untouched — a vacuous proof that looks like a real one.
141+
* Throws when `from` is absent from the span, so that case fails loudly.
142+
*/
143+
export function replaceInDeclaration(
144+
file: string,
145+
source: string,
146+
name: string,
147+
from: string,
148+
to: string,
149+
): string {
150+
const { span } = findDeclaration(file, source, name);
151+
const declaration = source.slice(span.start, span.end);
152+
if (!declaration.includes(from)) {
153+
throw new Error(
154+
`${file}#${name} does not contain ${JSON.stringify(from)}, so a mutation built on it would ` +
155+
`be a no-op. Re-point the case at the declaration's current text.`,
156+
);
157+
}
158+
return source.slice(0, span.start) + declaration.replace(from, to) + source.slice(span.end);
159+
}
160+
132161
function walk(node: unknown, visit: (node: Node) => void): void {
133162
if (!node || typeof node !== 'object') return;
134163
if (Array.isArray(node)) {
@@ -162,7 +191,16 @@ function rootTypeName(typeName: unknown): string | null {
162191
export function readTypeReferences(file: string, source: string, name: string): string[] {
163192
const { node } = findDeclaration(file, source, name);
164193
const names = new Set<string>();
194+
// A declaration's own generic parameters (`JsonRpcRequestEnvelope<TParams>`)
195+
// read as type references but have no declaration site to gate — they are
196+
// bound right here. Collected across the whole subtree so a nested generic
197+
// helper's parameters drop out too.
198+
const typeParameters = new Set<string>();
165199
walk(node, (child) => {
200+
if (child.type === 'TSTypeParameter') {
201+
const bound = (child.name as { name?: unknown } | undefined)?.name;
202+
if (typeof bound === 'string') typeParameters.add(bound);
203+
}
166204
if (child.type === 'TSTypeReference') {
167205
const referenced = rootTypeName(child.typeName);
168206
if (referenced) names.add(referenced);
@@ -173,5 +211,8 @@ export function readTypeReferences(file: string, source: string, name: string):
173211
}
174212
});
175213
names.delete(name);
214+
// `x as const` parses as a type reference to the contextual keyword.
215+
names.delete('const');
216+
for (const bound of typeParameters) names.delete(bound);
176217
return [...names].sort();
177218
}

0 commit comments

Comments
 (0)