mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(session): address review of plugin records
- Make the record commit an ES private method, so appendPluginRecord() is its only runtime entry. - Document PluginRecordType and PluginRecord on the session subsystem page, and state in the README and JSDoc that the V3-to-V4 edge renames unknown ignorable V3 events into the plugin: namespace and that plugins outside this repository must not call the writer. - The caller gate parses only files that contain the name or a \u escape, documents that wildcard re-exports are not reported, and its spec reads a temporary repository instead of this checkout. - Cover Session.fromRestore in the record test and name SessionEventMap in the catalog generator's diagnostic.
This commit is contained in:
1 parent
ab1e7811b9
commit
1678f0f5f5
13 files changed
+161
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<a id="write-experimental-plugin-records"></a>
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -63,11 +63,13 @@ session.deriveMessages() // the derived model history
|
||||
|
||||
会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,brand 在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。
|
||||
|
||||
<a id="write-experimental-plugin-records"></a>
|
||||
|
||||
### 写入实验性插件记录
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, string>): 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>): 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<string, string> {
|
||||
export function readRepositorySources(repoRoot: string = root): Map<string, string> {
|
||||
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<string, string> {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user