fix(metadata): FilesystemLoader.list() reports only names findFile() can resolve - #14922
Conversation
…can resolve `list()` reported `path.basename(file, ext)` for every file its glob found — nested or not, with an extension or without — while `findFile()` resolved `ROOT/TYPE/NAME` plus one of five hard-coded extensions. The two disagreed for three shapes, and the disagreement reached consumers through `MetadataManager.listNames()`, which unions loader `list()` output unfiltered: a name sat in the list while `get()` answered `null` for it, silently. `list()` now converges on `resolvableNameForPath()` — the derivation `loadManyKeyed()` already used — so it reports a file only where the mapping is a bijection: directly under `ROOT/TYPE/`, carrying an extension one of this instance's REGISTERED serializers claims. The extension set is registered rather than hard-coded, which is also how the card's row-4 membership mismatch closes: under the default format set a `.js` file leaves `list()`, where it was previously listed and resolvable but loadable by nothing. Ruled by the maintainer via the director seat (2026-09-02) as option A over the reverse-unify, which would have made a slash inside a metadata name every consumer's permanent obligation. The extension set deliberately does not follow ADR-0008 §10's `.json`-only rule — that governs the `metadata-fs` store, and applying it here would drop `.yaml` and `.ts` metadata from `listNames()`. Not taken: filtering the shared `loadMany()` walk, and refusing a path-shaped name in `findFile()`. Each inverts a landed #14341 pin in `filesystem-loader-keyed-items.test.ts`, a file under a concurrent claim (PR #14627). Both are pinned as RECORD cases in the new test file instead. Part of #14486 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…lesystemloader-list-reachability
…owing `check-adr-0087-registration` requires a declared-breaking changeset to answer the ledger question in writing. No authorable key, Zod schema or stored row moves here, and a tree carrying one of the two shapes needs the FILE relocated rather than any document rewritten — nothing `objectstack migrate meta` can project, so the disposition is `not-required (no-migration-prescription)`. Part of #14486 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4b6caebe035928ad5ca9a7e8646c416b85ae0485 && git checkout 4b6caebe035928ad5ca9a7e8646c416b85ae0485
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 431979e67cc4d0edfb0b8743385628cc425662bc 97531bb76c7db379a7ec22383fe51f1149ede1ff && git checkout -B drift-repro 431979e67cc4d0edfb0b8743385628cc425662bc && git merge --no-ff 97531bb76c7db379a7ec22383fe51f1149ede1ff
node scripts/docs-audit/affected-docs.mjs --json 431979e67cc4d0edfb0b8743385628cc425662bc
|
Contract review: PASS at head
|
Re-run green — the
|
Fixes #14486
FilesystemLoader.list()now reports only namesfindFile()/load()/exists()can resolve. One shared name-to-path derivation, per the maintainer ruling on the card (option A, narrow — via the director seat, 2026-09-02).The defect, driven at
origin/main(not inferred)Probe fixture from the card, default format set (
typescript/json/yaml):Both populations on the card reproduce.
MetadataManager.listNames()unions loader output unfiltered, so a name sat in the list whileget()answerednullfor it — the silent failure an author reads as their own typo.The repair
list()converges onresolvableNameForPath(), the derivationloadManyKeyed()already used: a file directly under the type directory, carrying an extension one of this instance's registered serializers claims. The extension set is now derived fromthis.serializersrather than a second hard-coded list, so the set a name can be RESOLVED under cannot drift from the set that is LISTED.That is also how the card's row-4 membership mismatch closes: under the default set a
.jsfile leaveslist(), where it was previously listed and resolvable whileloadMany()could never return it andload()threwNo serializer found for format: javascript.The extension set deliberately does not follow ADR-0008 §10's
.json-only rule, per the ruling: §10 governs themetadata-fsstore, and applying it verbatim here would drop.yamland.tsmetadata out oflistNames()— a breakage this card never asked for.After (same fixture, same probe):
PM assumptions, measured
list()over-reports, or the trio under-resolves? Settled by a citable contract, not by taste: the maintainer ruling in comment 5518651566 adopts A (narrowlist()), recorded before this dispatch.listNameshit is a definition (metadata-manager.ts:1571,objectql/metadata-facade.ts:306,spec/contracts/metadata-service.ts:440,core/fallbacks/memory-metadata.ts:84); there is no production call site at all.listForIndex()readsloadMany*, notlist(). Control: the same grep does return the call sites in test files.DatabaseLoaderhave the same split? No. Itslist()returns thenameCOLUMN, the same keyexists()/stat()/load()filter on andloadManyKeyed()keys by. No derivation, so no derivation mismatch — this card is one instance, not a two-instance class..tshalf — FALSIFIED, as triage required it be re-measured. A.tsmodule carryingexport constand a JSON-compatible object literal loads fine throughTypeScriptSerializerunder the default set. The card's probe missed because of its fixture, not the loader. Pinned as a CONTROL.Two halves of the ruling this PR does NOT take, and why
Both are the "neither listed nor resolvable" clause. Each would invert a landed #14341 pin in
filesystem-loader-keyed-items.test.ts— a file under a concurrent claim (PR #14627 touches this package's existing tests and itspackage.json), which this card was dispatched not to touch.loadMany()walk (the ruling's "nothing unlisted is returned byloadMany()either") would breakfilesystem-loader-keyed-items.test.ts:113(exact-equality on the keyed set, includingextensionless_namedandnested_named),:167,:187, and theloadMany()CONTROL at:196("with every file", length 7).findFile()would break:175, which assertsexists(TYPE, 'crm/nested_named')is true. It would also needsave()to narrow in the same stroke —save()mkdir-p's the name's dirname, so it still CREATES nested files.Both are pinned as RECORD cases in the new test file rather than left implicit, so whoever takes the remainder sees the divergence in a diff.
Tests
New file only —
packages/metadata/src/loaders/filesystem-loader-list-reachability.test.ts. ⛔ No edit topackages/metadata/package.jsonand ⛔ none to any existing*.test.tsin that package (diff is 3 files).Reverse verification: with the loader reverted to
origin/mainand the new test kept, 7 of 14 fail —lists exactly...,EVERY listed name resolves..., the nested case, the extension-less case, the.jscase, and both manager-level cases. The 7 that stay green are exactly the CONTROLs and RECORDs, which pin what must not move. Restore proven by blob hash equality withHEADplus an emptygit diff HEAD, not by an exit code.At
97531bb76:pnpm --filter '@objectstack/metadata^...' buildthenpnpm --filter @objectstack/metadata test— 45 files, 698 tests, all passing (includes the untouched metadata: FilesystemLoader and RemoteLoader still discard the store key in loadMany(), so a nameless body stays invisible to list() through those two loaders #14341 pins).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths — derived from the merge base). All green, except four that report NOT MEASURED (exit 3, prerequisite unmet, not findings):check-test-completeness,check-half-states,check:dual-build-cjs-loads(needs a fullpnpm build),check:type-check-debt.check-adr-0087-registrationfailed first (a BREAKING changeset with no ledger disposition) and is green after thenot-required (no-migration-prescription)marker was added and committed — the gate reads changesets from git revs, not the working tree.node scripts/pm/check-governed-merges.mjs --teston the final 3-file list: not governed.typecheckscript ([finding] @objectstack/metadata has no typecheck script, and its own tsconfig program does not compile: 89 pre-existing errors across 12 test files, checked by nothing #14342), so nothing type-checks the new test file here.Changeset
minorwith a BREAKING banner, per the ruling. The dispatch prompt saidpatch; the ruling is the more specific and citable instruction, and this narrows consumer-visible output, sominorit is — flagged here rather than chosen silently.Per the ruling this PR carries
needs:contract-reviewatCONTRACT_REVIEW_TIER, a dual carrier with the card.Out-of-scope finding filed: #14921 — two files sharing a stem across registered extensions make
list()report one name twice, and the loser is reachable through no name at all. Not repaired here; it is the opposite direction from this card and needs its own contract call.Draft on purpose: the PM seat marks ready and arms the queue.
🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code