diff --git a/.oxlintrc.json b/.oxlintrc.json index bd000d11f..8ca5017b3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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": { diff --git a/docs/capability-migration.md b/docs/capability-migration.md index 71019895d..0e7009bcd 100644 --- a/docs/capability-migration.md +++ b/docs/capability-migration.md @@ -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 diff --git a/package.json b/package.json index ed1ed79e2..8387d8358 100644 --- a/package.json +++ b/package.json @@ -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}": [ diff --git a/scripts/lib/module-boundaries.d.mts b/scripts/lib/module-boundaries.d.mts new file mode 100644 index 000000000..79f4b972b --- /dev/null +++ b/scripts/lib/module-boundaries.d.mts @@ -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[]; diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs new file mode 100644 index 000000000..e5c93b43e --- /dev/null +++ b/scripts/lib/module-boundaries.mjs @@ -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; +}; diff --git a/scripts/verify-module-boundaries.mjs b/scripts/verify-module-boundaries.mjs new file mode 100644 index 000000000..1064d2ae4 --- /dev/null +++ b/scripts/verify-module-boundaries.mjs @@ -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.`); diff --git a/src/application/BrowserObservationService.test.ts b/tests/boundary/browser/browserObservationService.test.ts similarity index 76% rename from src/application/BrowserObservationService.test.ts rename to tests/boundary/browser/browserObservationService.test.ts index 383995c20..9dd1c6328 100644 --- a/src/application/BrowserObservationService.test.ts +++ b/tests/boundary/browser/browserObservationService.test.ts @@ -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 () => { diff --git a/src/domain/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts similarity index 97% rename from src/domain/managedMemberComparison.test.ts rename to tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index ee3a3a6d4..44a90f636 100644 --- a/src/domain/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -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", () => { diff --git a/src/domain/managedMemberComparison.partialBodies.test.ts b/tests/boundary/providers/managed/managedMemberComparisonPartialBodies.test.ts similarity index 93% rename from src/domain/managedMemberComparison.partialBodies.test.ts rename to tests/boundary/providers/managed/managedMemberComparisonPartialBodies.test.ts index ad04fb077..0bba44e66 100644 --- a/src/domain/managedMemberComparison.partialBodies.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonPartialBodies.test.ts @@ -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); diff --git a/tests/evaluation/moduleBoundaries.test.ts b/tests/evaluation/moduleBoundaries.test.ts index 0276710f8..720a51c11 100644 --- a/tests/evaluation/moduleBoundaries.test.ts +++ b/tests/evaluation/moduleBoundaries.test.ts @@ -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, [