Skip to content

T-WGV4-runtime-agnostic-shipped-code

Status: closed/done · Impact: high · Complexity: medium

D-0014 rules that shipped code (plugin/lib/, plugin/cli/) is JS-runtime-agnostic: node: builtins and web standards only, so plain Node can run the CLI. Today a bounded set of Bun-only constructs breaks that — most dangerously the import.meta.main entry guard in plugin/cli/sdlc.ts, which is undefined under Node ≤23, so the CLI would silently do nothing. This task removes every Bun coupling from shipped code and adds a gate so it cannot return unnoticed.

Partial progress — PR #692 (2026-07-12). The Bun.* globals are already out of shipped code: Bun.spawnSyncnode:child_process (registry, cleanup, doctor, scan-candidates, markdown-fixtures), Bun.Glob → tinyglobby (resolve-touchpoints), Bun.serve@hono/node-server (dashboard), plus a project-check-no-bun-in-shipped lefthook gate. Still open on this task: (a) AC-1’s import.meta.dir (~25 sites) and import.meta.main entry guards — including plugin/cli/sdlc.ts, the most dangerous Bun-ism — which #692 did NOT touch; (b) AC-2 wants a sdlc gate runtime-agnostic verb wired into sdlc.yaml quality_checks, not the interim lefthook check; (c) AC-4’s plain-node run is unverified; (d) plugin/lib/util/runtime.ts. When finishing, fold the lefthook gate into the verb and widen it to import.meta.*.

LocationRole today
plugin/lib/registry.ts#spawnRunnerdefault CommandRunner seam; Bun.spawnSync for git/gh subprocesses
plugin/lib/services/project/cleanup/index.ts#makeRunFnBun.spawnSync for merge-base / worktree probes
plugin/lib/services/project/ops/doctor.ts#probeBinaryBun.spawnSync --version presence probe (git/gh/claude); typeof Bun runtime probe (already portable)
plugin/lib/services/project/ops/scan-candidates.tsBun.spawnSync git check-ignore --stdin -z with NUL-delimited stdin blob
plugin/lib/services/gate/ops/markdown-fixtures.tsBun.spawnSync of literal bunx rumdl@<pinned>; checks Bun-only proc.success
plugin/lib/services/dashboard/server.tsBun.serve over a Hono app (routes / and /api/state, no websockets/static mounts); detached re-spawn via spawn("bun", ["run", entry])
plugin/lib/model/entities/task/ops/resolve-touchpoints.ts#globMatchesBun.Glob().scanSync existence probe for touchpoint globs
plugin/lib/services/docs/site.tsspawns helper via spawnSync("bun", ["run", helperPath]); module-relative paths via Bun-only import.meta.dir
plugin/cli/sdlc.tsCLI entrypoint guarded by Bun-only import.meta.main
plugin/cli/backlog_cli/create.tsspawns bun run + bunx rumdl; dir via new URL(import.meta.url).pathname (breaks on encoded/Windows paths); import.meta.main
plugin/lib/model/authoring.tsone of 23 import.meta.dir sites resolving templates/schemas module-relative (full list in Files to touch)
package.jsonhono present; @hono/node-server absent; runtime deps otherwise runtime-agnostic (tinyglobby already available for the glob swap)

Not affected: 111 test files across 29 tests/ dirs import bun:test — dev-side per D-0014, staying on Bun. Shipped code has no bun: imports, no Bun.file/Bun.env/Bun.write, and no reliance on Bun’s .env autoload (all process.env reads are OS/CI/plugin vars).

Shipped trees contain zero Bun-only constructs. A shared plugin/lib/util/runtime.ts provides the four portable idioms (module dir, is-main guard, current-runtime exec path, bunx/npx picker); every former call site uses it or a direct node: API. The dashboard serves through @hono/node-server. A sdlc gate runtime-agnostic op scans shipped trees for the forbidden tokens and runs in the quality-check suite, so reintroduction fails CI. In-repo, plain Node ≥24 can run the CLI end to end (type stripping applies — the node_modules refusal is a package concern, not an in-repo one).

  1. Add plugin/lib/util/runtime.ts with unit test: moduleDir(metaUrl)dirname(fileURLToPath(metaUrl)); isMain(metaUrl) → compare against pathToFileURL(process.argv[1]).href; runtimeExec()process.execPath; xRunner()"bunx" when typeof Bun !== "undefined", else "npx".
  2. Mechanical sweep: 23 import.meta.dir sites → moduleDir(import.meta.url); 7 import.meta.main guards → isMain(import.meta.url). Fix backlog_cli/create.ts’s new URL(...).pathname to moduleDir in the same pass.
  3. Replace the five Bun.spawnSync calls with node:child_process spawnSync. API mapping: exitCodestatus, successstatus === 0, stdin:input: (the scan-candidates.ts NUL-stdin case), stdout/stderr buffers unchanged; markdown-fixtures.ts’s exitCode === null check becomes status === null (+ error/signal).
  4. Replace Bun.Glob in resolve-touchpoints.ts#globMatches with tinyglobby.globSync(rel, { cwd: projectRoot, dot: true }), preserving the directories-match-too semantics of onlyFiles: false (tinyglobby includes dirs via onlyDirectories:false default — add a test row covering a directory-form touchpoint to pin parity).
  5. Dashboard: add @hono/node-server; Bun.serve({...})serve({ fetch: app.fetch, hostname, port }); read the bound port from server.address(); server.stop(true)server.close() + closeAllConnections() in the SIGTERM/SIGINT handler; the detached re-spawn replaces spawn("bun", ["run", entry]) with spawn(runtimeExec(), [entry]) (both Bun and Node accept a direct entry file; run is dropped).
  6. Remaining literal bun/bunx spawns: docs/site.ts helper and backlog_cli/create.ts CLI re-invocation → runtimeExec(); the two pinned-rumdl invocations → xRunner().
  7. Add plugin/lib/services/gate/ops/runtime-agnostic.ts (path: ['gate','runtime-agnostic']): scans plugin/lib/ + plugin/cli/ (excluding tests/, *.test.ts, _example/) for Bun., from "bun, import.meta.dir, import.meta.main; exits non-zero listing file:line per hit. Test with an in-memory/fixture violation. Append to sdlc.yaml quality_checks.
  8. Verify under both runtimes: full Bun quality suite, then in-repo Node smoke (node plugin/cli/sdlc.ts --help, node plugin/cli/sdlc.ts project doctor --output json) on Node ≥24. If a type-stripping limitation surfaces (e.g. a JSON import needing attributes), fix it here — the built-artifact path (T-XTGT) must not be the first place shipped code meets Node.
LocationKindChange
plugin/lib/util/runtime.tsnewportable runtime idioms: moduleDir, isMain, runtimeExec, xRunner
plugin/lib/util/tests/modifyunit test for runtime.ts
plugin/lib/registry.tsmodifyBun.spawnSyncnode:child_process in spawnRunner
plugin/lib/services/project/cleanup/index.tsmodifyBun.spawnSyncnode:child_process in makeRunFn
plugin/lib/services/project/ops/doctor.tsmodifyBun.spawnSyncnode:child_process in probeBinary
plugin/lib/services/project/ops/scan-candidates.tsmodifyBun.spawnSyncspawnSync with input: for NUL stdin
plugin/lib/services/gate/ops/markdown-fixtures.tsmodifyBun.spawnSync+bunxspawnSync+xRunner(); success/exitCode mapping
plugin/lib/services/dashboard/server.tsmodifyBun.serve@hono/node-server; port via address(); stop(true)close(); re-spawn via runtimeExec()
plugin/lib/model/entities/task/ops/resolve-touchpoints.tsmodifyBun.Globtinyglobby.globSync; parity test for dir-form touchpoints
plugin/lib/services/docs/site.tsmodifybun run spawn → runtimeExec(); 3 import.meta.dir sites → moduleDir
plugin/cli/sdlc.tsmodifyimport.meta.mainisMain (entry guard)
plugin/cli/backlog_cli/create.tsmodifybun run/bunx spawns → helpers; URL(...).pathnamemoduleDir; isMain
plugin/cli/backlog_cli/capture.tsmodifyimport.meta.mainisMain
plugin/cli/backlog_cli/index.tsmodifyimport.meta.mainisMain
plugin/lib/services/docs/generate.tsmodify4 import.meta.dir sites → moduleDir; import.meta.mainisMain
plugin/lib/services/dashboard/dashboard-service.tsmodifyimport.meta.mainisMain
plugin/lib/services/dashboard/gen-client.tsmodifyimport.meta.dirmoduleDir; import.meta.mainisMain
plugin/lib/intersect/fixtures.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/authoring.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/ops/migrate.tsmodifydirname(import.meta.dir)moduleDir-based
plugin/lib/model/ops/audit.tsmodifydirname(import.meta.dir)moduleDir-based
plugin/lib/model/entities/backlog/ops/update.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/principle/reports/review/schema.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/standard/ops/update.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/standard/ops/supersede.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/task/commits/lifecycle/schema.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/task/ops/close-commit.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/task/ops/update.tsmodifyimport.meta.dirmoduleDir
plugin/lib/model/entities/task/reports/review/schema.tsmodifyimport.meta.dirmoduleDir
plugin/lib/services/plugin/info-report.tsmodifyresolve(import.meta.dir, ...)moduleDir-based
plugin/lib/services/project/ops/setup.tsmodifyimport.meta.dirmoduleDir
plugin/lib/services/docs/site_render.tsmodifyimport.meta.dirmoduleDir
plugin/lib/services/gate/ops/runtime-agnostic.tsnewgate op scanning shipped trees for Bun-only tokens
plugin/lib/services/gate/tests/modifytest for the new gate op (clean pass + seeded violation)
package.jsonmodifyadd @hono/node-server runtime dep (lockfile updates alongside)
sdlc.yamlmodifyappend gate runtime-agnostic to quality_checks
  • AC-1: grep -rE 'Bun\.|from "bun|import\.meta\.dir|import\.meta\.main' plugin/lib plugin/cli --include='*.ts' — excluding tests/ dirs, *.test.ts, and _example/ — returns zero matches.
  • AC-2: sdlc gate runtime-agnostic exits 0 on the clean tree, exits non-zero naming file:line when a violation exists (proven by test), and is listed in sdlc.yaml quality_checks.
  • AC-3: the existing quality suite stays green under Bun: bunx tsc --noEmit, bun test, bun test ./.claude.
  • AC-4: on Node ≥24, in-repo: node plugin/cli/sdlc.ts --help exits 0 and node plugin/cli/sdlc.ts project doctor --output json emits valid JSON reporting runtime node.
  • AC-5: dashboard lifecycle unchanged under Bun: sdlc dashboard start/stop serve / and /api/state and shut down gracefully on SIGTERM (existing tests pass).
  • AC-6: @hono/node-server is the only new runtime dependency added by this task.
  • The publish-time build, package rename, and files: allowlist (T-XTGT).
  • Skill-corpus invocation normalization and launcher runtime resolution (T-ITMN).
  • Package-root vs project-root path assumptions (T-T0O9) — this task keeps module-relative resolution semantics identical, only making them portable.
  • Tests stay on bun:test; root package.json scripts stay Bun (dev-side per D-0014).
  • The pinned-rumdl version policy in the markdown gates (unchanged, just re-spawned portably).
  • none — first wave of the D-0014 migration; T-XTGT and T-ITMN build on it.
  • D-0014 Migration step 2 (docs/planning/decisions/D-0014-cli-primary-npm-distribution.md).
  • Call-site inventory from a full repo scan on 2026-07-02: 5 Bun.spawnSync, 1 Bun.serve, 1 Bun.Glob, 23 import.meta.dir, 7 import.meta.main, 3 literal bun spawns, 2 bunx spawns; no bun: imports in shipped code.

← Back to Tasks