mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(proactive): default to the four core skills (core | all | off); docs, prompt and tests (GSTA-99) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(proactive): gstack-skill-start falls back to core, not true, when gstack-config is missing The PROACTIVE status line's fallback literal still said true (= all) after the default moved to core. Found by /ship's coverage audit; the regression test fails at the previous head and passes at main. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(proactive): STATUS line carries the stored value; every surface names the same core four and three values Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(proactive): off gates the office-hours handoff; the preamble and router scope the setting to unasked suggestions The office-hours next-review handoff gated on the legacy false only, so an off user was still asked to launch /plan-eng-review. The per-skill preamble now says the setting governs unasked suggestions and this skill's own steps still run (a handoff, a review gate, a dispatched /document-release is not a suggestion), at exactly the previous length, so the 89 size pins return to main's values; the router says the same in full. Found by /ship's specialist and red-team review. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skill-start): unknown proactive values read as core; first-run tips name core skills and stay quiet under off A hand-edited config can hold any value and only set validates, so STATUS normalizes anything but core|all|off|true|false to core. The first-run and first-loop tips named /spec, /investigate and /plan-eng-review whatever the setting; they now name the core four and are skipped under off. The fallback regression test folds into test/proactive-skill-start.test.ts, the duplicate header assertion leaves proactive-config, and the frozen-length preamble test is retired (the size guard enforces it). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(proactive): scope note, the unknown-value rule, and the CLAUDE.md routing follow-up; CHANGELOG Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skill-start): no CLAUDE.md routing offer under proactive off The one-time offer to append ## Skill routing to the project's CLAUDE.md fired whatever the proactive value, and its rules are the auto-invocation an off user turned off. The offer is skipped under off and the legacy false; a section already in CLAUDE.md is left alone. Under core the offered section still routes every skill, which docs/proactive.md records as the follow-up. Found by /ship's red team. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(proactive): STATUS says only core | all | off, and the preamble carves out only named skills gstack-skill-start reads the legacy true/false as all/off and tolerates a quoted, upper-case or commented hand edit (proactive: OFF # mine read as core before), so every surface sees one of three values and the off gating lives in one flag. The per-skill preamble drops the legacy spellings and replaces 'This skill's steps still run', which licensed any in-skill offer under off, with 'Skills the user or this skill's steps name run', at the same length, so the 89 size pins stay at main's. Tests run the script for the tips and the routing offer under core and off instead of grepping the source. Found by /ship's fix-range repeat (red team, testing, maintainability). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(config): set proactive is case-insensitive; follow-up #3446 cited; test hygiene set proactive OFF warned and stored core, the opposite of the opt-out typed; the value is lowercased first, as gstack-skill-start does for a hand edit. The fixture git init carries the tripwire timeout, the source-shape assertion on the private _PROACTIVE_OFF line goes (the routing-offer test proves the behavior), and the CHANGELOG Tests bullets collapse to one per file with the known gap pointing at #3446. Found by /ship's full repeat pass and its tests lane. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(proactive): a stored off answers the one-time prompt; the router names /browse and /investigate under core instead of invoking them Red team, full repeat pass: with proactive: off stored before the prompt, skill-start still emitted the opt-in prompt; it now writes the marker and asks nothing. The router's Route-first browser step and the Failures branches invoked non-core skills under core while the core block said to answer directly; one sentence in the core block resolves it, pinned by the core-set tests. CHANGELOG and docs/proactive.md say both. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(proactive): the full-catalog skill descriptions are the second surface that still routes every skill under core (#3446) From /ship's document-release audit: a --catalog-mode=full build and the non-Claude hosts keep the "Proactively suggest when ..." description sentences, which do not follow the setting; the default Claude build trims them. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(proactive): the tips test runs skill-start as an interactive session GitHub CI sets CI/GITHUB_ACTIONS, which gstack-session-kind reads as headless, and a headless start skips first-task detection, so the first-run tip never fires there (Free Tests shard 16 red at 0d7dbe6; green locally and on the Ubicloud pregate, which have no CI marker). The fixture now sets CLAUDE_CODE_ENTRYPOINT=cli, the positive interactive signal; reproduced locally with CI=1 and fixed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(proactive): an opt-out never fails open; the preamble carves out invocations, not offers The YAML yes/no family (yes/on/1, no/none/0/disabled/never) reads as all/off in both gstack-skill-start and gstack-config set, so a typed or hand-edited opt-out cannot land on core. The per-skill preamble says "Skills the user names or this one invokes run" (same 168-char length, pins unchanged): a skill dispatching another is not a suggestion, an in-skill offer to run one still is under off. The stored-off prompt marker is written before STATUS prints PROACTIVE_PROMPTED, so the line is true. docs/proactive.md's table row carves out in-skill handoffs. Fourth /ship fix cycle, past the skill's three-cycle cap by GStack Minder's decision (2026-10-26) under Garry's "do it" on the proactive core set; items from the Step 11 adversarial pass. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.240.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
400 lines
18 KiB
TypeScript
400 lines
18 KiB
TypeScript
/**
|
|
* gstack-config default-table completeness (gate, free).
|
|
*
|
|
* Skill preambles read configuration with
|
|
*
|
|
* VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")
|
|
*
|
|
* and that fallback only fires on a NON-ZERO exit. `get` used to answer a key
|
|
* it did not know with "" and exit 0, so VAR came back empty and the default
|
|
* written right there in the preamble was unreachable. The skill then branched
|
|
* on a value it never specified -- "skip entirely if QUESTION_TUNING is false"
|
|
* reached with QUESTION_TUNING="".
|
|
*
|
|
* Four keys skills actually read had no entry in lookup_default and took that
|
|
* path: question_tuning, repo_mode, team_mode, transcript_ingest_mode.
|
|
*
|
|
* Three invariants are pinned so the class cannot reopen:
|
|
*
|
|
* 1. every key read anywhere in the tree is matched by an arm of the DEFAULTS
|
|
* table. Add a `gstack-config get some_new_key` to a preamble without
|
|
* adding its default and this test fails. Checked by parsing the case arms
|
|
* rather than shelling out per key, which keeps it fast and makes the
|
|
* failure name the key.
|
|
* 2. a genuinely unknown key exits non-zero, so the caller fallback fires.
|
|
* 3. a known key whose default is intentionally empty still exits 0 --
|
|
* cross_project_learnings ("unset triggers the first-time prompt") and
|
|
* redact_repo_visibility ("empty falls through to gh/glab detection")
|
|
* depend on receiving "" successfully.
|
|
*
|
|
* The reverse direction is pinned too: every key in the DEFAULTS table must
|
|
* be read by code (bin/, lib/, scripts/, browse/src/, setup, or a .tmpl), or be listed in
|
|
* PROSE_ONLY_KEYS with a reason. A documented setting no code reads is a
|
|
* switch that does nothing, which is how a consent choice once went unread.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config');
|
|
const SELF = 'gstack-config-defaults.test.ts';
|
|
|
|
// Isolated state dir, so a value the developer happens to have set in their own
|
|
// ~/.gstack/config.yaml cannot mask a missing default.
|
|
const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-test-'));
|
|
|
|
function get(key: string): { out: string; code: number } {
|
|
const r = spawnSync('bash', [CONFIG_BIN, 'get', key], {
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
env: { ...process.env, GSTACK_STATE_ROOT: STATE },
|
|
});
|
|
return { out: r.stdout ?? '', code: r.status ?? -1 };
|
|
}
|
|
|
|
/** Case-arm patterns of lookup_default, in order, excluding the catch-all. */
|
|
function defaultArms(): string[] {
|
|
const src = fs.readFileSync(CONFIG_BIN, 'utf-8');
|
|
const body = src.slice(src.indexOf('lookup_default()'));
|
|
const end = body.indexOf('\n}');
|
|
const arms: string[] = [];
|
|
// e.g. ` proactive) echo "core" ;; # core | all | off ...` or ` user_slug_at_*) echo "" ;;`
|
|
for (const m of body.slice(0, end).matchAll(/^\s{4}([a-zA-Z0-9_*]+)\)/gm)) {
|
|
if (m[1] !== '*') arms.push(m[1]);
|
|
}
|
|
return arms;
|
|
}
|
|
|
|
function isCovered(key: string, arms: string[]): boolean {
|
|
return arms.some((a) =>
|
|
a.endsWith('*') ? key.startsWith(a.slice(0, -1)) : key === a,
|
|
);
|
|
}
|
|
|
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.context', 'dist', 'build', '.next']);
|
|
|
|
/** Every `gstack-config get <key>` call site in the tree. */
|
|
function keysReadInTree(root = ROOT): string[] {
|
|
const keys = new Set<string>();
|
|
// [ \t]+ rather than \s+: \s crosses newlines and would pair a trailing
|
|
// "gstack-config get" with the first word of the next line.
|
|
const re = /gstack-config["']?[ \t]+get[ \t]+([a-zA-Z0-9_]+)/g;
|
|
const stack = [root];
|
|
while (stack.length) {
|
|
const cur = stack.pop()!;
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const ent of entries) {
|
|
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
|
|
const full = path.join(cur, ent.name);
|
|
if (ent.isDirectory()) {
|
|
stack.push(full);
|
|
continue;
|
|
}
|
|
// Skip this file: its own prose cites example keys.
|
|
if (ent.name === SELF || ent.name === 'CHANGELOG.md') continue;
|
|
if (!/\.(md|ts|sh)$|^gstack-[a-z-]+$/.test(ent.name)) continue;
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(full, 'utf-8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const m of text.matchAll(re)) keys.add(m[1]);
|
|
}
|
|
}
|
|
return [...keys].sort();
|
|
}
|
|
|
|
/**
|
|
* DEFAULTS-table keys no code reads. Each entry needs a reason; an empty list
|
|
* means every documented setting has a reader.
|
|
*/
|
|
const PROSE_ONLY_KEYS: Record<string, string> = {};
|
|
|
|
const READER_DIRS = ['bin', 'lib', 'scripts', 'browse/src'];
|
|
const READER_FILES = ['setup'];
|
|
|
|
/**
|
|
* Source text a config reader could live in: everything under READER_DIRS,
|
|
* READER_FILES, and every .tmpl in the tree. bin/gstack-config counts too,
|
|
* minus its documentation header and its DEFAULTS table.
|
|
*/
|
|
function readerCorpus(root = ROOT): string {
|
|
const files = new Set<string>();
|
|
const walk = (dir: string, all: boolean) => {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const ent of entries) {
|
|
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
|
|
const full = path.join(dir, ent.name);
|
|
if (ent.isDirectory()) walk(full, all);
|
|
else if (all || ent.name.endsWith('.tmpl')) files.add(full);
|
|
}
|
|
};
|
|
for (const d of READER_DIRS) walk(path.join(root, d), true);
|
|
walk(root, false);
|
|
for (const f of READER_FILES) files.add(path.join(root, f));
|
|
return [...files]
|
|
.map((f) => {
|
|
let text = '';
|
|
try {
|
|
text = fs.readFileSync(f, 'utf-8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
if (f === path.join(root, 'bin', 'gstack-config')) {
|
|
text = text.replace(/CONFIG_HEADER='[\s\S]*?\n'\n/, '').replace(/lookup_default\(\) \{[\s\S]*?\n\}/, '');
|
|
}
|
|
return text;
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
/**
|
|
* A key counts as read when the corpus calls `gstack-config get|has <key>`
|
|
* (directly or through a variable naming the config binary), passes
|
|
* ['get', '<key>'] to a spawn, calls a config-reader function with the key,
|
|
* or anchors the YAML key (`^<key>:`). Wildcard keys match by prefix.
|
|
*/
|
|
function isReadInCorpus(arm: string, corpus: string): boolean {
|
|
const esc = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const wildcard = arm.endsWith('*');
|
|
const k = wildcard ? esc(arm.slice(0, -1)) : `${esc(arm)}(?![A-Za-z0-9_])`;
|
|
const re = new RegExp([
|
|
`(?:gstack-config|\\$\\{?[A-Z_]*CONF[A-Z_]*\\}?)["']?[ \\t]+(?:get|has)[ \\t]+["']?${k}`,
|
|
`['"](?:get|has)['"]\\s*,\\s*['"]${k}`,
|
|
`(?:readConfigKey|readConfigKeyWithRoot|readGstackConfigYamlKey|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`,
|
|
`\\^${k}${wildcard ? '' : ':'}`,
|
|
].join('|'));
|
|
return re.test(corpus);
|
|
}
|
|
|
|
describe('gstack-config defaults (gate, free)', () => {
|
|
test('retired checkpoint keys have no defaults or advertised configuration', () => {
|
|
expect(fs.readFileSync(CONFIG_BIN, 'utf8')).not.toMatch(/checkpoint/i);
|
|
for (const key of ['checkpoint_mode', 'checkpoint_push']) {
|
|
expect(defaultArms()).not.toContain(key);
|
|
expect(get(key)).toEqual({ out: '', code: 1 });
|
|
}
|
|
for (const command of ['list', 'defaults']) {
|
|
const result = spawnSync('bash', [CONFIG_BIN, command], {
|
|
encoding: 'utf8', timeout: 30_000,
|
|
env: { PATH: process.env.PATH, HOME: STATE, GSTACK_STATE_ROOT: STATE },
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).not.toMatch(/checkpoint_mode|checkpoint_push/);
|
|
}
|
|
});
|
|
|
|
test('workspace history does not add call sites to the source census', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-census-'));
|
|
try {
|
|
fs.mkdirSync(path.join(root, '.context', 'old-checkout'), { recursive: true });
|
|
fs.writeFileSync(path.join(root, 'active.md'), 'gstack-config get question_tuning\n');
|
|
fs.writeFileSync(path.join(root, '.context', 'old-checkout', 'old.md'), 'gstack-config get retired_workspace_key\n');
|
|
fs.writeFileSync(path.join(root, 'CHANGELOG.md'), 'Previously used gstack-config get retired_release_key\n');
|
|
expect(keysReadInTree(root)).toEqual(['question_tuning']);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('every key read in the tree is covered by the DEFAULTS table', () => {
|
|
const arms = defaultArms();
|
|
expect(arms.length).toBeGreaterThan(10); // the parse actually found the table
|
|
const uncovered = keysReadInTree().filter((k) => !isCovered(k, arms));
|
|
expect(uncovered).toEqual([]);
|
|
});
|
|
|
|
test('every DEFAULTS key is read by code or listed as prose-only', () => {
|
|
const corpus = readerCorpus();
|
|
const arms = defaultArms();
|
|
const unread = arms.filter((a) => !(a in PROSE_ONLY_KEYS) && !isReadInCorpus(a, corpus));
|
|
if (unread.length > 0) {
|
|
throw new Error(
|
|
`DEFAULTS keys with no reader: ${unread.join(', ')}. Fix one of two ways: ` +
|
|
`add the code that reads the key (gstack-config get/has <key> in bin/, lib/, scripts/, setup or a .tmpl), ` +
|
|
`or add it to PROSE_ONLY_KEYS in ${SELF} with the reason it has no reader.`,
|
|
);
|
|
}
|
|
const stale = Object.keys(PROSE_ONLY_KEYS).filter((k) => !arms.includes(k) || isReadInCorpus(k, corpus));
|
|
if (stale.length > 0) {
|
|
throw new Error(
|
|
`PROSE_ONLY_KEYS entries that are no longer prose-only: ${stale.join(', ')}. Fix one of two ways: ` +
|
|
`remove the entry because code now reads the key, or remove it because the key left the DEFAULTS table.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the reader matcher finds each reader form and nothing in prose', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-readers-'));
|
|
try {
|
|
fs.mkdirSync(path.join(root, 'bin'));
|
|
fs.mkdirSync(path.join(root, 'docs'));
|
|
fs.writeFileSync(path.join(root, 'bin', 'a'), [
|
|
'V=$("$CONFIG_BIN" get key_one 2>/dev/null)',
|
|
'gstack-config has key_two',
|
|
"spawnSync(bin, ['get', 'key_three'], { timeout: 2000 })",
|
|
"readConfigKey('key_four')",
|
|
"readGstackConfigYamlKey('key_eight')",
|
|
'grep -E "^key_five_${hash}:" "$F"',
|
|
].join('\n'));
|
|
fs.writeFileSync(path.join(root, 'docs', 'prose.md'), 'gstack-config get key_six\n');
|
|
fs.writeFileSync(path.join(root, 'skill.md.tmpl'), 'gstack-config get key_seven\n');
|
|
const corpus = readerCorpus(root);
|
|
const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_eight', 'key_one_more']
|
|
.filter((k) => isReadInCorpus(k, corpus));
|
|
expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven', 'key_eight']);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('an unknown key exits non-zero, so the caller fallback fires', () => {
|
|
const r = get('definitely_not_a_gstack_key_9f3a');
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.out).toBe('');
|
|
});
|
|
|
|
test('a known key whose default is intentionally empty still exits 0', () => {
|
|
// repo_mode is in this class BY CONTRACT: gstack-repo-mode treats any
|
|
// non-empty answer as a user override and skips classification, so a
|
|
// synthesized "unknown" default would turn the classifier into dead code
|
|
// (caught live by test/gstack-repo-mode.test.ts during the wave).
|
|
for (const key of ['cross_project_learnings', 'salience_allowlist', 'redact_repo_visibility', 'repo_mode']) {
|
|
expect({ key, ...get(key) }).toEqual({ key, out: '', code: 0 });
|
|
}
|
|
});
|
|
|
|
test('the regressed keys resolve to the values their callers assume', () => {
|
|
expect(get('question_tuning').out).toBe('false');
|
|
expect(get('team_mode').out).toBe('false');
|
|
expect(get('transcript_ingest_mode').out).toBe('off');
|
|
});
|
|
});
|
|
|
|
describe('design_detector (auto|off, rejecting validator)', () => {
|
|
test('defaults to auto', () => {
|
|
expect(get('design_detector')).toEqual({ out: 'auto', code: 0 });
|
|
});
|
|
|
|
test('set to an invalid value exits 1 and leaves the file unchanged', () => {
|
|
const file = path.join(STATE, 'config.yaml');
|
|
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
|
|
const r = spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'maybe'], {
|
|
encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE },
|
|
});
|
|
expect(r.status).toBe(1);
|
|
expect(r.stderr).toContain("design_detector 'maybe' not recognized");
|
|
const after = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
|
|
expect(after).toBe(before);
|
|
expect(get('design_detector').out).toBe('auto');
|
|
});
|
|
|
|
test('list and defaults enumerate design_detector', () => {
|
|
for (const verb of ['list', 'defaults']) {
|
|
const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toMatch(/design_detector:\s+auto/);
|
|
}
|
|
});
|
|
|
|
test('set off / set auto round-trip', () => {
|
|
spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'off'], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
|
|
expect(get('design_detector').out).toBe('off');
|
|
spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'auto'], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
|
|
expect(get('design_detector').out).toBe('auto');
|
|
});
|
|
});
|
|
|
|
describe('design_detector_install_prompted (true|false, rejecting validator)', () => {
|
|
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
|
test('defaults to false, rejects a typo with the file unchanged, round-trips true/false, and is enumerated', () => {
|
|
expect(get('design_detector_install_prompted')).toEqual({ out: 'false', code: 0 });
|
|
const file = path.join(STATE, 'config.yaml');
|
|
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
|
|
const bad = spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector_install_prompted', 'yes'], { encoding: 'utf-8', timeout: 30_000, env });
|
|
expect(bad.status).toBe(1);
|
|
expect(bad.stderr).toContain("design_detector_install_prompted 'yes' not recognized");
|
|
expect(fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null).toBe(before);
|
|
spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector_install_prompted', 'true'], { encoding: 'utf-8', timeout: 30_000, env });
|
|
expect(get('design_detector_install_prompted').out).toBe('true');
|
|
spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector_install_prompted', 'false'], { encoding: 'utf-8', timeout: 30_000, env });
|
|
expect(get('design_detector_install_prompted').out).toBe('false');
|
|
for (const verb of ['list', 'defaults']) {
|
|
const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env });
|
|
expect(r.stdout).toMatch(/design_detector_install_prompted:\s+false/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('transcript_ingest_mode (recent|all|off, rejecting validator)', () => {
|
|
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
|
const set = (key: string, value: string) =>
|
|
spawnSync('bash', [CONFIG_BIN, 'set', key, value], { encoding: 'utf-8', timeout: 30_000, env });
|
|
const has = (key: string) => spawnSync('bash', [CONFIG_BIN, 'has', key], { encoding: 'utf-8', timeout: 30_000, env }).status;
|
|
const file = path.join(STATE, 'config.yaml');
|
|
const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null);
|
|
|
|
test('accepts recent, all and off', () => {
|
|
for (const value of ['recent', 'all', 'off']) {
|
|
expect(set('transcript_ingest_mode', value).status).toBe(0);
|
|
expect(get('transcript_ingest_mode').out).toBe(value);
|
|
}
|
|
expect(has('transcript_ingest_mode')).toBe(0);
|
|
});
|
|
|
|
test('rejects legacy letters and unknown values with the file unchanged', () => {
|
|
set('transcript_ingest_mode', 'recent');
|
|
for (const value of ['A', 'incremental', 'new-only', 'yes', 'Recent']) {
|
|
const before = snapshot();
|
|
const r = set('transcript_ingest_mode', value);
|
|
expect(r.status).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
`Error: transcript_ingest_mode '${value}' not recognized. Valid values: recent, all, off. Existing value left unchanged.`,
|
|
);
|
|
expect(snapshot()).toBe(before);
|
|
}
|
|
expect(get('transcript_ingest_mode').out).toBe('recent');
|
|
});
|
|
});
|
|
|
|
describe('set rejects empty and multi-line values for every key', () => {
|
|
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
|
const file = path.join(STATE, 'config.yaml');
|
|
const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null);
|
|
|
|
test('an empty value exits 1 and leaves the file unchanged', () => {
|
|
for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) {
|
|
const before = snapshot();
|
|
const r = spawnSync('bash', [CONFIG_BIN, 'set', key, ''], { encoding: 'utf-8', timeout: 30_000, env });
|
|
expect(r.status).toBe(1);
|
|
expect(r.stderr).toMatch(/empty/i);
|
|
expect(snapshot()).toBe(before);
|
|
}
|
|
});
|
|
|
|
test('a value containing a newline exits 1 and leaves the file unchanged', () => {
|
|
for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) {
|
|
const before = snapshot();
|
|
const r = spawnSync('bash', [CONFIG_BIN, 'set', key, 'recent\nall'], { encoding: 'utf-8', timeout: 30_000, env });
|
|
expect(r.status).toBe(1);
|
|
expect(r.stderr).toMatch(/newline/i);
|
|
expect(snapshot()).toBe(before);
|
|
}
|
|
});
|
|
});
|