mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
19995985d3
Follow-up to #436. The escape-cooking fixture's comment cited flow-add-step.ts as a currently-shipping example of a `\n` description; main's flow rewrites reworded that description, so the citation is stale. The comment now leans on the fixture alone instead of any live tool's wording. No code change - `npm run test:scripts` still 59/59.
1173 lines
49 KiB
JavaScript
1173 lines
49 KiB
JavaScript
/**
|
|
* Guards scripts/extract-tools.mjs against dropping or corrupting tool
|
|
* definitions before they reach the downstream `spidershield scan --tools-json`
|
|
* security scan. The motivating bug: the old extractor scanned a fixed
|
|
* 2000-char window after each `id:`, so run-sequence's ~4285-char multi-line
|
|
* template-literal description came back null and the tool was silently
|
|
* excluded from the scan.
|
|
*
|
|
* Ground truth: the real-tree tests parse every tool source file with the
|
|
* actual TypeScript parser and require the extractor's output to match the AST
|
|
* exactly - names AND descriptions. Any drop (regex-literal lexing gap, window
|
|
* regression, walk change), corruption (truncation, bad unescape), or
|
|
* fabrication diverges from the AST and fails, with no reliance on stderr
|
|
* warning formats and no false red from `id:`-like text in comments or strings.
|
|
*
|
|
* The TypeScript package is a test-only dependency (a root devDependency,
|
|
* installed by unit-tests.yml's `npm ci`): the extractor itself must stay
|
|
* dependency-free because tool-description-quality.yml runs it on a bare
|
|
* checkout without installing.
|
|
*
|
|
* Run: node --test scripts/extract-tools.test.mjs
|
|
*/
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { join, extname, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import ts from "typescript";
|
|
import { extractToolsFromSource, dedupeToolsById } from "./extract-tools.mjs";
|
|
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const toolsRoot = join(repoRoot, "packages", "tool-server", "src", "tools");
|
|
|
|
function runExtractor() {
|
|
const res = spawnSync(process.execPath, ["scripts/extract-tools.mjs"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(res.status, 0, `extractor exited with ${res.status}\n${res.stderr}`);
|
|
return { tools: JSON.parse(res.stdout).tools };
|
|
}
|
|
|
|
function captureWarnings(fn) {
|
|
const warnings = [];
|
|
const originalError = console.error;
|
|
console.error = (...args) => warnings.push(args.join(" "));
|
|
try {
|
|
return { result: fn(), warnings };
|
|
} finally {
|
|
console.error = originalError;
|
|
}
|
|
}
|
|
|
|
// --- TypeScript-AST oracle --------------------------------------------------
|
|
// Computes the ground-truth tool set the extractor must emit: every object
|
|
// literal with a static `id` (string literal or no-substitution template
|
|
// literal; `as const` / `satisfies` / parens unwrapped) whose `description` is
|
|
// a plain string / no-substitution template literal written AFTER the id.
|
|
// `.text` is the COOKED value (escapes resolved), so it doubles as an oracle
|
|
// for the extractor's unescaping.
|
|
//
|
|
// Everything else an id-bearing object can do with `description` lands in a
|
|
// LOUD bucket instead of silently escaping the safety net:
|
|
// - nonStatic: the rendered text cannot be read statically - a non-literal
|
|
// initializer (concatenation, interpolation, method call, const reference,
|
|
// `as const`), a shorthand / getter / method `description` member, a
|
|
// dynamic computed key, a spread written AFTER the description (it may
|
|
// override it at runtime; one before it cannot), a spread with no explicit
|
|
// description at all (it may carry one), or a getter/method `id` beside a
|
|
// description.
|
|
// - misordered: a plain-literal description written BEFORE the id - the
|
|
// extractor's scan is forward-only and cannot capture it.
|
|
// The real tree must keep both buckets empty.
|
|
//
|
|
// The policy buckets apply only to objects that could BE tool definitions: an
|
|
// object literal reached as (part of) another object literal's property value
|
|
// is data (a payload, example, or config fragment - no real tool is written
|
|
// that way), so demanding "make your description static" of it would be a
|
|
// false red. Equality mirroring still covers nested data: an id-then-plain-
|
|
// description data object is emitted by both sides alike. Ids with no
|
|
// description-ish member at all (nested payload keys, descriptionless tools)
|
|
// stay out of the emitted set by design (the extractor warn-skips them), and
|
|
// dynamic ids (const references, shorthand, interpolated templates) are out of
|
|
// scope on both sides.
|
|
function unwrapExpression(node) {
|
|
while (
|
|
ts.isAsExpression(node) ||
|
|
ts.isSatisfiesExpression(node) ||
|
|
ts.isParenthesizedExpression(node)
|
|
) {
|
|
node = node.expression;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function isStaticText(node) {
|
|
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
|
}
|
|
|
|
// Property/member name as static text, or null when it can't be known
|
|
// statically (a dynamic computed key). `["description"]: ...` resolves to
|
|
// "description" so a computed spelling can't slip past the net.
|
|
function memberName(prop) {
|
|
if (!prop.name) return null;
|
|
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text;
|
|
if (ts.isComputedPropertyName(prop.name)) {
|
|
const expr = unwrapExpression(prop.name.expression);
|
|
return isStaticText(expr) ? expr.text : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// True when the object literal is (part of) another object literal's property
|
|
// value - possibly through array elements or as/satisfies/paren wrappers. Such
|
|
// an object is data, not a tool-definition site.
|
|
function isNestedDataValue(node) {
|
|
let cur = node.parent;
|
|
while (
|
|
cur &&
|
|
(ts.isArrayLiteralExpression(cur) ||
|
|
ts.isAsExpression(cur) ||
|
|
ts.isSatisfiesExpression(cur) ||
|
|
ts.isParenthesizedExpression(cur))
|
|
) {
|
|
cur = cur.parent;
|
|
}
|
|
return cur !== undefined && ts.isPropertyAssignment(cur);
|
|
}
|
|
|
|
function oracleFromSource(src, fileName = "oracle.ts") {
|
|
const sourceFile = ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true);
|
|
const tools = [];
|
|
const nonStatic = [];
|
|
const misordered = [];
|
|
const visit = (node) => {
|
|
if (ts.isObjectLiteralExpression(node)) {
|
|
let id = null;
|
|
let idIndex = -1;
|
|
let hasDynamicIdMember = false;
|
|
let descIndex = -1;
|
|
let descValue = null;
|
|
let descIsPlain = false;
|
|
let hasDescMember = false;
|
|
let lastSpreadIndex = -1;
|
|
node.properties.forEach((prop, index) => {
|
|
if (ts.isSpreadAssignment(prop)) {
|
|
lastSpreadIndex = index;
|
|
return;
|
|
}
|
|
const name = memberName(prop);
|
|
if (name === "id") {
|
|
if (ts.isPropertyAssignment(prop)) {
|
|
const init = unwrapExpression(prop.initializer);
|
|
if (isStaticText(init)) {
|
|
id = init.text;
|
|
idIndex = index;
|
|
}
|
|
} else if (ts.isGetAccessorDeclaration(prop) || ts.isMethodDeclaration(prop)) {
|
|
// A computed id the lexical extractor can never see; beside a
|
|
// description it would be a fully silent scan bypass.
|
|
hasDynamicIdMember = true;
|
|
}
|
|
}
|
|
if (name === "description") {
|
|
hasDescMember = true;
|
|
descIndex = index;
|
|
// Only a direct PropertyAssignment to a plain literal is readable;
|
|
// deliberately NOT unwrapped - `description: "x" as const` is not a
|
|
// shape the extractor can capture, so it must stay non-static.
|
|
if (ts.isPropertyAssignment(prop) && isStaticText(prop.initializer)) {
|
|
descIsPlain = true;
|
|
descValue = prop.initializer.text.trim();
|
|
}
|
|
}
|
|
});
|
|
const nested = isNestedDataValue(node);
|
|
if (id !== null && hasDescMember) {
|
|
if (descIsPlain && descIndex >= idIndex) {
|
|
// Mirrors the extractor, nested data included.
|
|
tools.push({ name: id, description: descValue });
|
|
if (!nested && lastSpreadIndex > descIndex) {
|
|
// Only a spread AFTER the description can override it at runtime.
|
|
nonStatic.push({ id, file: fileName });
|
|
}
|
|
} else if (!nested) {
|
|
if (descIsPlain) {
|
|
misordered.push({ id, file: fileName });
|
|
} else {
|
|
nonStatic.push({ id, file: fileName });
|
|
}
|
|
}
|
|
} else if (!nested && id !== null && lastSpreadIndex >= 0) {
|
|
// No explicit description, but the spread may carry one the extractor
|
|
// (and this oracle) can't see - force it to be written explicitly.
|
|
nonStatic.push({ id, file: fileName });
|
|
} else if (!nested && id === null && hasDynamicIdMember && hasDescMember) {
|
|
nonStatic.push({ id: "<dynamic id>", file: fileName });
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
return { tools, nonStatic, misordered };
|
|
}
|
|
|
|
function oracleFromTree() {
|
|
const tools = [];
|
|
const nonStatic = [];
|
|
const misordered = [];
|
|
const walk = (dir) => {
|
|
for (const name of readdirSync(dir)) {
|
|
const full = join(dir, name);
|
|
if (statSync(full).isDirectory()) {
|
|
walk(full);
|
|
} else if (extname(name) === ".ts" && !name.endsWith(".d.ts")) {
|
|
const fromFile = oracleFromSource(readFileSync(full, "utf8"), full);
|
|
tools.push(...fromFile.tools);
|
|
nonStatic.push(...fromFile.nonStatic);
|
|
misordered.push(...fromFile.misordered);
|
|
}
|
|
}
|
|
};
|
|
walk(toolsRoot);
|
|
return { tools, nonStatic, misordered };
|
|
}
|
|
|
|
const flagList = (entries) => entries.map((e) => `${e.id} (${e.file})`).join(", ");
|
|
|
|
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
|
|
// --- Real-tree ground-truth tests -------------------------------------------
|
|
|
|
test("extractor output equals the TypeScript-AST ground truth exactly", () => {
|
|
const { tools } = runExtractor();
|
|
const oracle = oracleFromTree();
|
|
|
|
// Sanity floor: an oracle bug that empties BOTH sides would make the
|
|
// equality below pass vacuously; the real tree has ~72 tools.
|
|
assert.ok(
|
|
oracle.tools.length >= 60,
|
|
`oracle found only ${oracle.tools.length} tools - oracle or tree layout broke`
|
|
);
|
|
|
|
// Unique ids: a same-name collision would mean one tool's description
|
|
// silently replaces another's in the scan.
|
|
const names = oracle.tools.map((t) => t.name);
|
|
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
assert.deepEqual(dupes, [], `duplicate tool ids in the source tree: ${dupes.join(", ")}`);
|
|
|
|
// The invariant: nothing dropped, nothing corrupted, nothing fabricated.
|
|
// Name-level diffs first for a readable failure, then the full deep equality
|
|
// (which also compares every description byte-for-byte).
|
|
const extractedNames = new Set(tools.map((t) => t.name));
|
|
const oracleNames = new Set(names);
|
|
const dropped = [...oracleNames].filter((n) => !extractedNames.has(n));
|
|
const fabricated = [...extractedNames].filter((n) => !oracleNames.has(n));
|
|
assert.deepEqual(
|
|
dropped,
|
|
[],
|
|
`tools in the AST but missing from the extractor: ${dropped.join(", ")}`
|
|
);
|
|
assert.deepEqual(
|
|
fabricated,
|
|
[],
|
|
`extractor emitted tools the AST does not contain: ${fabricated.join(", ")}`
|
|
);
|
|
assert.deepEqual([...tools].sort(byName), [...oracle.tools].sort(byName));
|
|
});
|
|
|
|
test("every real tool description is a plain literal (statically extractable, so it gets scanned)", () => {
|
|
const oracle = oracleFromTree();
|
|
assert.deepEqual(
|
|
oracle.nonStatic,
|
|
[],
|
|
`these tools' descriptions cannot be read statically (non-literal value, ` +
|
|
`shorthand/getter/computed member, or a spread that may carry/override it), ` +
|
|
`so they would drop out of the spidershield scan: ${flagList(oracle.nonStatic)}. ` +
|
|
`Write each as a plain string/template literal property.`
|
|
);
|
|
assert.deepEqual(
|
|
oracle.misordered,
|
|
[],
|
|
`these descriptions are written BEFORE their id: - the extractor's scan is ` +
|
|
`forward-only, so they would drop out of the spidershield scan: ` +
|
|
`${flagList(oracle.misordered)}. Move each description: after its id:.`
|
|
);
|
|
});
|
|
|
|
test("run-sequence and describe (the two originally dropped tools) and gesture-tap (control) are present", () => {
|
|
// Presence-only on purpose: fullness and exact wording are covered
|
|
// byte-for-byte by the AST-equality test, so a legitimate rewrite of a
|
|
// description cannot fail this suite while capture stays complete.
|
|
const { tools } = runExtractor();
|
|
const names = new Set(tools.map((t) => t.name));
|
|
for (const expected of ["run-sequence", "describe", "gesture-tap"]) {
|
|
assert.ok(names.has(expected), `${expected} missing from extractor output`);
|
|
}
|
|
});
|
|
|
|
// --- Boundary-parsing rules (pure, fixture-driven) -------------------------
|
|
|
|
test("a >4000-char multi-line description is captured in full (the original regression)", () => {
|
|
// Pins the fixed-window bug class forever, independent of any real tool's
|
|
// current wording or length: the old extractor cut its search at 2000 chars.
|
|
const sentence = "Runs one scripted step against the target device and reports the outcome. ";
|
|
const longDescription = sentence.repeat(60).trim(); // ~4560 chars
|
|
const src = `
|
|
export const longTool = defineTool({
|
|
id: "long-description-tool",
|
|
description: \`${longDescription}\`,
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(tools.length, 1, "long-description tool was dropped");
|
|
assert.equal(tools[0].description, longDescription, "long description was truncated");
|
|
});
|
|
|
|
test("a description containing an id: token is still captured in full", () => {
|
|
// The closing-delimiter-anchored search must not be truncated by an `id:`
|
|
// that appears INSIDE the description text (e.g. a structured example). The
|
|
// old "stop at the next id:" bound dropped such a tool — the exact class of
|
|
// silent scan-bypass this script exists to prevent.
|
|
const src = `
|
|
export const menuTool = defineTool({
|
|
id: "menu-item-tool",
|
|
description: \`Opens a context menu at a point. Example payload the agent can send:
|
|
{ id: "sub-thing", label: "Open" } — a structured example inside the text.
|
|
Use when you need to open a nested menu item on screen.\`,
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.ok("menu-item-tool" in byId, "tool with an id: inside its description was dropped");
|
|
assert.ok(
|
|
byId["menu-item-tool"].includes("nested menu item"),
|
|
"description was truncated at the inner id: instead of its closing backtick"
|
|
);
|
|
// The `id: "sub-thing"` inside the description text is not a real tool.
|
|
assert.ok(!("sub-thing" in byId), "an id: inside a description was mistaken for a tool");
|
|
});
|
|
|
|
test("a description-less tool does not borrow the next tool's description", () => {
|
|
// If another tool's `id:` sits between this id: and the matched description:,
|
|
// the description belongs to that later tool — don't inherit it.
|
|
const src = `
|
|
export const bare = defineTool({
|
|
id: "no-description-tool",
|
|
handler: async () => {},
|
|
});
|
|
export const next = defineTool({
|
|
id: "has-description-tool",
|
|
description: "This description belongs to has-description-tool only.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(
|
|
byId["has-description-tool"],
|
|
"This description belongs to has-description-tool only."
|
|
);
|
|
assert.ok(
|
|
!("no-description-tool" in byId),
|
|
"a description-less tool borrowed the following tool's description"
|
|
);
|
|
});
|
|
|
|
test("tools with mixed description forms in one file are both captured", () => {
|
|
// Position-based (not delimiter-form-priority) matching: an earlier tool's
|
|
// double-quoted description must not be lost to a LATER tool's template
|
|
// literal. A form-priority scan would grab the later template ahead of the
|
|
// nearer double-quoted one, then drop the earlier tool.
|
|
const src = `
|
|
export const first = defineTool({
|
|
id: "first-tool",
|
|
description: "First tool, plain double-quoted description.",
|
|
handler: async () => {},
|
|
});
|
|
export const second = defineTool({
|
|
id: "second-tool",
|
|
description: \`Second tool, a template-literal description with some length.\`,
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(byId["first-tool"], "First tool, plain double-quoted description.");
|
|
assert.equal(
|
|
byId["second-tool"],
|
|
"Second tool, a template-literal description with some length."
|
|
);
|
|
});
|
|
|
|
test("single-quoted id and description are captured (with escapes)", () => {
|
|
const src = `
|
|
export const sq = defineTool({
|
|
id: 'single-quoted-tool',
|
|
description: 'Types the user\\'s text into the focused field.',
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "single-quoted-tool")?.description,
|
|
"Types the user's text into the focused field."
|
|
);
|
|
});
|
|
|
|
test("standard escapes render as their actual characters, not literal backslash sequences", () => {
|
|
// A template-literal description with a literal "\n" must become a real
|
|
// newline in the extracted text — not survive as a stray backslash-n.
|
|
// (Descriptions in the real tree come and go from this shape — flow-add-step
|
|
// shipped a \n between sentences for a while — so the fixture, not any live
|
|
// tool, pins it.)
|
|
const src = `
|
|
export const withEscapes = defineTool({
|
|
id: "with-escapes",
|
|
description: \`Line one.\\nLine two.\\tTabbed. Say \\\`hi\\\` and \\$done.\`,
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const description = tools.find((t) => t.name === "with-escapes").description;
|
|
assert.equal(description, "Line one.\nLine two.\tTabbed. Say `hi` and $done.");
|
|
});
|
|
|
|
test("a regex literal between id: and description: does not hide the description", () => {
|
|
// The lexer must skip regex literals whole: a brace inside a character class
|
|
// (`/[{]/`) must not shift the tracked object depth, and a quote inside a
|
|
// regex (`/["']/`) must not open a fake string. Either would make the sibling
|
|
// description: invisible and drop the tool from the security scan.
|
|
for (const [label, field] of [
|
|
["brace class", "match: /[{]/,"],
|
|
["quote class", `schema: z.string().regex(/["']/),`],
|
|
["replace with a double-quote regex", `normalize: (s) => s.replace(/"/g, ""),`],
|
|
]) {
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "regex-field-tool",
|
|
${field}
|
|
description: "Real description of regex-field-tool.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(
|
|
tools.find((t) => t.name === "regex-field-tool")?.description,
|
|
"Real description of regex-field-tool.",
|
|
`${label}: regex literal between id: and description: dropped or corrupted the tool`
|
|
);
|
|
assert.deepEqual(warnings, [], `${label}: unexpected warnings`);
|
|
}
|
|
});
|
|
|
|
test("a quote-bearing regex BEFORE the id: does not swallow the tool", () => {
|
|
// A regex containing a quote that appears before the id: (an earlier
|
|
// property, or earlier code in the file) must not open a fake string that
|
|
// hides the id: token itself — that variant produced no warning at all.
|
|
const src = `
|
|
export const t = defineTool({
|
|
normalize: (s) => s.replace(/"/g, ""),
|
|
id: "after-regex-tool",
|
|
description: "Description after a quote-bearing regex.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "after-regex-tool")?.description,
|
|
"Description after a quote-bearing regex."
|
|
);
|
|
});
|
|
|
|
test("division is not mistaken for a regex literal", () => {
|
|
// The other side of the regex heuristic: `/` after a value (identifier,
|
|
// number, closing paren) is division and must be scanned through normally.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "division-tool",
|
|
timeoutMs: 60000 / 2,
|
|
budget: total / count,
|
|
description: "Kept: the slashes above are division, not regex openers.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "division-tool")?.description,
|
|
"Kept: the slashes above are division, not regex openers."
|
|
);
|
|
});
|
|
|
|
test("a TS postfix non-null assertion before a division is not read as a regex opener", () => {
|
|
// `a!` is a postfix non-null assertion that ENDS a value, so the following `/`
|
|
// is division, not a regex opener. If it were mis-read as a regex, the quote
|
|
// in the divisor (`"x/y"`) would open a fake string that hides this tool's
|
|
// description: AND swallows the next tool - both dropping out of the scan. A
|
|
// PREFIX logical-NOT (`!/re/`) is the opposite case and must stay a regex -
|
|
// both at operator position (`=> !/re/`) and after a keyword (`return !/re/`),
|
|
// where the char before the `!` is an identifier char but the `!` is still
|
|
// prefix.
|
|
const src = `
|
|
export const helpers = {
|
|
needsQuote: (x) => { return !/["']/.test(x); },
|
|
};
|
|
export const first = defineTool({
|
|
id: "bang-division-tool",
|
|
w: a! / "x/y".length,
|
|
description: "First: postfix ! then division.",
|
|
handler: async () => {},
|
|
});
|
|
export const second = defineTool({
|
|
id: "tool-after-bang",
|
|
check: (x) => !/["']/.test(x),
|
|
description: "Second: survives, and its prefix-! regex is not division.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(
|
|
byId["bang-division-tool"],
|
|
"First: postfix ! then division.",
|
|
"a postfix non-null assertion was mis-lexed as a regex position, hiding the description"
|
|
);
|
|
assert.equal(
|
|
byId["tool-after-bang"],
|
|
"Second: survives, and its prefix-! regex is not division.",
|
|
"a return-! or operator-! prefix regex was read as division, dropping a later tool"
|
|
);
|
|
});
|
|
|
|
test("a non-static description (concatenation / interpolation / method suffix) is dropped loudly, not emitted wrong", () => {
|
|
// The captured literal is only the description if nothing extends it. A `+`
|
|
// concatenation, a template `${...}` interpolation, or a method/operator
|
|
// suffix (`"...".slice(0, 3)`) means the rendered text differs from the
|
|
// leading literal; emitting just that literal would feed silently wrong text
|
|
// into the security scan. All must warn on stderr and be skipped.
|
|
for (const [label, id, src] of [
|
|
[
|
|
"concatenation",
|
|
"concat-tool",
|
|
`defineTool({ id: "concat-tool", description: "part A " + "part B", handler() {} });`,
|
|
],
|
|
[
|
|
"interpolation",
|
|
"interp-tool",
|
|
'defineTool({ id: "interp-tool", description: `hello ${world} rest`, handler() {} });',
|
|
],
|
|
[
|
|
"slice suffix",
|
|
"slice-tool",
|
|
`defineTool({ id: "slice-tool", description: "abcdefghij".slice(0, 3), handler() {} });`,
|
|
],
|
|
[
|
|
"replace suffix",
|
|
"replace-tool",
|
|
`defineTool({ id: "replace-tool", description: "hello WORLD".replace("WORLD", "there"), handler() {} });`,
|
|
],
|
|
]) {
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(tools.length, 0, `${label}: a partial/wrong description must not be emitted`);
|
|
assert.ok(
|
|
warnings.some((w) => /WARNING/.test(w) && w.includes(id)),
|
|
`${label}: expected a stderr WARNING naming ${id}; got ${JSON.stringify(warnings)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test("an escaped \\${ in a template is a literal, not interpolation, and is captured", () => {
|
|
// \\${...} is an escaped dollar-brace: it renders as the literal text "${...}"
|
|
// and is NOT runtime interpolation, so the tool must be captured normally.
|
|
const src =
|
|
'defineTool({ id: "escaped-dollar", description: `price is \\${amount} today`, handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "escaped-dollar")?.description,
|
|
"price is ${amount} today"
|
|
);
|
|
});
|
|
|
|
test("an id: key inside a nested object value is not mistaken for a tool", () => {
|
|
// A structured value between a tool's id: and its description: (a
|
|
// defaultPayload, example, etc.) can itself contain an `id:` key. That nested
|
|
// id: must neither (a) be emitted as a spurious tool nor (b) drop the real
|
|
// tool from the security scan. Matching the description by brace scope rather
|
|
// than raw position is what prevents both.
|
|
const src = `
|
|
export const createThing = defineTool({
|
|
id: "create-thing",
|
|
defaultPayload: { id: "example-id", name: "x" },
|
|
description: "Creates a thing.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(byId["create-thing"], "Creates a thing.", "real tool dropped by a nested id: key");
|
|
assert.ok(!("example-id" in byId), "a nested object's id: key was emitted as a spurious tool");
|
|
assert.equal(tools.length, 1, "exactly the real tool should be emitted");
|
|
});
|
|
|
|
test("a balanced nested object (with a deep inner id:) between id: and description: keeps the tool", () => {
|
|
// Mirrors the real `capability: { apple: { ... } }` shape 7 tools already
|
|
// use, but with an inner id: key added at depth 2 - the deepest footgun form.
|
|
// The tool must be captured in full and the inner id: must not leak out.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "native-thing",
|
|
capability: { apple: { simulator: true, device: true }, meta: { id: "cap-1" } },
|
|
description: \`Does a native thing.\`,
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(byId["native-thing"], "Does a native thing.");
|
|
assert.ok(!("cap-1" in byId), "a deeply nested id: key was emitted as a spurious tool");
|
|
assert.equal(tools.length, 1);
|
|
});
|
|
|
|
test("a *description: sibling (subdescription:) before the real description: is not captured", () => {
|
|
// findOwnDescriptionValue matches `description:` only on a word boundary, so a
|
|
// sibling key that merely ENDS in `description` (`subdescription:`) appearing
|
|
// first must not be read as the tool's own description - that would feed the
|
|
// wrong text into the security scan. The real `description:` after it wins.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "subdescription-tool",
|
|
subdescription: "WRONG - a sibling ending in 'description'.",
|
|
description: "RIGHT - the tool's own description.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "subdescription-tool")?.description,
|
|
"RIGHT - the tool's own description.",
|
|
"a `subdescription:` sibling was captured instead of the real description"
|
|
);
|
|
assert.equal(tools.length, 1);
|
|
});
|
|
|
|
test("a *id: sibling (uuid:, grid:) is not mistaken for a tool id", () => {
|
|
// findIdLiteralsInCode matches `id:` only on a word boundary, so sibling keys
|
|
// that merely END in `id` (`uuid:`, `grid:`) must not be discovered as tool
|
|
// ids - that would fabricate spurious tools into the scan. Only the real
|
|
// `id:` is a tool.
|
|
const src = `
|
|
export const t = defineTool({
|
|
uuid: "11111111-2222-3333-4444-555555555555",
|
|
grid: "10x10",
|
|
id: "real-id-tool",
|
|
description: "The only real tool here.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const names = tools.map((t) => t.name);
|
|
assert.deepEqual(
|
|
names,
|
|
["real-id-tool"],
|
|
`spurious tools fabricated from *id: siblings: ${names}`
|
|
);
|
|
});
|
|
|
|
test("a line comment with an apostrophe between id: and description: keeps the tool", () => {
|
|
// A `//` comment is not code: an apostrophe inside it (e.g. `don't`) must not
|
|
// open a fake string that swallows the source up to the next quote, which
|
|
// would hide the real description: and silently drop the tool from the scan.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "line-commented-tool",
|
|
// don't forget to update this description when behaviour changes
|
|
description: "The real, comment-preceded description.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(
|
|
byId["line-commented-tool"],
|
|
"The real, comment-preceded description.",
|
|
"a line comment (with an apostrophe) between id: and description: dropped the tool"
|
|
);
|
|
assert.equal(tools.length, 1);
|
|
});
|
|
|
|
test("a block comment between id: and description: keeps the tool", () => {
|
|
// A `/* ... */` block comment must be skipped whole: stray braces, an
|
|
// apostrophe, and even a fake `description:` token inside it must not shift
|
|
// the brace depth or be matched as the tool's real description.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "block-commented-tool",
|
|
/* don't { do } this: description: "fake" - braces + apostrophe in a comment */
|
|
description: "The real block-comment-preceded description.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(
|
|
byId["block-commented-tool"],
|
|
"The real block-comment-preceded description.",
|
|
"a block comment between id: and description: dropped the tool or matched the fake description"
|
|
);
|
|
assert.equal(tools.length, 1);
|
|
});
|
|
|
|
test("an id: literal inside a comment does not corrupt a real tool's description", () => {
|
|
// id detection must be lexically aware: an `id: "..."` in a COMMENT (a common
|
|
// cross-reference to another tool) must not be picked up as a tool candidate.
|
|
// If it were, it would read the enclosing object's real description and, via
|
|
// first-wins dedup, silently overwrite the referenced tool's description in the
|
|
// security scan — a silent mis-capture, the exact class this script prevents.
|
|
const src = `
|
|
export const foo = defineTool({
|
|
// Related to id: "screenshot"; call that first to capture the screen.
|
|
id: "foo-tool",
|
|
description: "Foo tool description.",
|
|
handler: async () => {},
|
|
});
|
|
export const shot = defineTool({
|
|
id: "screenshot",
|
|
description: "Take a screenshot of the device.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
const byId = Object.fromEntries(tools.map((t) => [t.name, t.description]));
|
|
assert.equal(
|
|
byId["screenshot"],
|
|
"Take a screenshot of the device.",
|
|
"a commented id: cross-reference overwrote the real tool's description"
|
|
);
|
|
assert.equal(byId["foo-tool"], "Foo tool description.");
|
|
assert.equal(tools.length, 2, "exactly the two real tools should be emitted");
|
|
});
|
|
|
|
test("a top-level tool with no description is dropped loudly (stderr warning)", () => {
|
|
// `description?` is optional in ToolDefinition, so a real top-level tool can
|
|
// legally omit it. It can't be extracted, but it must not vanish from the
|
|
// security scan silently - it has to warn on stderr so the drop is diagnosed
|
|
// rather than only surfacing as a cryptic count mismatch in CI.
|
|
const src = `
|
|
export const bare = defineTool({
|
|
id: "no-description-real-tool",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(tools.length, 0, "a description-less tool must not be emitted");
|
|
assert.ok(
|
|
warnings.some((w) => /WARNING/.test(w) && w.includes("no-description-real-tool")),
|
|
`expected a stderr WARNING naming the dropped id; got: ${JSON.stringify(warnings)}`
|
|
);
|
|
});
|
|
|
|
test("duplicate tool ids keep the first occurrence and warn about the rest", () => {
|
|
const duplicated = [
|
|
{ name: "dup-tool", description: "First definition - must survive." },
|
|
{ name: "unique-tool", description: "Unrelated tool." },
|
|
{ name: "dup-tool", description: "Second definition - must be skipped loudly." },
|
|
];
|
|
const { result: deduped, warnings } = captureWarnings(() => dedupeToolsById(duplicated));
|
|
assert.deepEqual(deduped, [
|
|
{ name: "dup-tool", description: "First definition - must survive." },
|
|
{ name: "unique-tool", description: "Unrelated tool." },
|
|
]);
|
|
assert.ok(
|
|
warnings.some((w) => /WARNING/.test(w) && w.includes("dup-tool")),
|
|
`expected a duplicate-id WARNING naming dup-tool; got: ${JSON.stringify(warnings)}`
|
|
);
|
|
});
|
|
|
|
// --- Lexer soundness: regex positions, templates, id forms ------------------
|
|
|
|
test("a keyword-position regex (return /[\"']/) before a tool does not swallow it", () => {
|
|
// `/` after `return` is a regex, not division; without keyword awareness the
|
|
// quotes inside it open a fake string and every later tool in the file is
|
|
// silently dropped. Helper functions with return-regexes share files with
|
|
// tool definitions in the real tree today.
|
|
const src = `
|
|
function isQuote(c) { return /["']/.test(c); }
|
|
export const t = defineTool({
|
|
id: "after-keyword-regex-tool",
|
|
description: "Real description after a keyword-position regex.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(
|
|
tools.find((t) => t.name === "after-keyword-regex-tool")?.description,
|
|
"Real description after a keyword-position regex."
|
|
);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("a keyword-NAMED property access before a slash is division, not a regex opener", () => {
|
|
// `counts.in / 2` and `i++ / 2` end in values; without the property-access
|
|
// and postfix checks the following `/` opened a fake regex that, combined
|
|
// with a second slash on the same line, desynchronized string lexing and
|
|
// could silently steal description text.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "division-after-member-tool",
|
|
ratio: counts.in / total, path: "per/sec",
|
|
tally: i++ / 2, unit: "x/y",
|
|
description: "Real description after member and postfix division.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(
|
|
tools.find((t) => t.name === "division-after-member-tool")?.description,
|
|
"Real description after member and postfix division."
|
|
);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("a nested template literal before a tool is skipped whole (interpolation included)", () => {
|
|
// Without `\${...}` tracking, the inner template's opening backtick "closes"
|
|
// the outer one, the apostrophe in it opens a fake string, and the following
|
|
// tool silently vanishes. Nested templates are common in the real tree.
|
|
const src = `
|
|
const msg = \`status: \${ready ? \`it's ready\` : "pending"}\`;
|
|
export const t = defineTool({
|
|
id: "after-nested-template-tool",
|
|
description: "Real description after a nested template.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(
|
|
tools.find((t) => t.name === "after-nested-template-tool")?.description,
|
|
"Real description after a nested template."
|
|
);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("a quote-bearing regex inside a template interpolation is skipped whole", () => {
|
|
// The real tree already contains this shape (open-url's android.ts shell
|
|
// quoting) — just never yet in a file with an id:, so only this fixture pins
|
|
// it. If the interpolation scanner did not lex the regex whole, the
|
|
// apostrophes in /'/g would open a fake string inside the interpolation and
|
|
// the following tool would vanish with no warning at all.
|
|
const src = `
|
|
const quoted = \`'\${params.url.replace(/'/g, "'\\\\''")}'\`;
|
|
export const t = defineTool({
|
|
id: "after-interp-regex-tool",
|
|
description: "Real description after an interpolation regex.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(
|
|
tools.find((t) => t.name === "after-interp-regex-tool")?.description,
|
|
"Real description after an interpolation regex."
|
|
);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("a fake description: inside a template interpolation cannot be stolen as the tool's", () => {
|
|
// The interpolation's content is not code at the tool's object level; a
|
|
// `description: "..."` string inside it must not be matched ahead of the
|
|
// tool's real description.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "victim-tool",
|
|
note: \`\${\`description: "stolen text",\`} tail\`,
|
|
description: "The real description of victim-tool.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "victim-tool")?.description,
|
|
"The real description of victim-tool."
|
|
);
|
|
assert.equal(tools.length, 1);
|
|
});
|
|
|
|
test("a no-substitution template-literal id is captured, and the AST oracle agrees", () => {
|
|
// `id: \`x\`` is runtime-identical to `id: "x"`. Both the extractor and the
|
|
// oracle must see it - if either side skipped it, a real tool would leave
|
|
// the scan with the equality test still green (the one shape that used to
|
|
// defeat the whole safety net).
|
|
const src =
|
|
'export const t = defineTool({ id: `template-id-tool`, description: "Real description T.", handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "template-id-tool")?.description,
|
|
"Real description T."
|
|
);
|
|
assert.deepEqual(oracleFromSource(src).tools, [
|
|
{ name: "template-id-tool", description: "Real description T." },
|
|
]);
|
|
});
|
|
|
|
test("an interpolated template id is out of scope on both sides (dynamic, like a const-ref id)", () => {
|
|
const src = 'export const t = defineTool({ id: `dyn-${x}`, description: "D.", handler() {} });';
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
const oracle = oracleFromSource(src);
|
|
assert.deepEqual(tools, []);
|
|
assert.deepEqual(oracle.tools, []);
|
|
assert.deepEqual(oracle.nonStatic, []);
|
|
});
|
|
|
|
test('an `id: "x" as const` tool is captured, and the AST oracle agrees', () => {
|
|
// The extractor's id matcher reads through the suffix; the oracle unwraps
|
|
// as/satisfies/parens. Without the unwrap this correct extraction would be
|
|
// reported as a fabrication.
|
|
const src =
|
|
'export const t = defineTool({ id: "as-const-tool" as const, description: "Real.", handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(tools.find((t) => t.name === "as-const-tool")?.description, "Real.");
|
|
assert.deepEqual(oracleFromSource(src).tools, [{ name: "as-const-tool", description: "Real." }]);
|
|
});
|
|
|
|
// --- Cooked-text fidelity ----------------------------------------------------
|
|
|
|
test("full JS escape semantics: \\xNN, \\uNNNN, \\u{...}, identity escapes, line continuations", () => {
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "escape-suite-tool",
|
|
description: "AB: \\x41 \\u0042 \\u{1F600}|\\v|\\b|\\f| \\qidentity and a \\\ncontinuation",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "escape-suite-tool")?.description,
|
|
"AB: A B \u{1F600}|\v|\b|\f| qidentity and a continuation"
|
|
);
|
|
});
|
|
|
|
test("template-literal line terminators are cooked: CRLF becomes LF", () => {
|
|
// Per spec, `\r\n` and lone `\r` inside a template literal cook to `\n`.
|
|
// Relevant on a CRLF (autocrlf) checkout, where raw bytes would otherwise
|
|
// leak \r into the scanned text.
|
|
const src =
|
|
'defineTool({ id: "crlf-tool", description: `line one\r\nline two\rline three`, handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "crlf-tool")?.description,
|
|
"line one\nline two\nline three"
|
|
);
|
|
});
|
|
|
|
test("$ + escape + { does not fabricate interpolation ($\\d{2} stays captured)", () => {
|
|
// The interpolation probe must not glue `$` to `{` across a removed escape
|
|
// pair: `$\d{2}` renders as the literal "$d{2}" and is NOT interpolation.
|
|
const src =
|
|
'defineTool({ id: "dollar-escape-tool", description: `pattern $\\d{2} end`, handler() {} });';
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.equal(
|
|
tools.find((t) => t.name === "dollar-escape-tool")?.description,
|
|
"pattern $d{2} end"
|
|
);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("surrounding whitespace in a description literal is trimmed", () => {
|
|
const src = 'defineTool({ id: "padded-tool", description: " padded text ", handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(tools.find((t) => t.name === "padded-tool")?.description, "padded text");
|
|
});
|
|
|
|
// --- Search-scope rules -------------------------------------------------------
|
|
|
|
test(">2000 chars of properties between id: and description: are scanned through", () => {
|
|
// The other axis of the fixed-window class: not the description LENGTH but
|
|
// the id->description search DISTANCE must be unbounded too.
|
|
const filler = Array.from({ length: 60 }, (_, k) => ` prop${k}: "${"x".repeat(40)}",`).join(
|
|
"\n"
|
|
);
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "far-description-tool",
|
|
${filler}
|
|
description: "Found beyond 2000 chars of siblings.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "far-description-tool")?.description,
|
|
"Found beyond 2000 chars of siblings."
|
|
);
|
|
});
|
|
|
|
test("a nested description: key between id: and the real description is not matched", () => {
|
|
// The depth anchor must hold: a `description:` inside a nested value (e.g. a
|
|
// schema fragment) is not the tool's description.
|
|
const src = `
|
|
export const t = defineTool({
|
|
id: "outer-tool",
|
|
schema: { description: "inner schema description, not the tool's" },
|
|
description: "The real outer description.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(
|
|
tools.find((t) => t.name === "outer-tool")?.description,
|
|
"The real outer description."
|
|
);
|
|
assert.equal(tools.length, 1);
|
|
});
|
|
|
|
test("a plain description written before its id: is skipped loudly and flagged by the oracle", () => {
|
|
// The extractor scans forward from the id, so it cannot capture this shape;
|
|
// it must warn, and the oracle's `misordered` bucket must name it so the
|
|
// real-tree test forces the property order to be fixed.
|
|
const src =
|
|
'defineTool({ description: "Written above the id.", id: "desc-first-tool", handler() {} });';
|
|
const { result: tools, warnings } = captureWarnings(() =>
|
|
extractToolsFromSource(src, "fixture.ts")
|
|
);
|
|
assert.deepEqual(tools, []);
|
|
assert.ok(
|
|
warnings.some((w) => /WARNING/.test(w) && w.includes("desc-first-tool")),
|
|
`expected a stderr WARNING naming desc-first-tool; got: ${JSON.stringify(warnings)}`
|
|
);
|
|
assert.deepEqual(
|
|
oracleFromSource(src).misordered.map((e) => e.id),
|
|
["desc-first-tool"]
|
|
);
|
|
});
|
|
|
|
// --- Oracle safety net for member kinds --------------------------------------
|
|
|
|
test("the oracle flags description members it cannot read statically (shorthand, getter, computed, spread)", () => {
|
|
// These shapes are invisible to the lexical extractor; each must land in the
|
|
// nonStatic bucket so the real-tree test turns red instead of the tool
|
|
// silently leaving the scan with CI green.
|
|
for (const [label, src] of [
|
|
["shorthand", 'defineTool({ id: "sh-tool", description, handler() {} });'],
|
|
["getter", 'defineTool({ id: "get-tool", get description() { return "x"; }, handler() {} });'],
|
|
["method", 'defineTool({ id: "m-tool", description() { return "x"; }, handler() {} });'],
|
|
[
|
|
"computed key with non-literal value",
|
|
'defineTool({ id: "computed-tool", ["description"]: makeDesc(), handler() {} });',
|
|
],
|
|
[
|
|
"spread with no explicit description",
|
|
'defineTool({ id: "spread-tool", ...common, handler() {} });',
|
|
],
|
|
[
|
|
"spread AFTER a plain description (may override it)",
|
|
'defineTool({ id: "spread2-tool", description: "explicit", ...common, handler() {} });',
|
|
],
|
|
]) {
|
|
const oracle = oracleFromSource(src);
|
|
assert.ok(
|
|
oracle.nonStatic.length >= 1,
|
|
`${label}: expected the oracle to flag the shape as non-static`
|
|
);
|
|
}
|
|
});
|
|
|
|
test("a defaults-spread BEFORE the description is fine (it cannot override a later property)", () => {
|
|
// `{ ...commonDefaults, id, description }` is idiomatic; at runtime a
|
|
// preceding spread never overrides a later explicit property, so the tool is
|
|
// fully extractable and must NOT be flagged.
|
|
const src =
|
|
'defineTool({ ...commonDefaults, id: "defaults-tool", description: "Explicit and final.", handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(tools.find((t) => t.name === "defaults-tool")?.description, "Explicit and final.");
|
|
const oracle = oracleFromSource(src);
|
|
assert.deepEqual(oracle.nonStatic, [], "a preceding spread must not be flagged");
|
|
assert.deepEqual(oracle.tools, [{ name: "defaults-tool", description: "Explicit and final." }]);
|
|
});
|
|
|
|
test("nested data objects never trigger the policy buckets", () => {
|
|
// An object that is another object's property value is data (a payload,
|
|
// example, defaults fragment), not a tool definition - the policy tests must
|
|
// not demand anything of it, whatever its shape.
|
|
const src = `
|
|
export const host = defineTool({
|
|
id: "host-tool",
|
|
examplePayload: { description: "sample item", id: "item-1" },
|
|
defaults: { id: "default-profile", ...baseProfile },
|
|
description: "Host tool description.",
|
|
handler: async () => {},
|
|
});
|
|
`;
|
|
const oracle = oracleFromSource(src);
|
|
assert.deepEqual(oracle.misordered, [], "a nested example payload must not demand reordering");
|
|
assert.deepEqual(oracle.nonStatic, [], "a nested defaults fragment must not be flagged");
|
|
assert.deepEqual(oracle.tools, [{ name: "host-tool", description: "Host tool description." }]);
|
|
// The extractor agrees: only the host tool is emitted.
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
assert.deepEqual(tools, [{ name: "host-tool", description: "Host tool description." }]);
|
|
});
|
|
|
|
test("a getter or method id beside a description is flagged, never silently green", () => {
|
|
// The lexical extractor can never see a computed id, and such an object IS a
|
|
// runtime-real tool - without this flag it would bypass the scan with zero
|
|
// signal on any channel.
|
|
for (const [label, src] of [
|
|
[
|
|
"getter id",
|
|
'defineTool({ get id() { return "getter-id-tool"; }, description: "Real.", handler() {} });',
|
|
],
|
|
[
|
|
"method id",
|
|
'defineTool({ id() { return "method-id-tool"; }, description: "Real.", handler() {} });',
|
|
],
|
|
]) {
|
|
const oracle = oracleFromSource(src);
|
|
assert.equal(oracle.nonStatic.length, 1, `${label}: expected a nonStatic flag`);
|
|
}
|
|
});
|
|
|
|
test("an escaped id literal is cooked to its runtime name on both sides", () => {
|
|
// An id written with an escape (\\u002D is "-") names the tool by its COOKED
|
|
// text at runtime; the scan must see that name, and the oracle and extractor
|
|
// must agree on it.
|
|
const src = 'defineTool({ id: "esc\\u002Dtool", description: "Escaped id.", handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(tools.find((t) => t.name === "esc-tool")?.description, "Escaped id.");
|
|
assert.deepEqual(oracleFromSource(src).tools, [{ name: "esc-tool", description: "Escaped id." }]);
|
|
});
|
|
|
|
test("U+2028/U+2029 line continuations cook to nothing, like backslash-newline", () => {
|
|
// Built programmatically so no raw LS/PS bytes live in this file: the
|
|
// fixture string contains backslash + U+2028 (and + U+2029), each a legal
|
|
// LineTerminatorSequence continuation that cooks to the empty string.
|
|
const src =
|
|
'defineTool({ id: "ls-tool", description: "before \\\u2028after \\\u2029end", handler() {} });';
|
|
const tools = extractToolsFromSource(src, "fixture.ts");
|
|
assert.equal(tools.find((t) => t.name === "ls-tool")?.description, "before after end");
|
|
assert.deepEqual(oracleFromSource(src).tools, [
|
|
{ name: "ls-tool", description: "before after end" },
|
|
]);
|
|
});
|
|
|
|
test('a computed ["description"] key with a literal value surfaces as an extractor-vs-oracle mismatch', () => {
|
|
// The oracle can read `["description"]: "x"` (it IS the runtime description)
|
|
// but the lexical extractor cannot see the `description:` token. The oracle
|
|
// must claim the tool so the real-tree equality test goes red and forces a
|
|
// plain key - never a silent green.
|
|
const src =
|
|
'defineTool({ id: "computed-desc-tool", ["description"]: "Real text.", handler() {} });';
|
|
const { result: tools } = captureWarnings(() => extractToolsFromSource(src, "fixture.ts"));
|
|
assert.deepEqual(tools, []);
|
|
assert.deepEqual(oracleFromSource(src).tools, [
|
|
{ name: "computed-desc-tool", description: "Real text." },
|
|
]);
|
|
});
|