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>
This commit is contained in:
DongHyeonka
2026-08-18 05:54:21 +09:00
co-authored by Claude Opus 5
parent 98649585e6
commit 7ff9728a5c
6 changed files with 354 additions and 24 deletions
@@ -19,6 +19,7 @@ import { DocumentEditorScreen } from "../../../src/features/tech-log/presentatio
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
@@ -609,3 +610,181 @@ test("a key backed by no asset and no catalog entry still fails mock validation"
);
assert.ok(report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"));
});
// --- Fix round 2 ---
// Round 1's `supportsEvidenceKeyIn(mergedCatalog)` made gate 1 ask the exact
// question gate 2 asks, so a real `EVIDENCE` catalog row backing something
// other than media (fixtures.ts:37 -- a row that exists for QUESTION
// resolution targets, not an Asset) passed gate 1 with no Asset and no
// legacy key behind it. `validateDocument` said VALID; `createPreview`'s
// resolver, which only ever knew the legacy key, then threw a raw
// `Error` -- I2's disagreement in the opposite direction, plus a bare
// `Error` escaping a port whose contract is `StudioGatewayError` only.
test("a directive key equal to a non-asset EVIDENCE catalog row's id is rejected on both mock paths, never as a raw Error", async () => {
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const gateway = installed.createStudioGateway();
const created = await gateway.createDocument(
{
kind: "CASE",
title: "카탈로그 행 오용",
slug: "catalog-row-misuse-check",
summary: "카탈로그에 EVIDENCE 행이 있다고 해서 Asset이 있는 건 아닙니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
// FIXTURE_IDS.fetchJoinCase backs an EVIDENCE catalog row too
// (fixtures.ts:37, label "Fetch Join Case") -- that row is for
// QUESTION resolution targets, not media.
bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`,
},
{ idempotencyKey: "catalog-row-misuse-create" },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: "catalog-row-misuse-validate" },
);
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
assert.ok(
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
JSON.stringify(report.issues),
);
// createPreview requires a current, error-free validation -- an INVALID
// one must reject with a StudioGatewayError (VALIDATION_STALE), never let
// a raw Error escape the port.
await assert.rejects(
gateway.createPreview(
created.id,
{ expectedVersion: created.version, validationId: report.validationId },
{ idempotencyKey: "catalog-row-misuse-preview" },
),
(error: unknown) => {
assert.ok(
error instanceof StudioGatewayError,
`expected a StudioGatewayError, got ${String(error)}`,
);
assert.equal(error.code, "VALIDATION_STALE");
return true;
},
);
});
// The same case pinned at the lower level: the mock's own `projectWorkingCopy`
// wrapper (adapters/mock/project-public-render-model.ts) must reject at the
// gate -- a `ContentFormatError`-flavored "supported local evidence key not
// found" -- rather than ever reaching the resolver's `isSupportedEvidenceKey`
// fallback and its raw `Error("Unknown local evidence asset: ...")`. This
// pins the fix even if some future change lets `createPreview`'s own
// staleness guard stop being the thing that prevents the call in practice.
test("the mock's projectWorkingCopy wrapper rejects a non-asset EVIDENCE catalog row at the gate, not at the resolver", () => {
const catalog = [
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC" as const, label: "JPA", dependencyRevision: "r1" },
{
id: FIXTURE_IDS.fetchJoinCase,
type: "EVIDENCE" as const,
label: "Fetch Join Case",
publicPath: "/cases/collection-fetch-join-pagination",
dependencyRevision: "r1",
},
];
const input = {
kind: "CASE" as const,
title: "t",
slug: "wrapper-gate-check",
summary: "s",
topicId: FIXTURE_IDS.topicJpa,
projectId: null,
relations: [],
problem: "p",
conclusion: "c",
environment: "e",
reproduction: "r",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="a" caption="c" zoom="false"\n:::`,
};
assert.throws(
() =>
mockProjectWorkingCopy(
input as never,
catalog as never,
{ mode: "PREVIEW", publishedAt: null },
[],
),
/supported local evidence key not found/,
);
});
// The rode-along fix (validate-working-copy.ts's EVIDENCE_ALT_REQUIRED check
// now reads Asset.decorative): a decorative Asset's empty alt must pass, a
// non-decorative one's must still fail. No repo test covered this pair
// before round 2.
test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a decorative Asset, fails for a non-decorative one", async () => {
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const gateway = installed.createStudioGateway();
const assetGateway = installed.createStudioAssetGateway();
const decorative = await assetGateway.uploadAsset(
{ file: new File(["<svg/>"], "deco.svg", { type: "image/svg+xml" }), kind: "IMAGE", decorative: true },
{ idempotencyKey: "decorative-alt-upload" },
);
const informative = await assetGateway.uploadAsset(
{ file: new File(["<svg/>"], "info.svg", { type: "image/svg+xml" }), kind: "IMAGE" },
{ idempotencyKey: "informative-alt-upload" },
);
assert.equal(decorative.decorative, true);
assert.equal(informative.decorative, false);
async function validateBody(bodyMarkdown: string, slugSuffix: string) {
const created = await gateway.createDocument(
{
kind: "CASE",
title: "대체 텍스트 규칙",
slug: `alt-rule-check-${slugSuffix}`,
summary: "decorative 여부에 따라 대체 텍스트 요구가 달라집니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown,
},
{ idempotencyKey: `alt-rule-create-${slugSuffix}` },
);
return gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: `alt-rule-validate-${slugSuffix}` },
);
}
const decorativeReport = await validateBody(
`:::evidence key="${decorative.assetKey}" alt="" caption="" zoom="false"\n:::`,
"decorative",
);
assert.ok(
!decorativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
JSON.stringify(decorativeReport.issues),
);
const informativeReport = await validateBody(
`:::evidence key="${informative.assetKey}" alt="" caption="" zoom="false"\n:::`,
"informative",
);
assert.ok(
informativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
JSON.stringify(informativeReport.issues),
);
});
+88 -5
View File
@@ -1,13 +1,42 @@
import { describe, expect, it } from "vitest";
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
import { evidenceCatalogEntriesFromAssets } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
import {
ContentFormatError,
parseCaseContent,
} from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
import { projectWorkingCopy } from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
import {
evidenceCatalogEntryFor,
projectWorkingCopy,
} from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts";
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
/**
* Fix round 2. Mirrors the gate-1 composition every production caller now
* uses (`validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
* `instant-preview.tsx`): the legacy key, or an actually-loaded Asset backs
* it -- never "is there any EVIDENCE catalog row for it" (that's gate 2,
* `evidenceCatalogEntryFor`, a separate question `projectWorkingCopy` asks
* of its own `catalog` argument). Round 1 collapsed gate 1 into gate 2 in
* every production caller, which let a real EVIDENCE catalog row backing
* something other than media (e.g. a QUESTION resolution-target row) pass
* gate 1 with no Asset and no legacy key behind it. Tests below pass no
* `assets`, so this reduces to the legacy check alone for them -- the same
* outcome bare `isSupportedEvidenceKey` gave before, just now visibly tied
* to the real composition instead of standing in for it by coincidence.
*/
function supportsEvidenceKey(
key: string,
assets: readonly Asset[] = [],
): boolean {
return (
isSupportedEvidenceKey(key) ||
Boolean(evidenceCatalogEntryFor(evidenceCatalogEntriesFromAssets(assets), key))
);
}
const rich = `## 측정 결과 {#measurements}
@@ -354,7 +383,7 @@ describe("Public render model projection", () => {
generatedAt: "2026-08-14T00:00:00Z",
dependencyRevision: "r1",
},
isSupportedEvidenceKey,
supportsEvidenceKey,
);
expect(model.kind).toBe("CASE");
@@ -390,7 +419,7 @@ describe("Public render model projection", () => {
input,
catalog,
{ mode: "PREVIEW", publishedAt: null },
isSupportedEvidenceKey,
supportsEvidenceKey,
);
expect(model.kind).toBe("QUESTION");
@@ -411,7 +440,7 @@ describe("Public render model projection", () => {
},
catalog,
{ mode: "PREVIEW", publishedAt: null },
isSupportedEvidenceKey,
supportsEvidenceKey,
),
).toThrow(/EVIDENCE catalog/);
});
@@ -440,7 +469,7 @@ describe("Public render model projection", () => {
caseWithEvidence(key),
unsupportedEvidenceCatalog,
{ mode: "PREVIEW", publishedAt: null },
isSupportedEvidenceKey,
supportsEvidenceKey,
),
).toThrow(/supported local evidence key/);
}
@@ -456,4 +485,58 @@ describe("Public render model projection", () => {
it("rejects a catalog-matching unsupported evidence label", () => {
expectUnsupportedEvidence("unsupported-label");
});
// Fix round 2 regression coverage. `catalog` above already carries
// "resolution-evidence" -- a real `EVIDENCE` row that exists for a
// QUESTION resolution target (see "requires EVIDENCE" above), not media.
// A production caller whose gate 1 collapsed into gate 2 (round 1's bug)
// would let this key through with no Asset and no legacy key backing it;
// this is the exact class of catalog row the reviewer used to reproduce
// that regression.
it("rejects a real EVIDENCE catalog row that exists for a different purpose than media (round 2 regression)", () => {
expect(() =>
projectWorkingCopy(
caseWithEvidence("resolution-evidence"),
catalog,
{ mode: "PREVIEW", publishedAt: null },
supportsEvidenceKey,
),
).toThrow(/supported local evidence key/);
});
it("accepts a key backed by a loaded READY Asset even though the document catalog carries no matching row", () => {
const assets: Asset[] = [
{
id: "99999999-9999-4999-8999-999999999999",
assetKey: "boundary-check",
kind: "DIAGRAM",
mediaType: "image/svg+xml",
originalFilename: "boundary.svg",
byteSize: 10,
width: null,
height: null,
altText: null,
decorative: false,
managementStatus: "READY",
publicPath: "/media/boundary-check.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
},
];
// `catalog` alone has no row for "boundary-check" -- gate 2 only passes
// because the caller merges in `evidenceCatalogEntriesFromAssets`, the
// same way every production caller does.
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
const model = projectWorkingCopy(
caseWithEvidence("boundary-check"),
evidenceCatalog,
{ mode: "PREVIEW", publishedAt: null },
(key: string) => supportsEvidenceKey(key, assets),
);
expect(model.kind).toBe("CASE");
});
});