diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d577477..978d769 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,9 +17,10 @@ There is no service, database, background worker, network control plane, or runt | Module | Responsibility | Must not own | |---|---|---| +| `src/errors.ts` | Stable public taxonomy, redacted error metadata, and centralized normalization | Editing/retry policy or surface-specific presentation | | `src/smart-edit.ts` | Editing policy, semantic operations, one bounded stale-anchor retry | CLI parsing or Pi registration | | `src/anchors.ts` | Public re-exports of shared anchor primitives | Filesystem I/O | -| `src/filesystem-client.ts` | Public filesystem adapter re-export | Recovery policy | +| `src/filesystem-client.ts` | Normalize the shared filesystem adapter's results and thrown failures | Recovery policy | | `src/types.ts` | Public port and operation contracts | Runtime behavior | | `src/extension.ts` | Translate Pi tool parameters/results, resolve `ctx.cwd` targets, and join Pi's per-file mutation queue | Duplicate editing policy or leak Pi host APIs into library/CLI | | `src/cli.ts` | Parse CLI arguments, print results/errors, choose exit status | Direct filesystem mutation | @@ -45,6 +46,12 @@ There is no service, database, background worker, network control plane, or runt The one-retry limit is intentional: repeated retries could hide concurrent edits. +### Error normalization and compatibility + +Core `[E_*]` results, thrown core errors, policy failures, filesystem errors, Pi schema failures, and queue failures converge in `src/errors.ts`. Library and filesystem adapter failures reject with `SmartEditError`; the CLI only adds deterministic coded stderr formatting; the Pi adapter propagates the same error and safe details when the host preserves them. Structured details are limited to `code` and `category` and never include paths, anchors, content, raw causes, or queue keys. + +The public taxonomy is additive and existing code meanings are not repurposed. Human-readable diagnostics and the bounded stale-anchor recovery flow remain intact, but messages are not a machine contract. See [`docs/ERRORS.md`](docs/ERRORS.md) for the taxonomy and migration decision. + ### Pi extension mutation transaction The Pi adapter resolves the target to an absolute `ctx.cwd`-relative path and makes `withFileMutationQueue` the outermost operation. Pi canonicalizes existing queue targets, so path aliases serialize together. The queue owns the entire semantic operation, including `replaceBetween` reads and both attempts of `replaceAnchoredWithRetry`; failures release ownership. `SmartEditSession`, the CLI, and the public library remain unaware of the Pi host queue. @@ -63,6 +70,7 @@ The Pi adapter resolves the target to an absolute `ctx.cwd`-relative path and ma - `test/filesystem-client.test.ts`: real temporary-file adapter behavior. - `test/smart-edit.test.ts`: policy, retry, and error paths. - `test/extension.test.ts`: Pi registration, path resolution, same-file serialization, different-file concurrency, retry boundary, and rejection release. +- `test/errors.test.ts`: taxonomy, normalization, redaction, adapter parity, and failure nonmutation. - `npm run coverage`: coverage budgets. - `npm run benchmark`: in-process policy overhead budget. - `npm run verify:release`: changelog and package-content contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index fff31e5..88f0628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,15 @@ All notable changes to this project are documented in this file. The format foll - Coverage, performance benchmark, and release-package verification commands. - Additional policy and failure-path tests plus adoption recipes. - End-to-end CLI and Pi extension adapter coverage, cross-platform CI fixtures, repository policy checks, and verified tag-release automation. +- Exported stable `SmartEditErrorCode`/`SmartEditError` contracts with centralized core, policy, filesystem, input/schema, and queue normalization. +- Published the structured error taxonomy, metadata-redaction decision, and message-matching migration guide. ### Changed - Tightened the `smart_edit` schema to discriminated mode and operation contracts with incompatible fields rejected before execution. - Expanded CI to Node.js 22 and 24 on Ubuntu, Windows, and macOS with stable required-check names. - Declared `@earendil-works/pi-coding-agent >=0.74.0` as the Pi host peer range while keeping library and CLI entry points host-independent. +- Core `[E_*]` failures now reject as coded library/filesystem adapter errors instead of resolving as successful strings; CLI failures use deterministic coded stderr and Pi propagates equivalent safe structured details when supported. ### Fixed diff --git a/README.md b/README.md index c997058..94f9ffe 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ A common Pi editing loop reads a file, receives `LINE#HASH:content` anchors, and - Use a local Pi-compatible filesystem adapter. - Invoke the same policy from a Pi tool, CLI, or TypeScript library. - Serialize the complete Pi extension read-modify-write/retry transaction with Pi's per-file mutation queue. +- Handle the same stable, structured error taxonomy across all entry surfaces. ## CLI @@ -73,6 +74,7 @@ Compatibility evidence: `withFileMutationQueue` first shipped in Pi 0.61.0 under - [Vision and measurable success targets](VISION.md) - [Architecture and module boundaries](ARCHITECTURE.md) - [Configuration, diagnostics, and recovery](docs/OPERATIONS.md) +- [Structured error taxonomy and migration guide](docs/ERRORS.md) - [Release process](docs/RELEASING.md) - [Roadmap governance](ROADMAP.md) - [Changelog](CHANGELOG.md) @@ -108,6 +110,8 @@ npm run verify:release `npm run verify:release` builds the package, checks immutable runtime dependencies and changelog/version structure, and inspects `npm pack --dry-run` contents. Tag-triggered GitHub releases attach the verified tarball without publishing to npm; see [the release process](docs/RELEASING.md). The benchmark is a regression budget for in-process policy overhead, not filesystem throughput. +Failures reject with exported `SmartEditError` instances. Use `error.code`/`SmartEditErrorCode` instead of parsing messages; the CLI prints `[E_CODE] readable message` to stderr and exits nonzero. See the [structured error contract](docs/ERRORS.md), including the compatibility change from resolved core error strings to rejected coded errors and the structured-metadata redaction policy. + CI tests Node.js 22 and 24 on the latest Ubuntu, Windows, and macOS runners. Cross-platform tests cover paths with spaces, shell-free CLI invocation, CRLF preservation, and capability-based permission behavior; the POSIX permission fixture is skipped on Windows because Windows does not enforce POSIX write bits. ## Limits diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 0000000..3721c17 --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,116 @@ +# Structured error contract + +`@t50-systems/pi-smart-edit` exposes one stable error contract across its library, filesystem adapter, CLI, and Pi tool. + +## Public API + +```ts +import { + SmartEditError, + SmartEditErrorCode, + type SmartEditErrorCode as SmartEditErrorCodeValue, +} from '@t50-systems/pi-smart-edit'; +``` + +Owned failures reject with `SmartEditError`. Its stable fields are: + +- `code: SmartEditErrorCodeValue` — machine-readable classification. +- `category: 'input' | 'policy' | 'filesystem' | 'queue' | 'core'` — broad routing classification. +- `details: { code, category }` — redacted structured metadata suitable for adapters. +- `message` — human-readable diagnostic text. Do not parse it for control flow. + +Success return values are unchanged. + +## Taxonomy + +Codes are stable: existing meanings will not be repurposed. Future releases may add codes, so consumers should include an unknown/default branch. + +| Code | Category | Meaning | +|---|---|---| +| `E_INVALID_INPUT` | input | CLI command or argument is missing or invalid. | +| `E_SCHEMA_INVALID` | input | Pi tool parameters do not match the public schema. | +| `E_BOUNDARY_NOT_FOUND` | policy | Exact `replace_between` boundaries were not found. | +| `E_STALE_RECOVERY_FAILED` | policy | A stale edit could not be recovered safely. | +| `E_FILESYSTEM_NOT_FOUND` | filesystem | An owned filesystem operation reported `ENOENT`. | +| `E_FILESYSTEM_PERMISSION` | filesystem | An owned filesystem operation reported `EACCES` or `EPERM`. | +| `E_FILESYSTEM_IO` | filesystem | Another filesystem read/write failure occurred. | +| `E_QUEUE_FAILURE` | queue | Pi's file mutation queue failed outside the edit callback. | +| `E_STALE_ANCHOR` | core | An anchor no longer matches. The session may consume this once for its existing bounded retry. | +| `E_INVALID_PATCH` | core | A patch or exact replacement is invalid or non-unique. | +| `E_BAD_REF` | core | A hashline reference is invalid. | +| `E_RANGE_OOB` | core | An edit range is outside the file. | +| `E_BAD_OP` | core | The core does not support the edit operation. | +| `E_EDIT_CONFLICT` | core | Edits conflict. | +| `E_NO_MATCH` | core | Required content did not match. | +| `E_MULTI_MATCH` | core | Content matched more than once. | +| `E_WOULD_EMPTY` | core | A guarded operation would empty the file. | +| `E_CORE_FAILURE` | core | An unknown core `[E_*]` code or unclassified core failure occurred. | + +## Surface behavior + +### Library and filesystem adapter + +Failures reject; they are not returned as successful strings. + +```ts +try { + await session.replaceUnique(path, oldText, newText); +} catch (error) { + if (error instanceof SmartEditError && error.code === SmartEditErrorCode.InvalidPatch) { + // Missing or ambiguous exact match; the file was not changed. + } +} +``` + +The one-attempt stale-anchor recovery policy is unchanged. The first `E_STALE_ANCHOR` is consumed only when an exact suggested anchor can safely recover the request. An unsafe recovery rejects with `E_STALE_RECOVERY_FAILED`; a failed retry rejects with its core code. + +### CLI + +Failures write deterministic coded stderr and exit nonzero: + +```text +[E_INVALID_INPUT] Missing --new +``` + +Multiline diagnostics retain their readable body. Successful output remains on stdout. + +### Pi tool + +The extension throws the same `SmartEditError`. Pi hosts that preserve error properties can inspect `code`, `category`, or `details`; other hosts still display the coded/readable failure. Host schema validation may reject malformed calls before extension execution, in which case the host owns the outer error envelope. + +## Redaction decision + +Structured details intentionally contain only `code` and `category`. They never copy: + +- file paths; +- anchor text or hashes; +- searched or replacement content; +- raw Node.js/Pi error objects; +- queue keys or host internals. + +Readable messages remain compatible and actionable and may contain the same path or stale suggestions they contained before this contract. Treat messages as operator-facing data and redact them before forwarding to an external log. The package emits no telemetry. + +## Compatibility and migration + +Before this contract, some core failures (for example `[E_INVALID_PATCH]`) resolved as strings. They now reject with `SmartEditError` so failures cannot be mistaken for success. + +Migrate from text matching: + +```ts +const result = await session.replaceUnique(path, oldText, newText); +if (result.startsWith('[E_INVALID_PATCH]')) handleInvalidPatch(); +``` + +To code matching: + +```ts +try { + await session.replaceUnique(path, oldText, newText); +} catch (error) { + if (error instanceof SmartEditError && error.code === SmartEditErrorCode.InvalidPatch) { + handleInvalidPatch(); + } +} +``` + +During migration, readable message text is preserved, but it is not a versioned machine contract. Never broaden retries based on a code: only `SmartEditSession` owns stale recovery, and it still retries at most once. diff --git a/src/cli.ts b/src/cli.ts index 3c400d8..a229e80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { FilesystemPiClient } from './filesystem-client.js'; import { SmartEditSession } from './smart-edit.js'; +import { formatSmartEditError, SmartEditError, SmartEditErrorCode } from './errors.js'; const usage = 'Usage: pi-smart-edit --path ...'; @@ -14,7 +15,7 @@ function arg(args: string[], name: string): string | undefined { function requiredArg(args: string[], name: string): string { const value = arg(args, name); - if (value === undefined) throw new Error(`Missing ${name}`); + if (value === undefined) throw new SmartEditError(SmartEditErrorCode.InvalidInput, `Missing ${name}`); return value; } @@ -24,10 +25,10 @@ function linesArg(args: string[]): string[] { try { value = JSON.parse(source); } catch { - throw new Error('--lines-json must be valid JSON'); + throw new SmartEditError(SmartEditErrorCode.InvalidInput, '--lines-json must be valid JSON'); } if (!Array.isArray(value) || value.some((line) => typeof line !== 'string')) { - throw new Error('--lines-json must be a JSON array of strings'); + throw new SmartEditError(SmartEditErrorCode.InvalidInput, '--lines-json must be a JSON array of strings'); } return value; } @@ -35,7 +36,7 @@ function linesArg(args: string[]): string[] { export async function runCli(args: string[]): Promise { const command = args[0]; const path = arg(args, '--path'); - if (!command || !path) throw new Error(usage); + if (!command || !path) throw new SmartEditError(SmartEditErrorCode.InvalidInput, usage); const session = new SmartEditSession(new FilesystemPiClient()); @@ -55,7 +56,7 @@ export async function runCli(args: string[]): Promise { if (command === 'anchored-retry') { const op = arg(args, '--op') ?? 'replace'; if (op !== 'replace' && op !== 'append' && op !== 'prepend') { - throw new Error('--op must be one of: replace, append, prepend'); + throw new SmartEditError(SmartEditErrorCode.InvalidInput, '--op must be one of: replace, append, prepend'); } return session.replaceAnchoredWithRetry(path, { op, @@ -65,14 +66,14 @@ export async function runCli(args: string[]): Promise { }); } - throw new Error(`Unknown command: ${command}. ${usage}`); + throw new SmartEditError(SmartEditErrorCode.InvalidInput, `Unknown command: ${command}. ${usage}`); } export async function main(args = process.argv.slice(2)): Promise { try { console.log(await runCli(args)); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + console.error(formatSmartEditError(error)); process.exitCode = 1; } } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..4851487 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,114 @@ +export const SmartEditErrorCode = { + InvalidInput: 'E_INVALID_INPUT', + SchemaInvalid: 'E_SCHEMA_INVALID', + BoundaryNotFound: 'E_BOUNDARY_NOT_FOUND', + StaleRecoveryFailed: 'E_STALE_RECOVERY_FAILED', + FilesystemNotFound: 'E_FILESYSTEM_NOT_FOUND', + FilesystemPermission: 'E_FILESYSTEM_PERMISSION', + FilesystemIo: 'E_FILESYSTEM_IO', + QueueFailure: 'E_QUEUE_FAILURE', + CoreFailure: 'E_CORE_FAILURE', + StaleAnchor: 'E_STALE_ANCHOR', + InvalidPatch: 'E_INVALID_PATCH', + BadReference: 'E_BAD_REF', + RangeOutOfBounds: 'E_RANGE_OOB', + BadOperation: 'E_BAD_OP', + EditConflict: 'E_EDIT_CONFLICT', + NoMatch: 'E_NO_MATCH', + MultipleMatches: 'E_MULTI_MATCH', + WouldEmpty: 'E_WOULD_EMPTY', +} as const; + +export type SmartEditErrorCode = (typeof SmartEditErrorCode)[keyof typeof SmartEditErrorCode]; + +export type SmartEditErrorCategory = 'input' | 'policy' | 'filesystem' | 'queue' | 'core'; + +export type SmartEditErrorDetails = Readonly<{ + code: SmartEditErrorCode; + category: SmartEditErrorCategory; +}>; + +const coreCodes = new Set([ + SmartEditErrorCode.StaleAnchor, + SmartEditErrorCode.InvalidPatch, + SmartEditErrorCode.BadReference, + SmartEditErrorCode.RangeOutOfBounds, + SmartEditErrorCode.BadOperation, + SmartEditErrorCode.EditConflict, + SmartEditErrorCode.NoMatch, + SmartEditErrorCode.MultipleMatches, + SmartEditErrorCode.WouldEmpty, +]); + +function categoryFor(code: SmartEditErrorCode): SmartEditErrorCategory { + if (code === SmartEditErrorCode.InvalidInput || code === SmartEditErrorCode.SchemaInvalid) return 'input'; + if (code === SmartEditErrorCode.BoundaryNotFound || code === SmartEditErrorCode.StaleRecoveryFailed) return 'policy'; + if ( + code === SmartEditErrorCode.FilesystemNotFound || + code === SmartEditErrorCode.FilesystemPermission || + code === SmartEditErrorCode.FilesystemIo + ) return 'filesystem'; + if (code === SmartEditErrorCode.QueueFailure) return 'queue'; + return 'core'; +} + +export class SmartEditError extends Error { + readonly code: SmartEditErrorCode; + readonly category: SmartEditErrorCategory; + readonly details: SmartEditErrorDetails; + + constructor(code: SmartEditErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'SmartEditError'; + this.code = code; + this.category = categoryFor(code); + this.details = Object.freeze({ code, category: this.category }); + } +} + +function codeFromMessage(message: string): SmartEditErrorCode | undefined { + const rawCode = /^\[(E_[A-Z0-9_]+)\]/.exec(message)?.[1]; + if (!rawCode) return undefined; + return coreCodes.has(rawCode as SmartEditErrorCode) + ? rawCode as SmartEditErrorCode + : SmartEditErrorCode.CoreFailure; +} + +function nodeErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined; + return typeof error.code === 'string' ? error.code : undefined; +} + +export function normalizeSmartEditError( + error: unknown, + fallbackCode: SmartEditErrorCode = SmartEditErrorCode.CoreFailure, +): SmartEditError { + if (error instanceof SmartEditError) return error; + + const message = error instanceof Error ? error.message : String(error); + const embeddedCode = codeFromMessage(message); + if (embeddedCode) return new SmartEditError(embeddedCode, message, { cause: error }); + + const systemCode = nodeErrorCode(error); + if (systemCode === 'ENOENT') { + return new SmartEditError(SmartEditErrorCode.FilesystemNotFound, message, { cause: error }); + } + if (systemCode === 'EACCES' || systemCode === 'EPERM') { + return new SmartEditError(SmartEditErrorCode.FilesystemPermission, message, { cause: error }); + } + + return new SmartEditError(fallbackCode, message, { cause: error }); +} + +export function normalizeSmartEditResult(result: string): string { + const embeddedCode = codeFromMessage(result); + if (embeddedCode) throw new SmartEditError(embeddedCode, result); + return result; +} + +export function formatSmartEditError(error: unknown): string { + const normalized = normalizeSmartEditError(error); + return normalized.message.startsWith(`[${normalized.code}]`) + ? normalized.message + : `[${normalized.code}] ${normalized.message}`; +} diff --git a/src/extension.ts b/src/extension.ts index 6390644..b4e926e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,10 @@ import { withFileMutationQueue } from '@earendil-works/pi-coding-agent'; import { resolve } from 'node:path'; import { Type, type Static } from 'typebox'; +import { Check } from 'typebox/value'; import { FilesystemPiClient } from './filesystem-client.js'; import { SmartEditSession } from './smart-edit.js'; +import { normalizeSmartEditError, SmartEditError, SmartEditErrorCode } from './errors.js'; const strictObjectOptions = { additionalProperties: false } as const; @@ -82,6 +84,14 @@ export type SmartEditExtensionApi = { registerTool(tool: SmartEditTool): void; }; +async function withNormalizedMutationQueue(targetPath: string, action: () => Promise): Promise { + try { + return await withFileMutationQueue(targetPath, action); + } catch (error) { + throw normalizeSmartEditError(error, SmartEditErrorCode.QueueFailure); + } +} + export default function (pi: SmartEditExtensionApi) { pi.registerTool({ name: 'smart_edit', @@ -95,10 +105,14 @@ export default function (pi: SmartEditExtensionApi) { _onUpdate: unknown, ctx: SmartEditContext, ) { + if (!Check(smartEditParameters, params)) { + throw new SmartEditError(SmartEditErrorCode.SchemaInvalid, 'smart_edit parameters do not match the public schema'); + } + const requestedPath = params.path.startsWith('@') ? params.path.slice(1) : params.path; const targetPath = resolve(ctx.cwd, requestedPath); - return withFileMutationQueue(targetPath, async () => { + return withNormalizedMutationQueue(targetPath, async () => { const session = new SmartEditSession(new FilesystemPiClient()); if (params.mode === 'replace_unique') { diff --git a/src/filesystem-client.ts b/src/filesystem-client.ts index 7b24c16..c02d3eb 100644 --- a/src/filesystem-client.ts +++ b/src/filesystem-client.ts @@ -1 +1,21 @@ -export { FilesystemPiClient } from 'pi-anchor-edit-core'; +import { FilesystemPiClient as CoreFilesystemPiClient } from 'pi-anchor-edit-core'; +import { normalizeSmartEditError, normalizeSmartEditResult, SmartEditErrorCode } from './errors.js'; +import type { EditParams, ReadParams } from './types.js'; + +export class FilesystemPiClient extends CoreFilesystemPiClient { + override async read(params: ReadParams): Promise { + try { + return await super.read(params); + } catch (error) { + throw normalizeSmartEditError(error, SmartEditErrorCode.FilesystemIo); + } + } + + override async edit(params: EditParams): Promise { + try { + return normalizeSmartEditResult(await super.edit(params)); + } catch (error) { + throw normalizeSmartEditError(error, SmartEditErrorCode.FilesystemIo); + } + } +} diff --git a/src/index.ts b/src/index.ts index 0846a9b..26f4035 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './errors.js'; export * from './types.js'; export * from './anchors.js'; export * from './smart-edit.js'; diff --git a/src/smart-edit.ts b/src/smart-edit.ts index b2e8b2f..d1e6e55 100644 --- a/src/smart-edit.ts +++ b/src/smart-edit.ts @@ -1,37 +1,70 @@ import { findAnchorByContent, parseReadAnchors, parseStaleAnchorError } from './anchors.js'; -import type { EditOp, PiClient, ReplaceLikeEditOp } from './types.js'; +import { + normalizeSmartEditError, + normalizeSmartEditResult, + SmartEditError, + SmartEditErrorCode, + type SmartEditErrorCode as SmartEditErrorCodeValue, +} from './errors.js'; +import type { EditOp, EditParams, PiClient, ReplaceLikeEditOp } from './types.js'; export class SmartEditSession { constructor(private readonly client: PiClient) {} + private async edit(params: EditParams, fallbackCode: SmartEditErrorCodeValue = SmartEditErrorCode.CoreFailure): Promise { + try { + return normalizeSmartEditResult(await this.client.edit(params)); + } catch (error) { + throw normalizeSmartEditError(error, fallbackCode); + } + } + async readFresh(path: string, offset = 1, limit = 400): Promise { - return this.client.read({ path, offset, limit }); + try { + return await this.client.read({ path, offset, limit }); + } catch (error) { + throw normalizeSmartEditError(error, SmartEditErrorCode.FilesystemIo); + } } async replaceUnique(path: string, oldText: string, newText: string): Promise { - return this.client.edit({ + return this.edit({ path, edits: [{ op: 'replace_text', oldText, newText }], }); } async replaceAnchored(path: string, edit: EditOp): Promise { - return this.client.edit({ path, edits: [edit] }); + return this.edit({ path, edits: [edit] }); } async replaceAnchoredWithRetry(path: string, edit: ReplaceLikeEditOp): Promise { - const first = await this.client.edit({ path, edits: [edit] }); + let first: string; + try { + first = await this.edit({ path, edits: [edit] }); + } catch (error) { + const normalized = normalizeSmartEditError(error); + if (normalized.code !== SmartEditErrorCode.StaleAnchor) throw normalized; + first = normalized.message; + } + const stale = parseStaleAnchorError(first); if (!stale.stale) return first; if (!edit.pos) { - throw new Error('Cannot auto-recover a stale edit without a position anchor'); + throw new SmartEditError( + SmartEditErrorCode.StaleRecoveryFailed, + 'Cannot auto-recover a stale edit without a position anchor', + ); } const originalPosContent = edit.pos.split(':')[1] ?? ''; const replacement = findAnchorByContent(stale.suggested, originalPosContent); if (!replacement) { - throw new Error(`Stale anchor detected, but no matching recovery anchor was found.\n${first}`); + throw new SmartEditError( + SmartEditErrorCode.StaleRecoveryFailed, + `Stale anchor detected, but no matching recovery anchor was found.\n${first}`, + ); } const originalEndContent = edit.end?.split(':')[1] ?? ''; @@ -39,7 +72,7 @@ export class SmartEditSession { ? findAnchorByContent(stale.suggested, originalEndContent)?.raw ?? edit.end : undefined; - return this.client.edit({ + return this.edit({ path, edits: [ { @@ -63,10 +96,13 @@ export class SmartEditSession { const end = findAnchorByContent(anchors, endContent); if (!start || !end) { - throw new Error(`Unable to find boundary anchors in ${path}`); + throw new SmartEditError( + SmartEditErrorCode.BoundaryNotFound, + `Unable to find boundary anchors in ${path}`, + ); } - return this.client.edit({ + return this.edit({ path, edits: [ { diff --git a/test/cli.test.ts b/test/cli.test.ts index 57fa857..bcd3ef5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; import { FilesystemPiClient } from '../src/filesystem-client.js'; import { runCli } from '../src/cli.js'; +import { SmartEditError, SmartEditErrorCode } from '../src/errors.js'; const cliPath = fileURLToPath(new URL('../src/cli.js', import.meta.url)); @@ -74,19 +75,21 @@ test('documented anchored-retry CLI recipe succeeds without shell quoting', asyn test('invalid CLI inputs fail actionably without writing', async () => { const path = await fixture('invalid.ts', 'unchanged\n'); - const cases: Array<{ args: string[]; error: RegExp }> = [ - { args: ['replace-between', '--path', path, '--start', 'unchanged', '--end', 'unchanged', '--lines-json', '['], error: /--lines-json must be valid JSON/ }, - { args: ['replace-between', '--path', path, '--start', 'unchanged', '--end', 'unchanged', '--lines-json', '[1]'], error: /JSON array of strings/ }, - { args: ['replace-unique', '--path', path, '--old', 'unchanged'], error: /Missing --new/ }, - { args: ['anchored-retry', '--path', path, '--pos', '1#AA:unchanged', '--op', 'remove'], error: /--op must be one of/ }, - { args: ['unknown', '--path', path], error: /Unknown command: unknown/ }, - { args: [], error: /Usage: pi-smart-edit/ }, + const cases: Array<{ args: string[]; code: string; error: RegExp }> = [ + { args: ['replace-between', '--path', path, '--start', 'unchanged', '--end', 'unchanged', '--lines-json', '['], code: 'E_INVALID_INPUT', error: /--lines-json must be valid JSON/ }, + { args: ['replace-between', '--path', path, '--start', 'unchanged', '--end', 'unchanged', '--lines-json', '[1]'], code: 'E_INVALID_INPUT', error: /JSON array of strings/ }, + { args: ['replace-unique', '--path', path, '--old', 'unchanged'], code: 'E_INVALID_INPUT', error: /Missing --new/ }, + { args: ['anchored-retry', '--path', path, '--pos', '1#AA:unchanged', '--op', 'remove'], code: 'E_INVALID_INPUT', error: /--op must be one of/ }, + { args: ['unknown', '--path', path], code: 'E_INVALID_INPUT', error: /Unknown command: unknown/ }, + { args: [], code: 'E_INVALID_INPUT', error: /Usage: pi-smart-edit/ }, ]; for (const entry of cases) { const before = await readFile(path, 'utf8'); const result = invoke(entry.args); assert.notEqual(result.status, 0, `expected failure for ${entry.args.join(' ')}`); + assert.match(result.stderr, new RegExp(`^\\[${entry.code}\\] `)); + assert.equal(result.stdout, ''); assert.match(result.stderr, entry.error); assert.equal(await readFile(path, 'utf8'), before); } @@ -96,7 +99,21 @@ test('runCli exposes the adapter for in-process coverage and stable errors', asy const path = await fixture('direct.ts', 'before\n'); await runCli(['replace-unique', '--path', path, '--old', 'before', '--new', 'after']); assert.equal(await readFile(path, 'utf8'), 'after\n'); - await assert.rejects(runCli(['anchored-retry', '--path', path, '--op', 'invalid']), /--op must be one of/); + await assert.rejects( + runCli(['anchored-retry', '--path', path, '--op', 'invalid']), + (error: unknown) => error instanceof SmartEditError && error.code === SmartEditErrorCode.InvalidInput, + ); +}); + +test('CLI emits the core code deterministically and does not mutate on ambiguity', async () => { + const path = await fixture('ambiguous.ts', 'same\nsame\n'); + const before = await readFile(path, 'utf8'); + const result = invoke(['replace-unique', '--path', path, '--old', 'same', '--new', 'changed']); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /^\[E_INVALID_PATCH\] replace_text requires one unique exact occurrence\r?\n$/); + assert.equal(result.stdout, ''); + assert.equal(await readFile(path, 'utf8'), before); }); test('permission failures are non-zero and preserve content', { skip: process.platform === 'win32' && 'POSIX write bits are not enforced on Windows' }, async () => { @@ -106,6 +123,7 @@ test('permission failures are non-zero and preserve content', { skip: process.pl const result = invoke(['replace-unique', '--path', path, '--old', 'locked', '--new', 'changed']); assert.notEqual(result.status, 0); assert.match(result.stderr, /permission|EACCES/i); + assert.match(result.stderr, /^\[E_FILESYSTEM_PERMISSION\] /); assert.equal(await readFile(path, 'utf8'), 'locked\n'); } finally { await chmod(path, 0o644); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..9029b92 --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + formatSmartEditError, + normalizeSmartEditError, + normalizeSmartEditResult, + SmartEditError, + SmartEditErrorCode, +} from '../src/errors.js'; +import { FilesystemPiClient } from '../src/filesystem-client.js'; +import { SmartEditSession } from '../src/smart-edit.js'; +import type { PiClient } from '../src/types.js'; + +async function capture(work: Promise): Promise { + try { + await work; + } catch (error) { + assert.ok(error instanceof SmartEditError); + return error; + } + assert.fail('expected SmartEditError rejection'); +} + +test('public error details are stable and redact operational context', () => { + const secretPath = 'C:/private/customer/secret.txt'; + const error = new SmartEditError(SmartEditErrorCode.BoundaryNotFound, `Unable to find boundary anchors in ${secretPath}`); + + assert.equal(error.code, 'E_BOUNDARY_NOT_FOUND'); + assert.deepEqual(error.details, { code: 'E_BOUNDARY_NOT_FOUND', category: 'policy' }); + assert.equal(JSON.stringify(error.details).includes(secretPath), false); + assert.match(error.message, /secret\.txt/); + assert.equal(formatSmartEditError(error), `[E_BOUNDARY_NOT_FOUND] ${error.message}`); +}); + +test('central normalization classifies core returns, thrown errors, filesystem, and queue failures', () => { + assert.throws( + () => normalizeSmartEditResult('[E_MULTI_MATCH] multiple matches'), + (error: unknown) => error instanceof SmartEditError && error.code === SmartEditErrorCode.MultipleMatches, + ); + + const thrownCore = normalizeSmartEditError(new Error('[E_MULTI_MATCH] multiple matches')); + assert.equal(thrownCore.code, SmartEditErrorCode.MultipleMatches); + + const permission = Object.assign(new Error('access denied'), { code: 'EACCES', path: 'C:/private/file' }); + const normalizedPermission = normalizeSmartEditError(permission, SmartEditErrorCode.FilesystemIo); + assert.equal(normalizedPermission.code, SmartEditErrorCode.FilesystemPermission); + assert.deepEqual(normalizedPermission.details, { code: 'E_FILESYSTEM_PERMISSION', category: 'filesystem' }); + assert.equal(JSON.stringify(normalizedPermission.details).includes('private'), false); + + const queue = normalizeSmartEditError(new Error('queue unavailable'), SmartEditErrorCode.QueueFailure); + assert.equal(queue.code, SmartEditErrorCode.QueueFailure); +}); + +test('returned and thrown core failures have library code parity', async () => { + const clients: PiClient[] = [ + { read: async () => '', edit: async () => '[E_INVALID_PATCH] invalid anchor' }, + { read: async () => '', edit: async () => { throw new Error('[E_INVALID_PATCH] invalid anchor'); } }, + ]; + + for (const client of clients) { + const error = await capture(new SmartEditSession(client).replaceUnique('fixture.txt', 'old', 'new')); + assert.equal(error.code, SmartEditErrorCode.InvalidPatch); + assert.match(error.message, /invalid anchor/); + } +}); + +test('filesystem adapter and library reject the same coded nonmutating failure', async () => { + const directory = await mkdtemp(join(tmpdir(), 'pi-smart-edit-errors-')); + const path = join(directory, 'fixture.txt'); + await writeFile(path, 'same\nsame\n', 'utf8'); + const before = await readFile(path, 'utf8'); + + const adapterError = await capture(new FilesystemPiClient().edit({ + path, + edits: [{ op: 'replace_text', oldText: 'same', newText: 'changed' }], + })); + assert.equal(adapterError.code, SmartEditErrorCode.InvalidPatch); + assert.equal(await readFile(path, 'utf8'), before); + + const libraryError = await capture(new SmartEditSession(new FilesystemPiClient()).replaceUnique(path, 'same', 'changed')); + assert.equal(libraryError.code, adapterError.code); + assert.equal(await readFile(path, 'utf8'), before); +}); diff --git a/test/extension.test.ts b/test/extension.test.ts index 0603a2a..ed20bca 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -12,6 +12,7 @@ import registerSmartEdit, { type SmartEditParameters, } from '../src/extension.js'; import { FilesystemPiClient } from '../src/filesystem-client.js'; +import { SmartEditError, SmartEditErrorCode } from '../src/errors.js'; type RegisteredTool = Parameters[0]; @@ -120,6 +121,21 @@ test('schema validation prevents adapter errors from reaching the filesystem', a assert.equal(await readFile(path, 'utf8'), 'unchanged\n'); }); +test('direct Pi execution exposes schema code details and does not mutate', async () => { + const tool = register(); + const path = await fixture('unchanged\n'); + const invalid = { path, mode: 'replace_unique', oldText: 'unchanged' } as SmartEditParameters; + + await assert.rejects( + tool.execute('invalid-direct', invalid, undefined, undefined, context(dirname(path))), + (error: unknown) => + error instanceof SmartEditError && + error.code === SmartEditErrorCode.SchemaInvalid && + error.details.code === SmartEditErrorCode.SchemaInvalid, + ); + assert.equal(await readFile(path, 'utf8'), 'unchanged\n'); +}); + test('serializes relative smart_edit paths with absolute queued aliases', async () => { const directory = await mkdtemp(join(tmpdir(), 'pi-smart-edit-alias-')); const path = join(directory, 'race.txt'); @@ -191,13 +207,16 @@ test('holds the queue across the stale-anchor retry boundary', async () => { FilesystemPiClient.prototype.edit = async function (params) { attempts += 1; - events.push(`attempt-${attempts}`); - const result = await originalEdit.call(this, params); - if (attempts === 1) { - firstAttemptFinished.resolve(); - await allowRetry.promise; + const attempt = attempts; + events.push(`attempt-${attempt}`); + try { + return await originalEdit.call(this, params); + } finally { + if (attempt === 1) { + firstAttemptFinished.resolve(); + await allowRetry.promise; + } } - return result; }; try { @@ -254,7 +273,10 @@ test('releases the queue when smart_edit rejects', async () => { }); allowRead.resolve(); - await assert.rejects(edit, /Unable to find boundary anchors/); + await assert.rejects( + edit, + (error: unknown) => error instanceof SmartEditError && error.code === SmartEditErrorCode.BoundaryNotFound, + ); await competitorEntered.promise; await competitor; assert.equal(await readFile(path, 'utf8'), 'released\n'); diff --git a/test/filesystem-client.test.ts b/test/filesystem-client.test.ts index c103dee..8c1aaec 100644 --- a/test/filesystem-client.test.ts +++ b/test/filesystem-client.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { FilesystemPiClient } from '../src/filesystem-client.js'; +import { SmartEditError, SmartEditErrorCode } from '../src/errors.js'; test('filesystem client reads hashline format', async () => { const dir = await mkdtemp(join(tmpdir(), 'pi-smart-edit-')); @@ -27,11 +28,16 @@ test('filesystem client detects stale anchor and succeeds after refreshed edit', const stalePos = firstRead.split(/\r?\n/)[1] as string; await writeFile(path, 'alpha\ngamma', 'utf8'); - const stale = await client.edit({ - path, - edits: [{ op: 'replace', pos: stalePos, lines: ['patched'] }], - }); - assert.match(stale, /\[E_STALE_ANCHOR\]/); + await assert.rejects( + client.edit({ + path, + edits: [{ op: 'replace', pos: stalePos, lines: ['patched'] }], + }), + (error: unknown) => + error instanceof SmartEditError && + error.code === SmartEditErrorCode.StaleAnchor && + /\[E_STALE_ANCHOR\]/.test(error.message), + ); const freshRead = await client.read({ path }); const freshPos = freshRead.split(/\r?\n/)[1] as string; diff --git a/test/index.test.ts b/test/index.test.ts index e956600..bd599fd 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,4 +6,6 @@ test('public index exports the supported library surface', () => { assert.equal(typeof api.SmartEditSession, 'function'); assert.equal(typeof api.FilesystemPiClient, 'function'); assert.equal(typeof api.parseReadAnchors, 'function'); + assert.equal(typeof api.SmartEditError, 'function'); + assert.equal(api.SmartEditErrorCode.StaleAnchor, 'E_STALE_ANCHOR'); });