import { test } from "node:test"; import assert from "node:assert/strict"; import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; // Run with: // pnpm --filter yt-dlp-transcript-common test // // THE LAYERING, ENFORCED. `plans/one-core.md` names six core layers with // dependency strictly downward: // // model (lib/) -> corpus -> operations -> dispatch (jobs/, controller/) // -> publish -> views -> ui (components/) // // Layering that exists only by convention is layering that has already been // broken — the survey behind that plan counted sixteen back-edges nobody meant // to add. This test is the same trick as ./controller/noCorpusWalkInRenderPaths. // test.ts, which is the one architecture guard the repo already had and which // works: a context-blind grep, cheap enough to run on every commit. // // THE ALLOW-LIST CAN ONLY SHRINK. A back-edge that is not on it fails the // build; an entry on it that is no longer a back-edge ALSO fails the build, so // the list cannot rot into a description of a tree that moved on. Deleting an // entry is the deliverable of a later slice — never add one to get green. const HERE = path.dirname(fileURLToPath(import.meta.url)); // Which directory may not import which. Read as "lib/ may not import // controller/ or jobs/". const FORBIDDEN: Record = { // `components` joined this row in one-core phase 2 slice S3. The model layer // importing the view layer was four edges: searchEval reaching for the leaf // pipeline and the IndexedDB layer memo, aiHandoff for a hit type, and // searchQuery for a mode union. All four inverted — the pipeline and the two // types moved down, and the fetch and the memo are now injected — so the // guard turns on with nothing added to the allow-list to pay for it. lib: ["controller", "jobs", "components", "views", "publish"], jobs: ["controller", "views", "publish"], controller: ["views", "publish"], components: ["controller", "jobs", "ytdlp", "publish"], // `views/` is the view-model layer (one-core phase 3 slice 1): pure functions // that fold live state into a payload. It sits ABOVE dispatch and BELOW ui, // so it may read lib/, controller/ and jobs/ for pure helpers and types, and // may not reach up into components/ or sideways into the entry points. views: ["components", "ytdlp", "bin", "social"], // `publish/` is the publish layer (one-core phase 4 slice 1): building a site, // uploading its archives, deploying it. It sits above dispatch and below views, // so it may read lib/, jobs/ and controller/, and may not reach up into // views/ or components/. publish: ["views", "components"], }; // The directories walked. `bin/` and `social/` are scanned so a back-edge cannot // hide behind an entry point, even though nothing forbids their imports yet. const ROOTS = [ "lib", "controller", "jobs", "components", "social", "ytdlp", "bin", "views", "publish", ]; // Today's back-edges, ` -> `, each with why it is still // here. THIS LIST IS A DEBT LEDGER, not a policy. const ALLOWED: Record = { // Three progress-parser factories for the transcription apps. Parsing a // subprocess's stdout is dispatch's job, not the model's; the fix is for the // app descriptor to name a parser the runner resolves, which is phase 1 work. "lib/transcriptionApps.ts -> jobs/progressParsers": "createTranscribeProgressParser and friends; the descriptor should name a parser instead (phase 1)", // The operation registry's `run(unit)` closures call the controllers that do // the work. This is THE structural back-edge the plan's layering fixes: an // OperationDescriptor should carry a run hook the dispatch layer supplies, // not import the engine. Phase 1 step 4 (one operationBatch) is where it goes. "lib/operations.ts -> controller/diarizeOne": "registry run() closure; inverts when descriptors take a run hook (phase 1)", "lib/operations.ts -> controller/attributionTarget": "registry run() closure; inverts when descriptors take a run hook (phase 1)", "lib/operations.ts -> controller/attributeOne": "registry run() closure; inverts when descriptors take a run hook (phase 1)", "lib/operations.ts -> controller/normalizeTranscript": "registry state() freshness check; inverts with the run hook (phase 1)", "lib/operations.test.ts -> controller/attributeOne": "test of the above; moves with it", "lib/operations.test.ts -> controller/normalizeTranscript": "test of the above; moves with it", // Two schedulers reaching into the controller for the thing they schedule. // Real work, not a type: the snapshot builder and the remote-capacity probe. "jobs/snapshotScheduler.ts -> controller/channelSnapshot": "schedules the snapshot build it imports; inverts when dispatch is one scheduler (phase 1)", "jobs/workerPool.ts -> controller/remoteCapacity": "probes remote worker capacity; same (phase 1)", }; // Static `import ... from "x"`, `export ... from "x"` and bare `import "x"`. // Deliberately simple, exactly like the guard next door: a regex that reads the // specifier is enough, and a dynamic import that dodges it is a bigger problem // than this test. const IMPORT_RE = /(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*["']([^"']+)["']|(?:^|\n)\s*import\s*["']([^"']+)["']/g; async function walk(dir: string): Promise { const out: string[] = []; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { if (e.name === "node_modules" || e.name === ".next") continue; out.push(...(await walk(full))); } else if (/\.tsx?$/.test(e.name)) { out.push(full); } } return out; } const topDir = (rel: string) => rel.split(path.sep)[0]; async function backEdges(): Promise { const found: string[] = []; for (const root of ROOTS) { for (const file of await walk(path.join(HERE, root))) { const rel = path.relative(HERE, file); const from = topDir(rel); const forbidden = FORBIDDEN[from]; if (!forbidden) continue; const source = await readFile(file, "utf8"); IMPORT_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = IMPORT_RE.exec(source))) { const spec = m[1] ?? m[2]; // Only relative specifiers can cross a layer inside this package. if (!spec.startsWith(".")) continue; const target = path.relative( HERE, path.resolve(path.dirname(file), spec), ); const to = topDir(target); if (to === from) continue; if (!forbidden.includes(to)) continue; const edge = `${rel} -> ${target}`; if (!found.includes(edge)) found.push(edge); } } } return found.sort(); } test("no new back-edges between common's layers", async () => { const found = await backEdges(); // Guard the guard: an empty walk would make every assertion below vacuous. assert.ok( found.length > 0 || Object.keys(ALLOWED).length === 0, "the scan found nothing at all — did the directory layout move?", ); const unexpected = found.filter((e) => !(e in ALLOWED)); assert.deepEqual( unexpected, [], `NEW back-edge(s) in common/. lib/ may not import controller/, jobs/, publish/, ` + `components/ or views/; ` + `jobs/ and controller/ may not import views/ or publish/; components/ may not ` + `import controller/, jobs/, ytdlp/ or publish/; publish/ may not import views/ or components/; views/ may not import ` + `components/. Move the type or the function down a ` + `layer instead of adding it to ALLOWED. Found: ${unexpected.join(", ")}`, ); }); test("the back-edge allow-list has no stale entries", async () => { const found = await backEdges(); const stale = Object.keys(ALLOWED).filter((e) => !found.includes(e)); assert.deepEqual( stale, [], `these are no longer back-edges — delete them from ALLOWED so the list ` + `keeps meaning what it says: ${stale.join(", ")}`, ); }); // ── THE VIEW LAYER'S TWO EXTRA RULES ──────────────────────────────────────── // // The back-edge scan above only looks at RELATIVE specifiers (`:137`), because // only a relative specifier can cross a layer inside this package. That is // exactly the wrong shape for `views/`, whose whole promise is negative: a // view-model is a pure function of its arguments, so what matters is what it // CANNOT reach — the framework, the filesystem, the singletons and the clock. // Two cheap checks buy that promise. // 1. No bare specifier a pure view has no business naming. // // react/next/server-only/client-only would make a view a component; `node:*` // would make it a reader and would drag `node:fs` into a `"use client"` graph // the moment a client component imported it. The PACKAGE'S OWN NAME is on the // list too, and that one is not paranoia: a file moved here that still says // `yt-dlp-transcript-common/controller/channels` resolves fine, compiles fine, // and is invisible to the relative-specifier scan above — so a forgotten // rewrite would silently exempt itself from the layer guard. Here it fails // loudly instead. const BARE_FORBIDDEN: Record = { views: /^(react|react-dom|next|server-only|client-only)(\/|$)|^node:|^yt-dlp-transcript-common(\/|$)/, }; async function bareEdges(): Promise<{ edges: string[]; scanned: number }> { const edges: string[] = []; let scanned = 0; for (const root of Object.keys(BARE_FORBIDDEN)) { const re = BARE_FORBIDDEN[root]; for (const file of await walk(path.join(HERE, root))) { // Tests legitimately import node:test and node:assert. Never loosen the // regex to accommodate them — skip the files instead. if (/\.test\.tsx?$/.test(file)) continue; scanned++; const rel = path.relative(HERE, file); const source = await readFile(file, "utf8"); IMPORT_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = IMPORT_RE.exec(source))) { const spec = m[1] ?? m[2]; if (spec.startsWith(".")) continue; if (!re.test(spec)) continue; const edge = `${rel} -> ${spec}`; if (!edges.includes(edge)) edges.push(edge); } } } return { edges: edges.sort(), scanned }; } test("views/ imports nothing from react, next, node: or the package's own name", async () => { const { edges, scanned } = await bareEdges(); // Guard the guard: ROOTS and BARE_FORBIDDEN are both explicit lists, so a // directory that was never added scans zero files and passes vacuously. assert.ok( scanned > 0, "the views/ scan found no files — is common/views/ still there, and is it in ROOTS?", ); assert.deepEqual( edges, [], `a view-model may not import the framework, the runtime or this package by ` + `name (imports inside common/ are relative — a package-name import hides ` + `from the layer guard). Found: ${edges.join(", ")}`, ); }); // 2. No singleton getter, no disk reader, no clock. // // This is the noCorpusWalkInRenderPaths trick, and for the same reason: the // property is about what a call GRAPH reaches, which no import list shows. // Every name below either CONSTRUCTS a lazy singleton, reads the corpus, or // makes the function non-deterministic. All of them are injected instead — // `views/inputs.ts` is the shape they arrive in, `Date.now` as `now()`. // // THE MATCH IS TEXTUAL AND CONTEXT-BLIND, so a comment that merely mentions one // of these with its parenthesis fails too. That is deliberate: a guard that // skipped comments is a guard a real call can hide behind, and the cost of the // false positive is one reworded comment. const VIEW_BANNED = [ "getRegistry(", "getScheduler(", "getWorkerPool(", "getPaths(", "getSettings(", "getAutoRunnerStatus(", "readChannelStat(", "readJobMeta(", "listChannelBriefs(", "listChannelConfigs(", "readSchedulerState(", "readAutoQueueState(", "computeLeafPending(", "readWorkerDefaults(", "diskGate(", "Date.now(", ]; test("views/ never calls a singleton getter, a disk reader or the clock", async () => { const files = (await walk(path.join(HERE, "views"))).filter( (f) => !/\.test\.tsx?$/.test(f), ); assert.ok( files.length > 0, "the views/ scan found no files — is common/views/ still there?", ); const offenders: string[] = []; for (const file of files) { const source = await readFile(file, "utf8"); for (const banned of VIEW_BANNED) { if (source.includes(banned)) { offenders.push(`${banned} in ${path.relative(HERE, file)}`); } } } assert.deepEqual( offenders.sort(), [], `a view-model takes its live state as an argument. These construct a ` + `singleton, read disk or read the clock — pass the value in through ` + `views/inputs.ts instead, and have the editor's shell do the reading. ` + `Found: ${offenders.join(", ")}`, ); });