mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
Merge remote-tracking branch 'origin/main' into gsta227-tabs-touch
This commit is contained in:
commit
6f72cc8041
74 files changed
+1867
-273
No files matched your search
@@ -13,7 +13,7 @@
|
||||
"stamp_sensitive_gates": ["tests"],
|
||||
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
|
||||
"maintainers": ["garrytan"],
|
||||
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
|
||||
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
|
||||
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
|
||||
"min_gstack_version": "1.91.340.0"
|
||||
}
|
||||
@@ -1,5 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.10.0] - 2026-11-01
|
||||
|
||||
**/ship's scoped second pass runs again, and its Red Team reviews the fix range while Step 11's voices still read the whole branch.** After a blocking fix on a small range, `gstack-review-plan` plans a scoped pass: one fix-check, the Red Team and the native adversarial pass on the range. `gstack-review-dispatch pass` refused that plan with `BRIEF_RENDER_FAILED: a pass dispatches no fix-check reader`, because a fix-check is not a pass reader. Every scoped pass failed this way, and GEVA-82 rendered its briefs by hand to get through (GST-619). Even past that refusal, the pass would have staged its Red Team on the merge base, because the Capy pass program never passes `--diff-base`.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-review-dispatch pass` keeps the fix-check out of the pass manifest and prints it as `FIX_CHECK: planned effort=full (Step 3.5 runs it on its own; not in this manifest)`.** A resumed `pass` command prints the line again. /ship's Step 9 text already runs that fix-check inside the scoped pass with `git diff $RANGE_BASE` as its hunks, so the pinned reviewer prose is unchanged. No reader is added or removed, so the pass runs the readers the plan already lists.
|
||||
- **A scoped pass's Red Team reviews the plan's fix range.** `pass` resolves the range base from the plan's `fix_range` and gives it to the Red Team alone, so it reviews `RANGE_BASE..HEAD` as docs/review-policy.md describes. Until Step 11 has run, the native adversarial pass and the outside voice in a scoped pass are Step 11's own voices, so they keep the merge base and read the whole branch. Once the plan flags carry `--adversarial-ran`, the whole scoped pass reads the range. An explicit `--diff-base` still wins, and a scoped plan without a readable range base is refused.
|
||||
|
||||
### Tests
|
||||
- `test/review-pass.test.ts`: GEVA-2's invocation (`--extras`, `--pass1-depth standard --fixed-blocking --range-base <short sha>`) plans `NEXT: scoped`. `pass` exits 0, prints the `FIX_CHECK:` line (and again on resume), and stages the Red Team, the adversarial pass, the outside voice and the audit reader. The Red Team's brief names the full range-base sha, and the outside voice's diff carries the pass-1 lines. With `--adversarial-ran` added, the manifest's diff base is the range base. Both fail on main with `BRIEF_RENDER_FAILED`, and on the previous head of this PR because every reader there read the range.
|
||||
|
||||
## [1.92.9.0] - 2026-11-01
|
||||
|
||||
**/freeze can no longer be walked around with paths through folders that don't exist yet (#3623).** /freeze checks every Edit, Write and NotebookEdit path against its folder. The check only resolved a parent folder that already existed and compared the raw text otherwise, so `<boundary>/../outside/new/x`, `<boundary>/link-to-outside/new/x`, `<boundary>/..`, `~/.zshrc` with the working folder inside the boundary, and `C:\proj\src\..\..\other\new\x` were all allowed. Reported by Quinn O'Donnell (#3623, #3624).
|
||||
|
||||
### Fixed
|
||||
- **The boundary check resolves the whole path.** `freeze/bin/check-freeze.sh` walks the path one folder at a time, follows symlinks at any depth even when the rest of the path does not exist yet, expands `~`, and resolves relative paths against the real working folder. An edit counts as inside only when both the operating system's reading and a path normalizer's reading of `..` land inside.
|
||||
- **Paths the hook can't place are blocked with a reason:** symlink loops, chains over 40 links, link targets that add up past 8192 characters, paths over 4096 characters, line breaks, Windows drive-relative (`C:x`) and root-relative (`\dev\x`) paths, names Windows trims (`.. `, `...`) and odd UNC roots. Windows rules keep `..` at the drive or share root.
|
||||
- **Legit edits that were blocked now pass:** `~/proj/src/x` from outside the folder, and a new subfolder when the boundary was saved through a symlinked parent (macOS `/tmp`).
|
||||
- **`careful/bin/hook-extract.sh`** keeps trailing newlines on path fields and matches Node's handling of lone surrogates there. /careful's command values are unchanged.
|
||||
|
||||
## [1.92.8.0] - 2026-11-01
|
||||
|
||||
**The secrets check no longer flags Python and Dart type names and variable reads as credentials (#3630).** A Python parameter like `session: HttpSession,` failed `gstack-pregate` as MEDIUM env.kv. Secrets findings can't be downgraded, so /ship wrote no receipt, and the only workaround was a file-wide allowlist entry. Dart's `'password': request.password,` and a React key like `key={item.id ?? index}` failed the same way. Reported by @dgrant in #3630. Approaches came from #3605 (@adam-balazs) and #3418/#3419 (@hbarney).
|
||||
|
||||
### Fixed
|
||||
- **`lib/redact-patterns.ts`:** env.kv's source-syntax exemption (#3048) now covers `.dart`, `.py` and `.pyi` files as well as TypeScript. These languages have no unquoted string literal, so a bare name after `:` is a name. A JSX brace may join names with `??`, `||` or `&&` as well as `+`. Each case still reports when it is not code:
|
||||
- a quoted value;
|
||||
- a value that is not an identifier chain;
|
||||
- an annotation with a literal default (`api_token: AuthToken = "<secret>"`, now caught in TypeScript too);
|
||||
- a `password:` line inside a Dart or Python `'''`/`"""` string, where escaped quotes don't close the string and a delimiter inside a comment or a one-line string opens nothing;
|
||||
- a `password: <value>` pair inside a one-line Python or Dart string;
|
||||
- a literal fallback after the name (`"password": settings.db_pass or "<secret>"`);
|
||||
- the same lines in `.env` or YAML files.
|
||||
|
||||
Python's `=` forms (`password=request.password`) still report.
|
||||
- **`lib/pregate/checks/secrets.ts`:** a changed Python or Dart file is scanned whole, and only findings on its added lines count. A line added inside an existing triple-quoted string is still read as a literal. Its row gives the file's line number. A file over 4 MiB, or one that can't be read, has its added lines scanned without the exemption.
|
||||
- **`bin/gstack-redact-prepush`:** the pre-push hook sees only added lines, so it scans Python and Dart lines without the exemption; a `password:` line added inside an existing triple-quoted string still warns.
|
||||
- **`lib/pregate/allowlist.ts`:** a pattern entry that names a finding by id and path but doesn't match prints a row saying so. Patterns are matched from where the flagged value starts, so the issue's `session: HttpSession` entry never matched. Before this change, that miss was silent.
|
||||
- **`docs/pregate.md`:** explains where a pattern is matched and how Python and Dart source is read.
|
||||
|
||||
## [1.92.7.0] - 2026-11-01
|
||||
|
||||
- `gstack-review-dispatch pass` no longer dies on a large diff. `gstack-review-plan` exited before its stdout pipe drained, so a plan JSON past about 128 KB (1.39 MB on a 4,000-file results PR) reached `pass` cut short and failed to parse. Both bins now set the exit code and let their output drain, and a 4,000-file test keeps it that way (GST-507).
|
||||
|
||||
## [1.92.6.0] - 2026-11-01
|
||||
|
||||
**Choosing "core" now means core everywhere gstack routes, and the four core skills carry their plain-English trigger phrases where hosts read them.** Under the default `proactive: core`, the CLAUDE.md routing section gstack offered still listed all 13 routing rules, and 16 non-core skills still told Codex and the other hosts to "proactively suggest" them (#3446). Separately, phrases like "test the site" or "ship it" lived only in each skill's `triggers:` metadata, which no host reads, so they never reached the list the agent picks skills from (#2118). The always-loaded Claude catalog does not grow. It measures 5,682 bytes before and 5,678 after (1,421 → 1,420 token-equivalents against the unchanged 1,423 ceiling).
|
||||
|
||||
### Changed
|
||||
- **The CLAUDE.md routing offer follows the setting.** Under `core`, `bin/gstack-skill-start` offers the four core rules (/office-hours, /qa, /review, /ship) and one line saying the other skills are not suggested: they run when you type them or when a running skill hands off to them (so /ship still dispatches /document-release). Under `all` it offers the same 13 rules as before. Sections already in a project's CLAUDE.md are left alone; docs/proactive.md says how to trim one.
|
||||
- **Only the core skills invite proactive suggestions.** The "Proactively suggest/invoke ..." sentence is gone from the descriptions of /autoplan, /design-consultation, /design-html, /design-review, /design-shotgun, /devex-review, /document-release, /investigate, /learn, /plan-ceo-review, /plan-design-review, /plan-devex-review, /plan-eng-review, /plan-tune, /qa-only and /retro. Their "Use when asked to ..." sentences stay. On Codex and the other hosts that keep full descriptions, `proactive: all` no longer gets that hint from those 16 skills.
|
||||
- **The core skills' descriptions carry their trigger phrases.** /qa's catalog entry is now "Test the site, API or CLI and fix the bugs it finds.", /review's "Review this PR's diff.", and /ship's "Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG, push and create a PR." Every `triggers:` phrase of the four core skills now appears in its full description.
|
||||
|
||||
### Tests
|
||||
- `test/proactive-skill-start.test.ts`: runs `gstack-skill-start` under `core`, `all` and the legacy `true`; core offers exactly the four core rules plus the scoping line, and all offers the shipped 13-rule section.
|
||||
- `test/proactive-core-set.test.ts`: each core skill's full description carries every `triggers:` phrase, its catalog entry carries at least one, and no other skill's catalog entry carries that phrase.
|
||||
- `test/skill-validation.test.ts`: no non-core template description says "Proactively suggest/invoke"; the proactive-phrase check now covers the four core skills.
|
||||
- All five new cases fail on main at 2d1b55d (office-hours already passed there).
|
||||
- Size ratchets move down only: 46 generated-SKILL.md pins in `test/fixtures/skill-md-size-allowlist.json` drop by the removed sentences, and /ship's size ledger records -21 bytes. The four ship goldens are regenerated for the new description.
|
||||
|
||||
## [1.92.5.0] - 2026-10-31
|
||||
|
||||
**Updates no longer stall in silence when the project around your gstack install has committed gstack's files (#1022).** If gstack lives inside another git project (for example `<project>/.claude/skills/gstack`) and that project committed gstack's files as ordinary files, a stash, reset or branch switch in the project put the old files back. VERSION stayed frozen at an old release while updates reported success. Once this release is on disk, every update path stops before it changes anything and prints the exact fix. An install the project has already reverted is still running the project's old copies of these scripts, so it needs the one-time repair in `docs/troubleshooting.md#auto-update-tracked-by-project` first.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-ff-update` checks before it moves.** It refuses with exit 6 and `tracked-by-project:` when the enclosing project tracks the install's files (a git submodule is fine), and with `version-reverted:` when VERSION's first line disagrees with HEAD's. The message spells out the repair. Untrack the install (`git rm --cached`, literal paths only), ignore it, commit on every branch, then `git stash push` in the install, which keeps every edit. `--preflight <install>` prints the same check on demand. An unmerged file still goes to the existing rescue, and a full update checks VERSION inside the install lock.
|
||||
- **`bin/gstack-upgrade`** runs that check before any fetch and refuses with `UPGRADE_REFUSED` (exit 3).
|
||||
- **The session auto-updater** holds the update on exit 6 and shows a banner that points to `--preflight` and the new troubleshooting section, `docs/troubleshooting.md#auto-update-tracked-by-project`.
|
||||
|
||||
## [1.92.4.0] - 2026-10-31
|
||||
|
||||
**A merge train now carries up to five READY PRs instead of three.** That means fewer full gates for the same number of PRs. A short train still leaves one hour after its oldest READY.
|
||||
|
||||
### Changed
|
||||
- `.gstack/ship-policy.json`: `train.max_size` goes from 3 to 5 (Garry, 2026-10-31). `fill_hours` stays at 1. `test/gstack-ship-policy.test.ts` pins the new value. Four READY PRs wait for the hour, and the fifth fills the train. The example block in docs/ship-policy.md, which shows gstack's own policy, now reads 5.
|
||||
|
||||
## [1.92.3.0] - 2026-10-31
|
||||
|
||||
**`gstack-redact` now catches a credential on a diff line (GST-543).** The env.kv rule matched from the start of the line, so the leading `+` or `-` of a diff line stopped the match. `git diff | gstack-redact` returned no finding and exit 0 for `+API_TOKEN=<random value>`. It did the same for a removed line that was indented or used `export`, and for a merge diff's two-column markers (`++`, ` +`). A `diff` code block in a PR body had the same blind spot. Pregate's `secrets` check and the pre-push hook were not affected, because both strip the `+` before scanning.
|
||||
|
||||
### Fixed
|
||||
- **`lib/redact-patterns.ts`:** env.kv accepts a diff marker before the name: `+` or `-`, optionally after one more column of a space, `+` or `-`. So the line in the example above now reports MEDIUM env.kv and exits 2. A `-` followed by one space and text still reads as a Markdown bullet or YAML list item, as before. The name-shape check and the schema-field exemption accept the same marker, so `API_TOKEN=changeme`, `cacheKey: …` and `input_key: …` stay clean on a diff line. Scanning every tracked file in the repo gives the same env.kv results as before.
|
||||
- **`test/redact-diff-prefix.test.ts`:** every pattern in `PATTERNS` has a sample. The test requires each sample to report bare, behind each diff marker, and on an added or removed line inside a real hunk, indented or not. A new pattern without a sample fails the test. The check found that env.kv was the only rule with this blind spot. Every other rule starts at a word boundary or has no line anchor.
|
||||
|
||||
## [1.92.2.0] - 2026-10-31
|
||||
|
||||
- The model panel adds a `<model>#no-gstack` arm beside each model's skill arm (Garry's rule that every head-to-head eval also tests no gstack), with its rows keyed by arm identity and the candidate's folders named after a project. arm-benchmark runs three with/without pairs per task under a spend ledger and a pinned model. A paired judge scores both orders and must pass calibration before it decides; the per-diff judge is reported beside it. The safety-rule and router cases gain a descriptive `none` arm where the task exists without the skill text. `gstack-eval-plan results --check <prereg> <results.md>` refuses a results file unless one table holds a filled row for every arm, so each no-gstack row sits beside its variants (`EVAL_REPORT_NO_GSTACK_ROW`).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.2.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.10.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,
|
||||
|
||||
@@ -8,9 +8,7 @@ description: |
|
||||
taste decisions (close approaches, borderline scope, outside-review disagreements) at a final
|
||||
approval gate. One command, fully reviewed plan out.
|
||||
Use when asked to "auto review", "autoplan", "run all reviews", "review this plan
|
||||
automatically", or "make the decisions for me".
|
||||
Proactively suggest when the user has a plan file and wants to run the full review
|
||||
gauntlet without answering 15-30 intermediate questions. (gstack)
|
||||
automatically", or "make the decisions for me". (gstack)
|
||||
codex_default_prompt: "Use $gstack-autoplan to run the CEO, design, DX and eng reviews over this plan in one pass, decide the routine questions itself, and stop at a single approval gate."
|
||||
voice-triggers:
|
||||
- "auto plan"
|
||||
|
||||
+47
-3
@@ -36,7 +36,14 @@
|
||||
# exit 3 autostash-unrecoverable: (edits kept where they are)
|
||||
# exit 4 not-fast-forward:
|
||||
# exit 5 index-unreadable:
|
||||
# exit 6 tracked-by-project: / version-reverted: (#1022, the preflight)
|
||||
# exit 75 ff-busy: (another update holds this install's lock)
|
||||
#
|
||||
# gstack-ff-update --preflight <dir> runs only the exit-6 checks (exit 0 or 6);
|
||||
# bin/gstack-upgrade calls it before any fetch. A project that tracks the
|
||||
# install's files as plain blobs puts old ones back on every checkout of its
|
||||
# own, so VERSION freezes behind HEAD while updates seem to succeed; a
|
||||
# submodule gitlink is the supported shape and passes.
|
||||
# A rescue also prints the inspect, export and delete commands on stderr.
|
||||
|
||||
set +e
|
||||
@@ -49,15 +56,51 @@ ff_flatten() {
|
||||
printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}"
|
||||
}
|
||||
ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; }
|
||||
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" "${4:-300}")"; exit "$1"; }
|
||||
|
||||
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision>"
|
||||
DIR=$1 REV=$2
|
||||
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision> | --preflight <install dir>"
|
||||
PREFLIGHT=0
|
||||
if [ "$1" = --preflight ]; then PREFLIGHT=1 DIR=$2 REV=HEAD; else DIR=$1 REV=$2; fi
|
||||
cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR"
|
||||
DIR=$(pwd -P)
|
||||
g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
|
||||
[ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \
|
||||
|| refuse 2 not-a-checkout "$DIR is not the top of a git checkout"
|
||||
|
||||
# ── Preflight (#1022) ──
|
||||
# The repository enclosing the install's parent, probed with the caller's git
|
||||
# environment cleared; any git error there means not tracked.
|
||||
_PF=$(
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY
|
||||
cd -- "$DIR/.." 2>/dev/null || exit 0 # best-effort: no parent directory means no project tracks the install
|
||||
pg() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
|
||||
_top=$(pg rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
_rel="$(pg rev-parse --show-prefix 2>/dev/null)${DIR##*/}" || exit 0
|
||||
_n=0
|
||||
while IFS= read -r _l; do
|
||||
case "$_l" in (''|'160000 '*) ;; (*) _n=$((_n + 1)) ;; esac
|
||||
done <<EOF_PF
|
||||
$(pg -C "$_top" --literal-pathspecs ls-files -s -- "$_rel" 2>/dev/null)
|
||||
EOF_PF
|
||||
[ "$_n" -gt 0 ] && printf '%s\n%s\n%s\n' "$_top" "$_rel" "$_n"
|
||||
)
|
||||
if [ -n "$_PF" ]; then
|
||||
_top=$(printf '%s\n' "$_PF" | sed -n 1p) _rel=$(printf '%s\n' "$_PF" | sed -n 2p) _n=$(printf '%s\n' "$_PF" | sed -n 3p)
|
||||
_ign=$(printf '%s' "$_rel" | sed 's/[][*?\\]/\\&/g')
|
||||
refuse 6 tracked-by-project "$_top tracks $_n files of the gstack install at $_rel, so every checkout there puts old gstack files back (VERSION frozen, #1022); nothing was changed; fix: git -C $(ff_quote "$_top") --literal-pathspecs rm -r -q --cached -- $(ff_quote "$_rel"), add /$_ign/ to $(ff_quote "$_top/.gitignore") and commit both on every branch you check out (or vendor gstack as a git submodule), then git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit) and re-run" 4000
|
||||
fi
|
||||
# A VERSION whose first line differs from HEAD's. Any unmerged file is left to
|
||||
# the rescue below; the full update runs this inside the lock, where a VERSION
|
||||
# already at the target's (an interrupted move) is finished, not held.
|
||||
version_check() {
|
||||
g cat-file -e HEAD:VERSION 2>/dev/null && [ -z "$(g ls-files -u 2>/dev/null)" ] || return 0
|
||||
_have=$(head -1 VERSION 2>/dev/null | tr -d '\r') _want=$(g show HEAD:VERSION 2>/dev/null | head -1 | tr -d '\r')
|
||||
[ "$_have" = "$_want" ] && return 0
|
||||
[ -n "$1" ] && [ "$_have" = "$(g show "$1:VERSION" 2>/dev/null | head -1 | tr -d '\r')" ] && return 0
|
||||
refuse 6 version-reverted "VERSION says ${_have:-nothing} but the checkout is at $_want: files changed outside git (an enclosing project's checkout puts old ones back, #1022, or a hand edit); nothing was changed; fix: git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit), then re-run" 4000
|
||||
}
|
||||
[ "$PREFLIGHT" -eq 1 ] && { version_check; exit 0; }
|
||||
|
||||
TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR"
|
||||
COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
||||
|| COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \
|
||||
@@ -128,6 +171,7 @@ case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac
|
||||
HB_PID=$!
|
||||
|
||||
# ── Inside the lock ──
|
||||
version_check "$TARGET"
|
||||
HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit"
|
||||
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed"
|
||||
g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed"
|
||||
|
||||
@@ -686,13 +686,15 @@ function main() {
|
||||
// as public-strict (HIGH blocks regardless either way).
|
||||
// Sliced (see scanAddedLines) so a large-but-legitimate diff is actually
|
||||
// scanned rather than blocked unscanned on the engine's size cap.
|
||||
// Scanned per file so a pattern can use the file's type (#3048).
|
||||
// Scanned per file so a pattern can use the file's type (#3048). A Dart or
|
||||
// Python file's added lines go without their path: their source-syntax
|
||||
// exemption needs the whole file to see a triple-quoted string (#3630).
|
||||
// An allowlisted span is found by scanning with and without the entries,
|
||||
// so the report names exactly what they suppressed.
|
||||
const allowed: string[] = [];
|
||||
const findingKey = (f: Finding) => `${f.id}:${f.line}:${f.col}`;
|
||||
for (const { part, allowlist } of parts) {
|
||||
const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file ? { sourcePath: part.file } : {}) };
|
||||
const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file && !/\.(?:dart|pyi?)$/i.test(part.file) ? { sourcePath: part.file } : {}) };
|
||||
const findings = scanAddedLines(part.text, opts);
|
||||
const kept = allowlist.length
|
||||
? new Set(scanAddedLines(part.text, { ...opts, allowlist }).map(findingKey))
|
||||
|
||||
@@ -161,18 +161,22 @@ function pass(): number {
|
||||
if (text.status !== 0) { process.stdout.write(text.stdout ?? ''); process.stderr.write(text.stderr ?? ''); return EXIT.fail; }
|
||||
const j = plan(true);
|
||||
if (j.status !== 0) { process.stderr.write(j.stderr ?? ''); return EXIT.fail; }
|
||||
const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] } };
|
||||
const planLines = text.stdout.trimEnd().split('\n');
|
||||
const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null };
|
||||
const fixChecks = p.dispatches.filter(d => d.role === 'fix-check');
|
||||
const planLines = [...text.stdout.trimEnd().split('\n'), ...fixChecks.map(d => `FIX_CHECK: planned effort=${d.effort} (Step 3.5 runs it on its own; not in this manifest)`)];
|
||||
const step = p.next.step;
|
||||
if (step !== 'full' && step !== 'scoped') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; }
|
||||
const diffBase = flag('--diff-base') ?? git(['merge-base', base, 'HEAD'])?.trim();
|
||||
const rangeBase = step === 'scoped' ? git(['rev-parse', '--verify', '--quiet', `${p.fix_range?.base ?? ''}^{commit}`])?.trim() : undefined;
|
||||
if (step === 'scoped' && !rangeBase) throw new BriefError('a scoped plan names no readable fix range base');
|
||||
const mergeBase = git(['merge-base', base, 'HEAD'])?.trim();
|
||||
const diffBase = flag('--diff-base') ?? (planArgs.includes('--adversarial-ran') ? rangeBase : undefined) ?? mergeBase;
|
||||
if (!diffBase) throw new BriefError(`no merge base between ${base} and HEAD`);
|
||||
const root = git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd();
|
||||
const extrasFile = flag('--extras');
|
||||
const extras = extrasFile ? JSON.parse(readFile(extrasFile, 'extras')!) as Extra[] : [];
|
||||
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT,
|
||||
dispatches: p.dispatches, next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
|
||||
targetedClasses: p.targeted?.classes, mergeBase: git(['merge-base', base, 'HEAD'])?.trim() });
|
||||
dispatches: p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
|
||||
targetedClasses: p.targeted?.classes, mergeBase, rangeBase });
|
||||
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
|
||||
const t = sectionTexts(ROOT);
|
||||
renderStage(m, 'first', t, branchDiff);
|
||||
@@ -345,7 +349,6 @@ function main(): number {
|
||||
return EXIT.usage;
|
||||
}
|
||||
|
||||
try { process.exit(main()); } catch (e) {
|
||||
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exit(EXIT.fail); }
|
||||
throw e;
|
||||
try { process.exitCode = main(); } catch (e) {
|
||||
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exitCode = EXIT.fail; } else throw e;
|
||||
}
|
||||
@@ -167,4 +167,4 @@ function main(): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
process.exit(main());
|
||||
process.exitCode = main();
|
||||
@@ -246,6 +246,8 @@ case "$_P_PULL" in
|
||||
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;;
|
||||
unmerged-files*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;;
|
||||
tracked-by-project*|version-reverted*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. gstack retries at the next check. The full fix: bash $_Q/bin/gstack-ff-update --preflight $_Q. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-tracked-by-project" ;;
|
||||
autostash-unrecoverable*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;;
|
||||
*)
|
||||
@@ -500,7 +502,7 @@ CALLER_PID=$(gstack_harness_pid "$PPID")
|
||||
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5"
|
||||
rescue_note "$4" "${5#unmerged=}" ;;
|
||||
0:*) ;;
|
||||
3:*) HELD=$FF_OUT PULL_EXIT=0 ;;
|
||||
3:*|6:*) HELD=$FF_OUT PULL_EXIT=0 ;;
|
||||
75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;;
|
||||
*) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;;
|
||||
esac
|
||||
|
||||
+19
-1
@@ -609,8 +609,26 @@ fi
|
||||
# is a quoted heredoc (markdown backticks must stay literal), so the resolved
|
||||
# bin path is substituted via sed on the __BIN__ placeholder. Under off the
|
||||
# routing rules are the auto-invocation the user turned off, so no offer.
|
||||
# Under core the heredoc's rule lines are replaced by the core rules below, so
|
||||
# the offered section routes only the four core skills.
|
||||
_CORE_ROUTING_RULES=$(cat <<'EOR'
|
||||
- Product ideas/brainstorming → invoke /office-hours
|
||||
- QA/testing site behavior → invoke /qa
|
||||
- Code review/diff check → invoke /review
|
||||
- Ship/deploy/PR → invoke /ship
|
||||
|
||||
Do not suggest or auto-invoke other gstack skills: they run when the user types them, or when a running skill hands off to them (`gstack-config set proactive all` routes every skill).
|
||||
EOR
|
||||
)
|
||||
_routing_offer() {
|
||||
if [ "$_PROACTIVE" = "core" ]; then
|
||||
_GSTACK_CORE_RULES="$_CORE_ROUTING_RULES" awk '/^- .* → invoke \// { if (!done) { print ENVIRON["_GSTACK_CORE_RULES"]; done = 1 } next } { print }'
|
||||
else
|
||||
cat
|
||||
fi | sed "s|__BIN__|$_BIN|g"
|
||||
}
|
||||
if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ] && [ "$_PROACTIVE_OFF" = "no" ]; then
|
||||
sed "s|__BIN__|$_BIN|g" <<'EOI' | _emit_block routing-injection
|
||||
_routing_offer <<'EOI' | _emit_block routing-injection
|
||||
gstack works best when the project CLAUDE.md includes skill routing rules. If no CLAUDE.md exists in the project root, create it. Use AskUserQuestion:
|
||||
|
||||
> gstack works best when your project's CLAUDE.md includes skill routing rules.
|
||||
|
||||
+6
-2
@@ -21,7 +21,9 @@
|
||||
# is not a gstack checkout, HEAD is detached or not on the remote default
|
||||
# branch (a pin), any tracked byte is dirty, a local commit diverged, the
|
||||
# incoming release's Bun floor holds, or another updater holds the setup
|
||||
# lock; never stashes, never resets. An unreachable remote is
|
||||
# lock; never stashes, never resets. Before any fetch it also refuses when
|
||||
# bin/gstack-ff-update --preflight does: an enclosing project tracks the
|
||||
# install's files, or VERSION was put back behind HEAD (#1022). An unreachable remote is
|
||||
# UPGRADE_FETCH_FAILED (exit 3). While another live session holds a workflow
|
||||
# lease on this installation (bin/gstack-lease live) it refuses with
|
||||
# UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints
|
||||
@@ -50,7 +52,7 @@ case "${1:-}" in
|
||||
--check) CHECK=1 ;;
|
||||
--ff-only-no-reinstall) REINSTALL=0 ;;
|
||||
'') ;;
|
||||
-h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -71,6 +73,8 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD
|
||||
TOPLEVEL=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null)
|
||||
[ -f "$ROOT/VERSION" ] && [ -f "$ROOT/setup" ] && [ -n "$TOPLEVEL" ] && gstack_path_same "$TOPLEVEL" "$ROOT" \
|
||||
|| refuse "$ROOT is not a gstack checkout" "run gstack-upgrade from a git checkout of https://github.com/garrytan/gstack"
|
||||
PF=$(bash "$ROOT/bin/gstack-ff-update" --preflight "$ROOT" 2>/dev/null)
|
||||
[ $? -eq 6 ] && { echo "gstack-upgrade: $PF (UPGRADE_REFUSED)" >&2; exit 3; }
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null); HOST="${HOST#*://}"; HOST="${HOST#*@}"; HOST="${HOST%%[/:]*}"
|
||||
R="user ran gstack-upgrade"
|
||||
|
||||
@@ -39,8 +39,11 @@ sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null &&
|
||||
# the hook's decision channel):
|
||||
# GSTACK_HOOK_TOOL tool_name ("" when absent or non-string)
|
||||
# GSTACK_HOOK_FIELD the FIELD that supplied the value ("" when none did)
|
||||
# GSTACK_HOOK_VALUE that field's string value, trailing newlines dropped
|
||||
# (the same shape $(gstack_hook_extract_field ...) gave)
|
||||
# GSTACK_HOOK_VALUE that field's string value; trailing newlines are
|
||||
# dropped from a command only (the shape
|
||||
# $(gstack_hook_extract_field ...) gave). A path keeps
|
||||
# them: 'src\n' is a sibling of 'src', and dropping the
|
||||
# newline let freeze read it as the boundary (#3623).
|
||||
# Returns 1 when no parser is available or the payload is not parseable
|
||||
# JSON; the caller decides the polarity, exactly as for the field extractor.
|
||||
# Python reads and writes bytes: Windows text-mode stdout turns every "\n"
|
||||
@@ -57,7 +60,7 @@ gstack_hook_extract_tool() {
|
||||
_ghet_out=""
|
||||
_ghet_ok=1
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json
|
||||
_ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json,re
|
||||
d = json.loads(sys.stdin.buffer.read())
|
||||
t = d.get("tool_name", "") if isinstance(d, dict) else ""
|
||||
i = d.get("tool_input", {}) if isinstance(d, dict) else {}
|
||||
@@ -68,10 +71,10 @@ for k in sys.argv[1:]:
|
||||
if isinstance(c, str) and c:
|
||||
f, v = k, c
|
||||
break
|
||||
sys.stdout.buffer.write(((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + v.rstrip("\n") + ".").encode("utf-8", "surrogatepass"))' "$@" 2>/dev/null) && _ghet_ok=0
|
||||
sys.stdout.buffer.write(((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + (v.rstrip("\n") if f == "command" else re.sub("[\ud800-\udfff]", "\ufffd", v)) + ".").encode("utf-8", "surrogatepass"))' "$@" 2>/dev/null) && _ghet_ok=0
|
||||
fi
|
||||
if [ "$_ghet_ok" -ne 0 ] && command -v node >/dev/null 2>&1; then
|
||||
_ghet_out=$(printf '%s' "$_ghet_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){process.exit(3)}const o=j&&typeof j==="object"?j:{};const t=typeof o.tool_name==="string"?o.tool_name:"";const i=o.tool_input&&typeof o.tool_input==="object"?o.tool_input:{};let f="",v="";for(const k of process.argv.slice(1)){const c=i[k];if(typeof c==="string"&&c){f=k;v=c;break}}process.stdout.write(t.replace(/\n/g," ")+"\n"+f+"\n"+v.replace(/\n+$/,"")+".")})' "$@" 2>/dev/null) && _ghet_ok=0
|
||||
_ghet_out=$(printf '%s' "$_ghet_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){process.exit(3)}const o=j&&typeof j==="object"?j:{};const t=typeof o.tool_name==="string"?o.tool_name:"";const i=o.tool_input&&typeof o.tool_input==="object"?o.tool_input:{};let f="",v="";for(const k of process.argv.slice(1)){const c=i[k];if(typeof c==="string"&&c){f=k;v=c;break}}process.stdout.write(t.replace(/\n/g," ")+"\n"+f+"\n"+(f==="command"?v.replace(/\n+$/,""):v)+".")})' "$@" 2>/dev/null) && _ghet_ok=0
|
||||
fi
|
||||
[ "$_ghet_ok" -eq 0 ] || return 1
|
||||
_ghet_out="${_ghet_out%.}"
|
||||
@@ -108,7 +111,7 @@ GSTACK_HOOK_PATH=""
|
||||
gstack_hook_normalize_path() {
|
||||
GSTACK_HOOK_PATH="$1"
|
||||
case "$GSTACK_HOOK_PATH" in
|
||||
[A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\]*) ;;
|
||||
[A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\/]*) ;;
|
||||
*) [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] || return 0 ;;
|
||||
esac
|
||||
GSTACK_HOOK_PATH="${GSTACK_HOOK_PATH//\\//}"
|
||||
|
||||
@@ -7,9 +7,7 @@ description: |
|
||||
complete design system (aesthetic, typography, color, layout, spacing, motion), and
|
||||
generates font+color preview pages. Creates DESIGN.md as your project's design source
|
||||
of truth. For existing sites, use /plan-design-review to infer the system instead.
|
||||
Use when asked to "design system", "brand guidelines", or "create DESIGN.md".
|
||||
Proactively suggest when starting a new project's UI with no existing
|
||||
design system or DESIGN.md. (gstack)
|
||||
Use when asked to "design system", "brand guidelines", or "create DESIGN.md". (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
@@ -9,8 +9,7 @@ description: |
|
||||
description. Text actually reflows, heights are computed, layouts are dynamic.
|
||||
30KB overhead, zero deps. Smart API routing: picks the right Pretext patterns
|
||||
for each design type. Use when: "finalize this design", "turn this into HTML",
|
||||
"build me a page", "implement this design", or after any planning skill.
|
||||
Proactively suggest when user has approved a design or has a plan ready. (gstack)
|
||||
"build me a page", "implement this design", or after any planning skill. (gstack)
|
||||
voice-triggers:
|
||||
- "build the design"
|
||||
- "code the mockup"
|
||||
|
||||
@@ -7,9 +7,7 @@ description: |
|
||||
AI slop patterns, and slow interactions — then fixes them. Iteratively fixes issues
|
||||
in source code, committing each fix atomically and re-verifying with before/after
|
||||
screenshots. For plan-mode design review (before implementation), use /plan-design-review.
|
||||
Use when asked to "audit the design", "visual QA", "check if it looks good", or "design polish".
|
||||
Proactively suggest when the user mentions visual inconsistencies or
|
||||
wants to polish the look of a live site. (gstack)
|
||||
Use when asked to "audit the design", "visual QA", "check if it looks good", or "design polish". (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
@@ -6,9 +6,7 @@ description: |
|
||||
Design shotgun: generate multiple AI design variants, open a comparison board,
|
||||
collect structured feedback, and iterate. Standalone design exploration you can
|
||||
run anytime. Use when: "explore designs", "show me options", "design variants",
|
||||
"visual brainstorm", or "I don't like how this looks".
|
||||
Proactively suggest when the user describes a UI feature but hasn't seen
|
||||
what it could look like. (gstack)
|
||||
"visual brainstorm", or "I don't like how this looks". (gstack)
|
||||
triggers:
|
||||
- explore design variants
|
||||
- show me design options
|
||||
|
||||
@@ -9,7 +9,7 @@ description: |
|
||||
scorecard with evidence. Compares against /plan-devex-review scores if they
|
||||
exist (the boomerang: plan said 3 minutes, reality says 8). Use when asked to
|
||||
"test the DX", "DX audit", "developer experience test", or "try the
|
||||
onboarding". Proactively suggest after shipping a developer-facing feature. (gstack)
|
||||
onboarding". (gstack)
|
||||
voice-triggers:
|
||||
- "dx audit"
|
||||
- "test the developer experience"
|
||||
|
||||
+19
-1
@@ -178,7 +178,11 @@ Two stages with separate clocks, both printed:
|
||||
simply not proven.
|
||||
- `secrets`: `gstack-redact` over the branch's added lines (merge-base to the
|
||||
working tree, untracked files included). HIGH, or MEDIUM in the `secret`
|
||||
category, fails; other MEDIUM findings warn. Cannot be downgraded.
|
||||
category, fails; other MEDIUM findings warn. Cannot be downgraded. A
|
||||
changed Python (`.py`, `.pyi`) or Dart file is scanned whole and only
|
||||
findings on its added lines count, so a line added inside an existing
|
||||
`"""` or `'''` string is still read as a literal; its rows give the file's
|
||||
line number (`app.py, line 10`).
|
||||
- `strays`: a new file at the repository root, a new top-level directory, or a
|
||||
scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere.
|
||||
- `literals`: for every changed exported constant or named timeout/retry
|
||||
@@ -802,6 +806,20 @@ those, each with its scope and its reason:
|
||||
the entry is inert for that finding and the row says so (`allowlist[0]
|
||||
env.kv not tested on env.kv (<file>): the scanner gave no position for its
|
||||
pattern; the finding is kept`) rather than silently keeping it.
|
||||
- The position is where the flagged value starts, not the name before it.
|
||||
For `session: HttpSession` in a YAML file the pattern is `HttpSession`;
|
||||
`session: HttpSession` never matches there. An entry that names a finding
|
||||
by id and path, and whose pattern matches the line but not at the value,
|
||||
prints `allowlist[0] env.kv pattern did not match env.kv (<file>, added
|
||||
line N, col C): …; the finding is kept`.
|
||||
- In Python and Dart source, a parameter or field annotation
|
||||
(`session: HttpSession,`) and a dict entry or Dart named argument that reads
|
||||
a name (`"password": request.password`) are code, not credentials, as they
|
||||
are in TypeScript (#3630). Python's keyword form (`password=request.password`)
|
||||
still reports. The same line inside a `"""` docstring is text and still
|
||||
reports; allowlist it by its value. The pre-push hook sees only added lines,
|
||||
so it scans Python and Dart lines without this exemption and may still warn
|
||||
(MEDIUM, non-blocking) on them.
|
||||
|
||||
The `secrets` row says `N allowlisted (<ids>)` and lists each allowlisted
|
||||
finding with the entry (`allowlisted env.kv (evals/receipts/t1.json, added line
|
||||
|
||||
+16
-14
@@ -34,21 +34,23 @@ needed, because every skill reads `PROACTIVE` from `gstack-skill-start` at the
|
||||
start of each run and the router (`/gstack`) applies it to its routing rules. A
|
||||
value gstack does not know (a hand-edited `proactive: maybe`) reads as `core`.
|
||||
|
||||
Two surfaces follow the setting only in part. The first is the `## Skill routing`
|
||||
section gstack offers to append to your project's CLAUDE.md. Under `off` the offer is
|
||||
not made, because those rules are the auto-invocation you turned off (a section
|
||||
already in CLAUDE.md stays; delete it yourself). Under `core` the offered
|
||||
section still lists every routing rule and tells the host to invoke any
|
||||
matching skill. If you accepted it before the `core` default, trim that section
|
||||
to the four core rules (or delete it and let gstack offer it again); making the
|
||||
offered section follow `core` is tracked in #3446.
|
||||
The `## Skill routing` section gstack offers to append to your project's
|
||||
CLAUDE.md follows the setting too. Under `core` it lists only the four core
|
||||
rules and says the other skills run when you type them or a running skill
|
||||
hands off to them; under `all` it lists
|
||||
every routing rule; under `off` it is not offered, because those rules are the
|
||||
auto-invocation you turned off. gstack offers the section once per project and
|
||||
never edits it afterwards. So a section you accepted earlier, or under another
|
||||
setting, stays as it is: trim it to match (the core four are /office-hours,
|
||||
/review, /qa and /ship), or delete it and let gstack offer it again.
|
||||
|
||||
The second is the full skill descriptions, which a `--catalog-mode=full` build
|
||||
and the non-Claude hosts keep. Non-core skills still say "Proactively suggest
|
||||
when ..." there, and that text does not change with the setting, so a host
|
||||
that picks skills from those descriptions can still reach them under `core`.
|
||||
The default Claude build uses one-line descriptions without that sentence.
|
||||
#3446 tracks that too.
|
||||
Skill descriptions cannot change with the setting, so they are written for the
|
||||
`core` default. Only the four core skills say "Proactively suggest ..." in the
|
||||
full descriptions that a `--catalog-mode=full` build and the non-Claude hosts
|
||||
keep. The other skills keep their "Use when asked to ..." sentences, which
|
||||
describe asks. On those hosts, `all` therefore loses the proactive hint the
|
||||
non-core descriptions used to carry; the router's routing rules still apply when
|
||||
`/gstack` runs.
|
||||
|
||||
gstack asks once, after the telemetry question, which of the three you want;
|
||||
if you stored `off` before that, it takes the answer as given and does not ask.
|
||||
|
||||
@@ -271,8 +271,9 @@ the specialists, audits and watchers at the host default too
|
||||
### Behavior changes
|
||||
|
||||
- Pass 2 after a blocking fix on a diff below full depth, with a fix range of at
|
||||
most 150 runtime lines, is a [scoped pass](#scoped-pass): a fix-check, the Red
|
||||
Team and the native adversarial pass on the range instead of the specialist army.
|
||||
most 150 runtime lines, is a [scoped pass](#scoped-pass): a fix-check and the Red
|
||||
Team on the range, plus the native adversarial pass (on the range once Step 11 has
|
||||
run, otherwise on the whole branch), instead of the specialist army.
|
||||
- A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the
|
||||
core checklist and the gate scan review it.
|
||||
- Every depth dispatches one batched specialist instead of one reader per
|
||||
|
||||
+3
-3
@@ -352,7 +352,7 @@ warning on stderr). A repository without gstack's eval workflow declares
|
||||
"train": {
|
||||
"gate_workflow": "evals.yml", "free_workflow": "free-tests.yml",
|
||||
"full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record",
|
||||
"max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid",
|
||||
"max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid",
|
||||
"trial_reservation_usd": 1.5
|
||||
},
|
||||
"min_gstack_version": "<the version that introduced train>"
|
||||
@@ -680,8 +680,8 @@ forked (the chain's merge onto the pin's own prefix is the verdict), refuses
|
||||
`QUEUE_TRAIN_CONFLICT` for a pin that does not merge, and otherwise prints
|
||||
`MERGE_CHECK: T1..T<k> merge clean onto <base>@<sha>`, so a
|
||||
`QUEUE_TRAIN_WAITING` result is also a clean dry-run merge of the pool (the
|
||||
crew template's sibling trial merge, done by the tool). gstack's own block fills at 3 PRs or one hour (it was 5 PRs or
|
||||
three hours until 2026-10-26, which left READY PRs waiting most of a day). The same pins on the same base return the live train
|
||||
crew template's sibling trial merge, done by the tool). gstack's own block fills at 5 PRs or one hour (it was 5 PRs or
|
||||
three hours until 2026-10-26, which left READY PRs waiting most of a day, then 3 PRs or one hour until 2026-10-31). The same pins on the same base return the live train
|
||||
instead of a second one; `train build --train <nonce>` resumes a train from
|
||||
its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
|
||||
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
|
||||
|
||||
@@ -1109,6 +1109,28 @@ conflict yourself instead, finish or abort your merge (`git -C '<dir>' merge
|
||||
--abort`, `git -C '<dir>' checkout -- <file>`); gstack retries at the next
|
||||
check.
|
||||
|
||||
<a id="auto-update-tracked-by-project"></a>
|
||||
### `gstack auto-update: update held (tracked-by-project: ...)` / `(version-reverted: ...)` / `gstack-upgrade: tracked-by-project: ... (UPGRADE_REFUSED)` / `gstack-upgrade: version-reverted: ... (UPGRADE_REFUSED)`
|
||||
|
||||
**Meaning.** Your gstack install sits inside another git project, and that
|
||||
project has committed gstack's files as ordinary files. Every time you switch
|
||||
branches or check out files in that project, git puts its old copies of
|
||||
gstack's files back, so VERSION and other files fall behind the install's own
|
||||
HEAD while updates seem to succeed (#1022). `version-reverted` is the same
|
||||
symptom seen from the install: its VERSION disagrees with HEAD. Neither
|
||||
`gstack-upgrade`, `/gstack-upgrade` nor the auto-updater moves the install
|
||||
until it is fixed. A project that holds gstack as a git submodule is fine.
|
||||
`version-reverted` with no enclosing project means VERSION was edited by
|
||||
hand; only the `stash push` step below applies.
|
||||
|
||||
**Fix.** Print the exact commands with
|
||||
`bash <install>/bin/gstack-ff-update --preflight <install>`. They do three
|
||||
things: untrack the install in the project (`git rm -r --cached`), add it to
|
||||
the project's `.gitignore`, and commit both on every branch you check out
|
||||
(or vendor gstack as a submodule instead). Then
|
||||
`git -C <install> stash push` puts the install's own files back and keeps
|
||||
any edit you made in the stash, and the next update proceeds.
|
||||
|
||||
<a id="auto-update-autostash-unrecoverable"></a>
|
||||
### `gstack auto-update: update held (autostash-unrecoverable: <reason>); your local edits are where they were and nothing was installed. Look first: git -C '<dir>' status --short. Fix now (...)` / `autostash-unrecoverable: <reason>`
|
||||
|
||||
@@ -1551,6 +1573,8 @@ directory is not a gstack checkout, HEAD is detached or on another branch (a
|
||||
pin), a tracked file is modified, a local commit is not on the remote branch,
|
||||
the incoming release's Bun floor is above the Bun on PATH, or another updater
|
||||
holds `<state root>/.setup-lock`. It never stashes, resets or discards.
|
||||
A `tracked-by-project` or `version-reverted` cause has its own section:
|
||||
[an enclosing project tracks the install](#auto-update-tracked-by-project).
|
||||
|
||||
**Fix.** Resolve the named cause with the printed command (commit or move the
|
||||
listed changes, check out the default branch, move local commits to another
|
||||
|
||||
+6
-1
@@ -11,4 +11,9 @@ on.
|
||||
The separate `triggers:` list in a skill's frontmatter is gstack metadata, not a
|
||||
matching surface. Claude Code and Codex choose a skill from its name and
|
||||
description, so a phrase that appears only under `triggers:` does not activate
|
||||
the skill (Codex output drops the field; the GBrain host keeps it).
|
||||
the skill (Codex output drops the field; the GBrain host keeps it). The four core
|
||||
skills (/office-hours, /review, /qa, /ship) carry their `triggers:` phrases in
|
||||
the description itself: every phrase in the full description, and at least one
|
||||
in the one-line catalog entry Claude Code reads ("test the site" for /qa, "ship
|
||||
it" for /ship). The other skills do not, because under the `core` default they
|
||||
wait for you to type them (docs/proactive.md).
|
||||
@@ -9,7 +9,7 @@ description: |
|
||||
detects architecture diagram drift, polishes CHANGELOG voice with a sell-test
|
||||
rubric, cleans up TODOS, and optionally bumps VERSION. Surfaces documentation
|
||||
debt in the PR body. Use when asked to "update the docs", "sync documentation",
|
||||
or "post-ship docs". Proactively suggest a documentation audit before merge. (gstack)
|
||||
or "post-ship docs". Runs before merge. (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
+5
-3
@@ -76,9 +76,11 @@ operation (nested under `hookSpecificOutput` — Claude Code ignores a top-level
|
||||
Polarity is fail-closed: a tool payload the hook cannot parse is DENIED, not
|
||||
allowed — a boundary that fails open is not a boundary. A payload that parses
|
||||
but has neither path field (a non-file tool) is allowed. A deny names the
|
||||
tool, the path field, the boundary and `/unfreeze`. Symlinks are resolved
|
||||
through their FINAL component, so an in-boundary symlink pointing outside the
|
||||
boundary is checked against its target.
|
||||
tool, the path field, the boundary and `/unfreeze`. The path is resolved before
|
||||
the check: `~`, `..` and relative paths, and symlinks at every depth, even
|
||||
through folders that do not exist yet. A path the hook cannot place (a symlink
|
||||
loop, a line break, a Windows drive-relative path, over 4096 characters) is
|
||||
denied.
|
||||
|
||||
The freeze boundary persists until explicitly removed via the state file. The hook
|
||||
script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces
|
||||
|
||||
@@ -80,9 +80,11 @@ operation (nested under `hookSpecificOutput` — Claude Code ignores a top-level
|
||||
Polarity is fail-closed: a tool payload the hook cannot parse is DENIED, not
|
||||
allowed — a boundary that fails open is not a boundary. A payload that parses
|
||||
but has neither path field (a non-file tool) is allowed. A deny names the
|
||||
tool, the path field, the boundary and `/unfreeze`. Symlinks are resolved
|
||||
through their FINAL component, so an in-boundary symlink pointing outside the
|
||||
boundary is checked against its target.
|
||||
tool, the path field, the boundary and `/unfreeze`. The path is resolved before
|
||||
the check: `~`, `..` and relative paths, and symlinks at every depth, even
|
||||
through folders that do not exist yet. A path the hook cannot place (a symlink
|
||||
loop, a line break, a Windows drive-relative path, over 4096 characters) is
|
||||
denied.
|
||||
|
||||
The freeze boundary persists until explicitly removed via the state file. The hook
|
||||
script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces
|
||||
|
||||
+214
-52
@@ -94,9 +94,32 @@ if [ -z "$FREEZE_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A deny for a path whose landing spot cannot be worked out: never fall back
|
||||
# to comparing the raw string (that fallback was the fail-open in #3623).
|
||||
_freeze_unresolved() {
|
||||
gstack_hook_log_fire freeze boundary_deny
|
||||
gstack_hook_decision deny "[freeze] Blocked: cannot tell where $1 lands ($2), so the freeze boundary cannot be checked (fail closed). Use a plain absolute path, or run /unfreeze to remove the boundary."
|
||||
_FREEZE_DECIDED=1
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Windows path rules apply on Windows bash and to drive-letter or UNC paths
|
||||
# on any platform (the normalizer rewrites those everywhere): there '..'
|
||||
# never climbs above X:\ or \\server\share, and names lose trailing dots
|
||||
# and spaces.
|
||||
_freeze_win_shaped() {
|
||||
[ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] && return 0
|
||||
case "$1" in
|
||||
[A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\/]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Both sides of the comparison go through one canonicalization: the state
|
||||
# file may hold C:\dev\proj\ or the /c/dev/proj/ that `pwd` prints in Git
|
||||
# Bash, and Claude Code on Windows reports file_path as C:\... (#2876).
|
||||
FREEZE_DIR_WIN=0
|
||||
if _freeze_win_shaped "$FREEZE_DIR"; then FREEZE_DIR_WIN=1; fi
|
||||
gstack_hook_normalize_path "$FREEZE_DIR"
|
||||
FREEZE_DIR="$GSTACK_HOOK_PATH"
|
||||
|
||||
@@ -135,76 +158,215 @@ if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# bash 3.2 expands $'...' only outside double quotes, so keep these in
|
||||
# variables for use inside parameter expansions.
|
||||
_FREEZE_NL=$'\n'
|
||||
_FREEZE_CR=$'\r'
|
||||
|
||||
# No real file name holds a line break, and command substitution would drop
|
||||
# a trailing one ('<b>\n' is a sibling of the boundary, not the boundary).
|
||||
case "$FILE_PATH" in
|
||||
*"$_FREEZE_NL"*|*"$_FREEZE_CR"*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a line break in the path" ;;
|
||||
esac
|
||||
# The walk below costs time quadratic in the path length, and a hook that
|
||||
# outlives the host's timeout lets the edit through. No real path is longer
|
||||
# than PATH_MAX (4096 on Linux), so bound the work before it starts.
|
||||
if [ "${#FILE_PATH}" -gt 4096 ]; then
|
||||
_freeze_unresolved "$TOOL_LABEL path of ${#FILE_PATH} characters" "longer than 4096 characters"
|
||||
fi
|
||||
|
||||
# A leading ~ is the home directory to the host's file tools, never a
|
||||
# directory named '~' under cwd: joined onto cwd, '~/.zshrc' passed whenever
|
||||
# cwd sat inside the boundary (#3623). Windows bash also writes '~\'.
|
||||
case "$FILE_PATH" in
|
||||
"~") FILE_PATH="$HOME" ;;
|
||||
"~/"*) FILE_PATH="$HOME/${FILE_PATH#\~/}" ;;
|
||||
"~\\"*) if [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ]; then FILE_PATH="$HOME/${FILE_PATH#\~?}"; fi ;;
|
||||
esac
|
||||
|
||||
# 'C:', 'C:x' and 'C:..\x' are relative to drive C's own working directory, and
|
||||
# '\c\dev' to the current drive's root; this hook sees neither, and the
|
||||
# normalizer would read them as cwd-relative or as drive C.
|
||||
if [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ]; then
|
||||
case "$FILE_PATH" in
|
||||
[A-Za-z]:|[A-Za-z]:[!\\/]*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a drive-relative path" ;;
|
||||
\\[!\\]*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a path relative to the current drive's root" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
FILE_PATH_WIN=0
|
||||
if _freeze_win_shaped "$FILE_PATH"; then FILE_PATH_WIN=1; fi
|
||||
# Canonicalize before the absolute test: a drive-letter path does not start
|
||||
# with '/', so it used to be joined onto cwd and every edit was denied.
|
||||
gstack_hook_normalize_path "$FILE_PATH"
|
||||
FILE_PATH="$GSTACK_HOOK_PATH"
|
||||
|
||||
# Resolve file_path to absolute if it isn't already
|
||||
# Resolve file_path to absolute if it isn't already, against the physical
|
||||
# cwd the host process has (a logical $PWD through a symlink differs, and
|
||||
# command substitution would drop a trailing newline from either).
|
||||
case "$FILE_PATH" in
|
||||
/*) ;; # already absolute
|
||||
*)
|
||||
FILE_PATH="$(pwd)/$FILE_PATH"
|
||||
_FREEZE_PWD=$(pwd -P && printf x) || _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "the working directory cannot be read"
|
||||
_FREEZE_PWD="${_FREEZE_PWD%"$_FREEZE_NL"x}"
|
||||
case "$_FREEZE_PWD" in
|
||||
*"$_FREEZE_NL"*|*"$_FREEZE_CR"*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a line break in the working directory" ;;
|
||||
esac
|
||||
FILE_PATH="$_FREEZE_PWD/$FILE_PATH"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Normalize: remove double slashes and trailing slash. A leading '//' is a UNC
|
||||
# host (//server/share) and is kept, or the check would compare a local path.
|
||||
_FP_LEAD=""
|
||||
case "$FILE_PATH" in
|
||||
//[!/]*) _FP_LEAD="/" ;;
|
||||
esac
|
||||
FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's|/\+|/|g;s|/$||')
|
||||
FILE_PATH="$_FP_LEAD$FILE_PATH"
|
||||
[ -n "$FILE_PATH" ] || FILE_PATH="/"
|
||||
|
||||
# Resolve symlinks and .. sequences (POSIX-portable, works on macOS).
|
||||
# The FULL path is resolved, including the FINAL component: the previous
|
||||
# version resolved only the parent directory, so an in-boundary symlink
|
||||
# pointing at an out-of-boundary target sailed through the check while the
|
||||
# actual write landed outside the boundary. A final component that is a
|
||||
# symlink is followed (bounded, cycle-safe) so the TARGET gets checked; a
|
||||
# final component that does not exist yet (new file) has nothing to follow
|
||||
# and parent resolution is the correct behavior.
|
||||
# Resolve an absolute path to where a write would land (bash 3.2 builtins
|
||||
# plus readlink). Components are walked left to right: empty and '.' drop,
|
||||
# '..' pops, and a symlink at ANY depth is replaced by its target, even when
|
||||
# the rest of the path does not exist yet. The old resolver followed only the
|
||||
# final component and ran 'cd && pwd -P' on the immediate parent, comparing
|
||||
# the raw string when that parent did not exist, so '<b>/../x/new/f',
|
||||
# '<b>/link-out/new/f' and '<b>/..' all passed (#3623). The walk also replaces
|
||||
# a per-line sed squeeze that let a newline split '..' off a name.
|
||||
# $2 lexical: apply '..' to the text and follow no symlink, the reading of a
|
||||
# path normalizer such as Node's path.resolve.
|
||||
# $3 1: Windows rules ('..' stops at /c or //server/share; link targets
|
||||
# use Windows syntax; a name ending in a dot or space, which Windows
|
||||
# trims, three leading slashes, and a UNC server or share of '', '.',
|
||||
# '..' or '?' cannot be placed). Without it a target like 'C:\..' is one
|
||||
# POSIX file name. On a POSIX host a Windows path names no local file,
|
||||
# so it is resolved as text and no local symlink is followed.
|
||||
# Returns 1 for a symlink loop, a chain over 40 links, an unreadable or
|
||||
# line-broken link, a Windows link target relative to a drive or its root,
|
||||
# link targets that add up past 8192 characters with the path (the walk is
|
||||
# quadratic, so its total input stays bounded), or an unplaceable Windows
|
||||
# name; the caller fails closed.
|
||||
_resolve_path() {
|
||||
local _p="$1" _dir _base _tgt _i=0
|
||||
while [ -L "$_p" ] && [ "$_i" -lt 40 ]; do
|
||||
_tgt=$(readlink "$_p" 2>/dev/null) || break
|
||||
case "$_tgt" in
|
||||
/*) _p="$_tgt" ;;
|
||||
*) _p="$(dirname "$_p")/$_tgt" ;;
|
||||
local _rem="$1" _win="${3:-0}" _lead="" _cur="" _n=0 _min=0 _reroot=1
|
||||
local _part _tgt _p _tail="" _hops=0 _probe=1 _budget="${#1}"
|
||||
if [ "$_win" = 1 ] && [ "$GSTACK_HOOK_IS_WINDOWS" != 1 ]; then _probe=0; fi
|
||||
while :; do
|
||||
if [ "$_reroot" = 1 ]; then
|
||||
_reroot=0; _lead=""; _cur=""; _n=0; _min=0
|
||||
case "$_rem" in
|
||||
///*)
|
||||
if [ "$_win" = 1 ]; then return 1; fi
|
||||
while [ "${_rem#/}" != "$_rem" ]; do _rem="${_rem#/}"; done
|
||||
;;
|
||||
//[!/]*)
|
||||
if [ "$_win" = 1 ]; then
|
||||
_lead="/"; _rem="${_rem#//}"; _min=2
|
||||
else
|
||||
_rem="${_rem#//}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
while [ "${_rem#/}" != "$_rem" ]; do _rem="${_rem#/}"; done
|
||||
if [ "$_win" = 1 ]; then
|
||||
case "$_rem" in [A-Za-z]|[A-Za-z]/*) _min=1 ;; esac
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
[ -n "$_rem" ] || break
|
||||
_part="${_rem%%/*}"
|
||||
case "$_rem" in */*) _rem="${_rem#*/}" ;; *) _rem="" ;; esac
|
||||
if [ "$_win" = 1 ] && [ -n "$_lead" ] && [ "$_n" -lt 2 ]; then
|
||||
case "$_part" in ''|.|..|'?') return 1 ;; esac
|
||||
fi
|
||||
case "$_part" in
|
||||
''|.) continue ;;
|
||||
..)
|
||||
if [ "$_n" -gt "$_min" ]; then _cur="${_cur%/*}"; _n=$((_n - 1)); fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
_i=$((_i + 1))
|
||||
if [ "$_win" = 1 ]; then
|
||||
case "$_part" in *[.\ ]) return 1 ;; esac
|
||||
fi
|
||||
if [ "${2:-}" = lexical ] || [ "$_probe" = 0 ] || [ ! -L "$_lead$_cur/$_part" ]; then
|
||||
_cur="$_cur/$_part"; _n=$((_n + 1))
|
||||
continue
|
||||
fi
|
||||
_hops=$((_hops + 1))
|
||||
[ "$_hops" -le 40 ] || return 1
|
||||
_tgt=$(readlink "$_lead$_cur/$_part" && printf x) || return 1
|
||||
_tgt="${_tgt%x}"; _tgt="${_tgt%"$_FREEZE_NL"}"
|
||||
case "$_tgt" in ''|*"$_FREEZE_NL"*|*"$_FREEZE_CR"*) return 1 ;; esac
|
||||
if [ "$_win" = 1 ]; then
|
||||
case "$_tgt" in [A-Za-z]:|[A-Za-z]:[!\\/]*|\\|\\[!\\]*) return 1 ;; esac
|
||||
gstack_hook_normalize_path "$_tgt"
|
||||
_tgt="$GSTACK_HOOK_PATH"
|
||||
fi
|
||||
case "$_tgt" in /*) _reroot=1 ;; esac
|
||||
_budget=$((_budget + ${#_tgt}))
|
||||
[ "$_budget" -le 8192 ] || return 1
|
||||
_rem="${_tgt%/}${_rem:+/$_rem}"
|
||||
done
|
||||
_dir="$(dirname "$_p")"
|
||||
_base="$(basename "$_p")"
|
||||
if [ "$_base" = / ]; then printf '/'; return; fi
|
||||
_dir="$(cd "$_dir" 2>/dev/null && pwd -P || printf '%s' "$_dir")"
|
||||
# pwd -P answers in the platform's own dialect (/cygdrive/c/... on Cygwin).
|
||||
gstack_hook_normalize_path "${_dir%/}/$_base"
|
||||
_p="$_lead$_cur"
|
||||
if [ "${2:-}" != lexical ] && [ "$_probe" = 1 ]; then
|
||||
# pwd -P on the deepest existing directory answers in the platform's own
|
||||
# dialect (/cygdrive/c/... on Cygwin) and sees links -L misses there.
|
||||
while [ -n "$_cur" ] && [ ! -d "$_lead$_cur" ]; do
|
||||
_tail="/${_cur##*/}$_tail"
|
||||
_cur="${_cur%/*}"
|
||||
done
|
||||
if [ -n "$_cur" ]; then
|
||||
if _p=$(cd "$_lead$_cur" 2>/dev/null && pwd -P && printf x); then
|
||||
_p="${_p%"$_FREEZE_NL"x}"
|
||||
case "$_p" in *"$_FREEZE_NL"*|*"$_FREEZE_CR"*) return 1 ;; esac
|
||||
else
|
||||
_p="$_lead$_cur"
|
||||
fi
|
||||
_p="${_p%/}$_tail"
|
||||
else
|
||||
_p="$_lead$_tail"
|
||||
fi
|
||||
# Git Bash spells one folder several ways through its mounts (/tmp is
|
||||
# %TEMP%, and HOME converts through them); cygpath -m gives the one drive
|
||||
# spelling Claude Code reports.
|
||||
if [ -n "$_p" ] && [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] && command -v cygpath >/dev/null 2>&1; then
|
||||
_p=$(cygpath -m "$_p" && printf x) || return 1
|
||||
_p="${_p%"$_FREEZE_NL"x}"
|
||||
case "$_p" in ''|*"$_FREEZE_NL"*|*"$_FREEZE_CR"*) return 1 ;; esac
|
||||
fi
|
||||
fi
|
||||
[ -n "$_p" ] || _p="/"
|
||||
gstack_hook_normalize_path "$_p"
|
||||
printf '%s' "$GSTACK_HOOK_PATH"
|
||||
}
|
||||
FILE_PATH=$(_resolve_path "$FILE_PATH")
|
||||
FREEZE_DIR=$(_resolve_path "$FREEZE_DIR")
|
||||
|
||||
_FREEZE_WHY="a symlink loop, a chain over 40 links, a path and link targets over 8192 characters in all, an unreadable link, a Windows link target relative to a drive or its root, or a Windows name that cannot be placed"
|
||||
FREEZE_DIR_RAW="$FREEZE_DIR"
|
||||
FREEZE_DIR=$(_resolve_path "$FREEZE_DIR_RAW" physical "$FREEZE_DIR_WIN") || _freeze_unresolved "the freeze boundary $FREEZE_DIR_RAW" "$_FREEZE_WHY"
|
||||
# Hosts disagree on '<link>/..': the OS means the target's parent, a lexical
|
||||
# normalizer means the link's parent. The edit is inside only when both
|
||||
# readings land inside the boundary.
|
||||
FILE_PATH_RAW="$FILE_PATH"
|
||||
FILE_PATH=$(_resolve_path "$FILE_PATH_RAW" physical "$FILE_PATH_WIN") || _freeze_unresolved "$TOOL_LABEL $FILE_PATH_RAW" "$_FREEZE_WHY"
|
||||
FILE_PATH_LEXICAL=$(_resolve_path "$FILE_PATH_RAW" lexical "$FILE_PATH_WIN") || _freeze_unresolved "$TOOL_LABEL $FILE_PATH_RAW" "$_FREEZE_WHY"
|
||||
FILE_PATH_LEXICAL=$(_resolve_path "$FILE_PATH_LEXICAL" physical "$FILE_PATH_WIN") || _freeze_unresolved "$TOOL_LABEL $FILE_PATH_RAW" "$_FREEZE_WHY"
|
||||
|
||||
# Check: does the file path start with the freeze directory? Windows paths
|
||||
# are case-insensitive, so compare that way there.
|
||||
[ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] && shopt -s nocasematch
|
||||
case "$FILE_PATH" in
|
||||
"${FREEZE_DIR%/}/"*|"${FREEZE_DIR}")
|
||||
# Inside freeze boundary — allow
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
;;
|
||||
*)
|
||||
# Outside freeze boundary — deny
|
||||
# Log hook fire event (shared helper respects GSTACK_HOME)
|
||||
gstack_hook_log_fire freeze boundary_deny
|
||||
_freeze_inside() {
|
||||
case "$1" in
|
||||
"${FREEZE_DIR%/}/"*|"${FREEZE_DIR}") return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
if _freeze_inside "$FILE_PATH" && _freeze_inside "$FILE_PATH_LEXICAL"; then
|
||||
# Inside freeze boundary — allow
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
else
|
||||
# Outside freeze boundary — deny
|
||||
# Log hook fire event (shared helper respects GSTACK_HOME)
|
||||
gstack_hook_log_fire freeze boundary_deny
|
||||
if _freeze_inside "$FILE_PATH"; then FILE_PATH="$FILE_PATH_LEXICAL"; fi
|
||||
_FREEZE_SHOWN="$FILE_PATH_RAW"
|
||||
if [ "$FILE_PATH" != "$FILE_PATH_RAW" ]; then _FREEZE_SHOWN="$FILE_PATH_RAW (lands at $FILE_PATH)"; fi
|
||||
|
||||
# The reason is JSON-encoded by the shared helper. Never interpolate paths
|
||||
# into hand-built JSON: a path containing a quote or newline produced
|
||||
# malformed JSON here, and the deny silently no-oped.
|
||||
gstack_hook_decision deny "[freeze] Blocked: $TOOL_LABEL $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed; run /unfreeze to remove the boundary."
|
||||
_FREEZE_DECIDED=1
|
||||
;;
|
||||
esac
|
||||
# The reason is JSON-encoded by the shared helper. Never interpolate paths
|
||||
# into hand-built JSON: a path containing a quote or newline produced
|
||||
# malformed JSON here, and the deny silently no-oped.
|
||||
gstack_hook_decision deny "[freeze] Blocked: $TOOL_LABEL $_FREEZE_SHOWN is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed; run /unfreeze to remove the boundary."
|
||||
_FREEZE_DECIDED=1
|
||||
fi
|
||||
+3
-3
@@ -56,15 +56,15 @@ Conventions:
|
||||
- [/plan-devex-review](plan-devex-review/SKILL.md): Interactive developer experience plan review.
|
||||
- [/plan-eng-review](plan-eng-review/SKILL.md): Eng manager-mode plan review.
|
||||
- [/plan-tune](plan-tune/SKILL.md): Self-tuning question sensitivity + developer psychographic for gstack (v1: observational).
|
||||
- [/qa](qa/SKILL.md): Fix browser/API/CLI/job/worker/webhook bugs.
|
||||
- [/qa](qa/SKILL.md): Test the site, API or CLI and fix the bugs it finds.
|
||||
- [/qa-only](qa-only/SKILL.md): Report browser/API/CLI/job/worker/webhook bugs.
|
||||
- [/retro](retro/SKILL.md): Weekly engineering retrospective.
|
||||
- [/review](review/SKILL.md): Pre-landing PR review.
|
||||
- [/review](review/SKILL.md): Review this PR's diff.
|
||||
- [/scrape](scrape/SKILL.md): Pull data from a web page through the Aside browser — your real, already signed-in sessions.
|
||||
- [/setup-browser-cookies](setup-browser-cookies/SKILL.md): Import cookies from your real Chromium browser into the headless browse session.
|
||||
- [/setup-deploy](setup-deploy/SKILL.md): Configure deployment settings for /land-and-deploy.
|
||||
- [/setup-gbrain](setup-gbrain/SKILL.md): Set up gbrain for this coding agent: install the CLI, initialize a local PGLite or Supabase brain, register MCP, capture per-remote trust policy.
|
||||
- [/ship](ship/SKILL.md): Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR.
|
||||
- [/ship](ship/SKILL.md): Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG, push and create a PR.
|
||||
- [/skillify](skillify/SKILL.md): Codify the most recent successful /scrape flow into a permanent browser-skill on disk.
|
||||
- [/spec](spec/SKILL.md): Turn vague intent into a precise, executable spec in five phases.
|
||||
- [/sync-gbrain](sync-gbrain/SKILL.md): Keep gbrain current with this repo's code and refresh agent search guidance in CLAUDE.md.
|
||||
|
||||
@@ -6,10 +6,7 @@ description: |
|
||||
Systematic debugging with root cause investigation. Four phases: investigate,
|
||||
analyze, hypothesize, implement. Iron Law: no fixes without root cause.
|
||||
Use when asked to "debug this", "fix this bug", "why is this broken",
|
||||
"investigate this error", or "root cause analysis".
|
||||
Proactively invoke this skill (do NOT debug directly) when the user reports
|
||||
errors, 500 errors, stack traces, unexpected behavior, "it was working
|
||||
yesterday", or is troubleshooting why something stopped working. (gstack)
|
||||
"investigate this error", or "root cause analysis". (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
@@ -6,8 +6,6 @@ description: |
|
||||
Manage project learnings. Review, search, prune, and export what gstack
|
||||
has learned across sessions. Use when asked to "what have we learned",
|
||||
"show learnings", "prune stale learnings", or "export learnings".
|
||||
Proactively suggest when the user asks about past patterns or wonders
|
||||
"didn't we fix this before?"
|
||||
triggers:
|
||||
- show learnings
|
||||
- what have we learned
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AllowlistFinding {
|
||||
id: string; category: string; severity: string; file: string; line?: number; col?: number;
|
||||
/** The added line the finding sits on (for `pattern`); absent when the scanner gave no position. */
|
||||
lineText?: string;
|
||||
/** `line` numbers the whole file (a Dart or Python file scanned whole, #3630), not the added lines. */
|
||||
fileLine?: boolean;
|
||||
}
|
||||
export interface Allowlisted<F extends AllowlistFinding = AllowlistFinding> { finding: F; index: number; entry: AllowlistEntry }
|
||||
|
||||
@@ -38,26 +40,34 @@ function patternHits(entry: AllowlistEntry, f: AllowlistFinding): boolean {
|
||||
return re.test(f.lineText!.slice(f.col! - 1));
|
||||
}
|
||||
|
||||
function matchesLine(pattern: string, line: string): boolean {
|
||||
try { return new RegExp(pattern).test(line); } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Split findings into the ones an entry covers (with the entry) and the ones
|
||||
* kept; env.value and engine.* are always kept. `untested` lists kept findings
|
||||
* that a pattern entry named by id and path but could not test because the
|
||||
* scanner gave no position, so the table says why the entry was inert.
|
||||
* scanner gave no position, so the table says why the entry was inert;
|
||||
* `missed` lists kept findings that a pattern entry named by id and path
|
||||
* whose pattern matches the finding's line but not at its position (#3630:
|
||||
* the entry was written from the name, not the value).
|
||||
*/
|
||||
export function applyAllowlist<F extends AllowlistFinding>(findings: readonly F[], entries: readonly AllowlistEntry[]): { kept: F[]; allowlisted: Allowlisted<F>[]; untested: Allowlisted<F>[] } {
|
||||
export function applyAllowlist<F extends AllowlistFinding>(findings: readonly F[], entries: readonly AllowlistEntry[]): { kept: F[]; allowlisted: Allowlisted<F>[]; untested: Allowlisted<F>[]; missed: Allowlisted<F>[] } {
|
||||
const kept: F[] = [];
|
||||
const allowlisted: Allowlisted<F>[] = [];
|
||||
const untested: Allowlisted<F>[] = [];
|
||||
const missed: Allowlisted<F>[] = [];
|
||||
for (const f of findings) {
|
||||
const scoped = (e: AllowlistEntry) => names(e, f) && (!e.paths || matchesAny(e.paths, f.file));
|
||||
const index = nonAllowlistable(f.id) ? -1 : entries.findIndex(e => scoped(e) && patternHits(e, f));
|
||||
if (index >= 0) { allowlisted.push({ finding: f, index, entry: entries[index]! }); continue; }
|
||||
kept.push(f);
|
||||
if (nonAllowlistable(f.id) || positioned(f)) continue;
|
||||
const named = entries.findIndex(e => e.pattern !== undefined && scoped(e));
|
||||
if (named >= 0) untested.push({ finding: f, index: named, entry: entries[named]! });
|
||||
if (nonAllowlistable(f.id)) continue;
|
||||
const named = entries.findIndex(e => e.pattern !== undefined && scoped(e) && (!positioned(f) || matchesLine(e.pattern, f.lineText!)));
|
||||
if (named >= 0) (positioned(f) ? missed : untested).push({ finding: f, index: named, entry: entries[named]! });
|
||||
}
|
||||
return { kept, allowlisted, untested };
|
||||
return { kept, allowlisted, untested, missed };
|
||||
}
|
||||
|
||||
/** sha256 of the entries, for the check's `inputs.allowlist`. */
|
||||
@@ -68,8 +78,13 @@ export function describeUntested(a: Allowlisted): string {
|
||||
return `allowlist[${a.index}] ${a.entry.finding} not tested on ${a.finding.id} (${a.finding.file}): the scanner gave no position for its pattern; the finding is kept`;
|
||||
}
|
||||
|
||||
/** One table line per finding a pattern entry named but did not match: the pattern is tested from where the flagged value starts, never the name before it. */
|
||||
export function describeMissed(a: Allowlisted): string {
|
||||
return `allowlist[${a.index}] ${a.entry.finding} pattern did not match ${a.finding.id} (${a.finding.file}${a.finding.line ? `, ${a.finding.fileLine ? 'line' : 'added line'} ${a.finding.line}` : ''}, col ${a.finding.col}): a pattern is matched from where the flagged value starts, not from the name; the finding is kept`;
|
||||
}
|
||||
|
||||
/** One table line per allowlisted finding: what, where, and which entry (with its reason) covered it. */
|
||||
export function describeAllowlisted(a: Allowlisted): string {
|
||||
const where = a.finding.line ? ` (${a.finding.file}, added line ${a.finding.line})` : ` (${a.finding.file})`;
|
||||
const where = a.finding.line ? ` (${a.finding.file}, ${a.finding.fileLine ? 'line' : 'added line'} ${a.finding.line})` : ` (${a.finding.file})`;
|
||||
return `allowlisted ${a.finding.id}${where} by allowlist[${a.index}] ${a.entry.finding}: ${a.entry.reason}`;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { runScriptSync } from '../../script-invocation';
|
||||
import { allowlistDigest, applyAllowlist, describeAllowlisted, describeUntested, type AllowlistFinding } from '../allowlist';
|
||||
import { allowlistDigest, applyAllowlist, describeAllowlisted, describeMissed, describeUntested, type AllowlistFinding } from '../allowlist';
|
||||
import { baseInputs, sha256 } from '../context';
|
||||
import { environmentResult, launchCause } from '../environment';
|
||||
import { checkScratch } from '../run-scratch';
|
||||
@@ -33,24 +33,69 @@ export function parseAddedLines(diff: string): Map<string, string[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Each file's added line numbers in the working-tree file, from the hunk headers' `+start`. */
|
||||
export function parseAddedLineNumbers(diff: string): Map<string, number[]> {
|
||||
const files = new Map<string, number[]>();
|
||||
let file = '';
|
||||
let next = 0;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --')) { file = ''; next = 0; continue; }
|
||||
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
||||
if (hunk) { next = Number(hunk[1]); continue; }
|
||||
if (!next) { if (line.startsWith('+++ ')) file = line.startsWith('+++ b/') ? line.slice(6) : ''; continue; }
|
||||
if (line.startsWith('+')) { if (!files.has(file)) files.set(file, []); files.get(file)!.push(next++); }
|
||||
else if (line.startsWith(' ')) next++;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Files whose source-syntax exemption needs the whole file: a line added inside an existing `'''`/`"""` block is a literal (#3630). */
|
||||
const LEXICAL_CONTEXT_SOURCE = /\.(?:dart|pyi?)$/i;
|
||||
const WHOLE_FILE_MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Added lines from the merge-base to the working tree, plus every line of
|
||||
* untracked files, grouped by file so gstack-redact can use each file's type
|
||||
* (#3211). External diff and textconv drivers are off: either can empty or
|
||||
* rewrite the diff, and an empty diff reads as clean.
|
||||
* (#3211). A tracked Dart or Python file is passed whole with `keep`, its
|
||||
* added line numbers, so the scanner sees the strings an added line sits in
|
||||
* and only findings on those lines count (#3630); an untracked one keeps every
|
||||
* line. One too large, unreadable, not a regular file, or whose working copy
|
||||
* no longer holds the diff's added lines at their numbers
|
||||
* to read whole is scanned under a neutral name, without that exemption. External diff and textconv
|
||||
* drivers are off: either can empty or rewrite the diff, and an empty diff
|
||||
* reads as clean.
|
||||
*/
|
||||
export function addedLines(ctx: PregateContext): Array<{ file: string; text: string }> {
|
||||
export function addedLines(ctx: PregateContext): Array<{ file: string; text: string; keep?: number[] }> {
|
||||
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/', '--unified=0', '--diff-filter=ACMR', ctx.mergeBase, '--'], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024, env: ctx.env });
|
||||
const files = parseAddedLines(diff.stdout ?? '');
|
||||
const numbers = parseAddedLineNumbers(diff.stdout ?? '');
|
||||
const whole = new Map<string, { text: string; keep: number[] }>();
|
||||
for (const [file, keep] of numbers) {
|
||||
if (!LEXICAL_CONTEXT_SOURCE.test(file)) continue;
|
||||
try {
|
||||
const st = fs.lstatSync(path.join(ctx.repoRoot, file));
|
||||
if (!st.isFile() || st.size > WHOLE_FILE_MAX_BYTES) continue;
|
||||
const text = fs.readFileSync(path.join(ctx.repoRoot, file), 'utf8');
|
||||
const lines = text.split('\n');
|
||||
const added = files.get(file) ?? [];
|
||||
if (keep.length === added.length && keep.every((n, k) => lines[n - 1] === added[k])) whole.set(file, { text, keep });
|
||||
} catch { /* unreadable: its added lines are scanned alone */ }
|
||||
}
|
||||
for (const t of ctx.touched) {
|
||||
if (!t.uncommitted || t.status === 'D') continue;
|
||||
const tracked = spawnSync('git', ['ls-files', '--error-unmatch', '--', t.path], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 15_000 }).status === 0;
|
||||
if (tracked) continue;
|
||||
try { const st = fs.statSync(path.join(ctx.repoRoot, t.path)); if (st.isFile() && st.size <= 4 * 1024 * 1024) files.set(t.path, [...(files.get(t.path) ?? []), ...fs.readFileSync(path.join(ctx.repoRoot, t.path), 'utf8').split('\n')]); } catch { /* unreadable: skipped */ }
|
||||
try {
|
||||
const st = fs.statSync(path.join(ctx.repoRoot, t.path));
|
||||
if (!st.isFile() || st.size > WHOLE_FILE_MAX_BYTES) continue;
|
||||
const lines = fs.readFileSync(path.join(ctx.repoRoot, t.path), 'utf8').split('\n');
|
||||
if (LEXICAL_CONTEXT_SOURCE.test(t.path) && !files.has(t.path)) whole.set(t.path, { text: lines.join('\n'), keep: lines.map((_, n) => n + 1) });
|
||||
files.set(t.path, [...(files.get(t.path) ?? []), ...lines]);
|
||||
} catch { /* unreadable: skipped */ }
|
||||
}
|
||||
return [...files].map(([file, lines]) => ({ file, text: lines.join('\n') })).filter(p => p.text.trim());
|
||||
return [...files].map(([file, lines]) => whole.get(file) ? { file, ...whole.get(file)! } : { file, text: lines.join('\n') }).filter(p => p.text.trim());
|
||||
}
|
||||
type Finding = { id?: string; description?: string; severity?: string; category?: string; line?: number; col?: number; file: string };
|
||||
type Finding = { id?: string; description?: string; severity?: string; category?: string; line?: number; col?: number; file: string; fileLine?: boolean };
|
||||
type Scanned = Finding & AllowlistFinding;
|
||||
|
||||
function run(ctx: PregateContext): CheckResult {
|
||||
@@ -67,7 +112,7 @@ function run(ctx: PregateContext): CheckResult {
|
||||
let medium = false;
|
||||
let parsedAll = true;
|
||||
for (const [i, part] of parts.entries()) {
|
||||
const file = path.join(tmp, String(i), path.basename(part.file) || 'added.txt');
|
||||
const file = path.join(tmp, String(i), (!part.keep && LEXICAL_CONTEXT_SOURCE.test(part.file) ? '' : path.basename(part.file)) || 'added.txt');
|
||||
fs.mkdirSync(path.dirname(file), { mode: 0o700 });
|
||||
fs.writeFileSync(file, part.text, { mode: 0o600 });
|
||||
const r = runScriptSync(path.join(BIN_DIR, 'gstack-redact'), ['--from-file', file, '--json', '--max-bytes', String(64 * 1024 * 1024)], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: Math.min(ctx.timeoutMs, 300_000), maxBuffer: 64 * 1024 * 1024, env: ctx.env });
|
||||
@@ -76,21 +121,21 @@ function run(ctx: PregateContext): CheckResult {
|
||||
high ||= r.status === 3;
|
||||
medium ||= r.status === 2;
|
||||
const lines = part.text.split('\n');
|
||||
try { findings.push(...(JSON.parse(r.stdout).findings ?? []).map((f: Omit<Finding, 'file'>) => ({ ...f, id: f.id ?? 'finding', category: f.category ?? '?', severity: f.severity ?? '?', file: part.file, lineText: f.line ? lines[f.line - 1] : undefined }))); } catch { parsedAll = false; }
|
||||
try { findings.push(...(JSON.parse(r.stdout).findings ?? []).filter((f: Omit<Finding, 'file'>) => !part.keep || f.id?.startsWith('engine.') || (f.line !== undefined && part.keep.includes(f.line))).map((f: Omit<Finding, 'file'>) => ({ ...f, id: f.id ?? 'finding', category: f.category ?? '?', severity: f.severity ?? '?', file: part.file, fileLine: !!part.keep, lineText: f.line ? lines[f.line - 1] : undefined }))); } catch { parsedAll = false; }
|
||||
}
|
||||
// The allowlist applies only to findings the scanner reported in full; an unparsed report keeps its exit-code verdict.
|
||||
const applied = parsedAll && ctx.config.allowlist.length ? applyAllowlist(findings, ctx.config.allowlist) : { kept: findings, allowlisted: [], untested: [] };
|
||||
const applied = parsedAll && ctx.config.allowlist.length ? applyAllowlist(findings, ctx.config.allowlist) : { kept: findings, allowlisted: [], untested: [], missed: [] };
|
||||
const kept = applied.kept;
|
||||
if (parsedAll) { high = kept.some(f => f.severity === 'HIGH'); medium = kept.some(f => f.severity === 'MEDIUM'); }
|
||||
const render = (f: Scanned) => `${f.severity} ${f.id} [${f.category}]: ${f.description ?? ''}${f.line ? ` (${f.file ? `${f.file}, ` : ''}added line ${f.line})` : ''}`;
|
||||
const allowLines = [...applied.allowlisted.map(describeAllowlisted), ...applied.untested.map(describeUntested)];
|
||||
const render = (f: Scanned) => `${f.severity} ${f.id} [${f.category}]: ${f.description ?? ''}${f.line ? ` (${f.file ? `${f.file}, ` : ''}${f.fileLine ? 'line' : 'added line'} ${f.line})` : ''}`;
|
||||
const allowLines = [...applied.allowlisted.map(describeAllowlisted), ...applied.untested.map(describeUntested), ...applied.missed.map(describeMissed)];
|
||||
const allowNote = applied.allowlisted.length ? `; ${applied.allowlisted.length} allowlisted (${[...new Set(applied.allowlisted.map(a => a.finding.id))].join(', ')})` : '';
|
||||
// HIGH and any MEDIUM credential fail; a MEDIUM hygiene/internal/pii/legal
|
||||
// hit in code (a `.local` property reads as a hostname) is listed as warn:
|
||||
// there is no user to answer gstack-redact's per-finding question here.
|
||||
const blocking = kept.filter(f => f.severity === 'HIGH' || (f.severity === 'MEDIUM' && f.category === 'secret'));
|
||||
const advisory = kept.filter(f => f.severity === 'MEDIUM' && f.category !== 'secret');
|
||||
const lineCount = parts.reduce((n, p) => n + p.text.split('\n').length, 0);
|
||||
const lineCount = parts.reduce((n, p) => n + (p.keep?.length ?? p.text.split('\n').length), 0);
|
||||
const fail = (lines: string[], detail: string): CheckResult => ({ ...base, status: 'fail', cause: 'diff', lines: [...lines, ...allowLines], code: 'PREGATE_SECRETS', detail: `${detail}${allowNote}`, fix: 'rotate and remove the credential from the added lines (history included); secrets cannot be downgraded (a benign finding is allowlisted by id, path and pattern in the policy, never env.value)' });
|
||||
if (!high && !medium) return { ...base, status: 'pass', ...(allowLines.length ? { lines: allowLines } : {}), detail: `${lineCount} added lines clean${kept.length ? ` (${kept.length} WARN)` : ''}${allowNote}` };
|
||||
if (blocking.length) return fail([...blocking, ...advisory].map(render), `${blocking.length} credential finding(s) in added lines (${blocking.map(f => f.id).join(', ')})`);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import {
|
||||
PATTERNS,
|
||||
PATTERNS_BY_ID,
|
||||
clearStringSpanCache,
|
||||
isPlaceholderSpan,
|
||||
type RedactPattern,
|
||||
type Tier,
|
||||
@@ -472,6 +473,7 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, Ori
|
||||
}
|
||||
}
|
||||
}
|
||||
clearStringSpanCache();
|
||||
|
||||
// Environment values, by exact value: a credential that is already in the
|
||||
// process environment needs no shape to be recognized.
|
||||
|
||||
+122
-14
@@ -121,7 +121,7 @@ export function shannonEntropy(s: string): number {
|
||||
// (apiKey, authToken, clientSecret, stripeApiKey) — cacheKey/sortKey/
|
||||
// monkey have no credential prefix and are rejected.
|
||||
const ENV_KV_NAME =
|
||||
/^[ \t]*(?:export[ \t]+)?["']?([A-Za-z0-9_.-]*?(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE))["']?[ \t]*[:=]/i;
|
||||
/^(?:[ +-]?\+|[ +-]?-(?! [^ \t]))?[ \t]*(?:export[ \t]+)?["']?([A-Za-z0-9_.-]*?(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE))["']?[ \t]*[:=]/i;
|
||||
const ENV_KV_SUFFIX =
|
||||
/(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)$/i;
|
||||
const ENV_KV_CRED_PREFIX =
|
||||
@@ -656,7 +656,7 @@ const CALL_SHAPED_VALUE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(/;
|
||||
* A random key is mixed-case or one unbroken run, so it never takes this shape.
|
||||
*/
|
||||
const SCHEMA_FIELD_NAME =
|
||||
/^[ \t]*["']?(?:input|output|field|column|lookup|cache|sort|partition|range|index|row|group|join|property|metric|catalog|schema|translation|idempotency|dedup)[_.-]key["']?[ \t]*[:=]/;
|
||||
/^(?:[ +-]?\+|[ +-]?-(?! [^ \t]))?[ \t]*["']?(?:input|output|field|column|lookup|cache|sort|partition|range|index|row|group|join|property|metric|catalog|schema|translation|idempotency|dedup)[_.-]key["']?[ \t]*[:=]/;
|
||||
const SCHEMA_FIELD_VALUE = /^[a-z][a-z0-9]*(?:[_.-][a-z][a-z0-9]*)+$/;
|
||||
function isSchemaFieldIdentifier(span: string, match: RegExpExecArray): boolean {
|
||||
return SCHEMA_FIELD_NAME.test(match[0]) && SCHEMA_FIELD_VALUE.test(span);
|
||||
@@ -673,28 +673,127 @@ function isCallExpression(span: string, match: RegExpExecArray): boolean {
|
||||
/**
|
||||
* #3048: in a TypeScript/JSX file, an unquoted value after `:` is a type or an
|
||||
* expression, never a string literal (`session: SessionState,`), and a JSX
|
||||
* brace holding only names and property reads (`key={turn.requestId + turn.role}`)
|
||||
* is an expression. Decided by file context, not by how the value looks:
|
||||
* brace holding only names and property reads joined by + ?? || or &&
|
||||
* (`key={turn.requestId + turn.role}`, `key={item.id ?? index}`, #3605) is an
|
||||
* expression. Decided by file context, not by how the value looks:
|
||||
* `API_KEY=VelvetRiverOrbitSunset;` in a .env or YAML file is still a literal.
|
||||
* Without a known TS/JSX path nothing is exempt.
|
||||
* #3630 (#3418, #3419): Dart and Python (.py, .pyi) qualify on the same grammar
|
||||
* fact, no unquoted string literal, so `'password': request.password` and
|
||||
* `session: HttpSession,` are names. Python's `=` forms
|
||||
* (`password=request.password`) still fire: the separator must be `:`.
|
||||
* Without a known source path nothing is exempt.
|
||||
*/
|
||||
const TS_SOURCE = /\.(?:[cm]?tsx?|jsx)$/i;
|
||||
const CODE_SOURCE = /\.(?:[cm]?tsx?|jsx|dart|pyi?)$/i;
|
||||
const CODE_NAME_CHAIN = /^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)*(?:<[\w$.,\s<>[\]|]*>)?(?:\[\])*$/;
|
||||
|
||||
/**
|
||||
* #3418: a `password:` pair inside a string is text in an embedded config,
|
||||
* not a dict entry or annotation, so the name exemption must not reach it.
|
||||
* In Python and Dart (no regex literals, so every quote is a string's), one
|
||||
* linear pass per input (a scan validates many candidates against the same
|
||||
* text) records every string's content span: a `'''`/`"""` block, or a
|
||||
* `'`/`"` string that ends at its line unless a backslash continues it.
|
||||
* Comments are skipped (`#` in Python; `//` and nested `/* *\/` in Dart), so a
|
||||
* delimiter inside one opens nothing; a backslash escapes the next character, except in a Dart raw
|
||||
* string (`r'''…'''`). The exemption needs the
|
||||
* whole file to see a block's opener: pregate passes Dart and Python files
|
||||
* whole, and the pre-push hook, which scans added lines alone, passes them
|
||||
* without their path.
|
||||
*/
|
||||
const STRING_SPAN_SOURCE = /\.(?:dart|pyi?)$/i;
|
||||
let stringSpansCache: { text: string; lang: string; spans: Array<[number, number]> } | null = null;
|
||||
|
||||
/** Drops the cached text once a scan ends, so a long-lived process holds no scanned input. */
|
||||
export function clearStringSpanCache(): void {
|
||||
stringSpansCache = null;
|
||||
}
|
||||
|
||||
function stringSpans(text: string, lang: "py" | "dart"): Array<[number, number]> {
|
||||
if (stringSpansCache?.text === text && stringSpansCache.lang === lang) return stringSpansCache.spans;
|
||||
const spans: Array<[number, number]> = [];
|
||||
const n = text.length;
|
||||
for (let i = 0; i < n; ) {
|
||||
const c = text[i]!;
|
||||
if (lang === "py" ? c === "#" : text.startsWith("//", i)) {
|
||||
const eol = text.indexOf("\n", i);
|
||||
i = eol < 0 ? n : eol;
|
||||
continue;
|
||||
}
|
||||
if (lang === "dart" && text.startsWith("/*", i)) {
|
||||
let depth = 1;
|
||||
for (i += 2; i < n && depth > 0; ) {
|
||||
if (text.startsWith("/*", i)) { depth += 1; i += 2; }
|
||||
else if (text.startsWith("*/", i)) { depth -= 1; i += 2; }
|
||||
else i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c !== '"' && c !== "'") { i += 1; continue; }
|
||||
const close = text.startsWith(c.repeat(3), i) ? c.repeat(3) : c;
|
||||
const multiline = close.length === 3;
|
||||
const raw = lang === "dart" && text[i - 1] === "r" && !/[\w$]/.test(text[i - 2] ?? "");
|
||||
let j = i + close.length;
|
||||
while (j < n && !text.startsWith(close, j) && (multiline || text[j] !== "\n")) j += !raw && text[j] === "\\" ? 2 : 1;
|
||||
spans.push([i + close.length, Math.min(j, n)]);
|
||||
i = j + close.length;
|
||||
}
|
||||
stringSpansCache = { text, lang, spans };
|
||||
return spans;
|
||||
}
|
||||
|
||||
function insideString(text: string, offset: number, sourcePath: string): boolean {
|
||||
if (!STRING_SPAN_SOURCE.test(sourcePath)) return false;
|
||||
const spans = stringSpans(text, /\.dart$/i.test(sourcePath) ? "dart" : "py");
|
||||
let lo = 0;
|
||||
let hi = spans.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (offset < spans[mid]![0]) hi = mid - 1;
|
||||
else if (offset >= spans[mid]![1]) lo = mid + 1;
|
||||
else return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The rest of a name's expression on its line, up to the comma, semicolon, bracket or comment that ends it; null when it continues on the next line. */
|
||||
function expressionTail(line: string, comment: string): string | null {
|
||||
let rest = line;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const c = rest[i]!;
|
||||
if (rest.startsWith(comment, i)) { rest = rest.slice(0, i); break; }
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
const close = rest.indexOf(c, i + 1);
|
||||
if (close < 0) return null;
|
||||
i = close;
|
||||
} else if ("([{".includes(c)) depth += 1;
|
||||
else if (")]}".includes(c)) { if (--depth < 0) return rest.slice(0, i); }
|
||||
else if ((c === "," || c === ";") && depth === 0) return rest.slice(0, i);
|
||||
}
|
||||
return depth > 0 || /(?:[=+\\]|\?\?|\|\||&&|\b(?:or|and))\s*$/.test(rest) ? null : rest;
|
||||
}
|
||||
|
||||
function isSourceExpression(span: string, match: RegExpExecArray, sourcePath: string | undefined): boolean {
|
||||
if (!sourcePath || !TS_SOURCE.test(sourcePath)) return false;
|
||||
const { start } = spanBounds(match);
|
||||
if (!sourcePath || !CODE_SOURCE.test(sourcePath)) return false;
|
||||
const { start, end } = spanBounds(match);
|
||||
const before = match.input[start - 1];
|
||||
if (before === '"' || before === "'" || before === "`") return false;
|
||||
if (span.startsWith("{")) {
|
||||
const line = match.input.slice(start + 1).split("\n", 1)[0];
|
||||
const close = line.indexOf("}");
|
||||
if (close < 0 || /["'`]/.test(line.slice(0, close))) return false;
|
||||
return line.slice(0, close).split("+").every(term => CODE_NAME_CHAIN.test(term.trim()));
|
||||
if (!line.slice(0, close).split(/\+|\?\?|\|\||&&/).every(term => CODE_NAME_CHAIN.test(term.trim()))) return false;
|
||||
} else {
|
||||
const head = match[0].slice(0, match[0].length - span.length).trimEnd();
|
||||
const separator = head.slice(-1);
|
||||
if (separator !== ":" || !CODE_NAME_CHAIN.test(span.replace(/[,;)=|]+$/, ""))) return false;
|
||||
const rest = (span.endsWith("=") ? "=" : "") + match.input.slice(end).split("\n", 1)[0];
|
||||
if (!/[,;]$/.test(span)) {
|
||||
const tail = expressionTail(rest, /\.pyi?$/i.test(sourcePath) ? "#" : "//");
|
||||
if (tail === null || carriesSecretLiteral(tail)) return false;
|
||||
}
|
||||
}
|
||||
const separator = match[0].slice(0, match[0].length - span.length).trimEnd().slice(-1);
|
||||
if (separator !== ":") return false;
|
||||
return CODE_NAME_CHAIN.test(span.replace(/[,;)=|]+$/, ""));
|
||||
return !insideString(match.input, start, sourcePath);
|
||||
}
|
||||
|
||||
export const PATTERNS: RedactPattern[] = [
|
||||
@@ -962,7 +1061,16 @@ export const PATTERNS: RedactPattern[] = [
|
||||
// (cacheKey:, sortKey:, monkey:) fires a MEDIUM confirm on entropic
|
||||
// values. The value must stay capture group 1 (the engine masks group 1),
|
||||
// so name-shape checking lives in validate, not in a second group.
|
||||
regex: /^[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \t]*[:=][ \t]*["']?([^\s'"]{8,})["']?/i,
|
||||
// GST-543: an optional leading `+` or `-` is a unified-diff line marker
|
||||
// (`git diff | gstack-redact`, a diff fence in a PR body), and one more
|
||||
// column before it (`++`, ` +`, `+-`) is a combined (merge) diff. Without
|
||||
// them the line anchor stopped at the marker, so an added or removed credential
|
||||
// went unreported (`-` alone matched only as a name character, never
|
||||
// before indentation or `export`). A `-` followed by exactly one space
|
||||
// and text is a Markdown bullet or YAML list item, not a marker, so those
|
||||
// read as they did before. ENV_KV_NAME and SCHEMA_FIELD_NAME take the same
|
||||
// marker so the name-shape and schema exemptions hold on a diff line.
|
||||
regex: /^(?:[ +-]?\+|[ +-]?-(?! [^ \t]))?[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \t]*[:=][ \t]*["']?([^\s'"]{8,})["']?/i,
|
||||
// Only fire on credential-shaped names with high-entropy values — kills
|
||||
// `FOO_KEY=changeme` and `cacheKey: <entropic-id>` FPs. #2912: a value
|
||||
// that is exactly an environment read (`os.environ["X"]`,
|
||||
@@ -971,7 +1079,7 @@ export const PATTERNS: RedactPattern[] = [
|
||||
// literal (`os.getenv("X", "<secret>")`). A literal appended to the read
|
||||
// itself (`process.env.X||"…"`) is not an exact read and still fires.
|
||||
// #2899: a function call assigned to the name is code, not a value (see
|
||||
// isCallExpression). #3048: so is a TS/JSX type or expression (isSourceExpression).
|
||||
// isCallExpression). #3048, #3630: so is a TS/JSX/Dart/Python type or expression (isSourceExpression).
|
||||
// #3208: so is a schema field identifier (isSchemaFieldIdentifier).
|
||||
validate: (span, match, opts) =>
|
||||
isCredentialShapedEnvName(match[0]) &&
|
||||
|
||||
+1
-1
@@ -590,7 +590,7 @@ export const RESULT_CODES = {
|
||||
// Field wave lane A (items 1-4): gstack-upgrade, the Capy skill bootstrapper, install admission.
|
||||
UPGRADE_REFUSED: {
|
||||
anchor: 'upgrade-refused',
|
||||
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, or the incoming release holds',
|
||||
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, tracked by an enclosing project (#1022), or the incoming release holds',
|
||||
fix: 'resolve the named cause (commit or move the listed changes, check out the default branch), then re-run bin/gstack-upgrade; it never stashes or resets',
|
||||
},
|
||||
UPGRADE_BUSY: {
|
||||
|
||||
+4
-3
@@ -74,7 +74,7 @@ const STAGE_OF: Record<string, Stage> = { specialist: 'specialists', 'red-team':
|
||||
export function buildManifest(o: {
|
||||
pass: string; out: string; ledger: string; base: string; diffBase: string; root: string; checklistRoot: string;
|
||||
dispatches: Dispatch[]; next: string; planLines: string[]; learnings?: string | null; extras?: Extra[];
|
||||
targetedClasses?: readonly string[]; mergeBase?: string;
|
||||
targetedClasses?: readonly string[]; mergeBase?: string; rangeBase?: string;
|
||||
}): Manifest {
|
||||
const readers: PassReader[] = [];
|
||||
for (const d of o.dispatches) {
|
||||
@@ -85,7 +85,8 @@ export function buildManifest(o: {
|
||||
const reader = readerKey(role, name);
|
||||
const exclude = d.exclude.length ? diffCommand(d).slice('git diff <diff-base> '.length) : undefined;
|
||||
readers.push({ reader, stage, title: `ship ${o.pass} ${reader}`, state: 'planned', role, name, effort: d.effort, exclude, checklists: d.checklists,
|
||||
...(role === 'targeted' ? { classes: [...(o.targetedClasses ?? [])], diff_base: o.mergeBase ?? o.diffBase } : {}) });
|
||||
...(role === 'targeted' ? { classes: [...(o.targetedClasses ?? [])], diff_base: o.mergeBase ?? o.diffBase } : {}),
|
||||
...(role === 'red-team' && o.rangeBase ? { diff_base: o.rangeBase } : {}) });
|
||||
}
|
||||
for (const e of o.extras ?? []) {
|
||||
if (!/^[a-z0-9-]+$/.test(e.name)) throw new BriefError(`an audit reader's name is lowercase letters, digits and dashes: ${e.name}`);
|
||||
@@ -142,7 +143,7 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor:
|
||||
continue;
|
||||
}
|
||||
const role = r.role!;
|
||||
const slots: Slots = { name: r.name, diff_base: m.diff_base, exclude: r.exclude, stack: m.stack, test_fw: m.test_fw, learnings: m.learnings ?? undefined,
|
||||
const slots: Slots = { name: r.name, diff_base: r.diff_base ?? m.diff_base, exclude: r.exclude, stack: m.stack, test_fw: m.test_fw, learnings: m.learnings ?? undefined,
|
||||
checklists: r.checklists?.map(n => path.join(m.checklist_root, 'review/specialists', `${n}.md`)).join(',') };
|
||||
if (role === 'specialist') slots.checklist = path.join(m.checklist_root, 'review/specialists', `${r.name}.md`);
|
||||
let findingsSha: string | undefined;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.2",
|
||||
"version": "1.92.10",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -9,9 +9,7 @@ description: |
|
||||
SCOPE EXPANSION (dream big), SELECTIVE EXPANSION (hold scope + cherry-pick
|
||||
expansions), HOLD SCOPE (maximum rigor), SCOPE REDUCTION (strip to essentials).
|
||||
Use when asked to "think bigger", "expand scope", "strategy review", "rethink this",
|
||||
or "is this ambitious enough".
|
||||
Proactively suggest when the user is questioning scope or ambition of a plan,
|
||||
or when the plan feels like it could be thinking bigger. (gstack)
|
||||
or "is this ambitious enough". (gstack)
|
||||
benefits-from: [office-hours]
|
||||
allowed-tools:
|
||||
- Read
|
||||
|
||||
@@ -8,9 +8,7 @@ description: |
|
||||
Rates each design dimension 0-10, explains what would make it a 10,
|
||||
then fixes the plan to get there. Works in plan mode. For live site
|
||||
visual audits, use /design-review. Use when asked to "review the design plan"
|
||||
or "design critique".
|
||||
Proactively suggest when the user has a plan with UI/UX components that
|
||||
should be reviewed before implementation. (gstack)
|
||||
or "design critique". (gstack)
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Edit
|
||||
|
||||
@@ -9,9 +9,7 @@ description: |
|
||||
points before scoring. Three modes: DX EXPANSION (competitive advantage),
|
||||
DX POLISH (bulletproof every touchpoint), DX TRIAGE (critical gaps only).
|
||||
Use when asked to "DX review", "developer experience audit", "devex review",
|
||||
or "API design review".
|
||||
Proactively suggest when the user has a plan for developer-facing products
|
||||
(APIs, CLIs, SDKs, libraries, platforms, docs). (gstack)
|
||||
or "API design review". (gstack)
|
||||
voice-triggers:
|
||||
- "dx review"
|
||||
- "developer experience review"
|
||||
|
||||
@@ -7,9 +7,7 @@ description: |
|
||||
Eng manager-mode plan review. Lock in the execution plan — architecture,
|
||||
data flow, diagrams, edge cases, test coverage, performance. Walks through
|
||||
issues interactively with opinionated recommendations. Use when asked to
|
||||
"review the architecture", "engineering review", or "lock in the plan".
|
||||
Proactively suggest when the user has a plan or design doc and is about to
|
||||
start coding — to catch architecture issues before implementation. (gstack)
|
||||
"review the architecture", "engineering review", or "lock in the plan". (gstack)
|
||||
voice-triggers:
|
||||
- "tech review"
|
||||
- "technical review"
|
||||
|
||||
@@ -12,9 +12,6 @@ description: |
|
||||
Use when asked to "tune questions", "stop asking me that", "too many questions",
|
||||
"show my profile", "what questions have I been asked", "show my vibe",
|
||||
"developer profile", or "turn off question tuning". (gstack)
|
||||
|
||||
Proactively suggest when the user says the same gstack question has come up before,
|
||||
or when they explicitly override a recommendation for the Nth time.
|
||||
triggers:
|
||||
- tune questions
|
||||
- stop asking me that
|
||||
|
||||
@@ -6,8 +6,7 @@ description: |
|
||||
Report browser/API/CLI/job/worker/webhook bugs. Produces a
|
||||
structured report with contract evidence or browser scores and repro steps — but never
|
||||
fixes anything. Use when asked to "just report bugs", "qa report only", or
|
||||
"test but don't fix". For the full test-fix-verify loop, use /qa instead.
|
||||
Proactively suggest when the user wants a bug report without any code changes. (gstack)
|
||||
"test but don't fix". For the full test-fix-verify loop, use /qa instead. (gstack)
|
||||
voice-triggers:
|
||||
- "bug report"
|
||||
- "just check for bugs"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
name: qa
|
||||
preamble-tier: 4
|
||||
version: 2.0.0
|
||||
description: Fix browser/API/CLI/job/worker/webhook bugs. (gstack)
|
||||
description: Test the site, API or CLI and fix the bugs it finds. (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ name: qa
|
||||
preamble-tier: 4
|
||||
version: 2.0.0
|
||||
description: |
|
||||
Fix browser/API/CLI/job/worker/webhook bugs.
|
||||
Commit verified fixes atomically. Use when asked to "qa", "QA", "test this site", "find bugs",
|
||||
Test the site, API or CLI and fix the bugs it finds.
|
||||
Covers browser/API/CLI/job/worker/webhook bugs and commits verified fixes atomically.
|
||||
Use when asked to "qa", "QA", "qa test this", "test this site", "find bugs", "find bugs on site",
|
||||
"test and fix", or "fix what's broken".
|
||||
Proactively suggest when the user says a feature is ready for testing
|
||||
or asks "does this work?". Three tiers: Quick (critical/high only),
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ description: |
|
||||
Weekly engineering retrospective. Analyzes commit history, work patterns,
|
||||
and code quality metrics with persistent history and trend tracking.
|
||||
Team-aware: breaks down per-person contributions with praise and growth areas.
|
||||
Use when asked to "weekly retro", "what did we ship", or "engineering retrospective".
|
||||
Proactively suggest at the end of a work week or sprint. (gstack)
|
||||
Use when asked to "weekly retro", "what did we ship", or "engineering retrospective". (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
name: review
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: Pre-landing PR review. (gstack)
|
||||
description: Review this PR's diff. (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
@@ -3,7 +3,7 @@ name: review
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Pre-landing PR review. Analyzes diff against the base branch for SQL safety, LLM trust
|
||||
Review this PR's diff. Checks it against the base branch for SQL safety, LLM trust
|
||||
boundary violations, conditional side effects, and other structural issues. Use when
|
||||
asked to "review this PR", "code review", "pre-landing review", or "check my diff".
|
||||
Proactively suggest when the user is about to merge or land code changes. (gstack)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
name: ship
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
|
||||
description: "Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG, push and create a PR. (gstack)"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ name: ship
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION,
|
||||
update CHANGELOG, commit, push, create PR. Use when asked to "ship", "deploy",
|
||||
Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG,
|
||||
push and create a PR. Use when asked to "ship", "deploy", "deploy this",
|
||||
"push to main", "create a PR", "merge and push", or "get it deployed".
|
||||
Proactively invoke this skill (do NOT push/PR directly) when the user says code
|
||||
is ready, asks about deploying, wants to push code up, or asks to create a PR. (gstack)
|
||||
|
||||
@@ -18,7 +18,8 @@ function fixture(plan = original) {
|
||||
writeFileSync(planFile, plan);
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd });
|
||||
// This free test controls unrelated startup side effects, not the seeded repo.
|
||||
writeFileSync(join(state, 'config.yaml'), 'update_check: false\nartifacts_sync: off\ntelemetry: off\n');
|
||||
// proactive: all, because the seeded CLAUDE.md is the every-rule section that setting offers.
|
||||
writeFileSync(join(state, 'config.yaml'), 'update_check: false\nartifacts_sync: off\ntelemetry: off\nproactive: all\n');
|
||||
writeFileSync(join(state, '.proactive-prompted'), '');
|
||||
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GSTACK_LEASE_DIR: join(state, 'gstack-leases') };
|
||||
const start = () => execFileSync(join(root, 'bin/gstack-skill-start'), ['--skill', 'autoplan'],
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('catalog mode CLI behavior', () => {
|
||||
});
|
||||
|
||||
test('omitting the flag defaults to trim', () => {
|
||||
expect(frontmatter(defaultContent)).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?$/m);
|
||||
expect(frontmatter(defaultContent)).toMatch(/^description: "?Ship it:[^\n]*\(gstack\)"?$/m);
|
||||
expect(frontmatter(defaultContent)).not.toMatch(/Use when asked to/i);
|
||||
expect(defaultContent).not.toContain('## When to invoke this skill');
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: gstack-ship
|
||||
description: |
|
||||
Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION,
|
||||
update CHANGELOG, commit, push, create PR. Use when asked to "ship", "deploy",
|
||||
Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG,
|
||||
push and create a PR. Use when asked to "ship", "deploy", "deploy this",
|
||||
"push to main", "create a PR", "merge and push", or "get it deployed".
|
||||
Proactively invoke this skill (do NOT push/PR directly) when the user says code
|
||||
is ready, asks about deploying, wants to push code up, or asks to create a PR. (gstack)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
name: ship
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
|
||||
description: "Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG, push and create a PR. (gstack)"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: ship
|
||||
description: |
|
||||
Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION,
|
||||
update CHANGELOG, commit, push, create PR. Use when asked to "ship", "deploy",
|
||||
Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG,
|
||||
push and create a PR. Use when asked to "ship", "deploy", "deploy this",
|
||||
"push to main", "create a PR", "merge and push", or "get it deployed".
|
||||
Proactively invoke this skill (do NOT push/PR directly) when the user says code
|
||||
is ready, asks about deploying, wants to push code up, or asks to create a PR. (gstack)
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: ship
|
||||
description: |
|
||||
Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION,
|
||||
update CHANGELOG, commit, push, create PR. Use when asked to "ship", "deploy",
|
||||
Ship it: merge the base, run tests, review the diff, bump VERSION and CHANGELOG,
|
||||
push and create a PR. Use when asked to "ship", "deploy", "deploy this",
|
||||
"push to main", "create a PR", "merge and push", or "get it deployed".
|
||||
Proactively invoke this skill (do NOT push/PR directly) when the user says code
|
||||
is ready, asks about deploying, wants to push code up, or asks to create a PR. (gstack)
|
||||
|
||||
+46
-46
@@ -2,51 +2,51 @@
|
||||
"_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.",
|
||||
"limit": 100000,
|
||||
"pins": {
|
||||
"agy/gstack-autoplan": 109810,
|
||||
"agy/gstack-plan-design-review": 110044,
|
||||
"agy/gstack-plan-devex-review": 116496,
|
||||
"agy/gstack-plan-eng-review": 144907,
|
||||
"agy/gstack-review": 104280,
|
||||
"codex/gstack-autoplan": 108553,
|
||||
"codex/gstack-plan-design-review": 111116,
|
||||
"codex/gstack-plan-devex-review": 118004,
|
||||
"codex/gstack-plan-eng-review": 146072,
|
||||
"codex/gstack-review": 103803,
|
||||
"copilot/gstack-autoplan": 109253,
|
||||
"copilot/gstack-plan-design-review": 110355,
|
||||
"copilot/gstack-plan-devex-review": 115958,
|
||||
"copilot/gstack-plan-eng-review": 145072,
|
||||
"copilot/gstack-review": 103861,
|
||||
"cursor/gstack-autoplan": 108485,
|
||||
"cursor/gstack-plan-design-review": 108900,
|
||||
"cursor/gstack-plan-devex-review": 115228,
|
||||
"cursor/gstack-plan-eng-review": 143662,
|
||||
"cursor/gstack-review": 122151,
|
||||
"factory/gstack-autoplan": 110763,
|
||||
"factory/gstack-plan-design-review": 111675,
|
||||
"factory/gstack-plan-devex-review": 118591,
|
||||
"factory/gstack-plan-eng-review": 146770,
|
||||
"factory/gstack-review": 125924,
|
||||
"gbrain/gstack-autoplan": 108892,
|
||||
"gbrain/gstack-plan-eng-review": 126289,
|
||||
"hermes/gstack-autoplan": 108891,
|
||||
"hermes/gstack-plan-eng-review": 126321,
|
||||
"kiro/gstack-autoplan": 108259,
|
||||
"kiro/gstack-plan-design-review": 108752,
|
||||
"kiro/gstack-plan-devex-review": 115080,
|
||||
"kiro/gstack-plan-eng-review": 143524,
|
||||
"kiro/gstack-review": 121975,
|
||||
"openclaw/gstack-autoplan": 108715,
|
||||
"openclaw/gstack-plan-eng-review": 124804,
|
||||
"opencode/gstack-autoplan": 109234,
|
||||
"opencode/gstack-plan-design-review": 110187,
|
||||
"opencode/gstack-plan-devex-review": 115836,
|
||||
"opencode/gstack-plan-eng-review": 144931,
|
||||
"opencode/gstack-review": 122773,
|
||||
"slate/gstack-autoplan": 108372,
|
||||
"slate/gstack-plan-design-review": 108826,
|
||||
"slate/gstack-plan-devex-review": 115154,
|
||||
"slate/gstack-plan-eng-review": 143593,
|
||||
"slate/gstack-review": 122063
|
||||
"agy/gstack-autoplan": 109666,
|
||||
"agy/gstack-plan-design-review": 109926,
|
||||
"agy/gstack-plan-devex-review": 116369,
|
||||
"agy/gstack-plan-eng-review": 144761,
|
||||
"agy/gstack-review": 104276,
|
||||
"codex/gstack-autoplan": 108409,
|
||||
"codex/gstack-plan-design-review": 110998,
|
||||
"codex/gstack-plan-devex-review": 117877,
|
||||
"codex/gstack-plan-eng-review": 145926,
|
||||
"codex/gstack-review": 103799,
|
||||
"copilot/gstack-autoplan": 109109,
|
||||
"copilot/gstack-plan-design-review": 110237,
|
||||
"copilot/gstack-plan-devex-review": 115831,
|
||||
"copilot/gstack-plan-eng-review": 144926,
|
||||
"copilot/gstack-review": 103857,
|
||||
"cursor/gstack-autoplan": 108341,
|
||||
"cursor/gstack-plan-design-review": 108782,
|
||||
"cursor/gstack-plan-devex-review": 115101,
|
||||
"cursor/gstack-plan-eng-review": 143516,
|
||||
"cursor/gstack-review": 122147,
|
||||
"factory/gstack-autoplan": 110619,
|
||||
"factory/gstack-plan-design-review": 111557,
|
||||
"factory/gstack-plan-devex-review": 118464,
|
||||
"factory/gstack-plan-eng-review": 146624,
|
||||
"factory/gstack-review": 125920,
|
||||
"gbrain/gstack-autoplan": 108748,
|
||||
"gbrain/gstack-plan-eng-review": 126143,
|
||||
"hermes/gstack-autoplan": 108751,
|
||||
"hermes/gstack-plan-eng-review": 126179,
|
||||
"kiro/gstack-autoplan": 108115,
|
||||
"kiro/gstack-plan-design-review": 108634,
|
||||
"kiro/gstack-plan-devex-review": 114953,
|
||||
"kiro/gstack-plan-eng-review": 143378,
|
||||
"kiro/gstack-review": 121971,
|
||||
"openclaw/gstack-autoplan": 108571,
|
||||
"openclaw/gstack-plan-eng-review": 124658,
|
||||
"opencode/gstack-autoplan": 109090,
|
||||
"opencode/gstack-plan-design-review": 110069,
|
||||
"opencode/gstack-plan-devex-review": 115709,
|
||||
"opencode/gstack-plan-eng-review": 144785,
|
||||
"opencode/gstack-review": 122769,
|
||||
"slate/gstack-autoplan": 108228,
|
||||
"slate/gstack-plan-design-review": 108708,
|
||||
"slate/gstack-plan-devex-review": 115027,
|
||||
"slate/gstack-plan-eng-review": 143447,
|
||||
"slate/gstack-review": 122059
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ function ff(fx: Fx, rev = 'origin/main', extra: Record<string, string> = {}, bin
|
||||
}
|
||||
const read = (fx: Fx, f: string) => fs.readFileSync(path.join(fx.install, f), 'utf8');
|
||||
const write = (fx: Fx, f: string, body: string) => fs.writeFileSync(path.join(fx.install, f), body);
|
||||
/** Moves mtimes 10 s back so no entry is racily clean against the index (a same-second edit can trip the barrier's recapture). */
|
||||
const backdate = (fx: Fx, ...files: string[]) => { const s = Date.now() / 1000 - 10; for (const f of files) fs.utimesSync(path.join(fx.install, f), s, s); };
|
||||
const rescueRef = (out: string) => out.match(/^rescued [0-9a-f]+ [0-9a-f]+ (refs\/gstack\/rescue\/[0-9a-f]{40}) unmerged=([01])$/m);
|
||||
function makeUnmerged(fx: Fx, file: string) {
|
||||
const blob = (body: string) => execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: fx.install, input: body, encoding: 'utf8', timeout: 30_000 }).trim();
|
||||
@@ -264,6 +266,124 @@ describe.skipIf(process.platform === 'win32')('gstack-ff-update: moving the tree
|
||||
expect(git(fx.install, 'log', '-1', '--format=%an <%ae>|%cn <%ce>', ref)).toBe('gstack rescue <gstack@localhost>|gstack rescue <gstack@localhost>');
|
||||
});
|
||||
|
||||
// Value: protects=the one move every upgrade path shares refuses (exit 6, nothing changed) when an enclosing project tracks the install or
|
||||
// VERSION was put back behind HEAD, even with nothing to move (#1022); fails_when=the preflight is skipped, runs after "already", or counts a gitlink;
|
||||
// why_new=no case had an enclosing project; seam=none
|
||||
test('preflight: a tracking project or a reverted VERSION refuses with exit 6 and changes nothing; a gitlink and --preflight on a clean install pass', () => {
|
||||
const fx = makeFixture();
|
||||
const old = git(fx.install, 'rev-parse', 'HEAD');
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
git(fx.base, 'update-index', '--add', '--cacheinfo', `160000,${old},install`);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
git(fx.base, 'update-index', '--force-remove', 'install');
|
||||
git(fx.base, 'update-index', '--add', '--', 'install/VERSION', 'install/notes.txt');
|
||||
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
|
||||
const tracked = ff(fx);
|
||||
expect(tracked.status, tracked.stdout + tracked.stderr).toBe(6);
|
||||
expect(tracked.stdout).toStartWith(`tracked-by-project: ${fx.base} tracks 2 files of the gstack install at install,`);
|
||||
expect(tracked.stdout.trim().split('\n')).toHaveLength(1);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
|
||||
fs.rmSync(path.join(fx.base, '.git'), { recursive: true, force: true });
|
||||
expect(ff(fx).status).toBe(0);
|
||||
write(fx, 'VERSION', '1.0.0\n');
|
||||
const reverted = ff(fx);
|
||||
expect(reverted.status).toBe(6);
|
||||
expect(reverted.stdout).toStartWith('version-reverted: VERSION says 1.0.0 but the checkout is at 1.1.0');
|
||||
expect(read(fx, 'VERSION')).toBe('1.0.0\n');
|
||||
write(fx, 'VERSION', '9.9.9\n');
|
||||
expect(ff(fx).stdout).toStartWith('version-reverted: VERSION says 9.9.9 but the checkout is at 1.1.0: files changed outside git');
|
||||
});
|
||||
|
||||
// Value: protects=an unmerged VERSION still takes the existing rescue, which the unmerged-files banner's command relies on;
|
||||
// fails_when=the version-reverted preflight fires on conflict markers; why_new=the preflight is new; seam=none
|
||||
test('preflight: an unmerged VERSION is rescued, not refused as version-reverted', () => {
|
||||
const fx = makeFixture();
|
||||
makeUnmerged(fx, 'VERSION');
|
||||
const target = release(fx, { 'notes.txt': 'x\n' });
|
||||
const r = ff(fx, target);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^rescued .* unmerged=1/);
|
||||
});
|
||||
|
||||
// Value: protects=the preflight runs no code from the enclosing project (its core.fsmonitor hook); fails_when=an enclosing-repo git call
|
||||
// drops the fsmonitor override; why_new=the enclosing-project probe is new; seam=none
|
||||
test('preflight: an enclosing project\'s core.fsmonitor hook never runs', () => {
|
||||
const fx = makeFixture();
|
||||
const sentinel = path.join(fx.base, 'fsmonitor-ran');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(fx.base, 'hook.sh'), `#!/bin/sh\ntouch '${sentinel}'\n`, { mode: 0o755 });
|
||||
git(fx.base, 'config', 'core.fsmonitor', path.join(fx.base, 'hook.sh'));
|
||||
const r = spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.existsSync(sentinel)).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the printed repair untracks only the install, even when its name holds glob characters, and prints whole at any path length;
|
||||
// fails_when=the rm drops --literal-pathspecs, the ignore rule is unescaped, or the message is capped short; why_new=review pass 1; seam=none
|
||||
test('preflight: the tracked-by-project repair is literal and printed whole', () => {
|
||||
const fx = makeFixture(`gstack*${'x'.repeat(150)}`);
|
||||
const name = path.basename(fx.install);
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
const decoy = `gstack-data${'x'.repeat(150)}`;
|
||||
fs.writeFileSync(path.join(fx.base, decoy), 'keep\n');
|
||||
git(fx.base, 'add', '--', decoy);
|
||||
git(fx.base, 'update-index', '--add', '--', `${name}/VERSION`);
|
||||
const r = spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(6);
|
||||
expect(r.stdout.trimEnd()).toEndWith('and re-run');
|
||||
expect(r.stdout).toContain(`add /gstack\\*${'x'.repeat(150)}/ to`);
|
||||
const rm = r.stdout.match(/fix: (git -C .*? rm -r -q --cached -- '[^']*')/)?.[1];
|
||||
expect(rm).toContain('--literal-pathspecs');
|
||||
expect(spawnSync('bash', ['-c', rm!], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
expect(git(fx.base, 'ls-files').split('\n')).toEqual([decoy]);
|
||||
});
|
||||
|
||||
// Value: protects=a dirty VERSION beside an unmerged file still takes the rescue (stash cannot run there); a VERSION that differs only in mode is no
|
||||
// revert; a held lock wins over the VERSION check; fails_when=the check looks only at VERSION's own stage, compares bytes, or runs before the lock;
|
||||
// why_new=review pass 1; seam=none
|
||||
test('preflight: version-reverted yields to the rescue, to a same-text VERSION, and to a held lock', () => {
|
||||
const fx = makeFixture();
|
||||
makeUnmerged(fx, 'notes.txt');
|
||||
write(fx, 'VERSION', '0.9.0\n');
|
||||
backdate(fx, 'VERSION', 'notes.txt');
|
||||
const mixed = ff(fx, release(fx, { 'other.txt': 'x\n' }));
|
||||
expect(mixed.status, mixed.stdout + mixed.stderr).toBe(0);
|
||||
expect(mixed.stdout).toMatch(/^rescued .* unmerged=1/);
|
||||
|
||||
const fy = makeFixture();
|
||||
fs.chmodSync(path.join(fy.install, 'VERSION'), 0o755);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fy.install], { encoding: 'utf8', env: isolatedEnv(fy.base), timeout: 60_000 }).status).toBe(0);
|
||||
|
||||
const fz = makeFixture();
|
||||
release(fz, { 'other.txt': 'x\n' });
|
||||
write(fz, 'VERSION', '0.9.0\n');
|
||||
const holder = spawn('sleep', ['30']);
|
||||
try {
|
||||
const lock = path.join(fz.install, '.git', 'gstack-ff.lock');
|
||||
fs.mkdirSync(lock);
|
||||
fs.writeFileSync(path.join(lock, 'pid'), `${holder.pid}\n`);
|
||||
expect(ff(fz, 'origin/main', { GSTACK_FF_LOCK_WAIT: '0' }).status).toBe(75);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fz.install], { encoding: 'utf8', env: isolatedEnv(fz.base), timeout: 60_000 }).status).toBe(6);
|
||||
} finally {
|
||||
holder.kill();
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a move interrupted after the tree was written but before HEAD moved still finishes on the next run, not held as
|
||||
// version-reverted; fails_when=the in-lock check ignores the target's VERSION; why_new=review pass 2; seam=none
|
||||
test('an interrupted move (tree at the target, HEAD old) finishes instead of holding', () => {
|
||||
const fx = makeFixture();
|
||||
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
|
||||
execFileSync('bash', ['-c', `git archive ${target} | tar -x`], { cwd: fx.install, timeout: 30_000 });
|
||||
expect(read(fx, 'VERSION')).toBe('1.1.0\n');
|
||||
backdate(fx, 'VERSION', 'notes.txt');
|
||||
const r = ff(fx, target);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
});
|
||||
|
||||
test('refusals: not a fast-forward, an unresolvable target, and a missing checkout', () => {
|
||||
const fx = makeFixture();
|
||||
write(fx, 'VERSION', 'local\n');
|
||||
|
||||
@@ -162,16 +162,17 @@ describe("gstack's own .gstack/ship-policy.json", () => {
|
||||
expect(raw.changelog_fragments.required_checks.filter((n: string) => !jobs.has(n))).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=the fill rule the coordinator relies on: a train dispatches at 3 READY PRs or one hour after the first READY, whichever comes first
|
||||
// (2026-10-26: 5 PRs / 3 hours left READY PRs waiting most of a day); fails_when=the live block drifts back to a larger or slower fill; why_new=the values were unpinned; seam=the policy file; no_claim=fillDue's arithmetic (ship-queue-train-logic)
|
||||
test('fills a train at 3 READY PRs or one hour after the first READY, whichever comes first', () => {
|
||||
// Value: protects=the fill rule the coordinator relies on: a train dispatches at 5 READY PRs or one hour after the first READY, whichever comes first
|
||||
// (2026-10-26: 5 PRs / 3 hours left READY PRs waiting most of a day, so the fill went to 3 PRs / 1 hour; 2026-10-31: Garry's B1 raised the size back to 5, keeping the 1-hour fill); fails_when=the live block drifts back to a larger or slower fill; why_new=the values were unpinned; seam=the policy file; no_claim=fillDue's arithmetic (ship-queue-train-logic)
|
||||
test('fills a train at 5 READY PRs or one hour after the first READY, whichever comes first', () => {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '..', '.gstack', 'ship-policy.json'), 'utf8'));
|
||||
expect([raw.train.max_size, raw.train.fill_hours]).toEqual([3, 1]);
|
||||
expect([raw.train.max_size, raw.train.fill_hours]).toEqual([5, 1]);
|
||||
const first = '2026-10-26T09:00:00.000Z';
|
||||
const slots = (n: number) => Array.from({ length: n }, (_, i) => ({ pr: i + 1, ready_at: new Date(Date.parse(first) + i * 60_000).toISOString() })) as any;
|
||||
expect(fillDue(slots(2), raw.train, '2026-10-26T09:59:00Z', false)).toBe('2026-10-26T10:00:00.000Z');
|
||||
expect(fillDue(slots(2), raw.train, '2026-10-26T10:00:00Z', false)).toBeNull();
|
||||
expect(fillDue(slots(3), raw.train, '2026-10-26T09:05:00Z', false)).toBeNull();
|
||||
expect(fillDue(slots(4), raw.train, '2026-10-26T09:05:00Z', false)).toBe('2026-10-26T10:00:00.000Z');
|
||||
expect(fillDue(slots(5), raw.train, '2026-10-26T09:05:00Z', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const REPO = path.resolve(import.meta.dir, '..');
|
||||
const DEPS = [
|
||||
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
|
||||
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
|
||||
'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh',
|
||||
'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-ff-update',
|
||||
];
|
||||
const hosts: CapyHost[] = [];
|
||||
const children: ChildProcess[] = [];
|
||||
@@ -40,7 +40,7 @@ const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.writeFileSync(file, text, { mode });
|
||||
};
|
||||
|
||||
function fixture() {
|
||||
function fixture(opts: { parent?: 'files' | 'gitlink' } = {}) {
|
||||
const host = capyHost({ systemBun: '1.4.2' });
|
||||
hosts.push(host);
|
||||
const git = (args: string[], cwd: string) => {
|
||||
@@ -68,8 +68,18 @@ function fixture() {
|
||||
const a = git(['rev-parse', 'HEAD'], seed);
|
||||
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
const root = path.join(host.base, 'gstack');
|
||||
const project = path.join(host.base, 'project');
|
||||
const root = opts.parent ? path.join(project, '.claude/skills/gstack') : path.join(host.base, 'gstack');
|
||||
fs.mkdirSync(path.dirname(root), { recursive: true });
|
||||
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
if (opts.parent) {
|
||||
// #1022: a project that committed the install's files as plain blobs (or,
|
||||
// the supported shape, as a submodule gitlink) beside the nested .git.
|
||||
git(['init', '-q', '-b', 'main'], project);
|
||||
if (opts.parent === 'files') git(['update-index', '--add', '--', ...git(['ls-files'], root).split('\n').map(f => `.claude/skills/gstack/${f}`)], project);
|
||||
else git(['update-index', '--add', '--cacheinfo', `160000,${a},.claude/skills/gstack`], project);
|
||||
git(['commit', '-qm', 'vendor gstack'], project);
|
||||
}
|
||||
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
||||
git(['commit', '-qam', 'B'], seed);
|
||||
const b = git(['rev-parse', 'HEAD'], seed);
|
||||
@@ -78,7 +88,7 @@ function fixture() {
|
||||
const run = (args: string[] = [], extra: Record<string, string> = {}) =>
|
||||
spawnSync('bash', [path.join(root, 'bin/gstack-upgrade'), ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 });
|
||||
return {
|
||||
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
|
||||
host, root, bare, a, b, run, project, git: (args: string[]) => git(args, root), parentGit: (args: string[]) => git(args, project),
|
||||
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
@@ -396,6 +406,70 @@ describe('gstack-upgrade', () => {
|
||||
expect(f.calls()).toEqual(['setup --refresh-registered', `doctor --check --require runtime,pins --project ${fs.realpathSync(project)}`]);
|
||||
});
|
||||
|
||||
// Value: protects=an install whose enclosing project tracks its files is refused before it moves, so a project checkout cannot revert VERSION behind an upgraded HEAD (#1022);
|
||||
// fails_when=the enclosing-project check is dropped or matches a submodule gitlink; why_new=no case had an enclosing repository; seam=none
|
||||
test('a project that tracks the install\'s files refuses --check and default with UPGRADE_REFUSED naming the untrack fix (#1022)', () => {
|
||||
const f = fixture({ parent: 'files' });
|
||||
const reflog = f.git(['reflog']);
|
||||
for (const args of [['--check'], []]) {
|
||||
const r = f.run(args);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(3);
|
||||
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
|
||||
expect(r.stderr).toContain(`${f.project} tracks`);
|
||||
expect(r.stderr).toContain(`git -C '${f.project}' --literal-pathspecs rm -r -q --cached -- '.claude/skills/gstack'`);
|
||||
expect(r.stderr.trim().split('\n').filter(l => l.startsWith('gstack-upgrade: '))).toHaveLength(1);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
expect(f.git(['reflog'])).toBe(reflog);
|
||||
}
|
||||
expect(f.calls()).toEqual([]);
|
||||
f.parentGit(['rm', '-r', '-q', '--cached', '--', '.claude/skills/gstack']);
|
||||
f.git(['stash', 'push', '-m', 'files reverted by the project']);
|
||||
const fixed = f.run();
|
||||
expect(fixed.status, fixed.stdout + fixed.stderr).toBe(0);
|
||||
expect(fixed.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
|
||||
});
|
||||
|
||||
// Value: protects=the supported vendoring shape (a submodule gitlink) still upgrades; fails_when=the #1022 check counts a gitlink as tracked files;
|
||||
// why_new=negative control for the case above; seam=none
|
||||
test('a project that holds the install as a submodule gitlink upgrades normally', () => {
|
||||
const f = fixture({ parent: 'gitlink' });
|
||||
const r = f.run();
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
// Value: protects=a working-tree VERSION that disagrees with HEAD is never reported current (the frozen VERSION of #1022);
|
||||
// fails_when=the current path trusts the VERSION file without comparing it to HEAD; why_new=every current case had a clean tree; seam=none
|
||||
test('a VERSION reverted behind HEAD is not current: --check and default refuse naming both versions (#1022)', () => {
|
||||
const f = fixture();
|
||||
expect(f.run(['--ff-only-no-reinstall']).status).toBe(0);
|
||||
fs.writeFileSync(path.join(f.root, 'VERSION'), '1.0.0\n');
|
||||
for (const args of [['--check'], []]) {
|
||||
const r = f.run(args);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(3);
|
||||
expect(r.stdout).not.toContain('gstack-upgrade: current');
|
||||
expect(r.stderr).toContain('VERSION says 1.0.0 but the checkout is at 1.0.1');
|
||||
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
|
||||
}
|
||||
expect(f.calls()).toEqual([]);
|
||||
f.git(['stash', 'push', '-q', '-m', 'files reverted by the project']);
|
||||
const fixed = f.run(['--check']);
|
||||
expect(fixed.status, fixed.stdout + fixed.stderr).toBe(0);
|
||||
expect(fixed.stdout).toContain('gstack-upgrade: current 1.0.1');
|
||||
});
|
||||
|
||||
// Value: protects=a Windows checkout with core.autocrlf (VERSION written CRLF) is not mistaken for a reverted VERSION;
|
||||
// fails_when=the VERSION check compares raw bytes instead of git's eol-normalized diff; why_new=the check is new; seam=none
|
||||
test('a CRLF VERSION under core.autocrlf=true is current, not reverted', () => {
|
||||
const f = fixture();
|
||||
expect(f.run(['--ff-only-no-reinstall']).status).toBe(0);
|
||||
f.git(['config', 'core.autocrlf', 'true']);
|
||||
fs.writeFileSync(path.join(f.root, 'VERSION'), '1.0.1\r\n');
|
||||
const r = f.run(['--check']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('gstack-upgrade: current 1.0.1');
|
||||
});
|
||||
|
||||
test('every bin a trailer or fix line names exists in the repo', () => {
|
||||
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
|
||||
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
|
||||
|
||||
@@ -1245,6 +1245,29 @@ describe('check-careful.sh PowerShell and cmd coverage (#3067)', () => {
|
||||
// ============================================================
|
||||
// check-freeze.sh tests
|
||||
// ============================================================
|
||||
// The freeze fix (#3623) made the shared extractor keep trailing newlines and
|
||||
// map lone surrogates on PATH fields only; a command must come out exactly as
|
||||
// before, on both extractor branches.
|
||||
describe('shared extractor keeps /careful command values unchanged (#3623)', () => {
|
||||
test.skipIf(process.platform === 'win32').each(['python3', 'node'])('with %s failing, a command still drops trailing newlines and nothing else', (broken) => {
|
||||
const shim = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-extract-shim-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(shim, broken), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
const env = { ...process.env, PATH: shim + path.delimiter + (process.env.PATH ?? '') };
|
||||
const payload = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'rm -rf /var/data \n# note\n\n\n' } });
|
||||
const r = spawnSync('bash', ['-c', '. "$1" && gstack_hook_extract_tool "$2" command && printf "%s|%s|%s" "$GSTACK_HOOK_TOOL" "$GSTACK_HOOK_FIELD" "$GSTACK_HOOK_VALUE"', 'x', HOOK_EXTRACT, payload], { env, encoding: 'utf-8', timeout: 5000 });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('Bash|command|rm -rf /var/data \n# note');
|
||||
|
||||
const hook = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data\n\n'), { PATH: env.PATH });
|
||||
expect(hook.output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(hook.output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
|
||||
} finally {
|
||||
fs.rmSync(shim, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('check-freeze.sh', () => {
|
||||
|
||||
describe('edits inside freeze boundary', () => {
|
||||
@@ -1466,6 +1489,351 @@ describe('check-freeze.sh', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary escape shapes (#3623)', () => {
|
||||
// The old resolver canonicalized only an EXISTING parent directory and
|
||||
// otherwise compared the raw string, so any path through a directory that
|
||||
// did not exist yet kept its literal in-boundary prefix: '..' segments and
|
||||
// symlinks in it were never applied. Each case names the escape shape.
|
||||
function decide(boundary: string, filePath: string, home: string, cwd?: string): string {
|
||||
let decision = '';
|
||||
withFreezeDir(boundary + '/', (stateDir) => {
|
||||
const r = runHook(FREEZE_SCRIPT, freezeInput(filePath), freezeEnv(stateDir, { HOME: home }), cwd);
|
||||
expect(r.exitCode).toBe(0);
|
||||
decision = r.output.hookSpecificOutput?.permissionDecision ?? (r.raw === '{}' ? 'allow' : r.raw);
|
||||
});
|
||||
return decision;
|
||||
}
|
||||
|
||||
function withTree(fn: (t: { base: string; home: string; src: string; outside: string }) => void) {
|
||||
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-esc-')));
|
||||
const home = path.join(base, 'home');
|
||||
const src = path.join(home, 'proj', 'src');
|
||||
const outside = path.join(base, 'outside');
|
||||
fs.mkdirSync(src, { recursive: true });
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
try {
|
||||
fn({ base, home, src, outside });
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('dotdot through a missing directory, a trailing dotdot, tilde and cwd-relative paths are resolved before the check', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
expect(decide(src, src + '/../../../outside/new-dir/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/..', home, base)).toBe('deny');
|
||||
expect(decide(src, '~/.zshrc', home, src)).toBe('deny');
|
||||
expect(decide(src, '../../new-dir/x.ts', home, src)).toBe('deny');
|
||||
|
||||
expect(decide(src, src + '/new-dir/x.ts', home, base)).toBe('allow');
|
||||
expect(decide(src, src + '/a/../b/x.ts', home, base)).toBe('allow');
|
||||
expect(decide(src, '~/proj/src/x.ts', home, base)).toBe('allow');
|
||||
expect(decide(src, 'new-dir/x.ts', home, src)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// A per-line sed squeezed slashes, so a newline split '..' off the next
|
||||
// name ('<b>/../\nfoo.ts' read as inside, landed in the parent), and the
|
||||
// extractor dropped a trailing one ('<b>\n' is a sibling of the boundary).
|
||||
// No real file name holds a line break, so any path with one denies.
|
||||
test('a line break in the path denies instead of hiding a dotdot or a sibling', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
expect(decide(src, src + '/../\nfoo.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/../\n/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '\n', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/new\r/x.ts', home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
// The walk is quadratic in path length, and a PreToolUse hook that times
|
||||
// out does not block the edit; an over-long path must deny fast.
|
||||
test('a path longer than PATH_MAX denies quickly instead of stalling the hook', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
const started = Date.now();
|
||||
expect(decide(src, src + '/a/..'.repeat(20000) + '/../outside/x.ts', home, base)).toBe('deny');
|
||||
expect(Date.now() - started).toBeLessThan(4000);
|
||||
expect(decide(src, src + '/a/..'.repeat(500) + '/x.ts', home, base)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// Link targets re-expand the walk, so their total is bounded too: a chain
|
||||
// of padded links must deny fast instead of running for minutes.
|
||||
test.skipIf(process.platform === 'win32')('a symlink chain whose targets add up past 8192 characters denies quickly', () => {
|
||||
withTree(({ base, home, src, outside }) => {
|
||||
// Each target pads with 'name/..' pairs (few components, so the walk
|
||||
// itself stays cheap) to about 3,800 characters; three hops pass 8192.
|
||||
const pad = ('n'.repeat(250) + '/../').repeat(15);
|
||||
fs.symlinkSync(pad + path.relative(src, outside), path.join(src, 'p3'));
|
||||
fs.symlinkSync(pad + 'p3', path.join(src, 'p2'));
|
||||
fs.symlinkSync(pad + 'p2', path.join(src, 'p1'));
|
||||
const started = Date.now();
|
||||
expect(decide(src, src + '/p1/x.ts', home, base)).toBe('deny');
|
||||
expect(Date.now() - started).toBeLessThan(4000);
|
||||
});
|
||||
});
|
||||
|
||||
test('Windows separators: a C:\\ dotdot escape through a missing directory denies, a new C:\\ subdirectory allows', () => {
|
||||
withTree(({ base, home }) => {
|
||||
expect(decide('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\..\\..\\other\\new\\x.ts', home, base)).toBe('deny');
|
||||
expect(decide('C:\\dev\\proj\\src', 'C:/dev/proj/src/../../other/new/x.ts', home, base)).toBe('deny');
|
||||
expect(decide('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\new\\x.ts', home, base)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// Windows never climbs above X:\ or \\server\share: 'C:\..\c\dev' is
|
||||
// C:\c\dev. A walker that popped the root would read it as C:\dev.
|
||||
test('dotdot stops at a drive root and a UNC share root', () => {
|
||||
withTree(({ base, home }) => {
|
||||
expect(decide('C:\\dev\\proj\\src', 'C:\\..\\c\\dev\\proj\\src\\x.ts', home, base)).toBe('deny');
|
||||
expect(decide('\\\\server.invalid\\share\\proj', '\\\\server.invalid\\share\\..\\..\\server.invalid\\share\\proj\\x.ts', home, base)).toBe('deny');
|
||||
expect(decide('\\\\server.invalid\\share\\proj', '\\\\server.invalid\\share\\proj\\new\\x.ts', home, base)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// Windows bash rules, run on any platform by setting OSTYPE before the
|
||||
// hook loads its helpers.
|
||||
function decideOnWindowsBash(boundary: string, filePath: string, home: string, cwd: string): string {
|
||||
let decision = '';
|
||||
withFreezeDir(boundary + '/', (stateDir) => {
|
||||
const r = spawnSync('bash', ['-c', 'OSTYPE=msys; . "$0"', FREEZE_SCRIPT], {
|
||||
input: JSON.stringify(freezeInput(filePath)),
|
||||
env: { ...process.env, ...freezeEnv(stateDir, { HOME: home }) },
|
||||
cwd,
|
||||
timeout: 5000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const raw = r.stdout.toString().trim();
|
||||
decision = raw === '{}' ? 'allow' : (JSON.parse(raw).hookSpecificOutput?.permissionDecision ?? raw);
|
||||
});
|
||||
return decision;
|
||||
}
|
||||
|
||||
test('Windows bash: drive-relative paths and names Windows trims are denied, fail closed', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
expect(decideOnWindowsBash(src, 'C:..\\..\\x.ts', home, src)).toBe('deny');
|
||||
expect(decideOnWindowsBash(src, 'C:x.ts', home, src)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\.. \\x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\...\\x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\c\\dev\\proj\\src\\x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\\\\\c\\..\\c\\dev\\proj\\src\\x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('\\\\server.invalid\\share\\proj', '\\\\server.invalid\\..\\share\\proj\\x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\\\.\\C:\\dev\\proj\\src\\x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', '///c/dev/proj/src/x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\\\/c/dev/proj/src/x.ts', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\', 'C:', home, base)).toBe('deny');
|
||||
expect(decideOnWindowsBash('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\new\\x.ts', home, base)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// Windows reads a link target of '\\x' against the link's drive root and
|
||||
// 'C:x' against drive C's own working directory; normalized as /x or C:/x,
|
||||
// either read as inside while the write landed elsewhere.
|
||||
test.skipIf(process.platform === 'win32')('Windows bash: a link target relative to a drive or its root denies, fail closed', () => {
|
||||
withTree(({ home, src }) => {
|
||||
fs.symlinkSync(src.split('/').join('\\'), path.join(src, 'rootrel'));
|
||||
fs.symlinkSync('C:x', path.join(src, 'driverel'));
|
||||
fs.symlinkSync('\\', path.join(src, 'bare'));
|
||||
fs.mkdirSync(path.join(src, 'real'));
|
||||
fs.symlinkSync(path.join(src, 'real'), path.join(src, 'fwd'));
|
||||
expect(decideOnWindowsBash(src, src + '/rootrel/x.ts', home, src)).toBe('deny');
|
||||
expect(decideOnWindowsBash(src, src + '/driverel/x.ts', home, src)).toBe('deny');
|
||||
expect(decideOnWindowsBash(src, src + '/bare' + src + '/x.ts', home, src)).toBe('deny');
|
||||
expect(decideOnWindowsBash(src, src + '/fwd/x.ts', home, src)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// Git Bash reaches one folder through several mounts (/tmp is %TEMP%, and
|
||||
// HOME converts through them), so '~/x' and 'D:\\...\\x' can name one file
|
||||
// in two spellings; the hook compares cygpath's drive spelling.
|
||||
test.skipIf(process.platform === 'win32')('Windows bash: a folder reached through a Git Bash mount compares by its drive spelling', () => {
|
||||
withTree(({ base, home, src, outside }) => {
|
||||
const bin = path.join(base, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const mnt = path.join(base, 'mnt');
|
||||
fs.writeFileSync(path.join(bin, 'cygpath'), [
|
||||
'#!/bin/bash',
|
||||
'case "$2" in',
|
||||
' "$FAKE_MNT"/in/*) printf "%s\\n" "$FAKE_IN${2#"$FAKE_MNT"/in}" ;;',
|
||||
' "$FAKE_MNT"/out/*) printf "%s\\n" "$FAKE_OUT${2#"$FAKE_MNT"/out}" ;;',
|
||||
' *) printf "%s\\n" "$2" ;;',
|
||||
'esac',
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const decideWithCygpath = (filePath: string): string => {
|
||||
let decision = '';
|
||||
withFreezeDir(src + '/', (stateDir) => {
|
||||
const r = spawnSync('bash', ['-c', 'OSTYPE=msys; . "$0"', FREEZE_SCRIPT], {
|
||||
input: JSON.stringify(freezeInput(filePath)),
|
||||
env: { ...process.env, ...freezeEnv(stateDir, { HOME: home }), PATH: bin + path.delimiter + (process.env.PATH ?? ''), FAKE_MNT: mnt, FAKE_IN: home, FAKE_OUT: outside },
|
||||
cwd: base,
|
||||
timeout: 5000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const raw = r.stdout.toString().trim();
|
||||
decision = raw === '{}' ? 'allow' : (JSON.parse(raw).hookSpecificOutput?.permissionDecision ?? raw);
|
||||
});
|
||||
return decision;
|
||||
};
|
||||
expect(decideWithCygpath(mnt + '/in/proj/src/x.ts')).toBe('allow');
|
||||
expect(decideWithCygpath(mnt + '/out/proj/src/x.ts')).toBe('deny');
|
||||
expect(decideWithCygpath(src + '/x.ts')).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// Linux and macOS read a leading '//' as '/', so neither a '//' path nor a
|
||||
// link to '//' may borrow a UNC boundary, and a link to '/' followed by
|
||||
// '/' stays a plain absolute path.
|
||||
test.skipIf(process.platform === 'win32')('POSIX: a double slash is the root, never a UNC prefix', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
fs.symlinkSync('//', path.join(base, 'dbl'));
|
||||
fs.symlinkSync('/', path.join(base, 'root'));
|
||||
const unc = '\\\\server.invalid\\share\\proj';
|
||||
expect(decide(unc, base + '/dbl/server.invalid/share/proj/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(unc, '//server.invalid/share/proj/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, base + '/root/' + src + '/x.ts', home, base)).toBe('allow');
|
||||
expect(decide(src, '/' + src + '/x.ts', home, base)).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// On POSIX '\\' is a filename character, so a name that spells '..' with
|
||||
// backslashes is one directory, not a way back into the boundary.
|
||||
test.skipIf(process.platform === 'win32')('a POSIX backslash name that spells dotdot never counts as inside', () => {
|
||||
withTree(({ base, home, src, outside }) => {
|
||||
expect(decide(src, outside + '/..\\home\\proj\\src\\x.ts', home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
// On POSIX, '\\/...' is a relative name, not a UNC path, and a Windows-
|
||||
// shaped path names no local file: neither may borrow Windows rules or
|
||||
// local symlinks to read as inside while Node writes elsewhere.
|
||||
test.skipIf(process.platform === 'win32')('POSIX: a backslash prefix never turns a relative escape into a UNC path inside the boundary', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
const bs = src.slice(1).split('/').join('\\');
|
||||
expect(decide(src, '\\\\/' + bs + '\\a\\b\\c/../../../outside/x.ts', home, src)).toBe('deny');
|
||||
|
||||
fs.mkdirSync(path.join(base, 'real', 'bnd'), { recursive: true });
|
||||
fs.symlinkSync(path.join(base, 'real'), path.join(base, 'alias'));
|
||||
const aliasBnd = path.join(base, 'alias', 'bnd');
|
||||
const abs = aliasBnd.slice(1).split('/').join('\\');
|
||||
expect(decide(aliasBnd, '\\\\' + abs + '\\a\\b/../../outside/x.ts', home, path.join(base, 'real', 'bnd'))).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
// The host resolves a relative path against its physical cwd; a logical
|
||||
// $PWD through a symlink names a different parent for '..'.
|
||||
test.skipIf(process.platform === 'win32')('a relative path resolves against the physical working directory', () => {
|
||||
withTree(({ base, home, src, outside }) => {
|
||||
const work = path.join(outside, 'work');
|
||||
fs.mkdirSync(work);
|
||||
fs.mkdirSync(path.join(src, 'a', 'b'), { recursive: true });
|
||||
fs.symlinkSync(work, path.join(src, 'work-link'));
|
||||
fs.symlinkSync(path.join(src, 'a', 'b'), path.join(outside, 'hop'));
|
||||
let decision = '';
|
||||
withFreezeDir(src + '/', (stateDir) => {
|
||||
const r = runHook(FREEZE_SCRIPT, freezeInput('../hop/../x.ts'), freezeEnv(stateDir, { HOME: home, PWD: path.join(src, 'work-link') }), work);
|
||||
decision = r.output.hookSpecificOutput?.permissionDecision ?? (r.raw === '{}' ? 'allow' : r.raw);
|
||||
});
|
||||
expect(decision).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
// Python used to write a lone UTF-16 surrogate as WTF-8 bytes while Node
|
||||
// writes U+FFFD, so the hook looked up a different name than the host
|
||||
// followed.
|
||||
test.skipIf(process.platform === 'win32')('a lone surrogate names the same file the host writes', () => {
|
||||
withTree(({ base, home, src, outside }) => {
|
||||
fs.symlinkSync(outside, path.join(src, 'a\uFFFD'));
|
||||
expect(decide(src, src + '/a\ud800/x.ts', home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
// The extractor tries python3, then node; with both on PATH a broken
|
||||
// python branch hides behind node, and CI never takes the node branch
|
||||
// that Windows hosts with a python3 Store stub run. Pin each one alone.
|
||||
test.skipIf(process.platform === 'win32').each(['python3', 'node'])('with %s failing, the other extractor branch still keeps a path trailing newline', (broken) => {
|
||||
withTree(({ base, home, src }) => {
|
||||
const shim = path.join(base, 'shim');
|
||||
fs.mkdirSync(shim);
|
||||
fs.writeFileSync(path.join(shim, broken), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
const env = { PATH: shim + path.delimiter + (process.env.PATH ?? '') };
|
||||
withFreezeDir(src + '/', (stateDir) => {
|
||||
const trailing = runHook(FREEZE_SCRIPT, freezeInput(src + '\n'), freezeEnv(stateDir, { HOME: home, ...env }), base);
|
||||
expect(trailing.output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(trailing.output.hookSpecificOutput?.permissionDecisionReason).toContain('line break');
|
||||
const inside = runHook(FREEZE_SCRIPT, freezeInput(src + '/x.ts'), freezeEnv(stateDir, { HOME: home, ...env }), base);
|
||||
expect(inside.raw).toBe('{}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Linux paths are case-sensitive: a case variant is another directory.
|
||||
// macOS volumes usually fold case, where the same denial is a safe false
|
||||
// deny that this test deliberately does not pin.
|
||||
test.skipIf(process.platform !== 'linux')('a case variant of the boundary is outside it on a case-sensitive filesystem', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
expect(decide(src, base + '/HOME/proj/src/x.ts', home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
// Windows CI runners lack Developer Mode, so symlinkSync throws EPERM there.
|
||||
test.skipIf(process.platform === 'win32')('symlinks at any depth are followed, even when the rest of the path does not exist', () => {
|
||||
withTree(({ base, home, src, outside }) => {
|
||||
fs.symlinkSync(outside, path.join(src, 'out-link'));
|
||||
fs.symlinkSync('../../../outside', path.join(src, 'up-link'));
|
||||
fs.symlinkSync(path.join(outside, 'not-yet'), path.join(src, 'dangle'));
|
||||
fs.mkdirSync(path.join(src, 'sub'));
|
||||
fs.symlinkSync(path.join(src, 'sub'), path.join(outside, 'in-link'));
|
||||
|
||||
expect(decide(src, src + '/out-link/new-dir/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/out-link/', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/up-link/new-dir/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/dangle/x.ts', home, base)).toBe('deny');
|
||||
// '..' after a symlink means the target's parent to the OS but the
|
||||
// link's parent to a lexical normalizer; both readings must be inside.
|
||||
expect(decide(src, src + '/out-link/../x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, outside + '/in-link/../x.ts', home, base)).toBe('deny');
|
||||
|
||||
expect(decide(src, outside + '/in-link/new-dir/x.ts', home, base)).toBe('allow');
|
||||
|
||||
// On POSIX a link target is POSIX text: 'C:\..' + src is one file
|
||||
// name beside the link, not drive C's parent.
|
||||
fs.symlinkSync('C:\\..' + src, path.join(outside, 'drive-text'));
|
||||
expect(decide(src, outside + '/drive-text/x.ts', home, base)).toBe('deny');
|
||||
// A link whose target ends in a newline is a sibling, not the target.
|
||||
fs.symlinkSync('../src\n', path.join(home, 'proj', 'nl-link'));
|
||||
expect(decide(src, path.join(home, 'proj', 'nl-link', 'x.ts'), home, base)).toBe('deny');
|
||||
fs.symlinkSync('/', path.join(src, 'root-link'));
|
||||
expect(decide(src, src + '/root-link' + outside + '/x.ts', home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a symlink that cannot be resolved (loop, chain over 40 hops) denies, fail closed', () => {
|
||||
withTree(({ base, home, src }) => {
|
||||
fs.symlinkSync(path.join(src, 'loop-b'), path.join(src, 'loop-a'));
|
||||
fs.symlinkSync(path.join(src, 'loop-a'), path.join(src, 'loop-b'));
|
||||
let prev = path.join(base, 'outside', 'not-yet');
|
||||
for (let i = 1; i <= 45; i++) {
|
||||
const link = path.join(src, `chain-${i}`);
|
||||
fs.symlinkSync(prev, link);
|
||||
prev = link;
|
||||
}
|
||||
expect(decide(src, src + '/loop-a/x.ts', home, base)).toBe('deny');
|
||||
expect(decide(src, src + '/chain-45/new-dir/x.ts', home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a boundary saved through a symlinked parent (macOS /tmp) allows a new subdirectory under either spelling', () => {
|
||||
withTree(({ base, home }) => {
|
||||
fs.mkdirSync(path.join(base, 'real', 'bnd'), { recursive: true });
|
||||
fs.symlinkSync(path.join(base, 'real'), path.join(base, 'alias'));
|
||||
const boundary = path.join(base, 'alias', 'bnd');
|
||||
expect(decide(boundary, boundary + '/new-dir/x.ts', home, base)).toBe('allow');
|
||||
expect(decide(boundary, path.join(base, 'real', 'bnd', 'new-dir', 'x.ts'), home, base)).toBe('allow');
|
||||
expect(decide(boundary, path.join(base, 'real', 'x.ts'), home, base)).toBe('deny');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -84,6 +84,30 @@ describe('allowlist: the secrets check', () => {
|
||||
expect(secrets(shape).status).toBe(EXIT.fail);
|
||||
});
|
||||
|
||||
// Value: protects=a pattern entry scoped to the finding that misses it says so, naming the column the pattern is tested from
|
||||
// (#3630: `session: HttpSession` as a pattern never matched, and the row was silent); fails_when=the miss is silent again or
|
||||
// the finding is dropped; why_new=the row is new; seam=none; no_claim=none
|
||||
test('a pattern entry that names the finding but misses its value prints a row and keeps the finding', () => {
|
||||
const line = 'session: HttpSessionStore\n';
|
||||
const missed = fixture({ ...BASE_PREGATE, allowlist: [{ finding: 'env.kv', paths: ['config.yaml'], pattern: 'session: HttpSessionStore', reason: 'a type name' }] });
|
||||
commit(missed, { 'config.yaml': line });
|
||||
expect(secrets(missed).status).toBe(EXIT.fail);
|
||||
expect(row(missed).lines.join('\n')).toContain('allowlist[0] env.kv pattern did not match env.kv (config.yaml, added line 1, col 10): a pattern is matched from where the flagged value starts, not from the name; the finding is kept');
|
||||
const value = fixture({ ...BASE_PREGATE, allowlist: [{ finding: 'env.kv', paths: ['config.yaml'], pattern: 'HttpSessionStore', reason: 'a type name' }] });
|
||||
commit(value, { 'config.yaml': line });
|
||||
expect(secrets(value).status).toBe(EXIT.ok);
|
||||
expect(row(value).detail).toContain('1 allowlisted (env.kv)');
|
||||
const other = fixture({ ...BASE_PREGATE, allowlist: [{ finding: 'env.kv', paths: ['config.yaml'], pattern: 'dummy-[0-9]+', reason: 'another value' }] });
|
||||
commit(other, { 'config.yaml': line });
|
||||
expect(secrets(other).status).toBe(EXIT.fail);
|
||||
expect(row(other).lines.join('\n')).not.toContain('pattern did not match');
|
||||
const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx';
|
||||
const py = fixture({ ...BASE_PREGATE, allowlist: [{ finding: 'env.kv', paths: ['app.py'], pattern: token, reason: 'a fixture' }] });
|
||||
commit(py, { 'app.py': `x = 1\nCONFIG = """\npassword: ${token}\n"""\n` });
|
||||
expect(secrets(py).status).toBe(EXIT.ok);
|
||||
expect(row(py).lines.join('\n')).toContain('allowlisted env.kv (app.py, line 3) by allowlist[0] env.kv: a fixture');
|
||||
});
|
||||
|
||||
// Value: protects=a category entry covers an advisory pii hit (a run id after a generic word, which the engine's run-word exemption does not cover, read as a phone number) and the WARN count drops;
|
||||
// fails_when=category matching is id-only; why_new=new; seam=none; no_claim=none
|
||||
test('a category entry silences the advisory pii finding for a run id', () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { parseAddedLines } from '../lib/pregate/checks/secrets';
|
||||
import { parseAddedLineNumbers, parseAddedLines } from '../lib/pregate/checks/secrets';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, BASE_PREGATE, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const PREGATE = path.resolve(import.meta.dir, '..', 'bin', 'gstack-pregate');
|
||||
const AWS = 'AKIA' + 'ZQ3XN7P2LM4RT8WV';
|
||||
@@ -58,6 +59,79 @@ describe('pregate secrets: source context and diff integrity', () => {
|
||||
expect(f.secrets().status).toBe('pass');
|
||||
});
|
||||
|
||||
test('a Python parameter annotation is code, not a secret; a quoted value on the same name still fails (#3630)', () => {
|
||||
expect(repo({ 'app.py': 'def fetch_days(\n *,\n session: HttpSession,\n start: int,\n) -> None:\n pass\n' }).secrets().status).toBe('pass');
|
||||
const r = repo({ 'app.py': 'SETTINGS = dict(\n session="8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ",\n)\nCONFIG = {\n "session": "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ",\n}\n' }).secrets();
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.lines.join('\n')).toContain('app.py, line 5)');
|
||||
});
|
||||
|
||||
test('parseAddedLineNumbers numbers each added line from its hunk header', () => {
|
||||
const diff = ['diff --git a/a.py b/a.py', '--- a/a.py', '+++ b/a.py', '@@ -3,0 +4,2 @@', '+one', '+two', '@@ -9 +11 @@', '-old', '+new'].join('\n');
|
||||
expect([...parseAddedLineNumbers(diff)]).toEqual([['a.py', [4, 5, 11]]]);
|
||||
});
|
||||
|
||||
test('a line added inside an existing triple-quoted block in a Python or Dart file still fails; a separate hunk keeps its own context (#3630)', () => {
|
||||
const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx';
|
||||
for (const [file, open, close] of [['app.py', 'CONFIG = """', '"""'], ['lib/config.dart', "const config = '''", "''';"]] as const) {
|
||||
const base = `def fetch(\n *,\n start: int,\n) -> None:\n pass\n\n${open}\nhost: db\n${close}\n`;
|
||||
const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { [file]: base } });
|
||||
try {
|
||||
commit(fx, { [file]: base.replace(' start: int,\n', ' session: HttpSession,\n start: int,\n').replace('host: db\n', `host: db\npassword: ${token}\n`) });
|
||||
const r = runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']);
|
||||
const check = JSON.parse(r.out).checks.find((c: { id: string }) => c.id === 'secrets');
|
||||
expect({ file, status: check.status }).toEqual({ file, status: 'fail' });
|
||||
expect((check.lines as string[]).filter((l) => l.includes('env.kv'))).toEqual([expect.stringContaining(`${file}, line 10)`)]);
|
||||
} finally { cleanup(fx); }
|
||||
}
|
||||
});
|
||||
|
||||
test('an untracked Python file reads annotations as code and still fails a literal, naming its line (#3630)', () => {
|
||||
const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx';
|
||||
const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'README.md': 'x\n' } });
|
||||
try {
|
||||
const secrets = () => JSON.parse(runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']).out).checks.find((c: { id: string }) => c.id === 'secrets');
|
||||
fs.writeFileSync(path.join(fx.work, 'app.py'), 'def f(\n session: HttpSession,\n) -> None:\n pass\n');
|
||||
expect(secrets().status).toBe('pass');
|
||||
fs.writeFileSync(path.join(fx.work, 'app.py'), `def f(\n session: HttpSession,\n) -> None:\n pass\n\nCONFIG = """\npassword: ${token}\n"""\n`);
|
||||
const check = secrets();
|
||||
expect({ status: check.status, rows: (check.lines as string[]).filter((l) => l.includes('env.kv')) }).toEqual({ status: 'fail', rows: [expect.stringContaining('app.py, line 7)')] });
|
||||
} finally { cleanup(fx); }
|
||||
});
|
||||
|
||||
test('a Python file whose working copy no longer holds the diff\'s added lines is scanned by its added lines (#3630)', () => {
|
||||
const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx';
|
||||
const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'app.py': 'x = 1\n' } });
|
||||
try {
|
||||
commit(fx, { 'app.py': `x = 1\nAPI_TOKEN = "${token}"\n` });
|
||||
fx.git('update-index', '--assume-unchanged', 'app.py');
|
||||
fs.writeFileSync(path.join(fx.work, 'app.py'), 'x = 1\ny = 2\n');
|
||||
const check = JSON.parse(runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']).out).checks.find((c: { id: string }) => c.id === 'secrets');
|
||||
expect({ status: check.status, kv: (check.lines as string[]).some((l) => l.includes('env.kv') && l.includes('app.py, added line 1')) }).toEqual({ status: 'fail', kv: true });
|
||||
} finally { cleanup(fx); }
|
||||
});
|
||||
|
||||
test('a one-line change to a Python file counts one added line (#3630)', () => {
|
||||
const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'app.py': 'x = 1\n'.repeat(50) } });
|
||||
try {
|
||||
commit(fx, { 'app.py': 'x = 1\n'.repeat(50) + 'y = 2\n' });
|
||||
const check = JSON.parse(runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']).out).checks.find((c: { id: string }) => c.id === 'secrets');
|
||||
expect({ status: check.status, detail: check.detail }).toEqual({ status: 'pass', detail: expect.stringMatching(/^1 added lines clean/) });
|
||||
} finally { cleanup(fx); }
|
||||
});
|
||||
|
||||
test('a Python file too large to read whole has its added lines scanned without the source exemption (#3630)', () => {
|
||||
const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx';
|
||||
const base = `CONFIG = """\nhost: db\n"""\n${'# pad\n'.repeat(800_000)}`;
|
||||
const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'app.py': base } });
|
||||
try {
|
||||
commit(fx, { 'app.py': base.replace('host: db\n', `host: db\npassword: ${token}\n`) });
|
||||
const r = runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']);
|
||||
const check = JSON.parse(r.out).checks.find((c: { id: string }) => c.id === 'secrets');
|
||||
expect({ status: check.status, kv: (check.lines as string[]).some((l) => l.includes('env.kv') && l.includes('app.py')) }).toEqual({ status: 'fail', kv: true });
|
||||
} finally { cleanup(fx); }
|
||||
});
|
||||
|
||||
test('the same assignment in a .env file still fails, naming the file', () => {
|
||||
const f = repo({ 'deploy.env': 'API_TOKEN=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ\n' });
|
||||
const r = f.secrets();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { frontmatterList } from '../lib/skill-triggers';
|
||||
import { QUESTIONS } from '../scripts/question-registry';
|
||||
import { generateUpgradeCheck } from '../scripts/resolvers/preamble/generate-upgrade-check';
|
||||
|
||||
@@ -151,3 +152,39 @@ describe('proactive core set: every surface names the same four skills and three
|
||||
expect(routerTmpl.replace(/\s+/g, ' ')).toContain('`gstack-config set proactive core` restores the default');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Value: protects=the four core skills, the ones gstack routes by plain speech
|
||||
* under the core default, carry their own `triggers:` phrases where hosts read
|
||||
* them: at least one in the Claude catalog one-liner, every one in the full
|
||||
* description other hosts keep, and no core one-liner phrase in another
|
||||
* skill's one-liner; fails_when=a core lead or description loses its trigger
|
||||
* phrases, or a trigger phrase also matches another skill's catalog entry;
|
||||
* why_new=#2118: `triggers:` was metadata no host reads, and no test tied it
|
||||
* to a description; seam=none; no_claim=how a host ranks or picks skills
|
||||
* (static text, no model run)
|
||||
*/
|
||||
describe('proactive core set: core skills carry their trigger phrases (#2118)', () => {
|
||||
const norm = (s: string) => s.replace(/\s+/g, ' ').toLowerCase();
|
||||
const description = (md: string): string => {
|
||||
const fm = md.slice(0, md.indexOf('\n---', 4));
|
||||
const block = fm.match(/^description:\s*\|\s*\n((?:[ \t]+.*\n|\n)+)/m)?.[1];
|
||||
return norm(block ?? fm.match(/^description:\s*(.+)$/m)?.[1]?.replace(/^"|"$/g, '') ?? '');
|
||||
};
|
||||
const skillDirs = fs.readdirSync(ROOT).filter(dir => fs.existsSync(path.join(ROOT, dir, 'SKILL.md.tmpl')) && fs.existsSync(path.join(ROOT, dir, 'SKILL.md')));
|
||||
const catalog = Object.fromEntries(skillDirs.map(dir => [dir, description(fs.readFileSync(path.join(ROOT, dir, 'SKILL.md'), 'utf-8'))]));
|
||||
|
||||
test.each(CORE.map(s => s.slice(1)))('%s: every trigger phrase is in its full description, and one is in its catalog one-liner that no other one-liner carries', (skill) => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md.tmpl'), 'utf-8');
|
||||
const triggers = frontmatterList(tmpl, 'triggers').map(norm);
|
||||
expect(triggers.length).toBeGreaterThan(0);
|
||||
const full = description(tmpl);
|
||||
for (const phrase of triggers) expect(full, `${skill} full description lacks "${phrase}"`).toContain(phrase);
|
||||
const inCatalog = triggers.filter(phrase => catalog[skill].includes(phrase));
|
||||
expect(inCatalog.length, `${skill} one-liner "${catalog[skill]}" carries none of ${triggers.join(', ')}`).toBeGreaterThan(0);
|
||||
for (const phrase of inCatalog) {
|
||||
const others = skillDirs.filter(dir => dir !== skill && catalog[dir].includes(phrase));
|
||||
expect(others, `"${phrase}" also in`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@
|
||||
* not know, and the config default when gstack-config is missing beside the
|
||||
* script; and the off-only surfaces (the first-run and first-loop tips, the
|
||||
* CLAUDE.md routing offer) fire under core and not under off, by running the
|
||||
* script; fails_when=gstack-skill-start stops consulting gstack-config, the
|
||||
* script, and the offered routing section is the core four under core and every
|
||||
* rule under all; fails_when=gstack-skill-start stops consulting gstack-config, the
|
||||
* default moves back to true, a legacy or junk value leaks into STATUS, the
|
||||
* missing-config fallback literal drifts from lookup_default, or a tip or the
|
||||
* routing offer fires under off; why_new=proactive-config.test.ts exercises
|
||||
@@ -28,6 +29,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { runBin } from './helpers/run-bin';
|
||||
import { readShippedSkillRouting } from './helpers/shipped-skill-routing';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
@@ -115,6 +117,36 @@ describe('gstack-skill-start PROACTIVE status line', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the offered section follows the setting: core offers the four core rules and the scoping line, all offers every rule (#3446)', () => {
|
||||
const offered = (config: string): string => {
|
||||
const state = freshState(config);
|
||||
fs.writeFileSync(path.join(state, '.proactive-prompted'), '');
|
||||
try {
|
||||
const out = runStart(path.join(BIN, 'gstack-skill-start'), state);
|
||||
const block = out.slice(out.indexOf('GSTACK_INSTRUCTION_BEGIN: routing-injection '));
|
||||
return block.slice(block.indexOf('## Skill routing\n'), block.indexOf('\nIf B:')).trim();
|
||||
} finally {
|
||||
fs.rmSync(state, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
const rulesOf = (section: string) => section.split('\n').filter(line => /^- .+→ invoke \//.test(line));
|
||||
const shipped = readShippedSkillRouting();
|
||||
const core = offered('update_check: false\n');
|
||||
expect(rulesOf(core)).toEqual([
|
||||
'- Product ideas/brainstorming → invoke /office-hours',
|
||||
'- QA/testing site behavior → invoke /qa',
|
||||
'- Code review/diff check → invoke /review',
|
||||
'- Ship/deploy/PR → invoke /ship',
|
||||
]);
|
||||
expect(core).toContain('Do not suggest or auto-invoke other gstack skills: they run when the user types them, or when a running skill hands off to them (`gstack-config set proactive all` routes every skill).');
|
||||
expect(core.split('\n').filter(line => line.includes('→'))).toEqual(rulesOf(core));
|
||||
expect(core.startsWith(shipped.instruction)).toBe(true);
|
||||
for (const config of ['update_check: false\nproactive: all\n', 'update_check: false\nproactive: true\n']) {
|
||||
expect(offered(config), config).toBe(shipped.section);
|
||||
}
|
||||
expect(rulesOf(shipped.section).length).toBeGreaterThan(4);
|
||||
}, 60_000);
|
||||
|
||||
test('a stored off answers the one-time proactive prompt: no prompt block, marker written; core still asks once telemetry was answered', () => {
|
||||
const cases: Array<[string, boolean]> = [
|
||||
['update_check: false\ntelemetry: off\n', true],
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* GST-543: a unified-diff line prefix never hides a finding. `git diff |
|
||||
* gstack-redact` and a `\`\`\`diff` fence in a PR body put `+`, `-` or a space
|
||||
* before every line; a line-anchored rule that stops at that character
|
||||
* misses the credential on an added line. Every pattern must report its
|
||||
* sample bare, behind each diff prefix and inside a real hunk.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
import { scan } from "../lib/redact-engine";
|
||||
import { PATTERNS } from "../lib/redact-patterns";
|
||||
|
||||
const j = (...p: string[]) => p.join("");
|
||||
const hex32 = "9f8e7d6c5b4a39281706f5e4d3c2b1a0";
|
||||
const SAMPLES: Record<string, string> = {
|
||||
"aws.access_key": j("AKIA", "ZQ3XN7P2LM4RT8WV"),
|
||||
"aws.secret_key": j("aws_secret_access_key = ", "Zq3Xn7P2Lm4Rt8Wv", "Bc5Df6Gh7Jk8Lm9Np0Qr1St2"),
|
||||
"github.pat": j("ghp_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
|
||||
"github.oauth": j("gho_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
|
||||
"github.server": j("ghs_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
|
||||
"github.fine_grained": j("github_pat_", "a1B2c3D4e5F6g7H8i9J0".repeat(4), "kL"),
|
||||
"gitlab.token": j("glpat-", "a1B2c3D4e5F6g7H8i9J0"),
|
||||
"groq.key": j("gsk_", "a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"tavily.key": j("tvly-", "a1B2c3D4e5F6g7H8i9J0"),
|
||||
"notion.token": j("ntn_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2"),
|
||||
"huggingface.token": j("hf_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6"),
|
||||
"npm.token": j("npm_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
|
||||
"digitalocean.token": j("dop_v1_", hex32, hex32),
|
||||
"gcp.service_account": j('{"private_key_id": "', hex32, '", "private_key": "-----BEGIN ', 'PRIVATE KEY-----"}'),
|
||||
"anthropic.key": j("sk-ant-", "api03-a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"openai.key": j("sk-proj-", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4"),
|
||||
"sendgrid.key": j("SG.", "a1B2c3D4e5F6g7H8i9J0k1", ".", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V"),
|
||||
"stripe.secret": j("sk_live_", "a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"slack.token": j("xoxb-", "12345", "67890-a1B2c3D4e5F6"),
|
||||
"slack.webhook": j("https://hooks.slack.com/services/", "T0ABCDEFG/B0ABCDEFG/", "a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"discord.webhook": j("https://discord.com/api/webhooks/", "123456789012345678/", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4y5Z6a7B8c9D0e1F2"),
|
||||
"twilio.auth_token": j("AC", hex32, " ", hex32.split("").reverse().join("")),
|
||||
"google.oauth_client_secret": j("GOCSPX-", "a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"telegram.bot_token": j("123456789", ":", "AAa1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q"),
|
||||
"pem.private_key": j("-----BEGIN RSA ", "PRIVATE KEY-----"),
|
||||
"db.url_with_password": j("postgres://admin:", "Xk9pQ2vLm7Rt", "@db.acme.io/prod"),
|
||||
"creds.basic_auth_url": j("https://deploy:", "Xk9pQ2vLm7Rt", "@ci.acme.io/hook"),
|
||||
"stripe.publishable": j("pk_live_", "a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"google.api_key": j("AIza", "SyA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q"),
|
||||
"jwt": ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0", "dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"].join("."),
|
||||
"env.kv": j("API_TOKEN=", "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"),
|
||||
"auth.bearer": j("Authorization: Bearer ", "a1B2c3D4e5F6g7H8i9J0k1L2"),
|
||||
"pii.email": j("dana.whitfield@", "acmecorp.io"),
|
||||
"pii.phone.e164": j("+1415", "5552671"),
|
||||
"pii.ssn": j("123-45", "-6789"),
|
||||
"pii.cc": j("4532 0151 ", "1283 0366"),
|
||||
"pii.ip_public": j("203.0.", "113.77"),
|
||||
"pii.wallet": j("0x529084000985", "27886E0F7030069857D2E4169EE7"),
|
||||
"internal.hostname": j("build-01.", "corp"),
|
||||
"internal.url_private": j("http://local", "host:8080/admin"),
|
||||
"legal.nda_marker": j("CONFIDEN", "TIAL"),
|
||||
"legal.named_criticism": j("Dana Whitfield was ", "fir", "ed"),
|
||||
"internal.user_path": j("/Us", "ers/dana/projects/app"),
|
||||
"hygiene.todo": j("TO", "DO(dana)"),
|
||||
};
|
||||
|
||||
const ids = (text: string) => scan(text, { repoVisibility: "public", processEnv: {} }).findings.map((f) => f.id);
|
||||
const hunk = (body: string) => ["diff --git a/x b/x", "--- a/x", "+++ b/x", "@@ -0,0 +1 @@", body, ""].join("\n");
|
||||
|
||||
describe("GST-543: a diff line prefix never hides a finding", () => {
|
||||
test("every pattern has a sample that reports bare", () => {
|
||||
expect(PATTERNS.map((p) => p.id).filter((id) => !SAMPLES[id])).toEqual([]);
|
||||
expect(PATTERNS.filter((p) => !ids(SAMPLES[p.id]!).includes(p.id)).map((p) => p.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test("each sample still reports behind +, - and a space, and on an added line inside a hunk", () => {
|
||||
const missed: string[] = [];
|
||||
for (const p of PATTERNS) {
|
||||
const s = SAMPLES[p.id]!;
|
||||
for (const [label, text] of [["+", "+" + s], ["-", "-" + s], ["space", " " + s], ["hunk", hunk("+" + s)], ["indented hunk", hunk("+ " + s)], ["indented removed", hunk("- " + s)], ["tab removed", hunk("-\t" + s)], ["combined ++", hunk("++" + s)], ["combined space+", hunk(" + " + s)], ["combined +-", hunk("+-" + s)]] as const)
|
||||
if (!ids(text).includes(p.id)) missed.push(`${p.id} (${label})`);
|
||||
}
|
||||
expect(missed).toEqual([]);
|
||||
});
|
||||
|
||||
test("env.kv reports an exported or indented credential on an added or removed line", () => {
|
||||
const v = SAMPLES["env.kv"]!.slice("API_TOKEN=".length);
|
||||
for (const line of [`-export API_TOKEN=${v}`, `+export API_TOKEN=${v}`, `- api_token: ${v}`])
|
||||
expect({ line, kv: ids(hunk(line)).includes("env.kv") }).toEqual({ line, kv: true });
|
||||
for (const bullet of ["- Credentials: `.auth.json`, `auth-token.json`", "- Session: `GSTACK_SESSION_KIND=unattended`", " - Session: `GSTACK_SESSION_KIND=unattended`", "-- Session: `GSTACK_SESSION_KIND=unattended`"])
|
||||
expect({ bullet, kv: ids(bullet).includes("env.kv") }).toEqual({ bullet, kv: false });
|
||||
});
|
||||
|
||||
test("git diff | gstack-redact reports an added env.kv credential and exits 2; a placeholder on the same line stays clean", () => {
|
||||
const run = (line: string) => spawnSync("bun", [path.join(import.meta.dir, "..", "bin", "gstack-redact"), "--json"], { input: hunk("+" + line), encoding: "utf8", timeout: 30_000 });
|
||||
const real = run(SAMPLES["env.kv"]!);
|
||||
expect({ status: real.status, ids: JSON.parse(real.stdout).findings.map((f: { id: string }) => f.id) }).toEqual({ status: 2, ids: ["env.kv"] });
|
||||
expect(run("API_TOKEN=changeme").status).toBe(0);
|
||||
});
|
||||
|
||||
test("the prefix changes nothing else: env.kv's exemptions hold on an added line", () => {
|
||||
for (const line of ["API_TOKEN=changeme", "cacheKey: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", "input_key: expected_media_rate_bits_per_second", 'api_key=os.environ["API_KEY"]', "TOKEN=${API_TOKEN}"]) {
|
||||
expect({ line, bare: ids(line).includes("env.kv") }).toEqual({ line, bare: false });
|
||||
for (const marker of ["+", "-", "+ ", "- ", "++", " +", "+-"]) expect({ line: marker + line, kv: ids(hunk(marker + line)).includes("env.kv") }).toEqual({ line: marker + line, kv: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -275,6 +275,86 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => {
|
||||
const jwt = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0", "dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"].join(".");
|
||||
expect(scan(`token: ${jwt},`, { repoVisibility: "private", sourcePath: "src/auth.ts" }).findings.map((f) => f.id)).toContain("jwt");
|
||||
});
|
||||
// #3630 (#3605, #3418, #3419) — the #3048 rule, extended: Dart and Python have
|
||||
// no unquoted string literal either, so a bare name chain after `:` is a name;
|
||||
// a JSX brace may join names with ?? || && as well as +. Each widening keeps a
|
||||
// negative control that still fires.
|
||||
test("env.kv skips Dart/Python source names and JSX logical joins, never a literal (#3630)", () => {
|
||||
const kv = (text: string, sourcePath?: string) =>
|
||||
scan(text, { repoVisibility: "private", ...(sourcePath ? { sourcePath } : {}) }).findings.some((f) => f.id === "env.kv");
|
||||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||||
const vIdent = "Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx";
|
||||
for (const [code, file] of [
|
||||
["def fetch_days(\n *,\n session: HttpSession,\n start: int,\n) -> None:\n pass", "app.py"],
|
||||
[" password: pydantic.SecretStr", "app/models.py"],
|
||||
[" password: pydantic.SecretStr", "app/models.pyi"],
|
||||
[' "password": request.password,', "app/api/handlers.py"],
|
||||
[' "api_key": settings.api_key,', "app/api/handlers.py"],
|
||||
[" 'password': request.password,", "backend/lib/src/handlers/onboarding_handlers.dart"],
|
||||
[" password: request.password,", "backend/lib/src/handlers/pfs_handlers.dart"],
|
||||
["const doc = '''usage''';\n password: request.password,", "backend/lib/src/config.dart"],
|
||||
["key={workout.id ?? index}", "src/SessionList.jsx"],
|
||||
["key={item.uuid || item.slug}", "src/List.tsx"],
|
||||
["key={row.ready && row.id}", "src/Grid.tsx"],
|
||||
// A trailing comment is not part of the expression.
|
||||
[" password: req.body.password // don't log it (see docs", "src/a.ts"], [" password: pydantic.SecretStr # the user's password, or", "app/models.py"],
|
||||
[" 'password': request.password // don't log", "lib/a.dart"],
|
||||
// A quote inside a comment opens nothing.
|
||||
["# it's a session\ndef f(\n session: HttpSession,\n): pass", "app.py"], ["/* it's a session */\n password: request.password,", "lib/a.dart"],
|
||||
// A literal in the next pair on the line belongs to that pair.
|
||||
[' "session": request.session, "mode": "readwrite-primary",', "app.py"],
|
||||
// Quotes that close before the name leave it code.
|
||||
[" login('it\\'s', password: request.password);", "lib/a.dart"], [' "user": "a", "password": request.password,', "app.py"],
|
||||
// The triple-quote guard is Dart/Python only: a stray ''' in a TS string leaves #3048 as it was.
|
||||
["const s = \"'''\";\n session: SessionState,", "src/a.ts"],
|
||||
] as const) expect({ code, file, kv: kv(code, file) }).toEqual({ code, file, kv: false });
|
||||
for (const [literal, file] of [
|
||||
[" session: HttpSession,", "deploy/values.yaml"],
|
||||
[" session: HttpSession,", undefined],
|
||||
[` session: "${v}",`, "app.py"],
|
||||
[` "password": "${v}",`, "app/api/handlers.py"],
|
||||
[` password = "${v}"`, "app/config.py"],
|
||||
[" password=request.password,", "app/api/handlers.py"],
|
||||
[" password = request.password", "app/api/handlers.py"],
|
||||
[`CONFIG = """\npassword: ${vIdent}\n"""`, "tests/conftest.py"],
|
||||
[` 'password': '${v}',`, "backend/lib/src/handlers/onboarding_handlers.dart"],
|
||||
[` password: "${v}",`, "backend/lib/src/config.dart"],
|
||||
[` password = "${v}";`, "backend/lib/src/config.dart"],
|
||||
[` password: ${v},`, "backend/lib/src/config.dart"],
|
||||
[`const fixture = '''\npassword: ${vIdent}\n''';`, "test/fixtures.dart"],
|
||||
[`key={settings.apiKey ?? "${v}"}`, "src/A.tsx"],
|
||||
[`key={${v} || index}`, "src/A.tsx"],
|
||||
["key={workout.id ?? index}", ".env"],
|
||||
// An annotation with a literal default is still a literal.
|
||||
[` api_token: AuthToken = "${v}"`, "app.py"], [` api_token: AuthToken="${v}"`, "app.py"],
|
||||
[` apiToken: AuthToken = "${v}";`, "src/a.ts"], [` apiToken: AuthToken = '${v}',`, "lib/a.dart"],
|
||||
// An escaped delimiter does not close the block.
|
||||
[`CONFIG = """\n\\"""\npassword: ${vIdent}\n"""`, "tests/conftest.py"],
|
||||
// A delimiter in a comment or a one-line string opens nothing, so the block after it is still a block.
|
||||
[`# don't use """ here\nCONFIG = """\npassword: ${vIdent}\n"""`, "tests/conftest.py"],
|
||||
[`DELIM = '"""'\nCONFIG = """\npassword: ${vIdent}\n"""`, "tests/conftest.py"],
|
||||
[`// no ''' here\nconst fixture = '''\npassword: ${vIdent}\n''';`, "test/fixtures.dart"],
|
||||
[`/* ''' */\nconst fixture = '''\npassword: ${vIdent}\n''';`, "test/fixtures.dart"],
|
||||
[`/* outer /* ''' */ still a comment ''' */\nconst fixture = '''\npassword: ${vIdent}\n''';`, "test/fixtures.dart"],
|
||||
[`const dir = r'''C:\\dir\\''';\nconst fixture = '''\npassword: ${vIdent}\n''';`, "test/fixtures.dart"],
|
||||
// An expression that continues on the next line may carry a literal there.
|
||||
[` api_token: AuthToken = (\n "${v}"\n )`, "app.py"],
|
||||
// A literal fallback after a name is still a literal.
|
||||
[` "password": settings.db_pass or "${v}",`, "app.py"], [` 'password': request.password ?? '${v}',`, "lib/a.dart"],
|
||||
// A one-line string continued by a backslash spans lines.
|
||||
[`CFG = "db: \\\npassword: ${vIdent} \\\n"`, "app.py"],
|
||||
// A key and value inside one quoted string are text, not a dict entry.
|
||||
[` 'password: ${vIdent} more',`, "lib/a.dart"], [` "password: ${vIdent} more",`, "app.py"],
|
||||
] as const) expect({ literal, file, kv: kv(literal, file) }).toEqual({ literal, file, kv: true });
|
||||
});
|
||||
test("env.kv's source-syntax check stays linear on a match-dense file (#3630)", () => {
|
||||
for (const file of ["app.py", "src/a.ts"]) {
|
||||
const text = "def f(\n" + " session: HttpSession,\n".repeat(12_000) + "): pass\n";
|
||||
const t0 = performance.now();
|
||||
const found = scan(text, { repoVisibility: "private", sourcePath: file }).findings.filter((f) => f.id === "env.kv").length;
|
||||
expect({ file, found, fast: performance.now() - t0 < 3_000 }).toEqual({ file, found: 0, fast: true });
|
||||
}
|
||||
});
|
||||
// #2912 — a line that READS a secret from the environment holds no secret;
|
||||
// it must not fire (and so must not be masked or withhold a /cso source file).
|
||||
test("env.kv skips exact environment reads (#2912)", () => {
|
||||
|
||||
@@ -761,4 +761,16 @@ describe("#3048: the pushed diff is scanned per file, so TS/JSX source syntax is
|
||||
const yamlHead = git(["rev-parse", "HEAD"]);
|
||||
expect(mediums(runHook(`refs/heads/main ${yamlHead} refs/heads/main ${tsHead}\n`).stderr)).toBe(1);
|
||||
});
|
||||
// #3630: the hook sees added lines alone, so a Python line added inside an existing """ block has no opener in view;
|
||||
// Dart and Python lines are scanned without their path and still warn.
|
||||
test("a line added inside an existing Python triple-quoted block still warns", () => {
|
||||
fs.writeFileSync(path.join(repo, "settings.py"), 'CONFIG = """\nhost: db\n"""\n');
|
||||
git(["add", "settings.py"]);
|
||||
git(["commit", "-q", "-m", "base"]);
|
||||
const base = git(["rev-parse", "HEAD"]);
|
||||
fs.writeFileSync(path.join(repo, "settings.py"), 'CONFIG = """\nhost: db\npassword: VelvetRiverOrbitSunset\n"""\n');
|
||||
git(["add", "settings.py"]);
|
||||
git(["commit", "-q", "-m", "secret"]);
|
||||
expect(mediums(runHook(`refs/heads/main ${git(["rev-parse", "HEAD"])} refs/heads/main ${base}\n`).stderr)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,14 @@ describe('gstack-review-dispatch render: briefs from the sections, slots only',
|
||||
expect(run(['render', '--role', 'specialist', '--name', 'no-such-specialist', '--out', out, '--diff-base', SHA]).stderr).toContain('BRIEF_RENDER_FAILED: checklist not found');
|
||||
});
|
||||
|
||||
// Value: protects=a brief-render refusal reads as one clean line, not a crash (GST-507 moved the bin to process.exitCode);
|
||||
// fails_when=the BriefError handler falls through to its rethrow and Bun dumps source and a stack after the line; seam=gstack-review-dispatch render
|
||||
test('a BriefError prints only its BRIEF_RENDER_FAILED line and exits 1', () => {
|
||||
const r = run(['render', '--role', 'specialist', '--name', 'no-such-specialist', '--out', tmp(), '--diff-base', SHA]);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr.trim().split('\n')).toEqual([expect.stringMatching(/^BRIEF_RENDER_FAILED: checklist not found/)]);
|
||||
});
|
||||
|
||||
// Value: protects=the lean ensemble's one batched reader (GSTA-167 part 2) gets checklist paths when the parent passes the plan's
|
||||
// checklists= names as printed; fails_when=the brief names bare checklist words a reader cannot open, or an unknown name
|
||||
// renders; seam=gstack-review-dispatch render
|
||||
|
||||
@@ -226,6 +226,25 @@ describe('the targeted reader in a pass (GST-257 on main)', () => {
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('a large diff (GST-507)', () => {
|
||||
// Value: protects=a pass on a results PR with thousands of files reads gstack-review-plan's whole JSON plan through the pipe and stages its readers; fails_when=either bin exits before its stdout drains, so the plan JSON arrives cut short and pass dies on JSON.parse;
|
||||
// why_new=HARN-1 hit it on a 4,000-file receipt tree (a 1.39 MB plan); seam=none; no_claim=what reviewers read on a large diff (GST-508)
|
||||
test('a 4,000-file diff round-trips a plan JSON over 1 MB and pass stages its readers', () => {
|
||||
const { dir, env } = repo();
|
||||
const files = 4000;
|
||||
const made = spawnSync('bash', ['-c', `for d in $(seq 1 40); do p=receipts/harness-evals-lane-b-results-run-$d/per-case-receipts/skill-behavior-panel; mkdir -p $p; for i in $(seq 1 100); do echo "{\\"case\\":$i}" > $p/receipt-for-case-number-$i-trial-a.json; done; done && git add -A && git commit -qm receipts`], { cwd: dir, env, encoding: 'utf8', timeout: 120_000 });
|
||||
expect(made.status, made.stderr).toBe(0);
|
||||
const plan = spawnSync(path.join(BIN, 'gstack-review-plan'), ['--base', 'main', '--json'], { cwd: dir, env, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
expect(plan.status, plan.stderr).toBe(0);
|
||||
expect(plan.stdout.length).toBeGreaterThan(1_000_000);
|
||||
expect((JSON.parse(plan.stdout) as { paths: string[] }).paths.filter(p => p.startsWith('receipts/'))).toHaveLength(files);
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
const p = spawnSync(path.join(BIN, 'gstack-review-dispatch'), ['pass', '--base', 'main', '--out', out, '--pass', 'big-p1'], { cwd: dir, env, encoding: 'utf8', timeout: 180_000 });
|
||||
expect(p.status, p.stdout + p.stderr).toBe(0);
|
||||
expect(p.stdout).toMatch(/^MANIFEST: file=.* pass=big-p1 readers=[1-9]/m);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe('Capy pass program (fake host, real bins)', () => {
|
||||
test('gstack-review-dispatch program serves the checked-in file verbatim with its sha', () => {
|
||||
const r = spawnSync(path.join(BIN, 'gstack-review-dispatch'), ['program'], { encoding: 'utf8', timeout: 30_000 });
|
||||
@@ -470,6 +489,63 @@ describe('the Capy skill loader', () => {
|
||||
});
|
||||
|
||||
|
||||
describe('a scoped pass after a blocker fix (GST-619)', () => {
|
||||
// Value: protects=the pass command accepts the scoped plan gstack-review-plan writes after a blocking fix, invoked as GEVA-2 ran it: the Red Team reviews the fix range, Step 11's voices still pending keep the whole branch, the fix-check is named outside the manifest for Step 3.5, and a resumed pass names it again; fails_when=pass refuses the scoped plan, stages the Red Team on the merge base, stages pending Step 11 voices on the range, drops the fix-check silently, or puts it in the manifest;
|
||||
// why_new=GEVA-82 on 1.91.379.0 hit BRIEF_RENDER_FAILED on every scoped pass and rendered briefs by hand; seam=none; no_claim=the fix-check's own brief
|
||||
function scoped(flags: string[]) {
|
||||
const { dir, env } = repo();
|
||||
const sh = (c: string) => { const r = spawnSync('bash', ['-c', c], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(r.stderr); return r.stdout.trim(); };
|
||||
const mergeBase = sh('git merge-base main HEAD');
|
||||
const rangeBase = sh('git rev-parse HEAD');
|
||||
sh('for i in $(seq 1 24); do echo "export const f$i = $i;" >> src/fix.ts; done && git add -A && git commit -qm "fix: pre-landing review fixes"');
|
||||
const out = path.join(dir, '.state/run/p2');
|
||||
const extras = path.join(dir, '.state/extras.json');
|
||||
fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.state/qa.md'), 'QA the fix.\n');
|
||||
fs.writeFileSync(extras, JSON.stringify([{ name: 'qa', prompt_file: path.join(dir, '.state/qa.md') }]));
|
||||
const args = ['pass', '--base', 'main', '--out', out, '--pass', 'p2', '--extras', extras, '--', '--pass', '1', '--last', 'full', '--pass1-depth', 'standard', '--fixed-blocking', ...flags, '--range-base', rangeBase.slice(0, 7)];
|
||||
const p = bin(dir, env, args);
|
||||
expect(p.status, p.stdout + p.stderr).toBe(0);
|
||||
expect(p.stdout).toMatch(/^NEXT: scoped /m);
|
||||
expect(p.stdout).toMatch(/^FIX_CHECK: planned effort=full \(Step 3\.5 runs it on its own; not in this manifest\)$/m);
|
||||
const mf = path.join(out, 'pass-manifest.json');
|
||||
return { dir, env, args, mf, mergeBase, rangeBase, m: JSON.parse(fs.readFileSync(mf, 'utf8')) as Manifest };
|
||||
}
|
||||
const settle = (s: ReturnType<typeof scoped>, stage: string) => {
|
||||
if (stage !== 'specialists') expect(bin(s.dir, s.env, ['stage', '--manifest', s.mf, '--stage', stage]).status).toBe(0);
|
||||
const m = JSON.parse(fs.readFileSync(s.mf, 'utf8')) as Manifest;
|
||||
const reply = path.join(s.dir, '.state/reply.txt');
|
||||
fs.writeFileSync(reply, treeLine(s.dir) + 'NO FINDINGS\n');
|
||||
for (const r of m.readers.filter(x => x.stage === stage)) for (const [st, extra] of [['intent', []], ['created', ['--path', '1']], ['collected', r.reader.startsWith('audit/') ? ['--output', reply] : []]] as const) expect(bin(s.dir, s.env, ['mark', '--manifest', s.mf, '--reader', r.reader, '--state', st, ...extra]).status).toBe(0);
|
||||
return JSON.parse(fs.readFileSync(s.mf, 'utf8')) as Manifest;
|
||||
};
|
||||
|
||||
test('GEVA-2\'s invocation: the Red Team reads the fix range, the pending Step 11 voices read the whole branch, and the fix-check is named outside the manifest', () => {
|
||||
const s = scoped([]);
|
||||
expect(s.m.next).toBe('scoped');
|
||||
expect(s.m.diff_base).toBe(s.mergeBase);
|
||||
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/qa', null]]);
|
||||
settle(s, 'specialists');
|
||||
const m = settle(s, 'red-team');
|
||||
const redTeam = fs.readFileSync(m.readers.find(r => r.reader === 'red-team')!.file!, 'utf8');
|
||||
expect(redTeam).toContain(s.rangeBase);
|
||||
expect(redTeam).not.toContain(s.mergeBase);
|
||||
expect(bin(s.dir, s.env, ['stage', '--manifest', s.mf, '--stage', 'voices']).status).toBe(0);
|
||||
const voices = JSON.parse(fs.readFileSync(s.mf, 'utf8')) as Manifest;
|
||||
expect(fs.readFileSync(voices.readers.find(r => r.reader === 'outside-voice')!.file!, 'utf8')).toContain('+export const v1 = 1;');
|
||||
const again = bin(s.dir, s.env, s.args);
|
||||
expect(again.status, again.stdout + again.stderr).toBe(0);
|
||||
expect(again.stdout).toMatch(/^FIX_CHECK: planned effort=full /m);
|
||||
expect(again.stdout).toContain('readers=4 resumed');
|
||||
}, 120_000);
|
||||
|
||||
test('after Step 11 ran (--adversarial-ran), the whole scoped pass reads the fix range', () => {
|
||||
const s = scoped(['--adversarial-ran']);
|
||||
expect(s.m.diff_base).toBe(s.rangeBase);
|
||||
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/qa', null]]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('readers on a shared machine review the checkout the pass ships (GST-482)', () => {
|
||||
/** The primary checkout on feat, and a worktree of it on another branch with one more commit: the GBRA-218 layout. */
|
||||
function worktree() {
|
||||
|
||||
@@ -166,10 +166,62 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update: held-updat
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
});
|
||||
|
||||
// Value: protects=the auto-updater holds, instead of rescuing and re-updating forever, an install whose enclosing project tracks its files (#1022);
|
||||
// fails_when=gstack-ff-update's exit 6 is not a hold, or the banner's command does not print the full fix; why_new=no case had an enclosing project; seam=none
|
||||
test('tracked-by-project: the update holds with HEAD unchanged; the banner names the preflight, which prints the full fix', async () => {
|
||||
const fx = makeFixture();
|
||||
const old = git(fx.install, 'rev-parse', 'HEAD');
|
||||
release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
git(fx.base, 'update-index', '--add', '--', ...git(fx.install, 'ls-files').split('\n').map((f) => `it's an install/${f}`));
|
||||
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
|
||||
await waitFor(fx, /Z HELD tracked-by-project: /);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
|
||||
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
|
||||
const lines = banner(fx).trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toStartWith(`gstack auto-update: update held (tracked-by-project: ${fx.base} tracks`);
|
||||
expect(lines[0]).toContain('troubleshooting.md#auto-update-tracked-by-project');
|
||||
const cmd = lines[0].match(/The full fix: (bash .*?)\. https:/)![1];
|
||||
const r = run(fx, cmd);
|
||||
expect(r.status).toBe(6);
|
||||
expect(r.stdout).toContain(`git -C ${quoted(fx.base)} --literal-pathspecs rm -r -q --cached -- ${quoted("it's an install")}`);
|
||||
});
|
||||
|
||||
// Value: protects=a project holding the install as a submodule gitlink keeps auto-updating; fails_when=the #1022 check counts a gitlink;
|
||||
// why_new=negative control for the case above; seam=none
|
||||
test('a gitlink in the enclosing project is not a hold: the update lands', async () => {
|
||||
const fx = makeFixture();
|
||||
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
git(fx.base, 'update-index', '--add', '--cacheinfo', `160000,${git(fx.install, 'rev-parse', 'HEAD')},it's an install`);
|
||||
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
|
||||
await waitFor(fx, /Z UPDATED /);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
});
|
||||
|
||||
// Value: protects=a VERSION behind HEAD with no enclosing project holds (not rescued) and its preflight prints the stash fix;
|
||||
// fails_when=session-update's exit-6 arm drops version-reverted; why_new=review pass 2; seam=none
|
||||
test('version-reverted: the update holds with HEAD unchanged; the preflight prints the stash fix', async () => {
|
||||
const fx = makeFixture();
|
||||
const old = git(fx.install, 'rev-parse', 'HEAD');
|
||||
release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
|
||||
fs.writeFileSync(path.join(fx.install, 'VERSION'), '0.0.1\n');
|
||||
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
|
||||
await waitFor(fx, /Z HELD version-reverted: /);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
|
||||
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
|
||||
const line = banner(fx).trim();
|
||||
expect(line).toStartWith('gstack auto-update: update held (version-reverted: VERSION says 0.0.1 but the checkout is at 1.0.0');
|
||||
const r = run(fx, line.match(/The full fix: (bash .*?)\. https:/)![1]);
|
||||
expect(r.status).toBe(6);
|
||||
expect(r.stdout).toContain(`git -C ${quoted(fx.install)} stash push`);
|
||||
});
|
||||
|
||||
test('every anchor the lines link to exists', () => {
|
||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||
const anchors = [...src.matchAll(/troubleshooting\.md#([a-z0-9-]+)/g)].map((m) => m[1]!);
|
||||
for (const a of ['auto-update-unmerged-files', 'auto-update-autostash-unrecoverable', 'auto-update-rescued-edits']) expect(anchors).toContain(a);
|
||||
for (const a of ['auto-update-unmerged-files', 'auto-update-autostash-unrecoverable', 'auto-update-rescued-edits', 'auto-update-tracked-by-project']) expect(anchors).toContain(a);
|
||||
for (const a of anchors) expect(DOC, a).toContain(`<a id="${a}"></a>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": -21,
|
||||
"why": "GSTA-225 (#3446/#2118): ship's catalog lead names its trigger phrases (ship it, create a PR) in fewer words"
|
||||
}
|
||||
@@ -1794,22 +1794,28 @@ describe('Skill trigger phrases', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Skills with proactive triggers should have "Proactively suggest" somewhere in the skill.
|
||||
const SKILLS_REQUIRING_PROACTIVE = [
|
||||
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
||||
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
||||
'design-review', 'design-consultation', 'retro', 'document-release',
|
||||
];
|
||||
// Only the four core skills (the proactive: core default) may invite unasked
|
||||
// suggestions; a non-core description that says "Proactively suggest" reaches
|
||||
// every host that keeps full descriptions, under core too (#3446).
|
||||
const SKILLS_REQUIRING_PROACTIVE = ['qa', 'ship', 'review', 'office-hours'];
|
||||
|
||||
for (const skill of SKILLS_REQUIRING_PROACTIVE) {
|
||||
test(`${skill}/SKILL.md.tmpl has proactive routing phrase`, () => {
|
||||
const skillPath = path.join(ROOT, skill, 'SKILL.md.tmpl');
|
||||
if (!fs.existsSync(skillPath)) return;
|
||||
const content = fs.readFileSync(skillPath, 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md.tmpl'), 'utf-8');
|
||||
// Same catalog-trim consideration — check the template description.
|
||||
expect(content).toMatch(/Proactively (suggest|invoke)/i);
|
||||
});
|
||||
}
|
||||
|
||||
test('no non-core skill description invites proactive suggestions (#3446)', () => {
|
||||
const offenders = fs.readdirSync(ROOT)
|
||||
.filter(dir => !SKILLS_REQUIRING_PROACTIVE.includes(dir) && fs.existsSync(path.join(ROOT, dir, 'SKILL.md.tmpl')))
|
||||
.filter(dir => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, dir, 'SKILL.md.tmpl'), 'utf-8');
|
||||
return /Proactively (suggest|invoke)/i.test(tmpl.slice(0, tmpl.indexOf('\n---', 4)));
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Private-path leak detector ──────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user