Files
msd-core/scripts/changeset
Tom Boucher b2961c3f69 fix(#2070): accept adaptive model_profile in validate health; warn on invalid models tiers (W022) (#2336)
* test(#2070): fail-first tests for adaptive model_profile and models tier validation

Encodes the three acceptance criteria from #2070 plus the boundary cases the
resolver silently ignores today (non-string values, empty string, mistyped
phase-type key), and pins VALID_TIERS to a catalog-derived set.

Red phase: these fail against current src/ by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* fix(#2070): accept adaptive model_profile in validate health; warn on invalid models tiers (W022)

W004 sourced its profile list from a hand-maintained literal that predated the
adaptive profile, so `"model_profile": "adaptive"` was false-flagged. It now
reads VALID_PROFILES, which model-catalog.cts derives from model-catalog.json.

models.<phase_type> was validated nowhere: the resolver's tier gate silently
drops unknown values, so a typo like `"planning": "opuss"` was an undiagnosable
no-op. A new W022 flags unknown phase-type keys and invalid tier values
(including non-string values, which the same gate also drops).

VALID_TIERS moves from a function-local literal in model-resolver.cts to a
catalog-derived export, so health and the resolver cannot disagree by
construction rather than by parity test. Object.values(adaptiveTierMap) is
['opus','sonnet','haiku'] plus 'inherit' — identical to the previous literal,
so resolution behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* docs(#2070): changeset for validate health adaptive profile + W022

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* fix(#2070): close review findings — malformed models, tier-list duplication, changeset gate

Review of the initial fix surfaced three real defects, folded in per the
no-defer rule:

1. verify.cts: the W022 guard skipped a top-level `models` that is present but
   not a plain object (`[]`, `"opus"`, `5`, `true`). The resolver ignores those
   identically, so they were the same undiagnosable no-op #2070 targets — just
   one level up. They now warn; absent/null/{} stay silent.

2. config-loader.cts: RUNTIME_OVERRIDE_TIERS was a second hardcoded copy of the
   tier vocabulary this change had just de-hardcoded elsewhere. It now derives
   from the catalog via ADAPTIVE_TIER_VALUES (no 'inherit' — runtime overrides
   resolve to a concrete tier). Byte-equivalent to the old literal.

3. scripts/changeset/lint.cjs: USER_FACING_PREFIXES omitted `src/`. Post-ADR-457
   the product source is src/*.cts compiled to a gitignored gsd-core/bin/lib,
   so the `gsd-core/` prefix is dead coverage for library code and a src/-only
   PR could merge with no release note — including this one. Adding `src/`
   closes the gate; tests/ stays non-user-facing.

Also corrects a false docstring in the VALID_TIERS test: value-equality cannot
detect a re-hardcoded literal, so the test no longer claims it does.

Two review findings were rejected with evidence rather than actioned:
- W021 double-allocation is governed by ADR-612 ("W021 renumber -> void ...
  kept, message-disambiguated"), not a defect.
- Global-defaults validation would be a false-positive generator: config-loader
  reads ~/.gsd/defaults.json only on the "no .planning/" branch, and health
  early-returns E001 without .planning/, so those values provably never affect
  resolution in any context health can run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* test(#2070): regenerate install goldens for the changeset-lint change

scripts/ ships as an installed artifact, so scripts/changeset/lint.cjs's content
hash is pinned in all 18 runtime golden fixtures. Adding 'src/' to
USER_FACING_PREFIXES changed that hash and tripped every golden parity check.
Regenerated via `npm run gen:golden`; the only delta is the lint.cjs hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* docs(#2070): backfill PR number 2336 into changeset

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:48:04 -04:00
..

changeset/ — release-notes tooling

This directory holds the scripts that turn per-PR fragments in .changeset/ and git history into the project's CHANGELOG.md and GitHub release notes.

The entry point is cli.cjs. It exposes three subcommands:

Subcommand Purpose
render Render a single version's changelog section from consolidated data.
github-release-notes Build GitHub release-notes body for a ref range.
extract Pull existing CHANGELOG.md entries that fall in a version range.

The rest of this document specifies the extract contract, because it is the surface most likely to be called by external tooling (CI workflows, npm scripts, release automation) that needs a stable exit-code and output guarantee to code against.


cli.cjs extract

Extract the changelog entries for every release in a version range, reading from an existing CHANGELOG.md. The range is --from exclusive, --to inclusive.

node scripts/changeset/cli.cjs extract --from VERSION --to VERSION \
  [--changelog FILE] [--repo <dir>] [--json]

Flags

Flag Required Description
--from VERSION Yes Lower bound, exclusive — entries equal to --from are not returned.
--to VERSION Yes Upper bound, inclusive — entries equal to --to are returned.
--changelog FILE No Path to the changelog to read. Defaults to <repo>/CHANGELOG.md.
--repo <dir> No Repo root used to locate CHANGELOG.md when --changelog is omitted. Defaults to the current working directory.
--json No Emit the structured report as JSON instead of rendered markdown.

Version validation

Both --from and --to must be stable triplet semver — MAJOR.MINOR.PATCH, digits only.

  • A leading v is accepted and stripped: v1.42.0 is treated as 1.42.0.
  • Pre-release and build suffixes are rejected: 1.42.0-rc.1, 1.42.0+build, and partial versions like 1.42.x all fail validation and exit 1.

Strict validation is deliberate. Coercing a malformed bound such as 1.42.x to 1.42.0 would silently change which releases the range selects, so a malformed bound is rejected early with a structured error rather than guessed at.

Changelog entries that are themselves pre-release or non-semver (and the Unreleased section) are skipped during matching; a notice for each skipped entry is written to stderr.

Exit codes

extract resolves to one of three exit codes. The output shape depends on whether --json is passed.

Exit Meaning Default stdout --json stdout
0 One or more releases fall in the range. Rendered markdown for the matched releases. { "releases": [ ... ], "from": "...", "to": "..." }
1 Bad input: --from/--to is not stable semver, a required flag is missing, or the changelog file was not found. Nothing (a missing-flag error and usage go to stderr). { "error": "<message>", "releases": [] }
2 Bounds are valid but no release falls in the range. A no releases found in range notice on stderr. { "releases": [], "from": "...", "to": "..." }

Notes for callers:

  • Treat exit 2 as "empty range", not "failure". For a well-formed invocation it means the request was understood and simply matched nothing — do not surface it as an error. (At the argument-parsing layer, malformed argv such as an unknown flag also exits 2; pass well-formed arguments and this overlap does not arise.)
  • In default (text) mode, a failure is signalled by the exit code alone — exit 1 from invalid semver or a missing changelog writes nothing to stdout. Machine consumers should pass --json to receive the error field.

Output shape

With --json, the report is pretty-printed JSON. The releases array contains one object per matched release (version, date, and parsed sections); from and to echo the normalized bounds. On exit 1, releases is empty and an error string describes the failure.

Without --json, exit 0 prints the matched releases as markdown, ready to paste into release notes:

## [1.42.0] - 2026-01-15

### Added

- New `--json` flag on the extract command (#3796)

### Fixed

- Trailing-slash handling in config paths (#3651)

Examples

Extract everything released after 1.41.0 up to and including 1.42.0:

node scripts/changeset/cli.cjs extract --from 1.41.0 --to 1.42.0

The same range as structured JSON, reading an explicit changelog file:

node scripts/changeset/cli.cjs extract \
  --from v1.41.0 --to v1.42.0 \
  --changelog ./CHANGELOG.md --json

Handle the three outcomes in a shell consumer:

if out=$(node scripts/changeset/cli.cjs extract --from "$FROM" --to "$TO" --json); then
  echo "$out"            # exit 0 — releases found
else
  case $? in
    2) echo "no releases in range — nothing to publish" ;;  # not an error
    *) echo "extract failed: $out" >&2; exit 1 ;;            # exit 1 — bad input
  esac
fi