mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
d97a628e38
* fix(ci): make the two rg-based static checks actually run
ripgrep is never installed on ubuntu-latest, so both `rg` assertions in
the Lint & Format job failed with "command not found" (exit 127) on
every run. `if rg ...; then ... fi` cannot distinguish that from "no
matches" (exit 1) — both read as false, so each step silently passed
without its assertion ever executing. The DI-seams check had 7 live
violations it never reported.
Rewrite both against `grep`, which every runner ships, with match/
no-match/error exit codes handled explicitly so a broken scan fails
the lane instead of reading as a pass, plus a zero-tracked-files guard
so a renamed directory can't quietly go uncovered.
The DI-seam pattern also gets narrower to drop two classes of false
positive surfaced by actually running it: `typeof fetch` (fetchImpl?/
fetch? seams inject the one global with no module boundary vi.mock can
intercept; auth-session.ts/cloud-profile.ts/daemon-proxy.ts exercise
the seam directly in their unit tests, while CLI-level tests use
vi.stubGlobal('fetch', ...) where the seam isn't reachable — a
deliberate, exercised seam) and `typeof SOME_CONSTANT` in
SCREAMING_SNAKE_CASE (derives a literal union type from a constant,
e.g. interaction-touch-response.ts's dispatchPath field — not an
injectable seam at all).
Fixes #1976
* fix(ci): replace the DI-seam name-based allowlist with an explicit per-site one
Review on PR #2006 (#1976): the previous revision fixed the exit-code
handling but decided which `?: typeof X` matches to ban with a regex
that exempted matches by the *spelling* of the typeof target
(`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always
passed). That's a name-based semantic allowlist, not ownership: a new,
genuinely test-only `typeof fetch` seam anywhere in the tree would
have silently passed, while an equally legitimate seam under any
other name would still fail.
Add scripts/di-seams: a small, tested TypeScript checker that judges
each match against an explicit, typed, per-site allowlist
(scripts/di-seams/approved.ts) keyed by (file, field name, typeof
target) rather than by name. A triple is exempt only because it was
individually reviewed and named — never because of how it's spelled —
and the gate fails just as hard on a stale approval (one whose triple
no longer matches anything, e.g. after a rename) as on an unapproved
seam, so the list can't silently drift out of sync with the code it
describes.
Moves the DI-seams step in ci.yml to run after Setup toolchain (it's
no longer a toolchain-free text scan); the Swift trailing-comma check
stays where it was.
* fix(ci): register di-seams as a real gate and route it through the tmpdir wrapper
CI caught two things the local (dependency-free) run couldn't:
- oxfmt formatting on the two new files.
- scripts/node-test-tmpdir.test.ts's repo-wide audit: every package.json
script that invokes `node --test` directly must route through
scripts/node-test-tmpdir.ts, or a crash/timeout mid-run leaks its
scratch TMPDIR. check:di-seams now does.
- check:gate-manifest: a package.json script that runs `node --test`
must be covered by a registered CHECK_CATALOG gate, or the audit
reports the test suite as run by no lane. Registered 'di-seams' in
scripts/check-affected/{model,checks}.ts and wired the CI step
through run-gate like every other structural guard in this job,
instead of invoking pnpm directly.
Verified locally with node_modules installed: check:di-seams,
check:gate-manifest, check:gate-manifest:test, check:affected:test,
check:layering, check:fallow (scoped to the changed files), format,
lint, and typecheck all pass.
* fix(ci): close the multiline and duplicate-site gaps in the DI-seam scanner
Review round 2 on PR #2006 (#1976):
- findSeamMatches scanned line by line, so a declaration split across
lines (`field?:` on one line, `typeof X` on the next) was invisible.
Matching now runs against each file's whole source in one pass —
`\s` matches a real newline in JavaScript regexes with no extra flag
needed — with the line number derived from the match's character
offset.
- checkSeams keyed approval by (file, field, target) alone, so once
one occurrence of a triple was approved, any further occurrence of
that same triple anywhere in the file passed too. The key now
includes the line the match starts on, so an approval names one
specific declaration, not a recurring pattern. approved.ts expands
from 5 collapsed entries to the 7 exact sites this closes down to.
Added regression tests planting both gaps directly (a cross-line
declaration, and a second unreviewed fetchImpl?: typeof fetch at a
different line in an already-approved file) and verified both against
the real tree with injected violations, restored cleanly afterward.
Re-ran the full local gate suite (di-seams, gate-manifest, layering,
fallow, format, lint, typecheck) — all green.
* fix(ci): resync approved DI-seam line after merging main
Merging main (#2002) removed an unused import above the approved
dispatchPath?: typeof MAESTRO_COORDINATE_FALLBACK_PATH declaration in
interaction-touch-response.ts, shifting it from line 61 to line 60 —
exactly the location-specific-approval staleness the gate is designed
to catch, just triggered by an unrelated upstream edit rather than a
change in this PR. Updated the approved line to match.
* fix(ci): replace the DI-seam positional table with a code-local approval marker
Review round 3 on PR #2006 (#1976): CI proved the round-2 fix's core
assumption wrong within one push. Keying approval by (file, line,
field, target) made a line number the identity — an unrelated edit
anywhere earlier in a file shifts every approval below it, and that's
exactly what happened: merging main removed an unused import above
the approved dispatchPath declaration, and the gate rejected an
unchanged, already-reviewed line.
Detection is now AST-based (oxc-parser, the same tool
scripts/layering/*.ts already uses) instead of a source-text regex:
any `{ optional: true, typeAnnotation: TSTypeQuery }` node — a
property signature or a bare parameter — is a candidate, which finds
a multiline `field?:\n typeof X` declaration for free instead of
needing a special case for it.
Approval is a `// di-seam-approved: <reason>` comment immediately
above the declaration, matching this repo's own `//
fallow-ignore-next-line complexity` convention: the marker precedes
what it exempts. approved.ts (the external table) is deleted — there
is nothing left to keep in sync, since the approval travels with the
code it approves. A second, unmarked seam under the same field/target
elsewhere still fails; reordering unrelated code around an approved
declaration no longer touches it.
Added the marker to the 7 real approved sites (fetch-global
injection seams in auth-session.ts/cloud-profile.ts/daemon-proxy.ts;
the literal-type-derivation false positive in
interaction-touch-response.ts) and regression tests proving: a
cross-line declaration is still found, a second unmarked occurrence
of an approved field/target pair still fails, and an unrelated
insertion above an approved declaration no longer breaks it. Verified
against the real tree with an injected multi-line unrelated insertion
before an approved site — still green. Re-ran the full local gate
suite (di-seams, gate-manifest, layering, fallow, format, lint,
typecheck, auth-session unit tests) — all green.
* fix(ci): reject a di-seam-approved marker with no reason text
Review round 4 on PR #2006 (#1976): approvalReason() returned '' (not
null) for a bare `// di-seam-approved:` comment with nothing after
it, and checkSeams() only filtered out null, so an empty marker
silently approved a seam with zero justification — exactly the kind
of unreviewed bypass this gate exists to prevent.
approvalReason() now returns null when the joined reason text is
empty after trimming, so a bare or whitespace-only marker is treated
the same as no marker at all. Added tests for both the model-level
behavior and the end-to-end checkSeams() result, plus verified
against the real tree by injecting a bare-marker declaration and
confirming it's flagged, then restored cleanly.
---------
Co-authored-by: Claude <noreply@anthropic.com>
150 lines
6.4 KiB
TypeScript
150 lines
6.4 KiB
TypeScript
// Detects `field?: typeof X` — the shape of a test-only DI seam (an optional parameter that
|
|
// exists to let a test inject an alternate implementation). Not every match is a seam this gate
|
|
// should ban: some are deliberately approved injection points, and some are unrelated `typeof
|
|
// CONST` literal-type derivations that only match by syntax coincidence.
|
|
//
|
|
// #1976 / PR #2006 review history:
|
|
// - Round 1: an earlier version exempted matches by the *spelling* of the typeof target
|
|
// (`typeof fetch` always passed, ALL-CAPS targets always passed) — silently waving through a
|
|
// new, genuinely test-only `typeof fetch` seam anywhere in the tree while banning an equally
|
|
// legitimate seam under any other name.
|
|
// - Round 2: replaced that with a global table keyed by (file, line, field, target). A line
|
|
// number is not a stable identity — an unrelated edit anywhere earlier in the file shifts
|
|
// every approval below it, so the very first real-world CI run (an unrelated import removed
|
|
// on `main`) broke an already-reviewed, unchanged approval.
|
|
//
|
|
// This version drops the external table entirely. Detection is AST-based (`oxc-parser`, already
|
|
// a devDependency, same tool scripts/layering/*.ts uses for exactly this reason: a regex has to
|
|
// enumerate every way TypeScript lets you format a declaration, and a full-source regex still
|
|
// cannot tell an optional property inside a type literal from a `typeof` mention inside a string
|
|
// or comment). Approval is a comment attached to the declaration itself — `// di-seam-approved:
|
|
// <reason>` immediately above it, the same "marker precedes what it exempts" shape as this repo's
|
|
// own `// fallow-ignore-next-line complexity` convention — so the approval moves with the code:
|
|
// nothing to resync when an unrelated line shifts, and a second, unmarked seam under the same
|
|
// field/target elsewhere in the file is not exempted by association.
|
|
|
|
import { parseSync } from 'oxc-parser';
|
|
|
|
const APPROVAL_MARKER = 'di-seam-approved:';
|
|
|
|
export type SourceFile = {
|
|
readonly path: string;
|
|
readonly source: string;
|
|
};
|
|
|
|
export type SeamMatch = {
|
|
readonly file: string;
|
|
readonly line: number;
|
|
readonly field: string;
|
|
readonly target: string;
|
|
readonly text: string;
|
|
/** The text after the marker, or null if this declaration has no `di-seam-approved:` comment. */
|
|
readonly approvalReason: string | null;
|
|
};
|
|
|
|
type AstNode = Record<string, unknown>;
|
|
type Comment = {
|
|
readonly type: string;
|
|
readonly value: string;
|
|
readonly start: number;
|
|
readonly end: number;
|
|
};
|
|
|
|
function lineOf(source: string, offset: number): number {
|
|
return source.slice(0, offset).split('\n').length;
|
|
}
|
|
|
|
/** `{ optional: true, typeAnnotation: TSTypeAnnotation<TSTypeQuery> }` — a property signature's
|
|
* own shape and a bare optional parameter's own shape are identical on this point, so one check
|
|
* covers both `{ field?: typeof X }` and `function f(field?: typeof X)`. */
|
|
function typeofTarget(node: AstNode): string | null {
|
|
if (node['optional'] !== true) return null;
|
|
const outer = node['typeAnnotation'] as AstNode | undefined;
|
|
const inner = outer?.['typeAnnotation'] as AstNode | undefined;
|
|
if (inner?.['type'] !== 'TSTypeQuery') return null;
|
|
const exprName = inner['exprName'] as AstNode | undefined;
|
|
return exprName?.['type'] === 'Identifier' ? (exprName['name'] as string) : null;
|
|
}
|
|
|
|
function fieldName(node: AstNode): string | null {
|
|
if (node['type'] === 'TSPropertySignature') {
|
|
const key = node['key'] as AstNode | undefined;
|
|
return key?.['type'] === 'Identifier' ? (key['name'] as string) : null;
|
|
}
|
|
if (node['type'] === 'Identifier') return node['name'] as string;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The `di-seam-approved:` reason for a declaration starting at `nodeStart`, or null. Walks
|
|
* backward through `comments` while each is separated from the next (or from the node) by
|
|
* whitespace only — a contiguous leading-comment block — then requires the FIRST comment in
|
|
* that block to carry the marker. Any non-whitespace between a comment and the node (another
|
|
* statement, a blank marker-less comment used for something else) breaks the chain, so a marker
|
|
* left on an unrelated declaration above never attaches to this one.
|
|
*/
|
|
function approvalReason(
|
|
source: string,
|
|
comments: readonly Comment[],
|
|
nodeStart: number,
|
|
): string | null {
|
|
const onlyWhitespace = (from: number, to: number) => /^\s*$/.test(source.slice(from, to));
|
|
const block: string[] = [];
|
|
let cursor = nodeStart;
|
|
for (let i = comments.length - 1; i >= 0; i--) {
|
|
const comment = comments[i]!;
|
|
if (comment.end > cursor) continue;
|
|
if (!onlyWhitespace(comment.end, cursor)) break;
|
|
block.unshift(comment.value.trim());
|
|
cursor = comment.start;
|
|
}
|
|
if (block.length === 0 || !block[0]!.startsWith(APPROVAL_MARKER)) return null;
|
|
const reason = [block[0]!.slice(APPROVAL_MARKER.length).trim(), ...block.slice(1)]
|
|
.join(' ')
|
|
.trim();
|
|
// A bare `// di-seam-approved:` with no reason text is not a review, it's a bypass — require
|
|
// something was actually written, not just the marker itself.
|
|
return reason.length > 0 ? reason : null;
|
|
}
|
|
|
|
export function findSeamMatches(files: readonly SourceFile[]): SeamMatch[] {
|
|
const matches: SeamMatch[] = [];
|
|
for (const { path: file, source } of files) {
|
|
const parsed = parseSync(file, source);
|
|
const comments = parsed.comments as readonly Comment[];
|
|
const visit = (node: unknown): void => {
|
|
if (node === null || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visit(child);
|
|
return;
|
|
}
|
|
const record = node as AstNode;
|
|
const target = typeofTarget(record);
|
|
const field = target === null ? null : fieldName(record);
|
|
if (target !== null && field !== null) {
|
|
const start = record['start'] as number;
|
|
const end = record['end'] as number;
|
|
matches.push({
|
|
file,
|
|
line: lineOf(source, start),
|
|
field,
|
|
target,
|
|
text: source.slice(start, end).replace(/\s+/g, ' ').trim(),
|
|
approvalReason: approvalReason(source, comments, start),
|
|
});
|
|
}
|
|
for (const value of Object.values(record)) visit(value);
|
|
};
|
|
visit(parsed.program);
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
export type SeamCheckResult = {
|
|
readonly violations: readonly SeamMatch[];
|
|
};
|
|
|
|
export function checkSeams(matches: readonly SeamMatch[]): SeamCheckResult {
|
|
return { violations: matches.filter((match) => match.approvalReason === null) };
|
|
}
|