---
phase: 03-server-documentation-schemas
plan: 11
subsystem: save-format-doc
tags: [gap-closure, ts-emitter, dedup, readonly-fix, save-format-doc, tdd]
requires:
  - 03-03-SUMMARY.md
provides:
  - tools/save-format-doc/output/save-formats.ts (strict-mode-clean Phase 4 import target)
  - tools/save-format-doc/src/emit/typescript.ts (de-dup + ReadonlyArray fix)
  - tools/save-format-doc/tests/unit/emit-typescript.test.ts (regression lock)
affects:
  - docs/extracted-server/save-formats.json (source unchanged — JSON byte-identical)
tech-stack:
  added: []
  patterns:
    - Per-scope seen-Map de-duplication for TS property emission
    - ReadonlyArray<T> generic form for TS1354-clean readonly array types
key-files:
  created:
    - tools/save-format-doc/tests/unit/emit-typescript.test.ts
  modified:
    - tools/save-format-doc/src/emit/typescript.ts
    - tools/save-format-doc/output/save-formats.ts
decisions:
  - Use per-call local seen Map (not module-scope) so recursive section sub-bodies get independent counters
  - Rename later duplicate occurrences as name_2, name_3 (not name__2 or name_dup) for readability
  - Carry @gmlName JSDoc on renamed properties to preserve wire-format identity for Phase 4 codecs
  - Replace 'readonly Array<' with 'ReadonlyArray<' (generic form) — TS1354 requires readonly only on array/tuple literals
  - Preserve 'readonly T[]' shorthand at the scalar-array emit path (line 100 of emitter) — already TS1354-valid
metrics:
  duration: ~35 minutes
  completed: 2026-05-05T10:04:54Z
  tasks_completed: 1
  files_modified: 3
---

# Phase 03 Plan 11: Save-Format TS Emitter De-Dup + ReadonlyArray Fix Summary

**One-liner:** Per-scope field-name de-duplication and `readonly Array<>` → `ReadonlyArray<>` correction in the save-format-doc TypeScript emitter, making `output/save-formats.ts` compile cleanly under `--strict --noUncheckedIndexedAccess --exactOptionalPropertyTypes` for Phase 4 import.

## Objective

Close gap CR-02 from 03-VERIFICATION.md (Truth #6, FAILED): `tools/save-format-doc/output/save-formats.ts` was not importable verbatim under TypeScript strict mode due to two independent bugs in `emitGrammarBody`:

1. **TS2300 (Duplicate identifier):** MB_Log.bnb's `@TOPIC` section loop legitimately writes `mb_topic` three times (string subject, real total, real reply-count). The emitter had no per-scope de-duplication — all three emitted `mb_topic:` in the same inline object body.

2. **TS1354 (readonly on non-array type):** Two emit sites used `readonly Array<{...}>` and `readonly Array<readonly Array<{...}>>`. TypeScript's `readonly` modifier is only valid on array/tuple literal types (`T[]`, `[T, U]`), not on the generic form `Array<T>`. The correct form is `ReadonlyArray<T>`.

## Implementation

### Task 1: TDD — RED then GREEN

**RED phase (commit `4b7dccb`):** Created `tools/save-format-doc/tests/unit/emit-typescript.test.ts` with five tests. Tests 1-4 failed against the corrupt emitter (confirming the bugs). Test 5 already passed (SaveFormatAny union was unaffected).

**GREEN phase (commit `1ddb996`):** Fixed the emitter and updated the test fixture, then regenerated the artifact.

### De-duplication algorithm (fix for TS2300)

Added a `seen` Map and `dedup()` helper function at the top of `emitGrammarBody` in `tools/save-format-doc/src/emit/typescript.ts`:

```typescript
const seen = new Map<string, number>();

function dedup(baseName: string): { emitted: string; count: number } {
  const count = (seen.get(baseName) ?? 0) + 1;
  seen.set(baseName, count);
  const emitted = count === 1 ? baseName : `${baseName}_${count}`;
  return { emitted, count };
}
```

Key design decisions:
- **Local scope per call:** The `seen` Map is declared inside `emitGrammarBody`, so each recursive call (each section sub-body) gets a fresh counter. Field `mb_topic` in section `@TOPIC` and the same-named field in `@REPLY` both emit as `mb_topic:` — the rename only applies within a single call's scope.
- **Rename pattern:** First occurrence → `name`, second → `name_2`, third → `name_3`. Applied to flat fields, loop keys, nested-loop keys, and section keys.
- **JSDoc tag:** Renamed properties carry `— @gmlName <original> (occurrence N)` in their JSDoc comment so Phase 4 codecs can recover the original GML wire name.

Applied to all four branches of the emitter:
- Flat field loop (`for (const f of flats)`)
- Scalar-array loop (single-flat body: `readonly T[]`)
- Object-array loop (`ReadonlyArray<{...}>`)
- Nested-loop (`ReadonlyArray<ReadonlyArray<{...}>>`)
- Section keys (duplicate marker names)

### ReadonlyArray correction (fix for TS1354)

Two surgical edits in `tools/save-format-doc/src/emit/typescript.ts`:

**Edit 1 — loop body with multiple fields (was line 104):**
```diff
-lines.push(`${indent}${safeKey(emitted)}: readonly Array<{`);
+lines.push(`${indent}${safeKey(emitted)}: ReadonlyArray<{`);
```

**Edit 2 — nested loop (was line 114):**
```diff
-`${indent}${safeKey(emitted)}: readonly Array<readonly Array<{`,
+`${indent}${safeKey(emitted)}: ReadonlyArray<ReadonlyArray<{`,
```

**Untouched:** Line 100's `readonly ${ts}[];` (the scalar-array shorthand) is TS1354-valid and was not modified. The property-level `readonly` modifiers on `_filenamePattern`, `_encoding`, `_archived` (lines 178, 179, 184) are also valid property modifiers — not touched.

### Formats affected

| Format | Section | Field | Old emission | Fixed emission |
|--------|---------|-------|-------------|----------------|
| MB_Log.bnb | topic.topics[] | mb_topic[i,0] | `mb_topic: string` | `mb_topic: string` (unchanged) |
| MB_Log.bnb | topic.topics[] | mb_topic[i,1] | `mb_topic: number` (TS2300) | `mb_topic_2: number` + `@gmlName` |
| MB_Log.bnb | topic.topics[] | mb_topic[i,2] | `mb_topic: number` (TS2300) | `mb_topic_3: number` + `@gmlName` |
| MB_Log.bnb | topic.topics[] | (array type) | `readonly Array<{` (TS1354) | `ReadonlyArray<{` |
| MB_Log.bnb | reply.topics | (array type) | `readonly Array<readonly Array<{` (2x TS1354) | `ReadonlyArray<ReadonlyArray<{` |
| User_DBUpdated.bnu | rows | (array type) | `readonly Array<{` (TS1354) | `ReadonlyArray<{` |
| UserData/Inv/Inventory_<uid>.bnu | categories | (array type) | `readonly Array<readonly Array<{` (2x TS1354) | `ReadonlyArray<ReadonlyArray<{` |

### Test approach (real-data round-trip, Windows-portable)

Five tests in `tools/save-format-doc/tests/unit/emit-typescript.test.ts`:

- **Test 1:** Synthetic fixture (3 duplicate `mb_topic` flat fields in one loop body) — asserts `mb_topic:`, `mb_topic_2:`, `mb_topic_3:` each appear exactly once, `@gmlName mb_topic` appears >= 2 times.
- **Test 2:** Synthetic fixture (two sections each with a 2-field loop body containing `mb_topic`) — asserts `mb_topic:` appears exactly twice (once per section), `mb_topic_2:` absent (counters reset between section recursive calls).
- **Test 3 (real-data):** Reads `docs/extracted-server/save-formats.json`, runs `emitSaveFormatsTs`, asserts zero `readonly Array<` tokens, >= 2 `readonly T[]` shorthand occurrences, >= 4 `ReadonlyArray<` occurrences.
- **Test 4 (real-data):** Same real-data emission, walks all brace-delimited blocks via a brace-depth tracker, asserts no duplicate property keys within any block. Replaces shell-pipeline grep approach with portable TypeScript assertion.
- **Test 5:** Every `Save<Format>` interface name appears in `SaveFormatAny` exactly once (regression for Plan 03-03 union contract).

All five tests ran RED against the corrupt emitter, GREEN after the fix.

### Strict-mode compile result

```
cd tools/save-format-doc && pnpm exec tsc --noEmit --strict --noUncheckedIndexedAccess --exactOptionalPropertyTypes output/save-formats.ts
→ exit 0, zero diagnostics
```

No TS2300, TS1354, TS2717, TS2532, or TS2375 errors. This covers the full project-standard flag set per CLAUDE.md ("TypeScript everywhere. Strict mode.") and matches the flag set Plan 12's verify-gate will apply.

### Byte-deterministic re-emit

```
sha256sum tools/save-format-doc/output/save-formats.ts  → 42597fa2...
pnpm save-format-doc:catalog
sha256sum tools/save-format-doc/output/save-formats.ts  → 42597fa2...  (identical)
```

Same JSON input → same TS output across runs. Note: `docs/extracted-server/save-formats.json` is byte-unchanged (source of truth; the scanner re-reads GML snippets which introduces CRLF drift on Windows but the JSON committed value is LF-only and unchanged).

### Underlying JSON catalog unchanged

`pnpm lint:save-formats` exits 0: "9 formats validated". `docs/extracted-server/save-formats.json` diff is empty (only CRLF drift from Windows scanner re-read, restored by `git checkout`).

## Verification Results

| Check | Result |
|-------|--------|
| `pnpm test` (21 tests) | PASS — 3 test files, 21 tests, 0 failures |
| `tsc --noEmit --strict --noUncheckedIndexedAccess --exactOptionalPropertyTypes output/save-formats.ts` | EXIT 0 — zero diagnostics |
| `grep -c "mb_topic_2:" output/save-formats.ts` | 1 (was 0) |
| `grep -c "mb_topic_3:" output/save-formats.ts` | 1 (was 0) |
| `grep -c "@gmlName mb_topic" output/save-formats.ts` | 2 |
| Zero `readonly Array<` tokens | OK |
| `ReadonlyArray<` count >= 4 | 6 occurrences |
| `readonly T[]` shorthand count >= 2 | 4 occurrences |
| `pnpm lint:save-formats` | "9 formats validated" |
| Deterministic re-emit (SHA256 match) | PASS |
| `save-format-doc:verify` | Pre-existing CRLF/LF drift on Windows — deferred to Linux CI per Plan 09 SUMMARY |

## Deviations from Plan

### Test fixture correction (Rule 1 — Bug)

**Found during:** GREEN phase, after running tests.

**Issue:** `makeTwoSectionsTable` fixture used single-flat loop bodies, which the emitter collapses to `readonly T[]` (scalar array) — no `mb_topic:` property key emitted at all. Test 2 expected `mb_topic:` to appear twice but it appeared zero times.

**Fix:** Changed the fixture to use 2-flat loop bodies (added a second `mb_total` flat field) so the loop emits `ReadonlyArray<{mb_topic: ...; mb_total: ...}>` — the `mb_topic:` property key actually appears in the output. This makes Test 2 correctly validate counter-reset behavior between sections.

**Files modified:** `tools/save-format-doc/tests/unit/emit-typescript.test.ts`

**Commit:** `1ddb996` (combined with emitter fix and artifact regeneration)

## Known Stubs

None. The `output/save-formats.ts` parser stubs (`parseSave*`, `serializeSave*`) that throw `'TODO Phase 4 SRV-10/11 implementation'` are intentional — they are the defined Phase 3 output (CLAUDE.md hard rule #6: no runtime parsing before Phase 4). Not stubs in the sense of unresolved plan goals.

## Threat Flags

None. No new network endpoints, auth paths, file access patterns, or schema changes introduced. This plan only modifies TypeScript emission logic and a committed generated artifact.

## Note on Plan 12

Plan 12 will add the strict-mode `tsc --noEmit` invocation (with the full `--strict --noUncheckedIndexedAccess --exactOptionalPropertyTypes` flag set) to the composite `scripts/verify-phase-3.mjs` gate so this cannot regress. Until Plan 12 lands, the manual invocation above is the verification proof.

## Self-Check: PASSED

| Item | Status |
|------|--------|
| `.planning/phases/03-server-documentation-schemas/03-11-SUMMARY.md` | FOUND |
| `tools/save-format-doc/src/emit/typescript.ts` | FOUND |
| `tools/save-format-doc/tests/unit/emit-typescript.test.ts` | FOUND |
| `tools/save-format-doc/output/save-formats.ts` | FOUND |
| RED commit `4b7dccb` | FOUND |
| GREEN commit `1ddb996` | FOUND |
