Ctrl K
ComunidadGuías…

scripts/check-workspace-paths.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 `<anything>, "workspaces", <wsId>, ...`
 * — 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/<wsId>/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/<wsId>/conversations/...` workspace-owned conversation
    // layout — the one sanctioned site that hand-builds that subtree.
    "conversation/paths.ts",
    // Defines the `workspaces/<wsId>/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<void> {
  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);
});