mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
1f9d940bff
* refactor(capture-kit): relocate snapshot and recording zones into capture-kit
Move the ADR 0019 end-state capture zones into @agent-device/capture-kit:
- src/snapshot/** -> packages/capture-kit/src/snapshot/** (presentation,
freshness, scroll-edge-state, ios-snapshot-runtime, android occlusion)
- src/recording/** -> packages/capture-kit/src/recording/**
- src/core/snapshot-{chrome,state,tree-ingestion,node-lookup}.ts ->
packages/capture-kit/src/
- src/snapshot-quality/ test -> capture-kit presentation tree (directory
retires with its last file)
Pure renames: import re-pointing and gate updates follow in the next commit.
The snapshot-desktop-surface test parks in src/__tests__/ because it pins
the root eager-import-closure walker.
* refactor(capture-kit): re-point capture and recording consumers to the new subpaths
Rewires every consumer of the relocated snapshot/recording modules to the new @agent-device/capture-kit subpath exports, adds the 23 subpath entries to the capture-kit exports map, fixes the moved recording-scripts test's __dirname-relative paths for the deeper location, and records the completed migration in ADR 0019's end state.
* chore(gates): align layering, mutation, fallow and CI gates with the capture-kit relocation
Moves the executable-policy roots, presentation-owner constant, zone ranks, authority fixture, mutation sharding globs, stryker aliases, fallow baselines and the iOS workflow's android-owned paths-ignore entry onto the new packages/capture-kit paths, and extends the planted-red coverage to the new presentation-owner subpath.
* chore: point capture-domain source-of-truth comments at the relocated capture-kit modules
* test: point shutdown recording mock at capture-kit and cover interactor acquisition presentation
* test(capture-kit): update upstream presentation test imports
* chore(gates): follow relocated snapshot assembly in R74
* test(daemon): freeze prewarm deadline assertion clocks
221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
// Catches: packages/contracts production source calling host, process, or timer mechanics
|
|
// directly — contracts owns vocabulary only, and a mechanic call there means an adapter's
|
|
// concern leaked into the shared-vocabulary package every zone imports, invisible to
|
|
// consumers because the call itself is fully typed and legal Node code.
|
|
// Evidence: 8f98d23f14 (#1750) gave R18 its own number after an id collision; 057ab1c82d (#1746)
|
|
// fixed double-reporting in this same authority check.
|
|
// Cost: 337 LOC (206 rule + 131 test).
|
|
// Kill criterion: none enforced today; retire only by maintainer decision that contracts owning
|
|
// vocabulary only no longer matters. package.json cannot replace it: node: built-ins are not
|
|
// dependencies and timer globals need no import. The A4 spike found `types: []` would break
|
|
// contracts' AbortSignal/URL/Buffer/process uses while every lib that supplies them also
|
|
// supplies setTimeout, and the file-level bans (interaction-outcome, snapshot-quality-warnings,
|
|
// network-traffic and ios-snapshot type-only statements, kernel-only imports) have no tsc form.
|
|
|
|
import { parseSync } from 'oxc-parser';
|
|
import type { LayeringViolation } from './model.ts';
|
|
|
|
export type ContractsProductionSource = Readonly<{ path: string; source: string }>;
|
|
|
|
const RULE = 'R18 contracts-implementation-authority';
|
|
const FORBIDDEN_HOST_MODULES = /^(?:node:)?(?:child_process|fs|timers)(?:\/|$)/;
|
|
const FORBIDDEN_TIMER_CALLS = new Set([
|
|
'clearImmediate',
|
|
'clearInterval',
|
|
'clearTimeout',
|
|
'setImmediate',
|
|
'setInterval',
|
|
'setTimeout',
|
|
]);
|
|
const IOS_SNAPSHOT_CONTRACT = 'packages/contracts/src/ios-snapshot.ts';
|
|
|
|
/** Contracts owns vocabulary. Host/process/timer mechanics belong in capture-kit or an adapter. */
|
|
export function contractsImplementationAuthorityViolations(
|
|
sources: readonly ContractsProductionSource[],
|
|
): LayeringViolation[] {
|
|
const violations: LayeringViolation[] = [];
|
|
for (const file of sources) {
|
|
if (!isContractsProduction(file.path)) continue;
|
|
const parsed = parseSync(file.path, file.source);
|
|
const networkTrafficViolation = networkTrafficImplementationViolation(
|
|
file.path,
|
|
file.source,
|
|
parsed.program.body,
|
|
);
|
|
if (file.path === 'packages/contracts/src/interaction-outcome.ts') {
|
|
violations.push(
|
|
violation(
|
|
file.path,
|
|
1,
|
|
'contracts may not own mutable interaction-outcome lifecycle; that WeakMap identity map belongs in src/core',
|
|
),
|
|
);
|
|
}
|
|
if (file.path === 'packages/contracts/src/snapshot-quality-warnings.ts') {
|
|
violations.push(
|
|
violation(
|
|
file.path,
|
|
1,
|
|
'contracts may not own snapshot quality warning rendering; that presentation policy belongs in packages/capture-kit/src/snapshot/snapshot-presentation',
|
|
),
|
|
);
|
|
}
|
|
if (networkTrafficViolation) violations.push(networkTrafficViolation);
|
|
const iosSnapshotViolation = iosSnapshotContractViolation(file.path, file.source, parsed);
|
|
if (iosSnapshotViolation) violations.push(iosSnapshotViolation);
|
|
for (const site of moduleSpecifiers(parsed.module, file.source)) {
|
|
if (!FORBIDDEN_HOST_MODULES.test(site.spec)) continue;
|
|
violations.push(
|
|
violation(
|
|
file.path,
|
|
site.line,
|
|
`contracts imports host implementation authority '${site.spec}'; move mechanics to @agent-device/capture-kit`,
|
|
),
|
|
);
|
|
}
|
|
visit(parsed.program, (node) => {
|
|
if (node.type !== 'CallExpression') return;
|
|
const timerName = timerCallName(node.callee);
|
|
if (!timerName) return;
|
|
violations.push(
|
|
violation(
|
|
file.path,
|
|
lineAt(file.source, Number(node.start ?? 0)),
|
|
`contracts calls timer primitive '${timerName}'; move lifecycle mechanics to @agent-device/capture-kit`,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function iosSnapshotContractViolation(
|
|
file: string,
|
|
source: string,
|
|
parsed: ReturnType<typeof parseSync>,
|
|
): LayeringViolation | undefined {
|
|
if (file !== IOS_SNAPSHOT_CONTRACT) return undefined;
|
|
const implementation = parsed.program.body.find((statement) => !isTypeOnlyStatement(statement));
|
|
if (implementation && typeof implementation === 'object') {
|
|
return violation(
|
|
file,
|
|
lineAt(source, Number((implementation as Record<string, unknown>).start ?? 0)),
|
|
'iOS snapshot contracts own typed vocabulary only; planning algorithms and provider/lifecycle implementations cannot enter contracts',
|
|
);
|
|
}
|
|
const disallowedImport = moduleSpecifiers(parsed.module, source).find(
|
|
({ spec }) => !spec.startsWith('@agent-device/kernel/'),
|
|
);
|
|
if (!disallowedImport) return undefined;
|
|
return violation(
|
|
file,
|
|
disallowedImport.line,
|
|
`iOS snapshot contracts may import only kernel vocabulary; '${disallowedImport.spec}' would bring planning algorithms or provider/lifecycle implementation into contracts`,
|
|
);
|
|
}
|
|
|
|
function networkTrafficImplementationViolation(
|
|
file: string,
|
|
source: string,
|
|
body: readonly unknown[],
|
|
): LayeringViolation | undefined {
|
|
if (!file.startsWith('packages/contracts/src/network-traffic')) return undefined;
|
|
if (file !== 'packages/contracts/src/network-traffic.ts') {
|
|
return violation(
|
|
file,
|
|
1,
|
|
'contracts may own only the neutral network-traffic vocabulary; parser modules belong in @agent-device/capture-kit',
|
|
);
|
|
}
|
|
const implementation = body.find((statement) => !isTypeOnlyStatement(statement));
|
|
if (!implementation || typeof implementation !== 'object') return undefined;
|
|
return violation(
|
|
file,
|
|
lineAt(source, Number((implementation as Record<string, unknown>).start ?? 0)),
|
|
'contracts network-traffic vocabulary contains runtime implementation; move parser mechanics to @agent-device/capture-kit',
|
|
);
|
|
}
|
|
|
|
function isTypeOnlyStatement(value: unknown): boolean {
|
|
if (value === null || typeof value !== 'object') return false;
|
|
const statement = value as Record<string, unknown>;
|
|
if (statement.type === 'ImportDeclaration') return statement.importKind === 'type';
|
|
if (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') {
|
|
return true;
|
|
}
|
|
if (statement.type !== 'ExportNamedDeclaration') return false;
|
|
if (statement.exportKind === 'type') return true;
|
|
const declaration = statement.declaration as Record<string, unknown> | undefined;
|
|
return (
|
|
declaration?.type === 'TSTypeAliasDeclaration' || declaration?.type === 'TSInterfaceDeclaration'
|
|
);
|
|
}
|
|
|
|
function moduleSpecifiers(
|
|
module: ReturnType<typeof parseSync>['module'],
|
|
source: string,
|
|
): ReadonlyArray<{ spec: string; line: number }> {
|
|
const sites: Array<{ spec: string; line: number }> = [];
|
|
const add = (request: { value?: string; start?: number } | undefined): void => {
|
|
if (request?.value)
|
|
sites.push({ spec: request.value, line: lineAt(source, request.start ?? 0) });
|
|
};
|
|
for (const entry of module.staticImports) add(entry.moduleRequest);
|
|
for (const entry of module.staticExports) {
|
|
for (const exported of entry.entries) add(exported.moduleRequest);
|
|
}
|
|
for (const entry of module.dynamicImports) {
|
|
const raw = source.slice(entry.moduleRequest.start, entry.moduleRequest.end);
|
|
const literal = /^(['"])([^'"]*)\1$/.exec(raw);
|
|
if (literal) sites.push({ spec: literal[2]!, line: lineAt(source, entry.moduleRequest.start) });
|
|
}
|
|
return sites;
|
|
}
|
|
|
|
function timerCallName(value: unknown): string | undefined {
|
|
if (value === null || typeof value !== 'object') return undefined;
|
|
const callee = value as Record<string, unknown>;
|
|
if (callee.type === 'Identifier' && FORBIDDEN_TIMER_CALLS.has(String(callee.name))) {
|
|
return String(callee.name);
|
|
}
|
|
if (callee.type !== 'MemberExpression' || callee.computed === true) return undefined;
|
|
const object = callee.object as Record<string, unknown> | undefined;
|
|
const property = callee.property as Record<string, unknown> | undefined;
|
|
if (
|
|
object?.type !== 'Identifier' ||
|
|
!['global', 'globalThis', 'window'].includes(String(object.name)) ||
|
|
property?.type !== 'Identifier' ||
|
|
!FORBIDDEN_TIMER_CALLS.has(String(property.name))
|
|
) {
|
|
return undefined;
|
|
}
|
|
return String(property.name);
|
|
}
|
|
|
|
function isContractsProduction(file: string): boolean {
|
|
return (
|
|
file.startsWith('packages/contracts/src/') &&
|
|
!file.endsWith('.test.ts') &&
|
|
!file.includes('/__tests__/')
|
|
);
|
|
}
|
|
|
|
function violation(file: string, line: number, message: string): LayeringViolation {
|
|
return { rule: RULE, file, line, message };
|
|
}
|
|
|
|
function lineAt(source: string, offset: number): number {
|
|
return source.slice(0, offset).split('\n').length;
|
|
}
|
|
|
|
function visit(node: unknown, callback: (node: Record<string, unknown>) => void): void {
|
|
if (node === null || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visit(child, callback);
|
|
return;
|
|
}
|
|
const record = node as Record<string, unknown>;
|
|
callback(record);
|
|
for (const value of Object.values(record)) visit(value, callback);
|
|
}
|