diff --git a/CHANGELOG.md b/CHANGELOG.md index d9931e121..04cad5798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.360.0] - 2026-10-31 + +**A PR's changelog.d fragment no longer brings back every paid gate case.** Since fragments became the way every PR records its CHANGELOG entry (v1.91.342.0), the PR eval selector read each `changelog.d/.md` as an unknown dependency and restored the full gate, so it saved nothing on any PR (GST-400, found on #3668). + +### Fixed +- **`scripts/test-pr-profile.ts`** treats a `.md` file directly in the ship policy's `changelog_fragments.dir` as having no eval dependents. Other files in that folder, nested files, and a touchfile that names a fragment still select as before. +- **`test/helpers/test-selection.ts`** lists both paths of a renamed file, so moving a file into `changelog.d/` or `docs/` still selects the cases its old path fed. + +### Tests +- `test/test-pr-profile.test.ts` covers a fragment-only PR (no full fallback, no cases), other files in the folder, and a touchfile that names a fragment; `test/changed-files-union.test.ts` covers a rename. + ## [1.91.359.0] - 2026-10-31 **New `/verify` skill: run the repo's own checks and the tests `/ship`'s pre-gate would pick, fix what breaks, and repeat until green, then hand off to `/ship`.** `/verify` is the loop an agent runs while building. It never calls a change green when it cannot tell which tests cover it, and it asks once before running any repo command. It never reviews, bumps a version, pushes or opens a PR. GST-116, PR 1 of 2; moving `/health` onto the same detector is PR 2. diff --git a/VERSION b/VERSION index afcab5d37..7348e7a64 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.359.0 +1.91.360.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 7ac3efb2a..c24fa5687 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.359.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.360.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index 23a605a04..fb64cc5c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.359", + "version": "1.91.360", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/harness-version.json b/scripts/harness-version.json index aa118641d..420cf7e06 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -17,7 +17,7 @@ "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6}, "scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"}, "scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, - "scripts/test-pr-profile.ts": {"blob":"2cf7d6d8d24b6f433e2fdbe43062348ded965a5a","since":6,"nonBehavioral":"narrow screens leave hub files to the train gate: selection only, no case verdict, trial or series changes"}, + "scripts/test-pr-profile.ts": {"blob":"911e393d0c36c050c3a68db5a81ef21701bfd5ae","since":6,"nonBehavioral":"selection only: fragment exemption narrowed to .md files directly in the fragments dir; changed-file lists include a rename's old path; no case prompt, fixture, judge or verdict logic changes (GST-400)"}, "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6}, "test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6}, "test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6}, @@ -50,7 +50,7 @@ "test/helpers/session-runner.ts": {"blob":"cc65a66adafa9c053e3da3c6f17b5ac86c0eade7","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"}, "test/helpers/skill-fixture.ts": {"blob":"8a654dae6b0a396d839b02179119ae3e3bb13f69","since":6,"nonBehavioral":"extractSkillBody and extractSkillSections follow the shared-preamble hoist (pointer plus sections/preamble.md); every fixture they build is byte-identical to the pre-hoist output (compared for 8 body and 4 section extractions), so no case's input or verdict changes; the /ship review then reworded only comments and two error messages, so no fixture byte changes"}, "test/helpers/skill-startup.ts": {"blob":"a8d21e3817c3f35a1d06af325c92fde7d420e377","since":6,"nonBehavioral":"readSkillEffective also appends a variant's added ### subsections (office-hours' Open-ended questions); readSkillStartup, readSharedSections and readSkillEffective return bytes identical to the pinned helper for every tier 2+ render without added subsections (735 across all hosts) and for office-hours' SKILL.md before this PR, so office-hours' fixture input changes only because its prompt changed in this PR, not because the helper did"}, - "test/helpers/test-selection.ts": {"blob":"51655f89f66be7a5ec487e2abfe4744300ebd5f6","since":6}, + "test/helpers/test-selection.ts": {"blob":"e1b54ad5e3d3eadd8027de1b44e57554d6d99a41","since":6,"nonBehavioral":"selection only: fragment exemption narrowed to .md files directly in the fragments dir; changed-file lists include a rename's old path; no case prompt, fixture, judge or verdict logic changes (GST-400)"}, "test/helpers/touchfiles.ts": {"blob":"15f369056d41b9dc42dcd86f81f60c878074704c","since":6} } } diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index 2cf7d6d8d..911e393d0 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -69,6 +69,13 @@ export const PR_PROFILE_FILES: Record = { }; const ROOT = path.resolve(import.meta.dir, '..'); +/** gstack's own changelog_fragments.dir (.gstack/ship-policy.json): release notes the merge step folds into CHANGELOG.md, read by no paid case (GST-400). */ +const CHANGELOG_FRAGMENTS_DIR = ((): string | null => { + try { + const dir = JSON.parse(fs.readFileSync(path.join(ROOT, '.gstack', 'ship-policy.json'), 'utf8'))?.changelog_fragments?.dir; + return typeof dir === 'string' && dir.trim() ? dir.replace(/^(\.\/)+/, '').replace(/\/+$/, '') : null; + } catch { return null; } +})(); /** The one paid test file whose source registers a case (its touchfiles name exactly one), else null. */ export function caseOwnerFile(id: string, maps: Pick = PR_PROFILE_MAPS): string | null { @@ -330,6 +337,7 @@ export function knownNonBehaviorFile(file: string): boolean { || ['AGENTS.md', 'CLAUDE.md', 'agents-digest/gstack-AGENTS.md'].includes(file) || FREE_ONLY_PR_FILES.some(pattern => matchGlob(file, pattern)) || RELEASE_METADATA_FILES.some(pattern => matchGlob(file, pattern)) + || (CHANGELOG_FRAGMENTS_DIR !== null && path.posix.dirname(file) === CHANGELOG_FRAGMENTS_DIR && file.endsWith('.md')) || (PERIODIC_ONLY_PR_FILES as readonly string[]).includes(file) || (file.startsWith('test/fixtures/') && Object.keys(FREE_FIXTURES).some(pattern => matchGlob(file, pattern))) // Free test files anywhere (test/, browse/test/, design/test/, make-pdf/test/): no paid case runs or reads a free test. diff --git a/test/changed-files-union.test.ts b/test/changed-files-union.test.ts index a65a076a5..e9b181bae 100644 --- a/test/changed-files-union.test.ts +++ b/test/changed-files-union.test.ts @@ -105,6 +105,19 @@ describe('getChangedFiles union', () => { } }); + // Value: protects=a rename lists its old path too, so a file moved into an exempt folder (changelog.d/, docs/) still selects the cases its old path fed; fails_when=getChangedFiles reports only the rename's new path (GST-400 adversarial finding); + // why_new=no test covered renames; seam=git diff rename detection; no_claim=copies + test('a committed rename lists both the old and the new path', () => { + write('lib/foo.ts', 'export const foo = 1;\n'.repeat(20)); + git(['add', 'lib/foo.ts']); + git(['commit', '-q', '-m', 'add foo']); + git(['tag', '-f', 'base']); + fs.mkdirSync(path.join(repo, 'changelog.d')); + git(['mv', 'lib/foo.ts', 'changelog.d/foo.md']); + git(['commit', '-q', '-m', 'move foo']); + expect(getChangedFiles('base', repo).sort()).toEqual(['changelog.d/foo.md', 'lib/foo.ts']); + }); + test('missing base ref → throws naming EVALS_ALL and the failing command', () => { expect(() => getChangedFiles('no-such-ref', repo)).toThrow(/EVALS_ALL=1/); expect(() => getChangedFiles('no-such-ref', repo)).toThrow(/diff --name-only no-such-ref\.\.\.HEAD/); diff --git a/test/helpers/test-selection.ts b/test/helpers/test-selection.ts index 51655f89f..e1b54ad5e 100644 --- a/test/helpers/test-selection.ts +++ b/test/helpers/test-selection.ts @@ -123,9 +123,9 @@ export function getChangedFiles( // and the dependent test is silently DESELECTED — under-selection, the // exact direction selection must fail away from. const noQuote = ['-c', 'core.quotePath=false']; - const committed = runGitOrThrow([...noQuote, 'diff', '--name-only', `${baseBranch}...HEAD`], cwd, spawnImpl) + const committed = runGitOrThrow([...noQuote, 'diff', '--name-only', `${baseBranch}...HEAD`, '--no-renames'], cwd, spawnImpl) .trim().split('\n').filter(Boolean); - const uncommitted = runGitOrThrow([...noQuote, 'diff', '--name-only', 'HEAD'], cwd, spawnImpl) + const uncommitted = runGitOrThrow([...noQuote, 'diff', '--name-only', 'HEAD', '--no-renames'], cwd, spawnImpl) .trim().split('\n').filter(Boolean); const untracked = runGitOrThrow([...noQuote, 'status', '--porcelain', '--untracked-files=all'], cwd, spawnImpl) .split('\n') diff --git a/test/paid-free-boundary.test.ts b/test/paid-free-boundary.test.ts index f66ffec4a..284bb981f 100644 --- a/test/paid-free-boundary.test.ts +++ b/test/paid-free-boundary.test.ts @@ -27,7 +27,7 @@ function runnerDependencies(root: string, entries: string[]): string[] { const source = fs.readFileSync(file, 'utf8').replace(/^#![^\n]*(?:\n|$)/, '\n'); let audited = source; if (relative === 'test/helpers/test-selection.ts') { - if (createHash('sha256').update(source).digest('hex') !== '052ad5a52472bcb41db04c9f21fe6a819e9547768468f7e5d390e0014b567677') { + if (createHash('sha256').update(source).digest('hex') !== 'd618192ed8d354df55ebe8df0c52d3437ef51b4517ef04513efced4ae097c6a6') { throw new Error('Re-audit the historical touchfile map loader before excluding its computed import'); } audited = source.replace('`const m = await import(${JSON.stringify(dataPath)});`,', "'',"); diff --git a/test/test-pr-profile.test.ts b/test/test-pr-profile.test.ts index d7c1219c3..1b5b2620a 100644 --- a/test/test-pr-profile.test.ts +++ b/test/test-pr-profile.test.ts @@ -6,6 +6,7 @@ import { PR_PROFILE_CASE_IDS, PR_PROFILE_FILES, PR_PROFILE_MAPS, caseNameAddressable, formatPrCoverageSummary, packageChangeOnlyVersion, selectPrProfile, validatePrProfileInventory, type PrProfileMaps, } from '../scripts/test-pr-profile'; +import { selectTests } from './helpers/touchfiles'; const maps: PrProfileMaps = { e2eTouchfiles: { @@ -61,6 +62,22 @@ describe('fast PR coverage policy', () => { expect(prompt.needsFullValidation || prompt.deferredPromptFiles.length > 0 || prompt.e2e.length > 0).toBe(true); }); + // Value: protects=a changelog.d fragment (every PR carries one since v1.91.342.0) has no eval dependents, so it never restores the full gate, while a touchfile that names a fragment still selects its case; fails_when=a fragment-only PR selects every gate shard as a "real unknown dependency" (GST-400, #3668); + // why_new=knownNonBehaviorFile predates changelog_fragments; seam=.gstack/ship-policy.json changelog_fragments.dir; no_claim=the shadow exercised-path block + test('a changelog.d fragment has no eval dependents; a touchfile naming one still selects its case (GST-400)', () => { + const fragment = select({ selectedE2E: [], selectedJudges: [], changedFiles: ['changelog.d/gst-400.md', 'changelog.d/README.md'], derived: null }); + expect([fragment.mode, fragment.unknownFiles, fragment.e2e, fragment.needsFullValidation]).toEqual(['pr', [], [], false]); + const outside = select({ selectedE2E: [], selectedJudges: [], changedFiles: ['changelog.d/x.ts', 'changelog.d/nested/x.md'], derived: null }); + expect([...outside.unknownFiles].sort()).toEqual(['changelog.d/nested/x.md', 'changelog.d/x.ts']); + const real = selectPrProfile({ selectedE2E: [], selectedJudges: [], changedFiles: ['changelog.d/gst-400.md'] }); + expect([real.mode, real.unknownFiles]).toEqual(['pr', []]); + const table = { ...maps.e2eTouchfiles, 'ceo-smoke': ['plan-ceo-review/**', 'changelog.d/ceo.md'] }; + const picked = selectTests(['changelog.d/ceo.md'], table, []).selected; + expect(picked).toEqual(['ceo-smoke']); + const named = select({ maps: { ...maps, e2eTouchfiles: table }, selectedE2E: picked, selectedJudges: [], changedFiles: ['changelog.d/ceo.md'], derived: null }); + expect([named.e2e, named.needsFullValidation]).toEqual([['ceo-smoke'], false]); + }); + test('does not add unrelated short probes or drop selected quality judges', () => { const result = select({ selectedJudges: ['browse quality', 'plan-ceo-review/SKILL.md modes'] }); expect(result.e2e).not.toContain('browse-smoke');