The template's demonstration screens exist to explain the template. A product replaces them with its domain, so deleting them is the expected end state — but two gates were coupled to them, and the merge accommodated that coupling instead of fixing it. product-features.test.ts derives its own scope now: a registry must gate on the manifest when it imports a module belonging to a manifest-declared feature. The previous fix put installed-feature-runtimes.tsx in the exempt list, which silenced the guard for that file permanently. Verified by removing the manifest reference from installed-feature-adapters.ts and watching the guard fail; a counter asserts the sweep still watches at least one file. product-feature-switch.test.tsx exercises the kill switch end to end again. The mechanism is the ownership lookup plus isFeatureActive, which has nothing to do with which screens ship, so the ownership map is the fixture: one real registered route attributed to a real installed feature, with the product's own router, components and codecs. That restoration exposed a real gap. The navigation-withdrawal half is not implemented here: it lives in the template's PrimaryNavigation and this product does not render the template's AppShell at all — the public header is a hand-written list of paths. A disabled feature's route is refused by the router but its link would still be advertised. Harmless only while no feature-owned route is navigable, which is now asserted so the gap cannot ship silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
12 KiB
Markdown
278 lines
12 KiB
Markdown
# Template merge — `main` → `feature/techlog-ui-migration`
|
||
|
||
Record of how the frontend template sync on `main` was integrated into the
|
||
TechLog UI migration branch, and of every decision taken to resolve a conflict.
|
||
|
||
## Why this direction first
|
||
|
||
The template sync landed on `main` while the UI migration ran in a worktree.
|
||
Two orders were possible.
|
||
|
||
Merging the feature branch into `main` first would have resolved 15 conflicts
|
||
directly on the integration branch: a bad resolution would already be on `main`,
|
||
and it would have to be repaired forward, on the branch other work depends on.
|
||
|
||
Merging `main` into the worktree first keeps the resolution where the UI work
|
||
lives. Every gate runs against the resolved tree before anything reaches `main`,
|
||
and a resolution that turns out wrong is discarded by resetting one feature
|
||
branch. `main` is then only ever fast-forwarded, so it never holds a state that
|
||
was not already proved in the worktree.
|
||
|
||
That is the order used here.
|
||
|
||
## Starting state
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| merge base | `325a2a0` |
|
||
| `main` | `bdee07a` — `chore: sync the frontend template from a0fbafb to 5434760`, 1 commit, 101 files, +3116/−448 |
|
||
| `feature/techlog-ui-migration` | `0355b64` — 29 commits |
|
||
| files changed by `main` | 101 |
|
||
| files changed by the feature branch | 423 |
|
||
| overlap | 23 |
|
||
|
||
Rollback refs were created before touching anything:
|
||
|
||
```
|
||
backup/ui-before-template-merge → 0355b64
|
||
backup/main-before-ff → bdee07a
|
||
```
|
||
|
||
### Pre-merge baseline on the feature branch
|
||
|
||
Captured so that a pre-existing failure could not be mistaken for a merge
|
||
regression.
|
||
|
||
| Gate | Result |
|
||
| --- | --- |
|
||
| `check:types` | pass |
|
||
| `lint` | pass |
|
||
| `verify:documentation` | `PASS_SCOPED` |
|
||
| `tests/unit` + `tests/component` | 1815 passed / 101 failed |
|
||
|
||
The 101 failures were confined to `tests/unit/ci-artifact-contract.test.ts` (19)
|
||
and `tests/unit/ci-workflow-generation.test.ts` (82) — sandbox subprocess gates
|
||
that cannot run in this environment.
|
||
|
||
## Conflicts and how each was decided
|
||
|
||
15 conflicts. The rule applied throughout: **keep the template's mechanism, keep
|
||
the product's content, and never invent a third state that neither branch
|
||
would accept.**
|
||
|
||
### Deletions the UI migration made deliberately (2)
|
||
|
||
| Path | Decision |
|
||
| --- | --- |
|
||
| `src/presentation/examples/platform-overview-page.tsx` | deletion kept |
|
||
| `tests/visual/.../platform-overview-light-chromium-visual-linux.png` | deletion kept |
|
||
|
||
`main` modified both; the feature branch deleted them in `c5c8b94`. Nothing on
|
||
the branch references either, so the deletion stands.
|
||
|
||
### `src/features/installed-feature-contracts.ts`
|
||
|
||
The template introduced a product manifest: registries are composed from
|
||
`INSTALLED_PRODUCT_FEATURES` so that narrowing the selection withdraws a
|
||
feature's routes, operations, schemas and messages.
|
||
|
||
The manifest composition is kept for operations, schemas, mappers and
|
||
invalidation. The **route registry is deliberately not composed from
|
||
`contract.routes`**, because the reference feature still declares
|
||
`REFERENCE_RESOURCE_*` routes whose screens this product deleted during the
|
||
migration. Reducing over them would register paths with no component behind
|
||
them — a typed deep link resolving to nothing.
|
||
|
||
The registry therefore lists the platform routes (empty on this branch) and
|
||
TechLog's. This was caught by running the gates: the first resolution did
|
||
compose from `contract.routes`, and `tests/unit/product-features.test.ts`
|
||
rejected it.
|
||
|
||
`ROUTE_FEATURE_OWNER` was narrowed to registered routes for the same reason.
|
||
Attributing an unregistered route to a feature claims the kill switch governs
|
||
something no router can mount.
|
||
|
||
### `src/features/installed-feature-runtimes.tsx`
|
||
|
||
The template gates the reference feature's route codecs and components on the
|
||
manifest. This product deleted that feature's presentation layer, so the import
|
||
does not resolve and there is nothing to gate. The gating was removed and the
|
||
reason recorded in the file; it belongs back the day those screens return.
|
||
|
||
Consequence: this file no longer references the manifest, so it was moved to the
|
||
exempt list in `tests/unit/product-features.test.ts` with the same note.
|
||
`tests/component/product-feature-switch.test.tsx` was rewritten to hold the
|
||
invariant that still applies here — no registered route without a component —
|
||
which is exactly the trap the first resolution fell into.
|
||
|
||
### `src/features/installed-feature-adapters.ts`
|
||
|
||
Both the manifest check and TechLog's input are kept. The reference feature's
|
||
input stays `Partial` because the manifest may narrow it out; TechLog's is total
|
||
because it is this product's own UI and is always installed. A build that
|
||
narrows the reference feature out still ships TechLog.
|
||
|
||
### `src/features/installed-feature-messages.ts`
|
||
|
||
The template's rationale — message keys stay total so `message()` cannot become
|
||
partial — is kept, and TechLog's catalog is merged in on the same terms.
|
||
|
||
### `src/presentation/routes/app-router.tsx`
|
||
|
||
The template looked the route definition and runtime component up by id inside
|
||
the route element; this branch passes both as props from the grouped route
|
||
contract. The prop-driven signature is kept.
|
||
|
||
The template's **feature kill switch is adopted**: a route whose owning feature
|
||
the runtime document disabled renders the disabled surface instead of mounting.
|
||
Withdrawing it from navigation alone would leave a working deep link. `getRoute`
|
||
was dropped from the imports because the definition arrives as a prop.
|
||
|
||
### `vite.config.ts`
|
||
|
||
Two independent additions — TechLog route chunking and `routerBasePath` — both
|
||
kept. A brace was lost in the first concatenation and caught by `check:types`.
|
||
|
||
### `scripts/build-frontend.ts`
|
||
|
||
Both new steps exist in the merged body, so the header comment was renumbered:
|
||
runtime config becomes step 4, the TechLog serving boundary step 7, the release
|
||
manifest step 8, and the inline step comments were corrected to match.
|
||
|
||
### `scripts/test-performance.ts`
|
||
|
||
The template clicked a navigation link before measuring; this product measures
|
||
its own landing route, which `goto` already reached, and has no `targetLabel`.
|
||
The click was dropped. The template's lesson was kept as a comment because it
|
||
applies to the next click that lands here: Playwright matches accessible names
|
||
by substring, so a nav entry can also match a call to action and resolve to two
|
||
links — a strict-mode violation that produces no performance evidence at all.
|
||
|
||
### Derived baselines — recomputed, not chosen (3)
|
||
|
||
`scripts/contracts/ci-gates.ts`, `tests/unit/task3-selective-integration.test.ts`
|
||
and `tests/unit/ci-workflow-generation.test.ts` each pin counts and a digest
|
||
describing the gate contract. **Neither side's numbers describe the merged
|
||
`config/ci/gates.json`**, so taking either would have been wrong. They were
|
||
recomputed from the merged file:
|
||
|
||
| Value | Result |
|
||
| --- | --- |
|
||
| canonical gate shape SHA-256 | `5063586d799f51de94c0f0ddaf9b75e180825bba5051bc309550425013ea81ef` |
|
||
| gates | 27 |
|
||
| commands | 82 |
|
||
| command references | 94 |
|
||
| evidence artifact references | 107 |
|
||
| artifacts | 128 (126 product + 2 from the template) |
|
||
|
||
### `README.md`, `docs/accessibility/manual-checklist.md`
|
||
|
||
The template enumerated its own example routes. Replacing them with generic
|
||
prose broke `verify:documentation`, which requires both documents to name every
|
||
installed route id — a rule the template sync itself introduced. Both documents
|
||
now enumerate this product's 27 routes.
|
||
|
||
## Result
|
||
|
||
Merge commit parents: `0355b64` (UI) and `bdee07a` (template).
|
||
|
||
### Gates on the merged tree
|
||
|
||
| Gate | Result |
|
||
| --- | --- |
|
||
| `check:types` | pass (6 projects) |
|
||
| `lint` | pass, `--max-warnings=0` |
|
||
| `check:architecture` | 399 modules, 1232 dependencies, 12 fixtures pass |
|
||
| `check:adapter-inventory` | 120 files, 5 importers of the shared abort primitive |
|
||
| `check:remediation-ledger` | 25 dispositions, 0 open |
|
||
| `check:diagnostics` | 8 diagnostics, 5 telemetry producers |
|
||
| `check:i18n` | 197 keys, 4 locales |
|
||
| `check:design-system` | 48 tokens |
|
||
| `verify:documentation` | `PASS_SCOPED` |
|
||
| `test:integration` | 81 passed |
|
||
| `test:recipes` | 17 passed |
|
||
| `test:runtime-schema` | 40 passed |
|
||
| `tests/unit` + `tests/component` | 1851 passed / 98 failed |
|
||
|
||
The 98 failures are the same two sandbox files as the baseline —
|
||
`ci-workflow-generation` (82) and `ci-artifact-contract` (16, down from 19). No
|
||
file fails that did not fail before the merge.
|
||
|
||
## Mistake made during this merge
|
||
|
||
`git stash` was run inside the worktree while the merge was still in progress,
|
||
to compare a gate against the pre-merge tree. That removed `MERGE_HEAD`: the
|
||
resolved content survived, but git no longer knew a merge was underway, and
|
||
committing then would have produced a single-parent commit — leaving `main` off
|
||
the ancestry and breaking the fast-forward that step 2 depends on. `MERGE_HEAD`
|
||
was restored to `bdee07a` before committing, and the resulting commit has both
|
||
parents.
|
||
|
||
To compare against a pre-merge state, use a separate checkout rather than
|
||
stashing an in-progress merge.
|
||
|
||
## Correction after review
|
||
|
||
Two of the resolutions above were wrong, and were fixed in a follow-up commit.
|
||
|
||
The template's demonstration screens — `platform-overview-page`, the UI and
|
||
state galleries, the auth example and the reference feature's screens — exist to
|
||
explain the template. A product replaces them with its domain, and deleting them
|
||
is the expected end state, not a regression. Two gates were nevertheless coupled
|
||
to them, and the first resolution accommodated that coupling instead of fixing
|
||
it.
|
||
|
||
### `tests/unit/product-features.test.ts` — a hard-coded exemption became a rule
|
||
|
||
The guard requires every installed registry to compose from the manifest.
|
||
`installed-feature-runtimes.tsx` was added to its exempt list once the reference
|
||
feature's presentation layer was gone. That silenced the guard for that file
|
||
permanently.
|
||
|
||
It now derives its own scope: a registry must gate on the manifest **when it
|
||
imports a module belonging to a manifest-declared feature**. A product whose
|
||
registries compose only its own domain drops out of the rule honestly, and the
|
||
guard fires again the moment a declared feature is imported without gating —
|
||
verified by removing the manifest reference from
|
||
`installed-feature-adapters.ts` and watching the guard fail. A counter asserts
|
||
the sweep is still watching at least one file, so an empty scope cannot pass
|
||
silently.
|
||
|
||
### `tests/component/product-feature-switch.test.tsx` — coverage restored
|
||
|
||
The end-to-end kill-switch assertions were replaced with composition checks
|
||
because the screens they rendered were gone. The mechanism under test is the
|
||
ownership lookup plus `isFeatureActive`, which has nothing to do with which
|
||
screens ship, so **the ownership map is now the fixture**: one real registered
|
||
route is attributed to a real installed feature, and the router, shell,
|
||
components and codecs are all the product's own. The deep-link half is asserted
|
||
end to end again.
|
||
|
||
### What that restoration exposed
|
||
|
||
The navigation-withdrawal half **is not implemented in this product**. It lives
|
||
in the template's `PrimaryNavigation`, and this product does not render the
|
||
template's `AppShell` at all — the public site header is a hand-written list of
|
||
paths in `src/features/tech-log/presentation/public/components/site-header.tsx`,
|
||
and the studio has its own shell.
|
||
|
||
So a disabled feature's route is refused by the router but its link would still
|
||
be advertised. That is harmless only while no feature-owned route is navigable,
|
||
which is true today and is now asserted. If that assertion fails, the header has
|
||
to consult `ROUTE_FEATURE_OWNER` — or navigation has to move back onto
|
||
`NAVIGATION_ROUTES` — before the route ships.
|
||
|
||
## Follow-ups this merge deliberately did not decide
|
||
|
||
1. **TechLog is outside the product manifest.** It is composed directly rather
|
||
than as a `SelectableProductFeature`, so the runtime kill switch does not
|
||
govern it. That is defensible — a product's own domain is not an optional
|
||
feature — but it means the switch governs nothing user-visible today.
|
||
2. **The reference feature declares routes it cannot serve.** Its screens were
|
||
deleted with the rest of the demonstration UI, but its contract still
|
||
declares `REFERENCE_RESOURCE_*` routes. Either the declarations go, or the
|
||
feature does. `TechLog` does not import it (`git grep reference-feature --
|
||
src/features/tech-log` is empty), so removing it is a live option and
|
||
FE-GATE-020 exists to prove it can be removed.
|
||
3. **The public site header is not feature-aware.** See above.
|