mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-31 15:29:39 +02:00
v1.44.1.0 fix wave: post-windhoek paper-cut — 9 community PRs in one bundle (#1682)
* fix(office-hours): #1671 — session writer was writing to the legacy file User-visible symptom: returning /office-hours users get the same closing pitch every visit, no matter how many times they've run the skill. The welcome_back tier (which exists specifically to skip the pitch for returning users) was unreachable. Live since 2026-04-18 / v1.0.0.0 on every fresh-$HOME user. Root cause: the v1.0.0.0 migration moved the read path to ~/.gstack/developer-profile.json but left the writer in office-hours/SKILL.md.tmpl writing to the legacy ~/.gstack/builder-profile.jsonl. Reader and writer disagreed on storage, so SESSION_COUNT never incremented and /office-hours always treated the user as a first-timer. Fix: - bin/gstack-developer-profile: new --log-session subcommand that read-modify-writes developer-profile.json's sessions[] array (atomic mktemp+mv, signals/resources/topics aggregation, gbrain-enqueue mirror of gstack-timeline-log:40). Naming matches the gstack-*-log family verb. - bin/gstack-developer-profile: do_read filters mode:"resources" entries when picking LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE so the Phase 6 resources auto-append doesn't clobber real-session state. Latent bug that was masked by the broken writer; activated by the fix. - office-hours/SKILL.md.tmpl: lines 490 + 893 swap echo >> for --log-session. - test/gstack-developer-profile.test.ts: +8 tests covering --log-session contract (regression, aggregation, dedup, validation, ts handling) plus the mode-filter regression. All 8 fail on main, all 8 pass with this fix. - test/static-no-legacy-writes.test.ts: new static-grep invariant walking every skill dir to prevent future regressions onto the legacy file. Affected users: stranded builder-profile.jsonl entries are not recovered automatically by this PR. On their next /office-hours run, the first new session lands in welcome_back; past data stays in the legacy file (still readable by other tools during deprecation). Most pre-existing users have only a handful of stranded sessions. See docs/designs/FIX_1671_PROFILE_MIGRATION.md for scope decisions (RC2/RC3 follow-ups, what was intentionally left out, and why). Issue: #1671 * test(office-hours): refine #1671 invariant regex comment for literal-path scope Clarifies that the WRITE_PATTERN regex catches literal-path writes only; variable-indirected writes (FILE=...; echo >> "$FILE") are not detected. The SKILL.md.tmpl assertions in the same suite pin the exact #1671 regression class directly; this regex is a backstop, not a flow analyzer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(timeline): pass read filters as data * feat(next-version): support monorepo VERSION paths via --version-path + .gstack/version-path The workspace-aware ship queue hardcoded the VERSION file at the repo root. In monorepos where versioning is subproject-scoped (one app inside a larger repo), every PR's VERSION lookup 404s, the queue silently empties, and parallel /ship sessions all bump from "current main + 1" — producing a cascade of slot collisions. Repro: tinas-second-brain repo. Root VERSION is absent; the real VERSION lives at "Tinas Second Brain/health-tracker/VERSION". In one day, four sequential collisions: 0.4.0.1 -> 0.5.0.0 -> 0.5.0.1 -> 0.5.0.2 -> 0.5.0.3. Fix: add a --version-path flag and a repo-local .gstack/version-path config file. Resolution priority: CLI flag > .gstack/version-path > "VERSION". The resolved path threads through all four call sites — git show origin/<base>:<path>, the GitHub Contents API, the GitLab files API, and the local sibling-worktree scan — and shows up in the JSON output as version_path so /ship and operators can see what got picked. The previous warning "could not fetch VERSION (fork or private)" was misleading whenever the real cause was wrong path. The new wording names the path that 404'd and hints at the two knobs. Backward-compatible: no flag, no config, no change in behavior. Tests: 6 unit tests for resolveVersionPath (priority, parsing, blank / missing / empty edge cases) + a second integration smoke that drives --version-path end-to-end and asserts it surfaces in JSON output. * fix(investigate): support standalone freeze hook path * fix(browse): clarify localhost bind failures * fix(migration): defer v1.40.0.0 done-marker until every repair succeeds (#1581) The v1.40.0.0 migration unconditionally `touch`ed its done-marker, even when the jq-gated `.brain-privacy-map.json` patch was skipped because jq was missing on the user's machine. On subsequent runs, the script short-circuited on the marker so the privacy-map repair never landed. Federation sync then silently dropped `/plan-eng-review` test plans. Track every failure mode via a single `incomplete` flag: jq missing, malformed JSON, jq mutation failure, tempfile creation failure, `mv` failure, allowlist append failure, gitattributes append failure. The marker is written only when `incomplete=0`, so the migration runner retries on the next /gstack-upgrade once the prerequisites are met. * test(migration): unit tests for v1.40.0.0 deferred done-marker fix (#1581) 8 cases pinning the fix: - Case 1 (happy path): jq present, fresh privacy-map → all three files patched, marker written. - Case 2 (regression for #1581): jq missing, privacy-map present → marker must NOT be written. Fails against the buggy script, passes against the fix. - Case 3 (recovery): jq missing, then jq restored → patch lands on second run. - Case 4 (idempotency): privacy-map already has correct entry → no mutation, marker written. - Case 5 (fresh-init): privacy-map file absent → allowlist + gitattrs patched, marker written. - Case 6 (malformed JSON): broken privacy-map JSON → no marker, no mutation. - Case 7 (jq mutation failure): fake jq returning 1 → no marker, tempfile cleaned up. - Case 8 (allowlist append failure): read-only allowlist → no marker. Tests use spawnSync('bash', [MIGRATION], …) with isolated tmpHomes. "jq missing" sets PATH to a curated dir of symlinks to standard utils, omitting jq; "jq mutation fails" uses an `exit 1` shim. Avoids blanket-clearing PATH (which would hide bash/grep/etc). * fix(brain-sync): make artifact sync work on Windows (discover-new + drain) Automatic artifact sync was fully non-functional on Windows (Git Bash): --discover-new enqueued nothing and the --once drain staged nothing, so artifacts_sync_mode looked active but no artifacts ever reached the repo. Three independent Windows-only causes in bin/gstack-brain-sync: 1. discover-new matched os.path.relpath (backslash separators on Windows) against the forward-slash allowlist globs, so no nested file ever matched. Normalized the relpath to "/". 2. discover-new enqueued via subprocess.run([gstack-brain-enqueue, rel]), but Windows Python cannot exec a bash-shebang script, so nothing was enqueued even once matched. Now appends to the queue in-process. 3. compute_paths_to_stage ends in print(p); Windows Python emits CRLF, the bash `read -r` keeps the trailing CR, and `git add -- "path<CR>"` matches nothing under `2>/dev/null || true`. Now strips the CR before staging. The in-process enqueue mirrors gstack-brain-enqueue's contract: one atomic O_APPEND write per record (each line < PIPE_BUF) so a parallel writer-shim append can't interleave mid-record, and the discover cursor advances only after the write succeeds, so a failed write retries instead of silently recording the file as synced. Skip-list entries are separator-normalized on both the discover and drain (compute_paths_to_stage) sides, so a backslash .brain-skip.txt entry can't be honored at discovery yet bypassed at commit. Adds test/brain-sync-windows-paths.test.ts (static invariants -- behavioral spawn tests cannot run on the Windows lane, since Node/Bun cannot exec the bin/ shebang scripts there) and wires it into windows-free-tests.yml. Verified red->green and end-to-end on Windows 11 / Git Bash; macOS/Linux behavior unchanged (os.sep is already "/", no CRLF, compute path logic unchanged besides the shared skip normalization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: detect bun.lock (Bun v1.2+ text lockfile) in diff-scope CONFIG gstack-diff-scope only matched the legacy binary lockfile `bun.lockb` but not the newer text-based `bun.lock` introduced in Bun v1.2+. Projects using current Bun versions were silently missing the SCOPE_CONFIG signal when only the lockfile changed. 🤖 Generated with [Qoder][https://qoder.com] * fix(ios-qa): resolve CoreDevice tunnel via devicectl + keep tunnel alive The daemon's tunnel bootstrap used `dns.resolve6` to look up `<device>.coredevice.local`, which fails with ESERVFAIL on macOS 26.x (Darwin 25.x) because Node's resolve6 path goes through libresolv and does NOT consult mDNSResponder. `dns.lookup` (getaddrinfo) does. Even when resolution works, CoreDevice in Xcode 26 only holds the USB tunnel up while a devicectl command is in-flight, so the IPv6 ULA becomes unroutable within ~10-15s of idle and subsequent proxy requests time out. Two-part fix: 1. Resolution order is now (a) `xcrun devicectl device info details --json-output` to read `result.connectionProperties.tunnelIPAddress` directly, (b) mDNS via `dns.lookup`, (c) legacy `dns.resolve6` as a last-ditch fallback. 2. After a successful bootstrap the daemon spawns a periodic `devicectl device info details` (~5s) to keep the tunnel session alive. Cleaned up on SIGINT/SIGTERM/exit. Adds tests for `getDeviceTunnelIPv6FromDevicectl`, the `resolveTunnelIPv6` fallback chain, and `startTunnelKeepalive`. Existing bootstrap tests updated to include the new `device info details` spawn step. Tested against: iPhone 12 Pro on iOS 26.x via Mac Mini M-series running macOS Sequoia 15.x / Darwin 25.3.0. * chore(release): v1.44.1.0 — 9-PR community fix wave (post-windhoek paper-cut) Bump VERSION + CHANGELOG entry. Wave covers /office-hours session counter, iOS QA macOS 26 tunnels, Windows brain-sync, browse server bind diagnostics, monorepo VERSION layouts, /investigate freeze hook on standalone installs, gstack-timeline-read quote injection, v1.40.0.0 migration on jq-less machines, bun.lock detection. 9 community PRs: #1676 #1635 #1627 #1648 #1664 #1589 #1672 #1649 #1673 9 contributors credited: @pryow @jbetala7 @cfeddersen @Gujiassh @spacegeologist @stedfn @daveowenatl @hiSandog @sternryan 4 issues closed: #1671 #1677 #1634 #1647 #1581 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Rook <rook@robomovers.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: Christoph <astaran@herr-der-ringe-film.de> Co-authored-by: gujishh <baiaoshh@163.com> Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com> Co-authored-by: Stefan Neamtu <stefan.neamtu@nearone.org> Co-authored-by: Dave Owen <daveowen66@gmail.com> Co-authored-by: 陈家名 <chenjiaming@kezaihui.com> Co-authored-by: Ryan Stern <206953196+sternryan@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Static invariants guarding Windows artifact-sync (bin/gstack-brain-sync).
|
||||
//
|
||||
// These are deliberately static, not behavioral. The brain-sync integration
|
||||
// suite (test/brain-sync.test.ts) spawns the bin/ scripts directly, which
|
||||
// Node/Bun cannot exec on Windows (they are bash-shebang scripts), so that
|
||||
// suite is excluded from the Windows CI lane. Instead we assert the source
|
||||
// keeps the properties that make `--discover-new` and the `--once` drain work
|
||||
// on Windows. Each maps to a confirmed, separately-reproduced failure:
|
||||
//
|
||||
// 1. os.path.relpath yields BACKSLASH separators on Windows, which never
|
||||
// match the forward-slash allowlist globs (e.g. "projects/*/learnings.jsonl"),
|
||||
// so nested artifacts were silently never discovered.
|
||||
// 2. discover-new enqueued via subprocess.run([bash-shim]); Windows Python
|
||||
// cannot exec a shebang script, so it enqueued nothing even once matched.
|
||||
// 3. compute_paths_to_stage's python print() emits CRLF on Windows; the bash
|
||||
// `read -r` keeps the trailing \r, so `git add -- "path\r"` matches
|
||||
// nothing and the drain silently stages/commits nothing.
|
||||
//
|
||||
// Plus two robustness properties (independent codex review, both [P2]):
|
||||
// 4. the inline enqueue must append one atomic record at a time (O_APPEND),
|
||||
// or a concurrent writer-shim append can interleave mid-record and produce
|
||||
// a malformed queue line that the drain silently drops.
|
||||
// 5. the skip-list must be normalized to the same separator form as `rel`,
|
||||
// or a backslash entry in .brain-skip.txt stops matching and a file the
|
||||
// user explicitly skipped gets synced.
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-brain-sync'), 'utf-8');
|
||||
|
||||
describe('gstack-brain-sync — Windows path/exec invariants', () => {
|
||||
test('discover-new normalizes relpath separators before fnmatch (bug 1)', () => {
|
||||
expect(SRC).toContain('os.path.relpath(full, gstack_home).replace(os.sep, "/")');
|
||||
});
|
||||
|
||||
test('no python subprocess exec — Windows cannot exec the bash shims (bug 2)', () => {
|
||||
// The whole script must never shell out to a bin/ bash script from Python;
|
||||
// that is the exec failure that left discover enqueuing nothing on Windows.
|
||||
expect(SRC).not.toContain('subprocess');
|
||||
});
|
||||
|
||||
test('drain loop strips trailing CR before git add (bug 3)', () => {
|
||||
const CR_STRIP = "p=\"${p%$'\\r'}\"";
|
||||
expect(SRC).toContain(CR_STRIP);
|
||||
// The strip must precede the staging call, or the pathspec still carries \r.
|
||||
expect(SRC.indexOf(CR_STRIP)).toBeLessThan(SRC.indexOf('add -f -- "$p"'));
|
||||
});
|
||||
|
||||
test('inline enqueue appends one atomic record at a time (codex P2 #1)', () => {
|
||||
expect(SRC).toContain('os.O_APPEND');
|
||||
expect(SRC).toContain('os.write(fd');
|
||||
// No buffered batch write to the queue (the interleave-corruption shape).
|
||||
expect(SRC).not.toContain('open(queue_path, "a"');
|
||||
});
|
||||
|
||||
test('skip-list is normalized on BOTH discover and drain sides (codex P2 #2)', () => {
|
||||
// The drain (compute_paths_to_stage) is the real staging boundary, so it
|
||||
// must normalize skip entries identically to discover_new — otherwise a
|
||||
// backslash .brain-skip.txt entry is honored at discovery but bypassed at
|
||||
// commit, syncing a file the user explicitly skipped.
|
||||
const NORM = 's.replace(os.sep, "/") for s in load_lines(skip_path)';
|
||||
expect(SRC.split(NORM).length - 1).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,12 @@ describe('gstack-diff-scope', () => {
|
||||
expect(scope.SCOPE_AUTH).toBe('true');
|
||||
});
|
||||
|
||||
test('detects config via bun.lock (Bun v1.2+ text lockfile)', () => {
|
||||
const dir = createRepo(['bun.lock']);
|
||||
const scope = runScope(dir);
|
||||
expect(scope.SCOPE_CONFIG).toBe('true');
|
||||
});
|
||||
|
||||
test('returns false for all new signals when no matching files', () => {
|
||||
const dir = createRepo(['docs/readme.md', 'config.yml']);
|
||||
const scope = runScope(dir);
|
||||
|
||||
@@ -439,3 +439,120 @@ describe('gstack-developer-profile errors', () => {
|
||||
expect(r.stderr).toContain('unknown subcommand');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// --log-session — the #1671 fix: writer that matches the reader.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('gstack-developer-profile --log-session (#1671 fix)', () => {
|
||||
test('regression: read-write-read sequence on fresh $HOME promotes to welcome_back', () => {
|
||||
// First --read creates an empty stub (this is the bug-shape on current main).
|
||||
const r1 = runDev('--read');
|
||||
expect(r1.stdout).toContain('SESSION_COUNT: 0');
|
||||
expect(r1.stdout).toContain('TIER: introduction');
|
||||
|
||||
// Office-hours writes a session via the new subcommand.
|
||||
const r2 = runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-23T00:00:00Z',
|
||||
mode: 'startup',
|
||||
project_slug: 'test',
|
||||
signal_count: 2,
|
||||
signals: ['s1', 's2'],
|
||||
}));
|
||||
expect(r2.status).toBe(0);
|
||||
|
||||
// Second --read sees the session — this is what was broken.
|
||||
const r3 = runDev('--read');
|
||||
expect(r3.stdout).toContain('SESSION_COUNT: 1');
|
||||
expect(r3.stdout).toContain('TIER: welcome_back');
|
||||
expect(r3.stdout).toContain('LAST_PROJECT: test');
|
||||
expect(r3.stdout).toContain('TOTAL_SIGNAL_COUNT: 2');
|
||||
});
|
||||
|
||||
test('aggregates signals across multiple sessions', () => {
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-20T00:00:00Z', mode: 'startup', project_slug: 'p', signals: ['a', 'b'],
|
||||
}));
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-21T00:00:00Z', mode: 'startup', project_slug: 'p', signals: ['a', 'c'],
|
||||
}));
|
||||
const p = readProfile() as { sessions: unknown[]; signals_accumulated: Record<string, number> };
|
||||
expect(p.sessions.length).toBe(2);
|
||||
expect(p.signals_accumulated).toEqual({ a: 2, b: 1, c: 1 });
|
||||
});
|
||||
|
||||
test('aggregates resources_shown and topics as deduped unions', () => {
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-20T00:00:00Z', mode: 'resources', project_slug: 'p',
|
||||
resources_shown: ['url1', 'url2'], topics: ['ai'],
|
||||
}));
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-21T00:00:00Z', mode: 'resources', project_slug: 'p',
|
||||
resources_shown: ['url2', 'url3'], topics: ['ai', 'eng'],
|
||||
}));
|
||||
const p = readProfile() as { resources_shown: string[]; topics: string[] };
|
||||
expect(p.resources_shown.sort()).toEqual(['url1', 'url2', 'url3']);
|
||||
expect(p.topics.sort()).toEqual(['ai', 'eng']);
|
||||
});
|
||||
|
||||
test('silently skips invalid JSON input (matches gstack-timeline-log pattern)', () => {
|
||||
const r = runDev('--log-session', 'not-json');
|
||||
expect(r.status).toBe(0); // silent skip, not error
|
||||
const file = path.join(tmpHome, 'developer-profile.json');
|
||||
expect(fs.existsSync(file)).toBe(false); // no stub created either
|
||||
});
|
||||
|
||||
test('silently skips JSON missing required fields', () => {
|
||||
const r = runDev('--log-session', JSON.stringify({ foo: 'bar' }));
|
||||
expect(r.status).toBe(0);
|
||||
const file = path.join(tmpHome, 'developer-profile.json');
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
test('injects ts field if missing', () => {
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-23T00:00:00Z', mode: 'startup', project_slug: 'p',
|
||||
}));
|
||||
const p = readProfile() as { sessions: Array<{ ts: string }> };
|
||||
expect(p.sessions[0].ts).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test('preserves user-set ts field if provided', () => {
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-23T00:00:00Z', mode: 'startup', project_slug: 'p',
|
||||
ts: '2026-05-23T12:34:56Z',
|
||||
}));
|
||||
const p = readProfile() as { sessions: Array<{ ts: string }> };
|
||||
expect(p.sessions[0].ts).toBe('2026-05-23T12:34:56Z');
|
||||
});
|
||||
|
||||
test('do_read picks LAST_* from real sessions, not from a trailing mode:resources entry', () => {
|
||||
// The Phase 6 resources auto-append happens AFTER the real session in the
|
||||
// same /office-hours invocation. Without the mode filter, that resources
|
||||
// entry would clobber LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE for
|
||||
// the next session.
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-20T00:00:00Z',
|
||||
mode: 'startup',
|
||||
project_slug: 'realproj',
|
||||
assignment: 'real assignment text',
|
||||
design_doc: 'plans/real.md',
|
||||
}));
|
||||
runDev('--log-session', JSON.stringify({
|
||||
date: '2026-05-20T01:00:00Z',
|
||||
mode: 'resources',
|
||||
project_slug: 'realproj',
|
||||
assignment: '',
|
||||
design_doc: '',
|
||||
resources_shown: ['url1'],
|
||||
}));
|
||||
|
||||
const r = runDev('--read');
|
||||
expect(r.stdout).toContain('LAST_PROJECT: realproj');
|
||||
expect(r.stdout).toContain('LAST_ASSIGNMENT: real assignment text');
|
||||
expect(r.stdout).toContain('LAST_DESIGN_TITLE: plans/real.md');
|
||||
// Resources still aggregate into RESOURCES_SHOWN.
|
||||
expect(r.stdout).toContain('RESOURCES_SHOWN: url1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
// when the relevant CLI isn't available).
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
parseVersion,
|
||||
fmtVersion,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
cmpVersion,
|
||||
pickNextSlot,
|
||||
markActiveSiblings,
|
||||
resolveVersionPath,
|
||||
} from "../bin/gstack-next-version";
|
||||
|
||||
describe("parseVersion", () => {
|
||||
@@ -150,6 +154,73 @@ describe("markActiveSiblings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVersionPath (monorepo VERSION-path support)", () => {
|
||||
test("CLI flag wins over everything", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-"));
|
||||
try {
|
||||
mkdirSync(join(dir, ".gstack"));
|
||||
writeFileSync(join(dir, ".gstack", "version-path"), "config/VERSION\n");
|
||||
expect(resolveVersionPath("flag/path/VERSION", dir)).toBe("flag/path/VERSION");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(".gstack/version-path config is picked up", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-"));
|
||||
try {
|
||||
mkdirSync(join(dir, ".gstack"));
|
||||
writeFileSync(join(dir, ".gstack", "version-path"), "Tinas Second Brain/health-tracker/VERSION\n");
|
||||
expect(resolveVersionPath(undefined, dir)).toBe("Tinas Second Brain/health-tracker/VERSION");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("trims whitespace and ignores blank lines after the first", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-"));
|
||||
try {
|
||||
mkdirSync(join(dir, ".gstack"));
|
||||
writeFileSync(join(dir, ".gstack", "version-path"), " apps/web/VERSION \n\n# comment-ish line\n");
|
||||
expect(resolveVersionPath(undefined, dir)).toBe("apps/web/VERSION");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("empty config file falls back to default VERSION", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-"));
|
||||
try {
|
||||
mkdirSync(join(dir, ".gstack"));
|
||||
writeFileSync(join(dir, ".gstack", "version-path"), "\n");
|
||||
expect(resolveVersionPath(undefined, dir)).toBe("VERSION");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("missing config file falls back to default VERSION", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-"));
|
||||
try {
|
||||
expect(resolveVersionPath(undefined, dir)).toBe("VERSION");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("empty override string falls back to config/default", () => {
|
||||
// Defensive: "" should NOT win over config — only a non-empty CLI arg should.
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-"));
|
||||
try {
|
||||
mkdirSync(join(dir, ".gstack"));
|
||||
writeFileSync(join(dir, ".gstack", "version-path"), "subproj/VERSION\n");
|
||||
expect(resolveVersionPath("", dir)).toBe("subproj/VERSION");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Integration smoke — only runs if gh is available and authenticated. Confirms
|
||||
// the CLI executes end-to-end against real APIs without crashing.
|
||||
describe("integration (smoke)", () => {
|
||||
@@ -181,5 +252,27 @@ describe("integration (smoke)", () => {
|
||||
expect(Array.isArray(parsed.claimed)).toBe(true);
|
||||
expect(parsed).toHaveProperty("siblings");
|
||||
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
|
||||
expect(parsed).toHaveProperty("version_path", "VERSION"); // default when no config + no flag
|
||||
}, 30_000); // Headroom over the 4-5s wall time of the spawned process under load
|
||||
|
||||
test("CLI runs with --version-path and surfaces it in JSON output", async () => {
|
||||
const proc = Bun.spawnSync([
|
||||
"bun",
|
||||
"run",
|
||||
"./bin/gstack-next-version",
|
||||
"--base",
|
||||
"main",
|
||||
"--bump",
|
||||
"patch",
|
||||
"--current-version",
|
||||
"1.6.3.0",
|
||||
"--workspace-root",
|
||||
"null",
|
||||
"--version-path",
|
||||
"Tinas Second Brain/health-tracker/VERSION",
|
||||
]);
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed).toHaveProperty("version_path", "Tinas Second Brain/health-tracker/VERSION");
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* gstack-upgrade/migrations/v1.40.0.0.sh — migration script unit tests.
|
||||
*
|
||||
* Per #1581: the original script unconditionally `touch`ed its done-marker even
|
||||
* when the jq-gated privacy-map patch was skipped. The fix defers `touch ${DONE}`
|
||||
* until every required repair either succeeded or was provably unnecessary.
|
||||
*
|
||||
* The "regression case" that this file pins is case 2: jq missing + privacy-map
|
||||
* present → no done-marker. Against the buggy script, case 2 fails (marker is
|
||||
* written despite skipped patch); against the fix it passes.
|
||||
*
|
||||
* Strategy: each test sets up an isolated tmpHome with controlled fixture
|
||||
* content, and runs the migration via `spawnSync('bash', [MIGRATION], …)`.
|
||||
* For "jq missing" we point PATH at a curated dir of symlinks to the standard
|
||||
* utilities the script uses, omitting jq. For "jq mutation fails" we point PATH
|
||||
* at a dir containing a jq shim that exits 1.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const MIGRATION = path.join(
|
||||
ROOT,
|
||||
"gstack-upgrade",
|
||||
"migrations",
|
||||
"v1.40.0.0.sh",
|
||||
);
|
||||
|
||||
const NEW_PATTERN = "projects/*/*-eng-review-test-plan-*.md";
|
||||
const REAL_PATH = "/usr/bin:/bin:/opt/homebrew/bin";
|
||||
|
||||
let tmpHome: string;
|
||||
let gstackHome: string;
|
||||
let migrationDir: string;
|
||||
let donePath: string;
|
||||
let allowlistPath: string;
|
||||
let privacyPath: string;
|
||||
let gitattrsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-mig-v1400-"));
|
||||
gstackHome = path.join(tmpHome, ".gstack");
|
||||
migrationDir = path.join(gstackHome, ".migrations");
|
||||
donePath = path.join(migrationDir, "v1.40.0.0.done");
|
||||
allowlistPath = path.join(gstackHome, ".brain-allowlist");
|
||||
privacyPath = path.join(gstackHome, ".brain-privacy-map.json");
|
||||
gitattrsPath = path.join(gstackHome, ".gitattributes");
|
||||
fs.mkdirSync(gstackHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.chmodSync(gstackHome, 0o755);
|
||||
if (fs.existsSync(allowlistPath)) fs.chmodSync(allowlistPath, 0o644);
|
||||
if (fs.existsSync(privacyPath)) fs.chmodSync(privacyPath, 0o644);
|
||||
if (fs.existsSync(gitattrsPath)) fs.chmodSync(gitattrsPath, 0o644);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
/**
|
||||
* Construct a PATH-style directory of symlinks to standard utilities the
|
||||
* migration script needs (mkdir, grep, sed, mv, rm, mktemp, cat, touch, printf,
|
||||
* command, etc.). Optionally omit jq, or substitute a shim.
|
||||
*/
|
||||
function makeCuratedPath(opts: { jq?: "missing" | "shim-fail" | "real" } = {}): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-mig-path-"));
|
||||
const utils = [
|
||||
"bash",
|
||||
"sh",
|
||||
"mkdir",
|
||||
"grep",
|
||||
"sed",
|
||||
"mv",
|
||||
"rm",
|
||||
"mktemp",
|
||||
"cat",
|
||||
"touch",
|
||||
"printf",
|
||||
"command",
|
||||
"echo",
|
||||
"test",
|
||||
"[",
|
||||
"tee",
|
||||
"true",
|
||||
"false",
|
||||
"ls",
|
||||
"chmod",
|
||||
];
|
||||
const realDirs = REAL_PATH.split(":");
|
||||
for (const u of utils) {
|
||||
for (const d of realDirs) {
|
||||
const src = path.join(d, u);
|
||||
if (fs.existsSync(src)) {
|
||||
try {
|
||||
fs.symlinkSync(src, path.join(dir, u));
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const jq = opts.jq ?? "real";
|
||||
if (jq === "real") {
|
||||
for (const d of realDirs) {
|
||||
const src = path.join(d, "jq");
|
||||
if (fs.existsSync(src)) {
|
||||
try {
|
||||
fs.symlinkSync(src, path.join(dir, "jq"));
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (jq === "shim-fail") {
|
||||
const shim = path.join(dir, "jq");
|
||||
fs.writeFileSync(
|
||||
shim,
|
||||
`#!/usr/bin/env bash\necho "fake jq: refusing" >&2\nexit 1\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
// jq === "missing" → don't add anything
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(opts: { path?: string } = {}) {
|
||||
const env = {
|
||||
HOME: tmpHome,
|
||||
PATH: opts.path ?? REAL_PATH,
|
||||
};
|
||||
return spawnSync("bash", [MIGRATION], {
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
cwd: tmpHome,
|
||||
});
|
||||
}
|
||||
|
||||
function freshPrivacyMap() {
|
||||
fs.writeFileSync(
|
||||
privacyPath,
|
||||
JSON.stringify(
|
||||
[{ pattern: "projects/*/*-some-other-*.md", class: "artifact" }],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function freshAllowlist() {
|
||||
fs.writeFileSync(
|
||||
allowlistPath,
|
||||
"# header\nprojects/*/*-some-other-*.md\n# ---- USER ADDITIONS BELOW\n",
|
||||
);
|
||||
}
|
||||
|
||||
function freshGitattrs() {
|
||||
fs.writeFileSync(gitattrsPath, "projects/*/*-some-other-*.md merge=union\n");
|
||||
}
|
||||
|
||||
describe("migrations/v1.40.0.0.sh", () => {
|
||||
test("case 1: jq present, fresh privacy-map — all three files patched, marker written", () => {
|
||||
freshAllowlist();
|
||||
freshPrivacyMap();
|
||||
freshGitattrs();
|
||||
|
||||
const r = run();
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(donePath)).toBe(true);
|
||||
|
||||
const allowlist = fs.readFileSync(allowlistPath, "utf-8");
|
||||
expect(allowlist).toContain(NEW_PATTERN);
|
||||
|
||||
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
||||
expect(
|
||||
privacy.some(
|
||||
(e: any) => e.pattern === NEW_PATTERN && e.class === "artifact",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const gitattrs = fs.readFileSync(gitattrsPath, "utf-8");
|
||||
expect(gitattrs).toContain(`${NEW_PATTERN} merge=union`);
|
||||
});
|
||||
|
||||
test("case 2 (regression for #1581): jq missing, privacy-map exists — marker NOT written, text patches still applied", () => {
|
||||
freshAllowlist();
|
||||
freshPrivacyMap();
|
||||
freshGitattrs();
|
||||
|
||||
const noJq = makeCuratedPath({ jq: "missing" });
|
||||
const r = run({ path: noJq });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toMatch(/jq not found/);
|
||||
|
||||
// Done-marker must NOT be written — this is the whole point of the fix.
|
||||
expect(fs.existsSync(donePath)).toBe(false);
|
||||
|
||||
// Text-only patches still landed (they don't need jq).
|
||||
expect(fs.readFileSync(allowlistPath, "utf-8")).toContain(NEW_PATTERN);
|
||||
expect(fs.readFileSync(gitattrsPath, "utf-8")).toContain(
|
||||
`${NEW_PATTERN} merge=union`,
|
||||
);
|
||||
|
||||
// Privacy-map untouched (still missing the new entry).
|
||||
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
||||
expect(privacy.some((e: any) => e.pattern === NEW_PATTERN)).toBe(false);
|
||||
});
|
||||
|
||||
test("case 3: jq missing, then jq restored — second run completes patch and writes marker", () => {
|
||||
freshAllowlist();
|
||||
freshPrivacyMap();
|
||||
freshGitattrs();
|
||||
|
||||
// First run with jq missing
|
||||
const noJq = makeCuratedPath({ jq: "missing" });
|
||||
const r1 = run({ path: noJq });
|
||||
expect(r1.status).toBe(0);
|
||||
expect(fs.existsSync(donePath)).toBe(false);
|
||||
|
||||
// Second run with jq restored
|
||||
const r2 = run();
|
||||
expect(r2.status).toBe(0);
|
||||
expect(fs.existsSync(donePath)).toBe(true);
|
||||
|
||||
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
||||
expect(
|
||||
privacy.some(
|
||||
(e: any) => e.pattern === NEW_PATTERN && e.class === "artifact",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("case 4: jq present, privacy-map already has correct entry — idempotent, marker written", () => {
|
||||
freshAllowlist();
|
||||
fs.writeFileSync(
|
||||
privacyPath,
|
||||
JSON.stringify(
|
||||
[{ pattern: NEW_PATTERN, class: "artifact" }],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
freshGitattrs();
|
||||
|
||||
const r = run();
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(donePath)).toBe(true);
|
||||
|
||||
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
||||
const matches = privacy.filter((e: any) => e.pattern === NEW_PATTERN);
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0].class).toBe("artifact");
|
||||
});
|
||||
|
||||
test("case 5: jq present, privacy-map file missing — allowlist + gitattrs patched, marker written", () => {
|
||||
freshAllowlist();
|
||||
// No privacy-map file
|
||||
freshGitattrs();
|
||||
|
||||
const r = run();
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(donePath)).toBe(true);
|
||||
expect(fs.existsSync(privacyPath)).toBe(false);
|
||||
|
||||
expect(fs.readFileSync(allowlistPath, "utf-8")).toContain(NEW_PATTERN);
|
||||
expect(fs.readFileSync(gitattrsPath, "utf-8")).toContain(
|
||||
`${NEW_PATTERN} merge=union`,
|
||||
);
|
||||
});
|
||||
|
||||
test("case 6: jq present, privacy-map JSON malformed — no marker, error logged, no mutation", () => {
|
||||
freshAllowlist();
|
||||
fs.writeFileSync(privacyPath, "{ this is not json [");
|
||||
freshGitattrs();
|
||||
|
||||
const r = run();
|
||||
expect(r.status).toBe(0);
|
||||
// No marker — broken JSON should NOT be papered over.
|
||||
expect(fs.existsSync(donePath)).toBe(false);
|
||||
// Privacy-map content untouched.
|
||||
expect(fs.readFileSync(privacyPath, "utf-8")).toBe("{ this is not json [");
|
||||
});
|
||||
|
||||
test("case 7: jq present but mutation fails (shim exit 1) — no marker, tempfile cleaned up", () => {
|
||||
freshAllowlist();
|
||||
freshPrivacyMap();
|
||||
freshGitattrs();
|
||||
|
||||
const fakeJq = makeCuratedPath({ jq: "shim-fail" });
|
||||
const r = run({ path: fakeJq });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(donePath)).toBe(false);
|
||||
|
||||
// Tempfile cleanup: no leftover *.tmp.* sidecars.
|
||||
const leftovers = fs
|
||||
.readdirSync(gstackHome)
|
||||
.filter((n) => n.startsWith(".brain-privacy-map.json.tmp."));
|
||||
expect(leftovers.length).toBe(0);
|
||||
});
|
||||
|
||||
test("case 8: allowlist append fails (read-only file, no USER ADDITIONS marker) — no marker, warn logged", () => {
|
||||
// Allowlist WITHOUT the "# ---- USER ADDITIONS BELOW" marker — the script
|
||||
// falls into the plain `printf >>` append path. Make the file read-only
|
||||
// so the append fails (sed -i.bak on macOS silently no-ops on read-only
|
||||
// files, so we have to take the printf path to exercise this).
|
||||
fs.writeFileSync(
|
||||
allowlistPath,
|
||||
"# header\nprojects/*/*-some-other-*.md\n",
|
||||
);
|
||||
freshPrivacyMap();
|
||||
freshGitattrs();
|
||||
fs.chmodSync(allowlistPath, 0o444);
|
||||
|
||||
const r = run();
|
||||
expect(r.status).toBe(0);
|
||||
// Marker must NOT be written when a required repair failed.
|
||||
expect(fs.existsSync(donePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FILES = ['investigate/SKILL.md.tmpl', 'investigate/SKILL.md'];
|
||||
|
||||
describe('investigate freeze path resolution', () => {
|
||||
for (const rel of FILES) {
|
||||
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
|
||||
test(`${rel} hook falls back to standalone gstack-freeze install`, () => {
|
||||
expect(content).toContain('${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh');
|
||||
expect(content).toContain('${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh');
|
||||
expect(content).toContain('[ -x "$S" ] && bash "$S" || exit 0');
|
||||
expect(content).toContain("command: 'bash -c ''");
|
||||
});
|
||||
|
||||
test(`${rel} scope lock availability check supports standalone install`, () => {
|
||||
expect(content).toContain('_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"');
|
||||
expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"');
|
||||
expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Static invariant test for #1671: nothing in production code should
|
||||
* append directly to ~/.gstack/builder-profile.jsonl. All session writes
|
||||
* must go through `gstack-developer-profile --log-session`. The legacy
|
||||
* file is now read-only — populated only by the pre-existing migration
|
||||
* and reconcile paths in bin/gstack-developer-profile.
|
||||
*
|
||||
* Prevents future regressions onto the legacy file that would re-create
|
||||
* the original bug (writer and reader disagreeing on storage location).
|
||||
*
|
||||
* Mirrors `test/setup-windows-fallback.test.ts`'s style — static invariant
|
||||
* via grep, resilient to line-number drift.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
// Paths allowed to mention builder-profile.jsonl. These read the file
|
||||
// or document its existence — they do not write to it.
|
||||
const ALLOWED_FILES = new Set<string>([
|
||||
// The binary that reads + reconciles the legacy file.
|
||||
'bin/gstack-developer-profile',
|
||||
// The legacy-shim binary that delegates reads.
|
||||
'bin/gstack-builder-profile',
|
||||
// Memory-ingest reads the legacy file during reconcile period.
|
||||
'bin/gstack-memory-ingest.ts',
|
||||
// The artifacts-init template registers the legacy file in
|
||||
// .brain-allowlist/.brain-privacy-map for users with pre-existing data.
|
||||
'bin/gstack-artifacts-init',
|
||||
// Documentation files mention the path.
|
||||
'CHANGELOG.md',
|
||||
'TODOS.md',
|
||||
'README.md',
|
||||
'office-hours/SKILL.md.tmpl',
|
||||
'office-hours/SKILL.md',
|
||||
'setup-gbrain/memory.md',
|
||||
'docs/designs/FIX_1671_PROFILE_MIGRATION.md',
|
||||
'docs/designs/PLAN_TUNING_V0.md',
|
||||
'docs/designs/PLAN_TUNING_V1.md',
|
||||
]);
|
||||
|
||||
// Directories to skip when walking the repo. Everything else is in scope —
|
||||
// any skill dir, migration script, resolver, or new top-level dir gets
|
||||
// covered automatically as the repo grows. Catches the "future contributor
|
||||
// adds the legacy write in retro/SKILL.md.tmpl" regression class.
|
||||
const SKIP_DIRS = new Set<string>([
|
||||
'node_modules', '.git', '.github', 'dist', 'test', 'docs',
|
||||
// Vendored binaries / build outputs.
|
||||
'browse/dist', 'design/dist', 'extension/node_modules',
|
||||
// The plan file's directory was already in ALLOWED_FILES; skip docs/ entirely.
|
||||
]);
|
||||
|
||||
function listSearchDirs(): string[] {
|
||||
return fs
|
||||
.readdirSync(ROOT, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !SKIP_DIRS.has(d.name) && !d.name.startsWith('.'))
|
||||
.map((d) => d.name);
|
||||
}
|
||||
|
||||
const SEARCH_DIRS = listSearchDirs();
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walk(p);
|
||||
} else if (entry.isFile()) {
|
||||
yield p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match any literal-path append/write pattern targeting builder-profile.jsonl.
|
||||
// Captures: `>> .../builder-profile.jsonl`, `writeFileSync(...builder-profile.jsonl...)`,
|
||||
// `> .../builder-profile.jsonl`. NOTE: this only catches LITERAL-PATH writes —
|
||||
// variable-indirected writes (`FILE=...builder-profile.jsonl; echo >> "$FILE"`)
|
||||
// are not detected. The SKILL.md.tmpl assertions below pin the exact #1671
|
||||
// regression class directly; this regex is a backstop against the obvious
|
||||
// pattern, not a comprehensive variable-flow analyzer.
|
||||
const WRITE_PATTERN = /(>>?\s*["']?[^"'\s]*builder-profile\.jsonl|writeFileSync\([^)]*builder-profile\.jsonl|appendFileSync\([^)]*builder-profile\.jsonl)/;
|
||||
|
||||
describe('#1671 invariant: no production code writes to builder-profile.jsonl', () => {
|
||||
test('only allowlisted files mention writes to builder-profile.jsonl', () => {
|
||||
const offending: { file: string; line: number; content: string }[] = [];
|
||||
|
||||
for (const searchDir of SEARCH_DIRS) {
|
||||
const fullDir = path.join(ROOT, searchDir);
|
||||
if (!fs.existsSync(fullDir)) continue;
|
||||
|
||||
for (const filePath of walk(fullDir)) {
|
||||
const rel = path.relative(ROOT, filePath);
|
||||
|
||||
// Skip allowlisted files.
|
||||
if (ALLOWED_FILES.has(rel)) continue;
|
||||
|
||||
// Only check text-like extensions to avoid binary files.
|
||||
if (!/\.(sh|ts|js|md|tmpl)$/.test(rel) && !rel.startsWith('bin/')) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
lines.forEach((line, idx) => {
|
||||
if (WRITE_PATTERN.test(line)) {
|
||||
offending.push({ file: rel, line: idx + 1, content: line.trim() });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (offending.length > 0) {
|
||||
const msg = offending
|
||||
.map((o) => ` ${o.file}:${o.line} ${o.content}`)
|
||||
.join('\n');
|
||||
throw new Error(
|
||||
`Found production writes to builder-profile.jsonl outside the allowlist.\n` +
|
||||
`These would re-create #1671 (writer/reader file mismatch).\n` +
|
||||
`Use \`gstack-developer-profile --log-session\` instead.\n${msg}`,
|
||||
);
|
||||
}
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
|
||||
test('office-hours/SKILL.md uses --log-session, not raw echo append', () => {
|
||||
const skill = fs.readFileSync(path.join(ROOT, 'office-hours/SKILL.md'), 'utf-8');
|
||||
// The two known writer call-sites must use the new subcommand.
|
||||
expect(skill).toContain('gstack-developer-profile --log-session');
|
||||
// And must NOT contain the old echo-append pattern.
|
||||
expect(skill).not.toMatch(/echo\s+['"][^'"]*['"]?\s*>>\s*["'][^"']*builder-profile\.jsonl/);
|
||||
});
|
||||
|
||||
test('office-hours/SKILL.md.tmpl uses --log-session, not raw echo append', () => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, 'office-hours/SKILL.md.tmpl'), 'utf-8');
|
||||
expect(tmpl).toContain('gstack-developer-profile --log-session');
|
||||
expect(tmpl).not.toMatch(/echo\s+['"][^'"]*['"]?\s*>>\s*["'][^"']*builder-profile\.jsonl/);
|
||||
});
|
||||
});
|
||||
+26
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import { execFileSync, execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -42,6 +42,20 @@ function runRead(args: string = ''): string {
|
||||
}
|
||||
}
|
||||
|
||||
function runReadArgs(args: string[] = []): string {
|
||||
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, GSTACK_HOME: tmpDir },
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
};
|
||||
try {
|
||||
return execFileSync(path.join(BIN, 'gstack-timeline-read'), args, execOpts).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-'));
|
||||
slugDir = path.join(tmpDir, 'projects');
|
||||
@@ -136,6 +150,17 @@ describe('gstack-timeline-read', () => {
|
||||
expect(output).not.toContain('feature-b');
|
||||
});
|
||||
|
||||
test('filters branch names containing single quotes', () => {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: "feature/o'hare", outcome: 'approved', ts: '2026-03-28T10:00:00Z' }));
|
||||
runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-other', outcome: 'merged', ts: '2026-03-28T11:00:00Z' }));
|
||||
|
||||
const output = runReadArgs(['--branch', "feature/o'hare"]);
|
||||
|
||||
expect(output).toContain('review');
|
||||
expect(output).toContain("feature/o'hare");
|
||||
expect(output).not.toContain('feature-other');
|
||||
});
|
||||
|
||||
test('limits output with --limit', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', outcome: 'approved', ts: `2026-03-2${i}T10:00:00Z` }));
|
||||
|
||||
Reference in New Issue
Block a user