mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-16 17:22:12 +08:00
v1.38.1.0 fix wave: surrogate-safe page captures (#1440), Implementation Tasks across review skills (#1454), root-level artifact patterns (#1452) (#1504)
* fix(browse): sanitize lone Unicode surrogates at commandResult chokepoint + /batch envelope (#1440) Page captures with mixed-script Unicode round-trip cleanly to the Claude API. Two new utilities in browse/src/sanitize.ts: stripLoneSurrogates for raw UTF-16 strings, stripLoneSurrogateEscapes for \uXXXX JSON escape text. sanitizeBody picks the right pass based on cr.json. buildCommandResponse is extracted from handleCommand (now exported) and applies sanitization before new Response(). /batch was bypassing this chokepoint via direct JSON.stringify, so it sanitizes each cr.result before pushing AND wraps the envelope with stripLoneSurrogateEscapes. Defense in depth wraps at getCleanText, getCleanTextWithStripping, html, accessibility, and snapshot.ts return points so downstream consumers (datamarking, envelope wrapping) see sanitized text before the response is built. 25 new unit tests across sanitize.test.ts and build-command-response.test.ts. content-security.test.ts updated to accept either pre- or post-sanitize form of the snapshot scoped branch (source-level regression check). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * 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> * chore: bump to v1.38.1.0 VERSION + package.json + CHANGELOG header + migration filename + test reference all consistently at v1.38.1.0. Migration renamed: gstack-upgrade/migrations/v1.38.0.0.sh -> v1.38.1.0.sh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
203
test/artifacts-init-migration.test.ts
Normal file
203
test/artifacts-init-migration.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Unit tests for gstack-upgrade/migrations/v1.38.1.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.38.1.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.38.1.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.38.1.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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user