diff --git a/docs/benchmarks/experiments/2026-08-16-topology-guard/README.md b/docs/benchmarks/experiments/2026-08-16-topology-guard/README.md
new file mode 100644
index 0000000..c0eda5d
--- /dev/null
+++ b/docs/benchmarks/experiments/2026-08-16-topology-guard/README.md
@@ -0,0 +1,154 @@
+# Standalone topology guard prototype — 2026-08-16
+
+**Story:** Shortcut sc-1678
+
+**Status:** one-off migration experiment, not a recurring performance metric
+
+**Verdict:** **retain ESLint; do not ship `guard-topology` from this prototype**
+
+The direct filesystem walker is materially faster and lighter than the current
+`guard-structure -> ESLint -> eslint-plugin-project-structure` process. It does not meet the
+pre-registered adoption boundary, however: it misses the incumbent import-wall responsibility,
+cannot report an illegal empty directory, and has no config input for Electron's six hand-written
+folder trees. Speed does not override these coverage gaps.
+
+This experiment makes no claim that the prototype is Oxc-native. Both the current command and the
+candidate are Devkit-orchestrated JavaScript processes; the candidate directly walks the filesystem.
+
+## Decision
+
+Keep the current ownership split:
+
+- config-driven placement, naming, and required-sibling rules remain on `guard-structure`, which
+ runs Devkit's packaged ESLint and `eslint-plugin-project-structure`;
+- Electron's six hand-written folder trees and its import walls remain in the consumer-side ESLint
+ preset;
+- generated structure baselines and permanent exemptions keep their current semantics; and
+- no install, doctor, hook, package dependency, or command registration changes in this story.
+
+A later direct runner can be reconsidered when it has a real import parser/resolver with the current
+wall's import forms and alias behavior, emits directory-level violations, and repeats this paired
+benchmark at coverage parity. The faster placement-only prototype is not being shipped as a hybrid:
+that would retain ESLint for walls while adding a second gate and diagnostic surface.
+
+The pre-implementation critique and rejected alternatives are recorded in
+[`feature-critique.md`](feature-critique.md). This outcome is an implementation note under the
+existing [`oxc-toolchain-migration`](../../../decisions/oxc-toolchain-migration.md) Target, not a new
+architectural axis.
+
+## Headline benchmark
+
+The fixture has 481 governed files across 80 component directories: TypeScript, TSX, CSS, HTML,
+SVG, and an arbitrary extension. The current lane uses a packed `@norvalbv/devkit@0.51.1` installed
+inside the disposable fixture, so the plugin observes the same `node_modules` layout as a real
+consumer. Dependencies and packaging are completed before timing.
+
+Three warm-ups were discarded. Each table cell is median / nearest-rank p95 over 20 measured runs;
+the order alternated current-first and candidate-first. CPU is aggregate user + system from
+`/usr/bin/time -lp`. Process-tree RSS is sampled every 10 ms by summing the wrapper and all
+descendants, with the direct child's wait4 peak as a floor.
+
+### Clean full-tree lane
+
+| Runner | Wall | CPU | Process-tree peak RSS |
+| --- | ---: | ---: | ---: |
+| Current packaged ESLint chain | 253.2 / 282.6 ms | 490 / 520 ms | 116.1 / 116.6 MiB |
+| Direct walker prototype | 90.5 / 106.2 ms | 120 / 130 ms | 79.2 / 79.6 MiB |
+| Median reduction | **64.3%** | **75.5%** | **31.8%** |
+
+### One placement violation lane
+
+Both runners find exactly one `src/loose.ts` placement violation before timing this lane.
+
+| Runner | Wall | CPU | Process-tree peak RSS |
+| --- | ---: | ---: | ---: |
+| Current packaged ESLint chain | 264.3 / 277.3 ms | 500 / 530 ms | 116.1 / 116.7 MiB |
+| Direct walker prototype | 87.6 / 97.8 ms | 120 / 130 ms | 79.3 / 79.5 MiB |
+| Median reduction | **66.9%** | **76.0%** | **31.7%** |
+
+The latency and CPU win is large enough to justify a future implementation, but it is not adoption
+evidence without responsibility parity. The candidate also reports a generic path violation where
+the plugin currently explains allowed names or the missing sibling, so diagnostic-message parity
+would still need deliberate UX work.
+
+## Coverage and exact diagnostic differences
+
+Each row uses a fresh Node process and a two-component fixture. “Current command” means the packaged
+generic `guard-structure` folder rule. Import walls are tested separately through the broader
+incumbent ESLint `independent-modules` owner because the generic command does not compile
+`structure.walls` today.
+
+| Case | Expected | Current command | Direct candidate | Difference |
+| --- | --- | ---: | ---: | --- |
+| Clean tree | clean | 0 | 0 | exact |
+| Folder/file placement | violation | 1 | 1 | same path/count; current message lists allowed names, candidate is generic |
+| Required `index.ts` sibling | violation | 1 | 1 | same path/count; current names the missing sibling, candidate is generic |
+| Folder naming | violation | 1 | 1 | same path/count; current lists allowed folder pattern, candidate is generic |
+| Root CSS file | violation | 0 | 1 | candidate expands coverage; current source-extension manifest never invokes the rule for it |
+| Root HTML file | violation | 0 | 1 | candidate expands coverage |
+| Root SVG asset | violation | 0 | 1 | candidate expands coverage |
+| Arbitrary extension | violation | 0 | 1 | candidate expands coverage |
+| Illegal empty directory | violation | 0 | 0 | **shared gap; candidate walker discards directory-only entries** |
+| Ignored directory | clean | 0 | 0 | exact |
+| Generated debt baseline | clean | 0 | 0 | exact |
+| Permanent exemption | clean | 0 | 0 | exact |
+| Cross-feature import wall | violation | 1 via incumbent ESLint wall | 0 | **candidate parity failure** |
+| Electron's six hand-written folder trees | violations | incumbent consumer ESLint preset | no config input | **candidate responsibility gap; Electron guard config has no `structure` block** |
+
+The CSS/HTML/asset/arbitrary-extension rows are a useful correction to the earlier migration
+hypothesis: the baseline walker sees these files, but the generic ESLint gate's generated
+`files: src/**/*.{ts,tsx}` manifest does not lint them. The direct walker therefore expands those
+classes rather than merely preserving the current command. Existing baselines still suppress the
+committed debt in the candidate fixture, but new arbitrary-file enforcement would be a behavior
+change requiring its own rollout audit.
+
+The import-wall fixture proves the incumbent rule rejects
+`src/feature-a/index.js -> src/feature-b/internal.js` and includes both resolved paths in its
+diagnostic. The candidate has no source parser or resolver at all. A regex-only substitute would
+also lose `require`, dynamic import, re-export, mock, query-suffix, and alias handling, so it was not
+treated as a viable parity implementation.
+
+Static ownership inspection found a second Electron-specific gap beyond import walls. The shipped
+Electron preset constructs six `createFolderStructure` policies for renderer, main, shared,
+preload, socket, and Vercel trees directly in `templates/electron/eslint.config.mjs`; its
+`guard.config.json` has no `structure.trees` representation. The prototype reads only those config
+trees, so it cannot even ingest this incumbent folder-topology responsibility. A replacement must
+first represent or migrate that preset and then add parity fixtures for all six trees.
+
+## Reproduction
+
+From a clean Devkit checkout on macOS with dependencies installed:
+
+```bash
+bun run build
+cd docs/benchmarks/experiments/2026-08-16-topology-guard
+node benchmark.mjs --output results.json
+```
+
+The harness:
+
+1. creates a disposable fixture;
+2. packs the checkout and installs Devkit into that fixture without lifecycle scripts;
+3. generates the same deterministic 481-file corpus for both runners;
+4. runs three discarded warm-ups and 20 alternating samples per lane;
+5. runs the coverage cases in fresh processes;
+6. records raw samples, diagnostics, host metadata, and the source commit in
+ [`results.json`](results.json); and
+7. deletes the package and fixture directories after the run.
+
+The committed raw result was captured on Darwin 25.5.0 arm64, 10 logical CPUs, Node 24.19.0, from
+source commit `4d624d059c8974aa58e0f3a27277cd6fd90383a4`. Host load was not isolated, so absolute p95 values
+should not be generalized to another machine. Alternating paired lanes and the size of the median
+deltas make the directional result clear; they do not rescue the coverage failure.
+
+## Revisit conditions
+
+Do not reopen the migration on startup-speed claims alone. Re-run this experiment only after a
+candidate can demonstrate all of the following on the fixture and then on Frink:
+
+- directory-level findings, including empty illegal directories;
+- a config representation and parity fixtures for all six hand-written Electron folder trees;
+- the current import forms, path aliases, query suffixes, and first-match/baseline wall semantics;
+- collision-safe generated debt and permanent exemption behavior;
+- stable, actionable diagnostics for placement, naming, and required siblings; and
+- the same or better process-tree CPU, wall, and RSS result at full coverage parity.
diff --git a/docs/benchmarks/experiments/2026-08-16-topology-guard/benchmark.mjs b/docs/benchmarks/experiments/2026-08-16-topology-guard/benchmark.mjs
new file mode 100644
index 0000000..16b486d
--- /dev/null
+++ b/docs/benchmarks/experiments/2026-08-16-topology-guard/benchmark.mjs
@@ -0,0 +1,294 @@
+#!/usr/bin/env node
+
+import { execFileSync, spawn, spawnSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { performance } from "node:perf_hooks";
+import { CASES, createFixture } from "./fixture.mjs";
+import { runImportWallFixture } from "./import-wall-current.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(HERE, "..", "..", "..", "..");
+const WARMUPS = 3;
+const SAMPLES = 20;
+const TIME_BIN = "/usr/bin/time";
+const PS_BIN = "/bin/ps";
+
+function processTreeRss(rootPid) {
+ try {
+ const rows = execFileSync(PS_BIN, ["-axo", "pid=,ppid=,rss="], { encoding: "utf8" })
+ .trim()
+ .split("\n")
+ .map((line) => line.trim().split(/\s+/).map(Number))
+ .filter(([pid, parent, rss]) => pid > 0 && parent >= 0 && rss >= 0);
+ const children = new Map();
+ const rssByPid = new Map();
+ for (const [pid, parent, rss] of rows) {
+ rssByPid.set(pid, rss);
+ const siblings = children.get(parent) ?? [];
+ siblings.push(pid);
+ children.set(parent, siblings);
+ }
+ const pending = [rootPid];
+ const seen = new Set();
+ let kib = 0;
+ while (pending.length > 0) {
+ const pid = pending.pop();
+ if (seen.has(pid)) continue;
+ seen.add(pid);
+ kib += rssByPid.get(pid) ?? 0;
+ pending.push(...(children.get(pid) ?? []));
+ }
+ return kib * 1024;
+ } catch {
+ return 0;
+ }
+}
+
+function parseTime(stderr) {
+ const value = (pattern, label) => {
+ const match = stderr.match(pattern);
+ if (!match) throw new Error(`/usr/bin/time output omitted ${label}`);
+ return Number(match[1]);
+ };
+ return {
+ userMs: value(/([0-9.]+)\s+user/, "user CPU") * 1000,
+ systemMs: value(/([0-9.]+)\s+sys/, "system CPU") * 1000,
+ directPeakRssBytes: value(/([0-9]+)\s+maximum resident set size/, "maximum resident set size"),
+ };
+}
+
+async function measure(script, cwd, args) {
+ const started = performance.now();
+ const child = spawn(TIME_BIN, ["-lp", process.execPath, script, ...args], {
+ cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stdout = "";
+ let stderr = "";
+ let treePeakRssBytes = 0;
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ const sampler = setInterval(() => {
+ treePeakRssBytes = Math.max(treePeakRssBytes, processTreeRss(child.pid));
+ }, 10);
+ const status = await new Promise((resolve, reject) => {
+ child.on("error", reject);
+ child.on("close", resolve);
+ });
+ clearInterval(sampler);
+ if (status !== 0) {
+ throw new Error(`timed command failed (${status}): ${stdout}\n${stderr}`);
+ }
+ const timing = parseTime(stderr);
+ return {
+ wallMs: performance.now() - started,
+ cpuMs: timing.userMs + timing.systemMs,
+ userMs: timing.userMs,
+ systemMs: timing.systemMs,
+ treePeakRssBytes: Math.max(treePeakRssBytes, timing.directPeakRssBytes),
+ directPeakRssBytes: timing.directPeakRssBytes,
+ };
+}
+
+const median = (values) => {
+ const sorted = [...values].sort((a, b) => a - b);
+ const middle = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
+};
+const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
+const summarize = (samples) =>
+ Object.fromEntries(
+ ["wallMs", "cpuMs", "userMs", "systemMs", "treePeakRssBytes", "directPeakRssBytes"].map(
+ (field) => [
+ field,
+ {
+ median: median(samples.map((sample) => sample[field])),
+ p95: p95(samples.map((sample) => sample[field])),
+ },
+ ],
+ ),
+ );
+
+function resetFixture(root) {
+ for (const relativePath of [
+ "src",
+ "eslint",
+ "guard.config.json",
+ "projectStructure.cache.json",
+ ]) {
+ rmSync(join(root, relativePath), { recursive: true, force: true });
+ }
+}
+
+function runCurrentCase(root, bin) {
+ const result = spawnSync(process.execPath, [bin, "gate"], { cwd: root, encoding: "utf8" });
+ const text = `${result.stdout ?? ""}${result.stderr ?? ""}`;
+ return {
+ code: result.status ?? 2,
+ errorCount: Number(text.match(/✖\s+(\d+)\s+problem/)?.[1] ?? 0),
+ text,
+ };
+}
+
+function runCandidateCase(root) {
+ const output = execFileSync(process.execPath, [join(HERE, "candidate.mjs"), "--json"], {
+ cwd: root,
+ encoding: "utf8",
+ });
+ return JSON.parse(output);
+}
+
+async function coverageMatrix(root, currentBin) {
+ const rows = [];
+ for (const fixtureCase of CASES) {
+ resetFixture(root);
+ createFixture(root, { caseId: fixtureCase.id, componentCount: 2 });
+ const current = runCurrentCase(root, currentBin);
+ const candidate = runCandidateCase(root);
+ rows.push({
+ ...fixtureCase,
+ current: { code: current.code, errorCount: current.errorCount, text: current.text ?? "" },
+ candidate: { code: candidate.code, diagnostics: candidate.diagnostics },
+ });
+ }
+
+ resetFixture(root);
+ mkdirSync(join(root, "src", "feature-a"), { recursive: true });
+ mkdirSync(join(root, "src", "feature-b"), { recursive: true });
+ writeFileSync(join(root, "src", "feature-a", "index.js"), "import '../feature-b/internal.js';\n");
+ writeFileSync(join(root, "src", "feature-b", "internal.js"), "export const internal = true;\n");
+ writeFileSync(
+ join(root, "guard.config.json"),
+ `${JSON.stringify({
+ scanRoots: ["src"],
+ sourceExtensions: ["js"],
+ structure: {
+ trees: [],
+ walls: [{ from: "src/feature-a/**", disallow: "src/feature-b/**" }],
+ },
+ })}\n`,
+ );
+ const current = await runImportWallFixture(root);
+ const candidate = runCandidateCase(root);
+ rows.push({
+ id: "import-wall",
+ expectedViolation: true,
+ currentBroaderEslintOwner: current,
+ candidate: { code: candidate.code, diagnostics: candidate.diagnostics },
+ });
+ return rows;
+}
+
+function installPackedDevkit(root, packRoot) {
+ writeFileSync(join(root, "package.json"), '{"private":true,"type":"module"}\n');
+ const packed = JSON.parse(
+ execFileSync("npm", ["pack", "--json", "--pack-destination", packRoot], {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ }),
+ );
+ const tarball = join(packRoot, packed[0].filename);
+ execFileSync("bun", ["add", "--cwd", root, tarball, "--ignore-scripts"], { stdio: "ignore" });
+ const packageRoot = join(root, "node_modules", "@norvalbv", "devkit");
+ const installedPackage = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
+ const driver = join(root, "current-driver.mjs");
+ writeFileSync(
+ driver,
+ "import { runStructureGate } from './node_modules/@norvalbv/devkit/dist/gate-engine/structure/run.mjs';\nawait runStructureGate(process.cwd());\n",
+ );
+ return {
+ bin: join(packageRoot, "dist", "gate-engine", "structure", "run.mjs"),
+ driver,
+ version: installedPackage.version,
+ };
+}
+
+async function benchmarkLane(root, caseId, currentScript, candidateScript) {
+ resetFixture(root);
+ createFixture(root, { caseId, componentCount: 80 });
+ for (let index = 0; index < WARMUPS; index += 1) {
+ await measure(currentScript, root, []);
+ await measure(candidateScript, root, ["--bench"]);
+ }
+ const samples = { current: [], candidate: [] };
+ for (let index = 0; index < SAMPLES; index += 1) {
+ const order =
+ index % 2 === 0
+ ? [
+ ["current", currentScript, []],
+ ["candidate", candidateScript, ["--bench"]],
+ ]
+ : [
+ ["candidate", candidateScript, ["--bench"]],
+ ["current", currentScript, []],
+ ];
+ for (const [name, script, args] of order) {
+ samples[name].push(await measure(script, root, args));
+ }
+ }
+ return {
+ fixtureCase: caseId,
+ samples,
+ summary: { current: summarize(samples.current), candidate: summarize(samples.candidate) },
+ };
+}
+
+async function main() {
+ const fixtureRoot = mkdtempSync(join(tmpdir(), "topology-benchmark-"));
+ const packRoot = mkdtempSync(join(tmpdir(), "topology-package-"));
+ const candidateScript = join(HERE, "candidate.mjs");
+ try {
+ const installed = installPackedDevkit(fixtureRoot, packRoot);
+ const lanes = {
+ clean: await benchmarkLane(fixtureRoot, "clean", installed.driver, candidateScript),
+ placement: await benchmarkLane(fixtureRoot, "placement", installed.driver, candidateScript),
+ };
+ const results = {
+ schemaVersion: 1,
+ recordedAt: new Date().toISOString(),
+ sourceCommit: execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ }).trim(),
+ host: {
+ uname: execFileSync("uname", ["-a"], { encoding: "utf8" }).trim(),
+ node: process.version,
+ logicalCpuCount: Number(
+ execFileSync("sysctl", ["-n", "hw.logicalcpu"], { encoding: "utf8" }).trim(),
+ ),
+ },
+ protocol: {
+ warmups: WARMUPS,
+ samples: SAMPLES,
+ order: "alternating current-first/candidate-first",
+ fixture: { componentCount: 80, files: 481, roots: 1 },
+ current: `packed @norvalbv/devkit@${installed.version} installed inside the fixture; driver invokes its runStructureGate API`,
+ rss: "10ms sampling; sum RSS of /usr/bin/time wrapper and all descendants, floored by wait4 direct-child peak RSS",
+ cpu: "/usr/bin/time -lp aggregate user + sys",
+ },
+ coverage: await coverageMatrix(fixtureRoot, installed.bin),
+ lanes,
+ };
+ const outputIndex = process.argv.indexOf("--output");
+ if (outputIndex >= 0) {
+ const output = join(process.cwd(), process.argv[outputIndex + 1]);
+ writeFileSync(output, `${JSON.stringify(results, null, 2)}\n`);
+ } else {
+ console.log(JSON.stringify(results, null, 2));
+ }
+ } finally {
+ rmSync(fixtureRoot, { recursive: true, force: true });
+ rmSync(packRoot, { recursive: true, force: true });
+ }
+}
+
+await main();
diff --git a/docs/benchmarks/experiments/2026-08-16-topology-guard/candidate.mjs b/docs/benchmarks/experiments/2026-08-16-topology-guard/candidate.mjs
new file mode 100644
index 0000000..a7cc9c7
--- /dev/null
+++ b/docs/benchmarks/experiments/2026-08-16-topology-guard/candidate.mjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+import { resolveGuardConfig, resolveTreeExtensions } from "../../../../gate-engine/config.mts";
+import { makeBaselineLoaders } from "../../../../gate-engine/structure/load-baseline.mts";
+import { walkTree } from "../../../../gate-engine/structure/walk.mts";
+
+export async function runCandidate(cwd = process.cwd()) {
+ try {
+ const config = resolveGuardConfig(cwd);
+ const loaders = makeBaselineLoaders(cwd);
+ const diagnostics = [];
+ for (const tree of config.structure?.trees ?? []) {
+ if (!tree.grammar || !tree.root || !tree.name) continue;
+ const absoluteRoot = join(cwd, tree.root);
+ if (!existsSync(absoluteRoot)) continue;
+ const ignored = new Set([
+ ...(await loaders.loadBaseline(tree.name)),
+ ...(await loaders.loadExempt(tree.name)),
+ ]);
+ const extensions = resolveTreeExtensions(config, tree);
+ for (const relativePath of walkTree(tree, absoluteRoot, extensions)) {
+ if (!ignored.has(relativePath)) {
+ diagnostics.push({ tree: tree.name, path: `${tree.root}/${relativePath}` });
+ }
+ }
+ }
+ return { code: diagnostics.length > 0 ? 1 : 0, diagnostics };
+ } catch (error) {
+ return {
+ code: 2,
+ diagnostics: [],
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const result = await runCandidate();
+ if (process.argv.includes("--json")) {
+ console.log(JSON.stringify(result));
+ } else if (!process.argv.includes("--bench")) {
+ for (const diagnostic of result.diagnostics) {
+ console.error(`${diagnostic.path}: topology violation`);
+ }
+ if (result.error) console.error(`guard-topology candidate: ${result.error}`);
+ }
+ process.exit(
+ process.argv.includes("--json") || process.argv.includes("--bench") ? 0 : result.code,
+ );
+}
diff --git a/docs/benchmarks/experiments/2026-08-16-topology-guard/feature-critique.md b/docs/benchmarks/experiments/2026-08-16-topology-guard/feature-critique.md
new file mode 100644
index 0000000..c83d160
--- /dev/null
+++ b/docs/benchmarks/experiments/2026-08-16-topology-guard/feature-critique.md
@@ -0,0 +1,95 @@
+# Feature critique: standalone filesystem topology guard
+
+**Story:** Shortcut sc-1678
+
+**Decision boundary:** adopt only if the candidate preserves the entire topology responsibility and
+measurably reduces CPU or agent-loop latency. Otherwise retain the ESLint path and record why.
+
+## Proposal
+
+Promote Devkit's existing config-driven filesystem walker from baseline generation into a direct
+`guard-topology` command, then replace the current
+`guard-structure -> ESLint -> eslint-plugin-project-structure` path.
+
+## Alternatives considered
+
+1. **Direct walker replacement.** Reuse `walkTree`, load the generated baseline and permanent
+ exemptions, and emit violations without starting ESLint. This has the smallest process-startup
+ and parser cost, but it is acceptable only at full responsibility parity.
+2. **Hybrid direct placement plus ESLint import walls.** Move directory placement to the walker but
+ retain ESLint for `independent-modules`. This may reduce part of the cost, but it keeps both
+ runners and introduces two diagnostic/configuration surfaces during every agent loop.
+3. **Retain ESLint for now.** Keep the current owner until a standalone implementation covers every
+ required class. This preserves behavior but leaves the measured startup and parser cost in place.
+
+The benchmark may prototype option 1, but the pre-registered acceptance rule prefers option 3 over
+either a coverage regression or an unmeasured hybrid.
+
+## Prior art inspected before the prototype
+
+- [`runStructureGate`](../../../../gate-engine/structure/run.mts), the current packaged
+ ESLint orchestration and 0/1/2 contract;
+- [`walkTree`](../../../../gate-engine/structure/walk.mts), the existing direct baseline walker;
+- the shared grammar-to-ESLint compiler and token predicates in
+ [`gate-engine/structure`](../../../../gate-engine/structure/);
+- the import-wall baseline generator's handling of resolved paths and loud failures in
+ [`generate-import-wall-baseline.mts`](../../../../cli/lib/generate/generate-import-wall-baseline.mts);
+- the current structure-governance ownership and baseline model in
+ [`docs/structure-governance.md`](../../../structure-governance.md);
+- the earlier [Frink lint-toolchain research](../2026-08-15-oxlint-js-plugin-frink/README.md) and
+ [`oxc-toolchain-migration`](../../../decisions/oxc-toolchain-migration.md) Target; and
+- the installed `eslint-plugin-project-structure@3.14.3` implementation and documentation for
+ folder structure, `enforceExistence`, cache deduplication, arbitrary extensions, and
+ `independent-modules` import resolution.
+
+## Critique by lens
+
+### Feasibility and data-flow correctness
+
+The existing `walkTree` is a strong placement prototype because it already consumes the same
+`guard.config.json` grammar and token predicates as the ESLint compiler. It is not yet a gate:
+
+- it returns only file paths intended for a grandfather baseline and deliberately drops directory
+ entries, so an empty illegal directory has no observable violation;
+- its ignored-directory contract is basename membership, while ESLint consumes generated glob
+ patterns; equivalence must be tested rather than assumed;
+- it has no import parser or resolver, so it cannot implement `independent-modules` import walls;
+- the generated baseline and `exempt.mjs` are asynchronous module inputs to ESLint, whereas the
+ walker itself currently does not load or subtract either set;
+- ESLint/plugin diagnostics are attached to linted files and can be deduplicated by the plugin's
+ cache. Exact message/count equality is therefore a stricter requirement than merely finding the
+ same broken subtree.
+
+### Runtime configurations
+
+The candidate must behave correctly for absent roots, multiple roots, source-extension overrides,
+CSS, HTML, assets, arbitrary extensions, empty directories, ignored directories, generated debt
+baselines, permanent exemptions, and malformed config/baseline inputs. Devkit's generic
+`guard-structure` command currently compiles grammar trees only. Electron's six folder trees and its
+import walls remain hand-written in the consumer-side ESLint configuration, while the Electron
+guard config has no `structure` block. A replacement claim must cover both owners before ESLint can
+be removed, rather than benchmarking only the generic command and silently narrowing “topology.”
+
+### UX and failure behavior
+
+A direct command could give agents materially earlier feedback and avoid loading a JS lint engine.
+However, different path prefixes, duplicate counts, or missing required-sibling messages would make
+existing baselines and remediation guidance misleading. The existing 0 clean / 1 violation / 2
+fail-open contract must remain stable. A hybrid would also make it unclear which command owns a
+reported structural error.
+
+### Security and trust boundaries
+
+The walker operates inside consumer repositories, so every path must remain consumer-cwd-relative.
+Import resolution is security-relevant: a naive regex over source text would miss dynamic imports,
+re-exports, mocks, aliases, and resolver behavior that the current plugin handles. A partial parser is
+not an acceptable substitute for the current wall.
+
+## Verdict before implementation
+
+**PROCEED WITH THE PROTOTYPE, WITH A RETAIN-BY-DEFAULT VERDICT.** Benchmark the direct walker on a
+pinned, reproducible fixture and record its coverage matrix and exact diagnostics. Adopt it only if
+all classes above pass and process-tree CPU or wall latency improves. Any import-wall,
+empty-directory, baseline, ignore, arbitrary-file, or unrepresented Electron-preset gap is
+independently sufficient to retain ESLint, regardless of the speed result. This is an implementation
+note under the existing `oxc-toolchain-migration` Target, not a new decision axis.
diff --git a/docs/benchmarks/experiments/2026-08-16-topology-guard/fixture.mjs b/docs/benchmarks/experiments/2026-08-16-topology-guard/fixture.mjs
new file mode 100644
index 0000000..b094d95
--- /dev/null
+++ b/docs/benchmarks/experiments/2026-08-16-topology-guard/fixture.mjs
@@ -0,0 +1,130 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+const write = (root, relativePath, contents = "") => {
+ const path = join(root, relativePath);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents);
+};
+
+const CONFIG = {
+ scanRoots: ["src"],
+ sourceExtensions: ["ts", "tsx"],
+ structure: {
+ trees: [
+ {
+ name: "topology",
+ root: "src",
+ sourceExtensions: ["ts", "tsx"],
+ ignoredDirs: ["ignored"],
+ grammar: {
+ files: ["index.ts"],
+ recurse: "component",
+ rules: {
+ component: {
+ folderName: "{pascal_dir}",
+ enforceExistence: "index.ts",
+ files: [
+ "index.ts",
+ "{pascal_tsx}",
+ "{css}",
+ "template.html",
+ "logo.svg",
+ "notes.weird",
+ ],
+ },
+ },
+ },
+ },
+ ],
+ walls: [],
+ },
+};
+
+export const CASES = Object.freeze([
+ { id: "clean", expectedViolation: false },
+ { id: "placement", expectedViolation: true },
+ { id: "required-sibling", expectedViolation: true },
+ { id: "naming", expectedViolation: true },
+ { id: "css", expectedViolation: true },
+ { id: "html", expectedViolation: true },
+ { id: "asset", expectedViolation: true },
+ { id: "arbitrary-extension", expectedViolation: true },
+ { id: "empty-directory", expectedViolation: true },
+ { id: "ignored-directory", expectedViolation: false },
+ { id: "generated-baseline", expectedViolation: false },
+ { id: "permanent-exemption", expectedViolation: false },
+]);
+
+function addCase(root, caseId) {
+ switch (caseId) {
+ case "clean":
+ return;
+ case "placement":
+ write(root, "src/loose.ts", "export const loose = true;\n");
+ return;
+ case "required-sibling":
+ write(root, "src/Missing/Missing.tsx", "export const Missing = () => null;\n");
+ return;
+ case "naming":
+ write(root, "src/bad-name/index.ts", "export {};\n");
+ return;
+ case "css":
+ write(root, "src/global.css", ":root { color: red; }\n");
+ return;
+ case "html":
+ write(root, "src/index.html", "