Files
msd-core/docs/adr/457-generated-cjs-single-source.md
Tom Boucher aee83c8e9e test(#3337): fold the manifest & package-identity issue-* cluster — Wave 5 (#3378)
* test(#3337): fold the manifest & package-identity issue-* cluster — Wave 5

Folds 6 legacy issue-*.test.cjs regression files (120 test() blocks) into
their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053).
Second of 4 issue-* waves.

- issue-766-plugin-manifest.test.cjs (50 tests): pure rename (git mv) into
  plugin-manifest.test.cjs, sole comprehensive suite for its module.
- issue-844-manifest-version-sync.test.cjs (14 tests, rename basis) +
  issue-1855-marketplace-manifest.test.cjs (17 tests, merged in): both
  target scripts/sync-manifest-versions.cjs via non-overlapping describe
  blocks (generic engine vs. marketplace.json-specific), now
  manifest-version-sync.test.cjs, 31 tests, 0 dropped.
- issue-498-identity-drift-lint.test.cjs (8 tests, rename basis) +
  issue-498-package-identity.test.cjs (17 tests, merged in): both target
  package-identity.cjs's surface from different angles (lint-side drift
  detection vs. derive/slugify), now package-identity.test.cjs, 25 tests,
  0 dropped.
- issue-607-cache-lineage.test.cjs (14 tests) merged into the existing
  gsd-statusline.test.cjs, matching its already-established fold-wrapper
  convention from prior waves.

Learned from Wave 4: fold agents checked src/** (not just docs/ and
gsd-core/references/) for stale filename references — found and fixed 5
across 3 ADR docs (766, 2121, 457), zero in src/ this time.

Zero net test-coverage loss. No production code changed.

* test(#3337): fix orthogonal-review findings — Wave 5 fold

Standards-axis review found real issues in the just-folded
manifest-version-sync.test.cjs, both fixed here:

- The folded:issue-1855-marketplace-manifest block omitted its own
  test/describe/assert/fs/path/os requires, silently closing over the
  #844 basis section's outer-scope bindings instead of declaring its own
  dependencies — inconsistent with every other fold block in this wave.
  Restored the local requires the original issue-1855 file declared.
- Merging two independently-lettered legacy files (each A-F) left
  duplicate top-level describe() labels (two "A:", two "B:", two "C:").
  Renamed the folded-in #1855 labels (A2/B4/C2) to make every top-level
  label in the file unique.

No test() count changed (31). No production code touched.

---------

Co-authored-by: sim <sim@local>
2026-08-11 23:55:11 -04:00

9.5 KiB
Raw 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", gsd-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)

  • gsd-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 gsd-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 GSD stages are synthetic {"type":"commonjs"} markers — and since #2544 those sit inside the directories GSD 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 gsd-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-gsd-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