Initial Commit
This commit is contained in:
91
openspec/changes/compress-svg-completion/proposal.md
Normal file
91
openspec/changes/compress-svg-completion/proposal.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Compress-SVG completion
|
||||
|
||||
**Package**: `@suaveplan/compress-svg`
|
||||
**Status**: PROPOSED
|
||||
**Type**: spec-conformance + feature
|
||||
|
||||
## Why
|
||||
|
||||
The genesis port of `@suaveplan/compress-svg` ships only the per-pass SVGO-style plugin core (`parser/svg`, `core/compress-svg`, `plugins/*`, `presets`, `types/common`) and a small set of in-place optimisation passes (cleanup-attrs, collapse-groups, convert-path-data, convert-transforms, mangle-ids, remove-comments, remove-doctype, remove-metadata). The legacy package at `../suaveplan/packages/universal/compress-svg/src` ships a substantially larger surface that is required by downstream tooling: a high-level `SvgOptimizer` façade, a structural-flattening pass (`SvgStructuralOptimizer`), cross-file content-addressed deduplication (`SvgDeduplicator`), a standalone `PathTokenizer` module, a CLI bin (`suaveplan-svg-optimize`), a binary archive format (`SvgPacker` + `SvgUnpacker` + `SvgArchiveBuilder` + `SvgArchiveUnpacker` totalling ~3 472 lines), and a browser unpacker (`SvgUnpackerBrowser` + `ArchiveUnpacker` ~1 592 lines) that consumes the archive without Node.js APIs.
|
||||
|
||||
Genesis policy is "no half-baked or mediocre libraries" (CLAUDE.md §Workflow rule 3) and "Preserve the full public surface during rewrites … never trim on 'probably unused' grounds" (CLAUDE.md §Workflow rule 5). The audit on 2026-05-02 confirmed every one of the seven gaps below by file-listing diff between `/home/ubuntu/web/suaveplan/packages/universal/compress-svg/src` and `/home/ubuntu/web/genesis/packages/docs/compress-svg/src`. This change closes those gaps in one coordinated pass so the package matches its upstream contract before any consumer wires it up.
|
||||
|
||||
## What changes
|
||||
|
||||
### `@suaveplan/compress-svg`
|
||||
|
||||
- ADDED: `SvgOptimizer` class — orchestrator that runs the configured plugin pipeline and surfaces a typed `OptimizationResult` (`{ originalBytes, optimizedBytes, pluginsApplied, warnings, durationMs, ratio }`); accepts a `SvgOptimizerConfig` selecting plugins, presets, and the dictionary/structural/dedup pre-passes; exposes `optimize(svg)` and `optimizeBatch(svgs)`.
|
||||
- ADDED: `SvgStructuralOptimizer` class — flatten redundant `<g>` wrappers (single-child groups with no styling), collapse identical attribute sets, hoist common attributes to a parent `<g>`, merge consecutive `<path>` elements with identical fill/stroke/style, returns a `StructuralOptimizationResult` with per-rule counts.
|
||||
- ADDED: `SvgDeduplicator` class — cross-file content-addressed deduplication; SHA-256 hashes the canonicalised inner content of repeating subtrees; emits `<symbol id="…">` definitions in a hoisted `<defs>` and replaces duplicates with `<use href="#…"/>`; returns a `DeduplicationReport` (`{ candidatesScanned, duplicatesFound, bytesSaved, symbolMap }`).
|
||||
- ADDED: `tokenizePath(d)` and `formatTokens(tokens)` — standalone path tokeniser that emits a typed `PathToken[]` stream (`{ command, args, absolute }`) and round-trips back to the original `d` string.
|
||||
- ADDED: `SvgPacker` and `SvgUnpacker` — binary archive format with version header, `SvgArchiveManifest`, and content-addressed payload table; pack/unpack by string SVG name → bytes; archive layout matches legacy on-disk format byte-for-byte for cross-tool compatibility.
|
||||
- ADDED: `ArchiveUnpacker` and `SvgUnpackerBrowser` — browser-only unpackers that read the binary archive without Node.js APIs (`Buffer`, `fs`, `zlib`); use Web Streams and `globalThis.crypto`; ship under the `./browser` subpath export.
|
||||
- ADDED: `bin/suaveplan-svg-optimize` — CLI command that takes glob patterns and writes optimised SVGs (in-place, to a directory, or to a binary archive); flags: `--in`, `--out`, `--preset`, `--archive`, `--dedupe`, `--structural`, `--dry-run`, `--quiet`, `--json`.
|
||||
- ADDED: subpath exports `./optimizer`, `./structural`, `./deduplicator`, `./path-tokenizer`, `./archive`, `./browser`, `./cli`.
|
||||
- ADDED: error codes registered on `@suaveplan/error` under `COMPRESS_SVG.*`: `OPTIMIZER_PIPELINE_FAILED`, `STRUCTURAL_INVALID_TREE`, `DEDUPE_HASH_COLLISION`, `PATH_TOKEN_PARSE_FAILED`, `ARCHIVE_VERSION_MISMATCH`, `ARCHIVE_CHECKSUM_FAILED`, `ARCHIVE_PAYLOAD_NOT_FOUND`, `BROWSER_UNSUPPORTED_ENVIRONMENT`, `CLI_INVALID_GLOB`.
|
||||
- MODIFIED: `package.json` `bin` field — adds `suaveplan-svg-optimize`. `exports` map gains the seven new subpaths.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Server-side rasterisation to PNG/JPG** — belongs in `@suaveplan/compress-image`; rasterisation requires a different dependency surface (`sharp` adapter or canvas) and the SVG package must remain a pure-string transformer.
|
||||
- **Sprite-sheet generation** — separate concern (atlas packing, layout, viewBox calc); deferred to a future `@suaveplan/svg-sprite` package if needed.
|
||||
- **Lossy colour quantisation beyond the existing plugin set** — the existing `cleanup-attrs` + `convert-path-data` passes already handle precision; introducing palette quantisation would require a colour-distance algorithm and is not what callers ask for.
|
||||
- **Drop-in SVGO-plugin compatibility** — the genesis plugin model is intentionally simpler than upstream SVGO; mapping arbitrary SVGO plugins is its own change.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/packages/universal/compress-svg/src`. There is no dedicated `enterprise/compress-svg.md` audit doc upstream, so the source mapping cites file:line ranges directly.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `SvgOptimizer` façade | `optimizer/SvgOptimizer.ts` | L1-L483 | Verbatim port; replace Node `zlib` with injectable `CompressorAdapter` for browser-safe build |
|
||||
| `OptimizationResult` and `SvgDictionary` types | `optimizer/SvgOptimizer.types.ts` | L1-L27 | Verbatim |
|
||||
| `SvgStructuralOptimizer` | `structural/SvgStructuralOptimizer.ts` | L1-L458 | Verbatim |
|
||||
| `SvgStructuralOptimizer` types | `structural/SvgStructuralOptimizer.types.ts` | (full file) | Verbatim |
|
||||
| `SvgDeduplicator` (cross-file dedup, `<use>` references, `DeduplicationReport`) | `deduplicator/SvgDeduplicator.ts` | L1-L398 | Verbatim port; replace `crypto.createHash('sha256')` with `@suaveplan/crypto-hash` `sha256` for browser/server parity |
|
||||
| `tokenizePath` / `formatTokens` / `PathToken` | `PathTokenizer.ts` | L1-L559 | Verbatim |
|
||||
| `SvgPacker` / `SvgUnpacker` (binary archive) | `SvgPacker.ts` | L1-L2931 | Verbatim port; the on-disk layout matches legacy byte-for-byte for cross-tool compatibility |
|
||||
| `SvgArchiveBuilder` | `archive/SvgArchiveBuilder.ts` | L1-L177 | Verbatim |
|
||||
| `SvgArchiveUnpacker` | `archive/SvgArchiveUnpacker.ts` | L1-L182 | Verbatim |
|
||||
| `ArchiveUnpacker` (browser) | `browser/SvgArchiveUnpackerBrowser.ts` | L1-L440 | Verbatim |
|
||||
| `SvgUnpackerBrowser` | `browser/SvgUnpackerBrowser.ts` | L1-L1152 | Verbatim |
|
||||
| `suaveplan-svg-optimize` CLI bin | `cli.ts` | L1-L271 | Verbatim |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `COMPRESS_SVG.*` error codes registered on `@suaveplan/error` | Genesis-wide policy that every thrown error route through `@suaveplan/error` (CLAUDE.md §Workflow rule 2). Upstream throws plain `Error`. |
|
||||
| Replace Node `zlib` with injectable `CompressorAdapter` in `SvgOptimizer` | Genesis ships universal-tier packages where browser tier matters; upstream binds to Node `zlib` directly. The adapter keeps the API identical while enabling the `./browser` subpath build. |
|
||||
| Replace `node:crypto` `createHash` with `@suaveplan/crypto-hash` `sha256` in `SvgDeduplicator` | Same browser/server parity reason; keeps a single SHA-256 implementation across the monorepo. |
|
||||
| `./optimizer`, `./structural`, `./deduplicator`, `./path-tokenizer`, `./archive`, `./browser`, `./cli` subpath exports | Genesis-wide subpath-per-feature rule (AGENTS.md §6). Upstream uses a single barrel; tree-shaking parity demands subpath splits. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
|
||||
|
||||
- `packages/docs/compress-svg/src/optimizer/svg-optimizer.test.ts` — pipeline orchestration, plugin selection, `OptimizationResult` shape, batch mode
|
||||
- `packages/docs/compress-svg/src/optimizer/svg-optimizer.spec-conformance.test.ts` — injectable `CompressorAdapter` round-trip; pipeline-failed error code; OTel histogram
|
||||
- `packages/docs/compress-svg/src/structural/svg-structural-optimizer.test.ts` — flatten single-child `<g>`; collapse identical attribute sets; hoist common attrs; merge consecutive paths
|
||||
- `packages/docs/compress-svg/src/structural/svg-structural-optimizer.spec-conformance.test.ts` — invalid-tree error code; per-rule counter accuracy
|
||||
- `packages/docs/compress-svg/src/deduplicator/svg-deduplicator.test.ts` — cross-file dedup, `<symbol>`/`<use>` emission, hash-collision rejection
|
||||
- `packages/docs/compress-svg/src/deduplicator/svg-deduplicator.spec-conformance.test.ts` — `DeduplicationReport` shape; injected hash function honoured; collision error code
|
||||
- `packages/docs/compress-svg/src/path-tokenizer/path-tokenizer.test.ts` — every SVG path command (`MmLlHhVvCcSsQqTtAaZz`); round-trip property test
|
||||
- `packages/docs/compress-svg/src/path-tokenizer/path-tokenizer.spec-conformance.test.ts` — malformed-path error code; absolute/relative flag preserved
|
||||
- `packages/docs/compress-svg/src/archive/svg-packer.test.ts` — binary header, manifest, payload table, content-addressed dedup, version round-trip
|
||||
- `packages/docs/compress-svg/src/archive/svg-packer.spec-conformance.test.ts` — version-mismatch error; checksum-failed error; payload-not-found error
|
||||
- `packages/docs/compress-svg/src/browser/svg-unpacker-browser.test.ts` — browser unpacker round-trip with the same fixtures as `svg-packer.test.ts`
|
||||
- `packages/docs/compress-svg/src/browser/svg-unpacker-browser.spec-conformance.test.ts` — Node `Buffer`/`fs`/`zlib` not referenced (static import scan); `BROWSER_UNSUPPORTED_ENVIRONMENT` thrown when `globalThis.crypto` is missing
|
||||
- `packages/docs/compress-svg/src/cli/cli.test.ts` — argument parsing, glob expansion, in-place vs out-dir vs archive modes, dry-run, JSON reporter
|
||||
- `packages/docs/compress-svg/src/cli/cli.spec-conformance.test.ts` — invalid-glob error code; exit codes match POSIX conventions
|
||||
- `tests/dep-graph.test.ts` — no Node.js API import on the `./browser` subpath; subpath exports resolve
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/compress-svg` to enumerate. Result of `grep -rl "@suaveplan/compress-svg" /home/ubuntu/web/genesis/packages/ --include="package.json"` on 2026-05-02 returned only the package itself (`packages/docs/compress-svg/package.json`). No in-repo dependents.
|
||||
|
||||
- No dependents (verified by `grep -rl "@suaveplan/compress-svg" /home/ubuntu/web/genesis/packages/ --include="package.json"` returning only the package's own `package.json`).
|
||||
|
||||
This change is **additive only** — every existing export is preserved, and the new exports land under new subpaths (`./optimizer`, `./structural`, `./deduplicator`, `./path-tokenizer`, `./archive`, `./browser`, `./cli`) plus root-level re-exports. The CLI bin is a new entry; existing users who depend on the per-pass plugin core continue to compile unchanged.
|
||||
@@ -0,0 +1,176 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: compress-svg (compress-svg-completion)
|
||||
|
||||
**Package**: `@suaveplan/compress-svg`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: SvgOptimizer façade
|
||||
|
||||
The package SHALL expose an `SvgOptimizer` orchestrator class that runs a configured plugin pipeline against one or more SVG documents and returns a typed `OptimizationResult` describing the bytes-before/bytes-after, the plugins applied, any warnings emitted, and the wall-clock duration. Callers can rely on the optimizer to delegate compression to an injectable `CompressorAdapter` so the same façade works on Node and in the browser; callers cannot rely on any specific Node API being available at the call site.
|
||||
|
||||
#### Scenario: single-document optimisation returns a populated result
|
||||
|
||||
- **WHEN** a caller invokes `new SvgOptimizer(config).optimize(svg)` with a valid SVG string and a config that selects at least one plugin
|
||||
- **THEN** the returned `OptimizationResult` carries `originalBytes > 0`, `optimizedBytes > 0`, `pluginsApplied.length >= 1`, `durationMs >= 0`, and `ratio = optimizedBytes / originalBytes`
|
||||
|
||||
#### Scenario: pipeline failure raises a stable error code
|
||||
|
||||
- **WHEN** any plugin in the pipeline throws while `optimize` is running
|
||||
- **THEN** `optimize` rejects with `SuaveplanError` whose `code` is `COMPRESS_SVG.OPTIMIZER_PIPELINE_FAILED` and whose `cause` is the original plugin error
|
||||
|
||||
#### Scenario: batch mode returns one result per input
|
||||
|
||||
- **WHEN** a caller invokes `optimizeBatch([svg1, svg2, svg3])`
|
||||
- **THEN** the returned array has length 3 and the indices line up with the inputs
|
||||
|
||||
**Verified by:** `src/optimizer/svg-optimizer.test.ts::"single-document optimisation returns a populated OptimizationResult"` AND `src/optimizer/svg-optimizer.spec-conformance.test.ts::"raises COMPRESS_SVG.OPTIMIZER_PIPELINE_FAILED with original cause when a plugin throws"` AND `src/optimizer/svg-optimizer.test.ts::"optimizeBatch returns one result per input in order"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SvgStructuralOptimizer
|
||||
|
||||
The package SHALL expose an `SvgStructuralOptimizer` class that performs four structural rewrites against a parsed SVG tree: flatten redundant `<g>` wrappers (single-child groups with no styling), collapse identical attribute sets across siblings, hoist common attributes to a parent `<g>`, and merge consecutive `<path>` elements that share `fill`, `stroke`, and `style`. Each rule is independently configurable on `StructuralOptimizerConfig` and the optimiser returns a `StructuralOptimizationResult` with per-rule counts so callers can audit what changed.
|
||||
|
||||
#### Scenario: single-child redundant `<g>` is flattened
|
||||
|
||||
- **WHEN** the input contains `<g><path d="…"/></g>` and the parent `<g>` carries no attributes
|
||||
- **THEN** the output replaces the wrapper with the inner `<path>` and `result.flattenedGroups === 1`
|
||||
|
||||
#### Scenario: consecutive identical-style paths merge
|
||||
|
||||
- **WHEN** two adjacent `<path>` elements have identical `fill`/`stroke`/`style`
|
||||
- **THEN** they are merged into one `<path>` whose `d` concatenates the two source `d` strings and `result.mergedPaths === 1`
|
||||
|
||||
#### Scenario: invalid input raises a stable error code
|
||||
|
||||
- **WHEN** the input cannot be parsed as SVG
|
||||
- **THEN** `optimize` throws `SuaveplanError` with code `COMPRESS_SVG.STRUCTURAL_INVALID_TREE`
|
||||
|
||||
**Verified by:** `src/structural/svg-structural-optimizer.test.ts::"flattens single-child redundant <g> wrappers"` AND `src/structural/svg-structural-optimizer.test.ts::"merges consecutive paths with identical fill/stroke/style"` AND `src/structural/svg-structural-optimizer.spec-conformance.test.ts::"throws COMPRESS_SVG.STRUCTURAL_INVALID_TREE on parser-rejectable input"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SvgDeduplicator (cross-file)
|
||||
|
||||
The package SHALL expose an `SvgDeduplicator` class that performs cross-file content-addressed deduplication: SHA-256 hashes the canonicalised inner content of each repeating subtree, hoists `<symbol id="dedup-{shortHash}">` definitions into a single `<defs>` block at the document root, and replaces each duplicate occurrence with `<use href="#dedup-{shortHash}"/>`. The deduplicator returns a `DeduplicationReport` with `candidatesScanned`, `duplicatesFound`, `bytesSaved`, and a `symbolMap` of short-hash → original subtree.
|
||||
|
||||
#### Scenario: identical subtrees in different files dedupe to one symbol
|
||||
|
||||
- **WHEN** two input SVGs each contain the same `<g>…</g>` subtree
|
||||
- **THEN** the merged output contains exactly one `<symbol id="dedup-…">` and two `<use href="#dedup-…"/>` references; `report.duplicatesFound === 1`
|
||||
|
||||
#### Scenario: hash collision raises a stable error code
|
||||
|
||||
- **WHEN** an injected hash function is configured to return the same digest for two distinct subtrees
|
||||
- **THEN** `dedupe` throws `SuaveplanError` with code `COMPRESS_SVG.DEDUPE_HASH_COLLISION`
|
||||
|
||||
#### Scenario: report carries accurate bytes-saved
|
||||
|
||||
- **WHEN** dedup runs against a corpus where 100 KB of subtree content is duplicated 4×
|
||||
- **THEN** `report.bytesSaved >= 200 KB` (3 of 4 occurrences replaced by short `<use>` references)
|
||||
|
||||
**Verified by:** `src/deduplicator/svg-deduplicator.test.ts::"merges identical cross-file subtrees into one <symbol> with <use> references"` AND `src/deduplicator/svg-deduplicator.spec-conformance.test.ts::"throws COMPRESS_SVG.DEDUPE_HASH_COLLISION when injected hash collides"` AND `src/deduplicator/svg-deduplicator.test.ts::"DeduplicationReport.bytesSaved reflects the actual byte savings"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PathTokenizer
|
||||
|
||||
The package SHALL expose `tokenizePath(d: string): PathToken[]` and `formatTokens(tokens: PathToken[]): string` that round-trip every valid SVG path `d` string. A `PathToken` carries the command letter, its arguments, and a flag indicating whether the command was absolute or relative. The tokeniser SHALL accept every standard SVG path command (`MmLlHhVvCcSsQqTtAaZz`).
|
||||
|
||||
#### Scenario: round-trip preserves input
|
||||
|
||||
- **WHEN** `tokenizePath(d)` is called on a valid path string and `formatTokens` is applied to the result
|
||||
- **THEN** the output normalises whitespace but parses to the same `PathToken[]` as the input
|
||||
|
||||
#### Scenario: absolute/relative flag preserved
|
||||
|
||||
- **WHEN** the input contains both absolute (`M 10 10`) and relative (`m 10 10`) commands
|
||||
- **THEN** the corresponding tokens carry `absolute: true` and `absolute: false` respectively
|
||||
|
||||
#### Scenario: malformed input raises a stable error code
|
||||
|
||||
- **WHEN** `tokenizePath` is called with a `d` string that is not a valid path
|
||||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.PATH_TOKEN_PARSE_FAILED` and the error carries the byte offset of the first invalid character
|
||||
|
||||
**Verified by:** `src/path-tokenizer/path-tokenizer.test.ts::"round-trips every SVG path command via tokenizePath/formatTokens"` AND `src/path-tokenizer/path-tokenizer.spec-conformance.test.ts::"preserves absolute/relative flag through round-trip"` AND `src/path-tokenizer/path-tokenizer.spec-conformance.test.ts::"throws COMPRESS_SVG.PATH_TOKEN_PARSE_FAILED on malformed d string"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Binary archive (SvgPacker / SvgUnpacker)
|
||||
|
||||
The package SHALL expose `SvgPacker` and `SvgUnpacker` classes that produce and consume a binary archive format with a fixed-byte version header, a manifest table mapping logical SVG names to byte ranges, a content-addressed payload table, and a checksum trailer. The on-disk layout matches the legacy `../suaveplan/packages/universal/compress-svg/src/SvgPacker.ts` byte-for-byte so archives produced by genesis can be consumed by legacy tooling and vice versa.
|
||||
|
||||
#### Scenario: round-trip preserves every input byte
|
||||
|
||||
- **WHEN** a directory of N SVG documents is packed via `SvgPacker.pack` and then unpacked via `SvgUnpacker.unpack`
|
||||
- **THEN** every output SVG is byte-equal to its input
|
||||
|
||||
#### Scenario: version mismatch raises a stable error code
|
||||
|
||||
- **WHEN** `SvgUnpacker.unpack` is called against an archive whose version header does not match a supported version
|
||||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.ARCHIVE_VERSION_MISMATCH`
|
||||
|
||||
#### Scenario: checksum mismatch raises a stable error code
|
||||
|
||||
- **WHEN** `SvgUnpacker.unpack` is called against an archive whose payload trailer checksum does not match the recomputed payload checksum
|
||||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.ARCHIVE_CHECKSUM_FAILED`
|
||||
|
||||
#### Scenario: missing payload raises a stable error code
|
||||
|
||||
- **WHEN** a caller invokes `SvgUnpacker.unpack(archive).get(name)` for a name not present in the manifest
|
||||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.ARCHIVE_PAYLOAD_NOT_FOUND`
|
||||
|
||||
**Verified by:** `src/archive/svg-packer.test.ts::"round-trips a directory of SVGs byte-for-byte through pack/unpack"` AND `src/archive/svg-packer.spec-conformance.test.ts::"throws COMPRESS_SVG.ARCHIVE_VERSION_MISMATCH on unsupported header"` AND `src/archive/svg-packer.spec-conformance.test.ts::"throws COMPRESS_SVG.ARCHIVE_CHECKSUM_FAILED on tampered payload"` AND `src/archive/svg-packer.spec-conformance.test.ts::"throws COMPRESS_SVG.ARCHIVE_PAYLOAD_NOT_FOUND on unknown name"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Browser unpacker
|
||||
|
||||
The package SHALL expose an `ArchiveUnpacker` class and an `SvgUnpackerBrowser` class under the `./browser` subpath that consume the binary archive format using only browser APIs (`globalThis.crypto.subtle`, `CompressionStream`, `DecompressionStream`, `Web Streams`, typed arrays). The browser subpath SHALL NOT statically import any Node-only API (`Buffer`, `node:fs`, `node:zlib`, `node:crypto`).
|
||||
|
||||
#### Scenario: archive round-trip via the browser unpacker matches Node
|
||||
|
||||
- **WHEN** the same archive bytes are unpacked by `SvgUnpacker` (Node) and `SvgUnpackerBrowser` (browser)
|
||||
- **THEN** the produced SVG strings are byte-equal
|
||||
|
||||
#### Scenario: missing browser environment raises a stable error code
|
||||
|
||||
- **WHEN** `SvgUnpackerBrowser.unpack` is invoked in an environment where `globalThis.crypto?.subtle` or `CompressionStream` is undefined
|
||||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.BROWSER_UNSUPPORTED_ENVIRONMENT`
|
||||
|
||||
#### Scenario: static import scan finds no Node-only APIs
|
||||
|
||||
- **WHEN** the test reads every `.ts` file under `src/browser/` and scans for `Buffer`, `node:fs`, `node:zlib`, `node:crypto` import specifiers
|
||||
- **THEN** zero matches are found
|
||||
|
||||
**Verified by:** `src/browser/svg-unpacker-browser.test.ts::"produces byte-equal output to the Node unpacker for the same archive bytes"` AND `src/browser/svg-unpacker-browser.spec-conformance.test.ts::"throws COMPRESS_SVG.BROWSER_UNSUPPORTED_ENVIRONMENT when globalThis.crypto.subtle is missing"` AND `src/browser/svg-unpacker-browser.spec-conformance.test.ts::"static import scan finds no Node-only APIs in src/browser"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CLI bin (`suaveplan-svg-optimize`)
|
||||
|
||||
The package SHALL ship a `suaveplan-svg-optimize` CLI bin that consumes glob patterns and writes optimised SVGs to disk, to a directory, or to a binary archive. The CLI SHALL accept the flags `--in <glob>`, `--out <dir>`, `--preset <name>`, `--archive <path>`, `--dedupe`, `--structural`, `--dry-run`, `--quiet`, and `--json`. The CLI exits with POSIX-conventional exit codes (0 success, 2 usage error, 1 runtime error).
|
||||
|
||||
#### Scenario: glob expansion writes one optimised file per match
|
||||
|
||||
- **WHEN** `--in "fixtures/*.svg" --out out/` matches three files
|
||||
- **THEN** three files are written to `out/` and each is byte-smaller than its input
|
||||
|
||||
#### Scenario: invalid glob raises a stable error code and exits 2
|
||||
|
||||
- **WHEN** `--in "no-such-glob/*.svg"` matches zero files
|
||||
- **THEN** the CLI prints a `SuaveplanError` with code `COMPRESS_SVG.CLI_INVALID_GLOB` and exits with code 2
|
||||
|
||||
#### Scenario: archive mode produces a valid archive
|
||||
|
||||
- **WHEN** `--in "fixtures/*.svg" --archive out/bundle.svgar` matches three files
|
||||
- **THEN** `SvgUnpacker.unpack(out/bundle.svgar)` produces three SVG strings byte-equal to the inputs
|
||||
|
||||
**Verified by:** `src/cli/cli.test.ts::"writes one optimised file per glob match into --out directory"` AND `src/cli/cli.spec-conformance.test.ts::"exits 2 with COMPRESS_SVG.CLI_INVALID_GLOB on no-match glob"` AND `src/cli/cli.test.ts::"archive mode produces a valid archive consumable by SvgUnpacker"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
144
openspec/changes/compress-svg-completion/tasks.md
Normal file
144
openspec/changes/compress-svg-completion/tasks.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Tasks — Compress-SVG completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick. The seven gaps closed here were never claimed by an earlier change; this is the first ticket that touches them.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Status: **DEFERRED**. `packages/docs/compress-svg/src/` only contains `__benchmarks__`, `constants`, `core`, `parser`, `plugins`, `presets`, `types` plus `error-codes.ts`. None of the seven gaps are filled: no `optimizer/` (SvgOptimizer), no `structural/`, no `deduplicator/`, no `path-tokenizer/`, no `archive/` (SvgPacker / SvgUnpacker / SvgArchiveBuilder), no `browser/` unpacker, no `cli.ts` bin entry. This is a substantial migration estimated at ~6 000+ source lines verbatim; 0% is done.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/packages/universal/compress-svg/src` end-to-end (no dedicated `enterprise/compress-svg.md` exists)
|
||||
- [ ] 1.2 Read `openspec/specs/compress-svg/spec.md` (does not exist yet — create on archive)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 0a (parity), 13 (verification), 14 (archival), 14a (affected dependents)
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [ ] 1.6 Draft `specs/compress-svg/spec.md` from `openspec/templates/spec-delta.md` covering all seven ADDED Requirements
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [ ] 1.8 `openspec change validate compress-svg-completion` exits with no errors
|
||||
|
||||
## 2. `SvgOptimizer` façade
|
||||
|
||||
- [ ] 2.1 Port `optimizer/SvgOptimizer.ts` (L1-L483) and `optimizer/SvgOptimizer.types.ts` into `packages/docs/compress-svg/src/optimizer/svg-optimizer.ts`
|
||||
- [ ] 2.2 Define `CompressorAdapter` interface and ship a Node adapter (binds `zlib`) and a browser adapter (binds `CompressionStream`)
|
||||
- [ ] 2.3 Wire the optimizer to the existing plugin pipeline (`core/compress-svg`); accept a `SvgOptimizerConfig` selecting plugins, presets, and the dictionary/structural/dedup pre-passes
|
||||
- [ ] 2.4 Emit OTel histogram `compress_svg.optimizer.duration_ms` and counter `compress_svg.optimizer.bytes_saved`
|
||||
- [ ] 2.5 Throw `COMPRESS_SVG.OPTIMIZER_PIPELINE_FAILED` when any selected plugin throws; chain the cause
|
||||
- [ ] 2.6 Sibling `.md` (≥200 words, basic + advanced examples)
|
||||
|
||||
## 3. `SvgStructuralOptimizer`
|
||||
|
||||
- [ ] 3.1 Port `structural/SvgStructuralOptimizer.ts` (L1-L458) and its `.types.ts` into `packages/docs/compress-svg/src/structural/svg-structural-optimizer.ts`
|
||||
- [ ] 3.2 Implement four rules: flatten single-child `<g>`; collapse identical attribute sets; hoist common attributes to a parent `<g>`; merge consecutive `<path>` elements with identical `fill`/`stroke`/`style`
|
||||
- [ ] 3.3 Return a `StructuralOptimizationResult` with per-rule counts; expose each rule as a configurable boolean on `StructuralOptimizerConfig`
|
||||
- [ ] 3.4 Throw `COMPRESS_SVG.STRUCTURAL_INVALID_TREE` on parser-rejectable input
|
||||
- [ ] 3.5 Sibling `.md`
|
||||
|
||||
## 4. `SvgDeduplicator`
|
||||
|
||||
- [ ] 4.1 Port `deduplicator/SvgDeduplicator.ts` (L1-L398) into `packages/docs/compress-svg/src/deduplicator/svg-deduplicator.ts`
|
||||
- [ ] 4.2 Replace `node:crypto.createHash('sha256')` with `@suaveplan/crypto-hash` `sha256` for browser/server parity
|
||||
- [ ] 4.3 Hoist a `<defs>` block at the document root with `<symbol id="dedup-{shortHash}">` for each duplicate; replace duplicates with `<use href="#dedup-{shortHash}"/>`
|
||||
- [ ] 4.4 Return a `DeduplicationReport` (`candidatesScanned`, `duplicatesFound`, `bytesSaved`, `symbolMap`)
|
||||
- [ ] 4.5 Throw `COMPRESS_SVG.DEDUPE_HASH_COLLISION` when two distinct subtrees produce the same short hash (extremely rare; guarded for safety)
|
||||
- [ ] 4.6 Sibling `.md`
|
||||
|
||||
## 5. `PathTokenizer`
|
||||
|
||||
- [ ] 5.1 Port `PathTokenizer.ts` (L1-L559) into `packages/docs/compress-svg/src/path-tokenizer/path-tokenizer.ts`
|
||||
- [ ] 5.2 Export `tokenizePath(d): PathToken[]` and `formatTokens(tokens: PathToken[]): string`; round-trip property test on 10 000 random valid paths
|
||||
- [ ] 5.3 Throw `COMPRESS_SVG.PATH_TOKEN_PARSE_FAILED` on malformed `d` strings; carry the byte offset of the first invalid character
|
||||
- [ ] 5.4 Sibling `.md`
|
||||
|
||||
## 6. Binary archive (`SvgPacker` / `SvgUnpacker`)
|
||||
|
||||
- [ ] 6.1 Port `SvgPacker.ts` (L1-L2931) into `packages/docs/compress-svg/src/archive/svg-packer.ts`
|
||||
- [ ] 6.2 Port `archive/SvgArchiveBuilder.ts` (L1-L177) and `archive/SvgArchiveUnpacker.ts` (L1-L182) into `packages/docs/compress-svg/src/archive/svg-archive-{builder,unpacker}.ts`
|
||||
- [ ] 6.3 Preserve the legacy on-disk layout byte-for-byte (version header, manifest table, content-addressed payload table, checksum trailer)
|
||||
- [ ] 6.4 Throw `COMPRESS_SVG.ARCHIVE_VERSION_MISMATCH`, `COMPRESS_SVG.ARCHIVE_CHECKSUM_FAILED`, `COMPRESS_SVG.ARCHIVE_PAYLOAD_NOT_FOUND` at the right call sites
|
||||
- [ ] 6.5 Round-trip property test: pack a directory of N SVGs → unpack → byte-equal originals
|
||||
- [ ] 6.6 Sibling `.md` for each implementation file
|
||||
|
||||
## 7. Browser unpacker
|
||||
|
||||
- [ ] 7.1 Port `browser/SvgArchiveUnpackerBrowser.ts` (L1-L440) and `browser/SvgUnpackerBrowser.ts` (L1-L1152) into `packages/docs/compress-svg/src/browser/`
|
||||
- [ ] 7.2 Replace every `Buffer`, `node:fs`, `node:zlib`, `node:crypto` import with Web-Streams / `globalThis.crypto` equivalents
|
||||
- [ ] 7.3 Static import scan in test asserts no Node-only API on the `./browser` subpath
|
||||
- [ ] 7.4 Throw `COMPRESS_SVG.BROWSER_UNSUPPORTED_ENVIRONMENT` when `globalThis.crypto?.subtle` or `CompressionStream` is missing
|
||||
- [ ] 7.5 Sibling `.md` for each implementation file
|
||||
|
||||
## 8. CLI bin
|
||||
|
||||
- [ ] 8.1 Port `cli.ts` (L1-L271) into `packages/docs/compress-svg/src/cli/cli.ts`
|
||||
- [ ] 8.2 Wire `bin` field in `package.json`: `"suaveplan-svg-optimize": "./dist/cli/cli.js"`
|
||||
- [ ] 8.3 Build the CLI atop `@suaveplan/cli` (argument parser, command router, output formatter, spinner, JSON reporter)
|
||||
- [ ] 8.4 Flags: `--in <glob>`, `--out <dir>`, `--preset <name>`, `--archive <path>`, `--dedupe`, `--structural`, `--dry-run`, `--quiet`, `--json`
|
||||
- [ ] 8.5 Throw `COMPRESS_SVG.CLI_INVALID_GLOB` when no files match `--in`; exit code 2 (POSIX EX_USAGE)
|
||||
- [ ] 8.6 Sibling `.md`
|
||||
|
||||
## 9. Error codes
|
||||
|
||||
- [ ] 9.1 Register `COMPRESS_SVG.*` codes in `error-codes.ts`: `OPTIMIZER_PIPELINE_FAILED`, `STRUCTURAL_INVALID_TREE`, `DEDUPE_HASH_COLLISION`, `PATH_TOKEN_PARSE_FAILED`, `ARCHIVE_VERSION_MISMATCH`, `ARCHIVE_CHECKSUM_FAILED`, `ARCHIVE_PAYLOAD_NOT_FOUND`, `BROWSER_UNSUPPORTED_ENVIRONMENT`, `CLI_INVALID_GLOB`
|
||||
- [ ] 9.2 Every code thrown by at least one production path; no orphan codes
|
||||
|
||||
## 10. Subpath exports
|
||||
|
||||
- [ ] 10.1 `package.json` `exports` map gains `./optimizer`, `./structural`, `./deduplicator`, `./path-tokenizer`, `./archive`, `./browser`, `./cli`
|
||||
- [ ] 10.2 Each subpath is tree-shakeable (`"sideEffects": false` already set; verify by build artefact size)
|
||||
- [ ] 10.3 `tests/dep-graph.test.ts` pins the subpath surface
|
||||
|
||||
## 11. Tests
|
||||
|
||||
- [ ] 11.1 `src/optimizer/svg-optimizer.test.ts` — pipeline orchestration, plugin selection, `OptimizationResult` shape, batch mode
|
||||
- [ ] 11.2 `src/optimizer/svg-optimizer.spec-conformance.test.ts` — injectable `CompressorAdapter` round-trip; pipeline-failed error code; OTel histogram emitted
|
||||
- [ ] 11.3 `src/structural/svg-structural-optimizer.test.ts` — every rule observable; per-rule counter accuracy
|
||||
- [ ] 11.4 `src/structural/svg-structural-optimizer.spec-conformance.test.ts` — invalid-tree error code
|
||||
- [ ] 11.5 `src/deduplicator/svg-deduplicator.test.ts` — cross-file dedup, `<symbol>`/`<use>` emission
|
||||
- [ ] 11.6 `src/deduplicator/svg-deduplicator.spec-conformance.test.ts` — `DeduplicationReport` shape; injected hash honoured; collision error code
|
||||
- [ ] 11.7 `src/path-tokenizer/path-tokenizer.test.ts` — every SVG path command; round-trip property test on 10 000 random paths
|
||||
- [ ] 11.8 `src/path-tokenizer/path-tokenizer.spec-conformance.test.ts` — malformed-path error code; absolute/relative flag preserved
|
||||
- [ ] 11.9 `src/archive/svg-packer.test.ts` — binary header, manifest, payload table, content-addressed dedup, version round-trip
|
||||
- [ ] 11.10 `src/archive/svg-packer.spec-conformance.test.ts` — version-mismatch / checksum-failed / payload-not-found error codes
|
||||
- [ ] 11.11 `src/browser/svg-unpacker-browser.test.ts` — browser unpacker round-trip with the same fixtures as the Node unpacker
|
||||
- [ ] 11.12 `src/browser/svg-unpacker-browser.spec-conformance.test.ts` — static-import scan asserts no Node API; missing-`globalThis.crypto` error path
|
||||
- [ ] 11.13 `src/cli/cli.test.ts` — argument parsing, glob expansion, in-place vs out-dir vs archive modes, dry-run, JSON reporter
|
||||
- [ ] 11.14 `src/cli/cli.spec-conformance.test.ts` — invalid-glob error code; POSIX exit codes
|
||||
- [ ] 11.15 `tests/dep-graph.test.ts` — subpath exports resolve, no Node API on `./browser`
|
||||
- [ ] 11.16 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for archive round-trip tests)
|
||||
|
||||
## 12. Documentation
|
||||
|
||||
- [ ] 12.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 12.2 README links every co-located `.md` under `## Modules`
|
||||
- [ ] 12.3 Update `CLAUDE.md` `compress-svg` package listing to reflect the restored surface
|
||||
|
||||
## 13. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 13.1 `bun run check` — Biome 0/0/0 across the monorepo
|
||||
- [ ] 13.2 `bunx turbo lint typecheck test --filter=@suaveplan/compress-svg` — green
|
||||
- [ ] 13.3 `bunx turbo test:coverage --filter=@suaveplan/compress-svg` — 100/100/100/100
|
||||
- [ ] 13.4 `bun scripts/openspec-conformance.ts @suaveplan/compress-svg` — exit 0, no `error` findings
|
||||
- [ ] 13.5 `bunx turbo build --filter=@suaveplan/compress-svg` — green
|
||||
|
||||
## 14. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 14.1 Generics threaded; `expectTypeOf` assertions on `OptimizationResult`, `DeduplicationReport`, `StructuralOptimizationResult`, `PathToken[]`
|
||||
- [ ] 14.2 Zod schemas wired at every declared boundary (`SvgOptimizerConfig`, `StructuralOptimizerConfig`, `DeduplicationConfig`, `SvgArchiveManifest`, CLI args)
|
||||
- [ ] 14.3 Every `COMPRESS_SVG.*` code thrown by at least one production path; no orphans
|
||||
- [ ] 14.4 Strict-mode behaviors tested both on and off (e.g. `dedupe: true` vs `false`; archive with vs without checksum)
|
||||
- [ ] 14.5 OTel histograms / counters asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 14.6 Lifecycle dedup tested where claimed: `SvgDeduplicator` idempotent on identical input; `SvgPacker` archive output stable byte-for-byte across runs (deterministic)
|
||||
- [ ] 14.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 15. Archive
|
||||
|
||||
- [ ] 15.1 Fold spec delta into `openspec/specs/compress-svg/spec.md` (creating the file)
|
||||
- [ ] 15.2 Move `openspec/changes/compress-svg-completion/` → `openspec/archive/<yyyy-mm-dd>-compress-svg-completion/`
|
||||
- [ ] 15.3 Refresh `MANIFEST.md` if the package's exports list materially changed
|
||||
- [ ] 15.4 Update `STATUS.md` if the package was previously listed as Pending
|
||||
Reference in New Issue
Block a user