Ctrl K
ComunidadGuías…

scripts/check-conversation-paths.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
/**
 * Lint: conversations are workspace-owned — never built at the flat top level.
 *
 * A conversation lives under the workspace (workspace) it runs in, with the owner
 * as a privacy sub-partition:
 *
 *   workspaces/<wsId>/conversations/<ownerId>/<convId>.jsonl          private user chats
 *   workspaces/<wsId>/conversations/_runs/<taskId>/<convId>.jsonl  task runs
 *
 * The flat `{workDir}/conversations/<convId>.jsonl` layout is gone. Any new
 * occurrence of the flat construction — joining `"conversations"` directly onto
 * a workDir-ish root, or spelling `…/conversations/…` outside a
 * `workspaces/<wsId>/` subtree — is a regression: it drops the workspace wall the
 * permission model depends on.
 *
 * What this script flags: a `join(...)` whose `"conversations"` segment is NOT
 * qualified by a workspace root before it (neither a literal `"workspaces",
 * <wsId>` pair nor a workspace-scoped base like `this.workspacesRoot` /
 * `getWorkspaceScopedDir()`), and the equivalent flat template / string-literal
 * path. The workspace-partitioned shape is the required, allowed shape.
 *
 * What it allows:
 *   - `src/conversation/paths.ts` — the single sanctioned construction (and
 *     parse) site for the `workspaces/<wsId>/conversations/...` layout. It *is*
 *     the definition this lint protects.
 *   - Workspace-scoped joins off a workspace root (`join(this.workspacesRoot, wsId,
 *     "conversations")`) — the `"conversations"` segment is already under a
 *     workspace, so these are the intended shape, not the flat one.
 *   - A `// lint-ok:conversation-path` marker on the line immediately above the
 *     construction, for the rare future case the typed helpers don't cover.
 *
 * Scope: `src/**\/*.ts`. Tests are out of scope (migration tests deliberately
 * construct the flat source paths to assert the migration moves them away).
 */

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:conversation-path";

// Files (relative to repo root) that legitimately reference the flat layout
// because they DEFINE the workspace-owned layout. The lint would otherwise
// fight the definitions it exists to protect.
const ALLOWED_FILES = new Set(
  [
    // The single sanctioned site that builds + parses the workspace-owned
    // `workspaces/<wsId>/conversations/...` layout.
    "src/conversation/paths.ts",
  ].map((f) => f.split("/").join(sep)),
);

interface Violation {
  file: string;
  line: number;
  column: number;
  snippet: string;
}

/** The callee's simple name for `f(...)` / `x.f(...)`, else `null`. */
function calleeName(node: ts.CallExpression): string | null {
  const callee = node.expression;
  return ts.isIdentifier(callee)
    ? callee.text
    : ts.isPropertyAccessExpression(callee)
      ? callee.name.text
      : null;
}

/** The static string value of a string / no-substitution-template literal, else `null`. */
function staticText(node: ts.Expression | undefined): string | null {
  if (!node) return null;
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
  return null;
}

const WORKSPACE_RE = /workspace/i;

/**
 * True iff `node` carries a workspace-root signal — a `"workspaces"`-ish string
 * literal, or an identifier / property / call whose name references a workspace
 * (`this.workspacesRoot`, `getWorkspaceScopedDir()`, …). Used to decide whether
 * a `"conversations"` segment is already workspace-scoped (qualified by a workspace)
 * rather than flat.
 */
function referencesWorkspaces(node: ts.Expression | undefined): boolean {
  if (!node) return false;
  const text = staticText(node);
  if (text !== null) return WORKSPACE_RE.test(text);
  if (ts.isIdentifier(node)) return WORKSPACE_RE.test(node.text);
  if (ts.isPropertyAccessExpression(node)) return WORKSPACE_RE.test(node.name.text);
  if (ts.isCallExpression(node)) {
    const name = calleeName(node);
    return name !== null && WORKSPACE_RE.test(name);
  }
  return false;
}

/** True iff any argument before `idx` qualifies the path with a workspace root. */
function hasWorkspaceQualifierBefore(args: ts.NodeArray<ts.Expression>, idx: number): boolean {
  for (let i = 0; i < idx; i++) {
    if (referencesWorkspaces(args[i])) return true;
  }
  return false;
}

/**
 * Returns true iff `node` is `join(...)` building a FLAT conversations path —
 * a `"conversations"` string-literal argument that is NOT qualified by a
 * workspace root before it. `join(workDir, "conversations", file)` flags;
 * `join(workDir, "workspaces", wsId, "conversations", file)` and
 * `join(this.workspacesRoot, wsId, "conversations")` do not (already
 * workspace-scoped).
 *
 * Exported for the self-test under `test/unit/scripts/`.
 */
export function isFlatConversationJoin(node: ts.CallExpression): boolean {
  if (calleeName(node) !== "join") return false;
  const args = node.arguments;
  for (let j = 0; j < args.length; j++) {
    if (staticText(args[j]) !== "conversations") continue;
    if (!hasWorkspaceQualifierBefore(args, j)) return true;
  }
  return false;
}

/** A `conversations` path segment anywhere in an assembled path. */
const CONV_SEGMENT_RE = /(^|\/)conversations(\/|$)/;
/** The workspace-owned shape: `workspaces/<id>/conversations`. */
const WORKSPACE_CONV_RE = /workspaces\/[^/]+\/conversations(\/|$)/;
/** A flat conversation FILE literal: `…/conversations/<convId>.jsonl`. */
const FLAT_CONV_FILE_RE = /(^|\/)conversations\/[^/]+\.jsonl$/;

/**
 * Returns true iff `node` is a template literal that spells a FLAT
 * `…/conversations/…` path — a `conversations` segment NOT under
 * `workspaces/<id>/`. Catches the
 * `` `${workDir}/conversations/${id}.jsonl` `` shape that `join` would
 * otherwise express piecewise.
 *
 * Exported for the self-test under `test/unit/scripts/`.
 */
export function isFlatConversationTemplate(node: ts.TemplateExpression): boolean {
  let assembled = node.head.text;
  for (const span of node.templateSpans) {
    // Placeholder so adjacency-matching works on the literal text between
    // substitutions.
    assembled += "<expr>";
    assembled += span.literal.text;
  }
  if (!CONV_SEGMENT_RE.test(assembled)) return false;
  return !WORKSPACE_CONV_RE.test(assembled);
}

/**
 * Returns true iff `node` is a string literal hardcoding a FLAT conversation
 * file path (`…/conversations/<id>.jsonl` not under `workspaces/<id>/`).
 * Resource URIs (`ui://conversations/browser`) and package routes
 * (`@scope/conversations`) share the token but aren't on-disk layout, so they
 * don't match.
 *
 * Exported for the self-test under `test/unit/scripts/`.
 */
export function isFlatConversationStringLiteral(node: ts.StringLiteral): boolean {
  const text = node.text;
  if (text.includes("://")) return false;
  if (!FLAT_CONV_FILE_RE.test(text)) return false;
  return !WORKSPACE_CONV_RE.test(text);
}

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(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 record(node: ts.Node): void {
    if (hasAllowMarker(node, sourceFile, src)) return;
    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(),
    });
  }

  function visit(node: ts.Node): void {
    if (ts.isCallExpression(node) && isFlatConversationJoin(node)) {
      record(node);
    } else if (ts.isTemplateExpression(node) && isFlatConversationTemplate(node)) {
      record(node);
    } else if (ts.isStringLiteral(node) && isFlatConversationStringLiteral(node)) {
      record(node);
    }
    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} flat conversation 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(
      "Conversations are workspace-owned: `workspaces/<wsId>/conversations/<ownerId>/<convId>.jsonl`.",
    );
    console.error(
      "Build the directory via `workspaceConversationsDir()` (src/conversation/paths.ts) or the runtime's workspace conversation store — never the flat `conversations/` path.",
    );
    console.error(
      `Legitimate exceptions (rare) require a // ${ALLOW_MARKER} comment on the line above the construction.`,
    );
    process.exit(1);
  }

  console.log(`✓ No flat conversation paths in ${scanned} src/ files`);
}

// Gate the side effect on direct invocation. Unit tests `import` this
// module to exercise the AST predicates above without triggering the
// full src/ scan + process.exit.
if (import.meta.main) {
  main().catch((err: unknown) => {
    console.error(err);
    process.exit(1);
  });
}