You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Bumps the version to 4.7.0 and records the CHANGELOG for the multi-session
security & correctness audit landed since v4.6.1 (PRs #299β#319, excluding the
parked #307).
Headline: a family of enforcement-gate bugs where `check`, `score`, `coverage`,
`deps --strict`, `lifecycle enforce`, and `--require-coverage` silently exited 0
in exactly the states a gate exists to catch β now all fail loud. Plus config
data-loss fixes (migrate, multi-line TOML arrays, scalar inline comments), a
path-traversal hardening in the scaffolding commands, BOM handling for specs and
config, and export-scanner language-idiom fixes.
Several entries change runtime/CI exit codes; the CHANGELOG's upgrade note and
per-entry remedies spell out the migration (notably: shallow CI checkouts running
`specsync diff --base` need `fetch-depth: 0`).
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+29Lines changed: 29 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
8
8
## [Unreleased]
9
9
10
+
## [4.7.0] - 2026-07-04
11
+
12
+
> **Upgrade note:** This release closes a family of bugs where enforcement gates silently exited `0` in exactly the states they exist to catch β `check`, `score`, and `coverage` on warm caches or spec-less projects, `deps --strict` on undeclared imports, `lifecycle enforce` configured with the documented camelCase keys, and `--require-coverage` measured against zero source files. After upgrading, CI that passed on one of those false-greens may now correctly fail; each entry below gives the specific remedy. Shallow CI checkouts that run `specsync diff --base <ref>` should set `fetch-depth: 0`.
13
+
14
+
### Security
15
+
16
+
-**`add-spec`/`scaffold`/`new`/`wizard` reject path-traversal module names** β the module-creating commands wrote the user-supplied module name verbatim into `<specs_dir>/<name>/<name>.spec.md` and joined it onto source directories with no validation, so a name like `../../PWNED/evil` escaped the project root, wrote the spec file and its companion directory to arbitrary locations on disk, and then panicked (exit 101). A new `validate_module_name` guard now requires the name to be a single path segment, refusing empty names, path separators (`/`, `\`), `.`/`..`, and absolute paths with a clear error and exit 1 before any filesystem write; every module-creating entry point (`add-spec`, `scaffold`, `new`, and the interactive `wizard`) is gated. Legitimate names such as `auth`, `auth-service`, or `user_profile` are unaffected.
17
+
18
+
### Fixed
19
+
20
+
-**`migrate` aborts on an unparseable config instead of silently discarding it** β `specsync migrate` converted the legacy config to `.specsync/config.toml` and then deleted the original, but the JSON loader swallowed parse errors and fell back to `SpecSyncConfig::default()`. A single malformed field (such as a trailing comma) therefore wrote a pure-default config, deleted `specsync.json`, and exited 0 reporting success β silently losing settings like `source_dirs` and flipping `enforcement = strict` to `warn`, quietly turning a failing CI gate green, and `--strict` did not catch it. A pre-flight parse check now runs before any mutation: if the JSON config exists but does not parse, `migrate` prints the parse error, leaves the project byte-for-byte unchanged, and exits 1. Unknown keys are still accepted, and the lenient legacy `.specsync.toml` parser is exempt.
21
+
-**`hooks uninstall` no longer destroys user content after the managed block** β `hooks install` wrote its instruction block without a closing marker, so `hooks uninstall` deleted everything from the block header down to the next level-1 `# ` heading or end of file, silently discarding any level-2 sections (e.g. `## Deploy Notes`) or prose that followed and erasing the whole file when spec-sync had created it β all while exiting 0. Uninstall now scopes removal precisely: new installs wrap the block in `<!-- specsync:hook:begin -->` / `<!-- specsync:hook:end -->` sentinels and remove exactly between them, pre-sentinel (legacy) installs are matched against their exact known snippet text so the existing installed base is protected, and a heuristic fallback of last resort still refuses to delete a file that has content before the block. CRLF line endings are now preserved so Windows files no longer show a spurious diff.
22
+
- **`check` no longer exits 0 without evaluating a requested coverage/enforcement gate** β two paths passed CI in exactly the states a gate exists to catch. With a warm hash cache and no specs to re-validate, `check --require-coverage 90` printed the coverage line and exited 0 where a cold run exited 1, so a pre-commit hook or CI that caches `.specsync/` got a false green. Separately, a project with source but no specs (the default state right after `init`) bypassed every gate β `--require-coverage 100` at 0% coverage and `--enforcement enforce-new` both exited 0, and `--format json` emitted plain text instead of JSON. `check` now evaluates the gate against freshly computed coverage before the warm-cache early-out, and handles the empty-project case itself: it fails when coverage is below the threshold or `enforce-new` flags unspecced files, and emits proper JSON. A bare `check` with no gate still exits 0 in the default `warn` mode. Runs that previously false-passed now exit 1, so any CI or pre-commit relying on that green must raise coverage, add specs, or drop the gate flag.
23
+
-**`lifecycle enforce` now honors the documented camelCase config keys** β the hand-rolled TOML reader matched only snake_case, so a `.specsync/config.toml` written with the camelCase names shown in `lifecycle enforce --help` and the README (`maxAge`, `allowedStatuses`, `trackHistory`, plus the guard keys `minScore`/`requireSections`/`noStale`/`staleThreshold`) was silently dropped, leaving the CI gate a no-op that exited 0 even on a wildly overdue draft β anyone who configured it from the docs got a silently green build. Both camelCase and snake_case forms are now accepted as aliases, and an unknown `[lifecycle.*]` subsection (e.g. a typo'd `maxAgee`) now warns instead of being silently ignored. A repo that relied on a camelCase lifecycle config was effectively unguarded and will now fail `lifecycle enforce` on non-compliant specs.
24
+
- **Multi-line TOML arrays no longer corrupt `.specsync.toml`** β `load_toml_config` parsed the config line-by-line, so a formatter-style multi-line array (`source_dirs = [` with entries on the following lines) was read as the bare value `[` and collapsed every array key β `source_dirs`, `exclude_patterns`, and the rest β into a single bogus `["["]` entry. specsync then scanned a nonexistent directory named `[`, found zero source files, and reported vacuous 100% coverage on a green build; worse, `migrate` reads and rewrites the config, so it persisted the corruption and destroyed the valid file. The parser now accumulates continuation lines until the array closes, reads the span between the first `[` and the last `]` (tolerating a trailing comment and bracketed globs like `**/[abc]/**`), and strips quote-aware inline `#` comments, so multi-line arrays parse to their real entries while scalar parsing stays byte-for-byte unchanged. Configs that used multi-line arrays are now scanned against their real directories, so a `check` that previously passed on false 100% coverage may now report actual β and possibly failing β results.
25
+
-**`score` now honors the `--require-coverage`, `--enforcement`, and `--strict` gates** β these global gate flags were parsed but never wired into the `score` subcommand, so `specsync score --require-coverage 100` printed A/B/C grades and exited `0` even on an under-covered project; any CI job that used `score` as a gate passed silently green. `score` now computes file coverage and resolves enforcement (CLI over config) through the same `compute_exit_code`/`exit_with_status` path as `check` and `coverage`, exiting non-zero when a requested gate fails while keeping `--format json` stdout valid. When a gate is requested, `score` no longer takes the no-spec early-exit, so a spec-less project now fails `--require-coverage 100` instead of exiting 0; a plain `score` with no gate flags or an advisory Warn config stays exit `0` and is unchanged.
26
+
-**`deps --strict` gates on undeclared-import warnings** β `--strict` was a silent no-op for `deps`: the flag was never passed into `cmd_deps`, and undeclared-import warnings (a module importing another it does not list in `depends_on`) were printed but never affected the exit code, so `deps --strict` exited 0 and CI stayed green despite the violation. Now `deps --strict` exits 1 when any dependency warning is present, appending a `N dependency warning(s) treated as errors` note on non-JSON formats while leaving JSON output valid and gating purely via the exit code. Default `deps` remains advisory (exit 0), and `--mermaid`/`--dot` return before validation so visualization never gates; a project whose imports are all declared still passes under `--strict`.
27
+
-**`hooks install --claude-code-hook` no longer clobbers existing Claude Code hooks** β installing the specsync hook blindly overwrote the entire `hooks` object in an existing `.claude/settings.json`, so anyone who already had their own `PreToolUse`, `PostToolUse`, or other hooks configured lost them silently the moment they ran the command. Install now performs a per-event deep merge: events the user lacks are added, an event they already have gets specsync's matcher group appended to the existing array, and other events and unrelated top-level settings (`permissions`, `model`, β¦) are left untouched. A `hooks` value that is present but not an object is reset to a working object instead of being merged into a non-map, and the existing idempotency guard still prevents a second install from double-appending.
28
+
- **`coverage` and `score` no longer bypass enforcement gates on spec-less projects** β on a project with source files but no specs, `coverage` never evaluated its gate: the no-spec early exit returned 0 and the `--format json` branch unconditionally called `process::exit(0)`, so `coverage --require-coverage 100` (text or JSON) reported a green pass at 0% coverage while silently ignoring `--require-coverage`, `--enforcement`, `--strict`, and config-level enforcement. `score` had a narrower leak β its no-spec early exit was gated only on CLI flags, so a config-only `enforce-new`/`strict` was bypassed even though `check` failed correctly. Both commands now compute and report 0% coverage, evaluate the gate, and exit non-zero when enforcement is requested (the JSON path stays valid JSON on stdout), while a `warn` config with no flags still exits 0 as a friendly advisory; `coverage`, `score`, and `check` are now consistent. CI that ran `coverage`/`score` on projects with source but no specs and relied on a green exit will now fail β add specs or relax the requested enforcement/threshold.
29
+
-**`migrate` no longer silently drops config data during JSONβTOML conversion** β the converter never serialized `parseMode`, `modules`, or the security-relevant `customRules`, and an omitted `track_history` loaded as `false` instead of its documented `true`, so migrating a 3.x project silently discarded AST parse mode, module groupings, threat-model gates, and history tracking. Because `migrate` deletes the source `specsync.json` (unrecoverable under `--no-backup`, and the default backup is gitignored), the loss was irreversible. `parseMode` and `modules` now round-trip losslessly and `track_history` defaults to `true` to match serde and the docs. Since `customRules` can't be faithfully represented in the hand-rolled TOML, `migrate` now refuses rather than drop it: a preflight exits 1 before any mutation, names the blocking field, and leaves `specsync.json` byte-for-byte intact.
30
+
-**Inline `#` comments on scalar TOML config values** β the hand-rolled config reader stripped inline comments only from array values, so a scalar like `specs_dir = "specs" # note` parsed as the literal `"specs" # note`. That mis-resolved the specs directory, hid every spec, and made `check` silently exit `0` on a project it should have validated; every scalar key was affected (`schema_pattern`, `ai_timeout`, and others). The quote- and escape-aware `strip_inline_comment` now runs on scalar values too, dropping the trailing comment while preserving a `#` inside quotes (`"a#b"` stays `a#b`), so specs are discovered and validated again. Projects that leaned on the previous silent pass will now see `check` actually check those specs and may exit non-zero where it used to exit `0`.
31
+
- **Export scanner recognizes Swift `final class`, Go grouped `const`/`var`/`type` blocks, Kotlin top-level `const val`, and Rust exports hidden by doc-comment quotes** β four parser gaps made a correctly-documented public symbol report as "no matching export found" (failing `check --strict`, exit 1) while an undocumented one was silently dropped from coverage. The Swift decl regex allowed `static`/`class` but not `final`; items inside grouped Go `const (...)`/`var (...)`/`type (...)` blocks carried no keyword prefix and were skipped; top-level Kotlin `const val` was missing from the modifier chain; and the Rust scanner stripped string literals before comments, so a `"` inside a `//`/`///` doc comment (any odd number of quotes) was read as a string opener that swallowed every `pub fn` up to the next real `"` β which broke specsync's own self-check. The scanners now match `final`, capture grouped exported (uppercase) identifiers with brace-depth tracking so struct/interface fields aren't miscounted as top-level exports, accept `const val` (while still excluding `internal`/`private const`), and tokenize Rust `//` and `/* */` comments before strings. Because previously-hidden exports are now detected, a `check --strict` run that passed before may newly flag those symbols as undocumented.
32
+
-**Leading UTF-8 BOM no longer breaks frontmatter parsing** β a spec saved with a leading BOM (`U+FEFF`, common from Windows/Notepad and some editors) failed validation with a misleading `Missing or malformed YAML frontmatter (expected --- delimiters)` error and scored 0% file coverage, because the invisible byte sat before the opening `---` and defeated the `^---` frontmatter anchor even though the delimiters were present. `parse_frontmatter` now strips a single leading `U+FEFF` before matching, so BOM-prefixed specs parse and validate correctly and the returned body is BOM-free. The fix lives at the one choke point, so every caller benefits (validator, scoring, deps, registry, merge); a `U+FEFF` anywhere other than the start is a genuine zero-width no-break space and is preserved.
33
+
-**`--require-coverage` no longer passes vacuously when zero source files are found** β coverage was reported as 100% whenever no source files were discovered, so `--require-coverage N` was silently satisfied against nothing measured. A misconfigured project β a wrong `source_dirs`, or an over-broad `exclude_patterns` such as `**/**` β passed its coverage gate in CI while covering no code. `check` now exits 1 when `--require-coverage N` is set with `N > 0` and no source files are found, printing a message that points at `source_dirs` and `exclude_patterns`. A require of `0` (or no flag) still passes, and real projects at 100% with non-zero files are unaffected.
34
+
-**Config files with a leading UTF-8 BOM** β a `.specsync/config.toml` or JSON config saved with a leading UTF-8 byte-order mark (`U+FEFF`) was mis-parsed, so a setting the user wrote was silently dropped. In TOML the BOM attached to the first key, which was then discarded as an unknown key (`Warning: unknown key "ο»Ώspecs_dir" (ignored)`); in JSON the BOM broke `serde_json` entirely and the config fell back to defaults. Every config-file read now strips leading BOMs before parsing, and the `migrate` command's preflight and `discover_specs` paths route through the same helper β so `migrate` no longer hard-refuses a BOM'd JSON config and no longer silently skips specs under a BOM-obscured custom `specsDir`/`specs_dir`.
35
+
-**`diff` now fails loud on a bad base ref** β `diff` only checked whether `git diff` spawned, never its exit status. When git ran but exited non-zero (a bad base ref or a non-git directory) it produced empty stdout, which the command reported as `No files changed since <base>` and exited 0. A comparison that never happened was silently treated as "no drift", so `specsync diff --base <bad-ref>` β even under `--strict` β could green-light a PR in CI that was never actually diffed. The command now inspects `output.status`, surfaces git's stderr with the offending base ref, and exits 1; a valid base ref (with or without changes) is unaffected. Shallow CI checkouts may need `fetch-depth: 0` so the base ref resolves.
36
+
-**`check` no longer panics on a `**/**` coverage exclude pattern** β the `**/dir/**` branch of the exclude matcher extracted the inner directory with `&pattern[3..len-3]`, but for the degenerate `**/**` (length 5) the `**/` prefix and `/**` suffix overlapped, producing a reversed byte range `[3..2]` that panicked and aborted the run with exit 1. The matcher now peels the `**/` prefix and `/**` suffix independently, so `**/**` yields an empty directory fragment that matches every path and excludes all source files (coverage `0/0`) instead of crashing. Normal patterns like `**/gen/**` are byte-for-byte unchanged.
37
+
-**`is_test_file` misclassified whole projects as tests under a test-named parent directory** β coverage and the generator pass absolute, canonicalized paths, and the check walked *every* path component, so a project living beneath a directory named `test`, `tests`, `spec`, `specs`, `testing`, or `__tests__` (including repos checked out under `test/` in CI) had all of its source files treated as tests and silently excluded. Coverage reported `File coverage: 0/0 (100%)` while the real source went unchecked, and the `--require-coverage` gate then failed loud on the wrongly empty source set. `is_test_file` now takes `root` and bounds the test-directory check to components below it via `strip_prefix`, so ancestors above the project are ignored while in-project test dirs (`src/tests/`, `__tests__/`) and `.test.ts`/`.spec.ts` filename patterns are still excluded.
0 commit comments