Files
deepseek-harness/scripts/verify-plugin-record-callers.ts
T
Ruilin Geng dd06371929 feat(gates): 禁止插件入口新增值导出 (#7860)
* feat(gates): reject value exports on function plugin entries

A function plugin entry may export only the Loader fields (name, inject,
Config, apply), types, and classes whose instances extend Cordis Service or
Error, as docs/defensive-patterns/package-surface.md requires. The new
verify-plugin-entry-exports gate reads every packages/*/*/src/index.ts that
exports a value apply through the Host and then the Client compiler face,
and rejects any other value export, including a default export beside
apply, values that arrive through export *, and exports that do not resolve.

Exports that predate the gate are listed per package in
scripts/plugin-entry-existing-exports.ts. A listed export passes; a line
fails when its package has no function plugin entry, its list is empty or
repeats a name, or it names an export the entry no longer reports.

* docs(gates): mark new existing-export lines as rule exceptions

A line added to scripts/plugin-entry-existing-exports.ts after the gate's
introduction is an exception under the plugin entry rule in
docs/defensive-patterns/package-surface.md, which requires the domain
owner's confirmation.

* fix(gates): report a default export beside apply even when it is a class

The class allowance also covered the default export, so a function plugin
entry that default-exported a class extending Cordis Service or Error beside
apply passed the gate. The Loader then loads that default export instead of
the module namespace, which is the mixed plugin form the rule forbids. A
default export beside apply is now reported whatever it holds.

* fix(gates): judge plugin entry exports by their run-time export paths

The gate read TypeScript's export table, which differs from what the entry
exports at run time in two forms. An `export type { x }` hid a value that
`export *` brings in under the same name, although the type-only export is
erased and the value stays. A name that a nested `export type *` reaches was
reported as a value, although nothing reaches the entry at run time.

The gate now walks the emitted export forms: type-only exports and declare
declarations emit nothing, a re-export emits only a run-time export of its
target, and `export *` brings in every run-time name of its target except
default and the names the module exports itself. An `export *` whose module
does not resolve is reported as `*`.

* feat(gates): check service plugin entries for value exports

The plugin entry rule in docs/defensive-patterns/package-surface.md covers
both entry forms: a function plugin exports the Loader fields, a service
plugin default-exports its plugin class, and beyond those a Host entry may
export only Service Definition classes and error types. The gate checked
only function plugins, so a value on a service plugin entry, which another
package imports past the Loader row just the same, went unreported, and an
entry could escape the gate by default-exporting a class.

An entry without apply whose default export is a class the Loader can
construct is now a service plugin. Its default class passes under any name,
as do types and classes extending Cordis Service or Error; name, inject,
Config, and every other value are reported. Libraries, a default export
that is not a class, and an abstract default class, which defines a
capability seam, stay unchecked. The corpus guard now requires both forms.

The existing-export list is regenerated to cover the service plugin
entries' current value exports.

* fix(gates): admit the plugin entry export list as a plugin record name

The plugin entry export list now records the session service entry's
existing value exports, appendPluginRecord among them. Like the published
dependency policy, the list names the function without calling it, so the
plugin record caller gate admits it.

* chore(gates): list the plugin entry exports that landed on master

Master gained value exports on the plugin-manager, llm, and subagent
service entries and renamed office-to-pdf to office-kit before the gate
lands. They predate the gate, so the existing-export list records them.
2026-11-05 11:11:05 +00:00

294 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, as are the published
* dependency policy and the plugin entry export list, which name the function to classify or record its
* export without calling it.
*
* 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<string> = new Set([
'scripts/verify-plugin-record-callers.ts',
'scripts/verify-plugin-record-callers.spec.ts',
])
/** The published dependency policy names the restricted function to classify the export's package edge. */
export const DEPENDENCY_POLICY_FILE = 'scripts/package-dependency-policy.ts'
/** The plugin entry export list names the restricted function to record the session entry's existing export. */
export const ENTRY_EXPORT_LIST_FILE = 'scripts/plugin-entry-existing-exports.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, this gate, the dependency policy, or the plugin
* entry export list.
*/
export function isAdmittedCaller(file: string): boolean {
return file.startsWith(EXPERIMENTAL_PREFIX) || file === OWNER_FILE || isTestFile(file) || GATE_FILES.has(file)
|| file === DEPENDENCY_POLICY_FILE || file === ENTRY_EXPORT_LIST_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<string, string>): 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<string, string>,
): 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<string>()
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<string, string> {
const listing = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
const files = new Map<string, string>()
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()