* feat(#3586): warn when .planning/ is gitignored but still tracked git ignore rules have no effect on files git already tracks, so a project that committed .planning/ before ignoring it keeps staging those files -- while commit_docs correctly resolves to false, which is exactly what makes the contradiction invisible. The probe lives in the SNAPSHOT BUILDER, not the rule: Rule.check may perform no ambient I/O (ADR-3180 8.1 rule 1, enforced by lint-planning-snapshot-bypass). buildPlanningTrackedField follows buildWorktreeHealthField's precedent -- injected execGit, bounded, degrading to UNREADABLE with a typed reason rather than throwing. W024 went inline instead only because no snapshot field carried its fact; that precondition does not apply here. W029 fires only on COMPLETE scope with ignored and tracked both true, so a degraded probe yields neither a finding nor a false all-clear, and the default project (tracked, not ignored) stays silent. The remedy is ADVISE-only -- --repair never untracks anything. * docs(#3586): document W029 and correct the health rule count CONFIGURATION.md documented the gitignore auto-detect without the caveat that ignore rules do not affect already-tracked files -- the very gap W029 exists to surface. Adds the caveat, the warning, its remedy, and why --repair will not act on it. CONTEXT.md's rule count was stale at 31 before this change (actual 32 through W028); corrected to 33 and pointed at the two other places the count is locked, so the next editor updates all three together. * fix(#3586): treat ls-files overflow as tracked, add CLI-level W029 tests Review findings. Security (minor, confirmed): execGit sets no maxBuffer, so Node's 1MB default applies to git ls-files. A .planning/ tree large enough to overflow it failed into git_list_failed and silenced W029 -- a false negative in exactly the large-history case most likely to have the real bug. Overflow is now treated as PROOF of tracking (the output was non-empty by definition) and resolves to tracked:true, scope COMPLETE, reason ok_truncated. Spec (major): test-matrix rows C1 and C2 were never implemented -- there was no CLI-level integration test at all, only rule-level ones. Both now drive the real validate-health dispatch and confirm W029 is reachable end-to-end. Known limit documented, not papered over: a deliberate git add -f under an otherwise-ignored .planning/ raises the same signal as the accidental case. There is no reliable way to tell them apart, the finding is advisory-only, and a heuristic that cannot actually distinguish them would be worse than the honest caveat. * test(#3586): update frozen health-doc counts and acknowledge health.md growth The remote matrix caught three gates that lint:ci does not cover. gen-health-docs.test.cjs froze a 35-row / 32-rule assertion; W029 makes it 36/33. Updated both the assertion and the test NAME, which embeds the counts -- a stale name is a lie even when the assertion passes. The second reported failure was the same assertion surfacing at describe-rollup granularity, not a distinct bug. emitted-attribution's growth arm needed an ack for the generated health.md. health.md was already named in 3309-health-docs-generated.json, and two ack sources naming one path is a hard error -- so a new fragment was not an option. That fragment's own history shows the pattern: #3309 created it, #2873 amended it in place for W028. Amended again for W029, with a note recording why this one file is amended rather than joined by a sibling. * docs(#3586): add the private-planning how-to and fix a wrong link docs/CONFIGURATION.md pointed 'Configure private planning' at how-to/configure-model-profiles.md -- an unrelated page -- and no private-planning how-to existed at all. Found while editing that section. The how-to test genuinely fires here: going private is four steps and crosses planning.search_gitignored, a setting owned by another concern, so a reference table structurally cannot carry it. The new page walks the whole sequence and leads with the step people miss -- .gitignore does not untrack what git already tracks -- which is the exact state W029 now detects. Also corrects 'artefacts' to 'artifacts' (repo house style is American). * chore(#3586): backfill changeset pr number to 3598 --------- Co-authored-by: sim <sim@local>
860 lines
32 KiB
JavaScript
860 lines
32 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for `src/health-diagnostic-rules/config-validation.cts` (Phase 11,
|
|
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "config.json validation": W003,
|
|
* E005, W004, W008, W016, W012, W013, W014, W015, W022.
|
|
*
|
|
* 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 (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"):
|
|
*
|
|
* - W003, W008, W016 are STRUCTURAL ABSENCE (a file, or a key, is missing) —
|
|
* exempt from external-citation provenance, mirrors
|
|
* tests/health-diagnostic-rules/root-existence.test.cjs's own framing.
|
|
* - E005, W004, W012, W013, W014, W015, W022 are MECHANICAL MUTATION: each
|
|
* fixture starts from `gsd-core/templates/config.json` (the real shipped
|
|
* shape, parsed once as `BASE_CONFIG`) with exactly ONE field changed to
|
|
* the invalid value under test, per the design doc's Fixture provenance
|
|
* §4. `w022`'s `models` key and `branching_strategy`/`context_window`/
|
|
* `phase_branch_template`/`milestone_branch_template` are not present in
|
|
* the shipped default at all (they are optional, additive keys) — for
|
|
* those the "one field changed" mutation is adding exactly that one key
|
|
* with its invalid value, the generic-mutation equivalent when there is no
|
|
* existing value to corrupt.
|
|
*
|
|
* Every fixture is driven through the REAL `buildPlanningSnapshot(cwd)`
|
|
* against a REAL temp `.planning/` directory with a REAL config.json file
|
|
* written to disk — mirrors tests/planning-snapshot.test.cjs's own
|
|
* `writeConfig` helper exactly (same shape, same call site convention).
|
|
*
|
|
* TDD RED: `src/health-diagnostic-rules/config-validation.cts` does not
|
|
* exist yet at the start of this batch — this file's
|
|
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs')`
|
|
* throws MODULE_NOT_FOUND until this batch's implementation lands.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const childProcess = require('node:child_process');
|
|
|
|
const { createTempDir, createTempGitProject, cleanup } = require('../helpers.cjs');
|
|
const { gitOrThrow } = require('../helpers/git-fixture.cjs');
|
|
|
|
const configValidation = require('../../gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs');
|
|
const { RULES } = configValidation;
|
|
|
|
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
|
|
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK, applyRepairs } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
|
|
const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs');
|
|
|
|
// Real shipped default, parsed once — the mutation base for every
|
|
// MECHANICAL MUTATION fixture below (design doc Fixture provenance §4).
|
|
const TEMPLATE_CONFIG_PATH = path.join(__dirname, '..', '..', 'gsd-core', 'templates', 'config.json');
|
|
const BASE_CONFIG = JSON.parse(fs.readFileSync(TEMPLATE_CONFIG_PATH, 'utf-8'));
|
|
|
|
function planningDirOf(cwd) {
|
|
return path.join(cwd, '.planning');
|
|
}
|
|
|
|
function writeConfig(cwd, obj) {
|
|
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
|
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj));
|
|
}
|
|
|
|
function writeRawConfig(cwd, rawText) {
|
|
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
|
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), rawText);
|
|
}
|
|
|
|
// Deep-clones BASE_CONFIG and applies exactly one mutation, mirroring the
|
|
// design doc's "mechanical, rule-blind mutation of the real shipped
|
|
// template" fixture class.
|
|
function mutatedConfig(mutate) {
|
|
const clone = JSON.parse(JSON.stringify(BASE_CONFIG));
|
|
mutate(clone);
|
|
return clone;
|
|
}
|
|
|
|
function ruleFor(code) {
|
|
const rule = RULES.find((r) => r.code === code);
|
|
assert.ok(rule, `rule ${code} not found in RULES`);
|
|
return rule;
|
|
}
|
|
|
|
// ─── RULES shape ────────────────────────────────────────────────────────────
|
|
|
|
describe('RULES (config-validation group)', () => {
|
|
test('exports exactly 11 rules: W003, E005, W004, W008, W016, W012, W013, W014, W015, W022, W029', () => {
|
|
assert.deepEqual(
|
|
RULES.map((r) => r.code).sort(),
|
|
['E005', 'W003', 'W004', 'W008', 'W012', 'W013', 'W014', 'W015', 'W016', 'W022', 'W029'].sort(),
|
|
);
|
|
});
|
|
|
|
test('E005 is severity ERROR; the rest are severity WARNING', () => {
|
|
assert.equal(ruleFor('E005').severity, SEVERITY.ERROR);
|
|
for (const code of ['W003', 'W004', 'W008', 'W012', 'W013', 'W014', 'W015', 'W016', 'W022', 'W029']) {
|
|
assert.equal(ruleFor(code).severity, SEVERITY.WARNING, `${code} should be WARNING`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── W003 — config.json not found ───────────────────────────────────────────
|
|
|
|
describe('W003 — config.json not found', () => {
|
|
test('fires when config.json is absent (structural absence)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w003-1-');
|
|
t.after(() => cleanup(cwd));
|
|
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W003').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W003',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json not found',
|
|
remedy: { action: REMEDY_ACTION.CREATE_CONFIG, risk: REMEDY_RISK.NONE, args: {} },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire when config.json exists', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w003-2-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W003').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── E005 — config.json JSON parse error ────────────────────────────────────
|
|
|
|
describe('E005 — config.json invalid JSON', () => {
|
|
test('fires when config.json exists but is unparseable', (t) => {
|
|
const cwd = createTempDir('gsd-3309-e005-1-');
|
|
t.after(() => cleanup(cwd));
|
|
writeRawConfig(cwd, '{ not valid json');
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('E005').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'E005',
|
|
severity: SEVERITY.ERROR,
|
|
message: 'config.json: JSON parse error',
|
|
remedy: { action: REMEDY_ACTION.RESET_CONFIG, risk: REMEDY_RISK.DESTRUCTIVE, args: {} },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire when config.json is absent (that is W003s job)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-e005-2-');
|
|
t.after(() => cleanup(cwd));
|
|
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('E005').check(snapshot), []);
|
|
assert.equal(ruleFor('W003').check(snapshot).length, 1);
|
|
});
|
|
|
|
test('does not fire when config.json is well-formed', (t) => {
|
|
const cwd = createTempDir('gsd-3309-e005-3-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('E005').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W004 — invalid model_profile ───────────────────────────────────────────
|
|
|
|
describe('W004 — invalid model_profile', () => {
|
|
test('fires on an invalid model_profile value (mutated shipped config)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w004-1-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.model_profile = 'not-a-real-profile';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W004').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W004',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: invalid model_profile "not-a-real-profile"',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: { command: 'Valid values: quality, balanced, budget, adaptive, inherit' },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire on a valid model_profile', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w004-2-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.model_profile = 'balanced';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W004').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when model_profile is absent', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w004-3-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W004').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W008 — workflow.nyquist_validation absent ──────────────────────────────
|
|
|
|
describe('W008 — workflow.nyquist_validation absent', () => {
|
|
test('fires when workflow is present but nyquist_validation key is deleted', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w008-1-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
delete c.workflow.nyquist_validation;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W008').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W008',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)',
|
|
remedy: { action: REMEDY_ACTION.ADD_NYQUIST_KEY, risk: REMEDY_RISK.NONE, args: {} },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire when nyquist_validation is present (shipped default)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w008-2-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W008').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when workflow itself is absent (guard mirrors original)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w008-3-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
delete c.workflow;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W008').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W016 — workflow.ai_integration_phase absent ────────────────────────────
|
|
|
|
describe('W016 — workflow.ai_integration_phase absent', () => {
|
|
test('fires on the unmodified shipped default — ai_integration_phase is not in the template at all (structural absence)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w016-1-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W016').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W016',
|
|
severity: SEVERITY.WARNING,
|
|
message:
|
|
'config.json: workflow.ai_integration_phase absent (defaults to enabled — run /gsd-ai-integration-phase before planning AI system phases)',
|
|
remedy: { action: REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, risk: REMEDY_RISK.NONE, args: {} },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire when ai_integration_phase key is present', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w016-2-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.workflow.ai_integration_phase = true;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W016').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when workflow itself is absent (guard mirrors original)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w016-3-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
delete c.workflow;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W016').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W012 — invalid branching_strategy ──────────────────────────────────────
|
|
|
|
describe('W012 — invalid branching_strategy', () => {
|
|
test('fires on an invalid branching_strategy value', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w012-1-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.branching_strategy = 'bogus-strategy';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W012').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W012',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: invalid branching_strategy "bogus-strategy"',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: { command: 'Valid values: none, phase, milestone' },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
for (const valid of ['none', 'phase', 'milestone']) {
|
|
test(`does not fire on valid branching_strategy "${valid}"`, (t) => {
|
|
const cwd = createTempDir('gsd-3309-w012-valid-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.branching_strategy = valid;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W012').check(snapshot), []);
|
|
});
|
|
}
|
|
|
|
test('does not fire when branching_strategy is absent', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w012-2-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W012').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W013 — context_window not a positive integer ───────────────────────────
|
|
|
|
describe('W013 — context_window not a positive integer', () => {
|
|
test('fires on a non-integer context_window', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w013-1-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.context_window = 3.5;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W013').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W013',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: context_window should be a positive integer, got "3.5"',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: { command: 'Set to 200000 (default) or 1000000 (for 1M models)' },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('fires on a non-positive context_window (boundary: 0)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w013-2-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.context_window = 0;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.equal(ruleFor('W013').check(snapshot).length, 1);
|
|
});
|
|
|
|
test('fires on a negative context_window', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w013-3-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.context_window = -1;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.equal(ruleFor('W013').check(snapshot).length, 1);
|
|
});
|
|
|
|
test('does not fire on a valid positive integer (boundary: 1)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w013-4-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.context_window = 1;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W013').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire on the conventional default (200000)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w013-5-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.context_window = 200000;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W013').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when context_window is absent', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w013-6-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W013').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W014 — phase_branch_template missing {phase} ───────────────────────────
|
|
|
|
describe('W014 — phase_branch_template missing {phase} placeholder', () => {
|
|
test('fires when the placeholder is stripped', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w014-1-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.phase_branch_template = 'phase/no-placeholder-here';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W014').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W014',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: phase_branch_template missing {phase} placeholder',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: { command: 'Template must include {phase} for phase number substitution' },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire when the placeholder is present', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w014-2-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.phase_branch_template = 'phase/{phase}';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W014').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when phase_branch_template is absent', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w014-3-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W014').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W015 — milestone_branch_template missing {milestone} ──────────────────
|
|
|
|
describe('W015 — milestone_branch_template missing {milestone} placeholder', () => {
|
|
test('fires when the placeholder is stripped', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w015-1-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.milestone_branch_template = 'milestone/no-placeholder-here';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W015').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W015',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: milestone_branch_template missing {milestone} placeholder',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: { command: 'Template must include {milestone} for version substitution' },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('does not fire when the placeholder is present', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w015-2-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.milestone_branch_template = 'milestone/{milestone}';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W015').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when milestone_branch_template is absent', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w015-3-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W015').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W022 — models malformed (3 internal conditions, 1 code) ───────────────
|
|
|
|
describe('W022 — config.json models malformed', () => {
|
|
test('(a) unknown phase type key fires', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022a-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = { not_a_real_phase_type: 'sonnet' };
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W022').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W022',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: models has an unknown phase type "not_a_real_phase_type" which will be ignored',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: {
|
|
command: 'Valid phase types: planning, discuss, research, execution, verification, completion',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('(b) known phase type with an invalid tier value fires', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022b-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = { planning: 'not-a-real-tier' };
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W022').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W022',
|
|
severity: SEVERITY.WARNING,
|
|
message: 'config.json: models.planning has an invalid tier value "not-a-real-tier" which will be ignored',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: { command: 'Valid tiers: opus, sonnet, haiku, inherit' },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('(c) models present but not an object fires, and does NOT also run (a)/(b)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022c-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = 'not-an-object';
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W022').check(snapshot);
|
|
|
|
assert.deepEqual(diagnostics, [
|
|
{
|
|
code: 'W022',
|
|
severity: SEVERITY.WARNING,
|
|
message:
|
|
'config.json: models is set to "not-an-object", but must be an object mapping phase types to tiers — this value will be ignored',
|
|
remedy: {
|
|
action: REMEDY_ACTION.ADVISE,
|
|
risk: REMEDY_RISK.NONE,
|
|
args: {
|
|
command: 'Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('(c) an array also counts as "not an object" (Array.isArray guard)', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022c-array-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = ['sonnet'];
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W022').check(snapshot);
|
|
assert.equal(diagnostics.length, 1);
|
|
assert.equal(diagnostics[0].code, 'W022');
|
|
});
|
|
|
|
test('multiple malformed entries in one models object each produce their own diagnostic', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022-multi-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = { planning: 'bogus-tier', not_a_real_phase_type: 'sonnet' };
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W022').check(snapshot);
|
|
assert.equal(diagnostics.length, 2);
|
|
for (const d of diagnostics) assert.equal(d.code, 'W022');
|
|
});
|
|
|
|
test('does not fire when models is a well-formed object', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022-ok-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = { planning: 'sonnet', research: 'inherit' };
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W022').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when models is absent', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022-absent-');
|
|
t.after(() => cleanup(cwd));
|
|
writeConfig(cwd, BASE_CONFIG);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W022').check(snapshot), []);
|
|
});
|
|
|
|
test('does not fire when models is null', (t) => {
|
|
const cwd = createTempDir('gsd-3309-w022-null-');
|
|
t.after(() => cleanup(cwd));
|
|
const cfg = mutatedConfig((c) => {
|
|
c.models = null;
|
|
});
|
|
writeConfig(cwd, cfg);
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W022').check(snapshot), []);
|
|
});
|
|
});
|
|
|
|
// ─── W029 — .planning/ ignored but still tracked (#3586, epic #2292) ──────
|
|
//
|
|
// Group B of the test matrix (.gsd/phase/fix-3586-tracked-gitignored-planning/
|
|
// 50-test-matrix.md): the rule is a pure predicate over
|
|
// `snapshot.planningTracked` — every fixture here is driven through the REAL
|
|
// `buildPlanningSnapshot(cwd)` against a REAL temp git repo, mirroring
|
|
// tests/planning-snapshot.test.cjs's own A1-A12 fixtures (same helpers:
|
|
// createTempGitProject / gitOrThrow, no hand-built partial snapshots except
|
|
// B4, which specifically tests the field-absent-entirely case).
|
|
|
|
function writeGitignore(cwd, content) {
|
|
fs.writeFileSync(path.join(cwd, '.gitignore'), content);
|
|
}
|
|
|
|
function commitAll(cwd, message) {
|
|
gitOrThrow(['add', '-A'], { cwd });
|
|
gitOrThrow(['commit', '-m', message], { cwd });
|
|
}
|
|
|
|
describe('W029 — .planning/ ignored but still tracked', () => {
|
|
test('B1: fires when ignored AND tracked, carrying the git rm -r --cached remedy', (t) => {
|
|
const cwd = createTempGitProject('gsd-3586-w029-b1-');
|
|
t.after(() => cleanup(cwd));
|
|
writeGitignore(cwd, '.planning/\n');
|
|
commitAll(cwd, 'add gitignore');
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W029').check(snapshot);
|
|
|
|
assert.equal(diagnostics.length, 1);
|
|
assert.equal(diagnostics[0].code, 'W029');
|
|
assert.equal(diagnostics[0].severity, SEVERITY.WARNING);
|
|
assert.deepEqual(diagnostics[0].remedy.action, REMEDY_ACTION.ADVISE);
|
|
assert.deepEqual(diagnostics[0].remedy.risk, REMEDY_RISK.NONE);
|
|
assert.match(diagnostics[0].remedy.args.command, /git rm -r --cached \.planning\//);
|
|
});
|
|
|
|
test('B2a: tracked + NOT ignored (the default project) is silent', (t) => {
|
|
const cwd = createTempGitProject('gsd-3586-w029-b2a-');
|
|
t.after(() => cleanup(cwd));
|
|
// No .gitignore at all.
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W029').check(snapshot), []);
|
|
});
|
|
|
|
test('B2b: ignored + untracked (the healthy ignored state) is silent', (t) => {
|
|
const cwd = createTempDir('gsd-3586-w029-b2b-');
|
|
t.after(() => cleanup(cwd));
|
|
gitOrThrow(['init'], { cwd });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd });
|
|
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd });
|
|
writeGitignore(cwd, '.planning/\n');
|
|
commitAll(cwd, 'initial (gitignore only)');
|
|
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, '.planning', 'PROJECT.md'), '# Project\n');
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W029').check(snapshot), []);
|
|
});
|
|
|
|
test('B2c: neither tracked nor ignored (fresh project) is silent', (t) => {
|
|
const cwd = createTempDir('gsd-3586-w029-b2c-');
|
|
t.after(() => cleanup(cwd));
|
|
gitOrThrow(['init'], { cwd });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd });
|
|
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd });
|
|
gitOrThrow(['commit', '--allow-empty', '-m', 'initial'], { cwd });
|
|
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, '.planning', 'PROJECT.md'), '# Project\n');
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.deepEqual(ruleFor('W029').check(snapshot), []);
|
|
});
|
|
|
|
test('B3: a degraded (UNREADABLE) probe produces zero findings — never a finding from a failed probe', (t) => {
|
|
const cwd = createTempDir('gsd-3586-w029-b3-');
|
|
t.after(() => cleanup(cwd));
|
|
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
|
|
// No `git init` at all — the probe degrades to UNREADABLE/not_a_git_repo.
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.notEqual(snapshot.planningTracked.scope, 'complete');
|
|
assert.deepEqual(ruleFor('W029').check(snapshot), []);
|
|
});
|
|
|
|
test('B4: field absent from the snapshot entirely is silent, no throw (forward/backward compat)', () => {
|
|
const fakeSnapshot = {}; // deliberately missing `planningTracked`
|
|
assert.doesNotThrow(() => {
|
|
assert.deepEqual(ruleFor('W029').check(fakeSnapshot), []);
|
|
});
|
|
});
|
|
|
|
test('B5: the RULES entry marks W029 non-repairable — ADVISE never auto-untracks', () => {
|
|
assert.equal(ruleFor('W029').repairable, false);
|
|
});
|
|
|
|
// ─── ENOBUFS overflow (#3586 review F2) — the field reads COMPLETE/tracked
|
|
// via the overflow-as-proof-of-tracking path, so the rule must still fire.
|
|
// Discriminates by argv (mirrors tests/planning-snapshot.test.cjs's own
|
|
// `mockGitSpawnEnobufs`) so `isGitIgnored`'s `check-ignore` call still runs
|
|
// against the real fixture `.gitignore`.
|
|
|
|
test('B1-enobufs: an ENOBUFS-overflowed ls-files still fires W029 (tracked is proof, not a failure)', (t) => {
|
|
const cwd = createTempGitProject('gsd-3586-w029-b1-enobufs-');
|
|
t.after(() => cleanup(cwd));
|
|
writeGitignore(cwd, '.planning/\n');
|
|
commitAll(cwd, 'add gitignore');
|
|
|
|
const originalSpawnSync = childProcess.spawnSync;
|
|
t.mock.method(childProcess, 'spawnSync', (cmd, args, opts) => {
|
|
if (Array.isArray(args) && args.includes('ls-files')) {
|
|
return {
|
|
status: null,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: null,
|
|
error: Object.assign(new Error('spawnSync git ENOBUFS'), { code: 'ENOBUFS' }),
|
|
};
|
|
}
|
|
return originalSpawnSync.call(childProcess, cmd, args, opts);
|
|
});
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
assert.strictEqual(snapshot.planningTracked.value.tracked, true);
|
|
assert.strictEqual(snapshot.planningTracked.scope, SCOPE.COMPLETE);
|
|
|
|
const diagnostics = ruleFor('W029').check(snapshot);
|
|
assert.equal(diagnostics.length, 1);
|
|
assert.equal(diagnostics[0].code, 'W029');
|
|
});
|
|
|
|
// ─── C3 — negative-proof: --repair must NOT untrack anything ─────────────
|
|
|
|
test('C3: applyRepairs never applies the W029 remedy, and .planning/ stays tracked afterward', (t) => {
|
|
const cwd = createTempGitProject('gsd-3586-w029-c3-');
|
|
t.after(() => cleanup(cwd));
|
|
writeGitignore(cwd, '.planning/\n');
|
|
commitAll(cwd, 'add gitignore');
|
|
|
|
const snapshot = buildPlanningSnapshot(cwd);
|
|
const diagnostics = ruleFor('W029').check(snapshot);
|
|
assert.equal(diagnostics.length, 1);
|
|
|
|
const result = applyRepairs(cwd, diagnostics, /* repair */ true, /* backfill */ false);
|
|
assert.deepEqual(result.applied, []);
|
|
assert.deepEqual(result.refused, []);
|
|
assert.deepEqual(result.details, []);
|
|
|
|
// The tracked file must still be in the index — nothing was untracked.
|
|
const trackedAfter = gitOrThrow(['ls-files', '--', '.planning'], { cwd }).trim();
|
|
assert.ok(trackedAfter.length > 0, '.planning/ must still be tracked after a --repair run');
|
|
});
|
|
});
|