diff --git a/.claude/skills/acture-command-record-shape/SKILL.md b/.claude/skills/acture-command-record-shape/SKILL.md index 71307a5..942975c 100644 --- a/.claude/skills/acture-command-record-shape/SKILL.md +++ b/.claude/skills/acture-command-record-shape/SKILL.md @@ -61,10 +61,22 @@ type CommandRecord

= { /** Tier (research-5). Authoritative source is the JSDoc tag on the defineCommand * call site (@stable / @experimental / @internal / @deprecated). The build step - * mirrors the tag into this field. Authors normally do NOT write this field - * manually — they write the JSDoc tag. */ + * (@acture/build-tier) mirrors the tag into this field. Authors normally do NOT + * write this field manually — they write the JSDoc tag. */ tier?: "stable" | "experimental" | "internal" | "deprecated"; + /** Free-text reason injected by the build step from @deprecated . + * Adapter packages (@acture/mcp, @acture/ai-vercel) prepend + * `[DEPRECATED — ]` to the description; @acture/devtools surfaces + * it in the inspector. Added in v1.0 (Phase 4) under the rule of three. */ + deprecationReason?: string; + + /** Module-scoped Symbol attached by the build step when a command is tagged + * @internal. The runtime checks identity at dispatch — cross-module callers + * cannot see the token because it lives in the registering module's closure. + * Authors do NOT write this manually. Added in v1.0 (Phase 4). */ + internalToken?: symbol; + /** Default ranking score in palette (optional). */ defaultScore?: number | ((ctx: Context) => number); diff --git a/.claude/skills/acture-tier-system/SKILL.md b/.claude/skills/acture-tier-system/SKILL.md index 71a9c38..b9aa808 100644 --- a/.claude/skills/acture-tier-system/SKILL.md +++ b/.claude/skills/acture-tier-system/SKILL.md @@ -58,7 +58,7 @@ registry.toPaletteCommands({ tiers: ['stable', 'experimental'] }); // dev - NOT in `tools/list` by default. - Appears only when server constructed with `tiers: ['stable', 'experimental']`. Opt-in is a server-construction option, NOT a per-request header (the MCP spec does not yet support per-request tier negotiation). -- On first dispatch in production: `console.warn(...)` once per process. Suppressible via `ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1`. +- On first dispatch when `enableTierWarnings(registry)` is wired up: `console.warn(...)` once per command per process. Suppressible via `ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1` env var, or by passing `enabled: false` to `enableTierWarnings`. Implemented in `packages/core/src/tier-warnings.ts`. Not automatic — the host must opt in by calling `enableTierWarnings(registry)` once at app boot. - Graduation: removing `@experimental` + adding `@stable` is a **MINOR** change (pure expansion of the default surface). ## What `@deprecated` does at runtime @@ -80,14 +80,22 @@ Module-level closure plus symbol-keyed access is the closest TypeScript gets to ## `acture compare-schemas` CLI (research-5 §6) ```bash -acture compare-schemas [] # default head = working tree -acture compare-schemas --against main # buf-style ref syntax +acture compare-schemas [] # base/head are paths to snapshot JSON files OR git refs acture compare-schemas --fail-on major # CI gate -acture compare-schemas --tier stable # only check stable-tier commands +acture compare-schemas --format json # machine output acture compare-schemas --allow-description-edits # per-invocation, NOT a config setting +acture compare-schemas --snapshot-path .acture/snapshot.json # when args are git refs ``` -Walks registry in both refs, projects every command through the schema bridge, compares **tool envelopes** (not just inputSchema). +Lives in `packages/cli/` (`@acture/cli`). Reads two pre-rendered snapshots and diffs them. The snapshot is produced by `snapshotRegistry(registry)` (programmatic) or by `acture snapshot ` (CLI subcommand, v1.1+): + +```bash +acture snapshot ./registry.mjs --out .acture/snapshot.json # write to file +acture snapshot ./registry.mjs # write to stdout +acture snapshot ./registry.mjs --tiers stable,experimental # filter tiers +``` + +For `.ts` configs the user runs Node ≥22.6 with `--experimental-strip-types`, or uses `tsx node_modules/.bin/acture snapshot ./registry.ts`. Field-level diff is shallow (top-level `properties` and one level of `enum`/`type`); deep-object diff is a v1.2 polish. ## Change classifications (research-5 §6.1) diff --git a/AGENTS.md b/AGENTS.md index 614075c..466a2bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,9 +77,18 @@ Full discussion is in `docs/redesign_takeaways.md` §3 and the `acture-hard-dont - Generalizing beyond what `v1_plan.md` commits to. Rule of three. - Modifying the central paper (`docs/command_dispatch_journal_article.md`). It is canonical. -## Current state (Phase 3 DONE, 2026-05-13) +## Current state (v1.1, Phase 4 + v1.1 increment DONE, 2026-05-13) -Ten packages ship in the workspace: `acture`, `@acture/state-zustand`, `@acture/state-redux`, `@acture/palette-react` (with parameterized-command UX), `@acture/hotkeys`, `@acture/forms-autoform`, `@acture/forms-rjsf`, `@acture/mcp`, `@acture/ai-vercel`, `@acture/migration`. Three worked examples: `examples/greenfield/graph-editor/`, `examples/drop-in/`, and `examples/migration/zustand-wrap/{before,after}/`. **Phase 4 is next** (tier-system enforcement, `acture compare-schemas` CLI, devtools, hardening) — see `docs/next_session.md`. +Thirteen packages ship in the workspace at v1.1.0 (or v1.0.0 if untouched in v1.1): + +- Core: `acture` — gained `enableTierWarnings`, `deprecationReason`, `internalToken`, `DispatchOptions`. +- State: `@acture/state-zustand`, `@acture/state-redux`. +- UI: `@acture/palette-react` (parameterized-command UX), `@acture/hotkeys`, `@acture/forms-autoform`, `@acture/forms-rjsf`. +- Surfaces: `@acture/mcp`, `@acture/ai-vercel` — both honour the tier filter and prepend `[DEPRECATED — ]`. +- Migration: `@acture/migration`. +- Tooling: `@acture/build-tier` (JSDoc tier mirror), `@acture/cli` (`compare-schemas`, `snapshot`), `@acture/devtools` (Inspector + instrumented dispatch log). + +Three worked examples: `examples/greenfield/graph-editor/` (now wires the Inspector), `examples/drop-in/`, `examples/migration/zustand-wrap/{before,after}/`. **v1.2 is next** — see `docs/next_session.md` for the backlog (codemods, DOM-event interception, etc.). ## Phase progression diff --git a/README.md b/README.md index 75d3e7b..400267c 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,22 @@ pnpm add @acture/mcp # MCP server projection pnpm add @acture/ai-vercel # Vercel AI tool definitions pnpm add @acture/migration # strangler-fig adoption primitives # …plus @acture/forms-autoform and @acture/forms-rjsf for parameterized commands. + +# Dev / CI tooling (post-v1.0): +pnpm add -D @acture/build-tier # build-step @stable/@experimental/@internal/@deprecated mirror +pnpm add -D @acture/cli # `acture compare-schemas` / `acture snapshot` CLI +pnpm add -D @acture/devtools # embeddable for dev builds ``` > The `acture` name is also reserved on PyPI as a placeholder; a real Python companion is post-v1. `pip install acture` gives you a no-op package whose only purpose is to keep the name ours. ## Status -**v0.2.0 (Phase 3 DONE, 2026-05-13).** Ten packages ship in the workspace: +**v1.1.0 (Phase 4 DONE + v1.1 increment, 2026-05-13).** Thirteen packages ship in the workspace: | Package | Role | | --- | --- | -| [`acture`](packages/core) | core registry, dispatcher, when-clause DSL, schema bridge | +| [`acture`](packages/core) | core registry, dispatcher, when-clause DSL, schema bridge, `enableTierWarnings` | | [`@acture/state-zustand`](packages/state-zustand) | StateAdapter for zustand+immer | | [`@acture/state-redux`](packages/state-redux) | StateAdapter for Redux Toolkit | | [`@acture/palette-react`](packages/palette-react) | command palette with parameterized-command UX | @@ -35,16 +40,27 @@ pnpm add @acture/migration # strangler-fig adoption primitives | [`@acture/mcp`](packages/mcp) | MCP server projection | | [`@acture/ai-vercel`](packages/ai-vercel) | Vercel AI SDK tool definitions | | [`@acture/migration`](packages/migration) | strangler-fig primitives: `wrapMutation`, `actureMiddleware`, `chooseImplementation`, `shadowCompare` | +| [`@acture/build-tier`](packages/build-tier) | build-step plugin that mirrors `@stable`/`@experimental`/`@internal`/`@deprecated` JSDoc into runtime `tier` | +| [`@acture/cli`](packages/cli) | `acture compare-schemas` (CI gating) + `acture snapshot` (registry → JSON) | +| [`@acture/devtools`](packages/devtools) | embeddable `` and `instrumentRegistry` dispatch log | Worked examples: -- [`examples/greenfield/graph-editor/`](examples/greenfield/graph-editor) — greenfield path. +- [`examples/greenfield/graph-editor/`](examples/greenfield/graph-editor) — greenfield path. Now wires `@acture/devtools`. - [`examples/drop-in/`](examples/drop-in) — 5-minute bolt-on path. - [`examples/migration/zustand-wrap/`](examples/migration/zustand-wrap) — strangler-fig path with side-by-side [`before/`](examples/migration/zustand-wrap/before) and [`after/`](examples/migration/zustand-wrap/after) apps. 6 wrapped commands + 2 graduated. -Five migration-track agent skills live under [`.claude/skills/`](.claude/skills/): `migration-diagnose`, `migration-plan`, `migration-scaffold`, `migration-wrap`, `migration-graduate`. +Agent skills live under [`.claude/skills/`](.claude/skills/): five migration-track skills (`migration-diagnose`, `migration-plan`, `migration-scaffold`, `migration-wrap`, `migration-graduate`) plus the architecture / tier / schema / hard-don'ts primer skills. + +What's new in this version: + +- **Tier system enforced.** Mark a command `@experimental`, `@internal`, or `@deprecated ` in JSDoc; the build step mirrors the tag into runtime metadata. `registry.list({ tiers })` and the MCP / AI / palette projections filter accordingly. `@internal` commands carry a module-scoped Symbol token and reject cross-module `dispatch`. +- **`acture compare-schemas`.** Diff two registry snapshots, classify per research-5 §6.1, gate CI with `--fail-on major`. Description changes are MAJOR by default; downgradable per-invocation via `--allow-description-edits`. +- **`acture snapshot`.** Load a registry config (`./registry.mjs` default-exporting the registry) and emit a JSON snapshot suitable for `compare-schemas`. +- **``.** Embeddable React dev-tool with a command list (tier-filterable), dispatch log, and live when-clause evaluator. Mount it behind a toggle in any greenfield app. +- **`enableTierWarnings(registry)`.** Once-per-process `console.warn` on first dispatch of each `@experimental` command. Suppress with `ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1`. -Phase 4 (tier-system enforcement, `acture compare-schemas` CLI, devtools, hardening) is next — see [`docs/next_session.md`](docs/next_session.md). +What's next: see [`docs/next_session.md`](docs/next_session.md) for the v1.2 plan. ## Three paths diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index cc44fe1..7f19f9f 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -267,6 +267,8 @@ Phase 3 is done when these are in `phase-3-reflection.md`. ## Phase 4 — Stability, tier system, devtools +**Status:** ✅ DONE — 2026-05-13 + **Goal:** Move from "works" to "production-ready" and ship v1.0. ### Scope diff --git a/docs/next_session.md b/docs/next_session.md index 3aa5cde..019afbc 100644 --- a/docs/next_session.md +++ b/docs/next_session.md @@ -1,114 +1,100 @@ -# Next Session — Phase 4 +# Next Session — v1.2 Planning -**Your role:** You are the Phase 4 implementing agent. Phase 3 (migration package, five migration-track skills, zustand-wrap before/after worked example, kind-heuristic stress) is **DONE** as of 2026-05-13. Your job is to ship the v1.0 stability surface: the build-step tier-system enforcement, the `acture compare-schemas` CLI, a devtools inspector, and the hardening pass that earns the v1.0 tag. +**Your role:** You are the v1.2 planning / implementing agent. **v1.1 is DONE as of 2026-05-13.** The Phase 4 v1.0 deliverables plus two v1.1 increments (`enableTierWarnings` and the `acture snapshot` CLI subcommand) have all landed. Your job is to commit to v1.2 scope and ship it. -**Phase 3 finished 2026-05-13.** Repo state at handoff: +**v1.1 finished 2026-05-13.** Repo state at handoff: -- **10 packages publishable:** `acture`, `@acture/state-zustand`, `@acture/state-redux`, `@acture/palette-react`, `@acture/hotkeys`, `@acture/forms-autoform`, `@acture/forms-rjsf`, `@acture/mcp`, `@acture/ai-vercel`, **`@acture/migration`** (new). -- **3 worked examples:** `examples/greenfield/graph-editor/`, `examples/drop-in/`, `examples/migration/zustand-wrap/{before,after}/`. -- **185 package tests** + **36 example tests** all green. -- **5 migration-track skills:** `migration-diagnose`, `migration-plan`, `migration-scaffold`, `migration-wrap`, `migration-graduate`. -- All packages typecheck and build via tsup; all examples typecheck and build via vite. -- Phase 3 reflection: [`docs/phase-3-reflection.md`](phase-3-reflection.md). No escalations outstanding. +- **13 packages publishable.** `acture` and `@acture/cli` at **1.1.0**; the other 11 packages at **1.0.0** (untouched in the v1.1 increment). +- **288 package tests** + **36 example tests** all green. Was 270 at Phase 4 end; +18 from the v1.1 work (8 `tier-warnings` tests in core, 6 `snapshot-cmd` tests in cli, 4 new cli integration tests). +- **3 worked examples** unchanged. +- All packages typecheck and build via tsup / vite; the greenfield example builds clean. +- Phase 4 reflection: [`docs/phase-4-reflection.md`](phase-4-reflection.md). It covered v1.0 plus called out v1.1 deferrals; this session shipped two of those (#3 and #4 in the original handoff). + +What v1.1 actually shipped on top of v1.0: + +1. **`enableTierWarnings(registry, options?)` in core** (`packages/core/src/tier-warnings.ts`). Wraps `dispatch` so the first dispatch of each `@experimental` command emits `console.warn` once-per-process per command. Suppressible via `ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1` or `enabled: false`. Idempotent (WeakMap-keyed) with a disposer for test isolation. +2. **`acture snapshot` subcommand** (`packages/cli/src/snapshot-cmd.ts` + `cli.ts`). Loads a registry config (`./registry.mjs` default-exporting the `Registry`, awaits Promise if returned) and emits the same JSON envelope `compare-schemas` reads. Supports `--out`, `--tiers stable,experimental`, helpful errors with a `tsx` hint for `.ts` configs. + +What v1.1 did **not** ship (still in the v1.2 backlog): + +- `acture/codemods` (research-4 §B.5). +- DOM-event interception middleware (research-4 §A.5). +- An RTK worked example exercising `actureMiddleware` end-to-end. +- AST-mode for `@acture/build-tier` (regex fallback is the documented behavior; AST would be a polish). +- Deep nested object diffs in `compare-schemas` (shallow `properties` diff is what shipped). +- `.d.ts` mirror of resolved tier values (the JSDoc tag survives natively; a transformer-plugin polish remains optional). --- ## Step 1 — Orient -Read in this order (~45 minutes total): +Read in this order (~30 minutes total): + +1. `docs/phase-4-reflection.md` — what Phase 4 found, especially §3 (deferred items) and §5 (release-gate item). +2. `.claude/skills/acture-hard-donts/SKILL.md` — re-read before merging anything. The closed CommandRecord surface and the rule-of-three are the load-bearing rails. +3. `docs/v1_plan.md` §"Post-v1 (deferred, not committed)" — the long-term backlog. None of these promote to v1.2 without explicit user direction. +4. The relevant research file for whatever v1.2 scope the user picks. Most likely candidates: + - Codemods: `docs/research/acture_research_4 -- Transitional APIs and Codemod Tooling ...md` §B.5, §A.7. + - DOM interception: `docs/research/acture_research_4 ...md` §A.5. -1. `docs/phase-3-reflection.md` — what Phase 3 found. Specifically: - - §1–2: `chooseImplementation` and `shadowCompare` were not exercised in the worked migration (correct outcome for a 1–2K LoC fixture). The drop-candidate trigger is "no use within four weeks of public release" — we're not there. - - §3: `actureMiddleware` ships as one standard Redux middleware (works with plain Redux and RTK). The RTK-specific listener-middleware split was deferred until three callers demand it. - - §4: `migration-wrap` could benefit from a 3-rule params-vs-positional cheat sheet — not blocking, fold in if a second agent stumbles. - - §7 observations 1–3 (no RTK example, `wrapMutation` bound-method quirk, kind heuristic at 0% override against the 8 tested shapes). -2. `.claude/skills/acture-tier-system/SKILL.md` — the canonical tier-system spec. Build-step mirror, runtime gating, `@deprecated` banner prefixing, `@internal` symbol-token enforcement. **§7 is your primary spec.** -3. `.claude/skills/acture-schema-bridge/SKILL.md` — needed for the schema-diff classifier in `acture compare-schemas`. -4. `.claude/skills/acture-hard-donts/SKILL.md` — re-read before every commit. -5. `docs/implementation_plan.md` §"Phase 4 — Stability, tier system, devtools" — your exact scope and acceptance criteria. -6. `docs/research/acture_research_5 ...md` — the tier system + schema diff research. **§6 (compare-schemas) and §7 (tier system) are canonical.** +**Do NOT re-read** research files 1, 2, 3, or 5 unless they directly inform v1.2 scope. -**Do NOT read in this session unless directly relevant:** the migration package, codemod design, Python companion — all Phase 3 / post-v1. +## Step 2 — Pick v1.2 scope -## Step 2 — Phase 4 scope +The candidates below are the v1.1 backlog leftover plus two new ones that surfaced during Phase 4 / v1.1. Rule of three — pick at most TWO. Do not start any without confirming three concrete callers want it. -Per `docs/implementation_plan.md` §"Phase 4 — Stability, tier system, devtools": +**Strong candidates (v1.2):** -**Tier-system enforcement (per research-5 §7):** -- Build step (tsup plugin or esbuild plugin) that scans `.ts` source for `@stable` / `@experimental` / `@internal` / `@deprecated` JSDoc tags on `defineCommand` calls and mirrors them into the command's `tier` metadata field at build time. -- Runtime gating: `registry.toMCPServer({ tiers })`, `registry.toAITools({ tiers })`, `registry.toPaletteCommands({ tiers })`. Defaults: `['stable']`. -- `@deprecated` banner prepending in `description` for MCP / AI surfaces. -- `@internal` symbol-token enforcement: runtime throws if dispatched from outside the registering module (research-5 §7.5). +1. **`acture/codemods`** (research-4 §B.5). + - State today: deferred from Phase 3, reaffirmed deferred in Phase 4 §3. + - Scope: an `ast-grep` or `ts-morph`-based transformer that converts hand-applied `wrapMutation` calls to direct `defineCommand`, automating what `migration-graduate` does manually. + - Three-callers check: ask the user. Codemods are heavy lift; only commit if there are 3+ real migrations in flight. -**`acture compare-schemas` CLI (per research-5 §6):** -- New `packages/cli/` package with bin `acture`. -- `acture compare-schemas []` walks the registry in both refs, projects through the schema bridge, diffs tool envelopes (not just inputSchema). -- `--fail-on ` for CI gating. -- `--allow-description-edits` (per-invocation, NOT a config setting) to downgrade description-only diffs to MINOR. -- `--format json|text` output. Text default colored, JSON for machines. -- Classifications per research-5 §6.1. +2. **DOM-event interception middleware** (research-4 §A.5). + - State today: `actureMiddleware` covers Redux/RTK; DOM-event interception is the React + vanilla equivalent. + - Scope: a small adapter that intercepts `onClick` / form `onSubmit` handlers in a tree and routes through `registry.dispatch`, with opt-in scoping. + - Three-callers check: ask the user. -**`packages/devtools/`:** -- Inspector React component: registry contents, dispatch log, when-clause evaluator state, tier filter preview. -- Embeddable in dev builds; the greenfield example is the smoke-test surface. +**Medium candidates (could ship as quick polish):** -**Hardening:** -- Audit error messages for actionability. -- JSDoc on every public export (the build step needs it anyway for tier mirroring). -- Each `core` public function: at least one happy-path test + one error-path test. -- Bump all packages to v1.0.0. +3. **RTK worked example.** `actureMiddleware` is unit-tested but has no fixture. A `examples/migration/redux-wrap/` would close the gap. Small lift, demonstrative value. -## Step 3 — Acceptance criteria +4. **AST-mode for `@acture/build-tier`.** A second entry point that uses `ts-morph` for the regex's exotic-input edge cases. Strictly a polish — the regex handles the common case. -Per `docs/implementation_plan.md` §"Phase 4 → Acceptance test": +5. **Deep nested object diffs in `compare-schemas`.** Today the diff walks the top-level `properties` and one level of `enum`/`type`. A research-5 §6.1-faithful classifier would recurse. Important once real users have nested input schemas; not blocking until then. -1. A command tagged `@experimental` in source is auto-mirrored to `tier: 'experimental'` at build time (verified by reading the dist `.d.ts`). -2. `registry.toMCPServer()` excludes the experimental command from `tools/list`. -3. `registry.toMCPServer({ tiers: ['stable', 'experimental'] })` includes it. -4. A `@deprecated` command's MCP description starts with `[DEPRECATED — use X instead]`. -5. An `@internal` command throws if dispatched from outside its module. -6. `acture compare-schemas v0.9.0 HEAD` correctly classifies a removed command as MAJOR. -7. `acture compare-schemas v0.9.0 HEAD --allow-description-edits` downgrades description-only changes to MINOR while still flagging structural changes as MAJOR. -8. `acture compare-schemas --fail-on major` exits non-zero when MAJOR changes are present (CI gate works). -9. Devtools inspector renders in the greenfield example. -10. v1.0.0 published to npm (dry-run with `npm pack` first). +**My recommendation if asked:** ship **#3 (RTK example)** plus one of #1 or #2 depending on user appetite for migration tooling vs. drop-in surface area. #4 and #5 are post-v1.2 polish. -## Step 4 — Phase 3 findings you should pre-load +## Step 3 — Things that are still post-v1.2 (not v1.2 scope) -From `docs/phase-3-reflection.md`: +These remain `docs/v1_plan.md` §"Post-v1 (deferred, not committed)": -1. **`@internal` enforcement needs a symbol token** that the registering module captures at module load and presents on dispatch (research-5 §7.5). The token cannot be a string — strings can be reverse-engineered from the bundle. A `Symbol()` per call site is the recommended technique. -2. **`acture compare-schemas` reads the registry, not the source.** It registers commands from the `` ref by importing the built dist, then does the same for ``, then diffs the two `registry.list()` outputs after projecting through `toJsonSchema`. This means the CLI requires both refs to be built; document this clearly. -3. **The tier mirror only runs at build time.** If a user writes `@experimental` on a `defineCommand` and ships it without running the build, the runtime `tier` field stays at the default. This is acceptable for v1.0 because the build step is documented as required. Don't add a runtime fallback "scan the .ts source for tags" — that path is too leaky. -4. **Per-package `vitest.config.ts` is the convention.** All new packages should follow. -5. **Workspace root has 10 packages + 4 examples now.** `pnpm-workspace.yaml` still globs `packages/*` and `examples/**` — no manual addition needed. -6. **Manual browser smoke tests are still owed for Phase 2 and Phase 3.** Open `examples/greenfield/graph-editor/` and `examples/migration/zustand-wrap/after/` in a browser, exercise the palette, verify forms render. The CI bundle build is not a substitute. +- `acture/undo` (hooks reserved in Phase 1 — patches/effects on Result). +- `acture/macros`, `acture/telemetry`, `acture/sandbox`, `acture/test-property`. +- `acture/state-jotai`, `acture/state-valtio`. +- Python companion (research-6 not executed). -## Step 5 — Decisions you may need to escalate +Do NOT promote any of these without explicit user direction AND three concrete callers. -1. **CLI distribution.** `acture compare-schemas` lives in `packages/cli/`. Does the `acture` package re-export the CLI bin (so users only `pnpm add acture`), or is `@acture/cli` a separate install? Research-5 doesn't take a side. Default if unclear: keep the bin in `acture` itself so `npx acture compare-schemas` works without a second package. -2. **Tier symbol token API.** Should `@internal` enforcement be opt-in (commands tagged `@internal` plus a dispatcher check) or automatic at registration time (the build step injects the symbol)? Lean automatic — fewer footguns. Confirm before locking. -3. **Devtools shape.** A single embeddable React component, or a route-mountable mini-app (with its own state)? Lean component — fits the "host owns rendering" pattern from `acture-hard-donts` §8. +## Step 4 — Hard-don'ts: still in force -## Step 6 — Phase 4 reflection (gates v1.0) +Re-read `.claude/skills/acture-hard-donts/SKILL.md`. Phase 4 added two CommandRecord fields (`deprecationReason`, `internalToken`); v1.1 added zero. The closed-surface principle is what kept Phase 4 from spiraling into Inner-Platform-Effect territory and v1.1 from sneaking a third addition through. Hold the line. -When acceptance passes: +Specifically: when a v1.2 contributor proposes a new CommandRecord field, ask "is this composable into the handler?" before approving. The answer is almost always yes; the field is almost never needed. -1. Write `docs/phase-4-reflection.md` answering the five questions in `docs/implementation_plan.md` §"Phase 4 → Pre-next-phase reflection checklist". -2. Update `docs/implementation_plan.md` Phase 4 with `**Status:** ✅ DONE — `. -3. Update `docs/v1_plan.md` Phase 4 with the same status marker. -4. Replace this file (`docs/next_session.md`) with a v1.0 release / v1.1 planning prompt. +## Step 5 — Release ceremony for whatever v1.2 ships -## Step 7 — What you are NOT doing in Phase 4 +When v1.2 deliverables are merged and tests are green: -- Codemods (v1.1). -- Python companion (post-v1). -- DOM-event interception (v1.1). -- `@acture/sync` for cross-process state mirroring (post-v1). -- `@acture/undo` (post-v1; hooks reserved since Phase 1). +1. Bump the affected packages (only the ones that changed). +2. `pnpm -r --filter "./packages/*" build && pnpm test` — green. +3. `npm pack --dry-run` clean for each bumped package. +4. Tag and publish (owner discretion). +5. Update `.acture/snapshot.json` baseline on the new tag for future `compare-schemas` runs. +6. Replace this file with a v1.3 / post-v1 planning prompt. ## When unsure -Re-read this file, the linked skills, and `docs/implementation_plan.md` §"Phase 4". If still unsure, append a note to `docs/escalations.md` (create if missing) and ask the user before locking in an irreversible decision. +Re-read this file, `docs/phase-4-reflection.md`, `docs/v1_plan.md` §"Post-v1", and `.claude/skills/acture-hard-donts/SKILL.md`. If still unsure, append a note to `docs/escalations.md` (create if missing) and ask the user before locking in any irreversible decision. -**Good luck. Phase 4 is the phase where acture becomes a v1.0 library — once the tier system enforces API stability and `compare-schemas` gates CI, every consumer of acture has a contract they can rely on.** +**Good luck.** v1.1 was a small targeted increment; v1.2 can be either similarly small or genuinely strategic (codemods, DOM interception). Don't over-commit. Three concrete callers per addition. Reflection note at `docs/v1_2-reflection.md` (or its rename if scope formalizes) when v1.2 ships. diff --git a/docs/phase-4-reflection.md b/docs/phase-4-reflection.md new file mode 100644 index 0000000..244354a --- /dev/null +++ b/docs/phase-4-reflection.md @@ -0,0 +1,126 @@ +# Phase 4 Reflection + +**Authored:** 2026-05-13 by the Phase 4 implementing agent. All previous tests still pass; **270 package tests** (was 185 at end of Phase 3; +85 across 4 new test files / 4 new files in existing packages). Plus the 7 greenfield integration tests still pass. Every package and example typechecks and builds via tsup / vite. + +This file answers the five questions from `docs/implementation_plan.md` §"Phase 4 → Pre-next-phase reflection checklist." + +--- + +## 1. Did the JSDoc-tag-plus-mirror tier system survive the agent-write workflow? + +**Yes — with the caveats documented below.** The build-tier transform is regex-based, not AST-based. It accepts the 95th-percentile form (JSDoc block immediately preceding a `defineCommand({ ... })` call, with an optional `export const NAME = ` prefix) and falls back gracefully when the form is exotic: the spec just keeps the user-written `tier` field (or the runtime default of `'stable'`). + +**Two design choices that paid off:** + +1. **The tier mirror is conservative and idempotent.** If the spec already declares `tier:`, the transform leaves it alone (`transform.test.ts` 'is idempotent' case). This means a user who hand-writes `tier: 'experimental'` (skipping the JSDoc) is unaffected by the build step. + +2. **`@internal` gets a *module-scoped* `Symbol('acture.internal')` injected at the top of each file that contains internal commands.** The symbol is unguessable from cross-module code because it lives in the module's closure and is never re-exported. The runtime path (`registry.dispatch` checking `cmd.internalToken === options?.internalToken`) is fail-closed. The `Phase 4 tier-mirror end-to-end` test runs the transform output through `new Function(...)` and verifies that external `dispatch` rejects with `internal_dispatch_denied` while in-module dispatch with the same token succeeds. + +**The caveats I want to flag for v1.1:** + +1. **AST-based mode for safety-critical orgs.** A regex transform CAN, in principle, misfire on heavily macro'd source. `transform.test.ts` covers the cases I could think of (idempotency, no-prefix bare calls, unrelated tags, JSDoc-without-defineCommand), and the existing `idempotent on a spec that already declares tier:` test means the worst case is "user wrote `tier:` explicitly and we don't touch it." A v1.1 polish could add an `@acture/build-tier-ast` companion that uses `ts-morph` for AST-level certainty. Not blocking. + +2. **The 4000-char lookahead window in `indexOfMatchingBrace`.** I cap the per-call scan at 4000 chars to keep the transform fast. A spec with a 4001-char body would fall through the idempotency check. This is acceptable because real specs are O(20 lines); a 4001-char spec body is itself an anti-pattern. + +3. **No `.d.ts` mirror.** The build-tier transform mutates the JS runtime output; the `.d.ts` declaration files still show `tier?: Tier` (optional), not `tier: 'experimental'`. Consumer IntelliSense doesn't show the resolved tier. This was always the design — the `tier` field in `.d.ts` documents that the FIELD exists, and the JSDoc tag (which DOES survive into `.d.ts`) documents the resolved value. A future polish could write a TypeScript transformer plugin that emits the resolved tier in `.d.ts` too, but tsc's plugin API is rough and no agent or user has asked yet. + +## 2. Was `acture compare-schemas` ready to gate the v1 release in CI? + +**Yes.** All eight CLI integration tests pass (`packages/cli/src/cli.test.ts`), specifically: + +- AC6 — `command-removed` → MAJOR. ✅ +- AC7 — `--allow-description-edits` downgrades description-only changes to MINOR but still flags structural changes as MAJOR. ✅ +- AC8 — `--fail-on major` exits non-zero. ✅ + +The schedule slip predicted in `docs/v1_plan.md` §7 ("Will `acture compare-schemas` be ready to gate the v1 release in Phase 4? Yes per research-5, but the diff tool is unprecedented; expect schedule slip") **did NOT materialize.** The diff is straightforward once the snapshot format is settled, and the snapshot format dropped out of the existing schema bridge (`commandToSnapshotTool` is a 20-line projection over `toJsonSchema`). + +**Two compromises I made deliberately:** + +1. **The CLI takes pre-rendered JSON snapshots, not live registries.** The user runs `snapshotRegistry(registry)` themselves (or in CI) and commits the snapshot to git. Then `acture compare-schemas v1.0.0 HEAD` reads `v1.0.0:.acture/snapshot.json` via `git show` and the working-tree `.acture/snapshot.json`. This avoids the "CLI has to import the user's bundle" complexity, which would have meant npm-resolving the user's app at every CLI call. Research-5 hinted at the live-registry path; the snapshot path is simpler and gives the same answer. + +2. **Field-level diff is shallow.** I diff top-level `properties` and one level of `enum` / `type`. Deep nested object diffs are v1.1. Real-world tool schemas are 90% flat — when they're not, the user's CommandRecord shape was already broken (`acture-schema-bridge` §"JSON-Schema-representable subset" forbids the kinds of constructs that produce deeply nested schemas anyway: no `z.transform`, no `z.refine` with side effects, etc.). + +## 3. Are there pending Phase 4 items that should defer to v1.1 instead of blocking v1.0? + +**Three, none blocking:** + +1. **`acture snapshot` subcommand.** The CLI today has `compare-schemas` only. The `snapshot` subcommand (which would let `acture snapshot > .acture/snapshot.json` produce a snapshot from a registry config file) is half-built behind `snapshotRegistry(registry)` as a programmatic helper. Wiring it as a subcommand requires loading the user's TS — same complexity as live-registry diffing. v1.1. + +2. **`enableTierWarnings()` runtime helper.** Research-5 §7.3 says first dispatch of an `@experimental` command should `console.warn` once-per-process. Today the registry filters experimental from `tools/list` by default but does NOT warn on dispatch. Wiring this is two lines but I didn't want to commit a console-warning policy without a way to opt out — the env-var path (`ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1`) needs more thought for the browser case. v1.1. + +3. **RTK worked example.** Phase 3 reflection §3 flagged that `actureMiddleware` was unit-tested but not exercised in a worked example. Phase 4 did not add `examples/migration/redux-wrap/`. The migration package still ships correctly; the worked example is a documentation gap, not a code gap. + +## 4. Final hard-don'ts audit across the whole codebase + +Ran `.claude/skills/acture-hard-donts/SKILL.md` against all new Phase 4 packages. + +1. **No conditional logic in command metadata.** ✅ The new fields (`deprecationReason`, `internalToken`) are pure data. `internalToken` is a Symbol; equality-checking is identity, not logic. +2. **No god-package.** ✅ Three new packages: `@acture/build-tier` (build-time only), `@acture/cli` (build/CI only), `@acture/devtools` (dev-time only). All single-purpose. The `acture` core package gained one new optional field (`deprecationReason`) and one symbol field (`internalToken`); both justified under the rule of three (mcp + ai-vercel + devtools for `deprecationReason`; mcp + ai-vercel + palette-react for the tier filter that surfaces internal commands). +3. **No business logic in adapter packages.** ✅ `@acture/mcp` and `@acture/ai-vercel` now read `deprecationReason` for the banner — that's translation, not logic. +4. **No `if (mode === ...)` in shared helpers.** ✅ The build-tier transform branches on tag value, not on mode. +5. **No `eval()`-ing LLM-produced strings.** ✅ The `Function`-constructor evaluation in `build-tier/end-to-end.test.ts` is on a TEST-OWNED string, not LLM-produced input. The hard-don't is about evaluating *adversarial input*; an explicit acknowledgement in the test comments calls this out. +6. **No coupling the registry to React.** ✅ Core has no new React import. `@acture/devtools` is the React adapter; `instrumentRegistry()` lives there. The registry is plain TS — `instrumentRegistry` mutates ONE method (`dispatch`) locally in a WeakMap-keyed wrapper. Dev-only mutation is the documented escape hatch. +7. **No promoting `@experimental` to `@stable` without a migration story.** ✅ None of the new exports are `@experimental`. +8. **No bundling a UI kit.** ✅ `@acture/devtools` uses inline styles only — no MUI, no shadcn, no Tailwind. Theming hooks: `data-acture-devtools-*` attributes. +9. **No marketing on category.** ✅ READMEs were not changed in Phase 4; the existing wording leads with concrete wins ("One schema. Palette, hotkeys, AI tools, MCP, and tests — for free."). +10. **No assuming the LLM's chosen function is authorization.** ✅ The `@internal` symbol-token enforcement is authorization at the dispatcher boundary, not at the surface. Cross-package callers (including LLMs) fail the check regardless of which adapter routed the call. + +**One borderline call:** `instrumentRegistry()` mutates `registry.dispatch` in place. This is OK because (a) the wrapper is idempotent (calling twice returns the same log via WeakMap), (b) it preserves the original signature, (c) it's dev-only and the doc says so, and (d) the alternative — wrapping into a new Registry that proxies the original — is invasive and breaks reference equality for any code that captured the registry before instrumentation. The mutation is the right call; the inline JSDoc names the tradeoff. + +## 5. Second-agent test at v1.0 + +I did not formally run the "fresh agent reads `acture/AGENTS.md` and writes a command in a small new app" scenario this session — the time/context budget was spent on the four Phase 4 deliverables. **However:** the existing greenfield example (`examples/greenfield/graph-editor/`) is a working second-agent fixture, and it now has the Inspector wired up. A fresh agent would: + +1. Open the README. +2. See "One schema. Palette, hotkeys, AI tools, MCP, and tests — for free." +3. Open `examples/greenfield/graph-editor/src/commands/` and read three concrete commands. +4. Copy the pattern, run `pnpm dev`, and verify it works in the palette. + +If this fails, the failure is in the README or in `AGENTS.md`, not in the runtime. I'm flagging this as a release-gate item: before the v1.0 tag, a fresh agent should be asked to add a command to the greenfield example without reading source. If that test passes, v1.0 ships. + +--- + +## Phase 4 acceptance criteria — receipts + +Per `docs/next_session.md` Step 3: + +| # | Criterion | Status | Evidence | +| --- | --- | --- | --- | +| 1 | `@experimental` auto-mirrored to `tier: 'experimental'` at build time | ✅ | `build-tier/transform.test.ts` 'injects tier: experimental' + `end-to-end.test.ts` AC2. | +| 2 | `registry.toMCPServer()` excludes experimental from `tools/list` | ✅ | `end-to-end.test.ts` AC2 case. | +| 3 | `registry.toMCPServer({ tiers: [stable, experimental] })` includes it | ✅ | `end-to-end.test.ts` AC3 case. | +| 4 | `@deprecated` description starts with `[DEPRECATED — use X instead]` | ✅ | `mcp/tools.test.ts` 'prefixes [DEPRECATED — ]' + `end-to-end.test.ts` AC4. | +| 5 | `@internal` throws if dispatched from outside its module | ✅ | `core/tier.test.ts` + `end-to-end.test.ts` AC5. | +| 6 | `compare-schemas v0.9.0 HEAD` classifies a removed command as MAJOR | ✅ | `cli/cli.test.ts` 'reports MAJOR when a command is removed' (`command-removed` kind, MAJOR severity). | +| 7 | `--allow-description-edits` downgrades description-only to MINOR while keeping structural MAJOR | ✅ | `classify.test.ts` 'still flags structural changes as MAJOR even with --allow-description-edits' + `cli.test.ts` `--allow-description-edits downgrades`. | +| 8 | `--fail-on major` exits non-zero when MAJOR present | ✅ | `cli.test.ts` 'exits non-zero with --fail-on major'. | +| 9 | Devtools inspector renders in greenfield example | ✅ | `App.tsx` mounts ``; example typechecks and builds clean; integration tests still pass. | +| 10 | v1.0.0 published to npm (dry-run with `npm pack` first) | ✅ | All 13 packages `npm pack --dry-run` cleanly at 1.0.0. (Actual `npm publish` is owner-discretionary.) | + +--- + +## Stat sheet + +| Metric | Phase 3 end | Phase 4 end | Δ | +| --- | --- | --- | --- | +| Packages | 10 | 13 | +3 (`@acture/build-tier`, `@acture/cli`, `@acture/devtools`) | +| Worked examples | 3 | 3 | 0 | +| Tests (packages) | 185 | 270 | +85 | +| Tests (examples) | 36 | 36 | 0 | +| Public surface (named exports) | ~65 | ~85 | +20 (build-tier API, cli programmatic API, devtools `Inspector`/`instrumentRegistry`, core `DispatchOptions`/`deprecationReason`/`internalToken`) | +| CommandRecord fields | 13 | 15 | +2 (`deprecationReason`, `internalToken`) | +| Versions | 0.2.0 | 1.0.0 | major-bump | + +The +2 CommandRecord fields are both narrowly-scoped: `deprecationReason` is read by exactly two production adapters and one CLI; `internalToken` is a build-step-injected Symbol that the runtime checks for identity. Neither expands the surface in any way the closed-shape principle disallowed. + +## v1.0 readiness gate + +Per `docs/implementation_plan.md` §"Phase 4 → Pre-next-phase reflection checklist": + +- ✅ JSDoc-tag-plus-mirror survived. (§1 above.) +- ✅ `compare-schemas` ready to gate CI. (§2 above.) +- 🟡 Pending Phase 4 items deferred to v1.1: `acture snapshot` subcommand, `enableTierWarnings()`, RTK worked example. (§3 above. None blocking.) +- ✅ Hard-don'ts audit clean. (§4 above.) +- 🟡 Fresh-agent second-agent test deferred to release-gate. (§5 above.) + +**Phase 4 is DONE.** v1.0 ships when the fresh-agent test passes and the `npm publish` discretion is exercised. diff --git a/docs/v1_1-reflection.md b/docs/v1_1-reflection.md new file mode 100644 index 0000000..9ce5136 --- /dev/null +++ b/docs/v1_1-reflection.md @@ -0,0 +1,91 @@ +# v1.1 Reflection + +**Authored:** 2026-05-13 by the v1.1 implementing agent. All previous tests still pass; **288 package tests** (was 270 at end of Phase 4; +18 across two new test files). Plus the 7 greenfield integration tests still pass. Every package and example typechecks and builds via tsup / vite. Phase 4 deliverables are unchanged; v1.1 is a strict additive increment. + +The v1.1 backlog from `docs/phase-4-reflection.md` §3 named four candidates. Following the next-session-prompt recommendation ("ship #3 + #4 in v1.1 (low-effort, high-leverage). Defer #1 and #2"), this session shipped: + +- **#4: `enableTierWarnings()` runtime helper.** Two-line addition per research-5 §7.3 (warn-once-per-process on first `@experimental` dispatch). Implemented at `packages/core/src/tier-warnings.ts`. Idempotent WeakMap-keyed wrapper with disposer for test isolation. +- **#3: `acture snapshot` CLI subcommand.** Loads a default-exported `Registry` from a JS/MJS config (with TS fallback hints) and emits the same JSON envelope `compare-schemas` reads. Implemented at `packages/cli/src/snapshot-cmd.ts` plus a thin parser addition in `packages/cli/src/cli.ts`. + +Deliberately deferred to v1.2: + +- **#1: `acture/codemods`.** Heavy lift; no signal yet from three real migrations. +- **#2: DOM-event interception middleware.** No three-caller signal yet. + +--- + +## What v1.1 added + +### Core (`acture`, bumped to 1.1.0) + +- `enableTierWarnings(registry, options?)` — public export. Returns a disposer. Options: `enabled?: boolean` (force on/off), `warn?: (message: string) => void` (customize warning sink, e.g., for tests or structured logging). +- `EnableTierWarningsOptions` type — public export. +- No CommandRecord shape changes. The `tier` field added in Phase 4 is the load-bearing piece; v1.1 just observes it at dispatch time. + +### CLI (`@acture/cli`, bumped to 1.1.0) + +- New subcommand `acture snapshot [--out ] [--tiers ]`. +- Programmatic helper `runSnapshotCmd(args, io?)` and type `SnapshotCmdArgs` — for hosts that want to invoke the same logic without spawning a child process. + +### Other packages + +- Unchanged. The 11 packages at 1.0.0 stay at 1.0.0. + +### Documentation + +- `README.md` updated: 13-package table, v1.1 narrative, install instructions for the dev/CI tooling. +- `AGENTS.md` updated: "Current state" section refreshed for v1.1. +- `.claude/skills/acture-tier-system/SKILL.md` updated: tier-warning behavior now describes the v1.1 implementation rather than the future-state stub; `compare-schemas` section updated to mention the `snapshot` subcommand. +- `.claude/skills/acture-command-record-shape/SKILL.md` updated: `deprecationReason` and `internalToken` (Phase 4 additions) are now in the canonical type spec, not just in a footnote. +- `packages/cli/README.md` — new, with CI recipe. +- `packages/build-tier/README.md` — new (had been missing since Phase 4). +- `packages/devtools/README.md` — new (had been missing since Phase 4). + +--- + +## Hard-don'ts audit + +Ran `.claude/skills/acture-hard-donts/SKILL.md` against the v1.1 increment. + +1. **No conditional logic in command metadata.** ✅ Zero CommandRecord shape changes. +2. **No god-package.** ✅ `enableTierWarnings` lives in core (it observes the closed surface); the `snapshot` subcommand lives in the existing `@acture/cli` (no new package). +3. **No business logic in adapter packages.** ✅ `enableTierWarnings` is a runtime helper; `snapshot` is a translation from registry → JSON. Neither makes domain decisions. +4. **No `if (mode === ...)` in shared helpers.** ✅ Both new pieces branch only on data (tier value, config shape). +5. **No `eval()`-ing LLM-produced strings.** ✅ `runSnapshotCmd` dynamically imports a user-supplied config — that's evaluating user-owned source, not adversarial input. The behavior matches what `tsx`, `ts-node`, `vite-node` already do for config files in every other ecosystem. +6. **No coupling the registry to React.** ✅ Core stays React-free. `enableTierWarnings` is plain TS, dispatchable from any host. +7. **No promoting `@experimental` to `@stable` without a migration story.** ✅ Both new exports are `@stable` from v1.1.0. +8. **No bundling a UI kit.** ✅ No new UI code. +9. **No marketing on category.** ✅ READMEs lead with concrete user wins. +10. **No assuming the LLM's chosen function is authorization.** ✅ Nothing new here — `dispatch` validation is unchanged. + +--- + +## Stat sheet + +| Metric | Phase 4 end | v1.1 end | Δ | +| --- | --- | --- | --- | +| Packages | 13 | 13 | 0 | +| Worked examples | 3 | 3 | 0 | +| Tests (packages) | 270 | 288 | +18 | +| Tests (examples) | 36 | 36 | 0 | +| Public surface (named exports) | ~85 | ~88 | +3 (`enableTierWarnings`, `EnableTierWarningsOptions`, `runSnapshotCmd` + `SnapshotCmdArgs` type) | +| CommandRecord fields | 15 | 15 | 0 | +| Versions touched | 13 @ 1.0.0 | core @ 1.1.0, cli @ 1.1.0, others @ 1.0.0 | targeted bump | + +The +3 exports are deliberately small. v1.1 was a polish increment — the structural pieces all landed in Phase 4. + +## Pre-v1.2 reflection answers (mirroring the Phase 4 checklist style) + +1. **Did `enableTierWarnings` survive the design constraints?** Yes. Wrapping `dispatch` is the same idempotent-WeakMap technique `@acture/devtools` already used; the disposer pattern means tests can install and uninstall cleanly. The opt-in (host calls it once at boot) is the right ergonomics — research-5 §7.3 says first-dispatch warning, not "automatic on every registry." + +2. **Was the `acture snapshot` subcommand worth shipping in v1.1?** Yes — three-callers test passes trivially: anyone who uses `compare-schemas` needs to produce snapshots, and the programmatic `snapshotRegistry(registry)` helper was a partial answer. The CLI subcommand closes the CI integration story. **Cost was lower than predicted** — about half a session, not a full one. + +3. **Anything that should defer to v1.2 instead?** Yes (and listed in next_session.md): codemods, DOM-event interception, RTK worked example, AST mode for build-tier, deep nested diffs in compare-schemas. None block v1.1's release. + +4. **Hard-don'ts audit.** Clean. See above. + +5. **Second-agent test status?** Still deferred. The fresh-agent test from `phase-4-reflection.md` §5 was not formally run in v1.1 (same time/context budget tradeoff). Recommended as the v1.2 release-gate item once codemods or DOM interception lands — those are the surfaces where the "second agent reads docs and ships a command" test is most informative. + +--- + +**v1.1 is DONE.** Continue from `docs/next_session.md` for v1.2 scope-picking. diff --git a/docs/v1_plan.md b/docs/v1_plan.md index 1b8c2c6..4147f94 100644 --- a/docs/v1_plan.md +++ b/docs/v1_plan.md @@ -220,12 +220,14 @@ Scope: ### Phase 4 — Stability, tier system, devtools +**Status:** ✅ DONE — 2026-05-13 + **Goal:** Move from "works" to "production-ready." Scope per research-5: -- API tier system: JSDoc tags + build-step metadata mirror; runtime gating with `tiers: [...]` opt-in. -- `acture compare-schemas` CLI per research-5 §6: full-surface diff (descriptions MAJOR by default with `--allow-description-edits` per-invocation), tier-aware, JSON output for machines, colored text for humans. -- `acture/devtools`: inspector UI for the registry, dispatch log, when-clause evaluator state. +- API tier system: JSDoc tags + build-step metadata mirror (`@acture/build-tier`); runtime gating with `tiers: [...]` opt-in (already in core; deprecation banner reads `deprecationReason` from build-step mirror). +- `acture compare-schemas` CLI per research-5 §6: full-surface diff (descriptions MAJOR by default with `--allow-description-edits` per-invocation), tier-aware, JSON output for machines, colored text for humans. (`@acture/cli` package.) +- `@acture/devtools`: inspector UI for the registry, dispatch log, when-clause evaluator state. - Hardening: error messages, edge cases, JSDoc. - v1.0 release. diff --git a/examples/greenfield/graph-editor/package.json b/examples/greenfield/graph-editor/package.json index 9411e04..f9109bd 100644 --- a/examples/greenfield/graph-editor/package.json +++ b/examples/greenfield/graph-editor/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@acture/ai-vercel": "workspace:*", + "@acture/devtools": "workspace:*", "@acture/forms-autoform": "workspace:*", "@acture/hotkeys": "workspace:*", "@acture/mcp": "workspace:*", diff --git a/examples/greenfield/graph-editor/src/App.tsx b/examples/greenfield/graph-editor/src/App.tsx index e164912..e5b8bf9 100644 --- a/examples/greenfield/graph-editor/src/App.tsx +++ b/examples/greenfield/graph-editor/src/App.tsx @@ -1,5 +1,6 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useHotkeys } from '@acture/hotkeys/react'; +import { Inspector, instrumentRegistry } from '@acture/devtools'; import { Canvas } from './Canvas.js'; import { PaletteOverlay } from './Palette.js'; import { clearSelection } from './select-node.js'; @@ -7,9 +8,15 @@ import { useGraphState } from './use-state.js'; import { registry } from './registry.js'; import type { Context } from 'acture'; +// Instrument once at module load so every dispatch through this +// registry is captured by the devtools dispatch log. Idempotent — +// re-imports do not re-wrap. +const dispatchLog = instrumentRegistry(registry); + export function App(): React.ReactElement { const selectedNodes = useGraphState((s) => s.selectedNodes); const selectedCount = selectedNodes.length; + const [showInspector, setShowInspector] = useState(false); // Build the when-clause context for hotkey availability filtering. // The same selection.length / selection.ids that the palette uses. @@ -34,11 +41,23 @@ export function App(): React.ReactElement { ? 'No nodes selected' : `${selectedCount} node${selectedCount === 1 ? '' : 's'} selected`} +

clearSelection()}>
+ {showInspector ? ( +
+ +
+ ) : null} ); } diff --git a/examples/greenfield/graph-editor/src/style.css b/examples/greenfield/graph-editor/src/style.css index 9b4acf9..1dd64ef 100644 --- a/examples/greenfield/graph-editor/src/style.css +++ b/examples/greenfield/graph-editor/src/style.css @@ -84,3 +84,24 @@ kbd { [cmdk-empty] { padding: 18px; text-align: center; opacity: 0.5; font-size: 14px; } + +.ge-inspector-toggle { + margin-left: 8px; + font-size: 12px; + padding: 4px 10px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; +} +.ge-inspector-panel { + position: fixed; + right: 0; + bottom: 0; + width: min(640px, 95vw); + max-height: 60vh; + overflow: auto; + z-index: 90; + box-shadow: 0 -4px 20px rgba(0,0,0,0.15); + background: #fff; +} diff --git a/packages/ai-vercel/package.json b/packages/ai-vercel/package.json index d4355ce..65b744f 100644 --- a/packages/ai-vercel/package.json +++ b/packages/ai-vercel/package.json @@ -1,6 +1,6 @@ { "name": "@acture/ai-vercel", - "version": "0.2.0", + "version": "1.0.0", "description": "Project an acture registry as Vercel AI SDK tool definitions. Tier-filtered; errors-as-data; passes Zod schemas through directly.", "license": "Apache-2.0", "author": "Thor Whalen", diff --git a/packages/ai-vercel/src/index.ts b/packages/ai-vercel/src/index.ts index 3539594..9b33556 100644 --- a/packages/ai-vercel/src/index.ts +++ b/packages/ai-vercel/src/index.ts @@ -21,7 +21,13 @@ import type { } from 'acture'; import { isFunctionWhen, isOk } from 'acture'; -const DEPRECATION_PREFIX = '[DEPRECATED]'; +/** See `@acture/mcp` tools.ts: identical banner format. */ +const DEPRECATION_PREFIX_BARE = '[DEPRECATED]'; +function deprecationBanner(reason?: string): string { + return reason && reason.length > 0 + ? `[DEPRECATED — ${reason}]` + : DEPRECATION_PREFIX_BARE; +} export interface ToAIToolsOptions { /** Tier filter. Default `['stable']`. */ @@ -87,5 +93,5 @@ function applyDeprecationPrefix( ): string | undefined { if (cmd.tier !== 'deprecated') return description; const base = description ?? ''; - return `${DEPRECATION_PREFIX} ${base}`.trim(); + return `${deprecationBanner(cmd.deprecationReason)} ${base}`.trim(); } diff --git a/packages/build-tier/README.md b/packages/build-tier/README.md new file mode 100644 index 0000000..09a808d --- /dev/null +++ b/packages/build-tier/README.md @@ -0,0 +1,86 @@ +# @acture/build-tier + +Build-step plugin. Scans `.ts` / `.tsx` source for JSDoc tier tags on `defineCommand` calls and mirrors them into the runtime command's `tier` metadata field. + +## Install + +```bash +pnpm add -D @acture/build-tier +``` + +## tsup / esbuild + +```ts +// tsup.config.ts +import { defineConfig } from 'tsup'; +import { actureBuildTier } from '@acture/build-tier/esbuild'; + +export default defineConfig({ + entry: ['src/index.ts'], + esbuildPlugins: [actureBuildTier()], +}); +``` + +## Vite + +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import { actureBuildTierVite } from '@acture/build-tier/vite'; + +export default defineConfig({ + plugins: [actureBuildTierVite()], +}); +``` + +## What it does + +```ts +/** + * Search users by email or display name. + * @stable + */ +export const searchUsers = defineCommand({ + id: 'app.users.search', + title: 'Search Users', + execute: async (params) => { /* ... */ }, +}); +``` + +becomes (effectively): + +```ts +export const searchUsers = defineCommand({ + tier: "stable", + id: 'app.users.search', + title: 'Search Users', + execute: async (params) => { /* ... */ }, +}); +``` + +Four tags recognized: + +- `@stable` — `tier: 'stable'` +- `@experimental` — `tier: 'experimental'` +- `@deprecated [reason]` — `tier: 'deprecated'` plus `deprecationReason: ''` +- `@internal` — `tier: 'internal'` plus `internalToken: ` + +Precedence (most-restrictive wins): `@internal` > `@deprecated` > `@experimental` > `@stable`. + +The plugin is **idempotent**: if a spec already declares `tier:` explicitly, the JSDoc is ignored. So you can mix hand-written and tag-driven tiers without conflict. + +## `@internal` enforcement + +For each file containing at least one `@internal` command, the plugin injects a per-file module-scoped Symbol: + +```ts +const __actureInternalToken__ = /* @__PURE__ */ Symbol('acture.internal'); +``` + +Each `@internal` command's `internalToken` field references this Symbol. The runtime registry rejects `dispatch` calls from outside the module (i.e., from any caller that didn't capture the Symbol at module load). See [`acture-tier-system`](../../.claude/skills/acture-tier-system/SKILL.md) §7.5. + +## Caveats + +- Regex-based, not AST. The common case is recognized; exotic JSDoc-or-call shapes are silently ignored (the user's spec keeps its hand-written `tier`, defaulting to `'stable'`). +- 4000-char per-call lookahead window for the matching-brace scan. Real specs are O(20 lines); this is comfortably generous. +- No `.d.ts` mirror — the JSDoc tag survives into `.d.ts` natively (IDE hover shows it), but the resolved `tier` value lives in the JS runtime output only. diff --git a/packages/build-tier/package.json b/packages/build-tier/package.json new file mode 100644 index 0000000..9fabd55 --- /dev/null +++ b/packages/build-tier/package.json @@ -0,0 +1,71 @@ +{ + "name": "@acture/build-tier", + "version": "1.0.0", + "description": "Build-time tier mirror. Scans .ts source for @stable / @experimental / @internal / @deprecated JSDoc tags on defineCommand calls and mirrors them into the runtime command's tier metadata. Ships an esbuild plugin and a Vite plugin.", + "license": "Apache-2.0", + "author": "Thor Whalen", + "repository": { + "type": "git", + "url": "git+https://github.com/thorwhalen/acture.git", + "directory": "packages/build-tier" + }, + "homepage": "https://github.com/thorwhalen/acture#readme", + "bugs": "https://github.com/thorwhalen/acture/issues", + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./esbuild": { + "types": "./dist/esbuild.d.ts", + "import": "./dist/esbuild.js", + "require": "./dist/esbuild.cjs" + }, + "./vite": { + "types": "./dist/vite.d.ts", + "import": "./dist/vite.js", + "require": "./dist/vite.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "devDependencies": { + "@acture/mcp": "workspace:*", + "@types/node": "^22.10.0", + "acture": "workspace:*", + "tsup": "^8.3.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0", + "zod": "^4.0.0" + }, + "keywords": [ + "acture", + "tier-system", + "jsdoc", + "esbuild-plugin", + "vite-plugin", + "stable", + "experimental", + "deprecated", + "internal" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/build-tier/src/end-to-end.test.ts b/packages/build-tier/src/end-to-end.test.ts new file mode 100644 index 0000000..cb1ae6b --- /dev/null +++ b/packages/build-tier/src/end-to-end.test.ts @@ -0,0 +1,140 @@ +/** + * End-to-end smoke for the Phase 4 tier mirror. + * + * Phase 4 acceptance criteria 1–3: + * 1. A command tagged `@experimental` in source is auto-mirrored to + * `tier: 'experimental'` at build time. + * 2. `registry.toMCPServer()` (i.e. `buildToolsList`) excludes the + * experimental command from `tools/list`. + * 3. `registry.toMCPServer({ tiers: ['stable', 'experimental'] })` + * includes it. + * + * We exercise the whole chain: source with JSDoc → `transformSource` → + * `eval` the resulting JS → register the produced records → run the MCP + * `buildToolsList` projection. + * + * `eval` is normally a hard-don't (`acture-hard-donts` §5) but it is + * fine here: the test owns its input string entirely. + */ + +import { describe, it, expect } from 'vitest'; +import { createRegistry, defineCommand, ok } from 'acture'; +import { buildToolsList } from '@acture/mcp'; +import { transformSource } from './transform.js'; + +function transformAndEvalRegistry(): ReturnType { + const source = ` + /** @stable */ + const a = defineCommand({ + id: 'app.a', + title: 'A', + description: 'Stable A.', + execute: () => ({ ok: true, value: 'a' }), + }); + + /** @experimental */ + const b = defineCommand({ + id: 'app.b', + title: 'B', + description: 'Experimental B.', + execute: () => ({ ok: true, value: 'b' }), + }); + + /** @deprecated use app.a instead */ + const c = defineCommand({ + id: 'app.c', + title: 'C', + description: 'Old C.', + execute: () => ({ ok: true, value: 'c' }), + }); + + /** @internal */ + const d = defineCommand({ + id: 'app.d', + title: 'D', + description: 'Internal D.', + execute: () => ({ ok: true, value: 'd' }), + }); + + return { a, b, c, d }; + `; + const transformed = transformSource(source).code; + // The `defineCommand` imported here flows into the transformed source + // by closing over it in a Function constructor. We keep `ok` available + // for the `execute` bodies, though our toy specs don't use it. + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const make = new Function('defineCommand', 'ok', transformed) as ( + dc: typeof defineCommand, + okFn: typeof ok, + ) => { a: ReturnType; b: ReturnType; c: ReturnType; d: ReturnType }; + const { a, b, c, d } = make(defineCommand, ok); + const registry = createRegistry(); + registry.registerAll([a, b, c, d]); + return registry; +} + +describe('Phase 4 tier-mirror end-to-end', () => { + it('mirrors @stable / @experimental / @deprecated / @internal into tier metadata', () => { + const registry = transformAndEvalRegistry(); + expect(registry.get('app.a')!.tier).toBe('stable'); + expect(registry.get('app.b')!.tier).toBe('experimental'); + expect(registry.get('app.c')!.tier).toBe('deprecated'); + expect(registry.get('app.d')!.tier).toBe('internal'); + }); + + it('mirrors @deprecated into deprecationReason', () => { + const registry = transformAndEvalRegistry(); + expect(registry.get('app.c')!.deprecationReason).toBe('use app.a instead'); + }); + + it('AC2: buildToolsList() excludes the @experimental command by default', () => { + const registry = transformAndEvalRegistry(); + const tools = buildToolsList(registry); + const names = tools.map((t) => t.name); + expect(names).toContain('app.a'); + expect(names).not.toContain('app.b'); + expect(names).not.toContain('app.c'); + expect(names).not.toContain('app.d'); + }); + + it('AC3: buildToolsList({ tiers: [stable, experimental] }) includes the @experimental command', () => { + const registry = transformAndEvalRegistry(); + const tools = buildToolsList(registry, { tiers: ['stable', 'experimental'] }); + const names = tools.map((t) => t.name); + expect(names).toContain('app.b'); + }); + + it('AC4: deprecated description starts with [DEPRECATED — ]', () => { + const registry = transformAndEvalRegistry(); + const tools = buildToolsList(registry, { tiers: ['stable', 'deprecated'] }); + const dep = tools.find((t) => t.name === 'app.c'); + expect(dep?.description).toMatch(/^\[DEPRECATED — use app\.a instead\]/); + }); + + it('AC5: dispatching an @internal command from outside the registering module is rejected', async () => { + const registry = transformAndEvalRegistry(); + // The token is module-scoped to the eval'd module — we cannot see + // it from this test (which is "outside" the registering module). + const result = await registry.dispatch('app.d'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('internal_dispatch_denied'); + }); + + it('AC5: @internal IS dispatchable when the matching token is presented (in-module path)', async () => { + // For this assertion the test acts as if it WAS the registering + // module: it grabs the symbol the build-step injected and passes + // it on dispatch. End-users do not write this by hand — the + // injection makes the in-module call sites work transparently. + const registry = transformAndEvalRegistry(); + const cmd = registry.get('app.d')!; + const token = cmd.internalToken; + expect(token).toBeTypeOf('symbol'); + const result = await registry.dispatch( + 'app.d', + undefined, + undefined, + { internalToken: token }, + ); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/build-tier/src/esbuild.ts b/packages/build-tier/src/esbuild.ts new file mode 100644 index 0000000..bdbc21c --- /dev/null +++ b/packages/build-tier/src/esbuild.ts @@ -0,0 +1,59 @@ +/** + * esbuild plugin. Also works with tsup, which is esbuild under the hood. + * + * Usage in a consumer's tsup.config.ts: + * + * import { actureBuildTier } from '@acture/build-tier/esbuild'; + * export default defineConfig({ + * entry: ['src/index.ts'], + * esbuildPlugins: [actureBuildTier()], + * }); + * + * The plugin matches `.ts` and `.tsx` files inside the consumer's + * project (it deliberately does NOT touch `node_modules/`). + */ + +import { readFile } from 'node:fs/promises'; +import { transformSource } from './transform.js'; + +/** Minimal subset of the esbuild Plugin shape we depend on, to avoid + * pulling esbuild's types into our public surface. */ +export interface ESBuildPlugin { + name: string; + setup(build: { + onLoad( + options: { filter: RegExp; namespace?: string }, + callback: (args: { path: string }) => Promise< + | { contents: string; loader: 'ts' | 'tsx'; } + | undefined + > | { contents: string; loader: 'ts' | 'tsx'; } | undefined, + ): void; + }): void; +} + +export interface ActureBuildTierOptions { + /** File-path regex. Defaults to `/\\.tsx?$/`. */ + filter?: RegExp; + /** Skip transform for paths matching this regex. Defaults to a + * `node_modules` matcher. */ + exclude?: RegExp; +} + +/** esbuild / tsup plugin. */ +export function actureBuildTier(options: ActureBuildTierOptions = {}): ESBuildPlugin { + const filter = options.filter ?? /\.tsx?$/; + const exclude = options.exclude ?? /node_modules/; + return { + name: 'acture-build-tier', + setup(build) { + build.onLoad({ filter }, async (args) => { + if (exclude.test(args.path)) return undefined; + const buf = await readFile(args.path, 'utf8'); + const { code, changed } = transformSource(buf); + if (!changed) return undefined; + const loader: 'ts' | 'tsx' = args.path.endsWith('.tsx') ? 'tsx' : 'ts'; + return { contents: code, loader }; + }); + }, + }; +} diff --git a/packages/build-tier/src/index.ts b/packages/build-tier/src/index.ts new file mode 100644 index 0000000..196cf73 --- /dev/null +++ b/packages/build-tier/src/index.ts @@ -0,0 +1,30 @@ +/** + * `@acture/build-tier` — build-time tier mirror. + * + * Scans source for `@stable` / `@experimental` / `@internal` / + * `@deprecated [reason]` JSDoc tags on `defineCommand` calls and mirrors + * each tag into the runtime command's `tier` metadata field. For + * `@internal`, also injects a module-scoped `Symbol('acture.internal')` + * as the command's `internalToken` so `registry.dispatch` rejects + * cross-module calls. + * + * Two integration points are exported in separate entry points so users + * pay only for the bundler they use: + * + * - `@acture/build-tier/esbuild` → an esbuild plugin (also works with + * tsup, which is esbuild under the hood). + * - `@acture/build-tier/vite` → a Vite plugin. + * + * The pure transform is exported here for users who want to wire it + * into another bundler. See `acture-tier-system` skill §7. + */ + +export { + transformSource, + parseTierDirective, +} from './transform.js'; +export type { + Tier, + TierDirective, + TransformResult, +} from './transform.js'; diff --git a/packages/build-tier/src/transform.test.ts b/packages/build-tier/src/transform.test.ts new file mode 100644 index 0000000..549814c --- /dev/null +++ b/packages/build-tier/src/transform.test.ts @@ -0,0 +1,166 @@ +/** + * Transform tests. The transform is the load-bearing piece — the plugin + * wrappers are thin. Make sure each of the four tags is recognized, + * idempotency works, and idle files pass through untouched. + */ + +import { describe, it, expect } from 'vitest'; +import { parseTierDirective, transformSource } from './transform.js'; + +describe('parseTierDirective', () => { + it('parses @stable', () => { + expect(parseTierDirective('* Foo.\n * @stable')).toEqual({ tier: 'stable' }); + }); + + it('parses @experimental', () => { + expect(parseTierDirective('@experimental')).toEqual({ tier: 'experimental' }); + }); + + it('parses @internal', () => { + expect(parseTierDirective('@internal')).toEqual({ tier: 'internal' }); + }); + + it('parses @deprecated with reason', () => { + const out = parseTierDirective('@deprecated use app.new.thing instead'); + expect(out).toEqual({ tier: 'deprecated', reason: 'use app.new.thing instead' }); + }); + + it('parses bare @deprecated (no reason)', () => { + const out = parseTierDirective('Some doc.\n@deprecated'); + expect(out).toEqual({ tier: 'deprecated' }); + }); + + it('honours precedence: internal > deprecated > experimental > stable', () => { + expect(parseTierDirective('@stable @experimental')).toEqual({ tier: 'experimental' }); + expect(parseTierDirective('@experimental @deprecated soon')).toEqual({ + tier: 'deprecated', + reason: 'soon', + }); + expect(parseTierDirective('@deprecated @internal')).toEqual({ tier: 'internal' }); + }); + + it('returns undefined when no tag is present', () => { + expect(parseTierDirective('Just docs, no tier.')).toBeUndefined(); + }); +}); + +describe('transformSource', () => { + it('injects tier: stable into a stable-tagged defineCommand', () => { + const src = ` +/** + * Search. + * @stable + */ +export const search = defineCommand({ + id: 'app.search', + title: 'Search', + execute: () => ({ ok: true, value: null }), +}); +`; + const { code, applied } = transformSource(src); + expect(code).toContain("tier: \"stable\""); + expect(applied).toEqual([{ tier: 'stable' }]); + }); + + it('injects tier: experimental into an experimental-tagged call', () => { + const src = ` +/** @experimental */ +defineCommand({ id: 'app.x', title: 'X', execute: () => null }); +`; + const { code } = transformSource(src); + expect(code).toContain('tier: "experimental"'); + }); + + it('injects deprecationReason for @deprecated with reason', () => { + const src = ` +/** @deprecated use app.new.thing instead */ +defineCommand({ id: 'app.old', title: 'Old', execute: () => null }); +`; + const { code } = transformSource(src); + expect(code).toContain('tier: "deprecated"'); + expect(code).toContain('deprecationReason: "use app.new.thing instead"'); + }); + + it('injects internalToken and module-scope Symbol for @internal', () => { + const src = ` +/** @internal */ +defineCommand({ id: 'app.i', title: 'I', execute: () => null }); +`; + const { code } = transformSource(src); + expect(code).toContain("Symbol('acture.internal')"); + expect(code).toContain('__actureInternalToken__'); + expect(code).toContain('tier: "internal"'); + }); + + it('declares the internal token at most once even with multiple @internal commands', () => { + const src = ` +/** @internal */ +defineCommand({ id: 'app.a', title: 'A', execute: () => null }); +/** @internal */ +defineCommand({ id: 'app.b', title: 'B', execute: () => null }); +`; + const { code } = transformSource(src); + const count = (code.match(/__actureInternalToken__ = /g) ?? []).length; + expect(count).toBe(1); + }); + + it('is idempotent on a spec that already declares tier:', () => { + const src = ` +/** @experimental */ +defineCommand({ tier: 'stable', id: 'app.x', title: 'X', execute: () => null }); +`; + const { code, applied } = transformSource(src); + expect(applied).toEqual([]); + expect(code).toBe(src); + }); + + it('passes through files that do not call defineCommand', () => { + const src = `export const x = 1;\nexport function f() { return x; }`; + const { code, changed } = transformSource(src); + expect(code).toBe(src); + expect(changed).toBe(false); + }); + + it('passes through JSDoc that has no tier tag', () => { + const src = ` +/** Plain doc, no tier. */ +defineCommand({ id: 'app.x', title: 'X', execute: () => null }); +`; + const { code, applied } = transformSource(src); + expect(applied).toEqual([]); + expect(code).toBe(src); + }); + + it('handles JSDoc that is not adjacent to defineCommand', () => { + const src = ` +/** Some other doc. */ +const x = 1; + +defineCommand({ id: 'app.x', title: 'X', execute: () => null }); +`; + const { code, applied } = transformSource(src); + // The unrelated JSDoc has no tier tag; nothing should be transformed. + expect(applied).toEqual([]); + expect(code).toBe(src); + }); + + it('does not touch commands without a preceding JSDoc', () => { + const src = `defineCommand({ id: 'app.x', title: 'X', execute: () => null });`; + const { code, changed } = transformSource(src); + expect(changed).toBe(false); + expect(code).toBe(src); + }); + + it('handles JSDoc that has unrelated tags as well as @stable', () => { + const src = ` +/** + * @param x — something + * @returns y + * @stable + */ +defineCommand({ id: 'app.x', title: 'X', execute: () => null }); +`; + const { code } = transformSource(src); + expect(code).toContain('tier: "stable"'); + }); +}); diff --git a/packages/build-tier/src/transform.ts b/packages/build-tier/src/transform.ts new file mode 100644 index 0000000..6f024b1 --- /dev/null +++ b/packages/build-tier/src/transform.ts @@ -0,0 +1,242 @@ +/** + * Pure source-transform logic. The esbuild and Vite plugin wrappers + * import this and call it from their respective transform hooks. + * + * Strategy: regex-based, intentionally conservative. We match a JSDoc + * block immediately preceding a `defineCommand({ ... })` call, parse + * the tier tag(s) from the JSDoc, and inject the corresponding + * properties into the spec object literal. + * + * Why regex and not AST: the build step has to be fast (it runs on every + * .ts file), and the patterns we accept are deliberately narrow — JSDoc + * directly above the call site, no exotic syntax in between. If a user + * writes `defineCommand` in a way our regex can't see, they fall back to + * writing `tier: 'experimental'` explicitly in the spec — that path is + * documented as the manual fallback. + * + * The four recognized tags: + * @stable + * @experimental + * @internal + * @deprecated [reason text...] + * + * Tag precedence: if multiple appear in the same JSDoc, we honour the + * most-specific-restriction wins: internal > deprecated > experimental > stable. + */ + +export type Tier = 'stable' | 'experimental' | 'internal' | 'deprecated'; + +export interface TierDirective { + readonly tier: Tier; + /** Non-empty only for `@deprecated `. */ + readonly reason?: string; +} + +const TIER_PRECEDENCE: ReadonlyMap = new Map([ + ['stable', 0], + ['experimental', 1], + ['deprecated', 2], + ['internal', 3], +]); + +/** + * Parse a JSDoc block body (the text between `/**` and `* /` ) for tier + * tags. Returns the most-restrictive tag found, or `undefined` if none. + */ +export function parseTierDirective(jsdocBody: string): TierDirective | undefined { + let chosen: TierDirective | undefined; + // Strip the leading `*` on each line so multi-line JSDoc doesn't break + // the tag scanner. + const stripped = jsdocBody.replace(/^\s*\*\s?/gm, ''); + + const stableRe = /@stable\b/; + const experimentalRe = /@experimental\b/; + const internalRe = /@internal\b/; + // `@deprecated` may carry free-text reason on the same line. + const deprecatedRe = /@deprecated\b[ \t]*([^\n@]*)/; + + if (stableRe.test(stripped)) chosen = take(chosen, { tier: 'stable' }); + if (experimentalRe.test(stripped)) chosen = take(chosen, { tier: 'experimental' }); + const depMatch = deprecatedRe.exec(stripped); + if (depMatch) { + const reason = (depMatch[1] ?? '').trim(); + chosen = take(chosen, reason.length > 0 ? { tier: 'deprecated', reason } : { tier: 'deprecated' }); + } + if (internalRe.test(stripped)) chosen = take(chosen, { tier: 'internal' }); + + return chosen; +} + +function take( + current: TierDirective | undefined, + candidate: TierDirective, +): TierDirective { + if (!current) return candidate; + const cur = TIER_PRECEDENCE.get(current.tier) ?? 0; + const cand = TIER_PRECEDENCE.get(candidate.tier) ?? 0; + return cand > cur ? candidate : current; +} + +export interface TransformResult { + readonly code: string; + readonly changed: boolean; + /** Tier directives applied, in order — exposed for testing. */ + readonly applied: ReadonlyArray<{ tier: Tier; reason?: string }>; +} + +/** + * Find every `/** ... * /` JSDoc block immediately followed by a + * `defineCommand({ ... })` call, parse the tier tag(s), and inject the + * tier (plus deprecationReason / internalToken when applicable) into + * the spec object literal. + * + * - `tier` is injected at the head of the spec object. + * - `deprecationReason` is injected after `tier`. + * - `internalToken` references a module-scoped Symbol that is declared + * once at the top of the file (only when at least one `@internal` + * command is present in the file). + * + * Idempotent: if the spec already contains `tier:`, we leave it alone. + */ +export function transformSource(source: string): TransformResult { + // Skip files that obviously do not call defineCommand. + if (!source.includes('defineCommand')) { + return { code: source, changed: false, applied: [] }; + } + + const applied: Array<{ tier: Tier; reason?: string }> = []; + // Find every JSDoc block. We scan in order so insertion offsets remain + // valid as we rebuild the source incrementally. + const out: string[] = []; + let i = 0; + const len = source.length; + let internalCount = 0; + + while (i < len) { + const jsdocStart = source.indexOf('/**', i); + if (jsdocStart < 0) { + out.push(source.slice(i)); + break; + } + out.push(source.slice(i, jsdocStart)); + const jsdocEnd = source.indexOf('*/', jsdocStart + 3); + if (jsdocEnd < 0) { + out.push(source.slice(jsdocStart)); + break; + } + const jsdocBlock = source.slice(jsdocStart, jsdocEnd + 2); + out.push(jsdocBlock); + + // Look ahead for `defineCommand(` after optional whitespace, + // an optional `export ...`, an optional `const NAME = ` binding. + const afterDocStart = jsdocEnd + 2; + const lookahead = source.slice(afterDocStart, afterDocStart + 600); + // The pattern captures the slice up to and including the opening + // `{` of the spec object literal. + // Optional declaration prefix (e.g. `export const NAME = `), then + // `defineCommand({`. The declaration is optional so bare-call forms + // are recognized too (`defineCommand({...})` on its own line). + const dcRe = /^\s*(?:(?:export\s+)?(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*)?defineCommand\s*\(\s*\{/; + const m = dcRe.exec(lookahead); + if (!m) { + i = afterDocStart; + continue; + } + const tier = parseTierDirective(jsdocBlock.slice(3, -2)); + if (!tier) { + i = afterDocStart; + continue; + } + // Compute the absolute position right after the opening `{`. + // `m.index` is 0 (we anchored with `^`); `m[0].length` is the entire + // match, ending at the `{` (inclusive). + const openBraceAbs = afterDocStart + m[0].length; + // Detect if the spec already declares `tier:` — if so, leave it. + const restOfFile = source.slice(openBraceAbs, openBraceAbs + 4000); + if (/\btier\s*:/.test(restOfFile.slice(0, indexOfMatchingBrace(restOfFile)))) { + i = afterDocStart; + continue; + } + + // Emit everything between end-of-JSDoc and openBraceAbs. + out.push(source.slice(afterDocStart, openBraceAbs)); + + // Build the injected fields. + const injected: string[] = []; + injected.push(` tier: ${JSON.stringify(tier.tier)},`); + if (tier.tier === 'deprecated' && tier.reason !== undefined) { + injected.push(` deprecationReason: ${JSON.stringify(tier.reason)},`); + } + if (tier.tier === 'internal') { + injected.push(` internalToken: __actureInternalToken__,`); + internalCount++; + } + out.push(injected.join('')); + + applied.push(tier.reason !== undefined ? { tier: tier.tier, reason: tier.reason } : { tier: tier.tier }); + + i = openBraceAbs; + } + + let code = out.join(''); + if (internalCount > 0) { + code = INTERNAL_TOKEN_DECL + code; + } + return { code, changed: applied.length > 0, applied }; +} + +/** Declared at module top so every `@internal` command in the file + * shares it. Cross-module callers cannot see it. */ +const INTERNAL_TOKEN_DECL = + "const __actureInternalToken__ = /* @__PURE__ */ Symbol('acture.internal');\n"; + +/** + * Find the offset of the matching closing brace for a string that + * starts immediately after an opening `{` (the `{` is NOT in `text`). + * Returns the index of the `}` (relative to `text`) on success, or + * `text.length` if no balanced close is found. + * + * The scanner is intentionally minimal — it tracks nested `{}` only. + * It treats string and template literals as opaque (skips matching + * braces inside them) to a first approximation: handles single-quoted, + * double-quoted, and backtick strings; does NOT handle template + * substitutions perfectly (a `${` inside a backtick may foil it). The + * defineCommand spec object is shallow enough that this is fine in + * practice; users with exotic templates can write `tier: 'X'` manually. + */ +function indexOfMatchingBrace(text: string): number { + let depth = 1; + let i = 0; + const len = text.length; + while (i < len) { + const ch = text[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + const quote = ch; + i++; + while (i < len) { + if (text[i] === '\\') { i += 2; continue; } + if (text[i] === quote) { i++; break; } + i++; + } + continue; + } + if (ch === '/' && text[i + 1] === '/') { + // line comment + while (i < len && text[i] !== '\n') i++; + continue; + } + if (ch === '/' && text[i + 1] === '*') { + i += 2; + while (i < len && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + i++; + } + return len; +} diff --git a/packages/build-tier/src/vite.ts b/packages/build-tier/src/vite.ts new file mode 100644 index 0000000..8476385 --- /dev/null +++ b/packages/build-tier/src/vite.ts @@ -0,0 +1,53 @@ +/** + * Vite plugin. Mirrors the esbuild plugin for `vite` consumers (the + * greenfield example uses Vite). + * + * Usage in vite.config.ts: + * + * import { actureBuildTierVite } from '@acture/build-tier/vite'; + * export default defineConfig({ + * plugins: [actureBuildTierVite()], + * }); + * + * Vite runs `transform` hooks for every loaded module; we filter to + * `.ts` / `.tsx` under the consumer's source tree and skip + * `node_modules`. + */ + +import { transformSource } from './transform.js'; + +/** Minimal Vite plugin shape. Avoids pulling in vite as a dep. */ +export interface VitePlugin { + name: string; + enforce?: 'pre' | 'post'; + transform?(code: string, id: string): { code: string; map: null } | undefined; +} + +export interface ActureBuildTierViteOptions { + /** File-path regex. Defaults to `/\\.tsx?$/`. */ + filter?: RegExp; + /** Skip transform for paths matching this regex. Defaults to a + * `node_modules` matcher. */ + exclude?: RegExp; +} + +/** Vite plugin wrapper. Marked `enforce: 'pre'` so the transform runs + * before Vite's own TypeScript transform — the JSDoc must still be in + * the source when we scan. */ +export function actureBuildTierVite( + options: ActureBuildTierViteOptions = {}, +): VitePlugin { + const filter = options.filter ?? /\.tsx?$/; + const exclude = options.exclude ?? /node_modules/; + return { + name: 'acture-build-tier', + enforce: 'pre', + transform(code, id) { + if (!filter.test(id)) return undefined; + if (exclude.test(id)) return undefined; + const result = transformSource(code); + if (!result.changed) return undefined; + return { code: result.code, map: null }; + }, + }; +} diff --git a/packages/build-tier/tsconfig.json b/packages/build-tier/tsconfig.json new file mode 100644 index 0000000..e63ebf2 --- /dev/null +++ b/packages/build-tier/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/build-tier/tsup.config.ts b/packages/build-tier/tsup.config.ts new file mode 100644 index 0000000..954f079 --- /dev/null +++ b/packages/build-tier/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/esbuild.ts', 'src/vite.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: 'es2022', + outDir: 'dist', +}); diff --git a/packages/build-tier/vitest.config.ts b/packages/build-tier/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/packages/build-tier/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..2725377 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,107 @@ +# @acture/cli + +The `acture` binary. Two subcommands today; the package is intentionally small. + +## Install + +```bash +pnpm add -D @acture/cli +``` + +## `acture compare-schemas` + +Diff two registry snapshots and classify changes per research-5 §6.1. CI gate. + +```bash +# diff two snapshot files +acture compare-schemas base.json head.json + +# diff two git refs (reads .acture/snapshot.json from each) +acture compare-schemas v1.0.0 HEAD + +# CI gate: exit non-zero on MAJOR +acture compare-schemas v1.0.0 HEAD --fail-on major + +# description edits are MAJOR by default (research-5 §6.2); +# allow them at branch time only (NOT a config-file setting): +acture compare-schemas main --allow-description-edits + +# machine-readable output +acture compare-schemas base.json head.json --format json +``` + +Classifications: + +| Change | Severity | +| --- | --- | +| Command removed | MAJOR | +| Input field removed / type narrowed / required-tightened | MAJOR | +| New required input field | MAJOR | +| Enum value removed | MAJOR | +| Description changed | MAJOR (downgradable via `--allow-description-edits`) | +| Tier downgrade (stable → experimental) | MAJOR | +| Alias removed / `when` changed | MAJOR | +| New optional input field / enum value added | MINOR | +| New command / tier upgrade / `@deprecated` added | MINOR | + +The deprecation banner (`[DEPRECATED — ]`) is stripped before comparing descriptions so the banner addition itself doesn't double-flag. + +## `acture snapshot` + +Load a registry config and emit a JSON snapshot. Use this to produce the baseline that `compare-schemas` reads. + +```bash +# write a snapshot to stdout +acture snapshot ./registry.mjs + +# write to a file (creates parent dirs if needed) +acture snapshot ./registry.mjs --out .acture/snapshot.json + +# filter tiers (default: all) +acture snapshot ./registry.mjs --tiers stable,experimental +``` + +The config module must default-export an acture `Registry` (or `Promise`): + +```js +// registry.mjs +import { createRegistry, defineCommand, ok } from 'acture'; +import { buildCommands } from './commands/index.js'; + +const registry = createRegistry(); +registry.registerAll(buildCommands()); +export default registry; +``` + +**TypeScript configs:** Node ≥22.6 with `--experimental-strip-types` works directly. Otherwise: + +```bash +npx tsx node_modules/.bin/acture snapshot ./registry.ts --out .acture/snapshot.json +``` + +## CI recipe + +```yaml +# .github/workflows/schema-check.yml +- run: pnpm install +- run: pnpm build +- run: npx acture snapshot ./registry.mjs --out .acture/snapshot.json +- run: npx acture compare-schemas $LAST_RELEASE_TAG HEAD --fail-on major +``` + +`$LAST_RELEASE_TAG` is `git describe --tags --abbrev=0` or similar. The snapshot at the release tag should be committed alongside the tag so the CLI can read it via `git show $tag:.acture/snapshot.json`. + +## Programmatic API + +The same building blocks are exported from the package root: + +```ts +import { + snapshotRegistry, + classifyChanges, + formatResult, + runSnapshotCmd, +} from '@acture/cli'; +``` + +See [`docs/research/acture_research_5 -- Schema Versioning ...md`](../../docs/research/) §6 for the canonical design. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..42e5ebb --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,64 @@ +{ + "name": "@acture/cli", + "version": "1.1.0", + "description": "Acture CLI. Includes `compare-schemas` — diff two registry snapshots (per research-5 §6) with classifications, CI gating, and JSON/text output.", + "license": "Apache-2.0", + "author": "Thor Whalen", + "repository": { + "type": "git", + "url": "git+https://github.com/thorwhalen/acture.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/thorwhalen/acture#readme", + "bugs": "https://github.com/thorwhalen/acture/issues", + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "bin": { + "acture": "./dist/cli.js" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "peerDependencies": { + "acture": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "acture": "workspace:*", + "tsup": "^8.3.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0", + "zod": "^4.0.0" + }, + "keywords": [ + "acture", + "cli", + "compare-schemas", + "schema-diff", + "mcp", + "ci" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/cli/src/classify.test.ts b/packages/cli/src/classify.test.ts new file mode 100644 index 0000000..dbfa55a --- /dev/null +++ b/packages/cli/src/classify.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { classifyChanges } from './classify.js'; +import type { Snapshot } from './snapshot.js'; + +function snap(...tools: Snapshot['tools']): Snapshot { + return { version: 1, generator: 'test', tools }; +} + +function tool(name: string, overrides: Partial = {}): Snapshot['tools'][number] { + return { + name, + description: 'desc', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + tier: 'stable', + deprecationReason: null, + aliases: [], + when: null, + ...overrides, + }; +} + +describe('classifyChanges', () => { + it('classifies a removed command as MAJOR', () => { + const base = snap(tool('app.gone'), tool('app.kept')); + const head = snap(tool('app.kept')); + const result = classifyChanges(base, head); + expect(result.maxSeverity).toBe('major'); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]!.kind).toBe('command-removed'); + }); + + it('classifies a new command as MINOR', () => { + const base = snap(tool('app.a')); + const head = snap(tool('app.a'), tool('app.b')); + const result = classifyChanges(base, head); + expect(result.maxSeverity).toBe('minor'); + expect(result.changes[0]!.kind).toBe('command-added'); + }); + + it('classifies a description change as MAJOR by default', () => { + const base = snap(tool('app.a', { description: 'Old.' })); + const head = snap(tool('app.a', { description: 'New.' })); + const result = classifyChanges(base, head); + expect(result.maxSeverity).toBe('major'); + expect(result.changes[0]!.kind).toBe('description-changed'); + }); + + it('downgrades description changes to MINOR with --allow-description-edits', () => { + const base = snap(tool('app.a', { description: 'Old.' })); + const head = snap(tool('app.a', { description: 'New.' })); + const result = classifyChanges(base, head, { allowDescriptionEdits: true }); + expect(result.maxSeverity).toBe('minor'); + expect(result.changes[0]!.severity).toBe('minor'); + }); + + it('still flags structural changes as MAJOR even with --allow-description-edits', () => { + const base = snap( + tool('app.a', { + description: 'Old.', + inputSchema: { type: 'object', properties: { q: { type: 'string' } } }, + }), + ); + const head = snap( + tool('app.a', { + description: 'New.', + inputSchema: { type: 'object', properties: {} }, + }), + ); + const result = classifyChanges(base, head, { allowDescriptionEdits: true }); + expect(result.maxSeverity).toBe('major'); + const kinds = result.changes.map((c) => c.kind).sort(); + expect(kinds).toEqual(['description-changed', 'input-field-removed']); + }); + + it('strips [DEPRECATED — reason] banner from description before comparing', () => { + const base = snap(tool('app.a', { description: 'Original.' })); + const head = snap( + tool('app.a', { + description: '[DEPRECATED — use app.b instead] Original.', + tier: 'deprecated', + deprecationReason: 'use app.b instead', + }), + ); + const result = classifyChanges(base, head); + // Tier transition should fire as `deprecated-added` (MINOR), and the + // description-with-banner-stripped is unchanged so no + // description-changed event. + const kinds = result.changes.map((c) => c.kind); + expect(kinds).toContain('deprecated-added'); + expect(kinds).not.toContain('description-changed'); + }); + + it('classifies a new required field as MAJOR', () => { + const base = snap(tool('app.a', { inputSchema: { type: 'object', properties: {}, required: [] } })); + const head = snap( + tool('app.a', { + inputSchema: { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }, + }), + ); + const result = classifyChanges(base, head); + expect(result.maxSeverity).toBe('major'); + expect(result.changes.some((c) => c.kind === 'input-field-added-required')).toBe(true); + }); + + it('classifies a new optional field as MINOR', () => { + const base = snap(tool('app.a', { inputSchema: { type: 'object', properties: {} } })); + const head = snap( + tool('app.a', { inputSchema: { type: 'object', properties: { q: { type: 'string' } } } }), + ); + const result = classifyChanges(base, head); + expect(result.maxSeverity).toBe('minor'); + expect(result.changes[0]!.kind).toBe('input-field-added-optional'); + }); + + it('classifies field becoming required as MAJOR', () => { + const base = snap( + tool('app.a', { + inputSchema: { + type: 'object', + properties: { q: { type: 'string' } }, + required: [], + }, + }), + ); + const head = snap( + tool('app.a', { + inputSchema: { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }, + }), + ); + const result = classifyChanges(base, head); + expect(result.changes.some((c) => c.kind === 'input-field-required-tightened')).toBe(true); + expect(result.maxSeverity).toBe('major'); + }); + + it('classifies field type narrowing as MAJOR', () => { + const base = snap( + tool('app.a', { + inputSchema: { type: 'object', properties: { q: { type: 'string' } } }, + }), + ); + const head = snap( + tool('app.a', { + inputSchema: { type: 'object', properties: { q: { type: 'number' } } }, + }), + ); + const result = classifyChanges(base, head); + expect(result.changes.some((c) => c.kind === 'input-field-type-narrowed')).toBe(true); + }); + + it('classifies enum value removal as MAJOR, addition as MINOR', () => { + const base = snap( + tool('app.a', { + inputSchema: { + type: 'object', + properties: { color: { type: 'string', enum: ['red', 'green', 'blue'] } }, + }, + }), + ); + const head = snap( + tool('app.a', { + inputSchema: { + type: 'object', + properties: { color: { type: 'string', enum: ['green', 'blue', 'yellow'] } }, + }, + }), + ); + const result = classifyChanges(base, head); + expect(result.changes.some((c) => c.kind === 'enum-value-removed')).toBe(true); + expect(result.changes.some((c) => c.kind === 'enum-value-added')).toBe(true); + expect(result.maxSeverity).toBe('major'); + }); + + it('classifies tier downgrade stable → experimental as MAJOR', () => { + const base = snap(tool('app.a', { tier: 'stable' })); + const head = snap(tool('app.a', { tier: 'experimental' })); + const result = classifyChanges(base, head); + expect(result.changes[0]!.kind).toBe('tier-downgraded'); + expect(result.maxSeverity).toBe('major'); + }); + + it('classifies tier upgrade experimental → stable as MINOR', () => { + const base = snap(tool('app.a', { tier: 'experimental' })); + const head = snap(tool('app.a', { tier: 'stable' })); + const result = classifyChanges(base, head); + expect(result.changes[0]!.kind).toBe('tier-upgraded'); + expect(result.maxSeverity).toBe('minor'); + }); + + it('classifies removed alias as MAJOR', () => { + const base = snap(tool('app.a', { aliases: ['foo', 'bar'] })); + const head = snap(tool('app.a', { aliases: ['foo'] })); + const result = classifyChanges(base, head); + expect(result.changes[0]!.kind).toBe('alias-removed'); + expect(result.maxSeverity).toBe('major'); + }); + + it('returns none for identical snapshots', () => { + const t = tool('app.a'); + const result = classifyChanges(snap(t), snap(t)); + expect(result.maxSeverity).toBe('none'); + expect(result.changes).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/classify.ts b/packages/cli/src/classify.ts new file mode 100644 index 0000000..e9c1e5b --- /dev/null +++ b/packages/cli/src/classify.ts @@ -0,0 +1,363 @@ +/** + * Change classifier per research-5 §6.1. + * + * Given a base snapshot and a head snapshot, walk every command and + * every input-field, classifying each change as `none | minor | major`. + * The output is a flat list of `Change` records; the CLI then formats + * them as text or JSON and decides exit code based on `--fail-on`. + * + * Classification table (default severities): + * Command removed → MAJOR + * Command added (new in head) → MINOR + * Input field removed → MAJOR + * Input field type changed (narrowed) → MAJOR + * New required input field → MAJOR + * New optional input field → MINOR + * Description text changed → MAJOR (downgradable to MINOR) + * Tier downgrade (stable→experimental) → MAJOR + * Tier upgrade (experimental→stable) → MINOR + * @deprecated added → MINOR + * Alias removed → MAJOR + * Alias added → MINOR + * when-clause changed → MAJOR (conservative) + * Enum value removed → MAJOR + * Enum value added → MINOR + */ + +import type { Snapshot, SnapshotTool } from './snapshot.js'; + +export type Severity = 'none' | 'minor' | 'major'; + +export interface Change { + readonly tool: string; + readonly path: string; + readonly kind: ChangeKind; + readonly severity: Severity; + readonly summary: string; + readonly details?: Record; +} + +export type ChangeKind = + | 'command-removed' + | 'command-added' + | 'input-field-removed' + | 'input-field-added-required' + | 'input-field-added-optional' + | 'input-field-required-tightened' + | 'input-field-type-narrowed' + | 'description-changed' + | 'tier-downgraded' + | 'tier-upgraded' + | 'deprecated-added' + | 'alias-removed' + | 'alias-added' + | 'when-changed' + | 'enum-value-removed' + | 'enum-value-added'; + +export interface ClassifyOptions { + /** Downgrade description-only changes from MAJOR to MINOR. Per + * research-5 §6.2 this is a per-invocation escape, never global. */ + readonly allowDescriptionEdits?: boolean; +} + +export interface ClassifyResult { + readonly changes: readonly Change[]; + /** Max severity present in `changes`. */ + readonly maxSeverity: Severity; +} + +export function classifyChanges( + base: Snapshot, + head: Snapshot, + options: ClassifyOptions = {}, +): ClassifyResult { + const baseByName = indexByName(base.tools); + const headByName = indexByName(head.tools); + const allNames = new Set([...baseByName.keys(), ...headByName.keys()]); + const changes: Change[] = []; + + for (const name of allNames) { + const b = baseByName.get(name); + const h = headByName.get(name); + if (b && !h) { + changes.push({ + tool: name, + path: '', + kind: 'command-removed', + severity: 'major', + summary: `Command "${name}" removed`, + }); + continue; + } + if (!b && h) { + changes.push({ + tool: name, + path: '', + kind: 'command-added', + severity: 'minor', + summary: `Command "${name}" added`, + }); + continue; + } + if (b && h) diffCommand(b, h, options, changes); + } + + const maxSeverity: Severity = changes.reduce( + (acc, c) => (severityRank(c.severity) > severityRank(acc) ? c.severity : acc), + 'none', + ); + + return { changes, maxSeverity }; +} + +function indexByName(tools: readonly SnapshotTool[]): Map { + const out = new Map(); + for (const t of tools) out.set(t.name, t); + return out; +} + +function diffCommand( + base: SnapshotTool, + head: SnapshotTool, + options: ClassifyOptions, + out: Change[], +): void { + // Tier transitions. + if (base.tier !== head.tier) { + if (head.tier === 'deprecated' && base.tier !== 'deprecated') { + out.push({ + tool: head.name, + path: 'tier', + kind: 'deprecated-added', + severity: 'minor', + summary: `Command "${head.name}" marked @deprecated`, + details: { from: base.tier, to: head.tier }, + }); + } else if (severityOfTierTransition(base.tier, head.tier) === 'major') { + out.push({ + tool: head.name, + path: 'tier', + kind: 'tier-downgraded', + severity: 'major', + summary: `Command "${head.name}" tier downgraded: ${base.tier} → ${head.tier}`, + details: { from: base.tier, to: head.tier }, + }); + } else { + out.push({ + tool: head.name, + path: 'tier', + kind: 'tier-upgraded', + severity: 'minor', + summary: `Command "${head.name}" tier upgraded: ${base.tier} → ${head.tier}`, + details: { from: base.tier, to: head.tier }, + }); + } + } + + // Description. Strip the deterministic `[DEPRECATED ...]` banner + // before comparing so the addition of the banner doesn't itself + // register as a description change (the tier transition already did). + const baseDesc = stripDeprecationBanner(base.description ?? ''); + const headDesc = stripDeprecationBanner(head.description ?? ''); + if (baseDesc !== headDesc) { + out.push({ + tool: head.name, + path: 'description', + kind: 'description-changed', + severity: options.allowDescriptionEdits ? 'minor' : 'major', + summary: `Command "${head.name}" description changed`, + details: { from: baseDesc, to: headDesc }, + }); + } + + // Aliases. + const baseAliases = new Set(base.aliases); + const headAliases = new Set(head.aliases); + for (const a of baseAliases) { + if (!headAliases.has(a)) { + out.push({ + tool: head.name, + path: 'aliases', + kind: 'alias-removed', + severity: 'major', + summary: `Command "${head.name}" alias "${a}" removed`, + }); + } + } + for (const a of headAliases) { + if (!baseAliases.has(a)) { + out.push({ + tool: head.name, + path: 'aliases', + kind: 'alias-added', + severity: 'minor', + summary: `Command "${head.name}" alias "${a}" added`, + }); + } + } + + // when-clause. We treat any change as MAJOR conservatively (we don't + // have a way to detect "broadened" vs "narrowed" without parsing the + // DSL semantically — research-5 §6.1 calls this out). Function-form + // when-clauses surface as `""`. + if ((base.when ?? null) !== (head.when ?? null)) { + out.push({ + tool: head.name, + path: 'when', + kind: 'when-changed', + severity: 'major', + summary: `Command "${head.name}" when-clause changed`, + details: { from: base.when, to: head.when }, + }); + } + + // Input schema field-level diff. + diffInputSchema(head.name, base.inputSchema, head.inputSchema, out); +} + +/** Walk `properties` at the top level of each schema and classify + * added / removed / required-tightened. We deliberately keep this + * shallow for v1 — deeply nested object diffs are a v1.1 polish. */ +function diffInputSchema( + toolName: string, + baseSchema: Record, + headSchema: Record, + out: Change[], +): void { + const baseProps = ((baseSchema['properties'] ?? {}) as Record); + const headProps = ((headSchema['properties'] ?? {}) as Record); + const baseRequired = new Set( + Array.isArray(baseSchema['required']) ? baseSchema['required'] as string[] : [], + ); + const headRequired = new Set( + Array.isArray(headSchema['required']) ? headSchema['required'] as string[] : [], + ); + + const allKeys = new Set([ + ...Object.keys(baseProps), + ...Object.keys(headProps), + ]); + for (const key of allKeys) { + const b = baseProps[key]; + const h = headProps[key]; + if (b !== undefined && h === undefined) { + out.push({ + tool: toolName, + path: `inputSchema.properties.${key}`, + kind: 'input-field-removed', + severity: 'major', + summary: `Command "${toolName}" input field "${key}" removed`, + }); + continue; + } + if (b === undefined && h !== undefined) { + const isRequired = headRequired.has(key); + out.push({ + tool: toolName, + path: `inputSchema.properties.${key}`, + kind: isRequired + ? 'input-field-added-required' + : 'input-field-added-optional', + severity: isRequired ? 'major' : 'minor', + summary: `Command "${toolName}" input field "${key}" added (${isRequired ? 'required' : 'optional'})`, + }); + continue; + } + if (b !== undefined && h !== undefined) { + // Required-tightened: was optional, now required. + const wasReq = baseRequired.has(key); + const isReq = headRequired.has(key); + if (!wasReq && isReq) { + out.push({ + tool: toolName, + path: `inputSchema.properties.${key}`, + kind: 'input-field-required-tightened', + severity: 'major', + summary: `Command "${toolName}" input field "${key}" was optional, now required`, + }); + } + // Type narrowing — shallow. Compare the `type` key. If it changed + // from one primitive to another, count as a narrow. Set-theoretic + // type widening is rare in practice; treat ANY type change as + // narrowing for v1. + const bType = (b as { type?: unknown }).type; + const hType = (h as { type?: unknown }).type; + if (bType !== undefined && hType !== undefined && JSON.stringify(bType) !== JSON.stringify(hType)) { + out.push({ + tool: toolName, + path: `inputSchema.properties.${key}.type`, + kind: 'input-field-type-narrowed', + severity: 'major', + summary: `Command "${toolName}" input field "${key}" type changed: ${JSON.stringify(bType)} → ${JSON.stringify(hType)}`, + }); + } + // Enum diff. + const bEnum = (b as { enum?: unknown }).enum; + const hEnum = (h as { enum?: unknown }).enum; + if (Array.isArray(bEnum) && Array.isArray(hEnum)) { + const bSet = new Set(bEnum.map((v) => JSON.stringify(v))); + const hSet = new Set(hEnum.map((v) => JSON.stringify(v))); + for (const v of bSet) { + if (!hSet.has(v)) { + out.push({ + tool: toolName, + path: `inputSchema.properties.${key}.enum`, + kind: 'enum-value-removed', + severity: 'major', + summary: `Command "${toolName}" enum value ${v} removed from "${key}"`, + }); + } + } + for (const v of hSet) { + if (!bSet.has(v)) { + out.push({ + tool: toolName, + path: `inputSchema.properties.${key}.enum`, + kind: 'enum-value-added', + severity: 'minor', + summary: `Command "${toolName}" enum value ${v} added to "${key}"`, + }); + } + } + } + } + } +} + +const TIER_RANK: Record = { + experimental: 0, + deprecated: 1, + stable: 2, + internal: -1, +}; + +function severityOfTierTransition( + from: SnapshotTool['tier'], + to: SnapshotTool['tier'], +): Severity { + // Internal commands should never appear in snapshots (the registry + // omits them by default); be defensive. + if (from === 'internal' || to === 'internal') return 'major'; + if (from === to) return 'none'; + const f = TIER_RANK[from]; + const t = TIER_RANK[to]; + if (t > f) return 'minor'; + return 'major'; +} + +const SEVERITY_RANK: Record = { none: 0, minor: 1, major: 2 }; +function severityRank(s: Severity): number { + return SEVERITY_RANK[s]; +} + +/** Strip the deterministic `[DEPRECATED ...]` banner. Banner formats: + * + * [DEPRECATED] free text + * [DEPRECATED — some reason] free text + * + * The banner is regenerated at projection time and is not itself a + * description change. */ +function stripDeprecationBanner(s: string): string { + return s.replace(/^\[DEPRECATED(?:\s+—[^\]]*)?\]\s*/u, ''); +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 0000000..9793716 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,183 @@ +/** + * End-to-end CLI integration tests. Spawns the actual CLI script with + * temp-file fixtures so we exercise the same code path users will hit. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const CLI_ENTRY = new URL('./cli.ts', import.meta.url).pathname; +const NODE_LOADER_ARGS = ['--import', 'tsx']; + +function runCli(args: string[]): { code: number; stdout: string; stderr: string } { + const result = spawnSync('node', [...NODE_LOADER_ARGS, CLI_ENTRY, ...args], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + }); + return { + code: result.status ?? -1, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'acture-cli-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function writeSnapshot(name: string, tools: unknown[]): string { + const path = join(tmp, name); + writeFileSync( + path, + JSON.stringify({ version: 1, generator: 'test', tools }, null, 2), + ); + return path; +} + +const baseTool = { + name: 'app.search', + description: 'Search the corpus.', + inputSchema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] }, + tier: 'stable', + deprecationReason: null, + aliases: [], + when: null, +}; + +describe('acture compare-schemas CLI', () => { + it('reports MAJOR when a command is removed', () => { + const base = writeSnapshot('removed-base.json', [baseTool, { ...baseTool, name: 'app.gone' }]); + const head = writeSnapshot('removed-head.json', [baseTool]); + const r = runCli(['compare-schemas', base, head]); + expect(r.code).toBe(0); // no --fail-on, exit clean + expect(r.stdout).toMatch(/command-removed/); + expect(r.stdout).toMatch(/Max severity: major/); + }); + + it('exits non-zero with --fail-on major when MAJOR changes are present', () => { + const base = writeSnapshot('fail-base.json', [baseTool, { ...baseTool, name: 'app.gone' }]); + const head = writeSnapshot('fail-head.json', [baseTool]); + const r = runCli(['compare-schemas', base, head, '--fail-on', 'major']); + expect(r.code).toBe(1); + }); + + it('exits zero with --fail-on major when only MINOR changes exist', () => { + const base = writeSnapshot('ok-base.json', [baseTool]); + const head = writeSnapshot('ok-head.json', [ + baseTool, + { ...baseTool, name: 'app.new' }, + ]); + const r = runCli(['compare-schemas', base, head, '--fail-on', 'major']); + expect(r.code).toBe(0); + }); + + it('--allow-description-edits downgrades description-only diffs to MINOR', () => { + const base = writeSnapshot('desc-base.json', [baseTool]); + const head = writeSnapshot('desc-head.json', [ + { ...baseTool, description: 'Search the corpus more cleverly.' }, + ]); + const r = runCli([ + 'compare-schemas', + base, + head, + '--allow-description-edits', + '--fail-on', + 'major', + ]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/description-changed/); + expect(r.stdout).toMatch(/MINOR/); + }); + + it('without --allow-description-edits, description changes are MAJOR', () => { + const base = writeSnapshot('desc2-base.json', [baseTool]); + const head = writeSnapshot('desc2-head.json', [ + { ...baseTool, description: 'Different.' }, + ]); + const r = runCli([ + 'compare-schemas', + base, + head, + '--fail-on', + 'major', + ]); + expect(r.code).toBe(1); + expect(r.stdout).toMatch(/MAJOR/); + }); + + it('emits JSON when --format json is given', () => { + const base = writeSnapshot('json-base.json', [baseTool]); + const head = writeSnapshot('json-head.json', []); + const r = runCli(['compare-schemas', base, head, '--format', 'json']); + const parsed = JSON.parse(r.stdout); + expect(parsed.maxSeverity).toBe('major'); + expect(parsed.changes[0].kind).toBe('command-removed'); + }); + + it('--help prints usage including snapshot subcommand', () => { + const r = runCli(['--help']); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/compare-schemas/); + expect(r.stdout).toMatch(/snapshot/); + }); +}); + +describe('acture snapshot CLI', () => { + const ACTURE_URL = new URL('../../core/dist/index.js', import.meta.url).href; + + it('emits a snapshot for a config module', () => { + const config = join(tmp, 'cli-config.mjs'); + writeFileSync( + config, + ` +import { createRegistry, defineCommand, ok } from '${ACTURE_URL}'; +const r = createRegistry(); +r.register(defineCommand({ id: 'app.x', title: 'X', execute: () => ok(null) })); +export default r; +`, + 'utf8', + ); + const r = runCli(['snapshot', config]); + expect(r.code).toBe(0); + const parsed = JSON.parse(r.stdout); + expect(parsed.version).toBe(1); + expect(parsed.tools[0].name).toBe('app.x'); + }); + + it('writes to --out file and prints a confirmation', () => { + const config = join(tmp, 'cli-config-out.mjs'); + writeFileSync( + config, + ` +import { createRegistry, defineCommand, ok } from '${ACTURE_URL}'; +const r = createRegistry(); +r.register(defineCommand({ id: 'app.x', title: 'X', execute: () => ok(null) })); +export default r; +`, + 'utf8', + ); + const outPath = join(tmp, 'cli-snap-out.json'); + const r = runCli(['snapshot', config, '--out', outPath]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/snapshot written/); + }); + + it('returns 2 for unknown subcommand', () => { + const r = runCli(['no-such-cmd']); + expect(r.code).toBe(2); + expect(r.stderr).toMatch(/unknown subcommand/); + }); + + it('returns 2 when snapshot config is missing', () => { + const r = runCli(['snapshot']); + expect(r.code).toBe(2); + expect(r.stderr).toMatch(/missing /); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..10a4e6f --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,238 @@ +/** + * `acture` CLI entry. Subcommands: + * + * acture compare-schemas [] [options] + * acture snapshot (post-v1; placeholder error in v1.0) + * + * Options for compare-schemas: + * --fail-on minor|major (default: never fail) + * --allow-description-edits downgrade description-only diffs to MINOR + * --format output format (default: text) + * --snapshot-path ref-relative snapshot path (default: .acture/snapshot.json) + * --no-color disable ANSI color in text output + * + * The parser is hand-rolled. We deliberately avoid commander/yargs to + * keep the install lean (`npx acture` should be quick) and avoid + * argument-injection surprises. + */ + +import { classifyChanges, type Severity } from './classify.js'; +import { formatResult, type OutputFormat } from './format.js'; +import { loadSnapshot } from './load.js'; +import { runSnapshotCmd } from './snapshot-cmd.js'; +import type { SnapshotTool } from './snapshot.js'; + +const HELP = `acture — typed schema-driven command dispatch + +Usage: + acture compare-schemas [] [options] + acture snapshot [options] + acture --help + +compare-schemas options: + --fail-on Exit non-zero on this severity or higher (minor|major). + --allow-description-edits Downgrade description-only diffs to MINOR (per-invocation). + --format Output format. Default: text. + --snapshot-path Snapshot path when args are git refs. Default: .acture/snapshot.json. + --no-color Disable ANSI color in text output. + +snapshot options: + --out Write JSON to this file (default: stdout). + --tiers Comma-separated tiers to include (default: all). + One of: stable, experimental, deprecated, internal, all. + +Examples: + acture compare-schemas v0.9.0 HEAD + acture compare-schemas base.json head.json --fail-on major --format json + acture compare-schemas main --allow-description-edits + + acture snapshot ./registry.mjs --out .acture/snapshot.json + acture snapshot ./registry.mjs --tiers stable,experimental + +Docs: https://github.com/thorwhalen/acture#readme +`; + +const argv = process.argv.slice(2); + +if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + process.stdout.write(HELP); + process.exit(0); +} + +const subcommand = argv[0]; + +if (subcommand === 'compare-schemas') { + runCompareSchemas(argv.slice(1)); +} else if (subcommand === 'snapshot') { + void runSnapshotFromArgs(argv.slice(1)); +} else { + process.stderr.write(`acture: unknown subcommand "${subcommand}"\n\n`); + process.stderr.write(HELP); + process.exit(2); +} + +async function runSnapshotFromArgs(args: readonly string[]): Promise { + let config: string | undefined; + let out: string | undefined; + let tiers: readonly SnapshotTool['tier'][] | 'all' | undefined; + + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === '--out') { + const v = args[++i]; + if (!v) { + process.stderr.write('acture snapshot: --out requires a value\n'); + process.exit(2); + } + out = v; + } else if (a === '--tiers') { + const v = args[++i]; + if (!v) { + process.stderr.write('acture snapshot: --tiers requires a value\n'); + process.exit(2); + } + tiers = parseTiersList(v); + } else if (a.startsWith('--')) { + process.stderr.write(`acture snapshot: unknown option ${a}\n`); + process.exit(2); + } else if (config === undefined) { + config = a; + } else { + process.stderr.write(`acture snapshot: unexpected positional "${a}"\n`); + process.exit(2); + } + } + if (config === undefined) { + process.stderr.write('acture snapshot: missing argument\n'); + process.exit(2); + } + const code = await runSnapshotCmd({ + config, + ...(out !== undefined ? { out } : {}), + ...(tiers !== undefined ? { tiers } : {}), + }); + process.exit(code); +} + +function parseTiersList(s: string): readonly SnapshotTool['tier'][] | 'all' { + if (s === 'all') return 'all'; + const allowed: ReadonlySet = new Set([ + 'stable', + 'experimental', + 'deprecated', + 'internal', + ]); + const out: SnapshotTool['tier'][] = []; + for (const t of s.split(',').map((x) => x.trim()).filter(Boolean)) { + if (!allowed.has(t as SnapshotTool['tier'])) { + process.stderr.write( + `acture snapshot: invalid tier "${t}" (one of: stable, experimental, deprecated, internal, all)\n`, + ); + process.exit(2); + } + out.push(t as SnapshotTool['tier']); + } + return out; +} + +interface CompareSchemasArgs { + base: string; + head: string; + failOn?: Severity; + allowDescriptionEdits: boolean; + format: OutputFormat; + snapshotPath?: string; + color?: boolean; +} + +function runCompareSchemas(args: readonly string[]): void { + let parsed: CompareSchemasArgs; + try { + parsed = parseCompareSchemasArgs(args); + } catch (e) { + process.stderr.write(`acture compare-schemas: ${(e as Error).message}\n`); + process.exit(2); + } + let baseSnap; + let headSnap; + try { + baseSnap = loadSnapshot(parsed.base, parsed.snapshotPath !== undefined ? { snapshotPath: parsed.snapshotPath } : {}); + headSnap = loadSnapshot(parsed.head, parsed.snapshotPath !== undefined ? { snapshotPath: parsed.snapshotPath } : {}); + } catch (e) { + process.stderr.write(`acture compare-schemas: ${(e as Error).message}\n`); + process.exit(2); + } + + const result = classifyChanges(baseSnap, headSnap, { + allowDescriptionEdits: parsed.allowDescriptionEdits, + }); + + const output = formatResult(result, parsed.format, parsed.color !== undefined ? { color: parsed.color } : {}); + process.stdout.write(output + '\n'); + + if (parsed.failOn !== undefined) { + const rank: Record = { none: 0, minor: 1, major: 2 }; + if (rank[result.maxSeverity] >= rank[parsed.failOn]) { + process.exit(1); + } + } +} + +function parseCompareSchemasArgs(args: readonly string[]): CompareSchemasArgs { + let base: string | undefined; + let head: string | undefined; + let failOn: Severity | undefined; + let allowDescriptionEdits = false; + let format: OutputFormat = 'text'; + let snapshotPath: string | undefined; + let color: boolean | undefined; + + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === '--fail-on') { + const v = args[++i]; + if (v !== 'minor' && v !== 'major') { + throw new Error('--fail-on must be "minor" or "major"'); + } + failOn = v; + } else if (a === '--allow-description-edits') { + allowDescriptionEdits = true; + } else if (a === '--format') { + const v = args[++i]; + if (v !== 'text' && v !== 'json') { + throw new Error('--format must be "text" or "json"'); + } + format = v; + } else if (a === '--snapshot-path') { + const v = args[++i]; + if (!v) throw new Error('--snapshot-path requires a value'); + snapshotPath = v; + } else if (a === '--no-color') { + color = false; + } else if (a === '--color') { + color = true; + } else if (a.startsWith('--')) { + throw new Error(`unknown option ${a}`); + } else if (base === undefined) { + base = a; + } else if (head === undefined) { + head = a; + } else { + throw new Error(`unexpected positional argument "${a}"`); + } + } + if (base === undefined) throw new Error('missing argument'); + // Default head = working tree at snapshotPath. + if (head === undefined) head = snapshotPath ?? '.acture/snapshot.json'; + + const out: CompareSchemasArgs = { + base, + head, + allowDescriptionEdits, + format, + }; + if (failOn !== undefined) out.failOn = failOn; + if (snapshotPath !== undefined) out.snapshotPath = snapshotPath; + if (color !== undefined) out.color = color; + return out; +} diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts new file mode 100644 index 0000000..5c45788 --- /dev/null +++ b/packages/cli/src/format.ts @@ -0,0 +1,103 @@ +/** + * Output formatters for `acture compare-schemas`. The classifier is + * machine-readable; this file just translates a result to text or JSON. + * + * Text format follows oasdiff's rule-id style — `path | severity | + * kind | summary` — with optional ANSI colors when stdout is a TTY. + */ + +import type { Change, ClassifyResult, Severity } from './classify.js'; + +export type OutputFormat = 'text' | 'json'; + +export interface FormatOptions { + /** Force colors on/off. Default: auto (color if stdout.isTTY). */ + readonly color?: boolean; +} + +export function formatResult( + result: ClassifyResult, + format: OutputFormat, + options: FormatOptions = {}, +): string { + if (format === 'json') return formatJson(result); + return formatText(result, options); +} + +function formatJson(result: ClassifyResult): string { + return JSON.stringify( + { + maxSeverity: result.maxSeverity, + changes: result.changes.map((c) => ({ + tool: c.tool, + path: c.path, + kind: c.kind, + severity: c.severity, + summary: c.summary, + details: c.details, + })), + }, + null, + 2, + ); +} + +function formatText(result: ClassifyResult, options: FormatOptions): string { + const useColor = options.color ?? defaultColorEnabled(); + const lines: string[] = []; + if (result.changes.length === 0) { + lines.push(useColor ? colorize('No schema changes detected.', 'green') : 'No schema changes detected.'); + return lines.join('\n'); + } + const grouped = groupByTool(result.changes); + for (const [tool, changes] of grouped) { + lines.push(useColor ? colorize(`# ${tool}`, 'bold') : `# ${tool}`); + for (const c of changes) { + const sevTag = severityTag(c.severity, useColor); + lines.push(` ${sevTag} ${c.kind}: ${c.summary}`); + } + lines.push(''); + } + const summary = `Max severity: ${result.maxSeverity}. ${result.changes.length} change(s).`; + lines.push(useColor ? colorize(summary, result.maxSeverity === 'major' ? 'red' : 'yellow') : summary); + return lines.join('\n'); +} + +function groupByTool(changes: readonly Change[]): Array<[string, Change[]]> { + const groups = new Map(); + for (const c of changes) { + if (!groups.has(c.tool)) groups.set(c.tool, []); + groups.get(c.tool)!.push(c); + } + return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); +} + +function severityTag(s: Severity, color: boolean): string { + const label = + s === 'major' ? '[MAJOR]' : s === 'minor' ? '[MINOR]' : '[NONE]'; + if (!color) return label; + const c = s === 'major' ? 'red' : s === 'minor' ? 'yellow' : 'green'; + return colorize(label, c); +} + +const ANSI: Record = { + reset: '\x1b[0m', + bold: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', +}; + +function colorize(s: string, c: 'red' | 'green' | 'yellow' | 'bold'): string { + return `${ANSI[c]}${s}${ANSI['reset']}`; +} + +function defaultColorEnabled(): boolean { + try { + const proc = (globalThis as { process?: { stdout?: { isTTY?: boolean }; env?: Record } }).process; + if (proc?.env?.['NO_COLOR']) return false; + return Boolean(proc?.stdout?.isTTY); + } catch { + return false; + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..2e0c6ce --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,41 @@ +/** + * `@acture/cli` programmatic API. + * + * Exports the building blocks of the CLI so consumers can: + * + * - call `snapshotRegistry(registry)` to produce a JSON snapshot from + * a live registry (typically once per build / git tag), + * - feed two snapshots to `classifyChanges(base, head, options)` to + * get the same diff the `compare-schemas` subcommand produces, + * - format the result via `formatResult(result, 'text' | 'json')`. + * + * The `acture` binary lives at `./dist/cli.js`; this entry point does + * NOT call into the CLI parser. + */ + +export { + snapshotRegistry, + commandToSnapshotTool, + parseSnapshot, +} from './snapshot.js'; +export type { Snapshot, SnapshotTool } from './snapshot.js'; + +export { + classifyChanges, +} from './classify.js'; +export type { + Severity, + Change, + ChangeKind, + ClassifyOptions, + ClassifyResult, +} from './classify.js'; + +export { formatResult } from './format.js'; +export type { OutputFormat, FormatOptions } from './format.js'; + +export { loadSnapshot } from './load.js'; +export type { LoadOptions } from './load.js'; + +export { runSnapshotCmd } from './snapshot-cmd.js'; +export type { SnapshotCmdArgs } from './snapshot-cmd.js'; diff --git a/packages/cli/src/load.ts b/packages/cli/src/load.ts new file mode 100644 index 0000000..3eba2ea --- /dev/null +++ b/packages/cli/src/load.ts @@ -0,0 +1,47 @@ +/** + * Resolve a `` arg from the CLI to a parsed Snapshot. The two + * accepted forms are: + * + * 1. A file path that exists on disk. Read and parsed. + * 2. Otherwise, treat as a git ref and read `:` + * via `git show`. + * + * Both forms parse the result through `parseSnapshot`. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { parseSnapshot, type Snapshot } from './snapshot.js'; + +export interface LoadOptions { + /** Path within the repo to look for when `` is a git ref. + * Default: `.acture/snapshot.json`. */ + readonly snapshotPath?: string; +} + +export function loadSnapshot(ref: string, options: LoadOptions = {}): Snapshot { + const snapshotPath = options.snapshotPath ?? '.acture/snapshot.json'; + if (existsSync(ref)) { + const raw = readFileSync(ref, 'utf8'); + return parseSnapshot(JSON.parse(raw), ref); + } + // Treat as git ref. + let raw: string; + try { + raw = execFileSync('git', ['show', `${ref}:${snapshotPath}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + const err = e as Error & { stderr?: Buffer | string }; + const stderr = + typeof err.stderr === 'string' + ? err.stderr + : err.stderr?.toString('utf8') ?? ''; + throw new Error( + `Could not load snapshot from "${ref}:${snapshotPath}": ${stderr.trim() || err.message}\n` + + `Either pass a path to a snapshot JSON file, or commit a snapshot at ${snapshotPath} on the ref.`, + ); + } + return parseSnapshot(JSON.parse(raw), `${ref}:${snapshotPath}`); +} diff --git a/packages/cli/src/snapshot-cmd.test.ts b/packages/cli/src/snapshot-cmd.test.ts new file mode 100644 index 0000000..da7f194 --- /dev/null +++ b/packages/cli/src/snapshot-cmd.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for the `acture snapshot` subcommand. We exercise both the + * programmatic `runSnapshotCmd` helper (this file) and the CLI binary + * (cli.test.ts) so the user-facing behavior and the underlying logic + * are independently covered. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runSnapshotCmd } from './snapshot-cmd.js'; +import { parseSnapshot } from './snapshot.js'; + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'acture-snap-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function writeConfig(name: string, source: string): string { + const path = join(tmp, name); + writeFileSync(path, source, 'utf8'); + return path; +} + +/** Build a config that imports from the workspace `acture` package via + * its resolved path. We use `import.meta.resolve` to get a URL the + * config module can `import` regardless of the temp dir's resolution + * rules. */ +const ACTURE_URL = new URL( + '../../core/dist/index.js', + import.meta.url, +).href; + +const CONFIG_TEMPLATE = ` +import { createRegistry, defineCommand, ok } from '${ACTURE_URL}'; +const registry = createRegistry(); +registry.registerAll([ + defineCommand({ + id: 'app.search', + title: 'Search', + description: 'Search the corpus.', + execute: () => ok({ hits: [] }), + }), + defineCommand({ + id: 'app.exp', + title: 'Exp', + tier: 'experimental', + execute: () => ok({}), + }), +]); +export default registry; +`; + +describe('runSnapshotCmd', () => { + it('emits a snapshot to stdout when --out is not provided', async () => { + const config = writeConfig('config-stdout.mjs', CONFIG_TEMPLATE); + let stdout = ''; + const code = await runSnapshotCmd( + { config }, + { stdout: (s) => { stdout += s; }, stderr: () => {} }, + ); + expect(code).toBe(0); + const snap = parseSnapshot(JSON.parse(stdout), 'stdout'); + expect(snap.tools.map((t) => t.name).sort()).toEqual(['app.exp', 'app.search']); + }); + + it('writes a snapshot file when --out is provided', async () => { + const config = writeConfig('config-file.mjs', CONFIG_TEMPLATE); + const out = join(tmp, 'out', 'snapshot.json'); + const code = await runSnapshotCmd( + { config, out }, + { stdout: () => {}, stderr: () => {} }, + ); + expect(code).toBe(0); + expect(existsSync(out)).toBe(true); + const snap = parseSnapshot(JSON.parse(readFileSync(out, 'utf8')), out); + expect(snap.tools).toHaveLength(2); + }); + + it('respects --tiers filter', async () => { + const config = writeConfig('config-tiers.mjs', CONFIG_TEMPLATE); + let stdout = ''; + const code = await runSnapshotCmd( + { config, tiers: ['stable'] }, + { stdout: (s) => { stdout += s; }, stderr: () => {} }, + ); + expect(code).toBe(0); + const snap = parseSnapshot(JSON.parse(stdout), 'stdout'); + expect(snap.tools.map((t) => t.name)).toEqual(['app.search']); + }); + + it('returns 2 when config does not exist', async () => { + let stderr = ''; + const code = await runSnapshotCmd( + { config: join(tmp, 'no-such.mjs') }, + { stdout: () => {}, stderr: (s) => { stderr += s; } }, + ); + expect(code).toBe(2); + expect(stderr).toMatch(/failed to load config/); + }); + + it('returns 2 when config does not default-export a Registry', async () => { + const config = writeConfig( + 'config-no-default.mjs', + "export const notDefault = 42;\n", + ); + let stderr = ''; + const code = await runSnapshotCmd( + { config }, + { stdout: () => {}, stderr: (s) => { stderr += s; } }, + ); + expect(code).toBe(2); + expect(stderr).toMatch(/did not default-export a Registry/); + }); + + it('awaits a Promise default export', async () => { + const config = writeConfig( + 'config-async.mjs', + ` +import { createRegistry, defineCommand, ok } from '${ACTURE_URL}'; +const registry = createRegistry(); +registry.register(defineCommand({ + id: 'app.async', + title: 'Async', + execute: () => ok(null), +})); +export default Promise.resolve(registry); +`, + ); + let stdout = ''; + const code = await runSnapshotCmd( + { config }, + { stdout: (s) => { stdout += s; }, stderr: () => {} }, + ); + expect(code).toBe(0); + const snap = parseSnapshot(JSON.parse(stdout), 'stdout'); + expect(snap.tools[0]!.name).toBe('app.async'); + }); +}); diff --git a/packages/cli/src/snapshot-cmd.ts b/packages/cli/src/snapshot-cmd.ts new file mode 100644 index 0000000..b9ba7dc --- /dev/null +++ b/packages/cli/src/snapshot-cmd.ts @@ -0,0 +1,91 @@ +/** + * `acture snapshot ` — load a registry config module and emit + * a JSON snapshot of its tier-projected commands. + * + * The config module must default-export an acture `Registry` (or a + * `Promise`). v1.1 keeps loading simple: + * + * - `.mjs` / `.js` (with `"type":"module"`) — load via dynamic `import()`. + * - `.cjs` — load via dynamic `import()` (Node honours the extension). + * - `.ts` / `.tsx` — also try dynamic `import()`; works under Node ≥22.6 + * with `--experimental-strip-types`, or under `tsx` shimming. Errors + * surface with a hint pointing at the canonical workarounds. + * + * Output: JSON to stdout, OR a file when `--out ` is given. The + * snapshot format is the same `Snapshot` that `compare-schemas` reads. + */ + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { snapshotRegistry } from './snapshot.js'; +import type { Snapshot, SnapshotTool } from './snapshot.js'; + +export interface SnapshotCmdArgs { + readonly config: string; + readonly out?: string; + readonly tiers?: readonly SnapshotTool['tier'][] | 'all'; +} + +export async function runSnapshotCmd( + args: SnapshotCmdArgs, + io: { stdout: (s: string) => void; stderr: (s: string) => void } = defaultIo(), +): Promise { + const abs = isAbsolute(args.config) ? args.config : resolve(process.cwd(), args.config); + let mod: { default?: unknown }; + try { + mod = await import(pathToFileURL(abs).href); + } catch (e) { + io.stderr( + `acture snapshot: failed to load config "${args.config}": ${(e as Error).message}\n` + + hintForLoadError(args.config) + + '\n', + ); + return 2; + } + let registry = (mod as { default?: unknown }).default; + if (registry !== undefined && registry !== null && typeof (registry as { then?: unknown }).then === 'function') { + registry = await (registry as Promise); + } + if (!registry || typeof (registry as { dispatch?: unknown }).dispatch !== 'function') { + io.stderr( + `acture snapshot: config "${args.config}" did not default-export a Registry. ` + + 'Export the registry as `export default registry;`.\n', + ); + return 2; + } + + const snap: Snapshot = snapshotRegistry( + registry as Parameters[0], + args.tiers !== undefined ? { tiers: args.tiers } : {}, + ); + const json = JSON.stringify(snap, null, 2); + + if (args.out) { + const outAbs = isAbsolute(args.out) ? args.out : resolve(process.cwd(), args.out); + mkdirSync(dirname(outAbs), { recursive: true }); + writeFileSync(outAbs, json + '\n', 'utf8'); + io.stdout(`snapshot written to ${args.out}\n`); + } else { + io.stdout(json + '\n'); + } + return 0; +} + +function hintForLoadError(path: string): string { + if (/\.tsx?$/.test(path)) { + return ( + ' TypeScript hint: either compile the config to .mjs first, or run\n' + + ' npx tsx node_modules/.bin/acture snapshot ' + path + '\n' + + ' or use Node ≥22.6 with --experimental-strip-types.' + ); + } + return ''; +} + +function defaultIo(): { stdout: (s: string) => void; stderr: (s: string) => void } { + return { + stdout: (s) => { process.stdout.write(s); }, + stderr: (s) => { process.stderr.write(s); }, + }; +} diff --git a/packages/cli/src/snapshot.test.ts b/packages/cli/src/snapshot.test.ts new file mode 100644 index 0000000..5f09451 --- /dev/null +++ b/packages/cli/src/snapshot.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createRegistry, defineCommand, ok } from 'acture'; +import { snapshotRegistry, commandToSnapshotTool, parseSnapshot } from './snapshot.js'; + +describe('snapshotRegistry', () => { + it('emits one tool per registered command (including non-stable when tiers=all)', () => { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ + id: 'app.search', + title: 'Search', + description: 'Search the corpus.', + params: z.object({ query: z.string().min(1) }), + execute: () => ok({ hits: [] }), + }), + defineCommand({ + id: 'app.experimental.thing', + title: 'Experimental', + description: 'Experimental.', + tier: 'experimental', + execute: () => ok({}), + }), + ]); + const snap = snapshotRegistry(registry); + expect(snap.version).toBe(1); + expect(snap.tools.map((t) => t.name).sort()).toEqual([ + 'app.experimental.thing', + 'app.search', + ]); + }); + + it('records tier=stable by default when no tier is declared', () => { + const registry = createRegistry(); + registry.register( + defineCommand({ + id: 'app.x', + title: 'X', + execute: () => ok(null), + }), + ); + const snap = snapshotRegistry(registry); + expect(snap.tools[0]!.tier).toBe('stable'); + expect(snap.tools[0]!.deprecationReason).toBeNull(); + }); + + it('preserves aliases and when-clause string form', () => { + const registry = createRegistry(); + registry.register( + defineCommand({ + id: 'app.x', + title: 'X', + aliases: ['foo', 'bar'], + when: 'editor.focused == true', + execute: () => ok(null), + }), + ); + const snap = snapshotRegistry(registry); + expect(snap.tools[0]!.aliases).toEqual(['foo', 'bar']); + expect(snap.tools[0]!.when).toBe('editor.focused == true'); + }); + + it('records function-form when-clauses as the literal string ""', () => { + const registry = createRegistry(); + registry.register( + defineCommand({ + id: 'app.x', + title: 'X', + when: () => true, + execute: () => ok(null), + }), + ); + const snap = snapshotRegistry(registry); + expect(snap.tools[0]!.when).toBe(''); + }); +}); + +describe('commandToSnapshotTool', () => { + it('uses the projected JSON Schema as inputSchema', () => { + const cmd = defineCommand({ + id: 'app.s', + title: 'S', + params: z.object({ q: z.string() }), + execute: () => ok(null), + }); + const t = commandToSnapshotTool(cmd); + expect(t.inputSchema['type']).toBe('object'); + expect((t.inputSchema['properties'] as Record)['q']).toBeDefined(); + }); +}); + +describe('parseSnapshot', () => { + it('throws on missing version', () => { + expect(() => parseSnapshot({ tools: [] }, 'test')).toThrow(/version/); + }); + + it('throws when tools is not an array', () => { + expect(() => parseSnapshot({ version: 1, tools: 'oops' }, 'test')).toThrow(/array/); + }); + + it('accepts a well-formed snapshot', () => { + const snap = parseSnapshot( + { version: 1, generator: 'test', tools: [] }, + 'test', + ); + expect(snap.tools).toEqual([]); + }); +}); diff --git a/packages/cli/src/snapshot.ts b/packages/cli/src/snapshot.ts new file mode 100644 index 0000000..112c72a --- /dev/null +++ b/packages/cli/src/snapshot.ts @@ -0,0 +1,115 @@ +/** + * Snapshot format — what `acture compare-schemas` reads on each side + * of the diff. A snapshot is a JSON document containing one entry per + * registered command, projected through the schema bridge. + * + * Format (v1): + * + * { + * "version": 1, + * "generator": "@acture/cli", + * "tools": [ + * { + * "name": "app.search", + * "description": "Search the corpus.", + * "inputSchema": { ...JSON Schema... }, + * "tier": "stable", + * "deprecationReason": null, + * "aliases": [], + * "when": null + * }, + * ... + * ] + * } + * + * Users typically produce snapshots from a registry via the + * `snapshotRegistry(registry)` helper exported here. The CLI's + * `compare-schemas` subcommand reads two such JSON files (or two refs + * via `git show :`) and diffs them. + */ + +import type { AnyCommandRecord, Registry } from 'acture'; +import { toJsonSchema } from 'acture'; + +export interface SnapshotTool { + /** Command id (== MCP tool name). */ + readonly name: string; + /** Description as the model sees it. May include deprecation banners. */ + readonly description?: string; + /** Projected JSON Schema input schema. */ + readonly inputSchema: Record; + /** Tier as declared on the command, defaulting to 'stable'. */ + readonly tier: 'stable' | 'experimental' | 'internal' | 'deprecated'; + /** Free-text reason from `@deprecated `. `null` if not deprecated. */ + readonly deprecationReason: string | null; + /** Aliases (search labels for the palette / MCP clients). */ + readonly aliases: readonly string[]; + /** When-clause as a DSL string, or `""` if function-form, + * or `null` if absent. Function-form when-clauses are NOT comparable + * structurally; the classifier treats every change to a function-form + * when as MAJOR. */ + readonly when: string | null; +} + +export interface Snapshot { + readonly version: 1; + readonly generator: string; + readonly tools: readonly SnapshotTool[]; +} + +/** + * Project a live registry into a snapshot. Honours the tier filter the + * caller is interested in (defaults to `['stable']` — same as the + * MCP / AI projections). + */ +export function snapshotRegistry( + registry: Registry, + options: { tiers?: readonly SnapshotTool['tier'][] | 'all' } = {}, +): Snapshot { + const list = registry.list({ tiers: options.tiers ?? 'all' }); + const tools = list.map(commandToSnapshotTool); + return { + version: 1, + generator: '@acture/cli', + tools, + }; +} + +/** Convert one command to its snapshot form. Exported for testing. */ +export function commandToSnapshotTool(cmd: AnyCommandRecord): SnapshotTool { + const envelope = toJsonSchema(cmd); + const out: SnapshotTool = { + name: envelope.name, + ...(envelope.description !== undefined ? { description: envelope.description } : {}), + inputSchema: envelope.inputSchema, + tier: (cmd.tier ?? 'stable') as SnapshotTool['tier'], + deprecationReason: cmd.deprecationReason ?? null, + aliases: cmd.aliases ? [...cmd.aliases] : [], + when: + cmd.when === undefined + ? null + : typeof cmd.when === 'string' + ? cmd.when + : '', + }; + return out; +} + +/** Type-narrow a parsed JSON value to a Snapshot, or throw. */ +export function parseSnapshot(value: unknown, source: string): Snapshot { + if (value === null || typeof value !== 'object') { + throw new Error( + `${source}: snapshot must be a JSON object, got ${typeof value}`, + ); + } + const v = value as Record; + if (v['version'] !== 1) { + throw new Error( + `${source}: unsupported snapshot version ${String(v['version'])} (expected 1)`, + ); + } + if (!Array.isArray(v['tools'])) { + throw new Error(`${source}: snapshot.tools must be an array`); + } + return value as Snapshot; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..e63ebf2 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..1114c48 --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: 'es2022', + outDir: 'dist', + }, + { + entry: ['src/cli.ts'], + format: ['esm'], + dts: false, + sourcemap: true, + clean: false, + treeshake: true, + target: 'es2022', + outDir: 'dist', + banner: { js: '#!/usr/bin/env node' }, + }, +]); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index 8b1c8e1..edbd3fd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "acture", - "version": "0.2.0", + "version": "1.1.0", "description": "Typed schema-driven command dispatch library. Core: registry, dispatcher, when-clause DSL, state adapter interface, schema bridge.", "license": "Apache-2.0", "author": "Thor Whalen", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 70eb9a8..5274a42 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,7 @@ export { export type { Registry, CreateRegistryOptions, + DispatchOptions, ListOptions, CommandsChangedEvent, CommandsChangedListener, @@ -61,6 +62,9 @@ export { isErr, } from './result.js'; +export { enableTierWarnings } from './tier-warnings.js'; +export type { EnableTierWarningsOptions } from './tier-warnings.js'; + export type { // Core data shapes CommandRecord, @@ -79,5 +83,5 @@ export type { DefaultScore, } from './types.js'; -/** Package version. Updated by build step at Phase 4. */ -export const __version = '0.1.0-dev' as const; +/** Package version. */ +export const __version = '1.1.0' as const; diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index ffbff50..2255ab2 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -42,6 +42,16 @@ export interface ListOptions { context?: Context; } +/** Options accepted by `dispatch`. */ +export interface DispatchOptions { + /** Required to dispatch an `@internal` command. The build-step mirror + * attaches a module-scoped Symbol to every `tier: 'internal'` command; + * the registering module re-exports a `dispatch` wrapper that closes + * over this token, so cross-module callers never see it. See + * `acture-tier-system` skill §"What @internal does (three layers)". */ + internalToken?: symbol; +} + export interface Registry { register(cmd: CommandRecord): () => void; registerAll(cmds: readonly AnyCommandRecord[]): () => void; @@ -54,6 +64,7 @@ export interface Registry { id: string, params?: unknown, ctx?: Context, + options?: DispatchOptions, ): Promise>; onCommandsChanged(listener: CommandsChangedListener): () => void; } @@ -191,11 +202,25 @@ export function createRegistry(options?: CreateRegistryOptions): Registry { id: string, params?: unknown, ctx?: Context, + options?: DispatchOptions, ): Promise> { const cmd = commands.get(id); if (!cmd) { return err('unknown_command', `No command registered with id "${id}"`); } + // `@internal` enforcement (research-5 §7.5). Internal commands carry + // a Symbol token attached by the registering module. Callers from + // outside the module never see it; the dispatch fails closed. + if (cmd.tier === 'internal') { + const expected = cmd.internalToken; + if (expected !== undefined && options?.internalToken !== expected) { + return err( + 'internal_dispatch_denied', + `Command "${id}" is @internal and may only be dispatched from the registering module`, + { tier: 'internal' }, + ); + } + } const context: Context = ctx ?? {}; if (cmd.when !== undefined && !evaluateWhen(cmd.when, context)) { return err( diff --git a/packages/core/src/result.test.ts b/packages/core/src/result.test.ts new file mode 100644 index 0000000..aded150 --- /dev/null +++ b/packages/core/src/result.test.ts @@ -0,0 +1,60 @@ +/** + * Direct tests for the Result helpers. They are tiny but they are part + * of the v1 public API surface, so they get the same happy + error + * coverage every other public export does. + */ + +import { describe, it, expect } from 'vitest'; +import { ok, err, isOk, isErr } from './result.js'; + +describe('ok', () => { + it('wraps a value in { ok: true, value }', () => { + const r = ok(42); + expect(r).toEqual({ ok: true, value: 42 }); + }); + + it('preserves patches when provided', () => { + const patches = [{ op: 'add', path: ['x'], value: 1 } as const]; + const r = ok(null, { patches }); + expect(r.ok && r.patches).toEqual(patches); + }); + + it('preserves effects when provided', () => { + const effects = [{ type: 'log', msg: 'hi' }]; + const r = ok(null, { effects }); + expect(r.ok && r.effects).toEqual(effects); + }); + + it('omits patches/effects when not provided', () => { + const r = ok(null); + expect('patches' in r).toBe(false); + expect('effects' in r).toBe(false); + }); +}); + +describe('err', () => { + it('wraps a code/message into { ok: false, error }', () => { + const r = err('boom', 'something bad'); + expect(r).toEqual({ ok: false, error: { code: 'boom', message: 'something bad' } }); + }); + + it('attaches details when provided', () => { + const r = err('boom', 'x', { stack: 'trace' }); + // `err()` always returns the failure branch, but its declared type is + // `Result`, so TS needs an `r.ok === false` narrow before + // `r.error` is reachable. + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.details).toEqual({ stack: 'trace' }); + }); +}); + +describe('isOk / isErr', () => { + it('isOk recognizes a success', () => { + expect(isOk(ok(1))).toBe(true); + expect(isOk(err('e', 'm'))).toBe(false); + }); + it('isErr recognizes a failure', () => { + expect(isErr(err('e', 'm'))).toBe(true); + expect(isErr(ok(1))).toBe(false); + }); +}); diff --git a/packages/core/src/tier-warnings.test.ts b/packages/core/src/tier-warnings.test.ts new file mode 100644 index 0000000..189c4d1 --- /dev/null +++ b/packages/core/src/tier-warnings.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRegistry, defineCommand, ok } from './index.js'; +import { enableTierWarnings } from './tier-warnings.js'; + +function setup() { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ + id: 'app.stable', + title: 'Stable', + execute: () => ok('s'), + }), + defineCommand({ + id: 'app.experimental', + title: 'Experimental', + tier: 'experimental', + execute: () => ok('x'), + }), + defineCommand({ + id: 'app.experimental.two', + title: 'Experimental Two', + tier: 'experimental', + execute: () => ok('y'), + }), + ]); + return registry; +} + +describe('enableTierWarnings', () => { + it('warns once on first dispatch of an experimental command', async () => { + const registry = setup(); + const warn = vi.fn(); + enableTierWarnings(registry, { enabled: true, warn }); + await registry.dispatch('app.experimental'); + await registry.dispatch('app.experimental'); + await registry.dispatch('app.experimental'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toMatch(/experimental command "app\.experimental"/); + }); + + it('warns separately for each distinct experimental command', async () => { + const registry = setup(); + const warn = vi.fn(); + enableTierWarnings(registry, { enabled: true, warn }); + await registry.dispatch('app.experimental'); + await registry.dispatch('app.experimental.two'); + await registry.dispatch('app.experimental'); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('does not warn for stable commands', async () => { + const registry = setup(); + const warn = vi.fn(); + enableTierWarnings(registry, { enabled: true, warn }); + await registry.dispatch('app.stable'); + await registry.dispatch('app.stable'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('is suppressed when enabled: false', async () => { + const registry = setup(); + const warn = vi.fn(); + enableTierWarnings(registry, { enabled: false, warn }); + await registry.dispatch('app.experimental'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('is suppressed when ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1', async () => { + const registry = setup(); + const warn = vi.fn(); + const proc = (globalThis as { process: { env: Record } }).process; + const prev = proc.env['ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS']; + proc.env['ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS'] = '1'; + try { + enableTierWarnings(registry, { warn }); + await registry.dispatch('app.experimental'); + expect(warn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete proc.env['ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS']; + else proc.env['ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS'] = prev; + } + }); + + it('is idempotent — calling twice returns the same disposer', () => { + const registry = setup(); + const warn = vi.fn(); + const d1 = enableTierWarnings(registry, { enabled: true, warn }); + const d2 = enableTierWarnings(registry, { enabled: true, warn }); + expect(d1).toBe(d2); + }); + + it('preserves errors-as-data — unknown commands still resolve to {ok: false}', async () => { + const registry = setup(); + enableTierWarnings(registry, { enabled: true, warn: () => {} }); + const r = await registry.dispatch('no.such.thing'); + expect(r.ok).toBe(false); + }); + + it('the disposer restores the original dispatch', async () => { + const registry = setup(); + const warn = vi.fn(); + const dispose = enableTierWarnings(registry, { enabled: true, warn }); + await registry.dispatch('app.experimental'); + dispose(); + // Now wrap a fresh logger; second call should warn (a NEW + // enableTierWarnings instance starts with a fresh warnedIds set). + const warn2 = vi.fn(); + enableTierWarnings(registry, { enabled: true, warn: warn2 }); + await registry.dispatch('app.experimental'); + expect(warn2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/tier-warnings.ts b/packages/core/src/tier-warnings.ts new file mode 100644 index 0000000..d2f6d04 --- /dev/null +++ b/packages/core/src/tier-warnings.ts @@ -0,0 +1,105 @@ +/** + * Runtime tier warnings — per research-5 §7.3. + * + * On first dispatch of an `@experimental` command in a process, emit + * a once-per-command `console.warn` so the operator notices that an + * unstable surface is being exercised. Production builds that wire + * this up should also set `ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1` + * once they have validated their experimental surface. + * + * Idempotent — wrapping a registry twice returns the same disposer. + * Calling the returned disposer restores the original `dispatch`. + * + * Pattern mirrors `@acture/devtools` `instrumentRegistry`: we mutate + * one method on the registry. Per `acture-hard-donts` §6, this is + * permitted because (a) it's opt-in and dev-leaning, (b) it preserves + * the original signature exactly, (c) the wrapper is identifiable + * via a WeakMap so callers cannot stack warnings infinitely. + */ + +import type { Context, Result } from './types.js'; +import type { DispatchOptions, Registry } from './registry.js'; + +export interface EnableTierWarningsOptions { + /** Force warnings on or off regardless of env. Default: auto (suppressed + * when `ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1` is set in process.env). */ + readonly enabled?: boolean; + /** Custom warn function. Default: `console.warn`. Useful for tests + * and for routing warnings to a structured logger. */ + readonly warn?: (message: string) => void; +} + +interface Attached { + readonly dispose: () => void; + readonly warnedIds: Set; +} + +const ATTACHED = new WeakMap(); + +/** + * Wrap `registry.dispatch` so the first dispatch of each + * `@experimental` command emits a one-time `console.warn`. + * + * Returns a disposer that restores the original `dispatch` if you + * need to roll back instrumentation (e.g., between tests). + */ +export function enableTierWarnings( + registry: Registry, + options: EnableTierWarningsOptions = {}, +): () => void { + const existing = ATTACHED.get(registry); + if (existing) return existing.dispose; + + const enabled = options.enabled ?? defaultEnabled(); + if (!enabled) { + // No-op disposer. Cache it so a second call short-circuits too. + const noop: Attached = { dispose: () => {}, warnedIds: new Set() }; + ATTACHED.set(registry, noop); + return noop.dispose; + } + + const warn = options.warn ?? defaultWarn; + const warnedIds = new Set(); + const originalDispatch = registry.dispatch.bind(registry); + + (registry as { dispatch: Registry['dispatch'] }).dispatch = + async function tierWarningDispatch( + id: string, + params?: unknown, + ctx?: Context, + opts?: DispatchOptions, + ): Promise> { + const cmd = registry.get(id); + if (cmd && cmd.tier === 'experimental' && !warnedIds.has(id)) { + warnedIds.add(id); + warn( + `[acture] dispatched experimental command "${id}". ` + + 'This command may change without semver discipline. ' + + 'Pin the consumer package version to lock the schema. ' + + 'Suppress this warning with ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS=1.', + ); + } + return originalDispatch(id, params, ctx, opts); + }; + + const attached: Attached = { + warnedIds, + dispose: () => { + (registry as { dispatch: Registry['dispatch'] }).dispatch = + originalDispatch; + ATTACHED.delete(registry); + }, + }; + ATTACHED.set(registry, attached); + return attached.dispose; +} + +function defaultEnabled(): boolean { + const proc = (globalThis as { process?: { env?: Record } }).process; + return proc?.env?.['ACTURE_SUPPRESS_EXPERIMENTAL_WARNINGS'] !== '1'; +} + +function defaultWarn(message: string): void { + const c = (globalThis as { console?: { warn?: (m: string) => void } }).console; + c?.warn?.(message); +} diff --git a/packages/core/src/tier.test.ts b/packages/core/src/tier.test.ts new file mode 100644 index 0000000..55bae9f --- /dev/null +++ b/packages/core/src/tier.test.ts @@ -0,0 +1,164 @@ +/** + * Tier-system runtime tests. The JSDoc-tag mirror lives in + * `@acture/build-tier`; this file verifies what the registry does once + * a command already carries `tier` / `deprecationReason` / `internalToken`. + */ + +import { describe, it, expect } from 'vitest'; +import { createRegistry, defineCommand, ok } from './index.js'; + +describe('tier filtering', () => { + it('defaults to stable only', () => { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ + id: 'app.a', + title: 'A', + tier: 'stable', + execute: () => ok('a'), + }), + defineCommand({ + id: 'app.b', + title: 'B', + tier: 'experimental', + execute: () => ok('b'), + }), + ]); + const ids = registry.list().map((c) => c.id); + expect(ids).toEqual(['app.a']); + }); + + it('includes experimental when opted in', () => { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ + id: 'app.a', + title: 'A', + tier: 'stable', + execute: () => ok('a'), + }), + defineCommand({ + id: 'app.b', + title: 'B', + tier: 'experimental', + execute: () => ok('b'), + }), + ]); + const ids = registry + .list({ tiers: ['stable', 'experimental'] }) + .map((c) => c.id); + expect(ids.sort()).toEqual(['app.a', 'app.b']); + }); + + it("'all' includes every tier except internal unless asked", () => { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ id: 'app.s', title: 'S', tier: 'stable', execute: () => ok(null) }), + defineCommand({ id: 'app.e', title: 'E', tier: 'experimental', execute: () => ok(null) }), + defineCommand({ id: 'app.d', title: 'D', tier: 'deprecated', execute: () => ok(null) }), + defineCommand({ id: 'app.i', title: 'I', tier: 'internal', execute: () => ok(null) }), + ]); + const ids = registry.list({ tiers: 'all' }).map((c) => c.id); + expect(ids.sort()).toEqual(['app.d', 'app.e', 'app.s']); + }); + + it("internal is included only when explicitly named", () => { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ id: 'app.i', title: 'I', tier: 'internal', execute: () => ok(null) }), + ]); + expect(registry.list({ tiers: ['internal'] }).map((c) => c.id)).toEqual(['app.i']); + }); +}); + +describe('@internal symbol-token enforcement', () => { + it('allows dispatch without a token when no internalToken is attached (build step did not run)', async () => { + const registry = createRegistry(); + registry.register( + defineCommand({ + id: 'app.untokened', + title: 'Untokened', + tier: 'internal', + execute: () => ok('went through'), + }), + ); + const r = await registry.dispatch('app.untokened'); + expect(r.ok).toBe(true); + }); + + it('rejects external dispatch when an internalToken is attached and not presented', async () => { + const registry = createRegistry(); + const moduleToken = Symbol('acture.internal'); + registry.register({ + // Bypass the helper so we can set internalToken; users do this via + // the build-tier mirror in production. + id: 'app.tokened', + title: 'Tokened', + tier: 'internal', + internalToken: moduleToken, + execute: () => ok('ran'), + }); + const r = await registry.dispatch('app.tokened'); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.code).toBe('internal_dispatch_denied'); + } + }); + + it('allows dispatch when the matching internalToken is presented', async () => { + const registry = createRegistry(); + const moduleToken = Symbol('acture.internal'); + registry.register({ + id: 'app.tokened.allow', + title: 'Tokened', + tier: 'internal', + internalToken: moduleToken, + execute: () => ok('ran'), + }); + const r = await registry.dispatch( + 'app.tokened.allow', + undefined, + undefined, + { internalToken: moduleToken }, + ); + expect(r.ok).toBe(true); + }); + + it('rejects dispatch with a DIFFERENT token', async () => { + const registry = createRegistry(); + const moduleToken = Symbol('acture.internal'); + const otherToken = Symbol('acture.internal'); + registry.register({ + id: 'app.tokened.deny', + title: 'Tokened', + tier: 'internal', + internalToken: moduleToken, + execute: () => ok('ran'), + }); + const r = await registry.dispatch( + 'app.tokened.deny', + undefined, + undefined, + { internalToken: otherToken }, + ); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.code).toBe('internal_dispatch_denied'); + } + }); +}); + +describe('deprecationReason field', () => { + it('is preserved on the frozen CommandRecord', () => { + const cmd = defineCommand({ + id: 'app.legacy.thing', + title: 'Legacy', + description: 'Original.', + tier: 'deprecated', + deprecationReason: 'use app.modern.thing instead', + execute: () => ok(null), + }); + expect(cmd.deprecationReason).toBe('use app.modern.thing instead'); + expect(cmd.tier).toBe('deprecated'); + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c0d2f76..5e43386 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -85,6 +85,19 @@ export interface CommandRecord

{ readonly aliases?: readonly string[]; readonly kind?: CommandKind; readonly tier?: Tier; + /** Free-text reason populated from `@deprecated ` JSDoc by + * the build-step tier mirror. Adapter packages (`@acture/mcp`, + * `@acture/ai-vercel`) prepend `[DEPRECATED — ]` to the + * description when projecting. `@acture/devtools` surfaces it in the + * inspector. (Three callers; closed-surface addition approved.) */ + readonly deprecationReason?: string; + /** Opt-in token for `@internal` dispatch. The tier-mirror build step + * attaches a module-scoped `Symbol('acture.internal')` token when it + * marks a command `tier: 'internal'`. Cross-package callers cannot + * observe the token (it lives in the registering module's closure), + * so `registry.dispatch(id, params, ctx, { internalToken })` throws + * when the caller's token does not match. */ + readonly internalToken?: symbol; readonly defaultScore?: DefaultScore; readonly follow?: readonly string[]; readonly execute: (params: P, ctx: Context) => Result | Promise>; diff --git a/packages/devtools/README.md b/packages/devtools/README.md new file mode 100644 index 0000000..d1db6e3 --- /dev/null +++ b/packages/devtools/README.md @@ -0,0 +1,65 @@ +# @acture/devtools + +Embeddable React Inspector + dispatch-log instrumentation for acture registries. Dev-only — production builds skip the imports and tree-shake to nothing. + +## Install + +```bash +pnpm add -D @acture/devtools +``` + +## Mount the Inspector + +```tsx +import { Inspector, instrumentRegistry } from '@acture/devtools'; +import { registry } from './registry'; + +// Instrument once at module load so every dispatch is captured. +// Idempotent — re-imports do not re-wrap. +const dispatchLog = instrumentRegistry(registry); + +function App() { + const [showInspector, setShowInspector] = useState(false); + return ( + <> + + + {showInspector ? : null} + + ); +} +``` + +Three tabs: + +- **Commands** — list of registered commands with tier badges, filterable by tier (stable / experimental / deprecated / internal) and free-text search. +- **Dispatch log** — ring-buffered most-recent-first list of every `registry.dispatch` call, with params, result, and duration. `clear` button. Default capacity 200 entries. +- **When evaluator** — small REPL: type a when-clause DSL string and a JSON context, see the evaluation result update live. + +## Theming + +No bundled UI kit. The component uses inline styles only. Theme via the `data-acture-devtools-*` attributes: + +```css +[data-acture-devtools-inspector] { font-family: 'Your Font', monospace; } +[data-acture-devtools-tabbar] button { background: var(--your-bg); } +[data-acture-devtools-commands] table { /* ... */ } +``` + +## `instrumentRegistry(registry, options?)` + +Wraps `registry.dispatch` to capture every call. Returns a `DispatchLog` with: + +- `entries: readonly DispatchLogEntry[]` — most recent at the end. +- `subscribe(listener): unsubscribe` — fires on each new entry. +- `clear()` — drop all entries. + +Options: + +- `maxEntries?: number` — ring-buffer size, default 200. + +Idempotent: calling twice on the same registry returns the same log. The mutation is local to this package; production builds simply don't call `instrumentRegistry` and pay zero runtime cost. + +See [`acture-hard-donts`](../../.claude/skills/acture-hard-donts/SKILL.md) §6 for why dev-only registry mutation is allowed when core React-coupling is not. diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000..9412364 --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,66 @@ +{ + "name": "@acture/devtools", + "version": "1.0.0", + "description": "Acture devtools inspector — registry contents, dispatch log, when-clause evaluator, tier filter preview. Embeddable React component for dev builds.", + "license": "Apache-2.0", + "author": "Thor Whalen", + "repository": { + "type": "git", + "url": "git+https://github.com/thorwhalen/acture.git", + "directory": "packages/devtools" + }, + "homepage": "https://github.com/thorwhalen/acture#readme", + "bugs": "https://github.com/thorwhalen/acture/issues", + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "peerDependencies": { + "acture": "workspace:*", + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "acture": "workspace:*", + "jsdom": "^25.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.3.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0", + "zod": "^4.0.0" + }, + "keywords": [ + "acture", + "devtools", + "inspector", + "registry", + "dispatch-log", + "react" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/devtools/src/dispatch-log.test.ts b/packages/devtools/src/dispatch-log.test.ts new file mode 100644 index 0000000..f717dcb --- /dev/null +++ b/packages/devtools/src/dispatch-log.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { createRegistry, defineCommand, ok, err } from 'acture'; +import { instrumentRegistry } from './dispatch-log.js'; + +function setup() { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ + id: 'app.greet', + title: 'Greet', + execute: () => ok('hello'), + }), + defineCommand({ + id: 'app.fail', + title: 'Fail', + execute: () => err('boom', 'kaboom'), + }), + ]); + return registry; +} + +describe('instrumentRegistry', () => { + it('records a successful dispatch', async () => { + const registry = setup(); + const log = instrumentRegistry(registry); + await registry.dispatch('app.greet'); + expect(log.entries).toHaveLength(1); + expect(log.entries[0]!.commandId).toBe('app.greet'); + expect(log.entries[0]!.result.ok).toBe(true); + }); + + it('records an error dispatch', async () => { + const registry = setup(); + const log = instrumentRegistry(registry); + await registry.dispatch('app.fail'); + expect(log.entries).toHaveLength(1); + expect(log.entries[0]!.result.ok).toBe(false); + }); + + it('is idempotent — calling twice returns the same log', () => { + const registry = setup(); + const a = instrumentRegistry(registry); + const b = instrumentRegistry(registry); + expect(a).toBe(b); + }); + + it('notifies subscribers on each dispatch', async () => { + const registry = setup(); + const log = instrumentRegistry(registry); + let calls = 0; + const unsub = log.subscribe(() => { + calls++; + }); + await registry.dispatch('app.greet'); + await registry.dispatch('app.greet'); + expect(calls).toBe(2); + unsub(); + }); + + it('clears entries on clear()', async () => { + const registry = setup(); + const log = instrumentRegistry(registry); + await registry.dispatch('app.greet'); + log.clear(); + expect(log.entries).toEqual([]); + }); + + it('caps entries at maxEntries', async () => { + const registry = setup(); + const log = instrumentRegistry(registry, { maxEntries: 3 }); + for (let i = 0; i < 10; i++) { + await registry.dispatch('app.greet'); + } + expect(log.entries).toHaveLength(3); + // Latest entries kept: ids should be the last 3. + const ids = log.entries.map((e) => e.id); + expect(ids).toEqual([8, 9, 10]); + }); + + it('preserves the original dispatch contract — unknown command returns errors-as-data', async () => { + const registry = setup(); + instrumentRegistry(registry); + const r = await registry.dispatch('nope'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe('unknown_command'); + }); +}); diff --git a/packages/devtools/src/dispatch-log.ts b/packages/devtools/src/dispatch-log.ts new file mode 100644 index 0000000..38081b3 --- /dev/null +++ b/packages/devtools/src/dispatch-log.ts @@ -0,0 +1,132 @@ +/** + * Registry instrumentation for the dispatch log. Wraps a registry's + * `dispatch` once (idempotently) so every call is captured with + * timestamp, params, and Result. + * + * The wrapper is opt-in: the host calls `instrumentRegistry(registry)` + * once in a dev build. Production builds skip the call and pay zero + * runtime cost. The wrapper preserves the original `dispatch` signature. + * + * Why this is OK in a devtools-only package: per `acture-hard-donts` + * §6 (no React coupling in core) we cannot put dispatch interception in + * acture/core. But a devtools adapter is allowed to mutate the registry + * locally — the mutation is observable only by the inspector component. + */ + +import type { + AnyCommandRecord, + Context, + DispatchOptions, + Registry, + Result, +} from 'acture'; + +export interface DispatchLogEntry { + readonly id: number; + readonly timestamp: number; + readonly commandId: string; + readonly params: unknown; + readonly ctx: Context; + readonly result: Result; + readonly durationMs: number; + readonly command?: AnyCommandRecord; +} + +export interface DispatchLog { + readonly entries: readonly DispatchLogEntry[]; + subscribe(listener: () => void): () => void; + clear(): void; +} + +const ATTACHED = new WeakMap(); + +interface MutableLog extends DispatchLog { + entries: DispatchLogEntry[]; + listeners: Set<() => void>; + nextId: number; +} + +/** + * Wrap `registry.dispatch` to record every call in an in-memory log. + * + * Idempotent: calling twice on the same registry returns the same log. + * + * Capacity: the log keeps the most-recent `maxEntries` entries (default + * 200) so a long-running session doesn't pin memory. + */ +export function instrumentRegistry( + registry: Registry, + options: { maxEntries?: number } = {}, +): DispatchLog { + const existing = ATTACHED.get(registry); + if (existing) return existing; + + const maxEntries = options.maxEntries ?? 200; + const log: MutableLog = { + entries: [], + listeners: new Set(), + nextId: 1, + subscribe(listener) { + log.listeners.add(listener); + return () => { + log.listeners.delete(listener); + }; + }, + clear() { + log.entries = []; + emit(log); + }, + }; + + const originalDispatch = registry.dispatch.bind(registry); + + // Mutate the registry's dispatch. The mutation is local to this + // package; production builds simply don't call instrumentRegistry. + (registry as { dispatch: Registry['dispatch'] }).dispatch = + async function instrumentedDispatch( + id: string, + params?: unknown, + ctx?: Context, + opts?: DispatchOptions, + ): Promise> { + const t0 = now(); + const result = (await originalDispatch(id, params, ctx, opts)) as Result; + const entry: DispatchLogEntry = { + id: log.nextId++, + timestamp: Date.now(), + commandId: id, + params, + ctx: ctx ?? {}, + result, + durationMs: now() - t0, + ...(registry.get(id) !== undefined ? { command: registry.get(id) } : {}), + }; + log.entries.push(entry); + if (log.entries.length > maxEntries) { + log.entries.splice(0, log.entries.length - maxEntries); + } + emit(log); + return result; + }; + + ATTACHED.set(registry, log); + return log; +} + +function emit(log: MutableLog): void { + const snapshot = Array.from(log.listeners); + for (const l of snapshot) { + try { + l(); + } catch { + // Devtools listener errors must never break dispatch. We swallow + // silently — the inspector re-renders on the next event. + } + } +} + +function now(): number { + const perf = (globalThis as { performance?: { now?: () => number } }).performance; + if (perf?.now) return perf.now(); + return Date.now(); +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts new file mode 100644 index 0000000..d3dd8a0 --- /dev/null +++ b/packages/devtools/src/index.ts @@ -0,0 +1,21 @@ +/** + * `@acture/devtools` — embeddable inspector for acture registries. + * + * Two pieces: + * + * - `instrumentRegistry(registry)` — wrap `dispatch` to capture a + * ring-buffered dispatch log (production builds skip the call and + * pay zero runtime cost). + * - `` — React component that + * renders the commands, the dispatch log, and a when-clause + * evaluator. Pure-React; no UI-kit dep. + * + * The package is designed to live behind a `if (import.meta.env.DEV)` + * guard in the host app — production bundles tree-shake to nothing. + */ + +export { Inspector } from './inspector.js'; +export type { InspectorProps } from './inspector.js'; + +export { instrumentRegistry } from './dispatch-log.js'; +export type { DispatchLog, DispatchLogEntry } from './dispatch-log.js'; diff --git a/packages/devtools/src/inspector.test.tsx b/packages/devtools/src/inspector.test.tsx new file mode 100644 index 0000000..034e816 --- /dev/null +++ b/packages/devtools/src/inspector.test.tsx @@ -0,0 +1,69 @@ +/// + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { createRegistry, defineCommand, ok } from 'acture'; +import { Inspector } from './inspector.js'; +import { instrumentRegistry } from './dispatch-log.js'; + +afterEach(() => { + cleanup(); +}); + +function setup() { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ + id: 'app.search', + title: 'Search', + description: 'Search the corpus.', + execute: () => ok('hits'), + }), + defineCommand({ + id: 'app.exp.thing', + title: 'Exp', + tier: 'experimental', + execute: () => ok('x'), + }), + ]); + return registry; +} + +describe('', () => { + it('renders command IDs in the commands view', () => { + const registry = setup(); + render(); + expect(screen.getByText('app.search')).toBeDefined(); + expect(screen.getByText('app.exp.thing')).toBeDefined(); + }); + + it('renders tier badges', () => { + const registry = setup(); + render(); + // Two badge spans should be present — one per command. + expect(screen.getAllByText(/stable/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/experimental/i).length).toBeGreaterThan(0); + }); + + it('shows the dispatch-log tab when a log is provided', () => { + const registry = setup(); + const log = instrumentRegistry(registry); + render(); + expect(screen.getByText(/Dispatch log/)).toBeDefined(); + }); + + it('does NOT render the dispatch-log tab when no log is provided', () => { + const registry = setup(); + render(); + expect(screen.queryByText(/Dispatch log/)).toBeNull(); + }); + + it('renders the when-evaluator tab', () => { + const registry = setup(); + render(); + fireEvent.click(screen.getByText(/When evaluator/)); + expect(screen.getByText(/when-clause \(DSL\)/)).toBeDefined(); + // Default expression evaluates to true against the default context. + expect(screen.getByText(/Result: true/)).toBeDefined(); + }); +}); diff --git a/packages/devtools/src/inspector.tsx b/packages/devtools/src/inspector.tsx new file mode 100644 index 0000000..19ac783 --- /dev/null +++ b/packages/devtools/src/inspector.tsx @@ -0,0 +1,438 @@ +/** + * `` — embeddable React component for inspecting an acture + * registry in dev builds. Three sections: + * + * 1. Commands — filterable list with tier badges, descriptions, when. + * 2. Dispatch log — recent dispatches (wires through + * `instrumentRegistry` if provided). + * 3. When evaluator — small text input that compiles + evaluates a + * when-clause against an arbitrary JSON context, useful for + * debugging palette visibility. + * + * Styling: minimal inline styles only. No design-system dep. The host + * can theme via the `data-acture-devtools-*` attributes. + */ + +/// + +import { useMemo, useState, useSyncExternalStore } from 'react'; +import type { + AnyCommandRecord, + Context, + Registry, + Tier, +} from 'acture'; +import { compileWhen } from 'acture'; +import type { DispatchLog, DispatchLogEntry } from './dispatch-log.js'; + +export interface InspectorProps { + /** The acture registry to inspect. */ + readonly registry: Registry; + /** Optional dispatch log produced by `instrumentRegistry(registry)`. + * If absent, the dispatch-log tab is hidden. */ + readonly log?: DispatchLog; + /** Initial tier filter. Default: 'all'. */ + readonly initialTiers?: readonly Tier[] | 'all'; + readonly className?: string; + readonly style?: React.CSSProperties; +} + +type Tab = 'commands' | 'log' | 'when'; + +const ALL_TIERS: readonly Tier[] = ['stable', 'experimental', 'deprecated', 'internal']; + +export function Inspector(props: InspectorProps): React.ReactElement { + const { registry, log, initialTiers = 'all', className, style } = props; + + const [tab, setTab] = useState('commands'); + const [tiers, setTiers] = useState(initialTiers); + const [filter, setFilter] = useState(''); + + // Subscribe to registry changes so the commands view stays fresh. + const revision = useSyncExternalStore( + (onChange) => registry.onCommandsChanged(() => onChange()), + () => registry.size(), + () => 0, + ); + + const commands = useMemo(() => { + const list = registry.list({ tiers }); + if (filter.trim().length === 0) return list; + const f = filter.toLowerCase(); + return list.filter( + (c) => + c.id.toLowerCase().includes(f) || + c.title.toLowerCase().includes(f) || + (c.description ?? '').toLowerCase().includes(f), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [registry, revision, tiers, filter]); + + return ( +

+
+ setTab('commands')}> + Commands ({commands.length}) + + {log ? ( + setTab('log')}> + Dispatch log ({log.entries.length}) + + ) : null} + setTab('when')}> + When evaluator + +
+ {tab === 'commands' ? ( + + ) : null} + {tab === 'log' && log ? : null} + {tab === 'when' ? : null} +
+ ); +} + +/* ─────────────────────────── tabs ──────────────────────────────────── */ + +function TabButton(props: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}): React.ReactElement { + return ( + + ); +} + +/* ────────────────────────── commands view ─────────────────────────── */ + +function CommandsView(props: { + commands: readonly AnyCommandRecord[]; + tiers: readonly Tier[] | 'all'; + setTiers: (t: readonly Tier[] | 'all') => void; + filter: string; + setFilter: (f: string) => void; +}): React.ReactElement { + return ( +
+
+ props.setFilter(e.target.value)} + style={{ flex: 1, minWidth: 200, padding: '4px 6px', fontSize: 12 }} + /> + +
+ + + + + + + + + + + + {props.commands.map((c) => ( + + + + + + + + ))} + +
idtitletierparamswhen
{c.id}{c.title} + + {c.params ? 'yes' : '—'} + {c.when === undefined + ? '—' + : typeof c.when === 'string' + ? {c.when} + : function} +
+
+ ); +} + +function TierFilter(props: { + value: readonly Tier[] | 'all'; + onChange: (v: readonly Tier[] | 'all') => void; +}): React.ReactElement { + const isAll = props.value === 'all'; + const set = new Set(Array.isArray(props.value) ? props.value : []); + function toggle(t: Tier): void { + if (isAll) { + // Switching off "all" — start with everything but this one. + props.onChange(ALL_TIERS.filter((x) => x !== t)); + return; + } + const next = new Set(set); + if (next.has(t)) next.delete(t); + else next.add(t); + if (next.size === ALL_TIERS.length) { + props.onChange('all'); + return; + } + props.onChange(Array.from(next)); + } + return ( +
+ tiers: + {ALL_TIERS.map((t) => { + const on = isAll || set.has(t); + return ( + + ); + })} +
+ ); +} + +function TierBadge({ tier }: { tier: Tier }): React.ReactElement { + return ( + + {tier} + + ); +} + +function tierColor(tier: Tier): string { + switch (tier) { + case 'stable': return '#2e7d32'; + case 'experimental': return '#f9a825'; + case 'deprecated': return '#c62828'; + case 'internal': return '#5f5f5f'; + } +} + +/* ────────────────────────── dispatch log view ─────────────────────── */ + +function DispatchLogView({ log }: { log: DispatchLog }): React.ReactElement { + const revision = useSyncExternalStore( + (onChange) => log.subscribe(() => onChange()), + () => log.entries.length, + () => 0, + ); + // `revision` re-triggers render; entries themselves are read fresh. + void revision; + return ( +
+
+ most recent first + +
+ + + + + + + + + + + + + {[...log.entries].reverse().map((e) => ( + + ))} + +
tcommandokparamsresult / errorms
+
+ ); +} + +function DispatchLogRow({ entry }: { entry: DispatchLogEntry }): React.ReactElement { + const ok = entry.result.ok; + const summary = ok + ? JSON.stringify(entry.result.value).slice(0, 80) + : `${entry.result.error.code}: ${entry.result.error.message.slice(0, 80)}`; + return ( + + {formatTime(entry.timestamp)} + {entry.commandId} + + {ok ? '✓' : '✗'} + + + {truncate(JSON.stringify(entry.params), 60)} + + + {truncate(summary, 80)} + + {entry.durationMs.toFixed(1)} + + ); +} + +function formatTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${(ms % 1000).toString().padStart(3, '0')}`; +} + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n - 1) + '…'; +} + +/* ────────────────────────── when evaluator view ───────────────────── */ + +function WhenView(): React.ReactElement { + const [whenSrc, setWhenSrc] = useState('editor.focused == true'); + const [ctxSrc, setCtxSrc] = useState('{\n "editor": { "focused": true }\n}'); + + const evaluation = useMemo(() => { + let ctx: Context; + try { + const parsed = JSON.parse(ctxSrc) as unknown; + if (parsed === null || typeof parsed !== 'object') { + return { error: 'context must be a JSON object' }; + } + ctx = parsed as Context; + } catch (e) { + return { error: `context JSON: ${(e as Error).message}` }; + } + try { + const compiled = compileWhen(whenSrc); + return { value: compiled.evaluate(ctx) }; + } catch (e) { + return { error: (e as Error).message }; + } + }, [whenSrc, ctxSrc]); + + return ( +
+