scripts/check-tool-namespace.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Lint: cross-workspace tool names go through `namespacedToolName` /
* `parseNamespacedToolName` in `src/tools/namespace.ts`.
*
* Stage 2 (cross-workspace refactor) makes `ws_<id>-<toolName>` the
* canonical cross-workspace tool-name shape. The primitive in
* `src/tools/namespace.ts` is the **single construction site** for that
* form. This lint enforces the constraint structurally so a future
* change to the format (separator, prefix, escape semantics) propagates
* via one helper rather than scattered string concat.
*
* What this script flags (both are regressions):
*
* (a) **Construction-by-hand** of the `ws_<id>-<toolName>` form:
* - String literal containing the substring `ws_<id>-` where the
* id slot is non-empty.
* - Template literal whose head text matches `^ws_…-` OR whose
* head is exactly `"ws_"` and the literal after the first span
* begins with `-` (the `` `ws_${wsId}-${toolName}` `` shape).
* - String concat with the `ws_` prefix immediately followed by
* a `-` literal further down the chain.
*
* (b) **Parse-by-hand**: `.split("-")` called on an identifier whose
* name strongly suggests a namespaced tool name (matches a small
* heuristic regex — `namespaced`, `qualifiedToolName`,
* `fullToolName`, etc.). The intent is to nudge callers to use
* `parseNamespacedToolName` instead, which validates the shape
* and throws on malformed input.
*
* What it allows:
* - `src/tools/namespace.ts` — the primitive defines the format and is
* the only legal construction/parse site.
* - A `// lint-ok:tool-namespace` marker on the line immediately
* above the construction, for the rare future case where the
* helper genuinely can't be used (e.g. a wire-format adapter that
* must accept the legacy shape unchanged).
*
* Scope: `src/**\/*.ts` and `web/src/**\/*.{ts,tsx}`. The web client has
* no parser for the form (its wire names are bare), so neither tier may
* build or split one by hand. Tests are out of scope (fixtures construct
* the shape deliberately).
*
* Known gap: this catches `ws_<id>-` *construction* and `.split("-")`
* *parsing*. It does NOT catch slicing the bare source out of a wire
* name via `indexOf("__")` / `slice(0, …)`, and the fix for that is a
* helper rather than a linter predicate (a `__`-slice predicate
* false-positives on legitimate display-only suffix extraction like
* `stripServerPrefix`). Which helper depends on the grammar being
* decomposed: `splitInnerToolName` (`src/util/tool-name.ts`) for the
* `<source>__<tool>` split every dispatch door shares,
* `parseNamespacedToolName` for the `ws_<id>-` prefix this lint owns,
* and `appNameFromToolName` (`web/src/lib/namespaced-tool.ts`) for the
* web tier, which can import neither.
*/
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 WEB_SRC_ROOT = join(ROOT, "web", "src");
// Both the platform `src/` and the web client `web/src/` are scanned: a
// web-side hand-parse is the regression that slipped through when this lint
// scanned `src/` only.
const SCAN_ROOTS = [SRC_ROOT, WEB_SRC_ROOT];
const ALLOW_MARKER = "lint-ok:tool-namespace";
// Relative to its scan root: the platform primitive is the only legal
// construction/parse site.
const ALLOWED_FILES = new Set(["tools/namespace.ts"].map((f) => f.split("/").join(sep)));
/**
* Identifiers whose name suggests "this is a namespaced tool name."
* Conservative on purpose — flagging any `.split("/")` would generate
* noise on URL parsing, server-name normalization, and other unrelated
* code paths. The bindings we mean to catch in this codebase have
* `namespaced`, `qualified`, or `fullTool` in their name, OR are
* literally `toolName`. We deliberately do NOT match the bare
* identifier `name` — too many call sites use `name` for unrelated
* things (e.g. reverse-DNS server names in `src/connectors/runtime/paths.ts`).
* If a future call site genuinely binds a namespaced tool name to
* `name`, the lint surfaces it via the construction predicates first;
* splitting it back open is then trivially obvious in review.
*/
const NAMESPACED_BINDING_RE = /^(?:namespaced|qualified)[A-Za-z_]*name$|^fullToolName$|^toolName$/i;
interface Violation {
file: string;
line: number;
column: number;
snippet: string;
reason: string;
}
// ── Construction predicates (a) ─────────────────────────────────────
/**
* True for a string literal whose text contains `ws_<id>-` where the
* `<id>` slot is non-empty. Catches hard-coded `"ws_3f9a1c7e0b2d4856-crm__search"`
* and similar.
*
* Workspace ids match `ws_[a-f0-9]{16}` (no hyphens) per
* `WORKSPACE_ID_PATTERN`, so the regex char class excludes `-`. The
* first `-` after `ws_<id>` is unambiguously the workspace/tool
* separator. Empty workspace slot (`"ws_-foo"`) is intentionally NOT
* flagged here — that's a degenerate shape the primitive rejects,
* and we don't want to false-positive on unrelated strings.
*/
export function isNamespacedToolStringLiteral(node: ts.StringLiteral): boolean {
return /ws_[a-zA-Z0-9_]+-/.test(node.text);
}
/**
* True for a template literal that assembles a `ws_<id>-<rest>` shape.
* Handles two common forms:
* - Head begins with `ws_<X>-` (literal id, dynamic rest).
* - Head is exactly `"ws_"` (i.e. the template starts with the
* workspace prefix) AND the literal after the first span starts
* with `-` (`` `ws_${wsId}-${toolName}` ``).
*
* The exact-`"ws_"` requirement (instead of `endsWith("ws_")`) avoids
* false positives on unrelated templates whose head merely contains
* `ws_` mid-string, e.g. `` `prefix-ws_${id}-...` ``. The latter is
* not a Stage-2 namespaced tool name and shouldn't be flagged.
*
* No-substitution templates (no `${}`) are routed through
* `isNamespacedToolStringLiteral` by the visitor — keeping the two
* predicates strictly disjoint at the AST level.
*/
export function isNamespacedToolTemplate(node: ts.TemplateExpression): boolean {
// Form 1: literal id in head.
if (/ws_[a-zA-Z0-9_]+-/.test(node.head.text)) return true;
// Form 2: head is exactly `"ws_"`, first span's trailing literal
// begins with `-`.
if (node.head.text === "ws_") {
const firstSpan = node.templateSpans[0];
if (firstSpan?.literal.text.startsWith("-")) return true;
}
return false;
}
/** Text of a string or no-substitution-template literal, else null. */
function literalText(node: ts.Node): string | null {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null;
}
/** Flattens a `+` chain into its operand nodes, in source order. */
function flattenPlusChain(node: ts.BinaryExpression): ts.Node[] {
const operands: ts.Node[] = [];
const collect = (n: ts.Node): void => {
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) {
collect(n.left);
collect(n.right);
} else {
operands.push(n);
}
};
collect(node);
return operands;
}
/** True for the `ws_` prefix literal: bare `"ws_"` or a partial id with no separator yet. */
function isWsPrefixLiteral(text: string): boolean {
return text === "ws_" || (text.startsWith("ws_") && !text.includes("-"));
}
/**
* Walks a `+` chain and returns true iff some operand is a literal
* `"ws_"` AND a later operand (anywhere in the chain) is the literal
* `"-"` (or starts with `-`). Catches `"ws_" + wsId + "-" + name`.
*
* The dual condition keeps the predicate from flagging unrelated
* concat patterns like `"ws_" + something` that don't continue into a
* tool-name component. We deliberately require the separator literal
* to START with `-` (rather than just contain `-`), because `-` is a
* common char in lots of unrelated strings — broader matching would
* generate noise on URL/slug/date concat patterns.
*/
export function isNamespacedToolBinaryConcat(node: ts.BinaryExpression): boolean {
if (node.operatorToken.kind !== ts.SyntaxKind.PlusToken) return false;
const operands = flattenPlusChain(node);
let sawWsPrefix = -1;
for (let i = 0; i < operands.length; i++) {
const op = operands[i];
const text = op ? literalText(op) : null;
if (text === null) continue;
if (sawWsPrefix < 0) {
// Either exactly "ws_" or a literal beginning with "ws_" and
// ending mid-id (no separator yet).
if (isWsPrefixLiteral(text)) sawWsPrefix = i;
} else if (i > sawWsPrefix && text.startsWith("-")) {
return true;
}
}
return false;
}
// ── Parse predicate (b) ─────────────────────────────────────────────
/**
* True for `<id>.split("-")` where `<id>` is an identifier whose name
* matches `NAMESPACED_BINDING_RE`. The pattern is the canonical
* by-hand-parse the primitive is meant to replace.
*
* Conservative on receiver shape: only flags direct identifier
* receivers (not arbitrary expressions). A property access like
* `obj.toolName.split("-")` is NOT flagged — the lint should not be a
* data-flow analyzer. Real misuse tends to be direct.
*/
export function isNamespacedSplit(node: ts.CallExpression): boolean {
if (!ts.isPropertyAccessExpression(node.expression)) return false;
if (node.expression.name.text !== "split") return false;
const receiver = node.expression.expression;
if (!ts.isIdentifier(receiver)) return false;
if (!NAMESPACED_BINDING_RE.test(receiver.text)) return false;
const [arg] = node.arguments;
if (!arg) return false;
return literalText(arg) === "-";
}
// ── Walker scaffolding (same shape as check-conversation-paths.ts) ──
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, scanRoot: string, violations: Violation[]): void {
const relPath = relative(scanRoot, 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, reason: string): 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(),
reason,
});
}
function visit(node: ts.Node): void {
if (ts.isStringLiteral(node) && isNamespacedToolStringLiteral(node)) {
record(node, "string literal builds `ws_<id>-<toolName>` shape");
} else if (ts.isTemplateExpression(node) && isNamespacedToolTemplate(node)) {
record(node, "template literal builds `ws_<id>-<toolName>` shape");
} else if (ts.isBinaryExpression(node) && isNamespacedToolBinaryConcat(node)) {
record(node, "string concat builds `ws_<id>-<toolName>` shape");
} else if (ts.isCallExpression(node) && isNamespacedSplit(node)) {
record(node, '`.split("/")` on a presumed namespaced tool name');
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
/** True for paths outside scope: deps, build output, type decls, tests, generated mirrors. */
function isExcludedPath(abs: string): boolean {
if (abs.includes("/node_modules/") || abs.includes("/dist/")) return true;
if (abs.endsWith(".d.ts")) return true;
// Tests construct/parse the shape deliberately (fixtures) — out of scope,
// matching the platform-side `test/` exclusion.
if (abs.includes("/__tests__/") || abs.endsWith(".test.ts") || abs.endsWith(".test.tsx")) {
return true;
}
// Generated mirrors of the workspace-id pattern are not hand-written.
return abs.includes("/_generated/");
}
/** Prints every violation with guidance to stderr and exits non-zero. */
function reportViolations(violations: Violation[]): never {
console.error(
`✗ Found ${violations.length} hand-built namespaced tool name(s) in src/ + web/src/:\n`,
);
for (const v of violations) {
console.error(` ${v.file}:${v.line}:${v.column} — ${v.reason}`);
console.error(` ${v.snippet}\n`);
}
console.error("Cross-workspace tool names must flow through `namespacedToolName(wsId, name)` /");
console.error("`parseNamespacedToolName(s)` — `src/tools/namespace.ts`.");
console.error(
`Legitimate exceptions (rare) require a // ${ALLOW_MARKER} comment on the line above the construction.`,
);
process.exit(1);
}
async function main(): Promise<void> {
const violations: Violation[] = [];
// `.tsx` included so the web client's React components are covered, not just
// its plain `.ts` modules.
const glob = new Glob("**/*.{ts,tsx}");
let scanned = 0;
for (const scanRoot of SCAN_ROOTS) {
for await (const rel of glob.scan({ cwd: scanRoot })) {
const abs = join(scanRoot, rel);
if (isExcludedPath(abs)) continue;
scanned++;
scanFile(abs, scanRoot, violations);
}
}
if (violations.length > 0) reportViolations(violations);
console.log(`✓ No hand-built namespaced tool names in ${scanned} src/ + web/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);
});
}