diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml
index 38dff01a1b..5cdbd02199 100644
--- a/docs/subsystems/session.i18n.yaml
+++ b/docs/subsystems/session.i18n.yaml
@@ -59,6 +59,9 @@
/sessions/plugin-contributed-log-only-events:
en: 0a014a755b75449f
zh: 9d59d74490a1bcb2
+/sessions/plugin-contributed-log-only-events/plugin-records:
+ en: fbdff72f8b2f3194
+ zh: 7a7745b0edb75ed6
/sessions/durability-contract:
en: 62e3e4fbcba292a3
zh: cb6dcaf531621e85
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index 65bcc3edb4..3f44d9b07b 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -752,6 +752,39 @@ When several events in one plugin-owned family assemble into one Web Client Conv
The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn.
+### Plugin records
+
+Experimental packages keep plugin-owned state in plugin records instead of declaring events: `appendPluginRecord()` appends an event marked `ignorable: true` whose type is in the `plugin:` namespace, and `pluginRecordOf()` reads one back. The [package README](../../packages/core/session/README.md#write-experimental-plugin-records) owns the caller restriction, retention, and read path.
+
+```ts type-equiv
+/**
+ * Event type of an experimental plugin record: `plugin:` followed by
+ * slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
+ * each starting with a letter or digit. A record type is never a
+ * {@link SessionEventMap} member.
+ */
+type PluginRecordType = `plugin:${string}`
+```
+
+```ts type-equiv
+/**
+ * One committed experimental plugin record, read from the log by
+ * `pluginRecordOf`. The record's owner validates `data` before use, because a
+ * restored record carries whatever JSON an earlier build of its owner wrote,
+ * or a V3 event that a format migration renamed into the `plugin:` namespace held.
+ */
+interface PluginRecord {
+ /** The record type, chosen by the owning plugin. */
+ readonly type: PluginRecordType
+ /** The record's position in the Session log. */
+ readonly seq: SessionSeq
+ /** Unix epoch milliseconds at append. */
+ readonly time: number
+ /** The JSON payload as committed. */
+ readonly data: unknown
+}
+```
+
## Durability contract
What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as a handle's `read()` returns the exact appended events; current JSONL writes one row per event (see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index 6ed98c07f2..67e122496c 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -756,6 +756,39 @@ interface TurnEndReasonMap {
钩子桥接层的 `hook/invoked` / `hook/result` 对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次。
+### 插件记录
+
+实验性包把插件自有的状态保存为插件记录,而不是声明事件:`appendPluginRecord()` 追加一条带 `ignorable: true` 标记、类型位于 `plugin:` 命名空间的事件,`pluginRecordOf()` 把它读回。调用方限制、保留方式与读取路径见[包 README](../../packages/core/session/README.zh.md#write-experimental-plugin-records)。
+
+```ts type-equiv
+/**
+ * Event type of an experimental plugin record: `plugin:` followed by
+ * slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
+ * each starting with a letter or digit. A record type is never a
+ * {@link SessionEventMap} member.
+ */
+type PluginRecordType = `plugin:${string}`
+```
+
+```ts type-equiv
+/**
+ * One committed experimental plugin record, read from the log by
+ * `pluginRecordOf`. The record's owner validates `data` before use, because a
+ * restored record carries whatever JSON an earlier build of its owner wrote,
+ * or a V3 event that a format migration renamed into the `plugin:` namespace held.
+ */
+interface PluginRecord {
+ /** The record type, chosen by the owning plugin. */
+ readonly type: PluginRecordType
+ /** The record's position in the Session log. */
+ readonly seq: SessionSeq
+ /** Unix epoch milliseconds at append. */
+ readonly time: number
+ /** The JSON payload as committed. */
+ readonly data: unknown
+}
+```
+
## 持久性约定
持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要句柄的 `read()` 返回与追加时完全一致的事件即可;当前 JSONL 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml
index 08cff85f8c..29323611a1 100644
--- a/packages/core/session/README.i18n.yaml
+++ b/packages/core/session/README.i18n.yaml
@@ -24,11 +24,11 @@
en: aa5f1951ecf3ac0c
zh: 62a599ace8f2bf15
/deepseek-ai-dsh-session/use-this-package/read-the-log:
- en: 2b29cfeac7a2baa8
- zh: b535b04dcbc29d3d
+ en: c4bf3103b9762b6c
+ zh: 369c734bb20de2ea
/deepseek-ai-dsh-session/use-this-package/write-experimental-plugin-records:
- en: 112ec5a859c20281
- zh: 813986ed8b9d8477
+ en: e2ee607edd3f567b
+ zh: 8aac3277adfd72cc
/deepseek-ai-dsh-session/use-this-package/fork-a-session:
en: 1c40d17a786059d3
zh: 81320ce118977c65
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index 7912e9805b..399e2aa36b 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -63,11 +63,13 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and
Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers.
+
+
### Write experimental plugin records
-`appendPluginRecord(session, type, data)` appends one plugin record for a package under `packages/experimental/`; the `verify-plugin-record-callers` gate rejects any other production caller in this repository. A record is an event marked `ignorable: true` whose type is `plugin:` followed by lowercase slash-separated segments, such as `plugin:pi-extensions/entry`. No `SessionEventMap` member uses that namespace, so the persistence catalog does not list records, and a build that does not recognize one retains and skips it on read. A record never enters the model-visible surface. Resume and fork carry it with the rest of the log, and a Session format migration keeps it on a best-effort basis.
+`appendPluginRecord(session, type, data)` appends one plugin record for a package under `packages/experimental/`; the `verify-plugin-record-callers` gate rejects any other production caller in this repository. Plugins outside this repository must not call it either, although no gate can check them. A record is an event marked `ignorable: true` whose type is `plugin:` followed by lowercase slash-separated segments, such as `plugin:pi-extensions/entry`. No `SessionEventMap` member uses that namespace, so the persistence catalog does not list records, and a build that does not recognize one retains and skips it on read. A record never enters the model-visible surface. Resume and fork carry it with the rest of the log, and a Session format migration keeps it on a best-effort basis.
-`pluginRecordOf(event)` returns an event as a record, or `undefined` for any other event; a `ctx.sessionProjections` unit that passes each event to it rebuilds plugin state on resume. The owning package validates `data` before use, because a restored record carries whatever JSON an earlier build wrote. The [ignorable events decision](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) records why records are restricted to experimental packages.
+`pluginRecordOf(event)` returns an event as a record, or `undefined` for any other event; a `ctx.sessionProjections` unit that passes each event to it rebuilds plugin state on resume. The V3-to-V4 format edge also renames each unknown ignorable V3 event into the `plugin:` namespace, so the owning package names its records under its own package name and validates `data` before use: a restored record carries whatever JSON an earlier build or a migrated event holds. The [ignorable events decision](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) records why records are restricted to experimental packages.
### Fork a session
diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md
index 1ce4ec2a67..30fb03d435 100644
--- a/packages/core/session/README.zh.md
+++ b/packages/core/session/README.zh.md
@@ -63,11 +63,13 @@ session.deriveMessages() // the derived model history
会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,brand 在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。
+
+
### 写入实验性插件记录
-`appendPluginRecord(session, type, data)` 为 `packages/experimental/` 下的包追加一条插件记录;`verify-plugin-record-callers` 门禁拒绝本仓库中任何其他生产调用方。记录是带有 `ignorable: true` 标记的事件,其类型为 `plugin:` 后接以斜杠分隔的小写段,例如 `plugin:pi-extensions/entry`。没有任何 `SessionEventMap` 成员使用该命名空间,因此持久化目录不列出记录;不认识某条记录的构建在读取时保留并跳过它。记录从不进入模型可见的 surface。resume 与 fork 随日志其余部分一起携带它,Session 格式迁移以尽力而为的方式保留它。
+`appendPluginRecord(session, type, data)` 为 `packages/experimental/` 下的包追加一条插件记录;`verify-plugin-record-callers` 门禁拒绝本仓库中任何其他生产调用方。本仓库之外的插件同样不得调用它,尽管没有门禁能检查它们。记录是带有 `ignorable: true` 标记的事件,其类型为 `plugin:` 后接以斜杠分隔的小写段,例如 `plugin:pi-extensions/entry`。没有任何 `SessionEventMap` 成员使用该命名空间,因此持久化目录不列出记录;不认识某条记录的构建在读取时保留并跳过它。记录从不进入模型可见的 surface。resume 与 fork 随日志其余部分一起携带它,Session 格式迁移以尽力而为的方式保留它。
-`pluginRecordOf(event)` 把事件作为记录返回,对其他任何事件返回 `undefined`;把每个事件都交给它的 `ctx.sessionProjections` 单元在 resume 时重建插件状态。所属包在使用 `data` 之前先校验,因为恢复出的记录携带的是早先构建写入的任意 JSON。[ignorable 事件决策](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)记录了为何只有实验性包可以写入记录。
+`pluginRecordOf(event)` 把事件作为记录返回,对其他任何事件返回 `undefined`;把每个事件都交给它的 `ctx.sessionProjections` 单元在 resume 时重建插件状态。V3-to-V4 格式迁移边也会把每个未知的 ignorable V3 事件改名放入 `plugin:` 命名空间,因此所属包以自己的包名命名其记录,并在使用 `data` 之前先校验:恢复出的记录携带的是早先构建写入或迁移事件带来的任意 JSON。[ignorable 事件决策](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)记录了为何只有实验性包可以写入记录。
### 派生会话的 fork
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index 45cb0faa1a..a6a0e45979 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -494,7 +494,9 @@ export function appendPluginRecord(session: Session, type: PluginRecordType, dat
}
/**
- * Read one committed event as a plugin record.
+ * Read one committed event as a plugin record. The V3-to-V4 format edge also
+ * renames each unknown ignorable V3 event into the `plugin:` namespace, so an
+ * owner names its records under its own package name and validates `data`.
* @param event - any committed Session event, live or restored.
* @returns the record, or undefined when the event is not an ignorable event
* whose type follows the plugin record grammar.
@@ -821,7 +823,7 @@ export class Session {
}
static {
- commitPluginRecord = (session, type, data) => session.appendRecord(type, data)
+ commitPluginRecord = (session, type, data) => session.#appendRecord(type, data)
}
/**
@@ -830,7 +832,7 @@ export class Session {
* @param data - JSON payload, snapshotted before it enters the log.
* @returns the record's sequence number.
*/
- private appendRecord(type: PluginRecordType, data: JsonValue): SessionSeq {
+ #appendRecord(type: PluginRecordType, data: JsonValue): SessionSeq {
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`plugin record "${type}" carries non-JSON-serializable data`)
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index f12805fe28..f20f9b4293 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -526,7 +526,8 @@ export type PluginRecordType = `plugin:${string}`
/**
* One committed experimental plugin record, read from the log by
* `pluginRecordOf`. The record's owner validates `data` before use, because a
- * restored record carries whatever JSON an earlier build of its owner wrote.
+ * restored record carries whatever JSON an earlier build of its owner wrote,
+ * or a V3 event that a format migration renamed into the `plugin:` namespace held.
*/
export interface PluginRecord {
/** The record type, chosen by the owning plugin. */
diff --git a/packages/core/session/tests/plugin-records.spec.ts b/packages/core/session/tests/plugin-records.spec.ts
index 3bbbc2cec3..f74c5954c6 100644
--- a/packages/core/session/tests/plugin-records.spec.ts
+++ b/packages/core/session/tests/plugin-records.spec.ts
@@ -7,6 +7,7 @@ import SessionStore, {
pluginRecordOf,
Session,
SessionId,
+ SessionLogOffset,
SessionSeq,
} from '@deepseek-ai/dsh-session'
import type { PluginRecordType, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -107,16 +108,17 @@ describe('plugin records', () => {
await ctx.fiber.dispose()
})
- it('carries records through a restore and into a fork', () => {
+ it('carries records through a snapshot restore, a storage reopen, and a fork', () => {
const session = Session.create(SessionId('source'))
openTurn(session)
appendPluginRecord(session, 'plugin:test/state', { count: 1 })
const events = session.snapshotEvents()
const restored = Session.create(SessionId('restored'), events)
+ const reopened = Session.fromRestore(SessionId('source'), events, session.header, SessionLogOffset(0), 'shared-frozen')
const child = Session.create(SessionId('child'), buildForkSeed(events, SessionSeq(2)))
- for (const copy of [restored, child]) {
+ for (const copy of [restored, reopened, child]) {
expect(pluginRecordOf(copy.snapshotEvents()[2] as SessionEvent))
.toEqual({ type: 'plugin:test/state', seq: 2, time: events[2]?.time, data: { count: 1 } })
expect(copy.deriveMessages()).toEqual(session.deriveMessages())
diff --git a/scripts/persistence-catalog-source.ts b/scripts/persistence-catalog-source.ts
index 46e9419ed5..76fa3614f3 100644
--- a/scripts/persistence-catalog-source.ts
+++ b/scripts/persistence-catalog-source.ts
@@ -186,7 +186,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const name = member.name.text
const where = `log event '${name}' (${src})`
if (name.startsWith('plugin:')) {
- violations.push(`${where} uses the plugin: namespace, which holds ignorable plugin records outside the event vocabulary; choose a scope/name event type.`)
+ violations.push(`${where} uses the plugin: namespace, which holds ignorable plugin records outside SessionEventMap; choose a scope/name event type.`)
continue
}
const prior = seen.get(name)
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 9a4d1d529f..844de0d14c 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -624,6 +624,16 @@
"symbol": "SessionEvent",
"source": "packages/core/session/src/types.ts"
},
+ {
+ "doc": "docs/subsystems/session.md",
+ "symbol": "PluginRecordType",
+ "source": "packages/core/session/src/types.ts"
+ },
+ {
+ "doc": "docs/subsystems/session.md",
+ "symbol": "PluginRecord",
+ "source": "packages/core/session/src/types.ts"
+ },
{
"doc": "docs/subsystems/session.md",
"symbol": "TurnEndCancelCause",
diff --git a/scripts/verify-plugin-record-callers.spec.ts b/scripts/verify-plugin-record-callers.spec.ts
index 6c1c95ec9d..7efbb08f5e 100644
--- a/scripts/verify-plugin-record-callers.spec.ts
+++ b/scripts/verify-plugin-record-callers.spec.ts
@@ -1,4 +1,8 @@
-import { describe, expect, it } from 'vitest'
+import { execFileSync } from 'node:child_process'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
import {
checkPluginRecordCallers,
findPluginRecordReferences,
@@ -7,6 +11,24 @@ import {
readRepositorySources,
} from './verify-plugin-record-callers.ts'
+const roots: string[] = []
+
+afterEach(() => {
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+/** A temporary git repository with the given files written and none of them staged. */
+function repository(files: Record): string {
+ const root = mkdtempSync(join(tmpdir(), 'dsh-plugin-record-callers-'))
+ roots.push(root)
+ execFileSync('git', ['init', '--quiet'], { cwd: root, stdio: 'pipe' })
+ for (const [file, contents] of Object.entries(files)) {
+ mkdirSync(dirname(join(root, file)), { recursive: true })
+ writeFileSync(join(root, file), contents)
+ }
+ return root
+}
+
const OWNER_SOURCE = 'export function appendPluginRecord(session, type, data) { return commit(session, type, data) }\n'
const EXPERIMENTAL = 'packages/experimental/bridge/src/index.ts'
const RELEASE = 'packages/core/agent/src/index.ts'
@@ -30,6 +52,7 @@ describe('verify-plugin-record-callers', () => {
['a destructured binding', 'const { appendPluginRecord: write } = S'],
['an element access', "S['appendPluginRecord'](session, 'plugin:a', {})"],
['a template element access', 'S[`appendPluginRecord`](session, `plugin:a`, {})'],
+ ['an escaped identifier', 'S.\\u0061ppendPluginRecord(session, "plugin:a", {})'],
])('rejects %s in release package source', (_form, source) => {
expect(checkPluginRecordCallers(corpus(`${source}\n`))).toEqual([{ file: RELEASE, line: 1, text: source }])
})
@@ -43,12 +66,13 @@ describe('verify-plugin-record-callers', () => {
.toEqual(['packages/client/ui/src/view.tsx', 'scripts/tool.mjs'])
})
- it('ignores comments, JSDoc links, longer strings, and other identifiers', () => {
+ it('ignores comments, JSDoc links, longer strings, other identifiers, and wildcard re-exports', () => {
const source = [
'// appendPluginRecord is restricted',
'/** Plugin state uses {@link appendPluginRecord} in experimental packages. */',
"const message = 'call appendPluginRecord only from experimental packages'",
'const appendPluginRecords = 1',
+ "export * from '@deepseek-ai/dsh-session'",
].join('\n')
expect(checkPluginRecordCallers(corpus(source))).toEqual([])
})
@@ -92,7 +116,25 @@ describe('verify-plugin-record-callers', () => {
}
})
- it('passes on the repository', () => {
- expect(checkPluginRecordCallers(readRepositorySources())).toEqual([])
+ it('reads tracked and unignored sources outside vendor, skipping other files and deleted ones', () => {
+ const root = repository({
+ '.gitignore': 'ignored/\n',
+ [OWNER_FILE]: OWNER_SOURCE,
+ 'scripts/tool.mjs': 'export {}\n',
+ 'ignored/scratch.ts': 'export {}\n',
+ 'vendor/cordis/src/index.ts': 'export {}\n',
+ 'docs/page.md': '# Page\n',
+ 'packages/core/gone/src/index.ts': 'export {}\n',
+ })
+ execFileSync('git', ['add', '.'], { cwd: root, stdio: 'pipe' })
+ rmSync(join(root, 'packages/core/gone/src/index.ts'))
+ mkdirSync(join(root, 'packages/core/agent/src'), { recursive: true })
+ writeFileSync(join(root, 'packages/core/agent/src/new.ts'), 'export {}\n')
+
+ expect([...readRepositorySources(root).keys()].sort()).toEqual([
+ 'packages/core/agent/src/new.ts',
+ OWNER_FILE,
+ 'scripts/tool.mjs',
+ ])
})
})
diff --git a/scripts/verify-plugin-record-callers.ts b/scripts/verify-plugin-record-callers.ts
index ed36811683..e0e9b1aed8 100644
--- a/scripts/verify-plugin-record-callers.ts
+++ b/scripts/verify-plugin-record-callers.ts
@@ -6,10 +6,13 @@
* 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, as `scripts/AGENTS.md` requires: every identifier spelled
- * `appendPluginRecord` counts — an import, re-export, call, namespace member, destructured binding,
- * or 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.
+ * 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. Only files whose text contains the name or a
+ * `\u` escape are parsed, so an escaped spelling of the name is still found.
*/
import { execFileSync } from 'node:child_process'
@@ -79,6 +82,7 @@ function scriptKind(file: string): ts.ScriptKind {
* @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) && !source.includes('\\u')) return []
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file))
const lines = source.split(/\r?\n/u)
const references: PluginRecordCaller[] = []
@@ -128,11 +132,12 @@ export function checkPluginRecordCallers(files: ReadonlyMap): Pl
/**
* 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(): Map {
+export function readRepositorySources(repoRoot: string = root): Map {
const listing = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
- cwd: root,
+ cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
@@ -141,7 +146,7 @@ export function readRepositorySources(): Map {
const file = entry.replaceAll('\\', '/')
if (file === '' || file.startsWith('vendor/') || !SOURCE_EXTENSION.test(file)) continue
try {
- files.set(file, readFileSync(resolve(root, file), 'utf8'))
+ 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