mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
import path from 'node:path';
|
|
import { parseSync } from 'oxc-parser';
|
|
import { isProductionSourceFile } from './tracked-sources.ts';
|
|
import type { LayeringViolation } from './model.ts';
|
|
|
|
/**
|
|
* R65 rejects every concrete-platform dependency from tracked production daemon sources.
|
|
*
|
|
* The module record supplies static imports and re-exports; the AST supplies dynamic imports and
|
|
* TypeScript import types. Reading syntax through oxc-parser, rather than searching source text,
|
|
* means comments and string data cannot masquerade as dependencies.
|
|
*/
|
|
|
|
export const DAEMON_PLATFORM_BOUNDARY_RULE = 'R65 daemon-platform-boundary';
|
|
|
|
/** A tracked production source passed to the standalone policy. */
|
|
export type ProductionSource = Readonly<{ path: string; source: string }>;
|
|
|
|
export type DaemonPlatformDependencyKind =
|
|
| 'static import'
|
|
| 'type-only import'
|
|
| 'dynamic import'
|
|
| 'type import'
|
|
| 'require'
|
|
| 'import equals'
|
|
| 're-export'
|
|
| 'type-only re-export';
|
|
|
|
export type DaemonPlatformDependency = Readonly<{
|
|
file: string;
|
|
line: number;
|
|
spec: string;
|
|
kind: DaemonPlatformDependencyKind;
|
|
target: string;
|
|
}>;
|
|
|
|
const PLATFORM_PACKAGE = /^@agent-device\/platform-[^/]+(?:\/|$)/;
|
|
const LEGACY_PLATFORM_ROOT = 'src/platforms';
|
|
|
|
function lineOf(source: string, offset: number | null | undefined): number {
|
|
const start = typeof offset === 'number' && offset >= 0 ? offset : 0;
|
|
return source.slice(0, start).split('\n').length;
|
|
}
|
|
|
|
function sourceLiteral(node: Record<string, unknown>, source: string): string | undefined {
|
|
const start = node.start;
|
|
const end = node.end;
|
|
if (typeof start !== 'number' || typeof end !== 'number') return undefined;
|
|
const raw = source.slice(start, end);
|
|
const quote = raw[0];
|
|
if ((quote !== "'" && quote !== '"' && quote !== '`') || raw.at(-1) !== quote) return undefined;
|
|
const body = raw.slice(1, -1);
|
|
if (quote === '`' && body.includes('${')) return undefined;
|
|
return body;
|
|
}
|
|
|
|
function constantSpecifier(node: unknown, source: string): string | undefined {
|
|
if (node === null || typeof node !== 'object') return undefined;
|
|
const record = node as Record<string, unknown>;
|
|
if (record.type === 'ParenthesizedExpression') {
|
|
return constantSpecifier(record.expression, source);
|
|
}
|
|
if (
|
|
record.type === 'TSAsExpression' ||
|
|
record.type === 'TSTypeAssertion' ||
|
|
record.type === 'TSSatisfiesExpression' ||
|
|
record.type === 'TSNonNullExpression'
|
|
) {
|
|
return constantSpecifier(record.expression, source);
|
|
}
|
|
if (record.type === 'Literal' && typeof record.value === 'string') {
|
|
// oxc-parser currently represents a template-literal TS import type as an empty Literal with
|
|
// no raw value. Recover its exact no-substitution source span so `import(`platform`)` cannot
|
|
// bypass the type-only boundary.
|
|
return record.value || sourceLiteral(record, source);
|
|
}
|
|
if (record.type === 'BinaryExpression' && record.operator === '+') {
|
|
const left = constantSpecifier(record.left, source);
|
|
const right = constantSpecifier(record.right, source);
|
|
return left === undefined || right === undefined ? undefined : left + right;
|
|
}
|
|
if (record.type !== 'TemplateLiteral') return undefined;
|
|
const expressions = record.expressions;
|
|
const quasis = record.quasis;
|
|
if (!Array.isArray(expressions) || !Array.isArray(quasis)) return undefined;
|
|
const parts: string[] = [];
|
|
for (let index = 0; index < quasis.length; index++) {
|
|
const quasi = quasis[index];
|
|
if (quasi === null || typeof quasi !== 'object') return undefined;
|
|
const value = (quasi as Record<string, unknown>).value;
|
|
if (value === null || typeof value !== 'object') return undefined;
|
|
const cooked = (value as Record<string, unknown>).cooked;
|
|
if (typeof cooked !== 'string') return undefined;
|
|
parts.push(cooked);
|
|
if (index < expressions.length) {
|
|
const expression = constantSpecifier(expressions[index], source);
|
|
if (expression === undefined) return undefined;
|
|
parts.push(expression);
|
|
}
|
|
}
|
|
return parts.join('');
|
|
}
|
|
|
|
function visitAst(node: unknown, visitor: (node: Record<string, unknown>) => void): void {
|
|
if (node === null || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visitAst(child, visitor);
|
|
return;
|
|
}
|
|
const record = node as Record<string, unknown>;
|
|
visitor(record);
|
|
for (const child of Object.values(record)) visitAst(child, visitor);
|
|
}
|
|
|
|
function resolveRelative(file: string, spec: string): string | undefined {
|
|
if (!spec.startsWith('.')) return undefined;
|
|
return path.posix.normalize(path.posix.join(path.posix.dirname(file), spec));
|
|
}
|
|
|
|
function targetFor(file: string, spec: string): string | undefined {
|
|
if (PLATFORM_PACKAGE.test(spec)) return spec;
|
|
const resolved = resolveRelative(file, spec);
|
|
if (!resolved) return undefined;
|
|
return resolved === LEGACY_PLATFORM_ROOT || resolved.startsWith(`${LEGACY_PLATFORM_ROOT}/`)
|
|
? resolved
|
|
: undefined;
|
|
}
|
|
|
|
function dependency(
|
|
file: string,
|
|
source: string,
|
|
kind: DaemonPlatformDependencyKind,
|
|
spec: string,
|
|
offset: number | null | undefined,
|
|
): DaemonPlatformDependency | undefined {
|
|
const target = targetFor(file, spec);
|
|
if (!target) return undefined;
|
|
return { file, line: lineOf(source, offset), spec, kind, target };
|
|
}
|
|
|
|
function staticDependencies(file: string, source: string): DaemonPlatformDependency[] {
|
|
const parsed = parseSync(file, source);
|
|
const dependencies: DaemonPlatformDependency[] = [];
|
|
|
|
for (const entry of parsed.module.staticImports) {
|
|
const kind: DaemonPlatformDependencyKind =
|
|
entry.entries.length > 0 && entry.entries.every(({ isType }) => isType)
|
|
? 'type-only import'
|
|
: 'static import';
|
|
const found = dependency(file, source, kind, entry.moduleRequest.value, entry.start);
|
|
if (found) dependencies.push(found);
|
|
}
|
|
|
|
for (const statement of parsed.module.staticExports) {
|
|
const moduleRequest = statement.entries.find((entry) => entry.moduleRequest)?.moduleRequest;
|
|
if (!moduleRequest) continue;
|
|
const entries = statement.entries.filter((entry) => entry.moduleRequest);
|
|
const kind: DaemonPlatformDependencyKind =
|
|
entries.length > 0 && entries.every(({ isType }) => isType)
|
|
? 'type-only re-export'
|
|
: 're-export';
|
|
const found = dependency(file, source, kind, moduleRequest.value, statement.start);
|
|
if (found) dependencies.push(found);
|
|
}
|
|
|
|
return dependencies;
|
|
}
|
|
|
|
function dynamicDependencies(file: string, source: string): DaemonPlatformDependency[] {
|
|
const parsed = parseSync(file, source);
|
|
const dependencies: DaemonPlatformDependency[] = [];
|
|
const requireBindings = new Set(['require']);
|
|
const createRequireBindings = new Set<string>();
|
|
const moduleNamespaces = new Set<string>();
|
|
const createdRequireBindings = new Set<string>();
|
|
|
|
visitAst(parsed.program, (node) => {
|
|
if (node.type === 'ImportDeclaration') {
|
|
const spec = constantSpecifier(node.source, source);
|
|
if (spec !== 'node:module' && spec !== 'module') return;
|
|
for (const item of (node.specifiers as Array<Record<string, unknown>> | undefined) ?? []) {
|
|
const local = item.local as Record<string, unknown> | undefined;
|
|
if (typeof local?.name !== 'string') continue;
|
|
if (item.type === 'ImportNamespaceSpecifier' || item.type === 'ImportDefaultSpecifier') {
|
|
moduleNamespaces.add(local.name);
|
|
}
|
|
const imported = item.imported as Record<string, unknown> | undefined;
|
|
if (imported?.name === 'createRequire') createRequireBindings.add(local.name);
|
|
}
|
|
return;
|
|
}
|
|
if (node.type !== 'VariableDeclarator') return;
|
|
const id = node.id as Record<string, unknown> | undefined;
|
|
const init = node.init as Record<string, unknown> | undefined;
|
|
if (!id || !init) return;
|
|
if (id.type === 'ObjectPattern' && isNodeModuleRequire(init, requireBindings, source)) {
|
|
for (const property of (id.properties as Array<Record<string, unknown>> | undefined) ?? []) {
|
|
const key = property.key as Record<string, unknown> | undefined;
|
|
const value = property.value as Record<string, unknown> | undefined;
|
|
if (key?.name === 'createRequire' && value?.type === 'Identifier') {
|
|
createRequireBindings.add(String(value.name));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (id.type !== 'Identifier' || typeof id.name !== 'string') return;
|
|
if (init.type === 'Identifier' && requireBindings.has(String(init.name))) {
|
|
requireBindings.add(id.name);
|
|
return;
|
|
}
|
|
if (init.type === 'Identifier' && createdRequireBindings.has(String(init.name))) {
|
|
createdRequireBindings.add(id.name);
|
|
return;
|
|
}
|
|
if (isCreateRequireCall(init, createRequireBindings, moduleNamespaces)) {
|
|
createdRequireBindings.add(id.name);
|
|
}
|
|
});
|
|
visitAst(parsed.program, (node) => {
|
|
const callee = node.callee as Record<string, unknown> | undefined;
|
|
const isRequire =
|
|
node.type === 'CallExpression' &&
|
|
callee?.type === 'Identifier' &&
|
|
(requireBindings.has(String(callee.name)) || createdRequireBindings.has(String(callee.name)));
|
|
const isInlineCreateRequire =
|
|
node.type === 'CallExpression' &&
|
|
callee?.type === 'CallExpression' &&
|
|
isCreateRequireCall(callee, createRequireBindings, moduleNamespaces);
|
|
const isModuleRequire =
|
|
node.type === 'CallExpression' &&
|
|
callee?.type === 'MemberExpression' &&
|
|
memberName(callee) === 'require';
|
|
const kind: DaemonPlatformDependencyKind | undefined =
|
|
node.type === 'ImportExpression'
|
|
? 'dynamic import'
|
|
: node.type === 'TSImportType'
|
|
? 'type import'
|
|
: node.type === 'TSImportEqualsDeclaration'
|
|
? 'import equals'
|
|
: isRequire || isInlineCreateRequire || isModuleRequire
|
|
? 'require'
|
|
: undefined;
|
|
if (!kind) return;
|
|
const specifierNode =
|
|
node.type === 'TSImportEqualsDeclaration'
|
|
? (node.moduleReference as Record<string, unknown> | undefined)?.expression
|
|
: isRequire || isInlineCreateRequire || isModuleRequire
|
|
? (node.arguments as unknown[] | undefined)?.[0]
|
|
: node.source;
|
|
const spec = constantSpecifier(specifierNode, source);
|
|
if (!spec) return;
|
|
const found = dependency(file, source, kind, spec, node.start as number | undefined);
|
|
if (found) dependencies.push(found);
|
|
});
|
|
return dependencies;
|
|
}
|
|
|
|
function isNodeModuleRequire(
|
|
node: Record<string, unknown>,
|
|
requireBindings: ReadonlySet<string>,
|
|
source: string,
|
|
): boolean {
|
|
if (node.type !== 'CallExpression') return false;
|
|
const callee = node.callee as Record<string, unknown> | undefined;
|
|
if (callee?.type !== 'Identifier' || !requireBindings.has(String(callee.name))) return false;
|
|
const spec = constantSpecifier((node.arguments as unknown[] | undefined)?.[0], source);
|
|
return spec === 'node:module' || spec === 'module';
|
|
}
|
|
|
|
function memberName(node: Record<string, unknown>): string | undefined {
|
|
const property = node.property as Record<string, unknown> | undefined;
|
|
if (property?.type === 'Identifier') return property.name as string | undefined;
|
|
return typeof property?.value === 'string' ? property.value : undefined;
|
|
}
|
|
|
|
function isCreateRequireCall(
|
|
node: Record<string, unknown>,
|
|
bindings: ReadonlySet<string>,
|
|
namespaces: ReadonlySet<string>,
|
|
): boolean {
|
|
if (node.type !== 'CallExpression') return false;
|
|
const callee = node.callee as Record<string, unknown> | undefined;
|
|
if (callee?.type === 'Identifier') return bindings.has(String(callee.name));
|
|
if (callee?.type !== 'MemberExpression' || memberName(callee) !== 'createRequire') return false;
|
|
const object = callee.object as Record<string, unknown> | undefined;
|
|
return object?.type === 'Identifier' && namespaces.has(String(object.name));
|
|
}
|
|
|
|
function tripleSlashDependencies(file: string, source: string): DaemonPlatformDependency[] {
|
|
const dependencies: DaemonPlatformDependency[] = [];
|
|
const comments = parseSync(file, source).comments as readonly {
|
|
type: string;
|
|
value: string;
|
|
start: number;
|
|
end: number;
|
|
}[];
|
|
const pattern = /^\s*\/\/\/\s*<reference\s+(?:types|path)\s*=\s*["']([^"']+)["'][^>]*\/?>/gm;
|
|
for (const match of source.matchAll(pattern)) {
|
|
const spec = match[1];
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
const directiveComment = comments.some(
|
|
(comment) =>
|
|
comment.type === 'Line' &&
|
|
comment.start >= start &&
|
|
comment.end <= end &&
|
|
comment.value.trimStart().startsWith('/'),
|
|
);
|
|
if (!spec || !directiveComment) continue;
|
|
const found = dependency(file, source, 'type import', spec, match.index);
|
|
if (found) dependencies.push(found);
|
|
}
|
|
return dependencies;
|
|
}
|
|
|
|
function dependencyMessage(dependency: DaemonPlatformDependency): string {
|
|
return (
|
|
`${dependency.kind} '${dependency.spec}' resolves to concrete platform code ` +
|
|
`(${dependency.target}); production src/daemon/ must use the request-bound runtime contract`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find every concrete platform dependency in tracked production `src/daemon/**` sources.
|
|
*
|
|
* The input is intentionally source records rather than a filesystem walk. Callers use the
|
|
* canonical tracked-production enumerator, while synthetic tests can plant one edge at a time
|
|
* without touching the live tree. Non-daemon, test, and untracked-shaped records are ignored.
|
|
*/
|
|
export function findDaemonPlatformDependencies(
|
|
sources: readonly ProductionSource[],
|
|
): DaemonPlatformDependency[] {
|
|
return sources
|
|
.filter(({ path: file }) => file.startsWith('src/daemon/') && isProductionSourceFile(file))
|
|
.flatMap(({ path: file, source }) => [
|
|
...staticDependencies(file, source),
|
|
...dynamicDependencies(file, source),
|
|
...tripleSlashDependencies(file, source),
|
|
])
|
|
.sort(
|
|
(left, right) =>
|
|
left.file.localeCompare(right.file) ||
|
|
left.line - right.line ||
|
|
left.kind.localeCompare(right.kind),
|
|
);
|
|
}
|
|
|
|
/** The gate-facing projection of `findDaemonPlatformDependencies`. */
|
|
export function daemonPlatformBoundaryViolations(
|
|
sources: readonly ProductionSource[],
|
|
): LayeringViolation[] {
|
|
const importViolations = findDaemonPlatformDependencies(sources).map((found) => ({
|
|
rule: DAEMON_PLATFORM_BOUNDARY_RULE,
|
|
file: found.file,
|
|
line: found.line,
|
|
message: dependencyMessage(found),
|
|
}));
|
|
return [...importViolations, ...cleanupDispatchViolations(sources)];
|
|
}
|
|
|
|
const CLEANUP_ORCHESTRATORS = new Set([
|
|
'src/daemon/session-teardown.ts',
|
|
'src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts',
|
|
'src/daemon/handlers/snapshot-session.ts',
|
|
]);
|
|
|
|
function cleanupDispatchViolations(sources: readonly ProductionSource[]): LayeringViolation[] {
|
|
const violations: LayeringViolation[] = [];
|
|
for (const { path: file, source } of sources) {
|
|
if (!CLEANUP_ORCHESTRATORS.has(file)) continue;
|
|
visitAst(parseSync(file, source).program, (node) => {
|
|
const platformMember = node.type === 'MemberExpression' && memberName(node) === 'platform';
|
|
const destructuresPlatform =
|
|
node.type === 'VariableDeclarator' &&
|
|
(node.id as Record<string, unknown> | undefined)?.type === 'ObjectPattern' &&
|
|
(
|
|
((node.id as Record<string, unknown>).properties as Array<Record<string, unknown>>) ?? []
|
|
).some(
|
|
(property) =>
|
|
memberName(property) === 'platform' ||
|
|
(property.key as Record<string, unknown> | undefined)?.name === 'platform',
|
|
);
|
|
const callee = node.callee as Record<string, unknown> | undefined;
|
|
const platformPredicate =
|
|
node.type === 'CallExpression' &&
|
|
callee?.type === 'Identifier' &&
|
|
/^(?:is|has).*(?:Android|Apple|Ios|Web|Harmony|Vega|Linux)/.test(String(callee.name));
|
|
if (!platformMember && !destructuresPlatform && !platformPredicate) return;
|
|
violations.push({
|
|
rule: DAEMON_PLATFORM_BOUNDARY_RULE,
|
|
file,
|
|
line: lineOf(source, node.start as number | undefined),
|
|
message:
|
|
'daemon cleanup orchestration may not select a concrete platform; invoke the typed root-composed cleanup capability',
|
|
});
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/** Alias kept explicit for callers that name all layering checks `check*`. */
|
|
export const checkDaemonPlatformBoundary = daemonPlatformBoundaryViolations;
|