build: verify resolved module boundaries (#862)

This commit is contained in:
N0zoM1z0 2026-10-07 13:02:28 +08:00 committed by GitHub
parent 3b92a2bced
commit 3427593daf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 365 additions and 141 deletions

View File

@ -34,97 +34,6 @@
"no-var-requires": "off"
}
},
{
"files": [
"src/{contracts,domain,server}/**/*.ts",
"src/application/**/*.ts"
],
"excludeFiles": ["src/application/runtime.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/ghidra/**",
"**/hopper/**",
"**/ida/**",
"**/android/**",
"**/firmware/**",
"**/browser/*Provider.*",
"**/javascript/**"
],
"message": "Provider imports belong behind typed ports or in production composition."
}
]
}
]
}
},
{
"files": ["src/{contracts,domain}/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/application/**",
"**/server/**",
"**/cli/**",
"**/main/**",
"**/cli.*",
"**/main.*",
"**/android/**",
"**/artifacts/**",
"**/browser/**",
"**/dotnet/**",
"**/firmware/**",
"**/javascript/**",
"**/ghidra/**",
"**/hopper/**",
"**/ida/**",
"**/native/**",
"**/process/**",
"**/reference/**",
"**/windows/**",
"**/composition/**"
],
"message": "Domain and contracts depend inward; use typed ports for application, caller and provider boundaries."
}
]
}
]
}
},
{
"files": ["src/application/**/*.ts"],
"excludeFiles": ["src/application/runtime.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/ghidra/**",
"**/hopper/**",
"**/ida/**",
"**/android/**",
"**/firmware/**",
"**/browser/*Provider.*",
"**/javascript/**",
"**/composition/**"
],
"message": "Application workflows use typed ports; production construction belongs in composition."
}
]
}
]
}
},
{
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
"rules": {
@ -139,37 +48,6 @@
]
}
},
{
"files": ["src/domain/**/*.{fixture,test}.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/application/**",
"**/server/**",
"**/process/**",
"**/browser/**",
"**/ghidra/**",
"**/hopper/**",
"**/ida/**",
"**/replay/**",
"**/cli.*",
"**/main.*",
"**/composition/**",
"**/android/**",
"**/firmware/**",
"**/javascript/**"
],
"message": "Domain tests may import only their owning domain and inward dependencies."
}
]
}
]
}
},
{
"files": ["src/application/**/*.test.ts"],
"rules": {

View File

@ -60,6 +60,21 @@ provider implementations. Production composition is a deliberate outer boundary.
Other existing adapter imports will migrate incrementally; do not widen a lint
exception to conceal new protocol code inside application workflows.
`npm run verify:module-boundaries` parses repository-owned source imports,
reexports, type imports and literal dynamic imports, then checks their resolved
source layer. Nested names such as `domain/android` are domain code; `src/android`
is the provider adapter. The check runs with lint, including cached static gates.
It covers tracked and untracked non-ignored TypeScript source and skips cached
paths removed during a move. Pass exact source paths for a focused check.
Pure source and its tests keep inward dependencies. Application/server provider
construction guards cover the migrated deep, Android, firmware, JavaScript recovery and observation
implementations. The existing binary runtime is an exact temporary composition
exception; shared browser capture/export helpers remain admitted while their
ownership is reviewed. Test lane restrictions still use Oxlint. Producer-dependent
browser and managed comparison tests live in boundary lanes with their original
fixtures and assertions. This is a development guard, not runtime authorization.
Select verification by the claim changed: schema/composition, package, real
engine or host-native workflow. Record host/target prerequisites and source-owned
fixtures. Run heavy lanes serially with bounded workers/heaps and reuse existing

View File

@ -84,8 +84,8 @@
"build:cached": "turbo run build",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"lint": "oxlint && npm run verify:module-boundaries",
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
"knip": "knip",
"jscpd": "jscpd src/ tests/",
"scan:todos": "node scripts/scan-todos.mjs",
@ -178,7 +178,8 @@
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs",
"verify:browser:scripts": "npm run build:cached && node scripts/verify-browser-scripts.mjs",
"verify:test-discovery": "node scripts/verify-test-discovery.mjs",
"verify:javascript:recovery": "node scripts/verify-javascript-recovery.mjs"
"verify:javascript:recovery": "node scripts/verify-javascript-recovery.mjs",
"verify:module-boundaries": "node scripts/verify-module-boundaries.mjs"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [

View File

@ -0,0 +1,18 @@
/** One dependency crossing a settled source ownership boundary. */
export interface ModuleBoundaryViolation {
readonly file: string;
readonly line: number;
readonly specifier: string;
readonly target: string;
readonly boundary:
| "pure-layer"
| "application-composition"
| "provider-construction";
}
/** Check resolved source ownership, including type imports and reexports. */
export function inspectModuleBoundaries(
file: string,
source: string,
root: string,
): readonly ModuleBoundaryViolation[];

View File

@ -0,0 +1,100 @@
import { parse } from "@babel/parser";
import { traverseFast } from "@babel/types";
import { isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const PURE_LAYERS = new Set(["domain", "contracts"]);
const MIGRATED_PROVIDER_ROOTS = new Set([
"android",
"firmware",
"ghidra",
"hopper",
"ida",
"javascript",
]);
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
const literalValue = (node) => {
if (node?.type === "StringLiteral") return node.value;
if (node?.type === "TemplateLiteral" && node.expressions.length === 0)
return node.quasis[0]?.value.cooked ?? undefined;
return undefined;
};
const importSpecifier = (node) => {
switch (node.type) {
case "ImportDeclaration":
case "ExportNamedDeclaration":
case "ExportAllDeclaration":
case "ImportExpression":
case "TSImportType":
return literalValue(node.source);
case "TSImportEqualsDeclaration":
return node.moduleReference.type === "TSExternalModuleReference"
? literalValue(node.moduleReference.expression)
: undefined;
default:
return undefined;
}
};
const sourceTarget = (file, specifier) => {
if (specifier.startsWith("file:")) return fileURLToPath(specifier);
if (isAbsolute(specifier)) return resolve(specifier);
return specifier.startsWith(".")
? fileURLToPath(new URL(specifier, pathToFileURL(file)))
: undefined;
};
const failedBoundary = (file, target) => {
if (!file.startsWith("src/") || !target.startsWith("src/")) return undefined;
const layer = file.split("/")[1];
const targetLayer = target.split("/")[1];
if (PURE_LAYERS.has(layer)) {
if (
(/^src\/[^/]+\//u.test(target) && !PURE_LAYERS.has(targetLayer)) ||
/^src\/(?:cli|main)\./u.test(target)
)
return "pure-layer";
}
if (file === "src/application/runtime.ts") return undefined;
if (layer === "application" && targetLayer === "composition")
return "application-composition";
if (
(layer === "application" || layer === "server") &&
(MIGRATED_PROVIDER_ROOTS.has(targetLayer) ||
/^src\/browser\/[^/]*Provider\.[^/]+$/u.test(target))
)
return "provider-construction";
return undefined;
};
/** Check resolved source ownership, including type imports and reexports. */
export const inspectModuleBoundaries = (file, source, root) => {
const absoluteFile = resolve(root, file);
const importer = repositoryPath(root, absoluteFile);
const tree = parse(source, {
sourceType: "module",
plugins: ["typescript"],
});
const violations = [];
traverseFast(tree, (node) => {
const specifier = importSpecifier(node);
if (specifier === undefined) return;
const resolved = sourceTarget(absoluteFile, specifier);
if (resolved === undefined) return;
const target = repositoryPath(root, resolved);
const boundary = failedBoundary(importer, target);
if (boundary !== undefined)
violations.push({
file: importer,
line: node.loc?.start.line ?? 1,
specifier,
target,
boundary,
});
});
return violations;
};

View File

@ -0,0 +1,56 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { promisify } from "node:util";
import { inspectModuleBoundaries } from "./lib/module-boundaries.mjs";
const execute = promisify(execFile);
const root = process.cwd();
const selected = process.argv.slice(2);
for (const file of selected)
if (!file.endsWith(".ts"))
throw new TypeError(`Expected an exact TypeScript source file: ${file}`);
const files =
selected.length > 0
? selected
: (
await execute(
"git",
[
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
"src",
],
{ cwd: root, maxBuffer: 8 * 1024 * 1024 },
)
).stdout.split("\0");
let checked = 0;
for (const file of new Set(files.filter((path) => path.endsWith(".ts")))) {
let source;
try {
source = await readFile(resolve(root, file), "utf8");
} catch (cause) {
if (selected.length === 0 && cause.code === "ENOENT") continue;
throw cause;
}
checked += 1;
try {
for (const violation of inspectModuleBoundaries(file, source, root)) {
console.error(
`${violation.file}:${violation.line}: ${violation.boundary}: ${violation.specifier} resolves to ${violation.target}`,
);
process.exitCode = 1;
}
} catch (cause) {
console.error(`Could not inspect ${file}: ${cause.message}`);
process.exitCode = 1;
}
}
if (process.exitCode !== 1)
console.log(`Checked resolved module boundaries in ${checked} source files.`);

View File

@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import { listBrowserTargetsInputSchema } from "../domain/browserObservation.js";
import { browserCaptureComparisonInputSchema } from "../domain/browserCaptureComparison.js";
import { compareWebScreenshotsInputSchema } from "../domain/webScreenshot.js";
import { BROWSER_TOOL_CONTRACTS } from "../contracts/browserToolContracts.js";
import { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js";
import { listBrowserTargetsInputSchema } from "../../../src/domain/browserObservation.js";
import { browserCaptureComparisonInputSchema } from "../../../src/domain/browserCaptureComparison.js";
import { compareWebScreenshotsInputSchema } from "../../../src/domain/webScreenshot.js";
import { BROWSER_TOOL_CONTRACTS } from "../../../src/contracts/browserToolContracts.js";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import {
compareWebCaptureEvidence,
compareWebScreenshotEvidence,
listBrowserTargets,
} from "./BrowserObservationService.js";
} from "../../../src/application/BrowserObservationService.js";
describe("browser observation service prerequisites", () => {
it("reports a missing provider without requiring a permission grant", async () => {

View File

@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest";
import {
compareManagedMembers,
managedMemberComparisonResultSchema,
} from "./managedMemberComparison.js";
import { inspectManagedMembersBytes } from "../dotnet/ManagedMemberInspector.js";
} from "../../../../src/domain/managedMemberComparison.js";
import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js";
import {
buildManagedPeFixture,
managedPeFixtureTarget,
} from "../dotnet/ManagedPe.fixture.js";
} from "../../../../src/dotnet/ManagedPe.fixture.js";
describe("managed member comparison", () => {
it("remaps renamed methods by exact CIL/signature without using names", () => {

View File

@ -2,12 +2,12 @@ import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { compareManagedMembers } from "./managedMemberComparison.js";
import { inspectManagedMembersBytes } from "../dotnet/ManagedMemberInspector.js";
import { compareManagedMembers } from "../../../../src/domain/managedMemberComparison.js";
import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js";
import {
buildManagedPeFixture,
managedPeFixtureTarget,
} from "../dotnet/ManagedPe.fixture.js";
} from "../../../../src/dotnet/ManagedPe.fixture.js";
const inspect = (bytes: Buffer, path: string) => {
const target = managedPeFixtureTarget(bytes, path);

View File

@ -1,10 +1,16 @@
import { spawnSync } from "node:child_process";
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import { execFile, spawnSync } from "node:child_process";
import { copyFile, mkdir, rename, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { inspectModuleBoundaries } from "../../scripts/lib/module-boundaries.mjs";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const verifier = resolve("scripts/verify-module-boundaries.mjs");
describe("incremental module import boundaries", () => {
it.each([
["src/domain/probe.ts", "../application/Workflow.js", false],
@ -32,16 +38,166 @@ describe("incremental module import boundaries", () => {
["src/composition/probe.ts", "../ghidra/Provider.js", true],
["src/composition/probe.ts", "../android/JadxProvider.js", true],
["src/composition/probe.ts", "../firmware/FirmwareProvider.js", true],
["src/domain/android/probe.ts", "./types.js", true],
["src/domain/javascript/probe.ts", "./types.js", true],
["src/domain/firmware/probe.ts", "../android/types.js", true],
["src/domain/android/probe.ts", "../../android/JadxProvider.js", false],
["src/application/android/probe.ts", "../../domain/android/types.js", true],
[
"src/application/android/probe.ts",
"../../android/JadxProvider.js",
false,
],
["src/application/runtime.ts", "../android/JadxProvider.js", true],
[
"src/application/android/runtime.ts",
"../../android/JadxProvider.js",
false,
],
[
"src/application/probe.test.ts",
"../browser/CdpBrowserProvider.js",
false,
],
["src/domain/probe.test.ts", "../dotnet/ManagedMemberInspector.js", false],
["src/server/probe.ts", "../composition/android.js", true],
["src/application/probe.ts", "../browser/CdpCaptureValues.js", true],
])("checks %s importing %s", async (file, dependency, allowed) => {
const temporary = await createTestTempDirectory("rea-module-boundary-");
const config = join(temporary, ".oxlintrc.json");
await copyFile(resolve(".oxlintrc.json"), config);
const fixture = join(temporary, file);
await mkdir(dirname(fixture), { recursive: true });
await writeFile(
fixture,
`import { value } from ${JSON.stringify(dependency)}; export { value };\n`,
);
const result = spawnSync(process.execPath, [verifier, file], {
cwd: temporary,
encoding: "utf8",
});
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(allowed ? 0 : 1);
if (!allowed) expect(result.stderr).toContain("resolves to src/");
});
});
describe("module import syntax and source discovery", () => {
it.each([
'import { value } from "../android/Provider.js";',
'import type { Value } from "../android/Provider.js";',
'export { value } from "../android/Provider.js";',
'export * from "../android/Provider.js";',
'type Value = import("../android/Provider.js").Value;',
'const loaded = import("../android/Provider.js");',
"const loaded = import(`../android/Provider.js`);",
'import provider = require("../android/Provider.js");',
'import { value } from "../domain/../android/Provider.js";',
'const loaded = import("../%61ndroid/Provider.js?view=one#fragment");',
])("checks the resolved owner in %s", (source) => {
expect(
inspectModuleBoundaries("src/domain/probe.ts", source, process.cwd()),
).toEqual([
expect.objectContaining({
target: "src/android/Provider.js",
boundary: "pure-layer",
}),
]);
});
it("ignores imports mentioned in comments and analyst fixture strings", () => {
expect(
inspectModuleBoundaries(
"src/domain/probe.ts",
'// import("../android/Provider.js");\nconst text = \'import("../android/Provider.js")\';',
process.cwd(),
),
).toEqual([]);
});
it.each(["absolute", "file-url"])("resolves %s source imports", (kind) => {
const target = resolve("src/android/Provider.js");
const specifier = kind === "file-url" ? pathToFileURL(target).href : target;
expect(
inspectModuleBoundaries(
"src/domain/probe.ts",
`export * from ${JSON.stringify(specifier)};`,
process.cwd(),
),
).toEqual([
expect.objectContaining({
target: "src/android/Provider.js",
boundary: "pure-layer",
}),
]);
});
it("rejects unsupported selected arguments instead of checking zero files", () => {
const result = spawnSync(process.execPath, [verifier, "--unknown"], {
encoding: "utf8",
});
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(1);
expect(result.stderr).toContain("Expected an exact TypeScript source file");
});
it("reports malformed source with the selected file and parsing reason", async () => {
const temporary = await createTestTempDirectory("rea-module-syntax-");
await mkdir(join(temporary, "src", "domain"), { recursive: true });
await writeFile(join(temporary, "src/domain/probe.ts"), "import {");
const result = spawnSync(
process.execPath,
[verifier, "src/domain/probe.ts"],
{
cwd: temporary,
encoding: "utf8",
},
);
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(1);
expect(result.stderr).toContain("Could not inspect src/domain/probe.ts");
expect(result.stderr).toContain("Unexpected token");
});
it("checks untracked source files and ignores cached paths removed during a move", async () => {
const temporary = await createTestTempDirectory("rea-module-discovery-");
await mkdir(join(temporary, "src", "domain"), { recursive: true });
await writeFile(join(temporary, "src/domain/old.ts"), "export const x=1;");
await execute("git", ["init", "--quiet"], { cwd: temporary });
await execute("git", ["add", "--", "src"], { cwd: temporary });
await rename(
join(temporary, "src/domain/old.ts"),
join(temporary, "src/domain/new.ts"),
);
await writeFile(
join(temporary, "src/domain/probe.ts"),
'export * from "../android/Provider.js";',
);
const result = spawnSync(process.execPath, [verifier], {
cwd: temporary,
encoding: "utf8",
});
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(1);
expect(result.stderr).toContain("src/domain/probe.ts:1: pure-layer");
expect(result.stderr).not.toContain("old.ts");
});
});
describe("test lane import boundaries", () => {
it.each([
["src/application/probe.test.ts", "../server/createServer.js", false],
["tests/composition/probe.test.ts", "node:child_process", false],
["tests/composition/probe.test.ts", "node:net", false],
["tests/composition/probe.test.ts", "playwright", false],
["tests/composition/probe.test.ts", "../../src/domain/result.js", true],
])("checks %s importing %s", async (file, dependency, allowed) => {
const temporary = await createTestTempDirectory("rea-test-lane-");
const config = join(temporary, ".oxlintrc.json");
await copyFile(resolve(".oxlintrc.json"), config);
await mkdir(dirname(join(temporary, file)), { recursive: true });
await writeFile(
join(temporary, file),
`import { value } from ${JSON.stringify(dependency)}; export { value };\n`,
);
const result = spawnSync(
process.execPath,
[