/** * Verify that only experimental packages call `appendPluginRecord` from production source. * * `appendPluginRecord` in `@deepseek-ai/dsh-session` appends an ignorable `plugin:` record that the * experimental persistence catalogue lists and that a Session format migration keeps only on a best-effort * basis. Experimental packages may rely on that; a release package must declare its events instead. * The owning module and test files, which exercise the operation, are exempt. * * Discovery is syntax-aware: every identifier spelled `appendPluginRecord` counts — an import, a * named re-export, a call, a namespace member, a destructured binding, or an alias source — and so * does a string literal of exactly that name, which reaches the function by element access. * Comments, including JSDoc links, and longer strings do not count. A wildcard re-export * (`export * from`) names no identifier and is not reported; code that calls the function through * it still names the function, and that reference is. A file is parsed when its text, or its text * with its escapes unescaped, contains the name, so a spelling that escapes part of it (`\u0061`, * `\x61`, `\u{61}`, `\141`, `\a`, a line continuation) is still found. * Production writes must name a record in the production-only `PluginRecordMap` inventory; * test declarations cannot authorize a production write. */ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { relative, resolve } from 'node:path' import ts from 'typescript' import { collectPluginRecords } from './plugin-record-catalog.ts' import { faceConfigs } from './ts-project.ts' const root = resolve(import.meta.dirname, '..') /** The restricted function. */ export const RESTRICTED_NAME = 'appendPluginRecord' /** The module that declares the restricted function. */ export const OWNER_FILE = 'packages/core/session/src/index.ts' /** Production source under this prefix may call the restricted function. */ export const EXPERIMENTAL_PREFIX = 'packages/experimental/' /** This gate and its spec name the restricted function to find it. */ const GATE_FILES: ReadonlySet = new Set([ 'scripts/verify-plugin-record-callers.ts', 'scripts/verify-plugin-record-callers.spec.ts', ]) const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/u /** A line continuation, which a string or template literal drops from its value. */ const LINE_CONTINUATION = /\\(?:\r\n|[\n\r\u2028\u2029])/gu /** A `\u{…}`, `\uXXXX`, or `\xXX` escape, capturing its hex digits. */ const HEX_ESCAPE = /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2}))/gu /** * A legacy octal escape, which a sloppy-mode script such as a `.cjs` file may use: up to three * octal digits, three only when the first is 0–3, so the value stays within 0o377. */ const OCTAL_ESCAPE = /\\([0-3][0-7]{0,2}|[4-7][0-7]?)/gu /** * The source as its escapes could spell it: line continuations dropped, hex and legacy octal * escapes decoded, and every other backslash removed. Text that the parser reads as the * restricted name, in an identifier or a string, contains the name here too. * @param source - file contents. * @returns the unescaped text. */ export function unescaped(source: string): string { return source .replace(LINE_CONTINUATION, '') .replace(HEX_ESCAPE, (match: string, braced?: string, unit?: string, byte?: string) => { const code = Number.parseInt(`${braced ?? ''}${unit ?? ''}${byte ?? ''}`, 16) return code <= 0x10ffff ? String.fromCodePoint(code) : match }) .replace(OCTAL_ESCAPE, (_match: string, digits: string) => String.fromCharCode(Number.parseInt(digits, 8))) .replaceAll('\\', '') } /** One production-source reference to the restricted function outside its admitted callers. */ export interface PluginRecordCaller { /** Repository-relative path, in POSIX separators. */ readonly file: string /** One-based line number. */ readonly line: number /** The trimmed source line. */ readonly text: string /** Why a production write lacks a catalogue declaration, when applicable. */ readonly reason?: string } /** * Whether a repository path is a test file under the repository's lint-override globs. * @param file - repository-relative path, in POSIX separators. * @returns whether the file is a package, app, or example test, or a script spec. */ export function isTestFile(file: string): boolean { return /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|examples\/[^/]+)\/tests\//u.test(file) || /^scripts\/.+\.spec\.tsx?$/u.test(file) } /** * Whether a file may reference the restricted function. * @param file - repository-relative path, in POSIX separators. * @returns whether the file is experimental source, the owner, a test, or this gate. */ export function isAdmittedCaller(file: string): boolean { return file.startsWith(EXPERIMENTAL_PREFIX) || file === OWNER_FILE || isTestFile(file) || GATE_FILES.has(file) } function scriptKind(file: string): ts.ScriptKind { if (file.endsWith('.tsx')) return ts.ScriptKind.TSX if (file.endsWith('.jsx')) return ts.ScriptKind.JSX if (/\.[cm]?js$/u.test(file)) return ts.ScriptKind.JS return ts.ScriptKind.TS } /** * Find references to the restricted function in one source file, admitted or not. * @param file - repository-relative path; its extension selects the parser. * @param source - file contents. * @returns one entry per referencing identifier or exact-name string literal, in source order. */ export function findPluginRecordReferences(file: string, source: string): PluginRecordCaller[] { if (!source.includes(RESTRICTED_NAME) && !unescaped(source).includes(RESTRICTED_NAME)) return [] const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file)) const lines = source.split(/\r?\n/u) const references: PluginRecordCaller[] = [] const visit = (node: ts.Node): void => { const named = ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) if (named && node.text === RESTRICTED_NAME) { const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line references.push({ file, line: line + 1, text: lines[line]?.trim() ?? '' }) } ts.forEachChild(node, visit) } visit(sourceFile) return references } /** Whether the owner still exports a function declaration under the restricted name. */ function declaresRestrictedFunction(source: string): boolean { const sourceFile = ts.createSourceFile(OWNER_FILE, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) return sourceFile.statements.some(statement => ts.isFunctionDeclaration(statement) && statement.name?.text === RESTRICTED_NAME && ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) === true) } /** * Check a source corpus and return its unadmitted references. * @param files - repository-relative path, in POSIX separators, to contents. * @returns every reference outside experimental source, the owner, tests, and this gate. * @throws when the corpus lacks the owner's exported declaration or any experimental or release * package source, because the gate would then pass by checking the wrong files. */ export function checkPluginRecordCallers(files: ReadonlyMap): PluginRecordCaller[] { const owner = files.get(OWNER_FILE) if (owner === undefined || !declaresRestrictedFunction(owner)) { throw new Error(`verify-plugin-record-callers: ${OWNER_FILE} no longer exports function ${RESTRICTED_NAME}; update this gate with the move or rename.`) } const paths = [...files.keys()] if (!paths.some(file => file.startsWith(EXPERIMENTAL_PREFIX)) || !paths.some(file => file.startsWith('packages/') && !isAdmittedCaller(file))) { throw new Error('verify-plugin-record-callers: the corpus lacks experimental or release package source; the file listing no longer matches the repository.') } return paths .filter(file => !isAdmittedCaller(file)) .flatMap(file => findPluginRecordReferences(file, files.get(file) ?? '')) } /** * Check production write keys against the generated catalogue's source inventory. * The compiler reads production sources only, so test map augmentations cannot widen the writer. * @param repoRoot - checkout holding the compiler face and record declarations. * @param files - tracked and unignored repository sources. * @returns compiler errors in experimental production consumers, including undeclared write keys. */ export function checkDeclaredPluginRecordWrites( repoRoot: string, files: ReadonlyMap, ): PluginRecordCaller[] { const declared = new Set(collectPluginRecords(repoRoot).map(record => record.name)) const candidates = [...files.keys()].filter(file => file.startsWith(EXPERIMENTAL_PREFIX) && !isTestFile(file) && findPluginRecordReferences(file, files.get(file) ?? '').length > 0) if (candidates.length === 0) return [] const remaining = new Set(candidates) const violations: PluginRecordCaller[] = [] for (const face of ['host', 'client'] as const) { if (remaining.size === 0) break const configs = faceConfigs(repoRoot, face) const roots = new Set() for (const config of configs.byPath.values()) { for (const file of config.fileNames) { if (!isTestFile(relative(repoRoot, file).replaceAll('\\', '/'))) roots.add(resolve(file)) } } const admitted = candidates.filter(file => roots.has(resolve(repoRoot, file))) if (admitted.length === 0) continue for (const file of admitted) remaining.delete(file) const consumers = new Set(admitted.map(file => file.split('/').slice(0, 3).join('/'))) const program = ts.createProgram([...roots], { ...configs.root.options, noEmit: true, composite: false, incremental: false, }) const owner = program.getSourceFile(resolve(repoRoot, 'packages/core/session/src/types.ts')) const map = owner?.statements.find((node): node is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(node) && node.name.text === 'PluginRecordMap') if (map === undefined) throw new Error('production compiler did not load the owning PluginRecordMap') for (const member of program.getTypeChecker().getTypeAtLocation(map).getProperties()) { if (declared.has(member.name)) continue const declaration = member.declarations?.[0] if (declaration === undefined) throw new Error(`plugin record has no source declaration: ${member.name}`) const source = declaration.getSourceFile() violations.push({ file: relative(repoRoot, source.fileName).replaceAll('\\', '/'), line: source.getLineAndCharacterOfPosition(declaration.getStart(source)).line + 1, text: declaration.getText(source), reason: `record ${member.name} is visible to production but has no production catalogue declaration`, }) } for (const file of roots) { const local = relative(repoRoot, file).replaceAll('\\', '/') if (!consumers.has(local.split('/').slice(0, 3).join('/'))) continue const source = program.getSourceFile(file) if (source === undefined) throw new Error(`plugin record caller source was not loaded: ${local}`) for (const diagnostic of program.getSemanticDiagnostics(source)) { if (diagnostic.category !== ts.DiagnosticCategory.Error) continue const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line violations.push({ file: local, line: line + 1, text: source.text.split(/\r?\n/u)[line]?.trim() ?? '', reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '), }) } } } for (const file of remaining) { violations.push({ file, line: 1, text: '', reason: 'production record writers must be included in a Host or Client compiler face' }) } return violations } /** * Read every tracked or unignored source file outside `vendor/`. * @param repoRoot - the repository root; defaults to this checkout. * @returns repository-relative path, in POSIX separators, to contents. */ export function readRepositorySources(repoRoot: string = root): Map { const listing = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }) const files = new Map() for (const entry of listing.split('\0')) { const file = entry.replaceAll('\\', '/') if (file === '' || file.startsWith('vendor/') || !SOURCE_EXTENSION.test(file)) continue try { files.set(file, readFileSync(resolve(repoRoot, file), 'utf8')) } catch (error: unknown) { // A file deleted in the working tree is still listed until the deletion is staged. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } } return files } function main(): void { const files = readRepositorySources() const callers = [...checkPluginRecordCallers(files), ...checkDeclaredPluginRecordWrites(root, files)] if (callers.length === 0) { console.log('verify-plugin-record-callers: production callers are experimental and record keys are catalogued.') return } console.error('verify-plugin-record-callers: invalid production record callers.\n') for (const caller of callers) console.error(` ${caller.file}:${String(caller.line)} ${caller.text}${caller.reason === undefined ? '' : ` — ${caller.reason}`}`) console.error('\nA release package declares its events in SessionEventMap instead of writing plugin records.') process.exit(1) } if (import.meta.filename === resolve(process.argv[1] ?? '')) main()