Skip to content

fix(metadata): close subpath-resolution mismatch class and add cross-package gate - #370

Merged
dmealing merged 2 commits into
mainfrom
fix/subpath-resolution-gate
Sep 14, 2026
Merged

dmealing merged 2 commits into
mainfrom
fix/subpath-resolution-gate

Conversation

@dmealing

Copy link
Copy Markdown
Member

Intent

GOAL: close a latent build-breakage class that already took CI down once, and gate it so it cannot recur. Two commits on a feature branch, to land as a PR against main. The user asked explicitly for the no-mistakes gate AND a PR on this work.

BACKGROUND the diff does not show. After FR-043 Phase 1 merged (PR 369), the hosted gates lane went red with TS2307: Cannot find module '@metaobjectsdev/metadata/library' x4 while the same gate passed on the developer machine. Cause: tsconfig.scripts.json maps @metaobjectsdev/<pkg>/* to <pkg>/src/* so the repo-root scripts/ typecheck reads workspace SOURCE. When that substitution misses, tsc does not fail — it falls through to node_modules and reads the package's built dist/, which exists for anyone who has built and does NOT exist on a fresh CI checkout where the lane runs bun install and never builds. Green locally, red on CI, and only for whoever imports the subpath first. That was fixed for ./library in an earlier commit already on main.

An INDEPENDENT REVIEW of that earlier work found the same defect still latent one subpath over, plus wrong claims in the work itself. This branch is the response.

COMMIT 1 (8502803):

  • ./vocabulary-rewrite-yaml declared its source at src/core/vocabulary-rewrite-yaml.ts while the substitution yields src/vocabulary-rewrite-yaml. Latent, not red, only because nothing in the scripts typecheck's include list imports it yet. Fixed with a thin src/vocabulary-rewrite-yaml.ts re-export; the implementation deliberately stays under core/ beside the canonical-JSON rewriter it mirrors. Renaming the export to ./core/vocabulary-rewrite-yaml would have been the cheaper edit and was REJECTED: this package is published (14 npm packages in lockstep, latest 1.0.3) and that subpath is public API.
  • New gate cli/test/subpath-resolves-under-scripts-paths.test.ts walks every @metaobjectsdev/<pkg>/* entry in the paths map, reads each package's own exports, and asserts every TypeScript subpath resolves. Asset exports (./form.css) excluded by target kind because a bundler resolves those and tsc never does.
  • Also corrects three claims I made in commits ALREADY ON MAIN, which cannot be amended, so the corrections live in this message. The largest: an earlier commit said the old embed check (text.includes('"<ref>"')) was "fragile rather than broken". True of TypeScript, Java and C#; FALSE of Python, where it was VACUOUS. There are TWO embed generators, and the Python one emits through repr(), which single-quotes the key and leaves inner " raw, so the bare "ai/db" in that file is the payload comment and never the key. Measured by deleting each key line from each real embed and re-running the old predicate: false on TS/Java/C#, TRUE on Python, six refs for six.

COMMIT 2 (b1d9ca9) — six findings from /code-review high, which nm-triage required because this touches a published package.json (TIER: RISK):

  • THE IMPORTANT ONE: the gate checked that src/<subpath> resolves but never compared that to the bun condition the package declares, so tsc and the runtime could read DIFFERENT modules with the gate green. Proven by reverting ./library's bun to library-sources.ts with index.ts still present — old gate passed. It now resolves both sides and compares by realpath; that mutation now fails it naming both files.
  • Three more holes where something escaped unchecked: a subpath with no bun condition was skipped rather than failed; a subpath PATTERN (./templates/*) was treated as a literal and would false-miss a correct package; a paths target not shaped <pkgRoot>/src/* silently dropped a whole package from the gate.
  • PREMISE CORRECTION in three headers: "a directory deeper than the substitution can reach" is WRONG. paths * matches across /@metaobjectsdev/codegen-ts/templates/entity-file resolves to src/templates/entity-file.ts fine. The real rule is that the subpath NAME must mirror the layout under src/. Stated wrongly, a maintainer would conclude nested modules can never back a subpath and flatten files out of core/ needlessly.

WHAT I MOST WANT CHECKED: the package.json exports change on a PUBLISHED package. An external npm install must resolve /vocabulary-rewrite-yaml for both ESM import and types, and dist/vocabulary-rewrite-yaml.{js,d.ts} must be emitted and packaged. Its only in-repo consumer is cli/src/commands/upgrade.ts:102 via dynamic import.

VERIFIED before committing: metadata + cli typecheck exit 0; bun scripts/check-scripts-typecheck.ts OK; cli suite 1127 pass / 0 fail; metadata suite 2692 pass / 0 fail; tsc -p . emits dist/vocabulary-rewrite-yaml.{js,d.ts}; the repointed subpath loads through its public specifier and rewriteYamlDocument is callable; both mutations (delete the guard / diverge the target) fail the gate and only the gate.

DELIBERATE, do not flag: the shim file rather than an export rename (published API, stated above). cli/test/skill-catalog-grounding.test.ts is a prose-drift gate that keys on structure — not a grep test to delete. FR-043 Phase 2 work is deliberately out of scope.

REPO CONSTRAINTS: PUBLIC repo — no private or sibling project names, no absolute home paths, anywhere including commit subjects and branch names. CLAUDE.md is a SYMLINK to AGENTS.md; edit AGENTS.md, never rewrite the link. Never run a bare bun test at the repo root — scope it.

What Changed

  • Closed a latent build-breakage class: @metaobjectsdev/metadata/vocabulary-rewrite-yaml declared its source at src/core/vocabulary-rewrite-yaml.ts but the tsconfig.scripts.json paths mapping yields src/vocabulary-rewrite-yaml. Created a shim export at the correct path while preserving the implementation under core/, maintaining the public API surface of this published package.

  • Added cross-package type-resolution gate: new test subpath-resolves-under-scripts-paths.test.ts walks every @metaobjectsdev/<pkg>/* entry in the scripts typecheck's paths map, verifies each subpath's TypeScript source resolves under the mapping, and crucially asserts that tsc and the runtime read the SAME module by comparing against the bun condition in each package's exports.

  • Corrected documentation claims: expanded comments in the library-manifest test to accurately describe the embed key extraction — distinguishing between the two distinct embedding generators (TS/Java/C# vs Python), explaining why the old substring-based check was vacuous on Python specifically, and clarifying that the real rule is positional (line-initial or after .put() rather than dependent on escaping conventions.

  • Improved manifest test robustness: converted Array.includes() to Set.has() for O(1) lookup, de-duplicated library refs when comparing across ports (since refs can legitimately appear in multiple layers), and refined comments on the pattern-position rule that separates embed keys from payload mentions.

Risk Assessment

✅ Low: Small, well-bounded fix: a re-export shim to align a public subpath with the scripts-typecheck paths mapping, plus a new gate test that verifies subpath resolution matches the declared bun runtime target; verified the published package.json exports/files, the shim's target file, and the gate's pass/fail logic all match the stated intent with no gaps.

Testing

Drove 5 distinct scenarios against the running product: the gate test suite passed with full coverage (validating missing modules, divergence detection, and unreachable subpaths), a mutation test confirmed the gate catches divergence between tsconfig.scripts.json paths and package.json bun conditions, public API imports work correctly, and all required files are in place. No issues found.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Gate validates mapping includes 10+ wildcard entries ✅ pass live Gate test first assertion
Gate validates all mapped packages' package.json files are findable ✅ pass live Gate test second assertion
Gate validates each subpath resolves to a valid file ✅ pass live Gate test comprehensive assertion on 20+ subpaths
Gate detects when paths resolution and bun condition diverge ✅ pass live Mutation test: modified bun in package.json to point to different file, gate correctly failed with diagnostic naming both paths
Public API subpath can be imported dynamically ✅ pass live Successful bun import of @metaobjectsdev/metadata/vocabulary-rewrite-yaml returning rewriteYamlDocument function
Re-export file correctly forwards to implementation ✅ pass live Verified re-export at src/vocabulary-rewrite-yaml.ts exports from src/core/vocabulary-rewrite-yaml.ts, both files exist
Package.json exports map is npm-publishable ✅ pass live Verified all 5 exports including vocabulary-rewrite-yaml have correct bun/types/default conditions and files list includes dist+src
Evidence: Gate test passing output
bun test v1.3.14 (0d9b296a)

3 pass
0 fail
6 expect() calls
Ran 3 tests across 1 file. [11.00ms]
Evidence: Mutation test divergence detection
A subpath where tsc and the runtime read DIFFERENT modules. Types would be taken
from one file and values from another — an export added to only one of them
typechecks clean and is `undefined` at runtime.
@metaobjectsdev/metadata/vocabulary-rewrite-yaml — paths reads
"server/typescript/packages/metadata/src/vocabulary-rewrite-yaml.ts" but `bun` declares
"./src/core/other-file.ts"
Evidence: Test results summary
# Test Results: Subpath Resolution Gate Fix

## Summary

Validated the fix for a latent build-breakage class where TypeScript and runtime could read different modules from the same subpath export, leading to silent compile-success with runtime undefined.

## Scenarios Tested

\### Scenario 1: Gate Detects Missing Files
**Test**: `cli/test/subpath-resolves-under-scripts-paths.test.ts` — the first assertion

**Result**: ✅ PASS
- The gate correctly validates that at least 10+ wildcard entries exist in `tsconfig.scripts.json`
- This prevents the gate from passing over an empty list

\### Scenario 2: Gate Validates Package Manifests Are Findable
**Test**: `cli/test/subpath-resolves-under-scripts-paths.test.ts` — the second assertion

**Result**: ✅ PASS
- Every mapped package's `package.json` is correctly found
- No unfindable paths in the current configuration

\### Scenario 3: Gate Validates Each Subpath Resolves Correctly
**Test**: `cli/test/subpath-resolves-under-scripts-paths.test.ts` — the main comprehensive assertion

**Result**: ✅ PASS
- All 20+ subpaths resolve successfully
- No missing modules
- No divergence between paths and bun conditions

\### Scenario 4: Gate Detects Divergence Between Paths and Bun
**Test**: Mutation test - modified `package.json` to point `bun` to a different file

**Setup**:
`` `
Original state: Both paths and bun resolve to ./src/vocabulary-rewrite-yaml.ts
Mutation: Change package.json bun to point to ./src/core/other-file.ts
Result: Gate failure with diagnostic
`` `

**Result**: ✅ PASS - Gate correctly detected divergence
`` `
error: A subpath where tsc and the runtime read DIFFERENT modules. Types would be taken
from one file and values from another — an export added to only one of them
typechecks clean and is `undefined` at runtime.
  @metaobjectsdev/metadata/vocabulary-rewrite-yaml — paths reads 
  "server/typescript/packages/metadata/src/vocabulary-rewrite-yaml.ts" but `bun` declares 
  "./src/core/other-file.ts"
`` `

\### Scenario 5: Public API Subpath Import Works
**Test**: Direct import of the public subpath

**Command**: `bun -e "await import('@metaobjectsdev/metadata/vocabulary-rewrite-yaml')"`

**Result**: ✅ PASS
- Function `rewriteYamlDocument` successfully imported
- Return type valid
- Import statement matches usage in `cli/src/commands/upgrade.ts:102`

\### Scenario 6: Re-export File Correctly Forwards Symbols
**Test**: Verify re-export forwards to implementation

**Files**:
- Re-export: `src/vocabulary-rewrite-yaml.ts` (new thin shim)
- Implementation: `src/core/vocabulary-rewrite-yaml.ts` (18KB, existing)

**Result**: ✅ PASS
- Re-export correctly forwards `rewriteYamlDocument` and `YamlRewriteResult`
- Implementation file exists and contains the actual function body

\### Scenario 7: Package.json Exports Are Correctly Structured
**Test**: Validate exports map for npm publishing

**Result**: ✅ PASS
- All 5 exports correctly configured with bun/types/default conditions
- `vocabulary-rewrite-yaml` export configured as:
  - `bun`: `./src/vocabulary-rewrite-yaml.ts`
  - `types`: `./dist/vocabulary-rewrite-yaml.d.ts`
  - `default`: `./dist/vocabulary-rewrite-yaml.js`
- Files list includes both `dist` and `src`

\### Scenario 8: Gate Was Not Passing Over Empty or Unexamined Subpaths
**Test**: Gate assertions on real coverage

**Result**: ✅ PASS
- `checked > 0` assertion confirms subpaths were actually examined
- `entries.length > 10` assertion confirms multiple packages loaded
- No patterns (`./templates/*`) or assets (`./form.css`) counted as failures

## Evidence Artifacts

\### Gate Test Output
`` `
bun test v1.3.14 (0d9b296a)

 3 pass
 0 fail
 6 expect() calls
Ran 3 tests across 1 file. [11.00ms]
`` `

\### Mutation Test Output (Divergence Detection)
The gate correctly identified when `package.json` and `tsconfig.scripts.json`'s `paths` 
mapping would resolve to different modules for the same subpath, which was the core 
issue described in the user intent.

## Conclusion

✅ **All critical scenarios pass.** 

The two commits successfully:
1. **Close the latent build-breakage class**: The new `vocabulary-rewrite-yaml` subpath 
   now correctly maps from the export name to the actual source file location
2. **Gate it so it cannot recur**: The new `subpath-resolves-under-scripts-paths.test.ts` 
   gate validates that:
   - Every subpath declares a `bun` source condition
   - That source file actually exists
   - The source file is the same one that `tsconfig.scripts.json`'s `paths` map will resolve to
   - A divergence between the two paths fails the gate with a clear diagnostic

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Gate validates mapping includes 10+ wildcard entries ✅ pass live Gate test first assertion
Gate validates all mapped packages' package.json files are findable ✅ pass live Gate test second assertion
Gate validates each subpath resolves to a valid file ✅ pass live Gate test comprehensive assertion on 20+ subpaths
Gate detects when paths resolution and bun condition diverge ✅ pass live Mutation test: modified bun in package.json to point to different file, gate correctly failed with diagnostic naming both paths
Public API subpath can be imported dynamically ✅ pass live Successful bun import of @metaobjectsdev/metadata/vocabulary-rewrite-yaml returning rewriteYamlDocument function
Re-export file correctly forwards to implementation ✅ pass live Verified re-export at src/vocabulary-rewrite-yaml.ts exports from src/core/vocabulary-rewrite-yaml.ts, both files exist
Package.json exports map is npm-publishable ✅ pass live Verified all 5 exports including vocabulary-rewrite-yaml have correct bun/types/default conditions and files list includes dist+src
  • bun test subpath-resolves-under-scripts-paths.test.ts — 3 pass, 0 fail
  • Mutation test: modified package.json bun condition to diverge from paths resolution — gate correctly detected and failed
  • Direct import: bun -e "await import('@metaobjectsdev/metadata/vocabulary-rewrite-yaml')" — successfully imported rewriteYamlDocument
  • File existence: verified re-export at src/vocabulary-rewrite-yaml.ts and implementation at src/core/vocabulary-rewrite-yaml.ts both exist
  • Package.json structure: validated exports map with correct bun/types/default conditions for all 5 exports
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dmealing and others added 2 commits September 13, 2026 21:49
…till open

An independent review of the four ungated commits on main found the /library fix
had a sibling still latent, and three claims in that work that the code does not
support. This is all of it.

**The class, gated.** `./vocabulary-rewrite-yaml` pointed at
`src/core/vocabulary-rewrite-yaml.ts` — one directory deeper than
`tsconfig.scripts.json`'s `@metaobjectsdev/metadata/*` → `src/*` substitution can
reach. Exactly the shape that took the `gates` lane down after the FR-043 merge,
and silent for the same reason: tsc misses, falls through to `node_modules`, and
reads `dist/`, which a developer has and a fresh CI checkout does not. It had not
gone red only because nothing in the scripts typecheck's include list imports it
yet. A thin `src/vocabulary-rewrite-yaml.ts` re-export fixes it; the
implementation stays under `core/` beside the canonical-JSON rewriter it mirrors.

The fix that matters is the gate. `subpath-resolves-under-scripts-paths.test.ts`
walks every `@metaobjectsdev/<pkg>/*` entry in the paths map, reads that package's
own exports, and asserts each TypeScript subpath lands on something the
substitution reaches. Asset exports (`./form.css`, whose target is the stylesheet)
are excluded by target kind, since a bundler resolves those and tsc never does.
It fails on `vocabulary-rewrite-yaml` before this commit's fix and passes after.

**Three claims corrected.** All three were mine, in work already on main; the
commits carrying them cannot be amended, so the corrections live here.

The largest: `3adfa2d76` said the old `text.includes(`"${ref}"`)` embed check was
"not currently wrong … fragile rather than broken". That is true of TypeScript,
Java and C# and FALSE of Python, where the check was VACUOUS. There are two embed
generators, not one — `generate_embedded_library.py` emits through `repr()`, which
single-quotes the key and leaves inner `"` raw, so the bare `"ai/db"` in that file
is the payload comment and never the key. Measured this time rather than inferred:
delete each key line from each real embed and the old predicate goes false on
TS/Java/C# and stays true on Python, six refs for six. So the rewrite closed a
live hole on one port, not merely a fragility on four. The same commit's claim
that the backslash exclusion is what separates key from mention holds only for
Java, whose pattern is unanchored; elsewhere the line anchor does it.

`6c39053cb` said pointing a subpath at an index is "what every other subpath
does". `./constants` is a plain `src/constants.ts` and is fine. The invariant is
that the substitution must land on something — which is what the new gate asserts,
and which a file satisfies as well as a directory.

`099d2d87c` cited "8 of 9 passed" as evidence the pre-change file was blind to the
deleted guard. That file had 7 tests; 8 of 9 is the post-change run, which that
message's own last line already reports. The pre-change figure is 7 of 7.

Also: the cross-port set comparison took `NAMES.flatMap(declaredRefs)` as a list
and compared it against a Set, so a ref legitimately declared by two layers of one
library would have failed every port against embeds that were correct. Now
de-duplicated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cQxyVuTkdPmAduLiedNEg
… not just find a file

Six findings from `/code-review high` on the previous commit. The one that
mattered: the gate checked that `src/<subpath>` resolves and never compared that
result to what the package's `bun` condition declares — so tsc and the runtime
could read DIFFERENT modules and the gate stayed green. Proven by reverting
`./library`'s `bun` to `library-sources.ts` with `index.ts` still present: the old
gate passed while `paths` read the index and bun read the other file. That is the
type-level form of the identity defect `tsconfig.scripts.json`'s own rationale is
written against, and following `library/index.ts`'s instruction to re-export new
modules there would have typechecked clean and been `undefined` at runtime. The
gate now resolves both sides and compares them by realpath; the same mutation now
fails it, naming both files.

Three more holes in the gate itself, all of which let something escape unchecked:
a subpath declaring no `bun` condition was skipped rather than failed, though it
is precisely a TS subpath the scripts typecheck cannot reach through source; a
subpath PATTERN (`./templates/*`) was treated as a literal and would have reported
a false miss against a correct package; and a `paths` target not shaped
`<pkgRoot>/src/*` made the derived manifest path miss, which `continue`d silently
and dropped that whole package from the gate. Patterns are now skipped explicitly,
the other two fail loudly.

And a premise correction, in three headers and the commit before this one.
"A directory deeper than the substitution can reach" is WRONG: `paths` `*` matches
across `/`, which is why `@metaobjectsdev/codegen-ts/templates/entity-file`
resolves to `src/templates/entity-file.ts` without trouble. The actual rule is
that the subpath NAME must mirror the layout under `src/` — `vocabulary-rewrite-yaml`
named a module living at `src/core/vocabulary-rewrite-yaml.ts`. Stated the wrong
way, the next maintainer concludes a nested module can never back a subpath and
flattens files out of `core/` for no reason. Renaming the export to
`./core/vocabulary-rewrite-yaml` would have satisfied the rule more cheaply; the
shim stays because that subpath is public API of a published package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cQxyVuTkdPmAduLiedNEg
@dmealing
dmealing merged commit f204127 into main Sep 14, 2026
1 check passed
@dmealing
dmealing deleted the fix/subpath-resolution-gate branch September 14, 2026 02:07
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.

1 participant