Files
gstack/test/modernize-skill.test.ts
Garry Tanandcapy-ai[bot] 10b77de4f2 v1.91.284.0 feat(absorb-tstack): /modernize skill, per-skill Codex default prompts, gstack-post --attribute, the unattended grant and a CONTRIBUTING porting section (#3497)
* feat(absorb-tstack): /modernize skill, per-skill Codex default prompts, gstack-post --attribute, the unattended grant and a CONTRIBUTING porting section

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(modernize): read the PR text through gstack-issue-guard; re-measure the context ceiling

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

- gstack-post: with --attribute, report scan findings at the body file's own
  line numbers and a finding inside the callout as PART: attribute (the
  coordinates were shifted by the three callout lines)
- /modernize: route commit messages through gstack-issue-guard with the rest
  of the PR text, read isCrossRepository and maintainerCanModify, fetch a fork
  head from pull/N/head, refuse a PR whose head is the base branch, and list
  moved files NUL-safely
- docs/unattended.md: say that /ship never rebases or force-pushes and an
  unattended /modernize stops at its verdict
- CHANGELOG: seven skills carry a codex_default_prompt, not six

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: /modernize fetches a fork head with a forced refspec and stops when the PR moved

A rerun after the author force-pushed left refs/modernize/pr-N stale (the
non-forced refspec is rejected with exit 0 through the pipe), and the lease
pinned to the current remote head would then have let the rebased stale head
overwrite the author's newer commits. The fetch is forced, Steps 2 and 4 stop
when the head is not the one observed in Step 1, and the head-is-base guard
applies only to same-repository PRs (a fork's main into main is a valid head).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: /modernize stops on merge commits in the PR range and pushes a fork head to the fork

A plain git rebase drops merge commits and git range-diff compares only the
non-merge patches, so a change authored in a PR's merge commit disappeared
while the proof printed = for every patch and Step 5 force-pushed the loss.
Step 4 now counts merges in $MERGE_BASE..$OLD_HEAD and stops when there are
any, switches with -C so a rerun after a refused lease does not fail on the
existing branch, and asks for a cross-repository diff to be read as untrusted
input before the repository's test command runs it. Step 5 pushes a fork head
to the fork's URL with the same pinned lease instead of to origin, where the
lease could never match.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: cover the Codex default-prompt render, the openai.yaml ownership proof and the unattended grant

Adds the /ship coverage audit's tests: test/host-renders-openai-yaml-proof.test.ts
drives bin/gstack-host-renders.sh's weakened proof against a rendered yaml, a
hand-written one and a generic-prompt one; test/unattended-grant.test.ts pins the
docs/unattended.md grant section and the handoff pointer; test/gen-skill-docs.test.ts
adds modernize to the skills whose codex_default_prompt must reach openai.yaml.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: name the static lists a new skill still needs and the Codex metadata file

From the ship documentation audit: CONTRIBUTING's new-skill checklist said no
static list needs a row, which this PR disproved (lib/doctor-components.ts
SKILL_REQUIREMENTS and the catalog rows); the host comparison table and
docs/ADDING_A_HOST.md gain the agents/openai.yaml default_prompt source; a blank
line before the new docs/unattended.md grant heading.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

CHANGELOG [Unreleased] gate-integrity record: the catalog and context-budget
figures re-measured on top of main (1,314 -> 1,359; modernize 9466;
alwaysOnTotal 7458 -> 7880), as the merge commit's ratchets state.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: name the optional codex_default_prompt key in the new-skill checklist

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(parity): re-pin the ship union cap at 1.7405 after the main merge

GSTA-36 grant 2026-10-27. On the tree merged with f27a7605 the ship union is 326,691 / 187,706 = 1.7404 against #3487's 1.7400; the only ship bytes this PR adds are the handoff section's one-line pointer. Cap 1.7400 -> 1.7405, measurement appended to the comment.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): the [Unreleased] gate-integrity bullet names the ship cap re-pin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

CHANGELOG [Unreleased]: end the Codex default-prompts sentence at
"generator input only." so bin/gstack-prose-lint --unreleased --gate
(main's new quality-gate step) passes, and restate the gate-integrity
bullet's figures for the tree merged with 1f246e35: ship cap
1.7524 -> 1.7528 (329,005 / 187,706), modernize 9466 -> 3772 after the
preamble hoist, alwaysOnTotal 7769 -> 7880, catalog 1,359 unchanged.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

CHANGELOG [Unreleased] gate-integrity bullet names the current ship cap pin
(1.7525 -> 1.7529, measured 329,024 / 187,706 on the 392f50d4 merge) and keeps
the 1.7528 pin in the earlier-merges clause.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 22:23:20 +00:00

78 lines
4.5 KiB
TypeScript

/**
* /modernize: an aging pull request gets a disposition before any push.
*
* Pins the three verdicts and what each permits, the hard rules (no push and
* no close before the verdict; the skill never closes a PR; a clean rebase is
* not currency; age and sunk cost are not evidence), the range-diff proof and
* the lease pinned to the observed head, and the catalog rows a new skill owes.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const tmpl = read('modernize/SKILL.md.tmpl');
const skill = read('modernize/SKILL.md');
const flat = skill.replace(/\s+/g, ' ');
describe('/modernize skill', () => {
test('frontmatter: tier 2, sensitive, a Codex default prompt and the four triggers', () => {
expect(tmpl.startsWith('---\nname: modernize\npreamble-tier: 2\n')).toBe(true);
expect(tmpl).toMatch(/^sensitive: true$/m);
expect(tmpl).toMatch(/^codex_default_prompt: "Use \$gstack-modernize to /m);
for (const t of ['modernize this pr', 'rebase this old pr', 'revive this branch', 'is this pr still worth landing']) {
expect(tmpl, t).toContain(' - ' + t + '\n');
}
expect(skill.slice(0, skill.indexOf('\n---', 4))).not.toContain('codex_default_prompt');
});
test('the disposition table names the three verdicts and comes before any push', () => {
for (const v of ['**MODERNIZE**', '**REWORK**', '**CLOSE**']) expect(skill).toContain('| ' + v + ' |');
expect(flat).toContain('The disposition comes first. No rebase is pushed, and no PR is closed, until the verdict below is written and shown.');
expect(skill.indexOf('## Step 3: The disposition')).toBeLessThan(skill.indexOf('## Step 4 (MODERNIZE only)'));
expect(skill.indexOf('## Step 4 (MODERNIZE only)')).toBeLessThan(skill.indexOf('## Step 5 (MODERNIZE only)'));
expect(flat).toContain('STOP and ask (AskUserQuestion) before any push or any closing comment');
expect(flat).toContain('If the session is unattended, the verdict and its evidence are the handoff and nothing is pushed.');
});
test('never closes a PR on its own', () => {
expect(flat).toContain('You never close a PR.');
expect(flat).toContain('Without explicit permission in this session, the PR stays open.');
expect(flat).toContain('The PR stays open until someone with the authority says so.');
expect(skill).not.toMatch(/gh pr close/);
});
test('a clean rebase is not currency and age is not evidence', () => {
expect(flat).toContain('Textual cleanliness is not currency.');
expect(flat).toContain('Age is not evidence either way.');
expect(flat).toContain('Effort already spent on it is not a reason to keep it.');
expect(flat).toContain('conceptual: did the base change the thing the PR relies on, even in files the PR never touches?');
});
test('the rebase is proved with git range-diff and pushed with a lease pinned to the observed head', () => {
expect(skill).toContain('git range-diff "$MERGE_BASE..$OLD_HEAD" "origin/$BASE..HEAD"');
expect(flat).toContain('A hunk you cannot explain is a lost intent: stop and look again.');
expect(skill).toContain('OBSERVED=$(gh pr view "$PR" --json headRefOid -q .headRefOid)');
expect(skill).toContain('git push "--force-with-lease=$HEAD_BRANCH:$OBSERVED" origin "HEAD:$HEAD_BRANCH"');
expect(flat).toContain('`--force` is never used');
expect(skill).not.toMatch(/git push (-f|--force)(?!-with-lease)/);
expect(flat).toContain("Keep the author's commits as theirs; your fixes are separate commits on top.");
});
test('/careful already rates the lease push MEDIUM, so no new HIGH pattern was added', () => {
expect(read('careful/SKILL.md.tmpl')).toContain('`--force-with-lease` is never HIGH');
});
test('is listed where every skill is listed', () => {
expect(read('README.md')).toMatch(/^\| `\/modernize` \| /m);
expect(read('AGENTS.md')).toMatch(/^\| `\/modernize` \| /m);
expect(read('docs/skills.md')).toMatch(/^\| \[`\/modernize`\]\(#modernize\) \| /m);
expect(read('docs/skills.md')).toContain('## `/modernize`');
expect(read('SKILL.md.tmpl')).toContain('invoke `/modernize`');
expect(read('gstack/llms.txt')).toContain('[/modernize](modernize/SKILL.md)');
expect(read('docs/PROJECT_STRUCTURE.md')).toMatch(/^├── modernize\//m);
expect(read('lib/doctor-components.ts')).toMatch(/^ modernize: CLAUDE_ONLY,$/m);
});
});