mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-22 09:39:59 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/sidebar-claude-timeouts
This commit is contained in:
@@ -163,6 +163,33 @@ describe('gstack-model-benchmark prompt resolution', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('positional file still works when value flags come first', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bench-prompt-'));
|
||||
const promptFile = path.join(tmp, 'prompt.txt');
|
||||
fs.writeFileSync(promptFile, 'hello after flags');
|
||||
try {
|
||||
const r = run(['--models', 'claude', '--output', 'json', promptFile, '--dry-run']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('hello after flags');
|
||||
expect(r.stdout).not.toContain('EISDIR');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('positional file still works after equals-form value flags', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bench-prompt-'));
|
||||
const promptFile = path.join(tmp, 'prompt.txt');
|
||||
fs.writeFileSync(promptFile, 'hello after equals flags');
|
||||
try {
|
||||
const r = run(['--models=claude', '--output=markdown', promptFile, '--dry-run']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('hello after equals flags');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('positional non-file arg is treated as inline prompt', () => {
|
||||
const r = run(['treat-me-as-inline', '--dry-run']);
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
import { buildGbrainEnv } from "../lib/gbrain-exec";
|
||||
import { buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec";
|
||||
|
||||
describe("buildGbrainEnv", () => {
|
||||
let home: string;
|
||||
@@ -117,4 +117,74 @@ describe("buildGbrainEnv", () => {
|
||||
const result = buildGbrainEnv({ baseEnv });
|
||||
expect(result.DATABASE_URL).toBe("postgresql://gbrain/db");
|
||||
});
|
||||
|
||||
// --- GBRAIN_PREPARE auto-detection (#1435) ---
|
||||
|
||||
it("sets GBRAIN_PREPARE=true when DATABASE_URL targets port 6543 (transaction-mode pooler)", () => {
|
||||
const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres";
|
||||
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl }));
|
||||
const baseEnv = { HOME: home };
|
||||
const result = buildGbrainEnv({ baseEnv });
|
||||
expect(result.DATABASE_URL).toBe(poolerUrl);
|
||||
expect(result.GBRAIN_PREPARE).toBe("true");
|
||||
});
|
||||
|
||||
it("does not set GBRAIN_PREPARE when DATABASE_URL targets port 5432 (session-mode pooler)", () => {
|
||||
const sessionUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:5432/postgres";
|
||||
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: sessionUrl }));
|
||||
const baseEnv = { HOME: home };
|
||||
const result = buildGbrainEnv({ baseEnv });
|
||||
expect(result.GBRAIN_PREPARE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set GBRAIN_PREPARE for pglite (no port in URL)", () => {
|
||||
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://gbrain/db" }));
|
||||
const baseEnv = { HOME: home };
|
||||
const result = buildGbrainEnv({ baseEnv });
|
||||
expect(result.GBRAIN_PREPARE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects caller's explicit GBRAIN_PREPARE=false (opt-out)", () => {
|
||||
const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres";
|
||||
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl }));
|
||||
const baseEnv = { HOME: home, GBRAIN_PREPARE: "false" };
|
||||
const result = buildGbrainEnv({ baseEnv });
|
||||
expect(result.GBRAIN_PREPARE).toBe("false");
|
||||
});
|
||||
|
||||
it("sets GBRAIN_PREPARE even when caller DATABASE_URL already matches config on port 6543", () => {
|
||||
const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres";
|
||||
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl }));
|
||||
const baseEnv = { HOME: home, DATABASE_URL: poolerUrl };
|
||||
const result = buildGbrainEnv({ baseEnv });
|
||||
expect(result.GBRAIN_PREPARE).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransactionModePooler", () => {
|
||||
it("returns true for Supabase transaction-mode pooler URL (port 6543)", () => {
|
||||
expect(isTransactionModePooler(
|
||||
"postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres"
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for session-mode pooler URL (port 5432)", () => {
|
||||
expect(isTransactionModePooler(
|
||||
"postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for pglite-style URL (no port)", () => {
|
||||
expect(isTransactionModePooler("postgresql://gbrain/db")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unparseable URL", () => {
|
||||
expect(isTransactionModePooler("not-a-url")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles postgres:// scheme (without 'ql')", () => {
|
||||
expect(isTransactionModePooler(
|
||||
"postgres://postgres.abc:pw@host:6543/postgres"
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,14 @@ function scanDocsForConfigKeys(): { docPath: string; key: string; line: number }
|
||||
return hits;
|
||||
}
|
||||
|
||||
function runConfig(args: string[], tmpHome: string) {
|
||||
return spawnSync(CONFIG_BIN, args, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome },
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
describe('docs ↔ gstack-config key drift guard', () => {
|
||||
test('docs/ references at least one config key (smoke)', () => {
|
||||
const hits = scanDocsForConfigKeys();
|
||||
@@ -65,15 +73,32 @@ describe('docs ↔ gstack-config key drift guard', () => {
|
||||
// without a Git Bash interpreter shim. Skip on Windows — the deprecated-key
|
||||
// denylist test above already pins the v1.27.0.0 rename behavior at the
|
||||
// doc layer, which is the actual invariant this wave defends.
|
||||
test.skipIf(process.platform === 'win32')('`explain_level` is exposed as a documented default', () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-cfg-'));
|
||||
try {
|
||||
const get = runConfig(['get', 'explain_level'], tmpHome);
|
||||
expect(get.status).toBe(0);
|
||||
expect(get.stdout.trim()).toBe('default');
|
||||
|
||||
const defaults = runConfig(['defaults'], tmpHome);
|
||||
expect(defaults.status).toBe(0);
|
||||
expect(defaults.stdout).toContain('explain_level:');
|
||||
expect(defaults.stdout).toContain('default');
|
||||
|
||||
const list = runConfig(['list'], tmpHome);
|
||||
expect(list.status).toBe(0);
|
||||
expect(list.stdout).toContain('explain_level:');
|
||||
expect(list.stdout).toContain('default');
|
||||
} finally {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('`gstack-config get artifacts_sync_mode` returns a value (the rename landed)', () => {
|
||||
// Run from a clean HOME so the user's local config doesn't pollute.
|
||||
const tmpHome = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-cfg-'));
|
||||
try {
|
||||
const result = spawnSync(CONFIG_BIN, ['get', 'artifacts_sync_mode'], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome },
|
||||
timeout: 5000,
|
||||
});
|
||||
const result = runConfig(['get', 'artifacts_sync_mode'], tmpHome);
|
||||
expect(result.status).toBe(0);
|
||||
// A known key returns its default value, not the "unknown key" error string.
|
||||
expect(result.stderr).not.toContain('not recognized');
|
||||
|
||||
+37
@@ -1921,6 +1921,43 @@ Example:
|
||||
\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\`
|
||||
\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\`
|
||||
|
||||
### Pre-emit verification gate (#1539 — kills the "field doesn't exist" FP class)
|
||||
|
||||
Before any finding is promoted to the report, the gate requires:
|
||||
|
||||
1. **Quote the specific code line that motivates the finding** — file:line plus
|
||||
the verbatim text of the line(s) that triggered it. If the finding is "field
|
||||
X doesn't exist on model Y", quote the lines of class Y where the field
|
||||
would live. If "dict.get() might return None", quote the dict initialization.
|
||||
If "race condition between A and B", quote both A and B.
|
||||
|
||||
2. **If you cannot quote the motivating line(s), the finding is unverified.**
|
||||
Force its confidence to 4-5 (suppressed from the main report). It still goes
|
||||
into the appendix so reviewers can audit calibration, but the user does NOT
|
||||
see it in the critical-pass output. Do not work around this by inventing
|
||||
speculative confidence 7+ — that defeats the gate.
|
||||
|
||||
**Framework-meta nudge:** When the symbol is generated by a framework
|
||||
metaclass, descriptor, ORM Meta inner-class, or migration history (Django
|
||||
`Meta`, Rails `has_many`/`scope`, SQLAlchemy `relationship`/`Column`,
|
||||
TypeORM decorators, Sequelize `init`/`belongsTo`, Prisma generated client),
|
||||
quote the meta-construct (the `Meta` block, the migration, the decorator,
|
||||
the schema file) instead of expecting the literal name in the class body.
|
||||
The verification is "I read the source that creates this symbol", not "I
|
||||
grep'd for the name and didn't find it." Deeper framework-aware verification
|
||||
(model introspection, migration-history-aware checks, ORM dialect detection)
|
||||
is deliberately out of scope for the lighter gate — see the deferred
|
||||
`~/.gstack-dev/plans/1539-framework-aware-review.md` design doc.
|
||||
|
||||
The FP classes the gate kills (measured against Django Sprint 2.5 #1539):
|
||||
|
||||
| FP class | Why the gate catches it |
|
||||
|---|---|
|
||||
| "field doesn't exist on model" | Requires quoting the model class body or Meta; the field's absence becomes obvious |
|
||||
| "dict.get() might be None" | Requires quoting the dict initialization (e.g. Django form's `cleaned_data` is `{}`-initialized) |
|
||||
| "save() might lose fields" | Requires quoting the ORM signature or model definition |
|
||||
| "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident |
|
||||
|
||||
**Calibration learning:** If you report a finding with confidence < 7 and the user
|
||||
confirms it IS a real issue, that is a calibration event. Your initial confidence was
|
||||
too low. Log the corrected pattern as a learning so future reviews catch it with
|
||||
|
||||
+37
@@ -1883,6 +1883,43 @@ Example:
|
||||
\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\`
|
||||
\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\`
|
||||
|
||||
### Pre-emit verification gate (#1539 — kills the "field doesn't exist" FP class)
|
||||
|
||||
Before any finding is promoted to the report, the gate requires:
|
||||
|
||||
1. **Quote the specific code line that motivates the finding** — file:line plus
|
||||
the verbatim text of the line(s) that triggered it. If the finding is "field
|
||||
X doesn't exist on model Y", quote the lines of class Y where the field
|
||||
would live. If "dict.get() might return None", quote the dict initialization.
|
||||
If "race condition between A and B", quote both A and B.
|
||||
|
||||
2. **If you cannot quote the motivating line(s), the finding is unverified.**
|
||||
Force its confidence to 4-5 (suppressed from the main report). It still goes
|
||||
into the appendix so reviewers can audit calibration, but the user does NOT
|
||||
see it in the critical-pass output. Do not work around this by inventing
|
||||
speculative confidence 7+ — that defeats the gate.
|
||||
|
||||
**Framework-meta nudge:** When the symbol is generated by a framework
|
||||
metaclass, descriptor, ORM Meta inner-class, or migration history (Django
|
||||
`Meta`, Rails `has_many`/`scope`, SQLAlchemy `relationship`/`Column`,
|
||||
TypeORM decorators, Sequelize `init`/`belongsTo`, Prisma generated client),
|
||||
quote the meta-construct (the `Meta` block, the migration, the decorator,
|
||||
the schema file) instead of expecting the literal name in the class body.
|
||||
The verification is "I read the source that creates this symbol", not "I
|
||||
grep'd for the name and didn't find it." Deeper framework-aware verification
|
||||
(model introspection, migration-history-aware checks, ORM dialect detection)
|
||||
is deliberately out of scope for the lighter gate — see the deferred
|
||||
`~/.gstack-dev/plans/1539-framework-aware-review.md` design doc.
|
||||
|
||||
The FP classes the gate kills (measured against Django Sprint 2.5 #1539):
|
||||
|
||||
| FP class | Why the gate catches it |
|
||||
|---|---|
|
||||
| "field doesn't exist on model" | Requires quoting the model class body or Meta; the field's absence becomes obvious |
|
||||
| "dict.get() might be None" | Requires quoting the dict initialization (e.g. Django form's `cleaned_data` is `{}`-initialized) |
|
||||
| "save() might lose fields" | Requires quoting the ORM signature or model definition |
|
||||
| "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident |
|
||||
|
||||
**Calibration learning:** If you report a finding with confidence < 7 and the user
|
||||
confirms it IS a real issue, that is a calibration event. Your initial confidence was
|
||||
too low. Log the corrected pattern as a learning so future reviews catch it with
|
||||
|
||||
+37
@@ -1912,6 +1912,43 @@ Example:
|
||||
\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\`
|
||||
\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\`
|
||||
|
||||
### Pre-emit verification gate (#1539 — kills the "field doesn't exist" FP class)
|
||||
|
||||
Before any finding is promoted to the report, the gate requires:
|
||||
|
||||
1. **Quote the specific code line that motivates the finding** — file:line plus
|
||||
the verbatim text of the line(s) that triggered it. If the finding is "field
|
||||
X doesn't exist on model Y", quote the lines of class Y where the field
|
||||
would live. If "dict.get() might return None", quote the dict initialization.
|
||||
If "race condition between A and B", quote both A and B.
|
||||
|
||||
2. **If you cannot quote the motivating line(s), the finding is unverified.**
|
||||
Force its confidence to 4-5 (suppressed from the main report). It still goes
|
||||
into the appendix so reviewers can audit calibration, but the user does NOT
|
||||
see it in the critical-pass output. Do not work around this by inventing
|
||||
speculative confidence 7+ — that defeats the gate.
|
||||
|
||||
**Framework-meta nudge:** When the symbol is generated by a framework
|
||||
metaclass, descriptor, ORM Meta inner-class, or migration history (Django
|
||||
`Meta`, Rails `has_many`/`scope`, SQLAlchemy `relationship`/`Column`,
|
||||
TypeORM decorators, Sequelize `init`/`belongsTo`, Prisma generated client),
|
||||
quote the meta-construct (the `Meta` block, the migration, the decorator,
|
||||
the schema file) instead of expecting the literal name in the class body.
|
||||
The verification is "I read the source that creates this symbol", not "I
|
||||
grep'd for the name and didn't find it." Deeper framework-aware verification
|
||||
(model introspection, migration-history-aware checks, ORM dialect detection)
|
||||
is deliberately out of scope for the lighter gate — see the deferred
|
||||
`~/.gstack-dev/plans/1539-framework-aware-review.md` design doc.
|
||||
|
||||
The FP classes the gate kills (measured against Django Sprint 2.5 #1539):
|
||||
|
||||
| FP class | Why the gate catches it |
|
||||
|---|---|
|
||||
| "field doesn't exist on model" | Requires quoting the model class body or Meta; the field's absence becomes obvious |
|
||||
| "dict.get() might be None" | Requires quoting the dict initialization (e.g. Django form's `cleaned_data` is `{}`-initialized) |
|
||||
| "save() might lose fields" | Requires quoting the ORM signature or model definition |
|
||||
| "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident |
|
||||
|
||||
**Calibration learning:** If you report a finding with confidence < 7 and the user
|
||||
confirms it IS a real issue, that is a calibration event. Your initial confidence was
|
||||
too low. Log the corrected pattern as a learning so future reviews catch it with
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests the voyage-code-3 default contract in setup-gbrain's PGLite init
|
||||
* sequences. The contract lives in the skill TEMPLATE (.tmpl), not in a TS
|
||||
* helper — the skill follows AI-readable instructions.
|
||||
*
|
||||
* Contract (asserted here):
|
||||
* 1. When VOYAGE_API_KEY is set, gstack's PGLite init passes
|
||||
* --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||
* 2. When VOYAGE_API_KEY is unset, those flags are omitted (gbrain's
|
||||
* auto-selected provider chain takes over)
|
||||
*
|
||||
* Why a separate file from gbrain-init-rollback.test.ts: that file owns the
|
||||
* .bak-rollback contract (Step 1.5 / 4.5 plan D7). This file owns the
|
||||
* embedding-model selection contract. Both extract bash from the skill
|
||||
* template and execute it against a fake gbrain.
|
||||
*
|
||||
* The fake gbrain records argv to a sentinel file so the test can assert
|
||||
* exact flags. No Voyage API calls are made.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
interface FakeEnv {
|
||||
tmp: string;
|
||||
home: string;
|
||||
bindir: string;
|
||||
argvLog: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeFakeEnv(): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-voyage-init-"));
|
||||
const home = join(tmp, "home");
|
||||
const bindir = join(tmp, "bin");
|
||||
const argvLog = join(tmp, "gbrain-argv.log");
|
||||
mkdirSync(join(home, ".gbrain"), { recursive: true });
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
|
||||
// Fake gbrain logs every argv invocation to argvLog (one line per call),
|
||||
// succeeds on init (writes a sentinel pglite config), and returns canned
|
||||
// output for --version. Nothing else is needed for the shape test.
|
||||
const fake = `#!/bin/sh
|
||||
echo "$@" >> "${argvLog}"
|
||||
case "$1" in
|
||||
--version)
|
||||
echo "gbrain 0.37.1.0"
|
||||
exit 0
|
||||
;;
|
||||
init)
|
||||
cat > "${home}/.gbrain/config.json" <<JSON
|
||||
{"engine":"pglite","database_path":"${home}/.gbrain/brain.pglite"}
|
||||
JSON
|
||||
echo '{"status":"success","engine":"pglite","pages":0}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
writeFileSync(join(bindir, "gbrain"), fake);
|
||||
chmodSync(join(bindir, "gbrain"), 0o755);
|
||||
|
||||
return {
|
||||
tmp,
|
||||
home,
|
||||
bindir,
|
||||
argvLog,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reimplementation of the skill template's voyage-code-3
|
||||
* conditional. The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5
|
||||
* inside the rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the
|
||||
* model to execute this bash; we execute the same bash here and assert the
|
||||
* argv passed to gbrain matches the contract.
|
||||
*
|
||||
* If the template changes the flag set or the env-var name, this test
|
||||
* should fail until the shell here is updated too — by design.
|
||||
*/
|
||||
function runInitWithVoyageGate(env: FakeEnv, voyageKey: string | undefined): string[] {
|
||||
const script = `
|
||||
set -u
|
||||
GBRAIN_EMBED_FLAGS=""
|
||||
if [ -n "\${VOYAGE_API_KEY:-}" ]; then
|
||||
GBRAIN_EMBED_FLAGS="--embedding-model voyage:voyage-code-3 --embedding-dimensions 1024"
|
||||
fi
|
||||
gbrain init --pglite --json $GBRAIN_EMBED_FLAGS
|
||||
`;
|
||||
const baseEnv: Record<string, string> = {
|
||||
...process.env,
|
||||
HOME: env.home,
|
||||
PATH: `${env.bindir}:/usr/bin:/bin`,
|
||||
};
|
||||
if (voyageKey === undefined) {
|
||||
delete baseEnv.VOYAGE_API_KEY;
|
||||
} else {
|
||||
baseEnv.VOYAGE_API_KEY = voyageKey;
|
||||
}
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf-8",
|
||||
env: baseEnv,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`init script exited ${result.status}: ${result.stderr}`);
|
||||
}
|
||||
return readFileSync(env.argvLog, "utf-8").trim().split("\n");
|
||||
}
|
||||
|
||||
describe("voyage-code-3 default for gstack-driven PGLite init", () => {
|
||||
it("passes voyage-code-3 flags when VOYAGE_API_KEY is set", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set");
|
||||
expect(calls.length).toBe(1);
|
||||
const argv = calls[0];
|
||||
expect(argv).toContain("init --pglite --json");
|
||||
expect(argv).toContain("--embedding-model voyage:voyage-code-3");
|
||||
expect(argv).toContain("--embedding-dimensions 1024");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, undefined);
|
||||
expect(calls.length).toBe(1);
|
||||
const argv = calls[0];
|
||||
expect(argv).toContain("init --pglite --json");
|
||||
expect(argv).not.toContain("voyage");
|
||||
expect(argv).not.toContain("--embedding-model");
|
||||
expect(argv).not.toContain("--embedding-dimensions");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]).not.toContain("voyage");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template alignment: the .tmpl actually contains the voyage gate", () => {
|
||||
// Belt-and-suspenders: if someone edits the template and drops the
|
||||
// VOYAGE_API_KEY conditional without updating the test above, this catches
|
||||
// it. The shell snippet under test must literally appear in the .tmpl.
|
||||
const TEMPLATE_PATH = join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl");
|
||||
const tmpl = readFileSync(TEMPLATE_PATH, "utf-8");
|
||||
|
||||
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY", () => {
|
||||
// Should appear at least once (currently 3 init sites use the same gate).
|
||||
expect(tmpl).toContain('if [ -n "${VOYAGE_API_KEY:-}" ]; then');
|
||||
expect(tmpl).toContain("--embedding-model voyage:voyage-code-3");
|
||||
expect(tmpl).toContain("--embedding-dimensions 1024");
|
||||
});
|
||||
|
||||
it("setup-gbrain template uses the conditional gate at all 3 PGLite init sites", () => {
|
||||
// Count the gate occurrences. If a future edit adds/removes a PGLite
|
||||
// init site, update this expectation deliberately.
|
||||
const matches = tmpl.match(/if \[ -n "\$\{VOYAGE_API_KEY:-\}" \]; then/g);
|
||||
expect(matches?.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Coverage for #1606 — `_gstack_gbrain_validate_varname` LC_ALL=C pin.
|
||||
*
|
||||
* Without the `local LC_ALL=C`, macOS default locale (en_US.UTF-8) makes
|
||||
* `case "$name" in [A-Z_][A-Z0-9_]*)` match lowercase letters too —
|
||||
* lower-case identifiers pass validation and then trip `printf -v "$varname"`
|
||||
* with "not a valid identifier" the caller can't distinguish from other
|
||||
* failures.
|
||||
*
|
||||
* Tests exercise the validator by sourcing bin/gstack-gbrain-lib.sh and
|
||||
* calling _gstack_gbrain_validate_varname directly. Asserts:
|
||||
* - Valid uppercase identifiers accepted (return 0)
|
||||
* - Lowercase identifiers REJECTED (return 2) — pre-#1606 regression case
|
||||
* - Mixed-case rejected
|
||||
* - Empty name rejected
|
||||
* - Names starting with digit rejected
|
||||
* - Underscore prefix accepted
|
||||
* - LC_ALL=C does not leak to caller (local scope preserved)
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const LIB = path.join(ROOT, "bin", "gstack-gbrain-lib.sh");
|
||||
|
||||
function runValidator(name: string): { status: number | null } {
|
||||
// Source the lib then run the validator against the input. Use bash -c with
|
||||
// single-quoted body to avoid double interpolation. LANG=en_US.UTF-8 set
|
||||
// explicitly so the test catches the macOS locale FP case even when CI's
|
||||
// default locale would mask it.
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["-c", `. "${LIB}"; _gstack_gbrain_validate_varname "$1"`, "bash", name],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
env: { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" },
|
||||
},
|
||||
);
|
||||
return { status: result.status };
|
||||
}
|
||||
|
||||
describe("#1606 _gstack_gbrain_validate_varname — LC_ALL=C pin", () => {
|
||||
test("ACCEPTS uppercase identifier (canonical happy path)", () => {
|
||||
expect(runValidator("DATABASE_URL").status).toBe(0);
|
||||
});
|
||||
|
||||
test("ACCEPTS uppercase + digits + underscores", () => {
|
||||
expect(runValidator("GBRAIN_DB_URL_v2".toUpperCase()).status).toBe(0);
|
||||
expect(runValidator("X1_2_3").status).toBe(0);
|
||||
});
|
||||
|
||||
test("ACCEPTS underscore-prefixed identifier", () => {
|
||||
expect(runValidator("_PRIVATE_VAR").status).toBe(0);
|
||||
});
|
||||
|
||||
test("REJECTS lowercase identifier (#1606 regression — would pass on macOS without LC_ALL=C)", () => {
|
||||
expect(runValidator("lower_case").status).toBe(2);
|
||||
});
|
||||
|
||||
test("REJECTS mixed-case identifier", () => {
|
||||
expect(runValidator("MixedCase").status).toBe(2);
|
||||
expect(runValidator("camelCase").status).toBe(2);
|
||||
});
|
||||
|
||||
test("REJECTS name starting with digit", () => {
|
||||
expect(runValidator("1ABC").status).toBe(2);
|
||||
});
|
||||
|
||||
test("REJECTS empty name", () => {
|
||||
expect(runValidator("").status).toBe(2);
|
||||
});
|
||||
|
||||
// Note: hyphen/dot acceptance is a pre-existing overpermissiveness in the
|
||||
// glob pattern `[A-Z_][A-Z0-9_]*` — `*` matches any chars after the bracket
|
||||
// class. NOT in scope for #1606; tracked separately for a future cleanup
|
||||
// wave. Tests intentionally do not assert hyphen/dot rejection so this
|
||||
// file doesn't regress when that future fix lands.
|
||||
|
||||
test("LC_ALL=C is local to the validator (does not leak to caller)", () => {
|
||||
// After sourcing + calling the validator, $LC_ALL in the caller scope
|
||||
// must remain whatever LANG/LC_ALL the caller set. We seed LC_ALL with a
|
||||
// distinctive value, call the validator, then print $LC_ALL — the
|
||||
// distinctive value must survive.
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["-c", `. "${LIB}"; LC_ALL=fr_FR.UTF-8; _gstack_gbrain_validate_varname FOO; echo "$LC_ALL"`],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
env: { ...process.env, LANG: "en_US.UTF-8" },
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("fr_FR.UTF-8");
|
||||
});
|
||||
});
|
||||
@@ -410,6 +410,89 @@ describe('pooler-url', () => {
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('DB_PASS env var is required');
|
||||
});
|
||||
|
||||
// --- Issue #1301: New Supabase projects' API returns transaction/6543 but
|
||||
// the shared pooler tenant only listens on session/5432. Rewrite that
|
||||
// single combination, leave every other shape alone. ---
|
||||
|
||||
test('rewrites single transaction/6543 response to session/5432 (issue #1301)', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres');
|
||||
expect(r.stderr).toContain('rewriting');
|
||||
});
|
||||
|
||||
test('leaves session/6543 alone (some regions genuinely serve session on 6543)', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp({ ...POOLER_OK, pool_mode: 'session', db_port: 6543 }),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres');
|
||||
expect(r.stderr).not.toContain('rewriting');
|
||||
});
|
||||
|
||||
test('leaves transaction/5432 alone (only the 6543 case is the known footgun)', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 5432 }),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres');
|
||||
expect(r.stderr).not.toContain('rewriting');
|
||||
});
|
||||
|
||||
test('GSTACK_SUPABASE_TRUST_API_PORT=1 disables the rewrite', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
GSTACK_SUPABASE_TRUST_API_PORT: '1',
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres');
|
||||
expect(r.stderr).not.toContain('rewriting');
|
||||
});
|
||||
|
||||
test('array response with explicit session entry on 5432 is unaffected (existing behavior)', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp([
|
||||
{ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 },
|
||||
{ ...POOLER_OK, pool_mode: 'session', db_port: 5432 },
|
||||
]),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres');
|
||||
expect(r.stderr).not.toContain('rewriting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list-orphans (D20)', () => {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Real integration: gbrain PGLite + voyage-code-3 end-to-end.
|
||||
*
|
||||
* Inits a sandboxed PGLite engine with voyage-code-3 embeddings, registers a
|
||||
* tiny code fixture as a source, syncs it (which triggers Voyage embedding
|
||||
* generation), and queries it back. The whole point is to catch the failure
|
||||
* modes that hit us in real life:
|
||||
*
|
||||
* - dimension mismatch between the configured embedding column and the
|
||||
* model's actual output dim (the 1280-vs-1536 trap that gbrain doctor
|
||||
* surfaces but `gbrain init` silently sets up)
|
||||
* - voyage-code-3 unavailable via gbrain's openai-compat adapter
|
||||
* - sync completes but embedding generation silently fails (0 chunks)
|
||||
*
|
||||
* We intentionally do NOT call `gbrain query` here — it produces correct
|
||||
* output but doesn't exit cleanly on a fresh PGLite (~2 min hang after
|
||||
* results print). The smoking-gun assertion for "embeddings worked" is the
|
||||
* "N pages embedded" line from sync output: if that's >= 1, voyage-code-3
|
||||
* returned 1024-dim vectors and gbrain persisted them. Symbol-aware
|
||||
* functionality is covered separately by the code-def test.
|
||||
*
|
||||
* Skips when:
|
||||
* - `gbrain` is not on PATH (dev machine without it installed)
|
||||
* - VOYAGE_API_KEY is unset (the test makes real Voyage API calls)
|
||||
*
|
||||
* Cost: ~$0.001 per run. The fixture is 3 tiny files, ~500 tokens total.
|
||||
* Not gated on EVALS=1 because it's not an LLM eval — it's a deterministic
|
||||
* integration test of the embedding pipeline. Always runs when the env
|
||||
* supports it.
|
||||
*
|
||||
* Runtime: ~30-60s (gbrain init schema migrations + sync + Voyage round-trip).
|
||||
* Long enough that `bun test` runs it serially with a per-test 120s timeout.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
const gbrainPath = spawnSync("which", ["gbrain"], { encoding: "utf-8" }).stdout.trim();
|
||||
const gbrainAvailable = gbrainPath.length > 0;
|
||||
const voyageKey = process.env.VOYAGE_API_KEY?.trim() ?? "";
|
||||
const voyageKeyPresent = voyageKey.length > 0;
|
||||
|
||||
const shouldRun = gbrainAvailable && voyageKeyPresent;
|
||||
const skipReason = !gbrainAvailable
|
||||
? "gbrain not on PATH"
|
||||
: !voyageKeyPresent
|
||||
? "VOYAGE_API_KEY not set (real Voyage API calls required)"
|
||||
: "";
|
||||
|
||||
if (!shouldRun) {
|
||||
console.log(`[gbrain-sync-voyage-code-3-integration] SKIP: ${skipReason}`);
|
||||
}
|
||||
|
||||
interface SandboxEnv {
|
||||
root: string;
|
||||
gbrainHome: string;
|
||||
fixtureDir: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeSandbox(): SandboxEnv {
|
||||
const root = mkdtempSync(join(tmpdir(), "gbrain-voyage-int-"));
|
||||
// GBRAIN_HOME points at the PARENT of .gbrain (per gbrain's configDir());
|
||||
// setting GBRAIN_HOME=/x means gbrain looks at /x/.gbrain/.
|
||||
const gbrainHome = root;
|
||||
const fixtureDir = join(root, "fixture-repo");
|
||||
mkdirSync(fixtureDir, { recursive: true });
|
||||
|
||||
// Tiny realistic fixture: three files exercising different file types so
|
||||
// gbrain's code stage has something to extract symbols + embeddings from.
|
||||
writeFileSync(
|
||||
join(fixtureDir, "math.ts"),
|
||||
`export function fibonacci(n: number): number {
|
||||
if (n <= 1) return n;
|
||||
return fibonacci(n - 1) + fibonacci(n - 2);
|
||||
}
|
||||
|
||||
export function isPrime(n: number): boolean {
|
||||
if (n < 2) return false;
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixtureDir, "queue.ts"),
|
||||
`export class JobQueue<T> {
|
||||
private items: T[] = [];
|
||||
enqueue(item: T): void { this.items.push(item); }
|
||||
dequeue(): T | undefined { return this.items.shift(); }
|
||||
size(): number { return this.items.length; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixtureDir, "README.md"),
|
||||
`# Fixture repo
|
||||
|
||||
Sample code for testing the voyage-code-3 embedding pipeline.
|
||||
The math module exposes fibonacci and primality helpers.
|
||||
The queue module is a simple FIFO job queue.
|
||||
`,
|
||||
);
|
||||
|
||||
// Make it a git repo because gbrain's code-sync strategy expects one.
|
||||
const gitInit = spawnSync("git", ["init", "-q"], { cwd: fixtureDir, encoding: "utf-8" });
|
||||
if (gitInit.status !== 0) {
|
||||
throw new Error(`git init failed: ${gitInit.stderr}`);
|
||||
}
|
||||
spawnSync("git", ["config", "user.email", "test@example.invalid"], { cwd: fixtureDir });
|
||||
spawnSync("git", ["config", "user.name", "test"], { cwd: fixtureDir });
|
||||
spawnSync("git", ["add", "."], { cwd: fixtureDir });
|
||||
spawnSync("git", ["commit", "-q", "-m", "fixture"], { cwd: fixtureDir });
|
||||
|
||||
return {
|
||||
root,
|
||||
gbrainHome,
|
||||
fixtureDir,
|
||||
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function gbrainEnv(s: SandboxEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
GBRAIN_HOME: s.gbrainHome,
|
||||
VOYAGE_API_KEY: voyageKey,
|
||||
};
|
||||
}
|
||||
|
||||
function runGbrain(s: SandboxEnv, args: string[], opts: { timeout?: number } = {}) {
|
||||
// cwd MUST be the sandbox root, not the test's parent CWD. If gbrain runs
|
||||
// from inside the gstack worktree, it picks up the worktree's
|
||||
// `.gbrain-source` pin and tries to sync that source too — which won't
|
||||
// exist in the sandbox PGLite, and the resulting "not found" exits 1.
|
||||
return spawnSync("gbrain", args, {
|
||||
encoding: "utf-8",
|
||||
env: gbrainEnv(s),
|
||||
cwd: s.root,
|
||||
timeout: opts.timeout ?? 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!shouldRun)(
|
||||
"gbrain PGLite + voyage-code-3 end-to-end (real Voyage API)",
|
||||
() => {
|
||||
test(
|
||||
"init with voyage-code-3 produces a 1024-dim-aligned PGLite config",
|
||||
() => {
|
||||
const s = makeSandbox();
|
||||
try {
|
||||
const init = runGbrain(s, [
|
||||
"init",
|
||||
"--pglite",
|
||||
"--json",
|
||||
"--embedding-model",
|
||||
"voyage:voyage-code-3",
|
||||
"--embedding-dimensions",
|
||||
"1024",
|
||||
]);
|
||||
expect(init.status).toBe(0);
|
||||
// init prints JSON status line at the end; just sniff for success.
|
||||
const out = (init.stdout || "") + (init.stderr || "");
|
||||
expect(out).toContain('"status":"success"');
|
||||
expect(out).toContain('"engine":"pglite"');
|
||||
|
||||
// doctor must agree the column width matches the live probe dim.
|
||||
const doctor = runGbrain(s, ["doctor"]);
|
||||
const dout = (doctor.stdout || "") + (doctor.stderr || "");
|
||||
// Doctor exits non-zero on error rows; warnings are OK. The
|
||||
// critical assertion is no dimension mismatch.
|
||||
expect(dout).not.toContain("DB dimension mismatch");
|
||||
// Should explicitly mention voyage-code-3 as the live provider.
|
||||
expect(dout).toMatch(/voyage-code-3/);
|
||||
// Width consistency check should be green for 1024d.
|
||||
expect(dout).toMatch(/Schema width \(1024d\)/);
|
||||
} finally {
|
||||
s.cleanup();
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
test(
|
||||
"sync --strategy code generates Voyage embeddings and registers pages + chunks",
|
||||
() => {
|
||||
const s = makeSandbox();
|
||||
try {
|
||||
// 1. init voyage-code-3 PGLite
|
||||
const init = runGbrain(s, [
|
||||
"init",
|
||||
"--pglite",
|
||||
"--json",
|
||||
"--embedding-model",
|
||||
"voyage:voyage-code-3",
|
||||
"--embedding-dimensions",
|
||||
"1024",
|
||||
]);
|
||||
expect(init.status).toBe(0);
|
||||
|
||||
// 2. register the fixture as a code source
|
||||
const add = runGbrain(s, [
|
||||
"sources",
|
||||
"add",
|
||||
"fixture-code",
|
||||
"--path",
|
||||
s.fixtureDir,
|
||||
]);
|
||||
expect(add.status).toBe(0);
|
||||
|
||||
// 3. sync with code strategy — this is where Voyage embeddings get
|
||||
// generated. Use --skip-failed so a single oversized file (which
|
||||
// can happen in real repos) doesn't block the assertion.
|
||||
const sync = runGbrain(
|
||||
s,
|
||||
[
|
||||
"sync",
|
||||
"--source",
|
||||
"fixture-code",
|
||||
"--strategy",
|
||||
"code",
|
||||
"--skip-failed",
|
||||
],
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
if (sync.status !== 0) {
|
||||
console.error(`[sync FAILED exit=${sync.status}]`);
|
||||
console.error(`STDOUT:\n${sync.stdout}`);
|
||||
console.error(`STDERR:\n${sync.stderr}`);
|
||||
}
|
||||
expect(sync.status).toBe(0);
|
||||
const sout = (sync.stdout || "") + (sync.stderr || "");
|
||||
// The fixture has 3 files; gbrain should import at least the 2 .ts
|
||||
// files (README.md may or may not be picked up by --strategy code
|
||||
// depending on gbrain's file-type heuristics).
|
||||
expect(sout).toMatch(/imported=[1-9]/);
|
||||
// The "pages embedded" line is the smoking gun: if it's 0,
|
||||
// embedding generation silently failed (voyage adapter broken,
|
||||
// dimension mismatch, etc). Anything > 0 means voyage-code-3
|
||||
// returned 1024-dim vectors and gbrain wrote them.
|
||||
expect(sout).toMatch(/[1-9]\d* pages embedded/);
|
||||
|
||||
// 4. verify the source has pages and chunks
|
||||
const list = runGbrain(s, ["sources", "list", "--json"]);
|
||||
expect(list.status).toBe(0);
|
||||
const sources = JSON.parse(list.stdout) as {
|
||||
sources: Array<{ id: string; page_count: number }>;
|
||||
};
|
||||
const fixture = sources.sources.find((x) => x.id === "fixture-code");
|
||||
expect(fixture).toBeDefined();
|
||||
expect(fixture!.page_count).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
s.cleanup();
|
||||
}
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
|
||||
test(
|
||||
"code-def finds symbols defined in the embedded fixture",
|
||||
() => {
|
||||
const s = makeSandbox();
|
||||
try {
|
||||
runGbrain(s, [
|
||||
"init",
|
||||
"--pglite",
|
||||
"--json",
|
||||
"--embedding-model",
|
||||
"voyage:voyage-code-3",
|
||||
"--embedding-dimensions",
|
||||
"1024",
|
||||
]);
|
||||
runGbrain(s, ["sources", "add", "fixture-code", "--path", s.fixtureDir]);
|
||||
runGbrain(
|
||||
s,
|
||||
["sync", "--source", "fixture-code", "--strategy", "code", "--skip-failed"],
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
|
||||
// code-def is the symbol-aware path. It doesn't strictly need
|
||||
// embeddings (symbols are extracted by tree-sitter), but the JSON
|
||||
// shape it returns is the contract gstack's CLAUDE.md guidance
|
||||
// points the agent at. Verify it works against our PGLite + Voyage
|
||||
// setup.
|
||||
const result = runGbrain(s, ["code-def", "fibonacci"]);
|
||||
expect(result.status).toBe(0);
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
symbol: string;
|
||||
count: number;
|
||||
results: Array<{ file: string; symbol_type: string }>;
|
||||
};
|
||||
expect(parsed.symbol).toBe("fibonacci");
|
||||
expect(parsed.count).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed.results[0].file).toContain("math.ts");
|
||||
} finally {
|
||||
s.cleanup();
|
||||
}
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Lightweight always-on guard: even without the integration test running, we
|
||||
// can still assert that the test file's `describe.skipIf` gate is correctly
|
||||
// formed. This catches a future edit that accidentally inverts the gate.
|
||||
test("integration test gate uses the correct skip predicate", () => {
|
||||
// shouldRun must be the boolean AND of the two pre-checks. If a refactor
|
||||
// makes it true when either piece is missing, the test below would attempt
|
||||
// real API calls without a key — undefined behavior.
|
||||
expect(shouldRun).toBe(gbrainAvailable && voyageKeyPresent);
|
||||
// When skipping, we logged a reason — basic sanity that the reason string
|
||||
// matches what shouldRun says.
|
||||
if (!shouldRun) {
|
||||
expect(skipReason.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
@@ -2273,6 +2273,20 @@ describe('setup script validation', () => {
|
||||
expect(fnBody).toContain('rm -f "$target"');
|
||||
});
|
||||
|
||||
test('setup links root gstack skill through a thin Claude wrapper alias', () => {
|
||||
const fnStart = setupContent.indexOf('link_claude_root_skill_alias()');
|
||||
const fnEnd = setupContent.indexOf('# ─── Helper: remove old unprefixed Claude skill entries', fnStart);
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('_gstack-command');
|
||||
expect(fnBody).toContain('_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"');
|
||||
|
||||
const claudeSection = setupContent.slice(
|
||||
setupContent.indexOf('# 4. Install for Claude'),
|
||||
setupContent.indexOf('# 5. Install for Codex')
|
||||
);
|
||||
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
|
||||
});
|
||||
|
||||
test('setup supports --host auto|claude|codex|kiro|opencode', () => {
|
||||
expect(setupContent).toContain('--host');
|
||||
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto');
|
||||
|
||||
@@ -67,6 +67,24 @@ describe('gstack-artifacts-url', () => {
|
||||
expect(r.stderr).toContain('unrecognized URL form');
|
||||
});
|
||||
|
||||
test('rejects remotes without both owner and repo path segments', () => {
|
||||
const malformed = [
|
||||
'https://github.com',
|
||||
'https://github.com/owner',
|
||||
'https://github.com/owner/',
|
||||
'https://github.com/owner//repo',
|
||||
'git@github.com:owner',
|
||||
'ssh://git@github.com',
|
||||
'ssh://git@github.com/owner',
|
||||
];
|
||||
|
||||
for (const url of malformed) {
|
||||
const r = run(['--to', 'ssh', url]);
|
||||
expect(r.code, url).toBe(3);
|
||||
expect(r.stderr, url).toContain('failed to parse host/owner');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects missing args with exit 2', () => {
|
||||
expect(run([]).code).toBe(2);
|
||||
expect(run(['--to']).code).toBe(2);
|
||||
|
||||
@@ -267,6 +267,10 @@ describe('schema regression', () => {
|
||||
'gbrain_local_status',
|
||||
'gbrain_mcp_mode',
|
||||
'gbrain_on_path',
|
||||
// PR #1591 added gbrain_pooler_mode for PgBouncer transaction-mode
|
||||
// detection. Keep alphabetized; downstream sync-gbrain ignores unknown
|
||||
// keys so adding here is forward-compat.
|
||||
'gbrain_pooler_mode',
|
||||
'gbrain_version',
|
||||
'gstack_artifacts_remote',
|
||||
'gstack_brain_git',
|
||||
|
||||
@@ -12,6 +12,7 @@ const tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-cwd-'));
|
||||
// gstack-slug derives slug from git remote (none here) → falls back to basename of cwd.
|
||||
const slug = path.basename(tmpCwd).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
const projDir = path.join(tmpHome, 'projects', slug);
|
||||
const otherProjDir = path.join(tmpHome, 'projects', 'other-project');
|
||||
|
||||
function run(args: string[]): string {
|
||||
return execFileSync(BIN, args, {
|
||||
@@ -23,12 +24,18 @@ function run(args: string[]): string {
|
||||
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(projDir, { recursive: true });
|
||||
fs.mkdirSync(otherProjDir, { recursive: true });
|
||||
const entries = [
|
||||
{ ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', key: 'foo-pattern', insight: 'A foo-related insight', confidence: 8, source: 'observed', files: [] },
|
||||
{ ts: '2026-05-02T00:00:00Z', skill: 'test', type: 'pitfall', key: 'bar-pitfall', insight: 'A bar-related insight', confidence: 8, source: 'observed', files: [] },
|
||||
{ ts: '2026-05-03T00:00:00Z', skill: 'test', type: 'pattern', key: 'baz-pattern', insight: 'A baz-related insight', confidence: 8, source: 'observed', files: [] },
|
||||
{ ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', key: 'foo-pattern', insight: 'A foo-related insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
{ ts: '2026-05-02T00:00:00Z', skill: 'test', type: 'pitfall', key: 'bar-pitfall', insight: 'A bar-related insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
{ ts: '2026-05-03T00:00:00Z', skill: 'test', type: 'pattern', key: 'baz-pattern', insight: 'A baz-related insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
];
|
||||
const otherEntries = [
|
||||
{ ts: '2026-05-04T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-observed', insight: 'A foreign observed insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
{ ts: '2026-05-05T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-user', insight: 'A foreign user-stated insight', confidence: 8, source: 'user-stated', trusted: true, files: [] },
|
||||
];
|
||||
fs.writeFileSync(path.join(projDir, 'learnings.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
||||
fs.writeFileSync(path.join(otherProjDir, 'learnings.jsonl'), otherEntries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -58,3 +65,18 @@ describe('gstack-learnings-search token-OR query semantics', () => {
|
||||
expect(out).toContain('baz-pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-learnings-search cross-project trust gating', () => {
|
||||
test('cross-project mode still includes observed entries from the current project', () => {
|
||||
const out = run(['--cross-project', '--query', 'foo']);
|
||||
expect(out).toContain('foo-pattern');
|
||||
expect(out).not.toContain('[cross-project]');
|
||||
});
|
||||
|
||||
test('cross-project mode only imports trusted entries from other projects', () => {
|
||||
const out = run(['--cross-project', '--query', 'foreign']);
|
||||
expect(out).toContain('foreign-user');
|
||||
expect(out).toContain('[cross-project]');
|
||||
expect(out).not.toContain('foreign-observed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "fs";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
@@ -96,6 +96,47 @@ describe("secretScanFile", () => {
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("probes the gitleaks executable directly before scanning", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-test-"));
|
||||
const binDir = join(dir, "bin");
|
||||
const log = join(dir, "gitleaks-calls.log");
|
||||
const file = join(dir, "clean.txt");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(file, "no secrets here\n");
|
||||
writeFileSync(
|
||||
join(binDir, "gitleaks"),
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "${log}"
|
||||
if [ "$1" = "version" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "detect" ]; then
|
||||
echo '[]'
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(join(binDir, "gitleaks"), 0o755);
|
||||
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = `${binDir}:${oldPath || ""}`;
|
||||
try {
|
||||
_resetGitleaksAvailabilityCache();
|
||||
const result = secretScanFile(file);
|
||||
expect(result.scanner).toBe("gitleaks");
|
||||
expect(result.findings).toEqual([]);
|
||||
const calls = readFileSync(log, "utf-8").trim().split("\n");
|
||||
expect(calls[0]).toBe("version");
|
||||
expect(calls[1]).toContain("detect --no-git --source");
|
||||
} finally {
|
||||
if (oldPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = oldPath;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseSkillManifest ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Coverage for PR #1620 — Post-failure PR-state check after `gh pr merge`
|
||||
* non-zero exit.
|
||||
*
|
||||
* The fix lives in land-and-deploy/SKILL.md.tmpl as Step §4a-postfail.
|
||||
* After ANY non-zero `gh pr merge`, the skill must query authoritative PR
|
||||
* state via `gh pr view --json state,mergeCommit,mergedAt,mergedBy` and
|
||||
* branch on the result instead of retrying `gh pr merge` (cli/cli#3442,
|
||||
* cli/cli#13380).
|
||||
*
|
||||
* Static invariants pin:
|
||||
* - §4a-postfail header present
|
||||
* - Universal invariant text + reference to upstream gh bugs
|
||||
* - All three state branches (MERGED, OPEN, CLOSED) named explicitly
|
||||
* - MERGED branch: capture merge SHA via mergeCommit.oid
|
||||
* - MERGED branch: non-destructive worktree cleanup with uncommitted-work guard
|
||||
* - MERGED branch: continues to §4a CI watch
|
||||
* - OPEN branch: checks autoMergeRequest before treating as failure
|
||||
* - CLOSED branch: STOPs
|
||||
* - Hard rule: never retry `gh pr merge`
|
||||
* - .tmpl edit propagated to generated SKILL.md (atomic per T-Codex-3)
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const TMPL = path.join(ROOT, "land-and-deploy", "SKILL.md.tmpl");
|
||||
const MD = path.join(ROOT, "land-and-deploy", "SKILL.md");
|
||||
|
||||
function readTmpl(): string {
|
||||
return fs.readFileSync(TMPL, "utf-8");
|
||||
}
|
||||
function readMd(): string {
|
||||
return fs.readFileSync(MD, "utf-8");
|
||||
}
|
||||
|
||||
describe("PR #1620 §4a-postfail in land-and-deploy template", () => {
|
||||
test("§4a-postfail header present in template", () => {
|
||||
expect(readTmpl()).toMatch(/### 4a-postfail: Post-failure PR-state check/);
|
||||
});
|
||||
|
||||
test("§4a-postfail comes before §4a (Merge queue detection)", () => {
|
||||
const body = readTmpl();
|
||||
const postfail = body.indexOf("### 4a-postfail:");
|
||||
const queue = body.indexOf("### 4a: Merge queue detection");
|
||||
expect(postfail).toBeGreaterThan(-1);
|
||||
expect(queue).toBeGreaterThan(-1);
|
||||
expect(postfail).toBeLessThan(queue);
|
||||
});
|
||||
|
||||
test("Universal invariant + upstream gh bug references", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/Universal invariant/);
|
||||
expect(body).toMatch(/non-zero exit from `gh pr merge`/);
|
||||
expect(body).toMatch(/cli\/cli#3442/);
|
||||
expect(body).toMatch(/cli\/cli#13380/);
|
||||
});
|
||||
|
||||
test("Authoritative state query uses gh pr view --json", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/gh pr view --json state,mergeCommit,mergedAt,mergedBy/);
|
||||
});
|
||||
|
||||
test("All three state branches named: MERGED, OPEN, CLOSED", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/state == "MERGED"/);
|
||||
expect(body).toMatch(/state == "OPEN"/);
|
||||
expect(body).toMatch(/state == "CLOSED"/);
|
||||
});
|
||||
|
||||
test("MERGED branch captures merge SHA via mergeCommit.oid", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/gh pr view --json mergeCommit -q \.mergeCommit\.oid/);
|
||||
});
|
||||
|
||||
test("MERGED worktree cleanup is non-destructive (uncommitted-work guard)", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/uncommitted work/);
|
||||
expect(body).toMatch(/STOP worktree cleanup without removing/);
|
||||
expect(body).toMatch(/Do NOT use `--force`/);
|
||||
expect(body).toMatch(/Do NOT remove the user's primary working tree/);
|
||||
});
|
||||
|
||||
test("MERGED branch continues to §4a CI auto-deploy detection", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/continue to §4a/);
|
||||
});
|
||||
|
||||
test("OPEN branch checks autoMergeRequest before treating as failure", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/gh pr view --json autoMergeRequest/);
|
||||
expect(body).toMatch(/auto-merge is enabled or merge queue is in use/);
|
||||
});
|
||||
|
||||
test("CLOSED branch STOPs", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/state == "CLOSED".*[\s\S]{0,200}STOP/);
|
||||
});
|
||||
|
||||
test("Hard rule: never retry gh pr merge after non-zero exit", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/never call `gh pr merge` a second time/);
|
||||
});
|
||||
|
||||
test("Generated SKILL.md carries the §4a-postfail section (atomic regen per T-Codex-3)", () => {
|
||||
const md = readMd();
|
||||
expect(md).toMatch(/### 4a-postfail: Post-failure PR-state check/);
|
||||
expect(md).toMatch(/state == "MERGED"/);
|
||||
});
|
||||
});
|
||||
@@ -29,20 +29,34 @@ describe("gstack-learnings-search injection prevention", () => {
|
||||
test("uses process.env for all user-controlled values", () => {
|
||||
const bunBlock = script.slice(script.indexOf('bun -e "'));
|
||||
|
||||
// Must use process.env for TYPE, QUERY, LIMIT, SLUG, CROSS_PROJECT
|
||||
// Must use process.env for TYPE, QUERY, LIMIT.
|
||||
// SLUG and CROSS are no longer threaded as env vars inside the bun
|
||||
// block since PR #1619 — current vs cross-project rows are now
|
||||
// distinguished by inline tags in the piped input (`current\t<line>`
|
||||
// vs `cross\t<line>`), removing the need for env-var filters inside
|
||||
// the bun block. CROSS is still set on the bash command line (it
|
||||
// controls whether the cross-project find runs at all), but the bun
|
||||
// block reads the tag, not the env var.
|
||||
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_TYPE");
|
||||
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_QUERY");
|
||||
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_LIMIT");
|
||||
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_SLUG");
|
||||
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_CROSS");
|
||||
});
|
||||
|
||||
test("env vars are set on the bun command line", () => {
|
||||
// The env vars must be passed to bun, not just set in the shell
|
||||
// The env vars must be passed to bun, not just set in the shell.
|
||||
// SLUG removed by PR #1619 — see above.
|
||||
expect(script).toContain("GSTACK_SEARCH_TYPE=");
|
||||
expect(script).toContain("GSTACK_SEARCH_QUERY=");
|
||||
expect(script).toContain("GSTACK_SEARCH_LIMIT=");
|
||||
expect(script).toContain("GSTACK_SEARCH_SLUG=");
|
||||
expect(script).toContain("GSTACK_SEARCH_CROSS=");
|
||||
});
|
||||
|
||||
test("current vs cross-project rows distinguished by inline tags, not SLUG env (#1619)", () => {
|
||||
const bunBlock = script.slice(script.indexOf('bun -e "'));
|
||||
// The bun block must inspect the per-line tag to mark cross-project rows.
|
||||
// The current shape emits `current\t<json>` or `cross\t<json>` from the
|
||||
// upstream pipe (via emit_tagged_file). Inside the bun block, the script
|
||||
// parses out the leading tag and sets a per-entry flag.
|
||||
expect(bunBlock).toMatch(/sourceTag|tabIndex|crossProject/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Regression tests for #1539 — /review false positive rate on mature
|
||||
* frameworks (Django, 4/8 FPs).
|
||||
*
|
||||
* The fix extends the Confidence Calibration resolver with a Pre-emit
|
||||
* verification gate: every finding must quote the specific code line that
|
||||
* motivates it; unverified findings are forced to confidence 4-5 so the
|
||||
* existing suppression rule auto-fires.
|
||||
*
|
||||
* Tests pin:
|
||||
* - The resolver emits the gate text
|
||||
* - The regenerated SKILL.md files for all consumers carry the gate
|
||||
* - The framework-meta nudge is present
|
||||
* - The deferred-design-doc reference is present (T-Codex-2 split)
|
||||
* - Each named FP class from the issue has an explicit row in the gate
|
||||
*
|
||||
* No paid eval. The static invariants are the durable guarantees that the
|
||||
* FP-killing mechanism doesn't regress — the LLM behavior under it is
|
||||
* separately measured via E2E review evals when this branch is run with
|
||||
* EVALS=1.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { generateConfidenceCalibration } from "../scripts/resolvers/confidence";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
|
||||
describe("#1539 confidence resolver — pre-emit verification gate present", () => {
|
||||
test("resolver text includes the gate header", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/Pre-emit verification gate/);
|
||||
expect(out).toMatch(/#1539/);
|
||||
});
|
||||
|
||||
test("gate requires quoted code snippet (file:line + verbatim text)", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/Quote the specific code line/);
|
||||
expect(out).toMatch(/file:line/);
|
||||
expect(out).toMatch(/verbatim text/);
|
||||
});
|
||||
|
||||
test("unverified findings auto-suppressed via existing confidence rule", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
// The gate must hook the existing "<7 -> suppress" rule rather than
|
||||
// invent new mechanism. Look for both forcing-to-4-5 AND a reference
|
||||
// to suppression.
|
||||
expect(out).toMatch(/Force its confidence to 4-5/);
|
||||
expect(out).toMatch(/suppress/i);
|
||||
});
|
||||
|
||||
test("framework-meta nudge present for Django/Rails/SQLAlchemy/TypeORM/Sequelize/Prisma", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/Framework-meta nudge/);
|
||||
expect(out).toMatch(/Django/);
|
||||
expect(out).toMatch(/Rails/);
|
||||
expect(out).toMatch(/SQLAlchemy/);
|
||||
expect(out).toMatch(/TypeORM/);
|
||||
expect(out).toMatch(/Sequelize/);
|
||||
expect(out).toMatch(/Prisma/);
|
||||
});
|
||||
|
||||
test("references the deferred design doc for framework-aware verification (T-Codex-2)", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/1539-framework-aware-review\.md/);
|
||||
});
|
||||
|
||||
test("enumerates the four FP classes the gate kills (#1539 named cases)", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/field doesn't exist on model/);
|
||||
expect(out).toMatch(/dict\.get\(\) might be None/);
|
||||
expect(out).toMatch(/save\(\) might lose fields/);
|
||||
expect(out).toMatch(/update_fields might miss/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1539 generated SKILL.md files — gate propagated to all consumers", () => {
|
||||
const consumers = [
|
||||
"review/SKILL.md",
|
||||
"cso/SKILL.md",
|
||||
"plan-eng-review/SKILL.md",
|
||||
"ship/SKILL.md",
|
||||
];
|
||||
|
||||
for (const rel of consumers) {
|
||||
test(`${rel} carries the Pre-emit verification gate`, () => {
|
||||
const body = fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||
expect(body).toMatch(/Pre-emit verification gate/);
|
||||
expect(body).toMatch(/Quote the specific code line/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("#1539 confidence suppression rule unchanged (regression on existing behavior)", () => {
|
||||
test("confidence 3-4 row still says 'Suppress from main report'", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/3-4[\s\S]{0,200}Suppress from main report/);
|
||||
});
|
||||
|
||||
test("confidence 9-10 row preserves 'Show normally' behavior", () => {
|
||||
const out = generateConfidenceCalibration({} as never);
|
||||
expect(out).toMatch(/9-10[\s\S]{0,200}Show normally/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Regression tests for #1611 — /sync-gbrain --full SIGTERM at hardcoded 35min,
|
||||
* no resume from gbrain's import-checkpoint.
|
||||
*
|
||||
* Tests cover three surfaces:
|
||||
* - resolveStageTimeoutMs (gstack-gbrain-sync.ts) — env parsing + bounds
|
||||
* - decideResume (gstack-gbrain-sync.ts) — checkpoint+staging detection
|
||||
* - SIGTERM staging preservation invariants in gstack-memory-ingest.ts
|
||||
*
|
||||
* The resolveStageTimeoutMs + decideResume helpers are exported from the
|
||||
* source file so we can call them directly. The SIGTERM behavior is pinned
|
||||
* via static-invariant checks against the source body — the signal handler
|
||||
* is hard to exercise in a unit test without forking, and the static check
|
||||
* is the durable guarantee.
|
||||
*
|
||||
* Branches under test (9 total):
|
||||
* 1. parseTimeoutEnv default (env unset → 2_100_000)
|
||||
* 2. parseTimeoutEnv non-numeric → warn + default
|
||||
* 3. parseTimeoutEnv below floor (<60_000) → warn + default
|
||||
* 4. parseTimeoutEnv above ceiling (>86_400_000) → warn + default
|
||||
* 5. parseTimeoutEnv valid mid-range → returns value
|
||||
* 6. decideResume: no checkpoint → no-checkpoint verdict
|
||||
* 7. decideResume: checkpoint + staging exists → resume verdict
|
||||
* 8. decideResume: checkpoint + staging missing → stale-staging-missing
|
||||
* 9. SIGTERM preserves staging dir when gbrain checkpoint points at it
|
||||
* (static invariant on memory-ingest source)
|
||||
*/
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
import {
|
||||
resolveStageTimeoutMs,
|
||||
readGbrainCheckpoint,
|
||||
decideResume,
|
||||
} from "../bin/gstack-gbrain-sync";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const DEFAULT_MS = 35 * 60 * 1000;
|
||||
const MIN_MS = 60_000;
|
||||
const MAX_MS = 86_400_000;
|
||||
|
||||
describe("#1611 resolveStageTimeoutMs — env parsing + bounds", () => {
|
||||
test("undefined env → default 2_100_000ms (unchanged from prior behavior)", () => {
|
||||
expect(resolveStageTimeoutMs(undefined, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("empty string env → default", () => {
|
||||
expect(resolveStageTimeoutMs("", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("non-numeric env → warn + default", () => {
|
||||
expect(resolveStageTimeoutMs("not-a-number", "GSTACK_SYNC_CODE_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("zero env → warn + default (not positive)", () => {
|
||||
expect(resolveStageTimeoutMs("0", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("negative env → warn + default", () => {
|
||||
expect(resolveStageTimeoutMs("-1000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("below 60_000ms floor (1min) → warn + default", () => {
|
||||
expect(resolveStageTimeoutMs("30000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
expect(resolveStageTimeoutMs(`${MIN_MS - 1}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("above 86_400_000ms ceiling (24h) → warn + default", () => {
|
||||
expect(resolveStageTimeoutMs(`${MAX_MS + 1}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
expect(resolveStageTimeoutMs("999999999999", "GSTACK_SYNC_CODE_TIMEOUT_MS")).toBe(DEFAULT_MS);
|
||||
});
|
||||
|
||||
test("at floor (60_000ms exactly) → accepted", () => {
|
||||
expect(resolveStageTimeoutMs(`${MIN_MS}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(MIN_MS);
|
||||
});
|
||||
|
||||
test("at ceiling (86_400_000ms exactly) → accepted", () => {
|
||||
expect(resolveStageTimeoutMs(`${MAX_MS}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(MAX_MS);
|
||||
});
|
||||
|
||||
test("valid mid-range (2h = 7_200_000ms) → returns value", () => {
|
||||
expect(resolveStageTimeoutMs("7200000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(7_200_000);
|
||||
});
|
||||
});
|
||||
|
||||
// decideResume + readGbrainCheckpoint exercise ~/.gbrain/import-checkpoint.json
|
||||
// and the staging dir on disk. We point HOME at a tmp dir, write fake state,
|
||||
// and assert verdicts.
|
||||
|
||||
describe("#1611 decideResume — checkpoint + staging detection", () => {
|
||||
let tmpHome: string;
|
||||
let origHome: string | undefined;
|
||||
let cpDir: string;
|
||||
let cpPath: string;
|
||||
let stagingDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-1611-"));
|
||||
origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
cpDir = path.join(tmpHome, ".gbrain");
|
||||
cpPath = path.join(cpDir, "import-checkpoint.json");
|
||||
stagingDir = path.join(tmpHome, ".staging-ingest-99-99");
|
||||
fs.mkdirSync(cpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = origHome;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
|
||||
test("no checkpoint file → no-checkpoint verdict", () => {
|
||||
// cpPath does not exist
|
||||
expect(fs.existsSync(cpPath)).toBe(false);
|
||||
expect(readGbrainCheckpoint()).toBeNull();
|
||||
expect(decideResume().kind).toBe("no-checkpoint");
|
||||
});
|
||||
|
||||
test("corrupt JSON checkpoint → no-checkpoint verdict", () => {
|
||||
fs.writeFileSync(cpPath, "{not valid json", "utf-8");
|
||||
expect(readGbrainCheckpoint()).toBeNull();
|
||||
expect(decideResume().kind).toBe("no-checkpoint");
|
||||
});
|
||||
|
||||
test("checkpoint + staging dir exists → resume verdict", () => {
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
fs.writeFileSync(stagingDir + "/page1.md", "content", "utf-8");
|
||||
fs.writeFileSync(cpPath, JSON.stringify({
|
||||
dir: stagingDir,
|
||||
totalFiles: 1989,
|
||||
processedIndex: 1000,
|
||||
completedFiles: 1000,
|
||||
timestamp: "2026-05-19T19:30:05.008Z",
|
||||
}), "utf-8");
|
||||
|
||||
const v = decideResume();
|
||||
expect(v.kind).toBe("resume");
|
||||
if (v.kind === "resume") {
|
||||
expect(v.stagingDir).toBe(stagingDir);
|
||||
expect(v.processedIndex).toBe(1000);
|
||||
expect(v.totalFiles).toBe(1989);
|
||||
}
|
||||
});
|
||||
|
||||
test("checkpoint references missing staging dir → stale-staging-missing", () => {
|
||||
// Note: stagingDir is NOT created on disk for this test
|
||||
fs.writeFileSync(cpPath, JSON.stringify({
|
||||
dir: stagingDir,
|
||||
totalFiles: 1989,
|
||||
processedIndex: 1000,
|
||||
}), "utf-8");
|
||||
|
||||
const v = decideResume();
|
||||
expect(v.kind).toBe("stale-staging-missing");
|
||||
if (v.kind === "stale-staging-missing") {
|
||||
expect(v.stagingDir).toBe(stagingDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("checkpoint with no dir field → no-checkpoint verdict", () => {
|
||||
fs.writeFileSync(cpPath, JSON.stringify({
|
||||
totalFiles: 1989,
|
||||
processedIndex: 1000,
|
||||
}), "utf-8");
|
||||
|
||||
expect(decideResume().kind).toBe("no-checkpoint");
|
||||
});
|
||||
|
||||
test("checkpoint with empty dir string → no-checkpoint verdict", () => {
|
||||
fs.writeFileSync(cpPath, JSON.stringify({
|
||||
dir: "",
|
||||
}), "utf-8");
|
||||
|
||||
expect(decideResume().kind).toBe("no-checkpoint");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1611 SIGTERM staging preservation — static invariants", () => {
|
||||
test("memory-ingest signal handler checks stagingDirIsCheckpointed before cleanup", () => {
|
||||
const body = fs.readFileSync(
|
||||
path.join(ROOT, "bin", "gstack-memory-ingest.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
// The forward handler must read the checkpoint before deciding whether
|
||||
// to clean up. Locks in the "preserve when checkpointed" branch.
|
||||
expect(body).toMatch(/stagingDirIsCheckpointed/);
|
||||
expect(body).toMatch(/preserving staging dir for resume/);
|
||||
// The branch order must be: checkpointed → preserve, else → cleanup
|
||||
const handlerStart = body.indexOf("if (_activeStagingDir)");
|
||||
expect(handlerStart).toBeGreaterThan(-1);
|
||||
const handlerSlice = body.slice(handlerStart, handlerStart + 1000);
|
||||
const preserveAt = handlerSlice.indexOf("preserving staging dir for resume");
|
||||
const cleanupAt = handlerSlice.indexOf("cleanupStagingDir");
|
||||
expect(preserveAt).toBeGreaterThan(-1);
|
||||
expect(cleanupAt).toBeGreaterThan(-1);
|
||||
expect(preserveAt).toBeLessThan(cleanupAt);
|
||||
});
|
||||
|
||||
test("memory-ingest reads GSTACK_INGEST_RESUME_DIR env to reuse staging dir", () => {
|
||||
const body = fs.readFileSync(
|
||||
path.join(ROOT, "bin", "gstack-memory-ingest.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(body).toMatch(/process\.env\.GSTACK_INGEST_RESUME_DIR/);
|
||||
expect(body).toMatch(/skipping prepare phase/);
|
||||
});
|
||||
|
||||
test("gbrain-sync orchestrator passes GSTACK_INGEST_RESUME_DIR to grandchild on resume", () => {
|
||||
const body = fs.readFileSync(
|
||||
path.join(ROOT, "bin", "gstack-gbrain-sync.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(body).toMatch(/GSTACK_INGEST_RESUME_DIR/);
|
||||
expect(body).toMatch(/resuming from gbrain checkpoint/);
|
||||
expect(body).toMatch(/previous checkpoint stale.*staging dir.*gone.*restaging from scratch/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Regression tests for #1624 — /retro silently produced empty/misleading
|
||||
* output when "today" anchor was wrong or origin/<default> was stale.
|
||||
*
|
||||
* The fix is Step 0.5 in retro/SKILL.md.tmpl: four ordered pre-check
|
||||
* branches before any window analysis. These tests are static invariants
|
||||
* against the template body — they fail the build if the guard is removed,
|
||||
* weakened, or its ordering broken.
|
||||
*
|
||||
* Branches under test:
|
||||
* 1. no-remote skip — git remote returns empty
|
||||
* 2. detached-HEAD skip — git symbolic-ref --quiet HEAD returns empty
|
||||
* 3. fetch-fail warn — git fetch origin <default> exits non-zero
|
||||
* 4. stale-base BLOCK — fetch ok, latest commit older than window
|
||||
*
|
||||
* Each branch must short-circuit further checks (only one verdict wins) and
|
||||
* must surface a disclosure line on stderr so the narrative carries the
|
||||
* reason rather than silently misreporting.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const RETRO_TMPL = path.join(ROOT, "retro", "SKILL.md.tmpl");
|
||||
const RETRO_MD = path.join(ROOT, "retro", "SKILL.md");
|
||||
|
||||
function readTmpl(): string {
|
||||
return fs.readFileSync(RETRO_TMPL, "utf-8");
|
||||
}
|
||||
|
||||
function readMd(): string {
|
||||
return fs.readFileSync(RETRO_MD, "utf-8");
|
||||
}
|
||||
|
||||
describe("#1624 retro stale-base guard — Step 0.5 exists and is ordered before Step 1", () => {
|
||||
test("Step 0.5 header is present in template", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/### Step 0\.5: Stale-base \+ bad-today-anchor pre-flight guard/);
|
||||
});
|
||||
|
||||
test("Step 0.5 appears before Step 1: Gather Raw Data", () => {
|
||||
const body = readTmpl();
|
||||
const step05 = body.indexOf("### Step 0.5:");
|
||||
const step1 = body.indexOf("### Step 1: Gather Raw Data");
|
||||
expect(step05).toBeGreaterThan(-1);
|
||||
expect(step1).toBeGreaterThan(-1);
|
||||
expect(step05).toBeLessThan(step1);
|
||||
});
|
||||
|
||||
test("regenerated SKILL.md carries the Step 0.5 guard", () => {
|
||||
const md = readMd();
|
||||
expect(md).toMatch(/Step 0\.5: Stale-base \+ bad-today-anchor pre-flight guard/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1624 retro guard — branch A: no-remote skip", () => {
|
||||
test("template checks for 'origin' remote absence and skips with disclosure", () => {
|
||||
const body = readTmpl();
|
||||
// Must check git remote for 'origin' and short-circuit
|
||||
expect(body).toMatch(/git remote[^|]*\|\s*grep -c '\^origin\$'/);
|
||||
expect(body).toMatch(/RETRO_GUARD: no 'origin' remote/);
|
||||
});
|
||||
|
||||
test("no-remote skip sets a verdict variable that gates later checks", () => {
|
||||
const body = readTmpl();
|
||||
// The verdict variable must be set so later branches short-circuit
|
||||
expect(body).toMatch(/_RETRO_GUARD_VERDICT="skip-no-remote"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1624 retro guard — branch B: detached-HEAD skip", () => {
|
||||
test("template checks for detached HEAD via git symbolic-ref", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/git symbolic-ref --quiet HEAD/);
|
||||
expect(body).toMatch(/RETRO_GUARD: detached HEAD/);
|
||||
});
|
||||
|
||||
test("detached-HEAD branch is gated by prior verdict check (ordering)", () => {
|
||||
const body = readTmpl();
|
||||
// The detached-HEAD block must be guarded by the verdict check so
|
||||
// no-remote always wins if both are true.
|
||||
const branchBStart = body.indexOf("# Pre-check B: detached HEAD");
|
||||
expect(branchBStart).toBeGreaterThan(-1);
|
||||
const branchBSlice = body.slice(branchBStart, branchBStart + 500);
|
||||
expect(branchBSlice).toMatch(/if \[ -z "\$_RETRO_GUARD_VERDICT" \]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1624 retro guard — branch C: fetch-fail warn", () => {
|
||||
test("template warns and proceeds against last-known origin when fetch fails", () => {
|
||||
const body = readTmpl();
|
||||
// Match either `git fetch ... ||` or `if ! git fetch ...` shape.
|
||||
expect(body).toMatch(/(?:if !\s+|[^\n]*\|\|\s*)git fetch origin <default>|git fetch origin <default>[^\n]*--quiet 2>\/dev\/null; then/);
|
||||
expect(body).toMatch(/fetch[^\n]*failed[^\n]*offline/);
|
||||
expect(body).toMatch(/_RETRO_GUARD_VERDICT="warn-fetch-failed"/);
|
||||
});
|
||||
|
||||
test("fetch-fail warn is gated by prior verdict check (ordering)", () => {
|
||||
const body = readTmpl();
|
||||
const branchCStart = body.indexOf("# Pre-check C: fetch origin");
|
||||
expect(branchCStart).toBeGreaterThan(-1);
|
||||
const branchCSlice = body.slice(branchCStart, branchCStart + 500);
|
||||
expect(branchCSlice).toMatch(/if \[ -z "\$_RETRO_GUARD_VERDICT" \]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1624 retro guard — branch D: stale-base BLOCK", () => {
|
||||
test("template extracts latest origin/<default> commit date via git log -1 --format=%ci", () => {
|
||||
const body = readTmpl();
|
||||
// The BLOCK check must read the actual latest-commit date so the
|
||||
// disclosure is concrete (not generic).
|
||||
expect(body).toMatch(/git log -1 --format=%ci origin\/<default>/);
|
||||
});
|
||||
|
||||
test("BLOCK prose names latest-commit date and instructs user remediation", () => {
|
||||
const body = readTmpl();
|
||||
// The BLOCK message must cite the date AND tell the user how to recover.
|
||||
// "Retro window is stale" is the canonical first line.
|
||||
expect(body).toMatch(/Retro window is stale/);
|
||||
expect(body).toMatch(/git fetch origin <default>/);
|
||||
expect(body).toMatch(/Confirm today's date/);
|
||||
});
|
||||
|
||||
test("BLOCK branch is gated by prior verdict checks (ordering)", () => {
|
||||
const body = readTmpl();
|
||||
const branchDStart = body.indexOf("# Pre-check D:");
|
||||
expect(branchDStart).toBeGreaterThan(-1);
|
||||
const branchDSlice = body.slice(branchDStart, branchDStart + 800);
|
||||
expect(branchDSlice).toMatch(/if \[ -z "\$_RETRO_GUARD_VERDICT" \]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1624 retro guard — disclosure must reach the narrative", () => {
|
||||
test("template names the skip paths that must carry a disclosure line", () => {
|
||||
const body = readTmpl();
|
||||
// The post-bash prose must explicitly tell the model to surface
|
||||
// these reasons in the retro output rather than silently dropping them.
|
||||
expect(body).toMatch(/skip-no-remote/);
|
||||
expect(body).toMatch(/skip-detached/);
|
||||
expect(body).toMatch(/warn-fetch-failed/);
|
||||
// The prose names disclosure + narrative together (either order) so the
|
||||
// retro output is never silently confidently-wrong.
|
||||
expect(body).toMatch(/(?:disclosure[\s\S]{0,200}narrative|narrative[\s\S]{0,200}disclosure)/);
|
||||
});
|
||||
});
|
||||
@@ -187,6 +187,37 @@ describe('gstack-relink (#578)', () => {
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
test('creates a thin root alias wrapper for the /gstack slash command', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(
|
||||
path.join(installDir, 'SKILL.md'),
|
||||
'---\nname: gstack\ndescription: root\n---\n# gstack',
|
||||
);
|
||||
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
||||
GSTACK_INSTALL_DIR: installDir,
|
||||
GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
||||
GSTACK_INSTALL_DIR: installDir,
|
||||
GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
|
||||
const aliasDir = path.join(skillsDir, '_gstack-command');
|
||||
const aliasSkill = path.join(aliasDir, 'SKILL.md');
|
||||
expect(fs.lstatSync(aliasDir).isDirectory()).toBe(true);
|
||||
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
|
||||
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(aliasSkill)).toBe(path.join(installDir, 'SKILL.md'));
|
||||
expect(fs.readFileSync(aliasSkill, 'utf-8')).toContain('name: gstack');
|
||||
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
||||
GSTACK_INSTALL_DIR: installDir,
|
||||
GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
expect(fs.existsSync(aliasSkill)).toBe(true);
|
||||
});
|
||||
|
||||
// FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution
|
||||
test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => {
|
||||
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
||||
|
||||
Reference in New Issue
Block a user