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