Files
msd-core/docs/adr/457-generated-cjs-single-source.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

9.5 KiB
Raw Permalink Blame History

ADR 457: Generation model for bin/lib/*.cjs type safety [Accepted]

  • Status: Accepted
  • Date: 2026-05-28 (rewritten and accepted 2026-05-31 after correcting fabricated context)

Accepted direction: build-at-publish (model 2 below). Implementation is tracked in a separate migration issue and proceeds module-by-module. The sole prerequisite — the ESLint harness (ADR 452) — is already accepted/merged.

Provenance note. An earlier draft of this ADR (and issue #457) was authored by an agent and asserted a codebase state that did not exist — "~13 files generated from .ts via tsc", msd-core/src/ / sdk/src/ source trees, and a tests/cjs-ts-parity.test.cjs. None of those existed. This rewrite grounds the decision in verified ground truth. Do not restore the earlier "natural completion of the 13 generated files" framing; it was fiction.

Context

What actually exists today (verified 2026-05-31)

  • msd-core/bin/lib/ holds 84 .cjs files. Exactly one carries a // @generated header: package-identity.cjs.
  • That one generated file is not tsc output. It is produced by scripts/generate-package-identity.cjs — a plain Node script that reads package.json and bakes literal coordinate values into a CJS module.
  • There is no msd-core/src/ or sdk/src/ TypeScript tree. There is no TS→CJS transpilation pipeline. There is no tests/cjs-ts-parity.test.cjs. The only parity test is tests/package-identity.test.cjs, scoped to the one baked file: it regenerates from package.json and asserts the committed output is not stale.
  • tsconfig.lint.json exists with allowJs + checkJs, but it is not wired into eslint.config.mjs. The .cjs config block (eslint.config.mjs lines 61–92) sets only sourceType/globals — no parser, no parserOptions.project, no projectService, and no @typescript-eslint rule is enabled. A comment on line 60 nonetheless claims "Type-aware via parserOptions.project=tsconfig.lint.json" — so the file is linted without type information while the config advertises the opposite.
  • eslint.config.mjs lists 12 files in GENERATED_CJS_IGNORES and treats them as generated (never linted) — but those 12 are all hand-written (verified: none carry an @generated header). This is a latent inconsistency: the lint config already pretends a generation pipeline exists for them.

So the real situation is: 83 hand-written .cjs, 1 value-baked .cjs, and a lint config that already advertises — in a comment and in a 12-file ignore list — a type-aware generation pipeline that was never built.

Two different things are both called "generation"

This distinction is the crux of the decision, and the earlier draft erased it:

  • Value baking (exists, forced). package-identity.cjs must be generated because the installed tree carries no package.json with a .name. The only ones MSD stages are synthetic {"type":"commonjs"} markers — and since #2544 those sit inside the directories MSD owns (hooks/, and the native plugin dir), not at the runtime config root — so a runtime require('package.json').name is undefined where it resolves at all, and a MODULE_NOT_FOUND where it does not (bug #378; see also the Codex case in src/runtime-artifact-conversion.cts, whose root never carried one). The values literally cannot be read at runtime; baking them at build time is the only option. Deletion test: remove the generator and the complexity reappears across every consumer. It is a deep seam and earns its keep.

  • Transpilation (proposed, optional). Authoring bin/lib logic as TS and emitting .cjs via tsc. Deletion test: remove it and nothing reappears — a hand-written .cjs and a tsc-emitted .cjs are behaviorally identical at runtime. The seam buys no runtime leverage. Its entire value is author-time and CI type checking.

package-identity is therefore not precedent for the proposed transpilation work. They are different techniques with different forcing functions.

The problem actually worth solving

Type safety on the hand-written runtime surface is second-class: type errors surface (if at all) as lint findings via the un-wired tsconfig.lint.json, not as compile errors. Any contributor — human or agent — adding a bin/lib file must decide hand-write vs generate, with no enforced answer. That inconsistency is real and grows.

The decision this ADR must make

Type-checking TS sources is the goal. The load-bearing question the earlier draft skipped is: do we check the generated .cjs into git, or treat it as a build artifact? Three models:

  1. Check in both .ts source and .cjs output. Creates a permanent "two copies must match" invariant, requiring parity tests, dual commits, and a pre-commit/CI drift gate. This is the model the earlier draft assumed — inherited from value-baking, where checking in output is forced. For transpilation, nothing forces it, so this imports maximum friction for no runtime gain.

  2. Build at publish (recommended). bin/lib/*.cjs becomes a gitignored build artifact emitted from a TS src/ tree by tsc; npm publishes the built output. Feasible today: package.json already ships msd-core and scripts via its files array, and already runs a pre-publish build step ("prepublishOnly": "npm run build:hooks") — the .cjs emit hooks into the same step, and npm pack includes on-disk artifacts regardless of .gitignore. No drift invariant, no parity test, no dual commits — the "Negative" consequences below mostly evaporate. Cost: contributors run a build to exercise local changes, and CI must build before test.

  3. Build at install. Rejected: fragile across Node versions and platforms (CONTEXT.md notes Windows / Node 24 hazards) and slows every install.

Decision [Accepted]

  1. Pursue type safety via a TS src/ tree compiled with tsc, adopting model 2 (build at publish): TS source is canonical, .cjs is a gitignored artifact. This dissolves the drift-policing machinery rather than building it.
  2. Keep value baking separate. package-identity.cjs stays a checked-in baked artifact under its existing generator and parity test; the install-tree #378 constraint is unaffected by this ADR.
  3. Migrate incrementally, lowest-coupling module first, behind one pilot PR that stands up the src/ tree + build wiring for a single module before any bulk move.
  4. Reconcile the lint config to reality first. The 12-entry GENERATED_CJS_IGNORES list currently lies about hand-written files; it must be corrected (those files linted as hand-written) before, not after, a pipeline exists — otherwise the inconsistency masks the migration's progress.
  5. Wire type-aware linting to the real tsconfig.json as modules become TS; retire tsconfig.lint.json only when the last hand-written .cjs is gone.

Consequences

Positive

  • Type-aware typescript-eslint rules apply to migrated runtime code.
  • No checked-in generated .cjs, so no drift invariant and no parity test to maintain for the transpiled surface (contrast: the rejected model 1).
  • One enforced answer to "hand-write or generate?" for new bin/lib code.

Negative

  • A build step now sits between editing src/*.ts and running bin/lib/*.cjs. Local dev and CI must build before exercising runtime behavior.
  • Migration touches ~83 files; each may surface latent type errors to fix.
  • Tooling that today reads bin/lib/*.cjs from a checkout (not an install) must build first or read from src/.

For testing

  • Tests importing bin/lib/*.cjs keep working only if the build has run; the test command must depend on the build. This is the main behavioral change versus today, where the .cjs is always present in the tree.

Rejected Alternatives

  • (a) Keep the split indefinitely — leaves the type-aware gap and the un-wired tsconfig.lint.json permanently. Rejected as a final state.
  • (b) Check in .ts + generated .cjs (model 1) — imports a drift invariant, parity tests, and dual commits for zero runtime benefit. Rejected in favor of build-at-publish.
  • (c) Full ESM rewrite — breaks require() consumers (no "type":"module" today); semver-major. Out of scope.
  • (d) Wire tsconfig.lint.json into ESLint and stop there — keeps the stopgap permanent and never delivers compile-level (vs lint-level) type errors. Rejected as the final state, but acceptable as an interim while the pilot proves out.

Open questions

  • Does any consumer rely on bin/lib/*.cjs being present in a raw (un-built) checkout? If so, build-at-publish needs a prepare-script bridge.
  • tsc CJS interop details (esModuleInterop, __importDefault shims) for the modules that re-require each other.
  • Whether the pilot should be a leaf utility or one of the 12 mislabeled GENERATED_CJS_IGNORES files (which already advertise themselves as generated).

References

  • ESLint harness prerequisite (accepted): 452-eslint-lint-harness.md
  • Test-rigor policies (accepted): 456-test-rigor-architecture.md
  • Single-runtime collapse (accepted): 0174-retire-msd-sdk-package-boundary.md
  • Superseded shared-module seam: 3524-cjs-sdk-hard-seam.md
  • Tracking issue: #457
  • Value-baking precedent (distinct technique): scripts/generate-package-identity.cjs, tests/package-identity.test.cjs