fix(fs): validate read meta semantics and sync public result-view docs
readMetaFromMeta narrows the opaque persisted meta boundary, so beyond shape it now rejects replayed JSON that is well-typed but semantically invalid: line numbers must be 1-based integers, totalLines a non-negative integer, and line numbers must strictly increase without exceeding totalLines. Any violation declines to the generic fallback. Sync the public ToolResultView contract across the core/tools and tool-fs READMEs and docs/core-data-structures/tools for the fourth result-view member and the ReadFileLine vocabulary, and expand the Agent Note Testing section with the new rejection paths and the snapshot evidence this PR carries.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
|
||||
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
|
||||
README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259
|
||||
README.md: 9e72ed53324d5f5efeaf659427c02d826221425c
|
||||
README.zh.md: aa94a7f6144b6cc6da34b059f5699312737d38a2
|
||||
|
||||
@@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps.
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
|
||||
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。
|
||||
|
||||
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。
|
||||
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
|
||||
|
||||
## 工具就是执行器;策略是事件门禁
|
||||
|
||||
|
||||
@@ -223,25 +223,41 @@ export interface FsReadMeta {
|
||||
lang?: string
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link FileTextLine} (defensive narrowing from opaque `meta`). */
|
||||
/**
|
||||
* Whether `value` is a valid {@link FileTextLine} (defensive narrowing from
|
||||
* opaque `meta`). `number` must be a 1-based integer line number, since a card
|
||||
* rendered from a zero, fractional, or non-finite line number would violate the
|
||||
* 1-based numbering contract the read window promises.
|
||||
*/
|
||||
function isFileTextLine(value: unknown): value is FileTextLine {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { number, text } = value as Record<string, unknown>
|
||||
return typeof number === 'number' && typeof text === 'string'
|
||||
return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a structured read window.
|
||||
* Malformed metadata returns `undefined` so presentation can fall back to the
|
||||
* generic text card instead of throwing during replay.
|
||||
* generic text card instead of throwing during replay. Beyond shape, the
|
||||
* semantic contract of a read window is enforced against replayed JSON that is
|
||||
* well-typed but out of range: `totalLines` must be a non-negative integer, each
|
||||
* line number must be a 1-based integer, the line numbers must strictly increase,
|
||||
* and no line number may exceed `totalLines`. Any violation declines to the
|
||||
* generic fallback rather than emitting a card that misnumbers or overcounts.
|
||||
* @param meta - result metadata.
|
||||
* @returns the validated read window, or `undefined` for absent or malformed data.
|
||||
* @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data.
|
||||
*/
|
||||
export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const { path, lines, totalLines, lang } = meta as Record<string, unknown>
|
||||
if (typeof path !== 'string' || typeof totalLines !== 'number') return undefined
|
||||
if (!Number.isInteger(totalLines) || totalLines < 0) return undefined
|
||||
if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined
|
||||
if (lang !== undefined && typeof lang !== 'string') return undefined
|
||||
let previous = 0
|
||||
for (const { number } of lines) {
|
||||
if (number <= previous || number > totalLines) return undefined
|
||||
previous = number
|
||||
}
|
||||
return { path, lines, totalLines, ...lang === undefined ? {} : { lang } }
|
||||
}
|
||||
|
||||
@@ -166,4 +166,27 @@ describe('readMetaFromMeta', () => {
|
||||
expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a line number that is not a 1-based integer', () => {
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a totalLines that is not a non-negative integer', () => {
|
||||
expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects lines that do not strictly increase or exceed totalLines', () => {
|
||||
const twoLines = { path: '/abs/a', lang: 'ts' }
|
||||
// Duplicate line numbers.
|
||||
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined()
|
||||
// Out-of-order line numbers.
|
||||
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined()
|
||||
// A line number past totalLines.
|
||||
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -351,7 +351,6 @@ describe('read tool', () => {
|
||||
if (result.isError) throw new Error('expected read success')
|
||||
expect(result.meta).toEqual({ path: '/abs/notes', lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
|
||||
Reference in New Issue
Block a user