From f51f00ef0ef65eb16d0de9bf36aa38c9ef428570 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:42 -0400 Subject: [PATCH] refactor(#3309): add agent-install health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W010 — agent install completeness across its 4 internal conditions, migrated onto the frozen rule table per ADR-3180 §8.2. --- src/health-diagnostic-rules/agent-install.cts | 119 ++++++++ .../agent-install.test.cjs | 263 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 src/health-diagnostic-rules/agent-install.cts create mode 100644 tests/health-diagnostic-rules/agent-install.test.cjs diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts new file mode 100644 index 000000000..986b495f3 --- /dev/null +++ b/src/health-diagnostic-rules/agent-install.cts @@ -0,0 +1,119 @@ +/** + * Agent Install rule (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5). + * + * One code, W010, ported behavior-preserving from `cmdValidateHealth`'s + * agent-install block (`src/verify.cts:1992-2027`). That block wraps a + * single `checkAgentsInstalled(_slashRuntime, cwd)` call in a try/catch that + * swallows any thrown exception silently ("agent check is non-blocking", + * `verify.cts:2025-2027`) and then branches on the SAME subject — + * "agent installation is incomplete" — across four mutually exclusive + * combinations of `missing_agents`/`incomplete_agents`, firing at most one + * `addIssue('warning', 'W010', ...)` per call. Per this phase's design doc + * ("Rejected alternatives" §3), these four sites are confirmed to be one + * subject varying only in trigger detail, not four subjects — W010 stays a + * single code. + * + * `snapshot.agentInstall` (`src/planning-snapshot.cts`'s `buildAgentInstallField`) + * already performs the try/catch this rule used to need: `scope` is + * `UNREADABLE` only when the scan itself threw, mirroring + * `cmdValidateHealth`'s silent catch — this rule reproduces that silence by + * returning no diagnostic for `UNREADABLE`, rather than inventing a new, + * more severe 5th case the original never had. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * ("Rule table organization" — Agent installation group) + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Rule = healthDiagnosticMod.Rule; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Remedy = healthDiagnosticMod.Remedy; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted +import type planningSnapshotMod = require('../planning-snapshot.cjs'); +type PlanningSnapshot = ReturnType; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('../planning-scope.cjs'); +const { SCOPE } = planningScopeMod; + +import { PACKAGE_NAME } from '../package-identity.cjs'; + +function adviseRemedy(command: string): Remedy { + return { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command } }; +} + +/** + * `check(snapshot)` for W010 — see module header for the exact 4-way + * branching this ports from `verify.cts:1992-2027`, and its message/fix + * templates copied verbatim (only the interpolated `agentStatus.*` values + * differ per call). + */ +function checkAgentInstall(snapshot: PlanningSnapshot): Diagnostic[] { + const { value: status, scope } = snapshot.agentInstall; + + // Mirrors verify.cts:2025-2027's try/catch around the checkAgentsInstalled + // call itself — a thrown scan is swallowed, not reported. `scope` here is + // UNREADABLE only in that same case (buildAgentInstallField's own catch). + if (scope === SCOPE.UNREADABLE) return []; + + if (status.agents_installed) return []; + + // verify.cts:1995 — zero agents installed at all. + if (status.installed_agents.length === 0) { + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `No GSD agents found in ${status.agents_dir} — Task(subagent_type="gsd-*") will fall back to general-purpose`, + remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`), + }, + ]; + } + + // verify.cts:2002 — some agents incomplete (missing a generated file), zero fully missing. + if (status.incomplete_agents.length > 0 && status.missing_agents.length === 0) { + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `Incomplete agent installs (missing generated file): ${status.incomplete_agents.join(', ')} — affected workflows may fall back to general-purpose`, + remedy: adviseRemedy(`Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest`), + }, + ]; + } + + // verify.cts:2009 — both missing AND incomplete agents present. + if (status.incomplete_agents.length > 0) { + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `Missing ${status.missing_agents.length} GSD agents: ${status.missing_agents.join(', ')}; incomplete agent installs (missing generated file): ${status.incomplete_agents.join(', ')} — affected workflows will fall back to general-purpose`, + remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`), + }, + ]; + } + + // verify.cts:2017 — agents missing only (no incomplete). + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `Missing ${status.missing_agents.length} GSD agents: ${status.missing_agents.join(', ')} — affected workflows will fall back to general-purpose`, + remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`), + }, + ]; +} + +const RULES: Rule[] = [ + { + code: 'W010', + severity: SEVERITY.WARNING, + check: checkAgentInstall, + }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/agent-install.test.cjs b/tests/health-diagnostic-rules/agent-install.test.cjs new file mode 100644 index 000000000..ca2d1671b --- /dev/null +++ b/tests/health-diagnostic-rules/agent-install.test.cjs @@ -0,0 +1,263 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/agent-install.cts` (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5) — the W010 rule (agent installation is + * incomplete), 4 mutually exclusive trigger conditions ported from + * `verify.cts:1992-2027`, plus the "0 missing 0 incomplete" (no diagnostic) + * case and the `scope === SCOPE.UNREADABLE` silent case. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md + * + * Fixture provenance (#2371): `checkAgentsInstalled` scans a REAL filesystem + * agents directory, not `.planning/`. Per the design doc's Fixture + * provenance §, this file REUSES rather than reinvents: + * - `createCompleteAgentsDir`/`withAgentsDirOverride` are copied verbatim + * from `tests/planning-snapshot.test.cjs`'s own `agentInstall field` + * describe block (Phase 11's own foundational batch already established + * this exact GSD_AGENTS_DIR-override technique for driving + * `buildPlanningSnapshot` against a controlled agents dir). + * - The manifest-driven "incomplete" fixture shape (a `gsd-file-manifest.json` + * alongside the agents dir, tracking a `.toml` key that is absent on disk + * for one agent) is copied from `tests/agent-install-check.test.cjs`'s + * "a partial manifest-backed local installation remains selected and + * incomplete" / "partial manifest: agent.toml absent but agent.md + * present" tests — the same manifest resolution + * (`readInstallManifest(path.dirname(agentsDir))`) `checkAgentsInstalled` + * itself uses. + * Every fixture below is structural absence/presence of agent files, exempt + * from the provenance concern (no document format is being modeled). + * + * Uses the REAL `buildPlanningSnapshot(cwd)` (`src/planning-snapshot.cts`) + * for every case except the UNREADABLE-scope case, which constructs the + * minimal `{agentInstall: {value, scope}}` slice a `Rule.check(snapshot)` + * actually reads — not a mock of `checkAgentsInstalled` (no owner is + * reimplemented or stubbed), just the documented `Scope` contract's + * UNREADABLE member, which is not otherwise reachable through the real + * filesystem scan without monkeypatching an owner internal. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const planningSnapshotLib = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { buildPlanningSnapshot } = planningSnapshotLib; +const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs'); +const { PACKAGE_NAME } = require('../../gsd-core/bin/lib/package-identity.cjs'); +const { MODEL_PROFILES } = require('../../gsd-core/bin/lib/model-profiles.cjs'); +const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); + +const { RULES } = require('../../gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs'); +const rule = RULES.find((r) => r.code === 'W010'); + +// ─── Fixture helpers (copied verbatim from tests/planning-snapshot.test.cjs's +// agentInstall describe block — see module header) ───────────────────────── + +function createCompleteAgentsDir(agentsDir) { + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`); + } +} + +function withAgentsDirOverride(t, agentsDir) { + const saved = process.env['GSD_AGENTS_DIR']; + process.env['GSD_AGENTS_DIR'] = agentsDir; + t.after(() => { + if (saved === undefined) delete process.env['GSD_AGENTS_DIR']; + else process.env['GSD_AGENTS_DIR'] = saved; + }); +} + +// Manifest-driven "incomplete agent" fixture shape, copied from +// tests/agent-install-check.test.cjs's partial-manifest tests (see module +// header). `agentsDir`'s PARENT directory is where checkAgentsInstalled +// resolves gsd-file-manifest.json from (readInstallManifest(dirname(agentsDir))). +function writeManifest(agentsDir, manifestFiles) { + fs.writeFileSync( + path.join(path.dirname(agentsDir), 'gsd-file-manifest.json'), + JSON.stringify({ files: manifestFiles }), + ); +} + +describe('agent-install rule (W010)', () => { + test('module exports exactly one W010 rule', () => { + assert.ok(rule, 'RULES must contain a W010 entry'); + assert.strictEqual(RULES.length, 1); + assert.strictEqual(rule.code, 'W010'); + assert.strictEqual(rule.severity, 'warning'); + }); + + test('0 missing 0 incomplete: all agents present — no diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-w010-clean-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents-complete'); + createCompleteAgentsDir(agentsDir); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.strictEqual(snapshot.agentInstall.scope, SCOPE.COMPLETE); + assert.deepStrictEqual(rule.check(snapshot), []); + }); + + test('condition 1: zero agents installed at all (agents dir absent)', (t) => { + const cwd = createTempDir('gsd-3309-w010-zero-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents-absent'); + withAgentsDirOverride(t, agentsDir); + // agentsDir deliberately never created. + + const snapshot = buildPlanningSnapshot(cwd); + assert.strictEqual(snapshot.agentInstall.scope, SCOPE.COMPLETE); + assert.strictEqual(snapshot.agentInstall.value.installed_agents.length, 0); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual(d.severity, 'warning'); + assert.strictEqual( + d.message, + `No GSD agents found in ${agentsDir} — Task(subagent_type="gsd-*") will fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('condition 2: some agents incomplete (missing generated file), zero fully missing', (t) => { + const cwd = createTempDir('gsd-3309-w010-incomplete-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + const incompleteAgent = EXPECTED_AGENTS[0]; + // Manifest tracks every agent's .md (present) plus a .toml for + // incompleteAgent only (absent on disk) — makes exactly one agent + // incomplete while presence (missing_agents) stays empty. + const manifestFiles = {}; + for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = {}; + manifestFiles[`agents/${incompleteAgent}.toml`] = {}; + writeManifest(agentsDir, manifestFiles); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.strictEqual(snapshot.agentInstall.value.missing_agents.length, 0); + assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, [incompleteAgent]); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual( + d.message, + `Incomplete agent installs (missing generated file): ${incompleteAgent} — affected workflows may fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('condition 3: both missing AND incomplete agents present', (t) => { + const cwd = createTempDir('gsd-3309-w010-both-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + const [missingAgent, incompleteAgent, ...restAgents] = EXPECTED_AGENTS; + // missingAgent: no files at all, no manifest entry — stays purely missing. + for (const agent of [incompleteAgent, ...restAgents]) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + const manifestFiles = {}; + manifestFiles[`agents/${incompleteAgent}.md`] = {}; + manifestFiles[`agents/${incompleteAgent}.toml`] = {}; // absent on disk -> incomplete + for (const agent of restAgents) manifestFiles[`agents/${agent}.md`] = {}; + writeManifest(agentsDir, manifestFiles); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snapshot.agentInstall.value.missing_agents, [missingAgent]); + assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, [incompleteAgent]); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual( + d.message, + `Missing 1 GSD agents: ${missingAgent}; incomplete agent installs (missing generated file): ${incompleteAgent} — affected workflows will fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('condition 4: agents missing only (no incomplete)', (t) => { + const cwd = createTempDir('gsd-3309-w010-missing-only-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + const [missingAgent, ...restAgents] = EXPECTED_AGENTS; + for (const agent of restAgents) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + const manifestFiles = {}; + for (const agent of restAgents) manifestFiles[`agents/${agent}.md`] = {}; + writeManifest(agentsDir, manifestFiles); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snapshot.agentInstall.value.missing_agents, [missingAgent]); + assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, []); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual( + d.message, + `Missing 1 GSD agents: ${missingAgent} — affected workflows will fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('scope UNREADABLE (agent scan itself threw): no diagnostic, mirrors verify.cts\'s silent catch', () => { + // Minimal snapshot slice — see module header for why this is not an + // owner mock: UNREADABLE is a real, documented Scope member that + // buildAgentInstallField sets when checkAgentsInstalled throws + // (planning-snapshot.cts's own try/catch), and the rule's whole + // contract is `(snapshot) => Diagnostic[]` — it never calls the owner + // itself. + const snapshot = { + agentInstall: { + scope: SCOPE.UNREADABLE, + value: { + agents_installed: false, + missing_agents: [], + installed_agents: [], + incomplete_agents: [], + agents_dir: '', + agent_runtime: 'claude', + }, + }, + }; + assert.deepStrictEqual(rule.check(snapshot), []); + }); +});