scripts/check-core-skill-tool-refs.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Lint: tool and scope references in shipped core skills must resolve against
* the static tool contracts they describe.
*
* Core-skill Markdown can drift independently from the schemas AJV validates
* before a handler runs. A stale tool name or scope therefore teaches the model
* to make a call the platform itself rejects. This check keeps that prose and
* the statically declared contracts in lockstep without constructing a runtime.
*
* Scope: backticked and bold-marked references in Markdown files directly
* under `src/skills/core/`. Both shapes declare contracts in these prompts — a
* bullet names its tool in bold and its arguments in backticks — so both are
* checked. Tool contracts come from TypeScript files under
* `src/tools/` (`nb__*`) and each platform app's `source.ts` / `schemas.ts`
* declarations (`<source>__*`). Explicit examples for dynamically installed
* connectors are syntax examples, so only their namespace shape is static.
*/
import { readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { Glob } from "bun";
import * as ts from "typescript";
const ROOT = join(import.meta.dirname ?? __dirname, "..");
const CORE_SKILLS_ROOT = join(ROOT, "src", "skills", "core");
const TOOLS_ROOT = join(ROOT, "src", "tools");
const PLATFORM_ROOT = join(ROOT, "src", "platform");
const MARKDOWN_REFERENCE_SPANS: Array<{ re: RegExp; delimiter: number }> = [
{ re: /`([^`]+)`/g, delimiter: 1 },
{ re: /\*\*([^*]+)\*\*/g, delimiter: 2 },
];
const TOOL_REF_RE = /\b([a-z][a-z0-9-]*)__([a-z][a-z0-9_-]*|\*)(?![a-z0-9_-])/g;
const SCOPE_REF_RE = /\bscope:\s*"([^"]+)"/g;
const EXAMPLE_PREFIX_RE = /(?:\be\.g\.|\bfor example)[,:]?\s*$/i;
const BLOCK_BREAK_RE = /^\s*(?:#|$)/;
export interface ToolContract {
source: string;
name: string;
scopes: string[];
}
export interface ReferenceViolation {
file: string;
line: number;
token: string;
owner: string;
accepted: string[];
}
export interface CoreSkillValidation {
references: string[];
violations: ReferenceViolation[];
}
function propertyName(node: ts.ObjectLiteralElementLike): string | null {
if (!ts.isPropertyAssignment(node)) return null;
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) return node.name.text;
return null;
}
function unwrap(expression: ts.Expression): ts.Expression {
let current = expression;
while (
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isTypeAssertionExpression(current) ||
ts.isSatisfiesExpression(current)
) {
current = current.expression;
}
return current;
}
function propertyValue(object: ts.ObjectLiteralExpression, name: string): ts.Expression | null {
for (const property of object.properties) {
if (propertyName(property) === name && ts.isPropertyAssignment(property)) {
return unwrap(property.initializer);
}
}
return null;
}
function stringValue(expression: ts.Expression | null): string | null {
if (
expression &&
(ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression))
) {
return expression.text;
}
return null;
}
function scopeEnum(inputSchema: ts.Expression | null): string[] {
if (!inputSchema || !ts.isObjectLiteralExpression(inputSchema)) return [];
const properties = propertyValue(inputSchema, "properties");
if (!properties || !ts.isObjectLiteralExpression(properties)) return [];
const scope = propertyValue(properties, "scope");
if (!scope || !ts.isObjectLiteralExpression(scope)) return [];
const enumValue = propertyValue(scope, "enum");
if (!enumValue || !ts.isArrayLiteralExpression(enumValue)) return [];
return enumValue.elements
.map((element) => stringValue(unwrap(element)))
.filter((value): value is string => value !== null)
.sort();
}
/** Extract static `{ name, inputSchema }` tool definitions from one TS file. */
export function extractToolContracts(source: string, code: string): ToolContract[] {
const sourceFile = ts.createSourceFile(
`${source}.ts`,
code,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const contracts = new Map<string, ToolContract>();
function visit(node: ts.Node): void {
if (ts.isObjectLiteralExpression(node)) {
const name = stringValue(propertyValue(node, "name"));
const inputSchema = propertyValue(node, "inputSchema");
if (name && inputSchema) {
const key = `${source}__${name}`;
const previous = contracts.get(key);
contracts.set(key, {
source,
name,
scopes: [...new Set([...(previous?.scopes ?? []), ...scopeEnum(inputSchema)])].sort(),
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return [...contracts.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function contractIndex(contracts: ToolContract[]): Map<string, ToolContract> {
const index = new Map<string, ToolContract>();
for (const contract of contracts) {
const key = `${contract.source}__${contract.name}`;
const previous = index.get(key);
index.set(key, {
...contract,
scopes: [...new Set([...(previous?.scopes ?? []), ...contract.scopes])].sort(),
});
}
return index;
}
interface LocatedReference {
token: string;
source: string;
name: string;
index: number;
allowUnknownSource: boolean;
}
function toolReferences(
text: string,
start: number,
allowUnknownSource = false,
): LocatedReference[] {
return [...text.matchAll(TOOL_REF_RE)].map((match) => ({
token: match[0],
source: match[1] ?? "",
name: match[2] ?? "",
index: start + (match.index ?? 0),
allowUnknownSource,
}));
}
function isGenericPlaceholder(ref: LocatedReference): boolean {
return ref.source === "source" && ref.name === "tool";
}
function markdownReferences(line: string): {
tools: LocatedReference[];
ownerTools: LocatedReference[];
scopes: Array<{ token: string; value: string; index: number }>;
} {
const tools = new Map<number, LocatedReference>();
const scopes = new Map<number, { token: string; value: string; index: number }>();
for (const { re, delimiter } of MARKDOWN_REFERENCE_SPANS) {
for (const span of line.matchAll(re)) {
const text = span[1] ?? "";
const spanIndex = span.index ?? 0;
const start = spanIndex + delimiter;
const allowUnknownSource = EXAMPLE_PREFIX_RE.test(line.slice(0, spanIndex));
for (const ref of toolReferences(text, start, allowUnknownSource)) {
tools.set(ref.index, ref);
}
for (const match of text.matchAll(SCOPE_REF_RE)) {
const index = start + (match.index ?? 0);
scopes.set(index, { token: match[0], value: match[1] ?? "", index });
}
}
}
// A nested span (`**\`nb__search\`**`) matches under both shapes at the same
// offset, so keying by offset keeps one reference per occurrence. Owner
// resolution reads every tool-shaped token on the line, marked up or not.
return {
tools: [...tools.values()],
ownerTools: toolReferences(line, 0),
scopes: [...scopes.values()],
};
}
function acceptedToolsBySource(contracts: ToolContract[]): Map<string, string[]> {
const accepted = new Map<string, string[]>();
for (const contract of contracts) {
const names = accepted.get(contract.source) ?? [];
if (!names.includes(contract.name)) names.push(contract.name);
accepted.set(contract.source, names.sort());
}
return accepted;
}
interface DeclaredTool {
index: number;
contract: ToolContract;
}
function declaredTools(
refs: LocatedReference[],
contracts: Map<string, ToolContract>,
): DeclaredTool[] {
return refs.flatMap((ref) => {
const contract = ref.name === "*" ? undefined : contracts.get(`${ref.source}__${ref.name}`);
return contract ? [{ index: ref.index, contract }] : [];
});
}
/**
* The tool a scope value belongs to: the nearest declaration before it on the
* line, else the block's standing owner — a bullet hard-wrapped across several
* lines still describes the tool it opened with — else one named later on the
* line.
*/
function owningTool(
refs: LocatedReference[],
scopeIndex: number,
contracts: Map<string, ToolContract>,
blockOwner: ToolContract | null,
): ToolContract | null {
const declared = declaredTools(refs, contracts);
const preceding = declared.filter((tool) => tool.index <= scopeIndex);
return preceding.at(-1)?.contract ?? blockOwner ?? declared[0]?.contract ?? null;
}
interface ValidationContext {
file: string;
line: number;
contracts: ToolContract[];
index: Map<string, ToolContract>;
sources: Set<string>;
acceptedBySource: Map<string, string[]>;
}
function validateToolReference(
ref: LocatedReference,
context: ValidationContext,
): ReferenceViolation | null {
if (!context.sources.has(ref.source)) {
if (ref.allowUnknownSource) return null;
return {
file: context.file,
line: context.line,
token: ref.token,
owner: "static tool source",
accepted: [...context.sources].sort(),
};
}
if (ref.name === "*" || context.index.has(ref.token)) return null;
return {
file: context.file,
line: context.line,
token: ref.token,
owner: ref.source,
accepted: context.acceptedBySource.get(ref.source) ?? [],
};
}
function validateScopeReference(
scope: { token: string; value: string; index: number },
tools: LocatedReference[],
context: ValidationContext,
blockOwner: ToolContract | null,
): ReferenceViolation | null {
const owner = owningTool(tools, scope.index, context.index, blockOwner);
if (!owner) {
return {
file: context.file,
line: context.line,
token: scope.token,
owner: "unresolved tool",
accepted: context.contracts
.filter((contract) => contract.scopes.length > 0)
.map((contract) => `${contract.source}__${contract.name}`)
.sort(),
};
}
if (owner.scopes.includes(scope.value)) return null;
return {
file: context.file,
line: context.line,
token: scope.token,
owner: `${owner.source}__${owner.name}`,
accepted: owner.scopes,
};
}
interface LineValidation {
references: string[];
violations: ReferenceViolation[];
/** The tool whose description continues onto the next line, if any. */
blockOwner: ToolContract | null;
}
function validateMarkdownLine(
line: string,
context: ValidationContext,
blockOwner: ToolContract | null,
): LineValidation {
const { tools, ownerTools, scopes } = markdownReferences(line);
const references: string[] = [];
const violations: ReferenceViolation[] = [];
for (const ref of tools) {
if (isGenericPlaceholder(ref)) continue;
references.push(ref.token);
const violation = validateToolReference(ref, context);
if (violation) violations.push(violation);
}
// A line naming no tool continues the previous line's subject; one naming a
// tool that does not resolve starts a subject we cannot identify, so the
// standing owner lapses rather than mis-attributing its scopes.
const standingOwner: ToolContract | null = ownerTools.length === 0 ? blockOwner : null;
for (const scope of scopes) {
references.push(scope.token);
const violation = validateScopeReference(scope, ownerTools, context, standingOwner);
if (violation) violations.push(violation);
}
return {
references,
violations,
blockOwner: BLOCK_BREAK_RE.test(line)
? null
: (declaredTools(ownerTools, context.index).at(-1)?.contract ?? standingOwner),
};
}
/** Validate one Markdown core skill against already-extracted contracts. */
export function validateCoreSkill(
file: string,
markdown: string,
contracts: ToolContract[],
): CoreSkillValidation {
const context: ValidationContext = {
file,
line: 0,
contracts,
index: contractIndex(contracts),
sources: new Set(contracts.map((contract) => contract.source)),
acceptedBySource: acceptedToolsBySource(contracts),
};
const references = new Set<string>();
const violations: ReferenceViolation[] = [];
const lines = markdown.split("\n");
let blockOwner: ToolContract | null = null;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
context.line = lineIndex + 1;
const line = validateMarkdownLine(lines[lineIndex] ?? "", context, blockOwner);
for (const reference of line.references) references.add(reference);
violations.push(...line.violations);
blockOwner = line.blockOwner;
}
return { references: [...references].sort(), violations };
}
function loadContracts(): ToolContract[] {
const contracts: ToolContract[] = [];
for (const rel of new Glob("**/*.ts").scanSync({ cwd: TOOLS_ROOT })) {
const file = join(TOOLS_ROOT, rel);
contracts.push(...extractToolContracts("nb", readFileSync(file, "utf-8")));
}
for (const rel of new Glob("*/{source,schemas}.ts").scanSync({ cwd: PLATFORM_ROOT })) {
const [source] = rel.split(/[\\/]/);
if (!source) continue;
const file = join(PLATFORM_ROOT, rel);
contracts.push(...extractToolContracts(source, readFileSync(file, "utf-8")));
}
return [...contractIndex(contracts).values()].sort((a, b) => {
const sourceOrder = a.source.localeCompare(b.source);
return sourceOrder === 0 ? a.name.localeCompare(b.name) : sourceOrder;
});
}
function main(): void {
const contracts = loadContracts();
const references = new Set<string>();
const violations: ReferenceViolation[] = [];
let scanned = 0;
for (const rel of new Glob("*.md").scanSync({ cwd: CORE_SKILLS_ROOT })) {
const file = join(CORE_SKILLS_ROOT, rel);
const displayPath = relative(ROOT, file).split("\\").join("/");
const result = validateCoreSkill(displayPath, readFileSync(file, "utf-8"), contracts);
for (const reference of result.references) references.add(reference);
violations.push(...result.violations);
scanned++;
}
if (violations.length > 0) {
console.error(`✗ Found ${violations.length} invalid core-skill tool/schema reference(s):\n`);
for (const violation of violations) {
console.error(` ${violation.file}:${violation.line} ${violation.token}`);
console.error(` owner: ${violation.owner}`);
console.error(` accepted: ${violation.accepted.join(", ") || "(none)"}\n`);
}
process.exit(1);
}
console.log(
`✓ ${references.size} distinct tool/scope references validated across ${scanned} core skills`,
);
}
if (import.meta.main) main();