refactor(#3309): add agent-install health-diagnostic rules

W010 — agent install completeness across its 4 internal conditions,
migrated onto the frozen rule table per ADR-3180 §8.2.
This commit is contained in:
sim
2026-08-13 01:37:42 -04:00
parent 1b09fb13d3
commit f51f00ef0e
2 changed files with 382 additions and 0 deletions

View File

@@ -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<typeof planningSnapshotMod.buildPlanningSnapshot>;
// 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 };

View File

@@ -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), []);
});
});