mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
bin/gstack-design-detect.ts finds and runs an impeccable engine the user installed; it never installs, downloads, or executes anything that could download. `probe` reads only: config (design_detector off → DISABLED), IMPECCABLE_BIN (absolute, realpath outside the repo and cwd), a PATH walk (absolute entries outside the repo; a #! shim counts as launcher-present, never READY), the ~/.impeccable/bin/<newest semver>/ cache, and the engine installed beside a skill launcher (scripts/bin/<os>-<arch>/impeccable, the layout a real install produced). It reports IMPECCABLE_SKILL, host-aware IMPECCABLE_HOOK (+ HOOK_OTHER), the ignore lists from .impeccable/config*.json, IMPECCABLE_ENGINE_UNTESTED for versions outside the fixture set, and a hint only when a launcher exists without its engine. `scan` re-probes, refuses URLs and anything outside the repo root or the design-report allow-list (realpath, so symlinks cannot escape), derives `--changed <base>` targets NUL-safely through git and lib/frontend-scope.ts, batches 100 absolute paths per engine call with stdin ignored, a SIGKILL timeout, a 50 MB stdout cap, and sanitized length-capped fields, then prints one normalized JSON document (--format gstack) or the engine's bytes (--format raw); DETECT_TOP (fenced as untrusted content), DETECT_SUMMARY, and DETECT_EXIT go to stderr; exit code passes through with 1 over 2 over 0; exit 3 is a gstack bug. `rules` prints the mapped set. Every run appends a content-free line to the local analytics file. lib/design-detect-contract.ts owns every sentinel string, the limits, and the normalized-finding shape (pure module); test/design-detect-contract.test.ts asserts every sentinel-shaped token the agent can read exists there. lib/frontend-scope.ts mirrors gstack-diff-scope's frontend arm, pinned by a parity test that runs the bash script. bin/gstack-config gains design_detector (auto | off, default auto, invalid values rejected with the file unchanged). test/fixtures/fake-impeccable.ts is the env-driven engine stand-in; test/gstack-design-detect.test.ts covers READY/NOT_CACHED/ NOT_AVAILABLE/DISABLED, env trust (.env never loaded, in-repo IMPECCABLE_BIN ignored), newest-semver cache, hook and ignore detection, refusals, exit passthrough, raw byte-identity, normalization, the display cap, timeout, parse errors, diagnostics, --changed, and analytics. The egress scanner test records the wrapper as a documented non-sink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
168 lines
6.8 KiB
TypeScript
168 lines
6.8 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('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');
|
|
});
|
|
});
|