diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index f99c96591..9691c226e 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -291,6 +291,14 @@ projects/*/*-design-*.md projects/*/*-test-plan-*.md projects/*/*-eng-review-test-plan-*.md projects/*/timeline.jsonl +# The decision store. gstack-decision-log enqueues projects//decisions.jsonl +# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected +# all of them at its "must match at least one allowlist glob" check -- a writer +# enqueueing a path the syncer is guaranteed to drop. Without these the durable +# decision ledger never leaves the machine, on any platform. +projects/*/decisions.jsonl +projects/*/decisions.active.json +projects/*/decisions.archive.jsonl retros/*.md developer-profile.json builder-journey.md @@ -318,6 +326,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' {"pattern": "projects/*/*-design-*.md", "class": "artifact"}, {"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"}, {"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/decisions.jsonl", "class": "artifact"}, + {"pattern": "projects/*/decisions.active.json", "class": "artifact"}, + {"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"}, {"pattern": "retros/*.md", "class": "artifact"}, {"pattern": "builder-journey.md", "class": "artifact"}, {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, diff --git a/test/artifacts-allowlist-decisions.test.ts b/test/artifacts-allowlist-decisions.test.ts new file mode 100644 index 000000000..7908cd28d --- /dev/null +++ b/test/artifacts-allowlist-decisions.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const INIT = fs.readFileSync(path.join(ROOT, "bin", "gstack-artifacts-init"), "utf-8"); + +/** Pull a quoted heredoc body out of gstack-artifacts-init by target filename. */ +function heredoc(target: string): string { + const re = new RegExp(`cat > "\\$GSTACK_HOME/${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`); + const m = INIT.match(re); + if (!m) throw new Error(`heredoc for ${target} not found in gstack-artifacts-init`); + return m[1]; +} + +/** fnmatch.fnmatchcase semantics, as compute_paths_to_stage applies them: + * `*` does not cross a path separator. */ +function globToRe(g: string): RegExp { + return new RegExp("^" + g.split("*").map((s) => s.replace(/[.]/g, "[.]")).join("[^/]*") + "$"); +} + +const DECISION_PATHS = [ + "projects/acme-widget/decisions.jsonl", + "projects/acme-widget/decisions.active.json", + "projects/acme-widget/decisions.archive.jsonl", +]; + +/** + * gstack-decision-log:40 enqueues projects//decisions.jsonl after EVERY write, + * but no managed glob matched it, so compute_paths_to_stage rejected all of them at + * its "must match at least one allowlist glob" check. The writer and the syncer + * disagreed silently: turning artifacts sync on backed up learnings, plans, designs + * and timelines -- everything EXCEPT the durable decision ledger -- and nothing + * anywhere reported a miss, because a dropped path prints exactly what a synced one + * does when the queue is otherwise empty. + * + * Source-level rather than end-to-end: gstack-artifacts-init.test.ts drives the real + * script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on + * Windows -- which is the platform where this bug bit. + */ +describe("the artifacts allowlist covers the decision store", () => { + const globs = heredoc("\\.brain-allowlist") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + + test("every decisions.* path matches at least one allowlist glob", () => { + for (const p of DECISION_PATHS) { + expect({ p, matched: globs.some((g) => globToRe(g).test(p)) }).toEqual({ p, matched: true }); + } + }); + + test("decisions.* are class artifact, so they sync in artifacts-only mode too", () => { + const map = JSON.parse(heredoc("\\.brain-privacy-map\\.json")); + for (const p of DECISION_PATHS) { + const hit = map.find((e: { pattern: string; class: string }) => globToRe(e.pattern).test(p)); + expect({ p, cls: hit?.class }).toEqual({ p, cls: "artifact" }); + } + }); + + test("the allowlist still ends with the user-additions marker", () => { + // Additions below it survive re-init; a glob added above would be silently + // overwritten the next time gstack-artifacts-init runs. + expect(heredoc("\\.brain-allowlist").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/); + }); +});