#!/usr/bin/env bun /** * Lint: workspace-scoped on-disk paths must flow through `WorkspaceContext`. * * Every `{workDir}/workspaces/{wsId}/...` derivation goes through a single * typed handle (`src/workspace/context.ts`). A path built by hand is a place * where a caller could mistype `wsId`, skip validation, or escape the * workspace tree via path traversal. * * What this script flags: any call to `join(...)` whose argument list * matches the literal sequence `, "workspaces", , ...` * — i.e. building a path under a specific workspace by hand rather than * via `ctx.getDataPath(...)`. * * What it allows: * - `join(workDir, "workspaces")` (no wsId) — the parent directory * itself, which is correct in scope-classification code. * - Sites inside the implementation files that DEFINE the layout * (`src/workspace/context.ts`, `src/workspace/workspace-store.ts`, and * `src/conversation/paths.ts` for the workspace-owned * `workspaces//conversations/...` subtree) — those are the * source of truth and the lint shouldn't fight itself. * - A `// lint-ok:workspace-path` marker on the line immediately * above the call, for the rare future case where the typed handle * genuinely doesn't apply. * * Scope: only `src/**\/*.ts`, minus vendored trees. Tests are out of scope — * they build fixture layouts by hand on purpose. */ import { readFileSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { Glob } from "bun"; import * as ts from "typescript"; const ROOT = join(import.meta.dirname ?? __dirname, ".."); const SRC_ROOT = join(ROOT, "src"); const ALLOW_MARKER = "lint-ok:workspace-path"; // Files that legitimately define the workspace path layout. The lint // would otherwise flag the definitions it exists to protect. const ALLOWED_FILES = new Set( [ "workspace/context.ts", "workspace/workspace-store.ts", // Defines the `workspaces//conversations/...` workspace-owned conversation // layout — the one sanctioned site that hand-builds that subtree. "conversation/paths.ts", // Defines the `workspaces//files/...` workspace-owned file layout — // the one sanctioned site that hand-builds that subtree (`workspaceFilesDir`). "files/paths.ts", ].map((f) => f.split("/").join(sep)), ); interface Violation { file: string; line: number; column: number; snippet: string; } /** * Returns true iff `node` is a call to `join(...)` whose second * argument is the string literal "workspaces" AND a third argument * exists (the workspace id). Matches both `join("workspaces", ...)` * shapes and the more common `join(workDir, "workspaces", wsId, ...)`. * * We accept any callee named `join` (whether `join`, `path.join`, or * `node:path`'s `join`) — the lint is conservative; the pattern is * specific enough that false positives are unlikely. */ function isWorkspaceJoinWithWsId(node: ts.CallExpression): boolean { const callee = node.expression; const calleeName = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null; if (calleeName !== "join") return false; // We need at least one "workspaces" string literal followed by another arg // (the wsId positional). Scan args looking for that adjacency. const args = node.arguments; for (let i = 0; i < args.length - 1; i++) { const arg = args[i]; if (!arg) continue; if ( (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) && arg.text === "workspaces" ) { // The next arg is treated as the wsId placeholder. It must NOT be // a string literal "workspaces" or empty (which would be parent- // dir-with-extra-noise rather than a wsId). The legitimate // pattern `join(workDir, "workspaces")` exits the loop without // matching because there's no next arg after position i. const next = args[i + 1]; if (!next) return false; // Allow the pattern `join(workDir, "workspaces", someConstStringLikeAnotherFolder)` // ONLY when the next arg is itself a static folder name that we know // isn't a workspace id — but in practice this never appears. Be // conservative: any positional after "workspaces" trips the lint. return true; } } return false; } function hasAllowMarker(node: ts.Node, sourceFile: ts.SourceFile, src: string): boolean { const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); if (line === 0) return false; const lines = src.split("\n"); for (let i = line - 1; i >= Math.max(0, line - 5); i--) { const lineText = lines[i] ?? ""; if (lineText.includes(ALLOW_MARKER)) return true; const trimmed = lineText.trim(); if ( trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*") ) { continue; } return false; } return false; } function scanFile(absPath: string, violations: Violation[]): void { const relPath = relative(SRC_ROOT, absPath); if (ALLOWED_FILES.has(relPath)) return; const src = readFileSync(absPath, "utf-8"); const sourceFile = ts.createSourceFile( absPath, src, ts.ScriptTarget.Latest, true, absPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); function visit(node: ts.Node): void { if (ts.isCallExpression(node) && isWorkspaceJoinWithWsId(node)) { if (!hasAllowMarker(node, sourceFile, src)) { const { line, character } = sourceFile.getLineAndCharacterOfPosition( node.getStart(sourceFile), ); violations.push({ file: relative(ROOT, absPath), line: line + 1, column: character + 1, snippet: (src.split("\n")[line] ?? "").trim(), }); } } ts.forEachChild(node, visit); } visit(sourceFile); } async function main(): Promise { const violations: Violation[] = []; const glob = new Glob("**/*.ts"); let scanned = 0; for await (const rel of glob.scan({ cwd: SRC_ROOT })) { const abs = join(SRC_ROOT, rel); if (abs.includes("/node_modules/") || abs.includes("/dist/")) continue; if (abs.endsWith(".d.ts")) continue; scanned++; scanFile(abs, violations); } if (violations.length > 0) { console.error(`✗ Found ${violations.length} hand-built workspace-scoped path(s) in src/:\n`); for (const v of violations) { console.error(` ${v.file}:${v.line}:${v.column}`); console.error(` ${v.snippet}\n`); } console.error( "Workspace-scoped paths must flow through WorkspaceContext (src/workspace/context.ts).", ); console.error( 'Use `ctx.getDataPath(scope, ...subpaths)` instead of `join(workDir, "workspaces", wsId, ...)`.', ); console.error( `Legitimate exceptions (rare) require a // ${ALLOW_MARKER} comment on the line above the call.`, ); process.exit(1); } console.log(`✓ No raw workspace path construction in ${scanned} src/ files`); } main().catch((err: unknown) => { console.error(err); process.exit(1); });