Skip to content

feat(napi): generate CommandCore mutable handle class via fluessig#318

Merged
zmaril merged 1 commit into
mainfrom
napi-fluessig/command-core
Jul 21, 2026
Merged

feat(napi): generate CommandCore mutable handle class via fluessig#318
zmaril merged 1 commit into
mainfrom
napi-fluessig/command-core

Conversation

@zmaril

@zmaril zmaril commented Jul 21, 2026

Copy link
Copy Markdown
Owner

What

First mutable-state opaque-handle class swap in the pidgin-napi → fluessig campaign: moves the hand-written #[napi] pub struct CommandCore (backing packages/coding-agent/src/core/package-manager.ts) onto the fluessig-generated napi surface. Module 6 proved an immutable handle (KeybindingsManagerCore); this is the strategic prove that the pattern extends to a handle carrying cross-call mutable state — the shape shared by 8 of the 11 Bucket-D classes (~79 of the remaining exports).

Stacked on napi-fluessig/keybindings-manager (the current stack tip).

The mutable-state bridge

The boxed CommandFlowMachine carries step state across calls — its start/advance take &mut self. But fluessig's generated handle methods always take &self (the trait bound is Sized + Send + Sync + 'static, and the class holds Arc<…Impl>). The bridge is interior mutability in the core impl: CommandCoreImpl holds the machine behind a std::sync::Mutex<Box<dyn CommandFlowMachine + Send>>, and its &self trait methods lock and drive the &mut self machine. A single JS caller never contends, so the lock is uncontended in practice. RefCell is not an option (the trait requires Sync); Mutex is the shim.

The generated .d.ts/.js are unaffected by this — self-mutability isn't in the TS type — so byte-parity holds.

Before / After

Before — hand-written in src/command_core.rs:

#[napi(js_name = "CommandCore")]
pub struct CommandCore { machine: Box<dyn CommandFlowMachine> }
#[napi]
impl CommandCore {
    #[napi(constructor)] pub fn new(op: String, params_json: String) -> Result<Self> {}
    #[napi] pub fn start(&mut self) -> Result<String> {}
    #[napi] pub fn advance(&mut self, output_json: String) -> Result<String> {}
}

After — authored as a ctor interface in schema/api.json (ctor op + start/advance unary ops), regenerated by regen.sh into src/generated.rs as #[napi] pub struct CommandCore { core: Arc<CommandCoreImpl> } with the ctor building the core via the generated CommandCoreCore trait and each &self method delegating through self.core. The engine seam (CommandCoreImpl) lives in src/core_impl.rs, holding the machine behind a Mutex and routing to the same command-flow planning logic — reproducing the hand-written error messages verbatim through anyhow. src/command_core.rs and its mod in lib.rs are deleted. catalog.json needs no change (no new DTO/scalar — both carriers are string).

How

  • Class picked: CommandCore — the simplest mutable Bucket-D handle: ctor + only 2 methods, all-string carriers in both directions (no float / bytes / DTO to confound the mutable-state variable), fallible exactly like module 6, dedicated status=native oracle. StdinBufferCore was the alternative but adds a nullable-int ctor param + a DTO-array return; CommandCore isolates the one new thing being proved.
  • The mutable-handle path worked end-to-end — no fluessig codegen gap. fluessig (pinned 1fed77a) lowered the ctor interface to the Arc<…Impl> handle class + …Core trait with &self methods exactly as for the immutable case; the interior-mutability Mutex in core_impl carries the &mut self machine, and the regen.sh output was purely additive (44 insertions, 0 deletions).

Gates

  • .d.ts: CommandCore class block byte-identical to the pre-swap baseline (JSDoc + all 3 members). Whole-file diff is a pure relocation of the block into the generated-module region.
  • .js: class entries byte-identical. module.exports.CommandCore = CommandCore is byte-identical text; the CommandCore nativeBinding token is unchanged — the only delta is the symbol relocating from the lib.rs region into the generated export block (every prior swap did the same; napi orders index.js by crate registration order).
  • cargo build -p pidgin-napi, cargo fmt --all --check, cargo clippy -p pidgin-napi --all-targets -- -D warnings — clean. codespell 0.
  • straitjacket v0.2.3 over git archive HEAD: 876 files, 0 errors, 1 conformance/STEWARD.md slop-prose WARN (pre-existing, allowed).
  • Oracle packages/coding-agent/test/package-manager.test.ts against the locally built addon: 116/116 pass — critically, this exercises the ctor + start/advance state transitions, validating the interior-mutability shim behaves identically.

🤖 Generated with Claude Code

https://claude.ai/code/session_017xL2W7dkhPsTSRuXQNCDeY


Generated by Claude Code

@zmaril
zmaril force-pushed the napi-fluessig/command-core branch from 5651c02 to 86154cd Compare July 21, 2026 09:00
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

Conformance smoke: agent + ai + tui

  • pi_sha: 3da591ab74ab9ab407e72ed882600b2c851fae21
  • manifest_native_modules: 54

Headline is rust-backed: passing cases in files whose module-under-test is a native (Rust addon) module. Raw all-pass is shown secondary — it is inflated by unflipped TypeScript that passes without touching any Rust.

Package rust-backed Native modules raw pass (secondary) Failing Skipped
agent 73/180 (40.6%) 7 180/180 0 0
ai 81/1296 (6.3%) 11 553/1296 3 740
tui 375/678 (55.3%) 14 678/678 0 0
smoke total 529/2154 (24.6%) 32 1411/2154 3 740

agent: 73/180 (40.6%) · ai: 81/1296 (6.3%) · tui: 375/678 (55.3%)

rust-backed = passing / total tests run, per the manifest's per-native-row tests lists. The Native modules column counts modules served by the Rust addon. This is the agent+ai+tui smoke subset; the full baseline lives in committed conformance.json.

Attribution

A pi test file is rust-backed iff the module it primarily exercises — its module-under-test — is status=native in conformance/manifest.json. A package's rust-backed count is the passing cases in its rust-backed files. Transitive or infrastructure use does not count (e.g. ~30 ai files construct the native faux provider but test other things — only faux-provider.test.ts, whose subject is faux, counts). A file that substantially tests both a native and an original module is excluded rather than counted, so the number under-reports rather than over-claims.

Per-file decisions (from each native manifest row's tests list):

Native module Test file Decision
ai/api/anthropic-messages.ts test/anthropic-sse-parsing.test.ts counted
ai/providers/faux.ts test/faux-provider.test.ts counted
coding-agent/utils/ansi.ts test/ansi-utils.test.ts counted
coding-agent/utils/changelog.ts test/changelog.test.ts counted
coding-agent/utils/git.ts test/git-ssh-url.test.ts counted
coding-agent/core/tools/path-utils.ts test/path-utils.test.ts counted
tui/keys.ts test/keys.test.ts counted
tui/utils.ts test/truncate-to-width.test.ts, test/regression-regional-indicator-width.test.ts counted
coding-agent/utils/mime.ts test/image-process.test.ts mixed — excluded (2 of 3 cases test processImage/original)
coding-agent/utils/version-check.ts test/version-check.test.ts mixed — excluded (fetch cases mock the original; not separable per-file)
coding-agent/core/export-html/ansi-to-html.ts test/export-html-whitespace.test.ts mixed — excluded (asset-grep + tool-renderer/original dominate)
coding-agent/core/tools/truncate.ts no dedicated test (exercised via tools.test.ts, subject is the read/edit factory/original)
coding-agent/core/tools/edit-diff.ts no dedicated test (edit-tool tests' subject is the edit tool/original)

CLI conformance (black-box, against the pidgin binary)

  • CLI conformance: 15/15 pass against target/release/pidgin (pass delta ±0, fail delta ±0).
File Passing Failing Skipped
packages/coding-agent/test/session-file-invalid.test.ts 1 0 0
packages/coding-agent/test/session-id-readonly.test.ts 7 0 0
packages/coding-agent/test/startup-session-name.test.ts 2 0 0
packages/coding-agent/test/stdout-cleanliness.test.ts 5 0 0

The four repointed coding-agent CLI test files spawn the compiled pidgin binary via $PIDGIN_BIN instead of pi's own cli.ts. This is a separate signal from the module smoke table and is never folded into the per-package Native count.

@zmaril
zmaril marked this pull request as ready for review July 21, 2026 09:52
@zmaril
zmaril force-pushed the napi-fluessig/keybindings-manager branch from 396d767 to 318fdb9 Compare July 21, 2026 22:59
Base automatically changed from napi-fluessig/keybindings-manager to main July 21, 2026 23:18
Swap the hand-written `#[napi] CommandCore` package-manager command-flow
handle to the fluessig-generated surface. This is the first mutable-state
handle class to migrate: the boxed `CommandFlowMachine` carries cross-step
state (`start`/`advance` take `&mut self`), while fluessig's generated handle
methods take `&self`. The bridge is interior mutability in the core impl:
`CommandCoreImpl` holds the machine behind a `std::sync::Mutex`, and its
`&self` trait methods lock and drive the `&mut self` machine.

- Author the `CommandCore` ctor interface + `start`/`advance` ops in
  `schema/api.json` (catalog.json needs no change — no new DTO/scalar).
- Move the command-flow driver logic (params parsing, `build_machine`,
  `step_to_json`) into `core_impl.rs` as `CommandCoreImpl`, reproducing the
  pre-swap error messages verbatim through `anyhow` so the thrown JS reasons
  are byte-identical.
- Regenerate `src/generated.rs`; delete the hand-written `src/command_core.rs`
  and its `mod` in `lib.rs`.

The generated `.d.ts` block, `.js` export, and nativeBinding entry for
`CommandCore` are byte-identical to the pre-swap build (only their file
position relocates into the generated-module region, as with the prior
KeybindingsManagerCore swap). Oracle `test/package-manager.test.ts` passes
116/116 against the locally built addon.
@zmaril
zmaril force-pushed the napi-fluessig/command-core branch from 86154cd to 0e44260 Compare July 21, 2026 23:33
@zmaril
zmaril merged commit 3eba71a into main Jul 21, 2026
13 checks passed
@zmaril
zmaril deleted the napi-fluessig/command-core branch July 21, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants