3 Commits

Author SHA1 Message Date
Jakub Zych
a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00
Tom Boucher
bbdf7e8e84 chore(#4654): add local/no-unconfined-path-join and drain it to zero — Phase 4 of #4636 (#4674)
* chore(#4654): add local/no-unconfined-path-join and drain it to zero

Phase 4 of epic #4636 — the ratchet, and the phase that makes the epic hold.

THE MEASUREMENT THAT RESHAPED THE PHASE. An AST census (the repo's own parser,
not grep) found what the epic never enumerated: ADR-4650 named seven containment
implementations; `src/` alone held roughly 24 more hand-rolled gates across ~13
files, several guarding a write or an `fs.rmSync`. Two verified by reading rather
than pattern-matching — `research-store.cts` comments its own as "ensure the
resolved file path stays inside the store dir" immediately before a write, and
`capability-lifecycle.cts` gates `fs.rmSync` with one.

So the epic's Done-when "one containment predicate, used at every site" was FALSE
when Phase 3 reported it satisfied. It is true now: the rule is clean across
src/, scripts/, gsd-core/bin/ and hooks/ with an EMPTY allowlist.

WHY NOT THE RULE THE ISSUE PROPOSED. #4654 proposed flagging `path.join` whose
first argument is a managed root and whose later arguments derive from argv. That
is a taint analysis over 2046 call sites, in ESLint, without type information;
"derives from argv" is not locally decidable. Any approximation either floods or
is trivially evaded, and a rule that fires on hundreds of correct sites earns an
allowlist of hundreds — the opposite of a ratchet. What is actually duplicated is
the COMPARISON, not the join, and that has one recognizable shape.

  Arm 1  X.startsWith(Y + sep)            the hand-rolled containment idiom
  Arm 2  a containment predicate called as a bare statement, answer discarded

Arm 2 is the issue's "asserts the result was narrowed, not merely that a helper
was called". Its example `validatePath(x, root).resolved` is already
structurally impossible — Phase 3 un-exported `validatePath` — so the remaining
expressible failure is ignoring the answer, which is the defect that recurred
five times in this epic. The census found exactly one live instance
(`milestone.cts:1643`); it now returns the proven `ContainedPath` so consumers
stop re-deriving the path the comment above it was extracted to stop them
re-deriving.

The rule deliberately does NOT try to catch validate-one-path-use-another where
the answer is used but a different variable flows onward. That needs flow
analysis; the branded `ContainedPath` from Phase 3 is the defense there, and the
two are complementary.

PER-SITE FAMILY CHOICE, NOT A DEFAULT. Phase 3's lesson binds: collapsing a
lexical site onto the realpath family broke four tests and was caught only by the
matrix. Every migrated site was triaged individually. The six
installer-migrations tree-walks and the six capability-lifecycle gates take the
LEXICAL family because their operands are already realpath-resolved and they
deliberately treat the final component as a link; boundary sites take realpath.

TWO SITES WITH AN INVERTED CONTRACT, which a mechanical swap would have broken.
`installer-migrations.cts:127` and `runtime-artifact-install-plan.cts:144` REJECT
`target === root` by contract, while the canonical comparison ACCEPTS it. Swapped
naively, a migration could `rmdir` the user's config root and a third-party
descriptor could write at configHome itself. Both keep `=== root` as an explicit
additional arm alongside the predicate call — the predicate decides containment,
the call site keeps its own extra condition (ADR-4650 decision 6).

ONE DUPLICATE DELETED OUTRIGHT: `planning-inspect.cts`'s `isWithinRoot` was
byte-identical to `isContainedIn` and said so in its own docstring.
`isContainedIn` is now exported for callers that have already resolved both
operands and need only the comparison, with a doc note that a caller which has
NOT resolved them must use a full predicate instead.

THE MARKER, AND WHY IT IS NOT THE ALLOWLIST. Nine sites are justified holdouts and
carry `// allow-handrolled-containment: <reason>` with a mandatory, reviewable
reason. Two justifications: (a) not a containment decision — an ancestor-walk loop
condition, sub-repo grouping, worktree identity matching, declared-path coverage;
(b) it IS containment but the canonical predicate is unreachable —
`capability-validator.cjs` is a committed pre-build `.cjs` and the compiled
`security.cjs` is untracked build output, so requiring it would break a fresh
clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci` with the same exposure.
The marker was renamed from `allow-lexical-prefix-match` mid-phase because that
name asserted only (a) and would have stated something false at the (b) sites.

A marker suppresses BEFORE the violation counter increments, so a file whose
every occurrence is marked still reports `staleAllowlistEntry` — otherwise a
drained entry lingers and silently re-permits the site later.

DEMONSTRATED RED, per #4654: a hand-rolled copy reintroduced into a real `src/`
file made `npm run lint` fail with the rule's full guidance message; removing it
returned the tree to clean. Both halves recorded — red alone proves nothing,
since a rule red for an unrelated reason looks identical.

DISCLOSED: `defaultRequireFromInstallRoot` (gsd-tools.cjs) previously carried two
distinct rejection messages and two manual realpath calls; routing it through
`tryWithinRoot` collapses them to one message, and a missing module now surfaces
as MODULE_NOT_FOUND rather than ENOENT. No test asserts either message. The
security property is preserved and slightly strengthened — the candidate is
realpathed and containment re-checked, and the dangling-symlink oracle closure
comes along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#4654): record the containment ratchet in CONTEXT.md and the security model

Both entries previously described the seam without the thing that keeps it a
seam. They now state what the rule bans, and — more usefully for whoever reads
this next — what it deliberately does NOT attempt: deciding per path.join call
whether an argument came from user input. That question is not locally
decidable, and an approximation across ~2000 join sites would earn an exemption
list of hundreds, which is the opposite of a ratchet.

Also records the marker's two legitimate justifications and that its reason is
mandatory, so the escape stays reviewable rather than becoming a mute button.

Glossary gate 270 refs exit 0; install-tree goldens and CONTEXT-INDEX.json
regenerated and confirmed byte-identical rather than assumed — which also
confirms eslint-rules/ is not a shipped path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4654): close review findings and the two matrix failures

MATRIX FAILURE 1 — a collapsed message broke a negative-proof test, and my
evidence for collapsing it was wrong. I searched tests/ for the literal string
"resolves outside its install root", found nothing, and reported that no test
asserted it. The test matches a REGEX SUBSTRING, /outside its install root/, so
the literal search missed it. What broke was "NEGATIVE PROOF: a symlinked module
pointing OUTSIDE the install root is not loaded" — the test guarding the exact
property I claimed was preserved. defaultRequireFromInstallRoot now does both
checks again with both messages byte-identical, each routed through the
canonical predicate, which is better than the original since that hand-rolled
both comparisons.

MATRIX FAILURE 2 — shipped migrations are checksum-locked, and a marker cannot
serve there. migrationChecksum hashes plan.toString(), which INCLUDES comments,
so a suppression marker inside a plan body drifts the baseline exactly as an
edit does. Measured: with markers in place, two of the four still differed from
their committed checksums. The four shipped bodies are now byte-identical to
next, and the rule's config excludes those four paths BY NAME rather than by a
directory wildcard, so a NEW migration is still covered. Six containment
comparisons stay un-ratcheted there; that gap is recorded in the rule's Known
gaps, in CONTEXT.md and in the security model rather than left implicit.
Justification (c) is removed from the marker's documented reasons, because a
marker was proven unable to express it.

ADVERSARIAL REVIEW — the sharpest finding was that the rule banned the CORRECT
shape while permitting the incorrect one: startsWith(root) with no separator is
the genuinely unsafe form, since it accepts a sibling such as root-evil, and my
own test blessed it as valid. Flagging every bare startsWith would swamp the
rule, so that stays a STATED gap rather than a silent one. Closed for real: the
template-literal spelling, which the census never saw because it only inspected
plus-concatenation — that surfaced TWELVE more sites, now triaged and migrated.
A separator reached through a const alias is now resolved via scope analysis.
And isContainedIn, exported in Phase 3, was missing from the discarded-result
set, so a bare no-op call went unflagged on the one function the epic funnels
through.

SECURITY REVIEW — the marker could over-suppress two ways: a block comment
worked identically to a line comment, and one marker silently covered every
violation sharing its line. It now requires a Line comment positioned after the
flagged node ends, so it anchors to the node it trails. Four sites had dropped
an unreachable-but-deliberate equality rejection against the root; each is
restored as the call site's own arm. eslint.config.mjs still documented the OLD
marker token, which my rename missed — it would have sent the next author in
circles.

A FALSE GREEN, recorded because it nearly stuck: lint:ci reported exit 0 from a
stale eslint cache while twelve real violations existed. Every lint check here
now clears the cache first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4654): anchor a suppression marker to the violation it actually trails

The matrix caught this; my own test caught it, on its first execution. The case
"two violations on one line: trailing marker suppresses only the one it trails"
expected 1 error and got 0 — both were suppressed.

ROOT CAUSE: the anchoring accepted any Line comment on the node's line whose
range started at or after the node's end. A trailing marker at the END of a line
sits after EVERY node on that line, so that condition held for all of them.
"After the node" does not identify WHICH node the marker trails. The fix reads
as correct and is not.

FIX: deferred reporting. Violations accumulate during traversal instead of being
reported immediately; at Program:exit each marker claims exactly ONE pending
violation — the one on its line whose end is nearest before the marker begins —
and every unclaimed violation is then counted and reported. One marker, one
suppression. An earlier violation sharing the line is still reported, which is
the property the security review asked for and the previous attempt only
appeared to deliver.

The counter now increments at flush time rather than during traversal, so a
suppressed occurrence still does not keep an allowlist entry alive.

AND A TOOL THAT SHOULD HAVE EXISTED BEFORE THE FIRST MATRIX RUN. `node --test`
is hard-blocked here, so this rule's test file could only ever be executed on
the remote matrix — which is why a broken anchoring shipped into a run. ESLint's
programmatic Linter API is not a test runner, and exercising the rule through it
verifies every case locally in seconds. All 24 now pass locally, including the
two-on-one-line case that failed remotely. That loop should have been built
before the rule was first sent to the matrix rather than after it failed twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4654): backfill PR 4674 into the changeset and complete 70-docs.json

The phase gate requires enablementSequence and the Diataxis quadrants; 70-docs
now carries both, with the how-to quadrant skipped for a stated reason rather
than an empty field. The audience for this deliverable is a contributor who
trips the rule, and the task-oriented guidance reaches them in the ESLint
message itself — which names the correct predicate, says how to choose between
the realpath and lexical families, cites the Phase 3 regression caused by
choosing wrong, and gives the marker syntax. A docs/how-to page would be a
second, driftable copy read by nobody at the moment of failure.

enablementSequence is recorded as what it actually is: a VERIFICATION sequence,
not an enablement one. The rule is never off, so there is no off-to-on
transition to describe.

scripts/lint-docs-required.cjs now passes (ok_docs_updated) — it could not
evaluate against the mandated pr:0 placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:17:46 -04:00
Tom Boucher
107eb8c1d9 feat(#3753): run docs guards on the PR that changes the docs they read (#3787)
A PR whose diff is entirely under docs/ runs zero tests, so a guard whose INPUT
is shipped prose cannot protect the PR lane of the diffs it exists to check. Its
only firing opportunity is after merge, on the shared branch -- which is how next
went red on dacae9273 while the PR that caused it (#3746) was green on every
check.

The docs-lint job in .github/workflows/docs-required.yml -- an ALREADY-REQUIRED
context -- now selects and runs the docs guards that read the specific docs files
the PR changed.

  scripts/docs-guard-registry.cjs    test file -> the docs paths it reads (63)
  scripts/select-docs-guards.cjs     pure (changedPaths, registry) -> test files
  scripts/lint-docs-guard-registration.cjs   drift guard, wired into lint:ci

scripts/ci-test-scope.cjs is NOT touched -- `git diff origin/next --` on it is
empty -- so #764's saving stands and its 21 pinning tests are untouched.

Selection: exact path; trailing-slash directory prefix (boundary-checked --
docs/adrenaline.md does NOT match docs/adr/, which a naive startsWith gets
wrong); and '*' for the 6 entries that walk docs/ generally or read a computed
path. Unknown maps to '*' -- guessing narrow is how a guard silently stops
running. Measured: a typo fix selects 6 of 63; docs/AGENTS.md selects 12;
docs/COMMANDS.md selects 18.

Four things this got wrong first, each found by an independent reviewer or by
probe, and each having been asserted safe in a comment:

1. The registry started as a RULE in ci-test-scope.cjs's RULES, on the theory
   that classify()'s !codeChanged normalization made it inert. True for
   docs-ONLY diffs; false for MIXED docs+code diffs, where codeChanged is true
   and the normalization never runs:

     node scripts/ci-test-scope.cjs --files "docs/a.md src/semver.cts"
       with the RULE:  25 targeted_tests
       origin/next:     3 targeted_tests

   Category error: RULES is the scoped lane's input; a docs-guard registry is a
   lane manifest for a consumer that never calls classify(). Extracted; pinned
   by value.

2. The second attempt was a dedicated workflow with paths: [docs/**]. Such a
   workflow never reports on a non-docs PR, so it can never be a required
   context without hanging every non-docs PR -- and a non-required check does not
   block a merge, so the guard would have been advisory and #3753 unfixed.
   docs-required.yml already has no paths: filter, already supplies the required
   docs-lint context, already computes docs_changed, and already ran one docs
   guard gated on it. Generalizing that step needs no ruleset edit at all.

3. The registry and the drift lint were built from ONE path-segment heuristic, so
   both were blind identically -- and blind at the guard that motivated the issue.
   The reader-call regex required a character BEFORE its keyword, so a callee
   named exactly read( / load( / parse( / doc( / file( / content( could never
   match; and only an INLINE path.join(ROOT,'docs','X.md') argument was caught,
   missing the two-step-via-variable form -- the MAJORITY spelling -- plus
   template literals and concatenation. Detector 1 fired on 14 of ~450 files, so
   35 genuine guards sat unregistered while the lint reported 0 violations,
   including cursor-reviewer (reads docs/COMMANDS.md, asserts
   .includes('--cursor')) and inventory-headings-countfree. The "accepted blind
   spot" this shipped with was the common case, not a fringe.

4. With detection fixed the true population is 115 files: 63 genuine guards, 52
   incidental. Running all 63 in a REQUIRED check on a one-line typo fix is the
   cost #764 exists to avoid -- install.test.cjs is 7840 lines and reads exactly
   one docs file, docs/AGENTS.md, for its frontmatter. Dropping it reproduces the
   bug; running it for a typo elsewhere is waste. Hence the map.

Then a second review round found six more, all fixed here:

- fragment-single-edit-propagation.install.test.cjs was EXEMPTED as
  "overlay fixture only". False: it reads the real docs/registries/eos.json and
  asserts on a registry entry name, and reads the real ADR-0001 and asserts its
  H1. A docs-only PR touching either would have gone green and red next -- #3753
  shipping again, from inside the fix for it. Now registered against both paths,
  and all 52 remaining exemptions were re-audited one by one.
- The SUITES-collision guard compared RAW registry keys, but run-tests.cjs strips
  a leading `tests/` BEFORE its suite check. So it caught 'all' and missed
  'tests/all' -- the only spelling that can actually occur, since every key
  carries the prefix. One typo would have run all 824 test files inside the
  required job. Now normalized the same way run-tests.cjs normalizes.
- The lint failed OPEN on an unreadable tests dir or candidate file: 0 violations,
  ok:true. A guard that cannot read its input must never report success.
- The exemption ratchet gated identity only, so a baselined file that later
  STARTED asserting on shipped docs stayed exempt silently -- 52 permanently blind
  files. The baseline now fingerprints the docs paths each exempted file
  references and fails when that set changes, naming what changed.
- The exemption marker was still honored inside a multi-line template literal in
  the header window. The scanner now tracks template-literal and block-comment
  state.
- `git diff --name-only | grep '^docs/'` silently dropped C-quoted non-ASCII docs
  paths, making docs_changed=false a green zero-guard check. Both call sites now
  pass -c core.quotepath=false.
- The run step was gated on hashFiles(), which a force-committed
  .docs-guard-tests.txt would satisfy. The step now rm -f's both scratch files
  first and gates on an output it sets itself.

Three empty states, deliberately distinct, because conflating them rebuilds
#3753: an empty or malformed registry HARD-FAILS; docs changed with no guard
covering them logs and skips; no docs change is already gated. The middle state
must never be expressed as an empty --files-from, which prints `no tests in suite
"all"` and exits 0 -- a green check that guarded nothing. With the current
registry that state is unreachable, because the six '*' entries always match;
the branch is kept as defensive handling for a future registry and says so.

timeout-minutes: 15 bounds the required job against a hanging fork-supplied test;
it had none. npm ci was added because the job never installed dependencies -- the
previous single-file step got away without it, the registry does not.

docs/contributing/docs-guard-registration.md documents the rule, following its
sibling cross-platform-portability-rules.md, and CONTRIBUTING.md's CI Test
Quality Checks table links to it. It is also load-bearing: without a docs/ file
in the diff this PR would not have triggered its own lane, shipping an
unexercised change to a required check.

One unrelated fix, included because this PR surfaced it and CLAUDE.md forbids
deferring a defect found while working. On this branch's first CI run,
`full test (windows-latest, 24, shard 3/3)` was CANCELLED at exactly 30 minutes;
tests were still passing 0.8s before the cancel, so it is a wall-clock timeout,
not a hang, and a cancelled job reddens `Required tests`.

The cause is not this PR's test file, which costs ~60ms. Shard composition is
unstable: adding ONE file to the unit suite reshuffled 115 of 268 files between
shards, and shard 3 drew a heavier mix. Underneath that is a real pre-existing
defect. tests/ci-test-job-timeout-budget.test.cjs requires every lane's budget to
be >= 1.5x its MEASURED cost -- "a lane that got slower must be re-budgeted, not
excused" -- and its test-full entry recorded 19m from a windows-22 shard. That is
stale. Measured on `next` with none of this PR's changes present: 26m18s (run
32614439702, windows-latest/24 shard 3/3), 23m36s and 23m17s on shard 2/3. So the
lane costs ~26m and the 30-minute cap carried 1.14x headroom, not 1.5x. The gate
had been out of compliance with its own rule; this PR was merely the file
addition that reshuffled shard 3 past the cliff.

Fixed as that file prescribes: measuredMinutes 19 -> 27 with fresh evidence, and
test-full timeout-minutes 30 -> 45. The rule's minimum for 27m is 41; 45 is
deliberately above it because the reshuffle means per-shard worst case moves run
to run, and a budget pinned to the exact minimum would be re-breached by the next
test file anyone adds. Only that one job's timeout changed; test.yml's scope,
matrix and steps are untouched, so #764's saving is unaffected.

Raising that cap let the Windows shard finish (28m45s, inside 45) and uncovered
a real failure the 30-minute cancel had been masking:
`new quick-task branch branches off origin/main (#2916)` died with
`outcome=timed_out exitCode=null`, SIGTERM, at the 15000ms bound.

tests/quick-branching.test.cjs:149 `runStep` runs a `#!/usr/bin/env bash` script
executing MULTIPLE git commands, but was bound to GIT_TIMEOUT_MS (15000) -- the
norm for a SINGLE git plumbing call. tests/helpers/timeouts.cjs already documents
this exact failure and exists to fix it: HOOK_FANOUT_TIMEOUT_MS was created after
PR #3285 recorded "outcome=timed_out exitCode=null at exactly the 15000ms probe
bound while every other lane passed the same commit", and calls that "a bound
sized for the wrong class, not a slow machine". Our failure is that case
verbatim, so both sites move to the class norm rather than to a bigger number.

The same class also failed on `next` itself 21 hours earlier -- run 32608945654,
windows-latest/24 shard 1/3, `plan touching only src/ in a submodule project
keeps worktree isolation ENABLED` -- where tests/worktree-safety.test.cjs:5845
`runGate` fans out to `git config --file .gitmodules` under a hardcoded 30000.
Fixed too, since it is a defect in the tree regardless of which branch surfaced
it.

A survey of the whole tests/ tree found the same class-mismatch at further
bash fan-out sites bound under 60000ms, and the maintainer approved sweeping
them rather than leaving them latent to surface the same way one at a time. 16
fan-out sites across 16 files now use the class norm.

The sweep is class-correctness, not raising numbers until things pass. Sites
were moved ONLY where the bash body demonstrably spawns something (git, node,
npm, a CLI); self-contained shell snippets were left where they are, and are
listed as deliberately unchanged: pure if/printf bodies (copilot-install), pure
array/case builtins (code-review-pipeline-regression:638), a documented
pure-shell gsd_run stub (host-integration), single-process hook calls
(workflow-guard:222/271/302), and a deliberately tight 5000ms fast-check hook
(gsd-write-guard.property). Nothing was lowered. process-seam.test.cjs:513
(literal 300) is untouched on purpose -- it tests timeout BEHAVIOR, so raising
it would destroy what it asserts.

Shared file-level constants were the trap here, and were handled per file rather
than by redefinition: GIT_TIMEOUT_MS has ~15 users in git-base-branch and only 1
is a fan-out; WORKTREE_TIMEOUT_MS has 16 users in worktree.test.cjs and 3 are;
PROBE_TIMEOUT_MS has several in three more files. In each the CALL SITE was
changed and the constant left alone, so no single-plumbing-call site silently
inherited a 60s bound. The one exception is hooks-opt-in.test.cjs, where
HOOK_TIMEOUT_MS has exactly one consumer -- spawnHook, the fan-out itself -- so
redefining it is identical in effect and reads better.

Only two of these sites have actually been observed failing. The rest cite that
shared class and those two run ids rather than inventing evidence of their own.

Co-authored-by: sim <sim@local>
2026-08-23 21:21:21 -04:00