mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
`gstack-design-detect.ts install` is the one download gstack makes, and only after a design skill's one-time question got a yes. It fetches the engine version gstack has tested (0.1.3) for this platform from impeccable's own GitHub release, verifies it against the checksum pinned in lib/design-detect-contract.ts (all five platforms, captured from the release's .sha256 sidecars; linux-x64 equals the fixture engine), writes an egress receipt before the fetch and refuses to download when the receipt cannot be written (fail-closed; the sink is registered in the wiring test's polarity table), caps the download at 32 MB, streams with the cap enforced, writes the file only after the hash matches, and places it under ~/.impeccable/bin/<version>/ (a trusted IMPECCABLE_HOME is honored; never inside a project). No skill, no hook, no launcher, no npx. --sha256 accepts a sidecar checksum for a version gstack has not pinned; --base allows a mirror (https, or http on loopback for tests). After a successful install the probe runs and its lines follow, so the skill sees READY at once. The probe ends with DESIGN_DETECTOR_INSTALL_OFFER (version, platform, bytes, destination) whenever it found no engine and the user has not answered the question; once design_detector_install_prompted is true it prints neither the offer nor the NOT_CACHED hint, which used to repeat on every run. The hint's npx wording is corrected: `npx impeccable detect --help` caches the engine for npx only, not where the probe looks. gstack-config gains design_detector_install_prompted (true|false, typo rejected, enumerated in list and defaults). Tests: a loopback mirror (async spawn, so the in-process server can answer) covers install, re-install as a verified no-op, checksum mismatch, 404, unpinned version, non-https base, design_detector off, and IMPECCABLE_HOME inside the repo; the offer and the silenced hint; pin completeness per platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
197 lines
8.6 KiB
TypeScript
197 lines
8.6 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.
|
|
*/
|
|
|
|
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 "true" ;;` 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', 'dist', 'build', '.next']);
|
|
|
|
/** Every `gstack-config get <key>` call site in the tree. */
|
|
function keysReadInTree(): 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) 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();
|
|
}
|
|
|
|
describe('gstack-config defaults (gate, free)', () => {
|
|
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('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/);
|
|
}
|
|
});
|
|
});
|