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:
119
src/health-diagnostic-rules/agent-install.cts
Normal file
119
src/health-diagnostic-rules/agent-install.cts
Normal 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 };
|
||||
263
tests/health-diagnostic-rules/agent-install.test.cjs
Normal file
263
tests/health-diagnostic-rules/agent-install.test.cjs
Normal 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), []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user