Merge branch 'main' into feature/techlog-ui-migration

Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI
migration. Merged in this direction so every conflict is resolved and proved in
the worktree; main is only fast-forwarded afterwards and never holds a state
that was not verified here.

15 conflicts. The rule throughout: keep the template's mechanism, keep the
product's content, and never invent a third state neither branch would accept.

The template's product manifest and its runtime feature kill switch are adopted.
The route registry is deliberately not composed from contract.routes: the
reference feature still declares screens this product deleted, and reducing over
them would register paths with no component behind them. ROUTE_FEATURE_OWNER is
narrowed to registered routes for the same reason. The first resolution did
compose from contract.routes and was rejected by product-features.test.ts.

Three files pinned counts and a digest describing the gate contract. Neither
side's numbers describe the merged config/ci/gates.json, so they were recomputed
from it rather than chosen: 27 gates, 82 commands, 94 command references, 107
evidence references, 128 artifacts, shape sha256 5063586d.

README.md and docs/accessibility/manual-checklist.md now enumerate this
product's 27 routes, which the template's own verify:documentation requires.

product-feature-switch.test.tsx was rewritten around the invariant that still
applies here — no registered route without a component — rather than deleted
with the screens it used to exercise.

docs/operations/template-merge-2026-08-17.md records every decision, the gate
results, and the three follow-ups this merge deliberately did not decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-17 18:38:08 +09:00
co-authored by Claude Opus 5
100 changed files with 3310 additions and 443 deletions
+45 -6
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
isReducedCiContractRun,
loadCiGateContract,
parseCiGateContract,
} from "../../scripts/contracts/ci-gates.ts";
@@ -70,8 +71,19 @@ describe("selective Task 3 contract closure", () => {
"--signal=SIGKILL",
unit,
]);
// `bwrap --args FD` stops parsing at the first non-option and never hands
// the remainder back, so a command placed in the args file is dropped and
// bubblewrap exits with its usage text. Refusing `--` in the option stream
// is what keeps that silent no-sandbox launch from returning.
expect(() =>
encodeProviderBwrapInput(
["--unshare-net", "--", "/usr/bin/prlimit"],
{ PROVIDER_COMMAND: command },
),
).toThrow(/terminate the option stream/u);
const frame = encodeProviderScopeFrame({
bwrapInput: Buffer.from("private-bwrap-vector\0"),
bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
@@ -81,6 +93,27 @@ describe("selective Task 3 contract closure", () => {
Buffer.from("private-bwrap-vector\0").toString("base64"),
);
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
// The command vector rides on real argv, so it must never be able to carry
// the secret that the args file exists to hide.
expect(frame.subarray(4).toString("utf8")).not.toContain(credential);
expect(() =>
encodeProviderScopeFrame({
bwrapInput: Buffer.from("x\0"),
bwrapCommand: [],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
}),
).toThrow(/bwrap command is invalid/u);
expect(() =>
encodeProviderScopeFrame({
bwrapInput: Buffer.from("x\0"),
bwrapCommand: ["prlimit"],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
}),
).toThrow(/bwrap command is invalid/u);
});
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
@@ -143,12 +176,18 @@ describe("selective Task 3 contract closure", () => {
.toContain("package script missing: root -> missing");
});
it("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
// A removal fixture runs against a pruned contract on purpose, so the
// canonical counts do not describe it. Asserting them there failed the
// fixture for the reduction it exists to demonstrate.
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(26);
expect(canonical.commands).toHaveLength(81);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93);
expect(canonical.artifacts).toHaveLength(126);
// Template merge. The template added one gate and one command to the
// product's 26/81; the artifact set is the product's 126 plus the
// template's 2.
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(82);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
expect(canonical.artifacts).toHaveLength(128);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
@@ -157,7 +196,7 @@ describe("selective Task 3 contract closure", () => {
expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u);
});
it("rejects the retired validate-candidate-archive grammar", async () => {
it.skipIf(isReducedCiContractRun())("rejects the retired validate-candidate-archive grammar", async () => {
const canonical = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;