Files
msd-core/.changeset
Behruz Nassre Esfahani febe6c9885 fix(#4685): a directory artifact fails its own entry instead of aborting the check (#4735)
* fix(#4685): a directory artifact fails its own entry instead of aborting the check

`must_haves.artifacts` entries are read with `safeReadFile`, which rethrows every
errno except ENOENT. A listed path that is a directory therefore threw EISDIR out
of the per-artifact loop: `query verify.artifacts` printed

    Error: EISDIR: illegal operation on a directory, read

and reported NOTHING — not the offending entry, and not the plan's other,
perfectly checkable artifacts. One directory entry disabled the whole plan's
check. Reproduced against a real plan before the fix, and after.

A directory is now reported as that entry's own failure, with an issue distinct
from `File not found` (the path did resolve; it simply is not the thing an
artifact entry can be checked against), and every other artifact in the plan is
still checked and reported independently. Anything else the stat or read throws
becomes that entry's failure too, carrying its errno, rather than discarding the
run — a check that disappears is worse than one that fails, because a failure is
visible.

Verifying directories properly — matching `contains:`/`min_lines:`/`exports:`
across the files inside one — is a feature decision and deliberately not made
here, per the issue's stated scope. Authoring-time rejection of a directory path
is likewise left alone: the brief raises it as a separate question, and the
runtime fix does not depend on it.

Also, found in pre-PR review and pre-existing: `safeReadFile(...) || ''` turned a
post-stat ENOENT into empty content, so an artifact declaring only
`path`/`provides` had no criterion left to fail and passed, having checked
nothing. A null read now reports that instead of inheriting a pass. It can only
turn a false pass into a failure.

Verified: reverting src/verify.cts to the merge-base turns both new rows red with
the exact EISDIR message; lint:ci exit 0; full suite 24/24 chunks, 37,280 tests,
0 failures; tests/verify.test.cjs 219/219.

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

* chore(#4685): backfill changeset PR number to 4735

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

* test(#4685): pin the injected-I/O branches, and narrow the guard's comment

Review findings from #4735.

Major — the two error branches this PR adds were untested, and the PR body
claimed the mid-check ENOENT window was "not deterministically reproducible
through the CLI seam these tests drive." That was wrong: ADR-3574 records this
repo's convention for exactly this — inject filesystem failures by
monkeypatching the fs method, never by chmod or mode-bit tricks, which root
bypasses and yields a test that passes with zero coverage in root Docker and CI.

Both branches are now pinned that way. The injection runs in the CHILD via
NODE_OPTIONS=--require, because `output()` writes fd 1 directly
(`writeAllSync(1, …)`, io.cjs) rather than through console.log, so an in-process
call cannot have its JSON captured. The preload patches the child's own module
objects, which the compiled code reads at call time.

  - a file that disappears between stat and read now fails as that entry rather
    than passing on empty content
  - a non-ENOENT errno (EACCES) is reported as that entry's failure, carrying its
    code, so an operator can tell a permissions problem from an I/O one

The injection matches the target by path SUFFIX, not string equality: the first
cut compared absolute paths, and a /tmp vs /private/tmp prefix difference
silently disarmed it — the test passed while asserting nothing. A disarmed
injection test is worse than no test, so the reason is recorded at the call site.

Nit — the comment above the try block said the guard "covers what the stat and
read below actually throw", which reads as if the min_lines/contains/exports
checks inside the same block were deliberately guarded too. They are pure string
operations and cannot throw; the comment now says so rather than implying a
guarantee it does not make.

Verified: reverting src/verify.cts to the merge-base turns all three #4685 rows
red — the directory row and both new ones; lint:ci exit 0; full suite 24/24
chunks, 37,389 tests, 0 failures; tests/verify.test.cjs 221/221.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-16 01:39:52 -04:00
..

Changeset Fragments

This directory holds per-PR CHANGELOG fragments. Every PR with user-facing changes drops one (or more) <random-name>.md files here describing its CHANGELOG entry. Fragments are consolidated into the top-level CHANGELOG.md at release time.

Why

Two PRs that both edit the ### Fixed block of CHANGELOG.md always conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh .changeset/<unique-name>.md never conflict because they don't share lines.

See #2975 for the full rationale.

Adding a fragment

node scripts/changeset/new.cjs \
  --type Fixed \
  --pr 1234 \
  --body "fix the thing — explain the user-visible change in one sentence"

This writes .changeset/<adjective>-<noun>-<noun>.md with frontmatter and a body. Three random words → concurrent PRs don't collide.

Format

---
type: Fixed
pr: 1234
---
**`/gsd-foo` no longer drops trailing slashes** — explain the user-visible change.

Allowed type: values follow Keep a Changelog: Added, Changed, Deprecated, Removed, Fixed, Security.

Opting out

PRs that legitimately have no user-facing impact can add the no-changelog label. CI honors it. When unsure, add the fragment.

At release time

Promotion is automatic. The release workflow's finalize job runs:

node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD --allow-empty

This reads every fragment, groups bullets by type:, replaces ## [Unreleased] with a new ## [vX.Y.Z] - YYYY-MM-DD block, opens a fresh ## [Unreleased] above, and deletes consumed fragments. The --allow-empty flag ensures a no-change release still gets a dated heading (with a _No notable changes._ placeholder). A subsequent verify step confirms the promotion landed correctly. Maintainers do not run this by hand.

Archived fragments

.changeset/archived/ holds fragments for already-shipped releases (≤ 1.3.1), retained for provenance. Their content was hand-curated into the dated ## [1.x.y] sections of CHANGELOG.md during the #690 backfill — they were never consumed by render. All changeset tooling enumerates .changeset/ non-recursively, so archived fragments are never picked up or rendered. Do not move them back to the top level.