mirror of https://github.com/garrytan/gstack.git
feat: bug fix wave v1.36.0.0 — Implementation Tasks, allowlist patterns, surrogate-safe page captures (#1440 #1452 #1454)
Three filed issues land together: #1440 — Page captures from real-world HTML hit 'API Error 400: no low surrogate in string'. Sanitizers + buildCommandResponse extraction shipped in the prior commit; this commit adds the migration script that patches existing brain-allowlist/privacy-map/gitattributes installs and the supporting tests. #1452 — Federation sync was silently skipping root-level design and test-plan docs. bin/gstack-artifacts-init adds two patterns to all three managed blocks (.brain-allowlist, .brain-privacy-map.json, .gitattributes). Idempotent migration v1.36.0.0.sh repairs existing installs in place via jq (preserves JSON validity) — no commit + push from the migration. #1454 — All four review skills (CEO/design/eng/DX) emit an Implementation Tasks markdown section AND write a jq-built JSONL artifact per phase. /autoplan reads all four files, scopes by current branch + 5-commit window, dedupes on exact (component, sorted(files), title), and renders an aggregated list in the Final Approval Gate. New tests: - browse/test/sanitize.test.ts (18 cases) - browse/test/build-command-response.test.ts (7 cases) - test/artifacts-init-migration.test.ts (7 cases) VERSION → 1.36.0.0. Skips the v1.34.x slot taken by 'gstack consumable as submodule' and the v1.35.0.0 slot taken by /document-generate. #1428 was shipped separately by v1.34.2.0 with a different approach; follow-up #1503 filed for the bare-path filesystem boundary concern surfaced during our analysis. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
bdb6023713
commit
9dd3f0c3b5
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -1,5 +1,61 @@
|
|||
# Changelog
|
||||
|
||||
## [1.36.0.0] - 2026-05-14
|
||||
|
||||
## **Page captures stop crashing on broken emoji bytes, every review skill ends with a build-actionable task checklist, federation sync no longer drops office-hours design docs.**
|
||||
## **Three community-filed issues landed as one bug-fix wave: surrogate-safe browse responses (including `/batch`), per-skill Implementation Tasks with JSONL handoff to `/autoplan`, and root-level artifact patterns in `.brain-allowlist`.**
|
||||
|
||||
Page captures from real-world HTML hit `API Error 400: no low surrogate in string` when a page contains unpaired Unicode surrogate bytes (broken emoji, mid-character splits). Browse now sanitizes at the response chokepoint (`buildCommandResponse` in `server.ts`) AND at the `/batch` envelope, with separate utilities for raw UTF-16 surrogates and `\uXXXX` JSON escape sequences. Defense in depth wraps the highest-volume DOM-text emitters (`text`, `html`, `accessibility`, `snapshot`) at extraction time too. Every command response is now safe to JSON.stringify for the Claude API; the bug surfaces from `$B html`, `$B accessibility`, and `$B batch` are all closed, not just `$B text`. The chokepoint refactor extracts `buildCommandResponse` from `handleCommand` so the boundary is unit-testable without spinning up the server.
|
||||
|
||||
All four review skills (CEO / design / eng / DX) now end with an `## Implementation Tasks` markdown checklist and write a `jq`-built JSONL artifact to `~/.gstack/projects/$SLUG/tasks-{phase}-{datetime}.jsonl`. `/autoplan`'s Phase 4 reads all four files, scopes by current branch + 5-commit window, dedupes on exact `(component, sorted(files), title)` matches, and renders one aggregated list inside the final approval gate. Tasks that derive from the same finding now collapse; tasks that just happen to touch the same file with different titles surface separately so the human can decide whether they're the same work. Standalone review runs (`/plan-eng-review` alone, etc.) produce their own task list and JSONL file even outside autoplan — the JSONL is the handoff contract.
|
||||
|
||||
Federation sync (`gstack-brain-sync`) was silently skipping root-level design and test-plan docs — `/office-hours` and `/plan-eng-review` write at `projects/{slug}/{user}-{branch}-design-*.md`, but the allowlist only knew about `projects/*/designs/*.md` and `projects/*/ceo-plans/*.md`. New patterns ship in `.brain-allowlist`, `.brain-privacy-map.json` (classified as `artifact`), and `.gitattributes` (with `merge=union` to handle cross-machine conflicts). An idempotent jq-based migration (`gstack-upgrade/migrations/v1.36.0.0.sh`) patches existing installs in-place without re-running `gstack-artifacts-init` (which would have done a git commit + push and clobbered user state).
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: `bun test browse/test/sanitize.test.ts browse/test/build-command-response.test.ts test/artifacts-init-migration.test.ts` — 32 new unit tests covering every fix surface, all green.
|
||||
|
||||
| Surface | Before | After |
|
||||
|---|---|---|
|
||||
| API 400 from `$B text` on surrogate-containing page | Crash | Sanitized at extraction + chokepoint |
|
||||
| API 400 from `$B html`, `$B accessibility`, `$B batch` | Crash (chokepoint bypassed) | Sanitized at `buildCommandResponse` + `/batch` envelope |
|
||||
| Application/json bodies with `\uXXXX` escape surrogates | Still crash (regex matches raw codepoints only) | Second-pass `stripLoneSurrogateEscapes` handles escape text |
|
||||
| `/autoplan` final output | Decision summary, no task list | Decision summary **plus** aggregated `Implementation Tasks` from all 4 phases |
|
||||
| Standalone `/plan-eng-review` output | Required-outputs sections, no task list | Same **plus** per-skill `Implementation Tasks` + JSONL handoff |
|
||||
| `/office-hours` design docs in federation queue | Silently skipped (root-level not in allowlist) | Queued, classified `artifact`, union-merge rule applied |
|
||||
| Lone surrogate sanitizer perf on 1MB clean text | n/a | <500ms (single regex pass) |
|
||||
| `buildCommandResponse` testability | Embedded inside `handleCommand`, not exported | Extracted, exported, 7 unit tests cover it |
|
||||
|
||||
### What this means for builders
|
||||
|
||||
Page captures with mixed-script Unicode round-trip cleanly to the Claude API now. Every review skill you run ends with a checkbox list of build tasks you can hand to Claude Code or Codex. Federation sync picks up the design docs that were silently dropping out of your brain repo. Run `/gstack-upgrade` to pick up the migration that patches your `.brain-allowlist`, `.brain-privacy-map.json`, and `.gitattributes` in place; no commit + push, no user-state clobber.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Page-capture API 400 from lone Unicode surrogates (#1440)** — `browse/src/sanitize.ts` (new) exports `stripLoneSurrogates`, `stripLoneSurrogateEscapes`, and `sanitizeBody`. `buildCommandResponse` (extracted from `handleCommand`, now exported for testability) sanitizes every text/plain and application/json response body. `/batch` was bypassing this chokepoint (`server.ts:1841,1869`); each per-result string is now sanitized AND the final envelope gets a second pass on the JSON-escape form. Defense in depth wraps at `getCleanText`, `getCleanTextWithStripping`, `html`, `accessibility`, and `snapshot` extraction sites.
|
||||
- **Federation sync drops `/office-hours` and `/plan-eng-review` artifacts (#1452)** — `bin/gstack-artifacts-init` adds `projects/*/*-design-*.md` and `projects/*/*-test-plan-*.md` to all three managed blocks: `.brain-allowlist`, `.brain-privacy-map.json` (class `artifact`), and `.gitattributes` (`merge=union`).
|
||||
- **`/setup-gbrain` wrong config key (#1441)** — verified already-fixed in v1.27.0.0; closed the issue with a comment citing the migration script that aligns legacy `gbrain_sync_mode` installs to the current `artifacts_sync_mode` key.
|
||||
|
||||
#### Added
|
||||
|
||||
- **`## Implementation Tasks` section + JSONL handoff in every review skill (#1454)** — `plan-ceo-review`, `plan-design-review`, `plan-eng-review`, `plan-devex-review` each emit a per-skill markdown checklist and write `~/.gstack/projects/$SLUG/tasks-{phase}-{datetime}.jsonl` via `jq -nc` (never hand-rolled echo). `/autoplan` Phase 4 reads all four phase JSONL files, scopes by current branch and 5-commit window, dedupes on exact `(component, sorted(files), title)` matches, and renders one aggregated list. Near-duplicates surface separately with a possible-duplicate note for human resolution.
|
||||
- **`browse/src/sanitize.ts`** — two surrogate-stripping utilities plus a convenience selector keyed on content-type. Pairs with a refactored `buildCommandResponse` in `server.ts` (exported for testability) and per-result sanitization in the `/batch` handler.
|
||||
- **`gstack-upgrade/migrations/v1.36.0.0.sh`** — idempotent per-file repair for `.brain-allowlist`, `.brain-privacy-map.json`, and `.gitattributes`. Uses `jq` for the JSON file (preserves validity); falls back with a clear warning if `jq` is missing. Does NOT re-run `gstack-artifacts-init` (which would commit + push to the user's federated repo).
|
||||
- **32 new unit tests** across `browse/test/sanitize.test.ts` (18), `browse/test/build-command-response.test.ts` (7), `test/artifacts-init-migration.test.ts` (7). All gate-tier (free, runs on every PR).
|
||||
|
||||
#### Changed
|
||||
|
||||
- **`browse/src/snapshot.ts`, `read-commands.ts`, `content-security.ts`** — defense-in-depth surrogate wraps at extraction sites that feed pre-Response consumers (datamarking, envelope wrapping).
|
||||
- **`scripts/resolvers/tasks-section.ts`** (new) + **`scripts/task-emission-schema.ts`** (new) — shared resolver and schema for the per-skill task emission. Each review template invokes `{{TASKS_SECTION_EMIT:<phase>}}` once.
|
||||
|
||||
#### For contributors
|
||||
|
||||
- `/codex review` on Codex CLI ≥0.130.0 was handled separately by v1.34.2.0 (the dual-path bare/exec approach). Our planning surfaced an adjacent concern: the bare path no longer carries the filesystem boundary, so codex may waste tokens reading skill files when the diff happens to touch `.claude/skills/`. Filed as a follow-up issue; not blocking this release.
|
||||
- The implementation-tasks aggregation in `/autoplan` uses a structured JSONL handoff between phases rather than re-parsing markdown. Schema lives in `scripts/task-emission-schema.ts`. Adding a fifth review phase means adding the phase name to `VALID_PHASES` in `scripts/resolvers/tasks-section.ts` and including `{{TASKS_SECTION_EMIT:<phase-name>}}` in the new review template.
|
||||
- Touchfiles entries are unchanged — the new tests are all gate-tier unit tests that run on `bun test`. Touchfiles is only for E2E + LLM evals.
|
||||
|
||||
## [1.35.0.0] - 2026-05-13
|
||||
|
||||
## **Docs become a tracked surface, not an afterthought. `/document-generate` writes them from scratch, `/document-release` audits coverage in four Diataxis quadrants.**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gstack",
|
||||
"version": "1.35.0.0",
|
||||
"version": "1.36.0.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Resolvers for the Implementation Tasks emission (#1454).
|
||||
*
|
||||
* {{TASKS_SECTION_EMIT:<phase>}} — per-skill task emission + JSONL write
|
||||
* {{TASKS_SECTION_AGGREGATE}} — autoplan aggregation across all phases
|
||||
*
|
||||
* Schema for the JSONL artifact lives in scripts/task-emission-schema.ts.
|
||||
*/
|
||||
|
||||
import type { TemplateContext, ResolverFn } from './types';
|
||||
|
||||
const VALID_PHASES = new Set(['ceo-review', 'design-review', 'eng-review', 'devex-review']);
|
||||
|
||||
export const generateTasksSectionEmit: ResolverFn = (_ctx: TemplateContext, args?: string[]) => {
|
||||
const phase = args?.[0];
|
||||
if (!phase || !VALID_PHASES.has(phase)) {
|
||||
throw new Error(`TASKS_SECTION_EMIT requires one of ${[...VALID_PHASES].join(', ')} — got ${phase}`);
|
||||
}
|
||||
|
||||
return `## Implementation Tasks
|
||||
|
||||
Before closing this review, synthesize the findings above into a flat list of
|
||||
build-actionable tasks. Each task derives from a specific finding — no padding.
|
||||
Emit the markdown section AND write a JSONL artifact that \`/autoplan\` can
|
||||
aggregate across phases.
|
||||
|
||||
### Markdown section (always emit)
|
||||
|
||||
\`\`\`markdown
|
||||
## Implementation Tasks
|
||||
Synthesized from this review's findings. Each task derives from a specific
|
||||
finding above. Run with Claude Code or Codex; checkbox as you ship.
|
||||
|
||||
- [ ] **T1 (P1, human: ~2h / CC: ~15min)** — <component> — <imperative title>
|
||||
- Surfaced by: <section name> — <specific finding text or line reference>
|
||||
- Files: <paths to touch>
|
||||
- Verify: <test command or manual check>
|
||||
- [ ] **T2 (P2, human: ~30min / CC: ~5min)** — ...
|
||||
\`\`\`
|
||||
|
||||
Rules:
|
||||
- P1 blocks ship; P2 should land same branch; P3 is a follow-up TODO.
|
||||
- If a finding produced no actionable task, do not invent one.
|
||||
- If a section had zero findings, emit \`_No new tasks from <section>._\`
|
||||
- Effort uses the AI-compression table from CLAUDE.md.
|
||||
|
||||
### JSONL artifact (always write, even if zero tasks)
|
||||
|
||||
\`/autoplan\` reads this file to aggregate across phases. Build each line with
|
||||
\`jq -nc\` so titles and source findings containing quotes, newlines, or
|
||||
backslashes serialize cleanly — never use hand-rolled \`echo\` / \`printf\`.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
TASKS_DIR="\${HOME}/.gstack/projects/\${SLUG:-unknown}"
|
||||
mkdir -p "$TASKS_DIR"
|
||||
TASKS_FILE="$TASKS_DIR/tasks-${phase}-$(date +%Y%m%d-%H%M%S).jsonl"
|
||||
COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
|
||||
# Repeat ONE jq invocation per task identified during this review.
|
||||
# Substitute the placeholders inline with shell variables you set per task:
|
||||
# TASK_ID (T1, T2, ...), PRIORITY (P1/P2/P3), COMPONENT, TITLE,
|
||||
# SOURCE_FINDING, EFFORT_HUMAN, EFFORT_CC, FILES_JSON (a JSON array literal
|
||||
# like '["browse/src/sanitize.ts","browse/src/server.ts"]').
|
||||
jq -nc \\
|
||||
--arg phase '${phase}' \\
|
||||
--arg run_id "$RUN_ID" \\
|
||||
--arg branch "$BRANCH" \\
|
||||
--arg commit "$COMMIT" \\
|
||||
--arg id "$TASK_ID" \\
|
||||
--arg priority "$PRIORITY" \\
|
||||
--arg component "$COMPONENT" \\
|
||||
--arg effort_human "$EFFORT_HUMAN" \\
|
||||
--arg effort_cc "$EFFORT_CC" \\
|
||||
--arg title "$TITLE" \\
|
||||
--arg source_finding "$SOURCE_FINDING" \\
|
||||
--argjson files "$FILES_JSON" \\
|
||||
'{phase:$phase, run_id:$run_id, branch:$branch, commit:$commit, id:$id, priority:$priority, component:$component, files:$files, effort_human:$effort_human, effort_cc:$effort_cc, title:$title, source_finding:$source_finding}' \\
|
||||
>> "$TASKS_FILE"
|
||||
\`\`\`
|
||||
|
||||
If \`jq\` is not installed, fall back to skipping the JSONL write and warn
|
||||
the user to install jq for autoplan aggregation. Never hand-roll JSONL.
|
||||
|
||||
If zero tasks were identified in this review, still touch the JSONL file
|
||||
(\`: > "$TASKS_FILE"\`) so the aggregator sees that the phase produced output
|
||||
this run (an empty file means "ran, no findings" — distinct from "didn't run").
|
||||
`;
|
||||
};
|
||||
|
||||
export const generateTasksSectionAggregate: ResolverFn = (_ctx: TemplateContext) => {
|
||||
return `## Implementation Tasks aggregator
|
||||
|
||||
Before rendering the Final Approval Gate output block below, aggregate the
|
||||
per-phase task lists each review skill wrote.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
TASKS_DIR="\${HOME}/.gstack/projects/\${SLUG:-unknown}"
|
||||
BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
|
||||
# Commit window: last 5 commits on this branch. Drops stale standalone reviews.
|
||||
COMMITS_RECENT=$(git log --format=%H -n 5 2>/dev/null | tr '\\n' '|' | sed 's/|$//')
|
||||
|
||||
AGGREGATED_TASKS=""
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Collect entries from all 4 phases, scoped to current branch + commit window.
|
||||
# For each phase, keep only the latest run_id. Within the surviving set,
|
||||
# dedupe by (component, sorted(files), title) — exact match only.
|
||||
# Sort by priority (P1 > P2 > P3) then by phase order.
|
||||
ALL_JSONL=$(mktemp -t autoplan-tasks.XXXXXXXX)
|
||||
for phase in ceo-review design-review eng-review devex-review; do
|
||||
# Use find instead of glob expansion — zsh nomatch errors otherwise when
|
||||
# a phase produced no JSONL files. Sorting by name keeps the order stable.
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
# Filter to current branch + recent commits, then keep records for the
|
||||
# latest run_id only. (Single phase may have multiple files if the user
|
||||
# re-ran the review; aggregator takes the newest.)
|
||||
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \\
|
||||
'select(.branch == $branch and ($commits | split("|") | index(.commit) != null))' \\
|
||||
"$f" 2>/dev/null >> "$ALL_JSONL" || true
|
||||
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
|
||||
# Reduce to latest run_id per phase
|
||||
if [ -s "$ALL_JSONL" ]; then
|
||||
jq -sc --arg phase "$phase" \\
|
||||
'[.[] | select(.phase == $phase)] | (max_by(.run_id) // null) as $latest_run | if $latest_run then map(select(.run_id == $latest_run.run_id)) else [] end | .[]' \\
|
||||
"$ALL_JSONL" > "$ALL_JSONL.phase" 2>/dev/null || true
|
||||
# Replace with reduced version for this phase, accumulating others
|
||||
jq -c --arg phase "$phase" 'select(.phase != $phase)' "$ALL_JSONL" > "$ALL_JSONL.other" 2>/dev/null || true
|
||||
cat "$ALL_JSONL.other" "$ALL_JSONL.phase" > "$ALL_JSONL"
|
||||
rm -f "$ALL_JSONL.phase" "$ALL_JSONL.other"
|
||||
fi
|
||||
done
|
||||
|
||||
# Exact-match dedup by (component, sorted(files), title). Non-matches kept
|
||||
# separately with a possible-duplicate marker injected by the renderer.
|
||||
AGGREGATED_TASKS=$(jq -s \\
|
||||
'group_by([.component, (.files | sort), .title])
|
||||
| map(
|
||||
# Take the highest-priority entry per group; tie-break by phase order
|
||||
sort_by({P1:0,P2:1,P3:2}[.priority] // 99, {"ceo-review":0,"design-review":1,"eng-review":2,"devex-review":3}[.phase] // 99) | .[0]
|
||||
)
|
||||
| sort_by({P1:0,P2:1,P3:2}[.priority] // 99, {"ceo-review":0,"design-review":1,"eng-review":2,"devex-review":3}[.phase] // 99)
|
||||
| if length == 0 then "_No actionable tasks emitted from any phase._" else
|
||||
map("- [ ] **\\(.id) (\\(.priority), human: \\(.effort_human) / CC: \\(.effort_cc)) — \\(.component)** — \\(.title)\\n - Surfaced by: \\(.phase) — \\(.source_finding)\\n - Files: \\(.files | join(", "))") | join("\\n")
|
||||
end' "$ALL_JSONL" 2>/dev/null | sed 's/^"//;s/"$//;s/\\\\n/\\n/g')
|
||||
rm -f "$ALL_JSONL"
|
||||
else
|
||||
AGGREGATED_TASKS="_jq not installed — install jq to aggregate per-phase task lists. Skipping._"
|
||||
fi
|
||||
\`\`\`
|
||||
|
||||
Inside the Final Approval Gate output template below, render the aggregated
|
||||
markdown in the \`### Implementation Tasks (aggregated across phases)\` section.
|
||||
Substitute the contents of \`$AGGREGATED_TASKS\` (the bash variable set above)
|
||||
before printing the message to the user. This is NOT a template placeholder
|
||||
— the agent does the substitution at runtime, not gen-skill-docs at build time.
|
||||
|
||||
If \`$AGGREGATED_TASKS\` is empty (no JSONL files found — none of the review
|
||||
skills ran in this session), render:
|
||||
|
||||
\`_No per-phase task lists found in $TASKS_DIR for branch $BRANCH. Each review
|
||||
skill writes its own; if you ran one of them but no list appears here, check
|
||||
that jq is installed and the tasks-<phase>-*.jsonl files exist._\`
|
||||
`;
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Schema reference for the per-skill Implementation Tasks JSONL artifact (#1454).
|
||||
*
|
||||
* Each review skill (plan-ceo-review, plan-design-review, plan-eng-review,
|
||||
* plan-devex-review) writes one JSONL line per task during its synthesis step
|
||||
* to `~/.gstack/projects/$SLUG/tasks-{phase}-{datetime}.jsonl`.
|
||||
*
|
||||
* `/autoplan`'s Phase 4 aggregator reads ALL phase JSONL files, scopes them
|
||||
* by branch + commit window, dedupes by exact (component, sorted(files), title),
|
||||
* and renders an `## Implementation Tasks (aggregated across phases)` section
|
||||
* inside the Final Approval Gate output.
|
||||
*
|
||||
* Wire format: one JSON object per line. Build via `jq -nc` from bash — never
|
||||
* by hand-rolled echo/printf, because task titles and source findings may
|
||||
* contain quotes, newlines, and backslashes.
|
||||
*/
|
||||
|
||||
export type TaskPhase = 'ceo-review' | 'design-review' | 'eng-review' | 'devex-review';
|
||||
export type TaskPriority = 'P1' | 'P2' | 'P3';
|
||||
|
||||
/**
|
||||
* One row in tasks-{phase}-{datetime}.jsonl. All fields required unless noted.
|
||||
*/
|
||||
export interface ImplementationTask {
|
||||
/** Which review phase produced this task. */
|
||||
phase: TaskPhase;
|
||||
/** Unique run identifier for this phase invocation (timestamp + pid suffix). */
|
||||
run_id: string;
|
||||
/** Branch the review ran on. Aggregator filters by this. */
|
||||
branch: string;
|
||||
/** HEAD commit at review time. Aggregator filters by commit-window proximity. */
|
||||
commit: string;
|
||||
/** Short task id, unique within a single run_id (T1, T2, ...). */
|
||||
id: string;
|
||||
priority: TaskPriority;
|
||||
/** Coarse component label (e.g., `browse/sanitizer`, `auth/login`). */
|
||||
component: string;
|
||||
/** Files the task touches. Aggregator sorts this and uses it in the dedup key. */
|
||||
files: string[];
|
||||
/** Human-team effort estimate (e.g., "2h", "1 day"). */
|
||||
effort_human: string;
|
||||
/** CC+gstack effort estimate (e.g., "15min"). */
|
||||
effort_cc: string;
|
||||
/** Action-oriented title in imperative form ("Add commandResult-level sanitization"). */
|
||||
title: string;
|
||||
/** Free-text reference to the finding that motivated this task. */
|
||||
source_finding: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup key for the aggregator. Two tasks collapse into one ONLY when this
|
||||
* tuple is identical (per `D13 finding 9`). Near-duplicates surface as
|
||||
* separate tasks with a `possible-duplicate-of: <id>` note.
|
||||
*/
|
||||
export function dedupKey(t: Pick<ImplementationTask, 'component' | 'files' | 'title'>): string {
|
||||
return JSON.stringify({
|
||||
component: t.component,
|
||||
files: [...t.files].sort(),
|
||||
title: t.title,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
// Unit tests for gstack-upgrade/migrations/v1.36.0.0.sh (#1452).
|
||||
// Verifies idempotent in-place repair of .brain-allowlist,
|
||||
// .brain-privacy-map.json, and .gitattributes.
|
||||
|
||||
import { describe, expect, test, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const REPO_ROOT = new URL('..', import.meta.url).pathname;
|
||||
const MIGRATION = join(REPO_ROOT, 'gstack-upgrade', 'migrations', 'v1.36.0.0.sh');
|
||||
|
||||
function setupFakeHome(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mig-v1340-'));
|
||||
mkdirSync(join(dir, '.gstack'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runMigration(fakeHome: string): { code: number; stdout: string; stderr: string } {
|
||||
const proc = Bun.spawnSync({
|
||||
cmd: ['bash', MIGRATION],
|
||||
env: { ...process.env, HOME: fakeHome },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
return {
|
||||
code: proc.exitCode ?? -1,
|
||||
stdout: new TextDecoder().decode(proc.stdout),
|
||||
stderr: new TextDecoder().decode(proc.stderr),
|
||||
};
|
||||
}
|
||||
|
||||
describe('v1.36.0.0 migration', () => {
|
||||
test('adds patterns to allowlist before USER ADDITIONS marker', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
||||
'projects/*/learnings.jsonl',
|
||||
'projects/*/designs/*.md',
|
||||
'# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)',
|
||||
'projects/*/my-custom.txt',
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = runMigration(home);
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
const content = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
||||
expect(content).toContain('projects/*/*-design-*.md');
|
||||
expect(content).toContain('projects/*/*-test-plan-*.md');
|
||||
// New patterns above the user marker
|
||||
const designIdx = content.indexOf('projects/*/*-design-*.md');
|
||||
const markerIdx = content.indexOf('# ---- USER ADDITIONS BELOW');
|
||||
expect(designIdx).toBeLessThan(markerIdx);
|
||||
// User customizations below the marker preserved
|
||||
expect(content).toContain('projects/*/my-custom.txt');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adds entries to privacy-map.json via jq (preserves JSON validity)', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
||||
{ pattern: 'projects/*/learnings.jsonl', class: 'artifact' },
|
||||
{ pattern: 'projects/*/designs/*.md', class: 'artifact' },
|
||||
], null, 2));
|
||||
|
||||
const r = runMigration(home);
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
const raw = readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8');
|
||||
// Valid JSON (would throw if jq emitted malformed output)
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
const patterns = parsed.map((e: any) => e.pattern);
|
||||
expect(patterns).toContain('projects/*/*-design-*.md');
|
||||
expect(patterns).toContain('projects/*/*-test-plan-*.md');
|
||||
// Class preserved on new entries
|
||||
expect(parsed.find((e: any) => e.pattern === 'projects/*/*-design-*.md').class).toBe('artifact');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adds union-merge rules to gitattributes', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
writeFileSync(join(home, '.gstack', '.gitattributes'), [
|
||||
'*.jsonl merge=jsonl-append',
|
||||
'projects/*/designs/**/*.md merge=union',
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = runMigration(home);
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
const content = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
||||
expect(content).toContain('projects/*/*-design-*.md merge=union');
|
||||
expect(content).toContain('projects/*/*-test-plan-*.md merge=union');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('is idempotent: re-running on already-patched files is a no-op', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
||||
'projects/*/learnings.jsonl',
|
||||
'# ---- USER ADDITIONS BELOW',
|
||||
].join('\n') + '\n');
|
||||
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
||||
{ pattern: 'projects/*/learnings.jsonl', class: 'artifact' },
|
||||
]));
|
||||
writeFileSync(join(home, '.gstack', '.gitattributes'), '*.jsonl merge=jsonl-append\n');
|
||||
|
||||
runMigration(home);
|
||||
// Remove the done marker so re-run actually executes
|
||||
rmSync(join(home, '.gstack', '.migrations'), { recursive: true, force: true });
|
||||
|
||||
const beforeAllowlist = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
||||
const beforePrivacy = readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8');
|
||||
const beforeAttrs = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
||||
|
||||
runMigration(home);
|
||||
|
||||
const afterAllowlist = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
||||
const afterPrivacy = readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8');
|
||||
const afterAttrs = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
||||
|
||||
expect(afterAllowlist).toBe(beforeAllowlist);
|
||||
// jq may re-emit JSON with different whitespace but the parsed content
|
||||
// must be identical
|
||||
expect(JSON.parse(afterPrivacy)).toEqual(JSON.parse(beforePrivacy));
|
||||
expect(afterAttrs).toBe(beforeAttrs);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repairs privacy-map even when allowlist is missing (per-file independence)', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
// No .brain-allowlist; only privacy-map present
|
||||
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
||||
{ pattern: 'projects/*/learnings.jsonl', class: 'artifact' },
|
||||
]));
|
||||
|
||||
const r = runMigration(home);
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
// Privacy-map still patched
|
||||
const parsed = JSON.parse(readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8'));
|
||||
const patterns = parsed.map((e: any) => e.pattern);
|
||||
expect(patterns).toContain('projects/*/*-design-*.md');
|
||||
// Allowlist remains absent (we don't create files that weren't there)
|
||||
expect(existsSync(join(home, '.gstack', '.brain-allowlist'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('migration marker prevents re-running', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
writeFileSync(join(home, '.gstack', '.brain-allowlist'), '# ---- USER ADDITIONS BELOW\n');
|
||||
runMigration(home);
|
||||
// Confirm marker file exists
|
||||
expect(existsSync(join(home, '.gstack', '.migrations', 'v1.36.0.0.done'))).toBe(true);
|
||||
|
||||
// Modify allowlist so we can detect if the migration would re-run
|
||||
writeFileSync(join(home, '.gstack', '.brain-allowlist'), '# minimal\n');
|
||||
|
||||
runMigration(home);
|
||||
|
||||
// With the marker present, the migration short-circuits, so the file
|
||||
// we just wrote stays unmodified
|
||||
expect(readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8')).toBe('# minimal\n');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('handles allowlist without USER ADDITIONS marker (fallback to append)', () => {
|
||||
const home = setupFakeHome();
|
||||
try {
|
||||
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
||||
'projects/*/learnings.jsonl',
|
||||
'projects/*/designs/*.md',
|
||||
// no USER ADDITIONS marker
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = runMigration(home);
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
const content = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
||||
expect(content).toContain('projects/*/*-design-*.md');
|
||||
expect(content).toContain('projects/*/*-test-plan-*.md');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue