Commit Graph
10 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 b2f577ef49 test: stop DOM-element assertions from OOM-killing the worker
`node:assert` builds its `AssertionError` eagerly, running `util.inspect`
over both operands with `depth: 1000`, `getters: true` and
`maxArrayLength: Infinity`. A React-rendered DOM element carries
`__reactFiber$*` / `__reactProps$*` as own enumerable properties, and that
fiber graph re-expands once per traversal path, so inspecting a single
rendered element allocates without bound. Measured on the Asset Library
heading: depth 6 = 1.3MB, 8 = 7.8MB, 10 = 36MB, 12 = 135MB -- at Node's
depth 1000 the worker dies before any `AssertionError` exists.

The damage is not the crash, it is the disguise. Equality assertions only
inspect their operands on failure, so these sites stayed invisible while
green and detonated exactly when the behaviour they guard regressed --
reporting as `worker exited unexpectedly` with a truncated count
(`8 passed (13)`) and no failing test named. Breaking the delete-path focus
restoration in `asset-library.tsx` reproduced it: 29.5GB anon-rss and the
system OOM killer, or a V8 heap abort in 3s under a 512MB cap. The same
regression now fails in 1.15s with `expect(element).toHaveFocus()` naming
both the expected heading and the `<body>` that took focus instead.

`expect` is not affected -- vitest prints and diffs DOM nodes through
pretty-format's DOM plugin, which reads tag/attributes/children and never
touches the fiber -- so every unsafe site converts to a matcher:
`toHaveFocus()` for the three focus comparisons, `not.toBeInTheDocument()`
for the sixteen `assert.equal(queryBy..., null)` absence checks, which are
equally lethal (proved separately: element-vs-null inspects the element).

Three layers so this cannot come back:
- the 20 live sites in asset-library/asset-picker now use matchers;
- `test-assertion-boundary/no-element-operand-equality` fails `pnpm lint`
  when a DOM-element expression reaches `node:assert` equality, resolving
  local bindings and exempting the forms that cannot fail with an element
  in hand (`assert.notEqual(el, null)`, `el.textContent`);
- a 2048MB worker old-space ceiling in `vitest.config.ts` bounds any future
  runaway to a legible `Reached heap limit` abort in seconds instead of an
  OOM-killed machine (heaviest suite peaks near 1.3GB RSS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:03:33 +09:00
DongHyeonkaandClaude Opus 5 9950cb9d6b fix: clear the decorative flag when the upload dialog is handed a different file
The twin of the previous commit, with a sharper consequence. `decorative`
does not merely describe the previous image, it *exempts* it:
`validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED reads
`Asset.decorative`, so a flag inherited from a discarded divider lets a
meaningful diagram publish with no accessible name at all -- the check
passes rather than catching it. Stale alt text ships a wrong description;
stale `decorative` ships none.

Same terminal-state retry path: tick 장식용 for `divider.png`, have it
rejected, pick `sequence.png`, upload -- and `sequence.png` shipped as
decorative with `altText: undefined`.

Resetting it also re-enables the alt input, so the post-selection focus
call no longer has to ask whether it would land on a disabled control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:23:32 +09:00
DongHyeonkaandClaude Opus 5 1f2cba79e9 fix: clear alt text when the upload dialog is handed a different file
The file input's `onChange` reset `state` to `IDLE` and left `altText`
untouched. On success that is harmless -- the dialog unmounts. But
REJECTED, QUARANTINED and TRANSPORT_FAILED all leave it mounted with the
file input re-enabled, and that is precisely the retry path: upload
`db-schema.png` described as "DB 스키마", have it quarantined, pick
`sequence.png`, upload -- and `sequence.png` shipped described as
"DB 스키마", passing EVIDENCE_ALT_REQUIRED and publishing with a caption
about a different image.

Cleared on every file selection rather than only after a failure: alt
text describes one image, and "which file is this describing" has one
honest answer per selection. `submit()`'s existing ALT_REQUIRED refusal
turns the emptied field into a stop rather than a silent omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:17:33 +09:00
DongHyeonkaandClaude Opus 5 f69edb633d feat: search the server from the Asset Picker without shrinking preview's catalog
The Picker asked for `{ managementStatus: "READY", limit: 50 }` and
ignored `nextCursor`, so the 51st-oldest READY asset onward could not be
inserted at all. It sits inside the editing flow, where scrolling a long
list is the wrong interaction, so it gets search rather than a "더 보기"
control -- and it still loads a first page, because an empty panel until
you type is hostile to an author reaching for the asset they uploaded a
minute ago.

The trap this creates is the substance of the change. The editor screen's
Asset array feeds two consumers with opposite needs: the Picker's
*displayed* list, which a search must narrow, and Instant Preview's
*resolution catalog*, which a search must never narrow -- its gate
rejects any key no loaded asset backs. Handing search results straight to
the screen's `setAssets` would blank previously-inserted evidence figures
the moment the author typed a query.

They are kept apart by making the screen's callback additive by
construction rather than by convention: `mergeAssetCatalog` (domain,
beside `findResolvableAsset`) can only grow the set, and both writers --
observed pages and fresh uploads -- go through it. The prop is renamed
`onAssetsObserved` so the contract reads as "what the Picker saw", not
"what to show"; the replacing version was one `setAssets` reference away
and looked correct.

A key backed by neither the first page nor the current results is still
unresolvable. That is the known deferred limitation, not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:16:31 +09:00
DongHyeonkaandClaude Opus 5 6085af51b6 fix: collect alt text and a decorative flag when uploading a Studio Asset
The upload dialog sent `{ file, kind }` only, though `UploadAssetForm` and
`asset-upload-transport.ts` both carry `altText`/`decorative`. Every asset
uploaded through the flagship authoring flow therefore landed as
`altText: null, decorative: false`, and the directive `case-fields.tsx` and
`asset-picker.tsx` build from `asset.decorative ? "" : (asset.altText ?? "")`
could only ever be `alt=""`. The document parsed and previewed correctly and
then failed publish validation with EVIDENCE_ALT_REQUIRED, recoverable only
by hand-editing raw Markdown -- the exact thing the Picker exists to prevent.

The dialog now stages the file instead of uploading on selection, and carries
a decorative checkbox plus an alt-text field (focused as soon as a file is
chosen, submitting on Enter). A decorative asset never demands alt text; a
meaningful one is refused with a stated reason rather than a disabled button.
Insertion is unchanged: both call sites already read the Asset, so they now
insert what it actually carries.

Three new tests drive the whole loop -- upload through the real MOCK
composition, auto-insert, save, validate, preview -- and assert the result is
publishable. The pre-existing loop test no longer needs its hand-edit
workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:07:38 +09:00
DongHyeonkaandClaude Opus 5 073fda87eb fix: collapse the evidence-key gate and resolver into one decision
Three fix rounds each rebuilt the gate as a separate expression that merely
agreed with the resolver on the inputs that round's tests used. Different
expressions cannot agree in general, so the defect class stayed open while
each reported instance closed.

`findResolvableAsset(assets, key)` is now the single place that decides which
Asset an evidence key resolves to. Every gate is
`Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared
composition, and every resolver returns what it returns:

- validate-working-copy: the key gate and the decorative lookup (a last-wins
  Map against the resolver's first-wins find, so alt could be judged against a
  different Asset than the one rendered)
- adapters/mock/project-public-render-model: gate and resolver
- instant-preview: gate and descriptor resolver
- createAssetCatalogResolver: the pixels, a fourth expression nobody had
  listed -- one Asset's caption could sit over another Asset's image

Duplicate assetKeys are a contract violation but reachable through a paged
list, so the choice is total and order-independent: newest updatedAt wins,
tie-broken by id.

InstantPreview's gate is no longer looser than the others. The un-loaded-asset
case it was loosened for blanks either way; all the looseness bought was
catalog-only keys rendering an empty gap with no message while validation said
EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the
consistent behaviour, and the false comment claiming a fix that did not exist
is gone.

idempotent() now maps a deterministic content failure to VALIDATION_STALE/409
instead of offering a retry that fails identically, and no longer caches
uncharacterized internal failures -- reporting one as retryable while freezing
it in the ledger meant the retry could never re-run.

Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial
(asset list, key) combinations asserting the agreement itself rather than
examples. It reported 53 disagreements against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 06:55:06 +09:00
DongHyeonkaandClaude Opus 5 783e9b2cf1 fix: rebuild gate 1 from the resolver's own predicate, not a catalog lookup
Fix round 3 (review of 7ff9728):

Round 2's gate 1 (supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets)))
still routed through evidenceCatalogEntryFor, matching on id || label ||
publicPath -- gate 2's question, asked over fewer rows, not the resolver's
actual success condition (assetKey === key && managementStatus === "READY"
&& Boolean(publicPath)). Two Asset shapes made the two predicates disagree:
a READY asset with publicPath null/"", and a READY asset whose publicPath
happens to satisfy the legacy /media/${someOtherKey}.svg convention for a
key that isn't its own assetKey. Both passed gate 1 while the resolver
could not produce real pixels, reproducing the validateDocument-VALID /
createPreview-throws-raw-Error disagreement a second time.

Rebuilt gate 1 in validate-working-copy.ts and
adapters/mock/project-public-render-model.ts directly from a new domain
function, supportsEvidenceKeyFromReadyAssets, that mirrors the resolver's
exact condition -- never through evidenceCatalogEntryFor again. Gate 2
keeps reading the merged catalog. Made the mock resolver total (returns a
placeholder instead of throwing, matching instant-preview.tsx's resolver),
removing a comment that asserted an invariant the code did not hold.
Wrapped mock-studio-gateway.ts's idempotent() so any non-StudioGatewayError
that reaches its catch is normalized before crossing the port -- closing
the class generally, not just this instance.

Reverted instant-preview.tsx's own gate 1 to the merged catalog (unlike
the mock adapters, its resolver is provably total, so a loose gate there
only ever degrades to a placeholder) -- round 2's narrowing there was a
separate regression: a CASE referencing a document-catalog-backed key
outside the editor's currently-loaded Asset list blanked the entire
preview instead of degrading one figure.

Pinned both slip-through shapes failing on both mock paths with the
thrown/rejected error's type asserted (StudioGatewayError, never a raw
Error), pinned idempotent()'s new wrapping via a validate/preview race,
pinned EVIDENCE_NOT_FOUND as reachable through validateWorkingCopy
directly, and pinned Instant Preview's graceful degradation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 06:24:46 +09:00
DongHyeonkaandClaude Opus 5 7ff9728a5c fix: un-collapse evidence gate 1 from gate 2 to close a raw-Error escape
Fix round 2 (review of 9864958):

Round 1's I2 fix made gate 1 (supportsEvidenceKeyIn) read the same
merged catalog gate 2 already checks, so gate 1 stopped asking "does a
real Asset (or the legacy key) back this key" and started asking the
identical question gate 2 asks ("is there any EVIDENCE catalog row for
it"). A real EVIDENCE catalog row that exists for something other than
media (fixtures.ts's row for a QUESTION resolution target) then passed
gate 1 with nothing backing it as evidence. validateDocument reported
VALID; createPreview's resolver -- which only ever knew the legacy key
and real Assets -- threw a raw, unwrapped Error, violating the port's
StudioGatewayError-only contract. I2's disagreement reproduced in the
opposite direction.

Rebuilt gate 1 in all three callers (validate-working-copy.ts,
adapters/mock/project-public-render-model.ts, instant-preview.tsx) as
"legacy key OR an Asset-derived catalog entry only" -- never the
merged document catalog -- while gate 2 keeps reading the merged
catalog as before. Also stopped emitting the real Asset UUID as the
synthesized CatalogEntry's id (prefixed instead), closing a path where
pasting an Asset's real id -- never something the Picker itself
produces -- would have resolved as an evidence key.

Restored content-format.test.ts's domain tests to exercise the same
gate-1 composition production callers now use instead of a bare
hand-injected predicate, and added coverage for: a real non-asset
EVIDENCE row still being rejected, an Asset-backed key being accepted
with no document-catalog row at all, the fixture-UUID case failing
both mock paths with no raw Error crossing the gateway port, and the
EVIDENCE_ALT_REQUIRED decorative-Asset pairing that had no test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:54:21 +09:00
DongHyeonkaandClaude Opus 5 98649585e6 fix: generate fresh upload idempotency keys and reconcile mock preview/validation on evidence keys
Fix round 1 (review of 54d9bf9):

I1: AssetUploadDialog reused one idempotency key across every upload
attempt in a session, generated once when the dialog opened. Every
terminal state re-enables the file input, so retrying with a different
file after a failure sent two distinct payloads under the same key.
The key is now generated fresh inside submit() on each call, matching
every other mutation call site in the repo, and dropped from the
dialog's public props entirely (it was never in the task's own
"Produces" interface).

I2: the mock's validateDocument gate only recognized the one legacy
hardcoded evidence key, completely disconnected from the Asset system
Instant Preview now consults -- so a directive the Picker or upload
dialog inserted always previewed live and then failed validation with
EVIDENCE_UNSUPPORTED for every other key. Extracted the Asset-to-
CatalogEntry mapping (evidenceCatalogEntriesFromAssets) and the
domain's one EVIDENCE-catalog matching rule (evidenceCatalogEntryFor)
into shared domain modules that both the preview path
(instant-preview.tsx) and the validation path (validate-working-copy.ts,
the mock's own createPreview) now call. Reconciled the underlying MOCK
studioSource gap that caused this: built a mock asset gateway
(mock-studio-asset-gateway.ts) sharing one in-memory Asset store with
the mock document gateway, wired per composition-root instance in
create-tech-log-feature-input.ts, so an Asset the editor actually
loaded is visible to validation too, while a key backed by nothing
still fails both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:31:01 +09:00
DongHyeonkaandClaude Opus 5 54d9bf9120 feat: insert evidence directives from the TechLog asset picker
Adds an Asset Picker and upload dialog to the CASE editor so authors can
insert `:::evidence` directives that reference backend assets, and opens
projectWorkingCopy's two evidence gates so Instant Preview accepts a key
backed by a freshly loaded READY asset instead of only the one hardcoded
legacy key. The editor screen now owns the loaded Asset list so the
Picker, the upload dialog, and Instant Preview all read the same array,
and a freshly uploaded asset appears in the preview without a refetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:32:56 +09:00