diff --git a/gsd-core/workflows/health.md b/gsd-core/workflows/health.md index 55a9b2cda..84e183518 100644 --- a/gsd-core/workflows/health.md +++ b/gsd-core/workflows/health.md @@ -216,14 +216,14 @@ Report final status. - | Code | Severity | Description | Repairable | |------|----------|-------------|------------| | E001 | error | .planning/ directory not found | No | | E002 | error | PROJECT.md not found | No | | E003 | error | ROADMAP.md not found | No | -| E004 | error | STATE.md not found | Yes | -| E005 | error | config.json parse error | Yes | +| E004 | error | STATE.md not found | No | +| E005 | error | config.json parse error | No | +| E010 | error | CWD resolves to the user's home directory — health check would target the wrong .planning/ | No | | W001 | warning | PROJECT.md missing required section | No | | W002 | warning | STATE.md references invalid phase | No | | W003 | warning | config.json not found | Yes | @@ -233,24 +233,38 @@ Report final status. | W007 | warning | Phase on disk but not in ROADMAP | No | | W008 | warning | config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip) | Yes | | W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No | +| W010 | warning | GSD agent installation missing or incomplete | No | +| W011 | warning | STATE.md current-phase status disagrees with ROADMAP.md checkbox | No | +| W012 | warning | config.json invalid branching_strategy value | No | +| W013 | warning | config.json context_window not a positive integer | No | +| W014 | warning | config.json phase_branch_template missing {phase} placeholder | No | +| W015 | warning | config.json milestone_branch_template missing {milestone} placeholder | No | +| W016 | warning | config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning) | Yes | +| W017 | warning | Orphan git worktree (path no longer exists on disk) | No | | W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) | | W019 | warning | Unrecognized .planning/ root file — not a canonical GSD artifact | No | +| W020 | warning | Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified | No | +| W021 | warning | Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed) | No | +| W022 | warning | config.json models entry malformed (unknown phase type, invalid tier, or non-object value) | No | +| W023 | warning | Phase directories collide on normalized key | No | | W024 | warning | STATE.md was written many commits ago — treat its contents as approximate | No | -| W025 | warning | config.json: workflow.use_worktrees enabled on a runtime whose dispatch.isolation is none (#2486) | No | +| W026 | warning | STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone | No | +| W027 | warning | Stale git worktree (not modified in a long time) | No | | I001 | info | Plan without SUMMARY (may be in progress) | No | +| I010 | info | Resolved CWD reported alongside the E010 home-directory guard | No | -Note: the `W0NN` warning-code namespace is owned by `src/verify.cts` (`validate.health`), which also emits codes this table does not list (`W010`–`W017` and `W020`–`W023` as of #2486). `W001`–`W024` are all allocated, so this workflow's isolation warning is `W025`. Before assigning a new code here, grep `src/verify.cts` for the next free number — the table alone under-represents the live namespace, and two PRs in flight can otherwise claim the same code (which is exactly what happened between #2486 and #2573). +Note: this table is **generated** — do not hand-edit it. It is produced by `node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`'s `RULES` table (31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never `.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, never set per emit call) — before assigning a new code, add a `Rule` entry under `src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; `npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file's own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in this generated table. - | Action | Effect | Risk | |--------|--------|------| | createConfig | Create config.json with defaults | None | | resetConfig | Delete + recreate config.json | Loses custom settings | | regenerateState | Create STATE.md from ROADMAP structure when it is missing | Loses session history | | addNyquistKey | Add workflow.nyquist_validation: true to config.json | None — matches existing default | +| addAiIntegrationPhaseKey | Add workflow.ai_integration_phase: true to config.json | None — matches existing default | | backfillMilestones | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | None — additive only; triggered by `--backfill` flag | **Not repairable (too risky):** diff --git a/package.json b/package.json index 2ca21c99a..adfff3145 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "lint:test-file-count": "node scripts/lint-test-file-count.cjs", "lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs", "lint:changeset": "node scripts/changeset/lint.cjs", - "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check", + "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check", "lint:docs": "node scripts/lint-docs-required.cjs", "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", diff --git a/scripts/gen-health-docs.cjs b/scripts/gen-health-docs.cjs new file mode 100644 index 000000000..53e1b7bc3 --- /dev/null +++ b/scripts/gen-health-docs.cjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Generates the `` and `` tables in + * `gsd-core/workflows/health.md` from `src/health-diagnostic.cts`'s `RULES` + * table (Phase 11 follow-up, #3309 "Proposed behavior": "health.md's tables + * are generated rather than hand-maintained, closing the 16-vs-30+ + * documentation gap structurally"). + * + * Sources: + * - The 31 real rules in the compiled `RULES` array + * (`gsd-core/bin/lib/health-diagnostic.cjs`, built from + * `src/health-diagnostic.cts` + `src/health-diagnostic-rules/*.cts`), + * each carrying a static `description`/`repairable` (see + * `src/health-diagnostic-types.cts`'s `Rule` interface). + * - `PRECHECK_CODES` below — E001, E010, I010 — the three diagnostics + * `cmdValidateHealth` (`src/verify.cts`) emits as pre-checks OUTSIDE the + * rule table entirely (ADR-3180 §8.2 rule 4, "no precedence system" — + * see `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`, + * "Two guards that stay OUTSIDE the rule table entirely"). These will + * never appear in `RULES`, so they are a small, static, clearly-labeled + * list merged in here instead. + * - `REMEDY_ACTION_METADATA` below — the Effect/Risk prose for each of the + * 6 real repair actions (`REMEDY_ACTION`, excluding `ADVISE`, which never + * acts). Static because the compiled module carries no Effect/Risk text + * of its own — only the action identifier. + * + * Deliberately EXCLUDED from the generated `` table: `W025` + * (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486). It is a + * workflow-layer diagnostic emitted directly by this same file's own + * `run_health_check` step (a bash block in `health.md` itself), never by + * `cmdValidateHealth`/`RULES` — it has no `Rule` entry and is not one of the + * three pre-checks above. It stays fully documented in prose at its own step + * (``), which is the authoritative, more + * detailed source `` used to merely summarize; dropping the + * redundant table row is not a loss of information, and folding it back in + * here would require this generator to parse bash, which it does not do. + * Same reasoning for `I002` (stale Windows task-directory cleanup, + * `` step) — it was never part of the `` + * tagged region even before this generator existed. + * + * Usage: + * node scripts/gen-health-docs.cjs # print both tables to stdout + * node scripts/gen-health-docs.cjs --write # rewrite the tagged regions in health.md + * node scripts/gen-health-docs.cjs --check # exit 1 if either region is stale + * node scripts/gen-health-docs.cjs --write --target # test-only: target a fixture file + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const HEALTH_MD_REL = 'gsd-core/workflows/health.md'; +const HEALTH_MD_PATH = path.join(ROOT, HEALTH_MD_REL); +const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs'; +const COMPILED_MODULE_PATH = path.join(ROOT, COMPILED_MODULE_REL); + +const ERROR_CODES_START = ''; +const ERROR_CODES_END = ''; +const REPAIR_ACTIONS_START = ''; +const REPAIR_ACTIONS_END = ''; + +/** + * The 3 pre-check diagnostics `cmdValidateHealth` emits OUTSIDE the rule + * table (see module header). All three are non-repairable safety rails, not + * `.planning/` findings a remedy could act on. + */ +const PRECHECK_CODES = [ + { + code: 'E001', + severity: 'error', + description: '.planning/ directory not found', + repairable: false, + }, + { + code: 'E010', + severity: 'error', + description: "CWD resolves to the user's home directory — health check would target the wrong .planning/", + repairable: false, + }, + { + code: 'I010', + severity: 'info', + description: 'Resolved CWD reported alongside the E010 home-directory guard', + repairable: false, + }, +]; + +/** + * Effect/Risk prose per real `REMEDY_ACTION` (everything except `ADVISE`, + * which never acts and has no row in ``). Text for the 5 + * actions the hand-written table already documented is reused VERBATIM; + * `addAiIntegrationPhaseKey` is new — #3309 itself notes it was "live in + * code, missing from docs" (mirrors `addNyquistKey`, its structural sibling: + * same shape, one config key each). + */ +const REMEDY_ACTION_METADATA = new Map([ + ['createConfig', { effect: 'Create config.json with defaults', risk: 'None' }], + ['resetConfig', { effect: 'Delete + recreate config.json', risk: 'Loses custom settings' }], + [ + 'regenerateState', + { + effect: 'Create STATE.md from ROADMAP structure when it is missing', + risk: 'Loses session history', + }, + ], + [ + 'addNyquistKey', + { effect: 'Add workflow.nyquist_validation: true to config.json', risk: 'None — matches existing default' }, + ], + [ + 'addAiIntegrationPhaseKey', + { effect: 'Add workflow.ai_integration_phase: true to config.json', risk: 'None — matches existing default' }, + ], + [ + 'backfillMilestones', + { + effect: 'Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots', + risk: 'None — additive only; triggered by `--backfill` flag', + }, + ], +]); + +/** Order the Effect/Risk table renders in — matches `REMEDY_ACTION`'s own declaration order. */ +const REMEDY_ACTION_ORDER = [ + 'createConfig', + 'resetConfig', + 'regenerateState', + 'addNyquistKey', + 'addAiIntegrationPhaseKey', + 'backfillMilestones', +]; + +/** + * Per-code override for the "Repairable" cell's display text, for codes + * whose remedy is conditional on a flag the plain `Yes`/`No` can't express + * (mirrors the hand-written table's pre-existing `W018` row: `Yes (--backfill)`). + */ +const REPAIRABLE_DISPLAY_OVERRIDE = new Map([['W018', 'Yes (`--backfill`)']]); + +const STATIC_NOT_REPAIRABLE_BULLETS = [ + 'PROJECT.md, ROADMAP.md content', + 'Phase directory renaming', + 'Orphaned plan cleanup', +]; + +const FOOTNOTE_PARAGRAPH = + 'Note: this table is **generated** — do not hand-edit it. It is produced by ' + + '`node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`\'s `RULES` table ' + + '(31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — ' + + 'see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by ' + + 'design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never ' + + '`.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 ' + + 'code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, ' + + 'never set per emit call) — before assigning a new code, add a `Rule` entry under ' + + '`src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not ' + + 'yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; ' + + '`npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/' + + '`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file\'s ' + + 'own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in ' + + 'this generated table.'; + +/** + * Load the compiled health-diagnostic module. Throws a clear ExitError (not + * a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run — mirrors + * `scripts/lint-health-diagnostic-rule-table.cjs`'s `loadCompiledModule`. + */ +function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) { + if (!fs.existsSync(compiledPath)) { + throw new ExitError( + 2, + `gen-health-docs: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` + + 'Run `npm run build:lib` first.', + ); + } + return require(compiledPath); +} + +/** + * Sort order for the `` table: E-codes, then W-codes + * numerically, then I-codes — matching the hand-written table's pre-existing + * order. NOT insertion order from `RULES` (which is grouped by + * subject-area file, not sorted by code). + */ +const PREFIX_RANK = { E: 0, W: 1, I: 2 }; + +function parseCode(code) { + const m = code.match(/^([A-Z]+)(\d+)$/); + if (!m) throw new Error(`gen-health-docs: unparseable diagnostic code "${code}"`); + return { prefix: m[1], number: Number(m[2]) }; +} + +function compareCodes(a, b) { + const pa = parseCode(a.code); + const pb = parseCode(b.code); + const rankA = PREFIX_RANK[pa.prefix] ?? 99; + const rankB = PREFIX_RANK[pb.prefix] ?? 99; + if (rankA !== rankB) return rankA - rankB; + return pa.number - pb.number; +} + +/** + * Combine the 31 real rules + the 3 static pre-checks into one sorted row + * list for the `` table. + * + * @param {Array<{code: string, severity: string, description: string, repairable: boolean}>} rules + */ +function buildErrorCodeRows(rules) { + const seen = new Set(); + const rows = []; + for (const entry of [...rules, ...PRECHECK_CODES]) { + if (seen.has(entry.code)) { + throw new Error(`gen-health-docs: duplicate diagnostic code "${entry.code}" across RULES + PRECHECK_CODES`); + } + seen.add(entry.code); + rows.push(entry); + } + rows.sort(compareCodes); + return rows; +} + +/** Escape a cell's markdown-table-hostile characters (mirrors gen-adr-index.cjs's `cellText`). */ +function cellText(text) { + return String(text) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(//g, '>') + .replace(/\r?\n/g, ' ') + .trim(); +} + +function repairableCell(row) { + if (REPAIRABLE_DISPLAY_OVERRIDE.has(row.code)) return REPAIRABLE_DISPLAY_OVERRIDE.get(row.code); + return row.repairable ? 'Yes' : 'No'; +} + +function renderErrorCodesRegion(rules) { + const rows = buildErrorCodeRows(rules); + const lines = ['', '| Code | Severity | Description | Repairable |', '|------|----------|-------------|------------|']; + for (const row of rows) { + lines.push(`| ${row.code} | ${row.severity} | ${cellText(row.description)} | ${repairableCell(row)} |`); + } + lines.push('', FOOTNOTE_PARAGRAPH, ''); + return lines.join('\n'); +} + +function renderRepairActionsRegion() { + const lines = ['', '| Action | Effect | Risk |', '|--------|--------|------|']; + for (const action of REMEDY_ACTION_ORDER) { + const meta = REMEDY_ACTION_METADATA.get(action); + if (!meta) { + throw new Error( + `gen-health-docs: no Effect/Risk metadata registered for repair action "${action}" — add an entry to REMEDY_ACTION_METADATA.`, + ); + } + lines.push(`| ${action} | ${meta.effect} | ${meta.risk} |`); + } + lines.push('', '**Not repairable (too risky):**'); + for (const bullet of STATIC_NOT_REPAIRABLE_BULLETS) lines.push(`- ${bullet}`); + lines.push(''); + return lines.join('\n'); +} + +/** + * Splice `newInner` between `${startTag}`/`${endTag}` inside `text`. Throws + * if either tag is missing, or if the tags appear more than once (this + * generator only ever targets the FIRST occurrence pair, and a duplicate + * tag anywhere in the file would silently corrupt the splice). + */ +function spliceRegion(text, startTag, endTag, newInner) { + const startIdx = text.indexOf(startTag); + const endIdx = text.indexOf(endTag); + if (startIdx === -1 || endIdx === -1) { + throw new ExitError( + 1, + `gen-health-docs: ${HEALTH_MD_REL} is missing the ${startTag}/${endTag} tags.`, + ); + } + if (text.indexOf(startTag, startIdx + 1) !== -1 || text.indexOf(endTag, endIdx + 1) !== -1) { + throw new ExitError(1, `gen-health-docs: ${HEALTH_MD_REL} has more than one ${startTag}/${endTag} pair.`); + } + const before = text.slice(0, startIdx + startTag.length); + const after = text.slice(endIdx); + return `${before}${newInner}\n${after}`; +} + +/** + * Regenerate `health.md`'s full text from `rules` (the compiled `RULES` + * array) and the current on-disk `health.md` content. + */ +function regenerateHealthMd(rules, currentText) { + let out = spliceRegion(currentText, ERROR_CODES_START, ERROR_CODES_END, renderErrorCodesRegion(rules)); + out = spliceRegion(out, REPAIR_ACTIONS_START, REPAIR_ACTIONS_END, renderRepairActionsRegion()); + return out; +} + +/** + * @param {string[]} argv + * @returns {{write: boolean, check: boolean, targetPath: string|null}} + */ +function parseArgs(argv) { + const opts = { write: false, check: false, targetPath: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--write') opts.write = true; + else if (arg === '--check') opts.check = true; + else if (arg === '--target') { + const value = argv[i + 1]; + if (value === undefined) throw new ExitError(1, '--target requires a path argument.'); + opts.targetPath = value; + i++; + } else { + throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --target .`); + } + } + return opts; +} + +function main() { + const { write, check, targetPath } = parseArgs(process.argv.slice(2)); + const { RULES } = loadCompiledModule(); + + // `--target` overrides the real committed health.md path, exclusively for + // test isolation (mirrors gen-section-manifest.cjs's `--manifest-path` + // override) — no production caller ever passes it. + const resolvedPath = targetPath ? path.resolve(targetPath) : HEALTH_MD_PATH; + const displayPath = targetPath ? targetPath : HEALTH_MD_REL; + + const currentText = fs.existsSync(resolvedPath) ? fs.readFileSync(resolvedPath, 'utf8') : null; + if (currentText === null) { + throw new ExitError(1, `gen-health-docs: ${displayPath} not found.`); + } + + const expected = regenerateHealthMd(RULES, currentText); + + if (write) { + fs.writeFileSync(resolvedPath, expected, 'utf8'); + process.stdout.write( + `Wrote ${displayPath} — ${RULES.length + PRECHECK_CODES.length} error/warning/info code(s), ` + + `${REMEDY_ACTION_ORDER.length} repair action(s).\n`, + ); + return 0; + } + + if (check) { + if (expected !== currentText) { + process.stderr.write( + `${displayPath} is stale — its / tables do not match ` + + "src/health-diagnostic.cts's RULES table.\nRun:\n node scripts/gen-health-docs.cjs --write\n\n", + ); + throw new ExitError(1); + } + process.stdout.write( + `${displayPath} is up to date (${RULES.length + PRECHECK_CODES.length} codes, ${REMEDY_ACTION_ORDER.length} repair actions).\n`, + ); + return 0; + } + + process.stdout.write(renderErrorCodesRegion(RULES) + '\n\n' + renderRepairActionsRegion() + '\n'); + return 0; +} + +// Guarded: requiring this module (the test suite imports the pure render +// functions directly) must not also run the generator as a side effect. +if (require.main === module) runMain(main); + +module.exports = { + loadCompiledModule, + buildErrorCodeRows, + renderErrorCodesRegion, + renderRepairActionsRegion, + regenerateHealthMd, + spliceRegion, + compareCodes, + parseCode, + PRECHECK_CODES, + REMEDY_ACTION_METADATA, + REMEDY_ACTION_ORDER, + REPAIRABLE_DISPLAY_OVERRIDE, + HEALTH_MD_PATH, + COMPILED_MODULE_PATH, + ERROR_CODES_START, + ERROR_CODES_END, + REPAIR_ACTIONS_START, + REPAIR_ACTIONS_END, +}; diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts index 49392944f..ae7cdfad7 100644 --- a/src/health-diagnostic-rules/agent-install.cts +++ b/src/health-diagnostic-rules/agent-install.cts @@ -107,6 +107,8 @@ const RULES: Rule[] = [ { code: 'W010', severity: SEVERITY.WARNING, + description: 'GSD agent installation missing or incomplete', + repairable: false, check: checkAgentInstall, }, ]; diff --git a/src/health-diagnostic-rules/config-validation.cts b/src/health-diagnostic-rules/config-validation.cts index c997767d5..867b28440 100644 --- a/src/health-diagnostic-rules/config-validation.cts +++ b/src/health-diagnostic-rules/config-validation.cts @@ -276,16 +276,59 @@ function checkW022(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W003', severity: SEVERITY.WARNING, check: checkW003 }, - { code: 'E005', severity: SEVERITY.ERROR, check: checkE005 }, - { code: 'W004', severity: SEVERITY.WARNING, check: checkW004 }, - { code: 'W008', severity: SEVERITY.WARNING, check: checkW008 }, - { code: 'W016', severity: SEVERITY.WARNING, check: checkW016 }, - { code: 'W012', severity: SEVERITY.WARNING, check: checkW012 }, - { code: 'W013', severity: SEVERITY.WARNING, check: checkW013 }, - { code: 'W014', severity: SEVERITY.WARNING, check: checkW014 }, - { code: 'W015', severity: SEVERITY.WARNING, check: checkW015 }, - { code: 'W022', severity: SEVERITY.WARNING, check: checkW022 }, + { code: 'W003', severity: SEVERITY.WARNING, description: 'config.json not found', repairable: true, check: checkW003 }, + { code: 'E005', severity: SEVERITY.ERROR, description: 'config.json parse error', repairable: false, check: checkE005 }, + { code: 'W004', severity: SEVERITY.WARNING, description: 'config.json invalid field value', repairable: false, check: checkW004 }, + { + code: 'W008', + severity: SEVERITY.WARNING, + description: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', + repairable: true, + check: checkW008, + }, + { + code: 'W016', + severity: SEVERITY.WARNING, + description: + 'config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning)', + repairable: true, + check: checkW016, + }, + { + code: 'W012', + severity: SEVERITY.WARNING, + description: 'config.json invalid branching_strategy value', + repairable: false, + check: checkW012, + }, + { + code: 'W013', + severity: SEVERITY.WARNING, + description: 'config.json context_window not a positive integer', + repairable: false, + check: checkW013, + }, + { + code: 'W014', + severity: SEVERITY.WARNING, + description: 'config.json phase_branch_template missing {phase} placeholder', + repairable: false, + check: checkW014, + }, + { + code: 'W015', + severity: SEVERITY.WARNING, + description: 'config.json milestone_branch_template missing {milestone} placeholder', + repairable: false, + check: checkW015, + }, + { + code: 'W022', + severity: SEVERITY.WARNING, + description: 'config.json models entry malformed (unknown phase type, invalid tier, or non-object value)', + repairable: false, + check: checkW022, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/milestone-archive-hygiene.cts b/src/health-diagnostic-rules/milestone-archive-hygiene.cts index f7751ac0f..99a02c305 100644 --- a/src/health-diagnostic-rules/milestone-archive-hygiene.cts +++ b/src/health-diagnostic-rules/milestone-archive-hygiene.cts @@ -96,8 +96,20 @@ function checkW019(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W018', severity: SEVERITY.WARNING, check: checkW018 }, - { code: 'W019', severity: SEVERITY.WARNING, check: checkW019 }, + { + code: 'W018', + severity: SEVERITY.WARNING, + description: 'MILESTONES.md missing entry for archived milestone snapshot', + repairable: true, + check: checkW018, + }, + { + code: 'W019', + severity: SEVERITY.WARNING, + description: 'Unrecognized .planning/ root file — not a canonical GSD artifact', + repairable: false, + check: checkW019, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts index 504f0413b..7b78f1ce4 100644 --- a/src/health-diagnostic-rules/phase-structure.cts +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -180,10 +180,34 @@ function checkW009(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W005', severity: SEVERITY.WARNING, check: checkW005 }, - { code: 'W023', severity: SEVERITY.WARNING, check: checkW023 }, - { code: 'I001', severity: SEVERITY.INFO, check: checkI001 }, - { code: 'W009', severity: SEVERITY.WARNING, check: checkW009 }, + { + code: 'W005', + severity: SEVERITY.WARNING, + description: 'Phase directory naming mismatch', + repairable: false, + check: checkW005, + }, + { + code: 'W023', + severity: SEVERITY.WARNING, + description: 'Phase directories collide on normalized key', + repairable: false, + check: checkW023, + }, + { + code: 'I001', + severity: SEVERITY.INFO, + description: 'Plan without SUMMARY (may be in progress)', + repairable: false, + check: checkI001, + }, + { + code: 'W009', + severity: SEVERITY.WARNING, + description: 'Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md', + repairable: false, + check: checkW009, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts index 7265fdeb8..83ec18898 100644 --- a/src/health-diagnostic-rules/roadmap-disk-consistency.cts +++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts @@ -210,8 +210,20 @@ function checkW007(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W006', severity: SEVERITY.WARNING, check: checkW006 }, - { code: 'W007', severity: SEVERITY.WARNING, check: checkW007 }, + { + code: 'W006', + severity: SEVERITY.WARNING, + description: 'Phase in ROADMAP but no directory', + repairable: false, + check: checkW006, + }, + { + code: 'W007', + severity: SEVERITY.WARNING, + description: 'Phase on disk but not in ROADMAP', + repairable: false, + check: checkW007, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/root-existence.cts b/src/health-diagnostic-rules/root-existence.cts index 8208116b1..0e34689d9 100644 --- a/src/health-diagnostic-rules/root-existence.cts +++ b/src/health-diagnostic-rules/root-existence.cts @@ -163,10 +163,16 @@ function checkW001(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'E002', severity: SEVERITY.ERROR, check: checkE002 }, - { code: 'E003', severity: SEVERITY.ERROR, check: checkE003 }, - { code: 'E004', severity: SEVERITY.ERROR, check: checkE004 }, - { code: 'W001', severity: SEVERITY.WARNING, check: checkW001 }, + { code: 'E002', severity: SEVERITY.ERROR, description: 'PROJECT.md not found', repairable: false, check: checkE002 }, + { code: 'E003', severity: SEVERITY.ERROR, description: 'ROADMAP.md not found', repairable: false, check: checkE003 }, + { code: 'E004', severity: SEVERITY.ERROR, description: 'STATE.md not found', repairable: false, check: checkE004 }, + { + code: 'W001', + severity: SEVERITY.WARNING, + description: 'PROJECT.md missing required section', + repairable: false, + check: checkW001, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts index e908b992d..b0a24e85c 100644 --- a/src/health-diagnostic-rules/state-consistency.cts +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -86,6 +86,8 @@ const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhas const RULE_W024: Rule = { code: 'W024', severity: SEVERITY.WARNING, + description: 'STATE.md was written many commits ago — treat its contents as approximate', + repairable: false, check: (_snapshot: PlanningSnapshot): Diagnostic[] => [], }; @@ -139,6 +141,8 @@ function normalizePhaseTokenSet(valid: Set): Set { const RULE_W002: Rule = { code: 'W002', severity: SEVERITY.WARNING, + description: 'STATE.md references invalid phase', + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const validPhases = buildValidPhaseSet(snapshot); // Mirrors `verify.cts:1765`'s `if (normalizedValid.size > 0)` guard @@ -193,6 +197,8 @@ function currentPhaseIdFromLabel(label: string | null): string | null { const RULE_W011: Rule = { code: 'W011', severity: SEVERITY.WARNING, + description: 'STATE.md current-phase status disagrees with ROADMAP.md checkbox', + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const phaseId = currentPhaseIdFromLabel(snapshot.currentPhaseLabel.value); if (phaseId === null) return []; @@ -216,6 +222,9 @@ const RULE_W011: Rule = { const RULE_W021: Rule = { code: 'W021', severity: SEVERITY.WARNING, + description: + "Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed)", + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const convention = snapshot.config.value?.['phase_id_convention']; if (convention !== 'milestone-prefixed') return []; @@ -246,6 +255,8 @@ const RULE_W021: Rule = { const RULE_W026: Rule = { code: 'W026', severity: SEVERITY.WARNING, + description: 'STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone', + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase(); if (!/milestone complete|archived/.test(statusVal)) return []; diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts index 4ca349b5a..b5613f359 100644 --- a/src/health-diagnostic-rules/worktree-health.cts +++ b/src/health-diagnostic-rules/worktree-health.cts @@ -154,9 +154,27 @@ function checkW027(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W020', severity: SEVERITY.WARNING, check: checkW020 }, - { code: 'W017', severity: SEVERITY.WARNING, check: checkW017 }, - { code: 'W027', severity: SEVERITY.WARNING, check: checkW027 }, + { + code: 'W020', + severity: SEVERITY.WARNING, + description: 'Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified', + repairable: false, + check: checkW020, + }, + { + code: 'W017', + severity: SEVERITY.WARNING, + description: 'Orphan git worktree (path no longer exists on disk)', + repairable: false, + check: checkW017, + }, + { + code: 'W027', + severity: SEVERITY.WARNING, + description: 'Stale git worktree (not modified in a long time)', + repairable: false, + check: checkW027, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-types.cts b/src/health-diagnostic-types.cts index e3de6eb7b..c6a397364 100644 --- a/src/health-diagnostic-types.cts +++ b/src/health-diagnostic-types.cts @@ -77,6 +77,47 @@ interface Diagnostic { interface Rule { code: string; severity: Severity; + /** + * Short, static, human-readable summary of what this rule checks — the + * source of `gsd-core/workflows/health.md`'s generated `` + * table (`scripts/gen-health-docs.cjs`). Deliberately distinct from a + * fired `Diagnostic`'s `message`, which is dynamic/per-instance (e.g. + * W001's message names the specific PROJECT.md section that is missing); + * `description` is exactly one fixed sentence per code, matching the + * hand-written table's pre-existing style for the codes it already + * documented (E001-E005, W001-W009, W018, W019, W024, I001). + */ + description: string; + /** + * Whether `--repair` will actually apply this rule's remedy (`true`) or + * never will (`false`) — the source of the generated table's "Repairable" + * column. This MUST match `diagnosticToIssueEntry`'s (`src/verify.cts`) + * per-diagnostic semantics: `remedy.action !== ADVISE && remedy.risk !== + * REMEDY_RISK.DESTRUCTIVE`. `false` covers TWO distinct cases, and both + * must map to `false` here: + * + * 1. ADVISE-only rules — no real `REMEDY_ACTION` exists to apply. + * 2. DESTRUCTIVE-risk rules (`regenerateState`, `resetConfig`) — a real + * action exists and is described, but `applyRepairs`'s dispatcher + * (`src/health-diagnostic.cts`) refuses to auto-apply any + * DESTRUCTIVE-risk remedy (§8.3 rule 3), so `--repair` never applies it + * either. "A remedy exists to describe" is NOT sufficient for `true` — + * only "an unattended `--repair` run will actually apply it" is. + * + * STATIC field, not derived by executing `check` against a fixture at + * doc-gen time: confirmed by direct read of all 8 + * `src/health-diagnostic-rules/*.cts` files that every rule in this + * codebase uses exactly ONE `remedy.action` (and therefore one + * `remedy.risk`) across every `Diagnostic` it can ever emit — no rule mixes + * ADVISE with a real action, or NONE-risk with DESTRUCTIVE-risk, depending + * on the triggering condition (the design doc's "primary remedy" ambiguity + * this field's doc comment was asked to consider does not arise in + * practice). A single static boolean is therefore a faithful, + * execution-free summary, and cheaper/simpler than adding a second + * `primaryRemedyAction` field or having the generator import and execute + * every rule against a synthetic snapshot. + */ + repairable: boolean; check: (snapshot: PlanningSnapshot) => Diagnostic[]; // §8.1 rule 1 signature, verbatim } diff --git a/tests/gen-health-docs.test.cjs b/tests/gen-health-docs.test.cjs new file mode 100644 index 000000000..9653e7d07 --- /dev/null +++ b/tests/gen-health-docs.test.cjs @@ -0,0 +1,258 @@ +'use strict'; + +/** + * gen-health-docs.cjs regression tests (#3309, "health.md's tables are + * generated rather than hand-maintained, closing the 16-vs-30+ documentation + * gap structurally"). + * + * Every CLI-level test spawns the real generator (execFileSync) against a + * temp copy of the shipped `gsd-core/workflows/health.md`, using the + * generator's `--target ` override — never mutates the real committed + * file. No fs monkeypatching is needed for these cases. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { + buildErrorCodeRows, + renderErrorCodesRegion, + renderRepairActionsRegion, + regenerateHealthMd, + spliceRegion, + compareCodes, + PRECHECK_CODES, + REMEDY_ACTION_ORDER, + ERROR_CODES_START, + ERROR_CODES_END, +} = require('../scripts/gen-health-docs.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'gen-health-docs.cjs'); +const SHIPPED_HEALTH_MD = path.join(ROOT, 'gsd-core', 'workflows', 'health.md'); +const COMPILED_MODULE_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'health-diagnostic.cjs'); + +function loadRealRules() { + // Real compiled RULES — build:lib is a pretest dependency for the whole + // suite (package.json `pretest`), so this is always present by the time + // node:test runs these files. + return require(COMPILED_MODULE_PATH).RULES; +} + +/** + * @param {string[]} args + * @param {string} cwd + * @returns {{code: number, stdout: string, stderr: string}} + */ +function runGenHealthDocs(args, cwd = ROOT) { + try { + const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + return { + code: err.status ?? 1, + stdout: err.stdout ? err.stdout.toString() : '', + stderr: err.stderr ? err.stderr.toString() : '', + }; + } +} + +function copyShippedHealthMd(destDir) { + const dest = path.join(destDir, 'health.md'); + fs.copyFileSync(SHIPPED_HEALTH_MD, dest); + return dest; +} + +// ─── CLI: --check / --write round trip ───────────────────────────────────── + +describe('gen-health-docs.cjs --check / --write (CLI, --target fixture)', () => { + test('--check passes on a freshly-written file', (t) => { + const tmpRoot = createTempDir('gen-health-docs-'); + t.after(() => cleanup(tmpRoot)); + + const target = copyShippedHealthMd(tmpRoot); + + const w = runGenHealthDocs(['--write', '--target', target]); + assert.equal(w.code, 0, `stderr: ${w.stderr}`); + + const c = runGenHealthDocs(['--check', '--target', target]); + assert.equal(c.code, 0, `--check must be clean immediately after --write; stderr: ${c.stderr}`); + assert.match(c.stdout, /up to date/); + }); + + test('--check fails when the tagged region is stale (mutate a temp copy)', (t) => { + const tmpRoot = createTempDir('gen-health-docs-'); + t.after(() => cleanup(tmpRoot)); + + const target = copyShippedHealthMd(tmpRoot); + + // Mutate the committed, already-up-to-date table so it drifts from what + // the generator would produce — a single row edit is enough. + let content = fs.readFileSync(target, 'utf8'); + assert.ok(content.includes('| E001 | error |'), 'sanity: shipped health.md must carry the E001 row'); + content = content.replace('| E001 | error |', '| E001 | error-STALE-MUTATION |'); + fs.writeFileSync(target, content, 'utf8'); + + const c = runGenHealthDocs(['--check', '--target', target]); + assert.equal(c.code, 1, 'a hand-mutated table must fail --check'); + assert.match(c.stderr, /is stale/); + assert.match(c.stderr, /gen-health-docs\.cjs --write/); + }); + + test('--write on a stale copy regenerates it back to a clean --check', (t) => { + const tmpRoot = createTempDir('gen-health-docs-'); + t.after(() => cleanup(tmpRoot)); + + const target = copyShippedHealthMd(tmpRoot); + let content = fs.readFileSync(target, 'utf8'); + content = content.replace('| W010 |', '| W010-DRIFTED |'); + fs.writeFileSync(target, content, 'utf8'); + + const failedCheck = runGenHealthDocs(['--check', '--target', target]); + assert.equal(failedCheck.code, 1, 'sanity: the mutated copy must fail --check first'); + + const w = runGenHealthDocs(['--write', '--target', target]); + assert.equal(w.code, 0, `stderr: ${w.stderr}`); + + const c = runGenHealthDocs(['--check', '--target', target]); + assert.equal(c.code, 0, `stderr: ${c.stderr}`); + }); + + test('plain invocation (no flag) prints both tables to stdout and exits 0', () => { + const r = runGenHealthDocs([]); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /\| Code \| Severity \| Description \| Repairable \|/); + assert.match(r.stdout, /\| Action \| Effect \| Risk \|/); + }); + + test('an unrecognized flag exits 1 rather than silently falling through', () => { + const r = runGenHealthDocs(['--bogus']); + assert.equal(r.code, 1); + assert.match(r.stderr, /unknown flag/); + }); + + test('the shipped gsd-core/workflows/health.md already passes --check against the real repo', () => { + const r = runGenHealthDocs(['--check']); + assert.equal(r.code, 0, `the committed health.md must already be up to date; stderr: ${r.stderr}`); + }); +}); + +// ─── Row content: representative codes, including previously-undocumented ─ + +describe('gen-health-docs.cjs row content (representative codes)', () => { + const rules = loadRealRules(); + + test('produces a 34-row table: 31 rules + 3 pre-checks (E001, E010, I010)', () => { + const rows = buildErrorCodeRows(rules); + assert.equal(rows.length, 34); + const codes = rows.map((r) => r.code); + for (const precheck of PRECHECK_CODES) { + assert.ok(codes.includes(precheck.code), `missing pre-check code ${precheck.code}`); + } + }); + + test('W010 (previously-undocumented, agent-install) renders with its Rule-sourced description and Repairable=No', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| W010 |')); + assert.ok(row, 'W010 row must be present'); + const w010Rule = rules.find((r) => r.code === 'W010'); + assert.ok(row.includes(w010Rule.description)); + assert.match(row, /\| No \|$/); + }); + + test('W026 (previously-undocumented, new post-migration split code) renders with its Rule-sourced description', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| W026 |')); + assert.ok(row, 'W026 row must be present'); + const w026Rule = rules.find((r) => r.code === 'W026'); + assert.ok(row.includes(w026Rule.description)); + }); + + test('E004 (already-documented, DESTRUCTIVE-risk remedy) renders with Repairable=No — --repair refuses to auto-apply regenerateState', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| E004 |')); + assert.ok(row); + assert.match(row, /\| No \|$/); + }); + + test('W018 renders the --backfill-qualified Repairable override, not a bare "Yes"', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| W018 |')); + assert.ok(row); + assert.match(row, /Yes \(`--backfill`\)/); + }); + + test('W025 (workflow-layer diagnostic, not a Rule) is absent from the generated table', () => { + const region = renderErrorCodesRegion(rules); + assert.ok( + !region.split('\n').some((line) => line.startsWith('| W025 |')), + 'W025 must not appear as a generated row — it is documented in its own workflow step, not the RULES table', + ); + }); + + test(' rows are sorted E-codes, then W-codes numerically, then I-codes', () => { + const rows = buildErrorCodeRows(rules); + const sorted = [...rows].sort(compareCodes); + assert.deepEqual(rows, sorted, 'buildErrorCodeRows must already return its rows in sorted order'); + // Spot-check the three-group boundary explicitly. + const codes = rows.map((r) => r.code); + const lastE = codes.lastIndexOf(codes.filter((c) => c.startsWith('E')).at(-1)); + const firstW = codes.findIndex((c) => c.startsWith('W')); + const lastW = codes.lastIndexOf(codes.filter((c) => c.startsWith('W')).at(-1)); + const firstI = codes.findIndex((c) => c.startsWith('I')); + assert.ok(lastE < firstW, 'every E-code must sort before every W-code'); + assert.ok(lastW < firstI, 'every W-code must sort before every I-code'); + }); + + test('renderRepairActionsRegion lists all 6 real repair actions, including the previously-undocumented addAiIntegrationPhaseKey', () => { + const region = renderRepairActionsRegion(); + for (const action of REMEDY_ACTION_ORDER) { + assert.ok(region.includes(`| ${action} |`), `missing repair action row: ${action}`); + } + assert.equal(REMEDY_ACTION_ORDER.length, 6); + assert.ok(region.includes('addAiIntegrationPhaseKey'), '#3309: this action was "live in code, missing from docs"'); + }); +}); + +// ─── spliceRegion / regenerateHealthMd — pure-function edge cases ───────── + +describe('gen-health-docs.cjs spliceRegion (pure function)', () => { + test('throws when a tag is missing', () => { + assert.throws( + () => spliceRegion('no tags here', ERROR_CODES_START, ERROR_CODES_END, 'x'), + /missing the .*tags/, + ); + }); + + test('throws when a tag appears more than once', () => { + const text = `${ERROR_CODES_START}a${ERROR_CODES_END}${ERROR_CODES_START}b${ERROR_CODES_END}`; + assert.throws(() => spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'x'), /more than one/); + }); + + test('preserves content strictly outside the tags, byte-for-byte', () => { + const before = 'PROSE BEFORE\n'; + const after = '\nPROSE AFTER'; + const text = `${before}${ERROR_CODES_START}old inner${ERROR_CODES_END}${after}`; + const out = spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'new inner'); + assert.ok(out.startsWith(before + ERROR_CODES_START)); + assert.ok(out.endsWith(ERROR_CODES_END + after)); + assert.ok(!out.includes('old inner')); + assert.ok(out.includes('new inner')); + }); + + test('regenerateHealthMd is idempotent: regenerating an already-generated document is a no-op', () => { + const rules = loadRealRules(); + const shipped = fs.readFileSync(SHIPPED_HEALTH_MD, 'utf8'); + const regenerated = regenerateHealthMd(rules, shipped); + assert.equal(regenerated, shipped); + }); +});