Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand All @@ -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.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions docs/ERRORS.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 8 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <replace-unique|replace-between|anchored-retry> --path <file> ...';

Expand All @@ -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;
}

Expand All @@ -24,18 +25,18 @@ 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;
}

export async function runCli(args: string[]): Promise<string> {
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());

Expand All @@ -55,7 +56,7 @@ export async function runCli(args: string[]): Promise<string> {
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,
Expand All @@ -65,14 +66,14 @@ export async function runCli(args: string[]): Promise<string> {
});
}

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<void> {
try {
console.log(await runCli(args));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error(formatSmartEditError(error));
process.exitCode = 1;
}
}
Expand Down
114 changes: 114 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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>([
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}`;
}
Loading
Loading