v1.91.71.0 feat(install): gstack-doctor --check one-line verdict, gstack-capy-install, lazy Chromium (multi-agent wave PR A) (#3116)

* v1.91.71.0 feat(install): gstack-doctor --check one-line verdict, gstack-capy-install, lazy Chromium

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(qa-callers): substitute <plan-path> in the plan-completion acceptance block so the ship caller fixture admits the documented command

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-11 00:00:20 +00:00
1 parent 45a678323b
commit 6681adf7a8
49 files changed
+2700 -98

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
/**
* doctor-components — the one component table behind `gstack-doctor --check`.
*
* Row ids, `--for <skill>` resolution, `--require <csv>`, the trailer and the
* `--json` keys all come from here, so a parent agent never maps between
* vocabularies. The doctor is Bash 3.2 and must report with Bun absent, so it
* cannot import this file: scripts/gen-doctor-components.ts renders it into
* bin/gstack-doctor-components.sh (sourced by bin/gstack-doctor-check.sh) and
* test/doctor-components.test.ts keeps the committed render fresh, the way
* agents-digest/gstack-AGENTS.md is kept fresh.
*
* Row semantics (printed as `PASS | FAIL | SKIP <id>`):
* core always evaluated and always required (runtime, pins, state root,
* privacy, cores, revision). `pins` and `state-root` SKIP with a
* reason when they cannot be evaluated; a SKIP never fails a run.
* host a host profile. Required when `--for`/`--require` names it, or by
* default when it is installed from this checkout. With no flag and
* no host installed, `claude` (setup's default host) is required so
* an empty machine is never `ok`.
* component a built or rendered artifact (patch, browse bundle, browser).
* SKIP (not_applicable) when the selected workflow does not need it.
* optional present-or-absent helpers (Codex CLI, CSO native helper). SKIP
* (optional) when absent and not required; FAIL only when required.
*/
import { RESULT_CODES } from './result-codes';
export type ComponentKind = 'core' | 'host' | 'component' | 'optional';
export interface DoctorComponent {
id: string;
title: string;
kind: ComponentKind;
}
export const DOCTOR_COMPONENTS: readonly DoctorComponent[] = [
{ id: 'claude', title: 'Claude skills', kind: 'host' },
{ id: 'codex', title: 'Codex skills', kind: 'host' },
{ id: 'patch', title: 'Astra behavioral patch in the Codex render', kind: 'component' },
{ id: 'codex-cli', title: 'Codex CLI (outside voice)', kind: 'optional' },
{ id: 'browse-bundle', title: 'browse bundle', kind: 'component' },
{ id: 'browser', title: 'Chromium launch and render', kind: 'component' },
{ id: 'cso', title: 'CSO native helper', kind: 'optional' },
{ id: 'runtime', title: 'gstack runtime (Bun)', kind: 'core' },
{ id: 'pins', title: 'project pins', kind: 'core' },
{ id: 'state-root', title: 'state root', kind: 'core' },
{ id: 'privacy', title: 'privacy', kind: 'core' },
{ id: 'cores', title: 'cores', kind: 'core' },
{ id: 'revision', title: 'revision', kind: 'core' },
] as const;
export type ComponentId = (typeof DOCTOR_COMPONENTS)[number]['id'];
export const COMPONENT_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.map(c => c.id);
/** What a workflow needs beyond the core rows. `optional` rows SKIP when absent. */
export interface SkillRequirement {
requires: readonly ComponentId[];
optional: readonly ComponentId[];
}
const PLANNING: SkillRequirement = { requires: ['claude', 'codex', 'patch'], optional: ['codex-cli'] };
const BROWSER: SkillRequirement = { requires: ['claude', 'codex', 'patch', 'browse-bundle', 'browser'], optional: ['codex-cli'] };
const CLAUDE_ONLY: SkillRequirement = { requires: ['claude'], optional: [] };
/**
* Every skill directory in the repo is classified here; test/doctor-components.test.ts
* fails when a skill is added without a row. Planning skills need both host
* profiles and the Astra patch (the outside voice reads the Codex render);
* browser skills add the browse bundle and Chromium; /cso adds the native
* helper; /codex needs the CLI itself; everything else runs on the Claude
* profile alone.
*/
export const SKILL_REQUIREMENTS: Readonly<Record<string, SkillRequirement>> = {
autoplan: PLANNING,
'plan-ceo-review': PLANNING,
'plan-eng-review': PLANNING,
'plan-design-review': PLANNING,
'plan-devex-review': PLANNING,
'plan-tune': PLANNING,
'office-hours': PLANNING,
spec: PLANNING,
review: PLANNING,
ship: PLANNING,
investigate: PLANNING,
codex: { requires: ['claude', 'codex', 'patch', 'codex-cli'], optional: [] },
cso: { requires: ['claude', 'cso'], optional: [] },
browse: BROWSER,
qa: BROWSER,
'qa-only': BROWSER,
'design-review': BROWSER,
'design-consultation': BROWSER,
'design-html': BROWSER,
'devex-review': BROWSER,
scrape: BROWSER,
skillify: BROWSER,
canary: BROWSER,
benchmark: BROWSER,
'make-pdf': BROWSER,
diagram: BROWSER,
'pair-agent': BROWSER,
'open-gstack-browser': BROWSER,
'setup-browser-cookies': BROWSER,
'connect-chrome': BROWSER,
'land-and-deploy': BROWSER,
'benchmark-models': CLAUDE_ONLY,
careful: CLAUDE_ONLY,
'claude-code': CLAUDE_ONLY,
'context-restore': CLAUDE_ONLY,
'context-save': CLAUDE_ONLY,
'design-shotgun': CLAUDE_ONLY,
'deslop-shared-libs': CLAUDE_ONLY,
'document-generate': CLAUDE_ONLY,
'document-release': CLAUDE_ONLY,
freeze: CLAUDE_ONLY,
'gstack-upgrade': CLAUDE_ONLY,
guard: CLAUDE_ONLY,
health: CLAUDE_ONLY,
'ios-clean': CLAUDE_ONLY,
'ios-design-review': CLAUDE_ONLY,
'ios-fix': CLAUDE_ONLY,
'ios-qa': CLAUDE_ONLY,
'ios-sync': CLAUDE_ONLY,
'landing-report': CLAUDE_ONLY,
learn: CLAUDE_ONLY,
retro: CLAUDE_ONLY,
'setup-deploy': CLAUDE_ONLY,
'setup-gbrain': CLAUDE_ONLY,
'sync-gbrain': CLAUDE_ONLY,
'test-audit': CLAUDE_ONLY,
unfreeze: CLAUDE_ONLY,
};
/** Rows always evaluated and required, in print order. */
export const CORE_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'core').map(c => c.id);
export const OPTIONAL_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'optional').map(c => c.id);
export const HOST_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'host').map(c => c.id);
/** The SKIP reason the installer and the doctor print for a lazily installed browser. */
export const BROWSER_LAZY_REASON = 'lazy; gstack-browser-ensure installs on first use';
/** Render the table as Bash 3.2 (sourced by bin/gstack-doctor-check.sh). */
export function renderDoctorComponentsSh(): string {
const caseArm = (ids: readonly string[], body: string) => ` ${ids.join('|')}) ${body} ;;`;
const groups = new Map<string, string[]>();
for (const [skill, req] of Object.entries(SKILL_REQUIREMENTS)) {
const key = `${req.requires.join(' ')}\t${req.optional.join(' ')}`;
groups.set(key, [...(groups.get(key) ?? []), skill]);
}
const requireArms: string[] = [];
const optionalArms: string[] = [];
for (const [key, skills] of groups) {
const [requires, optional] = key.split('\t');
requireArms.push(caseArm(skills, `printf '%s' '${requires}'`));
optionalArms.push(caseArm(skills, `printf '%s' '${optional}'`));
}
return [
'# shellcheck shell=bash',
'# gstack-doctor-components.sh — GENERATED from lib/doctor-components.ts and',
'# lib/result-codes.ts by scripts/gen-doctor-components.ts (bun run gen:skill-docs). Do not edit; the',
'# freshness test is test/doctor-components.test.ts. Sourced by',
'# bin/gstack-doctor-check.sh; Bash 3.2 builtins only.',
'',
`GSTACK_DOCTOR_COMPONENTS="${COMPONENT_IDS.join(' ')}"`,
`GSTACK_DOCTOR_CORE="${CORE_IDS.join(' ')}"`,
`GSTACK_DOCTOR_HOSTS="${HOST_IDS.join(' ')}"`,
`GSTACK_DOCTOR_OPTIONAL="${OPTIONAL_IDS.join(' ')}"`,
`GSTACK_DOCTOR_SKILLS="${Object.keys(SKILL_REQUIREMENTS).join(' ')}"`,
`GSTACK_BROWSER_LAZY_REASON="${BROWSER_LAZY_REASON}"`,
'',
'# gstack_doctor_component_title ID — the human title; returns 1 for an unknown id.',
'gstack_doctor_component_title() {',
' case "$1" in',
...DOCTOR_COMPONENTS.map(c => caseArm([c.id], `printf '%s' '${c.title.replace(/'/g, "'\\''")}'`)),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_component_kind ID — core | host | component | optional; returns 1 for an unknown id.',
'gstack_doctor_component_kind() {',
' case "$1" in',
...DOCTOR_COMPONENTS.map(c => caseArm([c.id], `printf '%s' '${c.kind}'`)),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_skill_requires SKILL — the non-core ids the skill needs; returns 1 for an unknown skill.',
'gstack_doctor_skill_requires() {',
' case "$1" in',
...requireArms,
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_skill_optional SKILL — ids that SKIP when absent; returns 1 for an unknown skill.',
'gstack_doctor_skill_optional() {',
' case "$1" in',
...optionalArms,
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code.',
'gstack_result_anchor() {',
' case "$1" in',
...Object.entries(RESULT_CODES).map(([code, row]) => caseArm([code], `printf '%s' '${row.anchor}'`)),
' *) return 1 ;;',
' esac',
'}',
'',
].join('\n');
}
+50 -2
View File
@@ -1,7 +1,9 @@
/**
* Result codes for the unattended workflow commands (gstack-artifact,
* gstack-gate, gstack-autoplan-timing, gstack-review-log --findings, and the
* skill-start guard line). Sibling of lib/gate-outcomes.ts, whose `state`
* gstack-gate, gstack-autoplan-timing, gstack-review-log --findings, the
* skill-start guard line) and the install side (gstack-doctor --check,
* gstack-capy-install, gstack-browser-ensure; the Bash side reads the same
* table from the generated bin/gstack-doctor-components.sh). Sibling of lib/gate-outcomes.ts, whose `state`
* enum is for gate outcomes only: a result code has no state, just one
* stable anchor in docs/troubleshooting.md, a summary and a `fix:` clause.
* Every error line a workflow command prints ends with `(<CODE>)` so a parent
@@ -112,10 +114,56 @@ export const RESULT_CODES = {
summary: 'part of the reply matched no gate item or option, so nothing was applied',
fix: 'reply with `all`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous',
},
// PR A (multi-agent wave): gstack-doctor --check, gstack-capy-install, gstack-browser-ensure.
COMPONENT_MISSING: {
anchor: 'doctor-component-missing',
summary: 'a component the selected workflow needs is not installed, not built or not current',
fix: 'run the command in the row\'s fix clause, then re-run gstack-doctor --check',
},
RUNTIME_BELOW_MINIMUM: {
anchor: 'doctor-runtime-below-minimum',
summary: 'the Bun on PATH is absent, below gstack\'s security floor, or below its supported minimum',
fix: 'install the supported Bun through setup\'s checksum-verified recipe (bin/gstack-capy-install does this on Capy), then re-run ./setup',
},
PROJECT_PIN_MISMATCH: {
anchor: 'doctor-project-pin-mismatch',
summary: 'the running Bun or Node is outside a version the target project pins (package.json engines, .tool-versions, .nvmrc, .bun-version or a CI workflow)',
fix: 'install the pinned version for this platform, or change the pin in the project',
},
REVISION_UNMET: {
anchor: 'doctor-revision-unmet',
summary: 'the installed gstack checkout is not at the revision --revision requested',
fix: 'run /gstack-upgrade (or git -C <checkout> fetch && git checkout <ref>, then ./setup) to move the checkout',
},
RUNTIME_PIN_CONFLICT: {
anchor: 'capy-install-runtime-conflict',
summary: 'gstack\'s supported Bun minimum and the project\'s Bun pin cannot both be satisfied, so the installer changed nothing',
fix: 'raise the project\'s pin to gstack\'s supported minimum, or install gstack on a machine whose Bun the project accepts',
},
BROWSER_UNAVAILABLE: {
anchor: 'browser-ensure-failed',
summary: 'Chromium could not be installed or launched by gstack-browser-ensure',
fix: 'read the installer output above (offline, proxy, AppArmor); on Ubuntu 24.04+ try GSTACK_CHROMIUM_NO_SANDBOX=1, then re-run gstack-browser-ensure',
},
INSTALL_PREFLIGHT_FAILED: {
anchor: 'capy-install-preflight-failed',
summary: 'a tool the installer needs before ./setup (git, curl, node, jq) is missing, or the Bun install recipe failed its checksum',
fix: 'install the named tool, then re-run bin/gstack-capy-install',
},
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
/** Alias kept for the doctor/installer side. */
export type ResultCodeId = ResultCodeName;
export const TROUBLESHOOTING_DOC = 'https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md';
/** `(CODE; <doc>#anchor)` — the suffix the doctor and installer rows carry. */
export function resultCodeSuffix(code: ResultCodeName): string {
return `(${code}; ${TROUBLESHOOTING_DOC}#${RESULT_CODES[code].anchor})`;
}
/** One error line: `<context>: <summary>[ (<detail>)]; fix: <fix> (<CODE>)`. */
export function resultLine(context: string, code: ResultCodeName, detail?: string): string {
const row: ResultCode = RESULT_CODES[code];
+236
View File
@@ -0,0 +1,236 @@
/**
* runtime-pins — what a target project pins for Bun and Node, and whether the
* running versions satisfy it. Feeds the doctor's `project pins` row
* (bin/gstack-runtime-pins.ts; bin/gstack-doctor-check.sh reads the
* PINS_VERDICT line) and nothing else reads project pins by hand.
*
* Sources, each kept with its file:line: package.json (`packageManager`,
* `engines.bun`, `engines.node`), `.tool-versions`, `.nvmrc`, `.bun-version`
* and `bun-version:` / `node-version:` lines in `.github/workflows/*.yml`. A
* range (`>=1.4.2`) is told apart from an exact pin (`1.4.2`); a CI pin keeps
* the platform lane of the job's `runs-on`, so a Windows job's pin never fails
* a Linux run. A value this module cannot evaluate (`lts/*`, a matrix
* expression) is reported as skipped, never silently dropped.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export type Tool = 'bun' | 'node';
export type Lane = 'any' | 'linux' | 'macos' | 'windows';
export interface Pin {
tool: Tool;
/** The constraint as written (`>=1.4.2`, `1.4.2`, `24`, `lts/*`). */
constraint: string;
kind: 'exact' | 'range' | 'unevaluable';
/** `package.json:81`, `.github/workflows/x.yml:84`. */
source: string;
/** Where the pin comes from: `engines.bun`, `packageManager`, `.nvmrc`, `CI`. */
field: string;
lane: Lane;
}
export interface Running { bun?: string; node?: string }
export interface PinReport {
pins: Pin[];
/** Distinct effective constraints with their first source and count, for a one-line row. */
summary: string[];
/** Human lines: one per failing tool. */
failures: string[];
/** Pins skipped as unevaluable, one line each. */
skipped: string[];
verdict: 'pass' | 'fail' | 'none';
}
const RANGE_OP = /^(>=|<=|>|<|=|\^|~)/;
type Ver = [number, number, number];
/** Parse `v1.2.3`, `1.2`, `24` into a triple; null when not a version. */
export function parseVersion(text: string): Ver | null {
const m = text.trim().replace(/^v/, '').match(/^(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
if (!m) return null;
const n = (s: string | undefined) => (s === undefined || s === 'x' || s === '*' ? 0 : Number(s));
return [Number(m[1]), n(m[2]), n(m[3])];
}
const cmp = (a: Ver, b: Ver) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
/** Components written (`1.4` → 2, `24` → 1, `1.4.x` → 2) for prefix pins. */
function writtenParts(text: string): number {
const parts = text.trim().replace(/^v/, '').split(/[-+]/)[0].split('.');
return parts.filter(p => p !== 'x' && p !== '*').length;
}
function satisfiesOne(version: Ver, clause: string): boolean | null {
const op = clause.match(RANGE_OP)?.[1] ?? '';
const rest = clause.slice(op.length).trim();
if (rest === '' || rest === '*' || rest === 'x') return true;
const target = parseVersion(rest);
if (!target) return null;
const parts = writtenParts(rest);
const upper: Ver = parts === 1 ? [target[0] + 1, 0, 0] : parts === 2 ? [target[0], target[1] + 1, 0] : [target[0], target[1], target[2] + 1];
switch (op) {
case '>=': return cmp(version, target) >= 0;
case '>': return parts === 3 ? cmp(version, target) > 0 : cmp(version, upper) >= 0;
case '<=': return parts === 3 ? cmp(version, target) <= 0 : cmp(version, upper) < 0;
case '<': return cmp(version, target) < 0;
case '^': {
const top: Ver = target[0] > 0 ? [target[0] + 1, 0, 0] : target[1] > 0 ? [0, target[1] + 1, 0] : [0, 0, target[2] + 1];
return cmp(version, target) >= 0 && cmp(version, top) < 0;
}
case '~': return cmp(version, target) >= 0 && cmp(version, parts === 1 ? [target[0] + 1, 0, 0] : [target[0], target[1] + 1, 0]) < 0;
default: return cmp(version, target) >= 0 && cmp(version, upper) < 0;
}
}
/**
* `satisfies('1.3.14', '>=1.4.2')` → false. Supports `||` alternatives,
* space-joined conjunctions, `>= > <= < = ^ ~`, `x`/`*` wildcards and partial
* versions (`24` means 24.x.x). Returns null when the constraint cannot be
* evaluated (`lts/*`, `node`, a `${{ }}` expression).
*/
export function satisfies(version: string, constraint: string): boolean | null {
const v = parseVersion(version);
if (!v) return null;
let sawNull = false;
for (const alt of constraint.split('||')) {
const clauses = alt.trim().replace(/\s*(>=|<=|>|<|=|\^|~)\s*/g, ' $1').trim().split(/\s+/).filter(Boolean);
if (clauses.length === 0) continue;
const results = clauses.map(c => satisfiesOne(v, c));
if (results.some(r => r === null)) { sawNull = true; continue; }
if (results.every(Boolean)) return true;
}
return sawNull ? null : false;
}
export function classify(constraint: string): Pin['kind'] {
const trimmed = constraint.trim();
if (satisfies('0.0.0', trimmed) === null) return 'unevaluable';
if (RANGE_OP.test(trimmed) || /\|\||\s|[x*]/.test(trimmed)) return 'range';
return writtenParts(trimmed) === 3 ? 'exact' : 'range';
}
function laneOf(runsOn: string): Lane {
const value = runsOn.toLowerCase();
if (value.includes('windows')) return 'windows';
if (value.includes('macos')) return 'macos';
if (value.includes('ubuntu') || value.includes('linux')) return 'linux';
return 'any';
}
function lineOf(text: string, needle: RegExp): number {
const lines = text.split('\n');
const i = lines.findIndex(l => needle.test(l));
return i < 0 ? 1 : i + 1;
}
function readIf(file: string): string | null {
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
}
/** Every pin the project declares, in source order. */
export function collectPins(projectDir: string): Pin[] {
const pins: Pin[] = [];
const pkgText = readIf(path.join(projectDir, 'package.json'));
if (pkgText) {
let pkg: { packageManager?: unknown; engines?: Record<string, unknown> } = {};
try { pkg = JSON.parse(pkgText); } catch { /* reported as no pins from package.json */ }
if (typeof pkg.packageManager === 'string') {
const m = pkg.packageManager.match(/^(bun|node)@([^+]+)/);
if (m) pins.push({ tool: m[1] as Tool, constraint: m[2], kind: classify(m[2]), source: `package.json:${lineOf(pkgText, /"packageManager"/)}`, field: 'packageManager', lane: 'any' });
}
for (const tool of ['bun', 'node'] as const) {
const value = pkg.engines?.[tool];
if (typeof value !== 'string') continue;
pins.push({ tool, constraint: value, kind: classify(value), source: `package.json:${lineOf(pkgText, new RegExp(`"${tool}"\\s*:`))}`, field: `engines.${tool}`, lane: 'any' });
}
}
const toolVersions = readIf(path.join(projectDir, '.tool-versions'));
if (toolVersions) {
toolVersions.split('\n').forEach((line, i) => {
const m = line.trim().match(/^(bun|nodejs|node)\s+(\S+)/);
if (!m) return;
const tool: Tool = m[1] === 'bun' ? 'bun' : 'node';
pins.push({ tool, constraint: m[2], kind: classify(m[2]), source: `.tool-versions:${i + 1}`, field: '.tool-versions', lane: 'any' });
});
}
for (const [file, tool] of [['.nvmrc', 'node'], ['.bun-version', 'bun']] as const) {
const text = readIf(path.join(projectDir, file));
const value = text?.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
if (value) pins.push({ tool, constraint: value, kind: classify(value), source: `${file}:1`, field: file, lane: 'any' });
}
const workflows = path.join(projectDir, '.github', 'workflows');
let names: string[] = [];
try { names = fs.readdirSync(workflows).filter(n => /\.ya?ml$/.test(n)).sort(); } catch { /* no workflows */ }
for (const name of names) {
const text = readIf(path.join(workflows, name));
if (!text) continue;
let lane: Lane = 'any';
text.split('\n').forEach((line, i) => {
const runsOn = line.match(/^\s*runs-on:\s*(.+?)\s*$/);
if (runsOn) { lane = laneOf(runsOn[1]); return; }
const m = line.match(/^\s*(bun|node)-version:\s*["']?(\$\{\{.*?\}\}|[^"'#\s]+)["']?\s*(?:#.*)?$/);
if (!m) return;
const constraint = m[2];
const kind = constraint.includes('${{') ? 'unevaluable' : classify(constraint);
pins.push({ tool: m[1] as Tool, constraint, kind, source: `.github/workflows/${name}:${i + 1}`, field: 'CI', lane });
});
}
return pins;
}
const PLATFORM_LANE: Record<string, Lane> = { linux: 'linux', darwin: 'macos', win32: 'windows' };
/** Distinct (tool, field, constraint, lane) groups with their first source and count. */
function groupPins(pins: Pin[]): Array<{ pin: Pin; count: number }> {
const groups = new Map<string, { pin: Pin; count: number }>();
for (const pin of pins) {
const key = `${pin.tool}\t${pin.field === 'CI' ? 'CI' : pin.field}\t${pin.constraint}\t${pin.lane}`;
const g = groups.get(key);
if (g) g.count++; else groups.set(key, { pin, count: 1 });
}
return [...groups.values()];
}
const describe = (g: { pin: Pin; count: number }) =>
`${g.pin.field === 'CI' ? `CI${g.pin.lane === 'any' ? '' : ` ${g.pin.lane} lane`}` : g.pin.field}, ${g.pin.source.replace(/^\.github\/workflows\//, '')}${g.count > 1 ? ` +${g.count - 1} more` : ''}`;
/** Evaluate the running versions against the pins that apply on this platform. */
export function evaluatePins(pins: Pin[], running: Running, platform: NodeJS.Platform = process.platform): PinReport {
const here = PLATFORM_LANE[platform] ?? 'any';
const failures: string[] = [];
const skipped: string[] = [];
const summary = groupPins(pins).map(g => `${g.pin.tool} ${g.pin.constraint} (${describe(g)})`);
let evaluated = 0;
for (const tool of ['bun', 'node'] as const) {
const mine = pins.filter(p => p.tool === tool);
if (mine.length === 0) continue;
const version = running[tool];
const applicable = mine.filter(p => p.lane === 'any' || p.lane === here);
const unmet: Pin[] = [];
for (const pin of applicable) {
if (pin.kind === 'unevaluable') { skipped.push(`${tool} ${pin.constraint} (${pin.source}) not evaluated`); continue; }
if (!version) { skipped.push(`${tool} ${pin.constraint} (${pin.source}): ${tool} not found on PATH`); continue; }
evaluated++;
if (satisfies(version, pin.constraint) === false) unmet.push(pin);
}
if (unmet.length === 0) continue;
const [first, ...rest] = groupPins(unmet);
const context = rest.map(g => `${describe(g)} pins ${g.pin.constraint}`);
failures.push(`${tool} ${version} outside ${first.pin.field} ${first.pin.constraint} (${first.pin.source}${context.length ? '; ' + context.join('; ') : ''})`);
}
return { pins, summary, failures, skipped, verdict: failures.length ? 'fail' : evaluated ? 'pass' : 'none' };
}
/** The wire lines bin/gstack-doctor-check.sh reads (PINS_VERDICT last). */
export function renderPinLines(report: PinReport, platform: NodeJS.Platform = process.platform): string[] {
const here = PLATFORM_LANE[platform] ?? 'any';
const lines = report.pins.map(p => `PIN: ${p.tool} ${p.constraint} ${p.kind} ${p.source} lane=${p.lane}${p.lane !== 'any' && p.lane !== here ? ' (other platform)' : ''}`);
for (const s of report.summary) lines.push(`PIN_SUMMARY: ${s}`);
for (const s of report.skipped) lines.push(`PIN_SKIP: ${s}`);
for (const f of report.failures) lines.push(`PIN_FAIL: ${f}`);
lines.push(`PINS_VERDICT: ${report.verdict}`);
return lines;
}