mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995) /land-and-deploy gated only on required checks, so a repo with none (every GitHub Free private repo, any repo without branch protection) skipped the CI wait and could merge over red CI. The verdict now comes from bin/gstack-ci-gate, a tested helper bound to the head SHA: all checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit per-head approval, and both merge commands (including --auto) run only after a PASS for the same SHA they pin with --match-head-commit. Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901) gstack-slug kept only the last two remote path segments, so customer-a/product/repo and customer-b/product/repo shared one bucket and /context-restore could present another project's checkpoint. - One canonical-remote rule (bash + TS, golden vectors) used by all six resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and Bitbucket Server normalized. 3+ segment remotes get <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged. - Versioned sticky cache (v2:) keeps cache hits git-free. - context-save stamps the credential-free remote and root; context-restore only treats this project's checkpoints as latest, reports foreign/unverified ones first, and treats a different root with the same remote (Conductor handoff) as information. - gstack-slug --adopt-legacy copies only files the user confirms. - question-preference-hook reads the same versioned cache, so never-ask preferences fire; unknown identity asks instead of auto-deciding. * fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922) - brain-context-load called 'gbrain list_pages' (an MCP tool name), so every skill's brain context was 0 bytes. It now uses 'gbrain list' with gbrain's real filter flags, and prints one line when queries fail. - The code-stage sync always passes --no-pull (managed gbrain >= 0.51 refuses without it); a gbrain that rejects the flag fails with an upgrade message and is never retried without it. stderr is streamed with a bounded tail and quoted in failure summaries. - /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by default it skips types already synced through a federated curated source, so curated pages stop being re-duplicated. Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011) - Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and never a false PASS; P2/P3-only output passes. - import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's ROOTPATH (including git-URL targets) through explicit, captured mappings; declared bases must sit inside the source root or the run's checkout, URIs are percent-decoded, and rejected results are counted with rule, base and path instead of silently dropped. * fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984) - The pre-push redact hook exempts a root VERSION file that is only a version string and the version token in CHANGELOG release headings; the engine allowlist is untouched. - gstack-update-check caches an unreadable remote as CHECK_FAILED with a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports 'latest' after a failed check. - SECURITY.md points reporters at GitHub private vulnerability reporting. - pr-title-sync (pull_request_target, write token) pins actions/checkout to a reviewed SHA. * fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006) The guard required a session journal to start with a user message, but Claude Code starts it with SessionStart hook attachments (and a forked compacted session starts with a compact_boundary), so every phase was denied for anyone with a SessionStart hook. Windows path spellings (C:/, /c/, drive case) were rejected too. - Reader accepts the captured startup, compact, clear and fork shapes (claude 2.1.284 fixture), computes root uniqueness over every candidate, and returns typed reason codes instead of one string. - Hard denials (competing root, foreign cwd, sidechain, agent, cycle, malformed, identity) say what happened and how to run the reviews by hand; unflushed journals still retry. - A well-formed, stable journal whose root shape is unrecognized now warns ('phase publication was NOT verified') and continues, without overriding Claude Code's own permission check; no bypass switch. - Windows paths fold before validation, so '..' is still rejected. - Periodic drift canary runs the reader on fresh journals from the pinned claude. Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com> * v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs * test: seed the versioned slug cache in the memory-injection deny case (#2901) The preference hook now resolves the project bucket from gstack-slug's v2 slug-cache entry instead of basename(cwd); this fixture still wrote preferences under the basename bucket without the entry the skill preamble always writes first. * fix: full-suite follow-ups for the fix wave - gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50 pipe-deadlock guard like the other bins. - Refresh the codex/factory ship goldens for the plan-gates slug change. - The periodic lane gains the autoplan journal drift canary (107 paid files); raise the eval-slices cap to 25 so every slice still starts at once until its recorded duration lets the planner pack it. * fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs The workflow judge scored plan-eng-review's instructions at actionability 3.67 (bar 4) on text unchanged from main. Resolve the ambiguities it named together, without changing any gate or output: Step 0 names the Read's order and keeps one STOP; the pending-call, read-only, forbidden-write and failed-save rules become one Write routes block; header/label limits and grid checks are concrete; Performance gets a checklist; each section has a stated output shape; Lake Score is defined. Same total length (parity cap). --------- Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
/** Isolated fixtures and assertions for the single periodic /health capture. */
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { extractSkillSections } from './skill-fixture';
|
||
import type { SkillTestResult } from './session-runner';
|
||
import type { EvalTestEntry } from './eval-store';
|
||
|
||
export const HEALTH_EVAL_ID = 'health-reporting';
|
||
export const HEALTH_EVAL_SECTIONS = [
|
||
'Step 1: Detect Health Stack',
|
||
'Step 2: Run Tools',
|
||
'Step 3: Score Each Category',
|
||
'Step 4: Present Dashboard',
|
||
'Step 5: Persist to Health History',
|
||
'Step 6: Trend Analysis + Recommendations',
|
||
'Important Rules',
|
||
];
|
||
|
||
const PRIOR_HISTORY = JSON.stringify({
|
||
ts: '2026-01-01T00:00:00Z', branch: 'unknown', score: 10,
|
||
typecheck: null, lint: null, test: 10, deadcode: null, shell: null,
|
||
gbrain: null, duration_s: 1,
|
||
}) + '\n';
|
||
|
||
export interface HealthEvalFixture {
|
||
dir: string;
|
||
gstackHome: string;
|
||
receipts: string;
|
||
prompt: string;
|
||
projectFiles: Record<string, string>;
|
||
}
|
||
|
||
function projectSnapshot(dir: string): Record<string, string> {
|
||
const files: Record<string, string> = {};
|
||
const walk = (relative: string) => {
|
||
for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
|
||
const name = path.join(relative, entry.name);
|
||
if (entry.isDirectory()) { files[name + '/'] = '<directory>'; walk(name); }
|
||
else files[name] = entry.isSymbolicLink() ? `<symlink:${fs.readlinkSync(path.join(dir, name))}>` : fs.readFileSync(path.join(dir, name), 'utf-8');
|
||
}
|
||
};
|
||
for (const project of ['partial', 'no-tools']) walk(project);
|
||
return files;
|
||
}
|
||
|
||
export function createHealthEvalFixture(dir: string, repoRoot: string): HealthEvalFixture {
|
||
const gstackHome = path.join(dir, 'gstack-state');
|
||
const receipts = path.join(dir, 'checker-runs.txt');
|
||
// Instrumentation belongs to the fixture, not to the model's shell setup.
|
||
// Single-quote escaping also covers temporary paths containing apostrophes.
|
||
const quotedReceipts = "'" + receipts.replaceAll("'", "'\"'\"'") + "'";
|
||
fs.mkdirSync(path.join(dir, 'bin'), { recursive: true });
|
||
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-slug'), path.join(dir, 'bin', 'gstack-slug'));
|
||
fs.chmodSync(path.join(dir, 'bin', 'gstack-slug'), 0o755);
|
||
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-state-root.sh'), path.join(dir, 'bin', 'gstack-state-root.sh'));
|
||
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-remote-identity.sh'), path.join(dir, 'bin', 'gstack-remote-identity.sh'));
|
||
|
||
// Only redirect installed paths. The workflow and score/history rules come
|
||
// from the real generated skill, without its unrelated shared preamble.
|
||
const skill = extractSkillSections(path.join(repoRoot, 'health'), HEALTH_EVAL_SECTIONS)
|
||
.replaceAll('~/.claude/skills/gstack/bin/gstack-slug', path.join(dir, 'bin', 'gstack-slug'))
|
||
.replaceAll('~/.gstack', gstackHome);
|
||
fs.writeFileSync(path.join(dir, 'health-SKILL.md'), skill);
|
||
|
||
for (const project of ['partial', 'no-tools']) {
|
||
const projectDir = path.join(dir, project);
|
||
fs.mkdirSync(projectDir);
|
||
fs.writeFileSync(path.join(projectDir, '.project.yaml'), `name: ${project}\n`);
|
||
const historyDir = path.join(gstackHome, 'projects', project);
|
||
fs.mkdirSync(historyDir, { recursive: true });
|
||
fs.writeFileSync(path.join(historyDir, 'health-history.jsonl'), PRIOR_HISTORY);
|
||
}
|
||
fs.writeFileSync(path.join(dir, 'partial', 'CLAUDE.md'), `# Partial project
|
||
|
||
## Health Stack
|
||
|
||
- typecheck: bash ./check-typecheck.sh
|
||
- test: bash ./check-tests.sh
|
||
|
||
Only the listed tools are available. Lint, dead-code, shell-lint, and GBrain
|
||
tools are not installed. This configuration is final; do not install tools.
|
||
`);
|
||
fs.writeFileSync(path.join(dir, 'no-tools', 'CLAUDE.md'), `# No-tools project
|
||
|
||
## Health Stack
|
||
|
||
No health tools are configured or installed for any category.
|
||
This configuration is final; do not install tools or substitute other checks.
|
||
`);
|
||
fs.writeFileSync(path.join(dir, 'partial', 'check-typecheck.sh'), `#!/usr/bin/env bash
|
||
printf 'typecheck\\n' >> ${quotedReceipts}
|
||
for ((i = 1; i <= 60; i++)); do
|
||
printf 'src/file%s.ts(1,1): error TS2322: Type mismatch.\\n' "$i" >&2
|
||
done
|
||
for ((i = 1; i <= 80; i++)); do
|
||
printf 'Additional diagnostic context %s\\n' "$i"
|
||
done
|
||
exit 2
|
||
`);
|
||
fs.writeFileSync(path.join(dir, 'partial', 'check-tests.sh'), `#!/usr/bin/env bash
|
||
printf 'test\\n' >> ${quotedReceipts}
|
||
printf '5 pass\\n0 fail\\n'
|
||
`);
|
||
|
||
return {
|
||
dir, gstackHome, receipts, projectFiles: projectSnapshot(dir),
|
||
prompt: `Read health-SKILL.md and run its /health workflow, Steps 1–6, for
|
||
the partial project first and the no-tools project second. Each has its own
|
||
CLAUDE.md with the final Health Stack configuration. Run commands from the
|
||
corresponding project directory. These are local fixtures without Git remotes.
|
||
|
||
Save each complete dashboard, details, trends, and recommendations as
|
||
partial-report.md or no-tools-report.md in ${dir}. Keep the dashboard's
|
||
COMPOSITE SCORE label. Existing health histories are available under
|
||
${gstackHome}/projects/<project>/health-history.jsonl; apply the skill's normal
|
||
history rules. GSTACK_HOME already points at this isolated state directory.
|
||
|
||
Do not modify project files, install tools, or ask to change either Health Stack.
|
||
Only write the reports and any history updates required by the supplied skill.
|
||
Finish after producing both reports.`,
|
||
};
|
||
}
|
||
|
||
/** Validate behavior, allowing ordinary Markdown/wording variation in reports. */
|
||
export function healthReportingFailures(fixture: HealthEvalFixture): string[] {
|
||
const failures: string[] = [];
|
||
const check = (ok: boolean, message: string) => { if (!ok) failures.push(message); };
|
||
const read = (file: string) => fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||
const plain = (text: string) => text.replace(/[*_`]/g, '');
|
||
const partial = plain(read(path.join(fixture.dir, 'partial-report.md')));
|
||
const empty = plain(read(path.join(fixture.dir, 'no-tools-report.md')));
|
||
const composite = (report: string) => report.match(/composite\s+score\s*[:|]?\s*(N\/A|\d+(?:\.\d+)?)/i)?.[1];
|
||
check(composite(partial) === '5.6', 'partial coverage must score 5.6, preserving the failing typecheck');
|
||
check(/partial\s+coverage/i.test(partial), 'numeric score must be labeled as partial coverage');
|
||
const typecheckRows = partial.split('\n').filter(line => /\btype\s*check\b/i.test(line));
|
||
check(typecheckRows.some(line => /(?:\b0\s*\/\s*10\b|\|\s*0\s*\|)/.test(line)
|
||
&& /\b(?:critical|fail(?:ed|ure)?|error)\b/i.test(line) && !/\bclean\b/i.test(line)),
|
||
'dashboard must report the failing typecheck as 0/10, not clean');
|
||
check(/\b60\s+(?:\w+\s+){0,2}(?:errors|diagnostics|findings)\b/i.test(partial)
|
||
|| /(?:errors|diagnostics|findings)[^\n]{0,20}\b60\b/i.test(partial),
|
||
'report must count all 60 errors before the final 50 output lines');
|
||
check(/(?:coverage|checked|executed)/i.test(partial), 'partial report must disclose checked coverage');
|
||
check(/type\s*check/i.test(partial) && /tests?/i.test(partial), 'checked category names must be visible');
|
||
for (const category of ['lint', 'dead[ -]?code', 'shell(?:[ -]?lint)?', 'gbrain']) {
|
||
const unavailable = '(?:unavailable|skipped|not (?:installed|configured|available|found))';
|
||
check(new RegExp(`${unavailable}[\\s\\S]{0,240}${category}|${category}[^\\n]{0,120}${unavailable}`, 'i').test(partial),
|
||
`partial report must name unavailable ${category}`);
|
||
}
|
||
check(/(?:coverage|categor(?:y|ies)|checks)[\s\S]{0,160}(?:chang|differ|not compar)/i.test(partial)
|
||
|| /(?:chang|differ|not compar)[\s\S]{0,160}(?:coverage|categor(?:y|ies)|checks)/i.test(partial),
|
||
'partial report must flag changed coverage instead of comparing unlike histories');
|
||
check(!/[+-]\s*4\.4\b|trend\s*:\s*(?:improving|regressing|worsening)/i.test(partial),
|
||
'partial report must not calculate a trend delta against different coverage');
|
||
check(composite(empty)?.toUpperCase() === 'N/A', 'no-tools composite must be N/A');
|
||
check(/(?:no|zero|0)\s+(?:health\s+)?checks?\s+(?:ran|run|executed|available)|no\s+tools/i.test(empty),
|
||
'no-tools report must explain that no checks ran');
|
||
|
||
const history = read(path.join(fixture.gstackHome, 'projects', 'partial', 'health-history.jsonl'));
|
||
check(history.startsWith(PRIOR_HISTORY), 'partial run must preserve its prior history row');
|
||
const rows = history.trim().split('\n').filter(Boolean);
|
||
check(rows.length === 2, 'partial run must append exactly one history row');
|
||
try {
|
||
const row = JSON.parse(rows.at(-1) || '{}');
|
||
check(row.score === 5.6 && row.typecheck === 0 && row.test === 10,
|
||
'persisted partial scores must reflect all diagnostics and the actual exit status');
|
||
check(['lint', 'deadcode', 'shell', 'gbrain'].every(category => row[category] === null),
|
||
'unavailable categories must persist as null');
|
||
} catch {
|
||
failures.push('partial history must remain valid JSONL');
|
||
}
|
||
check(read(path.join(fixture.gstackHome, 'projects', 'no-tools', 'health-history.jsonl')) === PRIOR_HISTORY,
|
||
'no-tools run must leave its history unchanged');
|
||
const runs = read(fixture.receipts).trim().split('\n');
|
||
check(runs.includes('typecheck') && runs.includes('test'), 'both configured checkers must actually run');
|
||
check(JSON.stringify(projectSnapshot(fixture.dir)) === JSON.stringify(fixture.projectFiles),
|
||
'health must leave project files unchanged');
|
||
return failures;
|
||
}
|
||
|
||
/** One attempt records once, after all assertions; throws remain failures. */
|
||
export async function recordHealthAttempt(
|
||
record: (entry: EvalTestEntry) => void,
|
||
capture: () => Promise<SkillTestResult>,
|
||
verify: (result: SkillTestResult) => void,
|
||
): Promise<void> {
|
||
const started = Date.now();
|
||
let result: SkillTestResult | undefined;
|
||
let passed = false;
|
||
let failure: unknown;
|
||
try {
|
||
result = await capture();
|
||
if (result.exitReason !== 'success') throw new Error(`health capture ended: ${result.exitReason}`);
|
||
verify(result);
|
||
passed = true;
|
||
} catch (error) {
|
||
failure = error;
|
||
throw error;
|
||
} finally {
|
||
record({
|
||
name: HEALTH_EVAL_ID, suite: 'health', tier: 'e2e', passed,
|
||
duration_ms: result?.duration ?? Date.now() - started,
|
||
cost_usd: result?.costEstimate.estimatedCost ?? 0,
|
||
turns_used: result?.costEstimate.turnsUsed,
|
||
tokens_used: result?.costEstimate.estimatedTokens,
|
||
transcript: result?.transcript,
|
||
output: [result?.output, failure === undefined ? '' : String(failure)].filter(Boolean).join('\n').slice(-4000),
|
||
exit_reason: result?.exitReason === 'success' && !passed ? 'assertion_failed' : result?.exitReason ?? 'runner_error',
|
||
model: result?.model,
|
||
});
|
||
}
|
||
}
|