openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
280 lines (259 loc) • 8.95 kB
JavaScript
// Shared parsing, diffing, and reporting helpers for inventory guard scripts.
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
let cachedTypeScript;
/** Convert an absolute file path to a repo-relative POSIX path. */
export function normalizeRepoPath(repoRoot, filePath) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
/** Resolve a relative or absolute module specifier to a repo-relative path. */
export function resolveRepoSpecifier(repoRoot, specifier, importerFile) {
if (specifier.startsWith(".")) {
return normalizeRepoPath(repoRoot, path.resolve(path.dirname(importerFile), specifier));
}
if (specifier.startsWith("/")) {
return normalizeRepoPath(repoRoot, specifier);
}
return null;
}
/** Visit static and dynamic module specifiers in a parsed TypeScript source file. */
export function visitModuleSpecifiers(ts, sourceFile, visit, options = {}) {
function walk(node) {
let kind;
let specifierNode;
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
kind = "import";
specifierNode = node.moduleSpecifier;
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
kind = "export";
specifierNode = node.moduleSpecifier;
} else if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length >= 1 &&
ts.isStringLiteralLike(node.arguments[0])
) {
kind = "dynamic-import";
specifierNode = node.arguments[0];
} else if (
options.includeImportTypes &&
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteralLike(node.argument.literal)
) {
kind = "dynamic-import";
specifierNode = node.argument.literal;
} else if (
options.includeCommonJs &&
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "require" &&
node.arguments.length >= 1 &&
ts.isStringLiteralLike(node.arguments[0])
) {
kind = "commonjs-require";
specifierNode = node.arguments[0];
} else if (
options.includeCommonJs &&
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference) &&
node.moduleReference.expression &&
ts.isStringLiteralLike(node.moduleReference.expression)
) {
kind = "commonjs-require";
specifierNode = node.moduleReference.expression;
} else if (
options.includeImportMetaUrl &&
ts.isNewExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "URL" &&
node.arguments?.length >= 2 &&
ts.isStringLiteralLike(node.arguments[0]) &&
ts.isPropertyAccessExpression(node.arguments[1]) &&
node.arguments[1].name.text === "url" &&
ts.isMetaProperty(node.arguments[1].expression) &&
node.arguments[1].expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
node.arguments[1].expression.name.text === "meta"
) {
kind = "import-meta-url";
specifierNode = node.arguments[0];
}
if (specifierNode) {
visit({ kind, node, specifier: specifierNode.text, specifierNode });
}
ts.forEachChild(node, walk);
}
walk(sourceFile);
}
/** Write one line to a stream without each caller repeating newline handling. */
export function writeLine(stream, text) {
stream.write(`${text}\n`);
}
function hasSupplementalModuleReference(ts, source, acceptSpecifier) {
// Escaped names and regexp ambiguity must reach the scanner before rejecting the file.
if (
!source.includes("new") &&
!source.includes("${") &&
!source.includes("export") &&
!source.includes("/") &&
!source.includes("\\u")
) {
return false;
}
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
true,
ts.LanguageVariant.Standard,
source,
);
const kinds = ts.SyntaxKind;
const templateBraceDepths = [];
const scanAcceptedSpecifier = () => {
const token = scanner.scan();
return (
(token === kinds.StringLiteral || token === kinds.NoSubstitutionTemplateLiteral) &&
acceptSpecifier(scanner.getTokenValue())
);
};
for (let token = scanner.scan(); token !== kinds.EndOfFileToken; token = scanner.scan()) {
if (
token === kinds.NewKeyword &&
scanner.lookAhead(
() =>
scanner.scan() === kinds.Identifier &&
scanner.getTokenValue() === "URL" &&
scanner.scan() === kinds.OpenParenToken &&
scanAcceptedSpecifier(),
)
) {
return true;
}
if (
(token === kinds.RequireKeyword ||
(token === kinds.Identifier && scanner.getTokenValue() === "require")) &&
scanner.lookAhead(() => scanner.scan() === kinds.OpenParenToken && scanAcceptedSpecifier())
) {
return true;
}
if (
token === kinds.ExportKeyword &&
scanner.lookAhead(() => {
let next = scanner.scan();
if (next === kinds.TypeKeyword) {
next = scanner.scan();
}
return next === kinds.AsteriskToken && scanner.scan() === kinds.AsKeyword;
})
) {
return true;
}
// Only the parser distinguishes division from regexps, whose quotes or braces
// can hide later module references from a context-free scanner.
if (token === kinds.SlashToken || token === kinds.SlashEqualsToken) {
return true;
}
// Resume after each nested interpolation; otherwise template tails swallow later imports.
if (token === kinds.TemplateHead) {
templateBraceDepths.push(0);
} else if (token === kinds.OpenBraceToken && templateBraceDepths.length > 0) {
templateBraceDepths[templateBraceDepths.length - 1] += 1;
} else if (token === kinds.CloseBraceToken && templateBraceDepths.length > 0) {
const index = templateBraceDepths.length - 1;
if (templateBraceDepths[index] > 0) {
templateBraceDepths[index] -= 1;
} else if (scanner.reScanTemplateToken(false) === kinds.TemplateTail) {
templateBraceDepths.pop();
}
}
}
return false;
}
/**
* Lexically reject clean files before parsing candidate module-boundary violations.
*
* @param {string} source
* @param {{
* acceptSpecifier?: (specifier: string) => boolean;
* fileName?: string;
* ts?: typeof import("typescript");
* }} [options]
* @returns {Array<{ kind: string; line: number; specifier: string }>}
*/
export function collectModuleReferencesFromSource(source, options = {}) {
const ts = options.ts ?? (cachedTypeScript ??= require("typescript"));
const acceptSpecifier = options.acceptSpecifier ?? (() => true);
const candidates = ts.preProcessFile(source, true, true).importedFiles;
if (
!candidates.some(({ fileName }) => acceptSpecifier(fileName)) &&
!hasSupplementalModuleReference(ts, source, acceptSpecifier)
) {
return [];
}
const sourceFile = ts.createSourceFile(
options.fileName ?? "source.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const references = [];
visitModuleSpecifiers(
ts,
sourceFile,
({ kind, specifier, specifierNode }) => {
if (acceptSpecifier(specifier)) {
references.push({
kind,
line:
sourceFile.getLineAndCharacterOfPosition(specifierNode.getStart(sourceFile)).line + 1,
specifier,
});
}
},
{ includeCommonJs: true, includeImportMetaUrl: true, includeImportTypes: true },
);
return references.toSorted(
(left, right) =>
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier),
);
}
/** Memoize an async factory while resetting the cache after failures. */
export function createCachedAsync(factory) {
let cachedPromise = null;
return async function getCachedValue() {
if (cachedPromise) {
return cachedPromise;
}
cachedPromise = factory();
try {
return await cachedPromise;
} catch (error) {
cachedPromise = null;
throw error;
}
};
}
/** Format grouped inventory entries for human-readable guard output. */
export function formatGroupedInventoryHuman(params, inventory) {
if (inventory.length === 0) {
return `${params.rule}\n${params.cleanMessage}`;
}
const lines = [params.rule, params.inventoryTitle];
let activeFile = "";
for (const entry of inventory) {
if (entry.file !== activeFile) {
activeFile = entry.file;
lines.push(activeFile);
}
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
lines.push(` specifier: ${entry.specifier}`);
lines.push(` resolved: ${entry.resolvedPath}`);
}
return lines.join("\n");
}