From a7ecbc1848563ddeb50b5d0dc05d9566245a47e1 Mon Sep 17 00:00:00 2001 From: avireddy0 Date: Mon, 22 Jun 2026 00:53:30 -0400 Subject: [PATCH] feat(hub-mode): re-derive hub-mode as a gsd-core capability (repo_type + hub v1 query + validator relaxations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-derives the deferred Envision-authored hub-mode against gsd-core's capability architecture (ADR-457 build-at-publish, ADR-959 command families). Hybrid: a capability descriptor + additive, repo_type-gated branches in core .cts. The standalone validator path is byte-behavior-identical (all hub branches isHub-gated). CREATE: - capabilities/hub-mode/capability.json — role:feature; capability-owned federated config keys repo_type {enum standalone|spoke|hub, default standalone} + workflow.hub_mode {boolean,false}; commands:[{family:"hub", module:"hub-command-router.cjs",router:"routeHubCommand"}] (auto-routed by dispatchCapabilityCommand — no gsd-tools.cjs edit). repo_type is NOT added to the central config-schema.manifest.json (validateCrossCapability owns-exactly-once). - src/hub-query.cts — typed, synchronous port of the deferred hub-query.cjs (5 read-only getters + deriveState/loadPhaseBundle/readStateMilestone/ sliceCurrentMilestoneSection). PORT FIX: extractCurrentMilestone imported from ./roadmap-parser.cjs (retired ./core.cjs spine). Stamps generator_version 1.0.0. - src/hub-command-router.cts — synchronous routeHubCommand; requires `hub v1`; dispatches milestone current / phase / phases / manifest / spoke. All mutation:false. - tests/{hub-query,repo-type-hub-mode}.test.cjs — ported (47 asserts, via runGsdTools). EDIT (additive, repo_type-gated): - src/verify.cts cmdValidateHealth — reads repo_type RAW via JSON.parse (default standalone, fail-open) → isHub. Hub branches: W005 skip _-dirs; W006 archive-aware; W007 scope to current-milestone {lo,hi}; W002 lookbehind/lookahead regex + archive prefix union; W019 .planning/.gsdrootallow allowlist + hub fix text. NEW invalid repo_type warning = W022 (W021 already used twice at :1719 and :1822). - .gitignore + eslint.config.mjs — register the two gitignored compiled artifacts. - gsd-core/bin/lib/capability-registry.cjs — regenerated (gen:capability-registry). RECONCILIATIONS: - repo-type T1/T6: the deferred spec asserted standalone STRICTLY flags 3-digit phase dirs, but live phaseDirNameRe accepts \d{2,} by upstream contract (tests/26-w005-... asserts 999.1-foo is valid in standalone). Restoring a strict 2-digit standalone regex regressed that upstream test, so standalone keeps the shared regex; hub's only W005 relaxation is the _-prefix skip. T1/T6 reconciled to the engine's real behavior (documented inline). - W021→W022: the invalid-repo_type warning uses W022; ported W021 asserts updated. - Assert count: 47 (21 repo-type + 26 hub-query), matching the README design intent. Verified: npm run build (exit 0), gen-capability-registry --check (in sync, repo_type federated + absent from central manifest), 47/47 hub asserts pass, upstream W005/W006/validator regression tests green, lint:ci clean on all hub files, verb + relaxation smokes green. Claude-Session: https://claude.ai/code/session_01Jo32HyGuio7m4BDxLbBYvf --- .gitignore | 2 + capabilities/hub-mode/capability.json | 35 ++ eslint.config.mjs | 2 + gsd-core/bin/lib/capability-registry.cjs | 69 ++++ src/hub-command-router.cts | 87 +++++ src/hub-query.cts | 392 +++++++++++++++++++++++ src/verify.cts | 148 ++++++++- tests/hub-query.test.cjs | 186 +++++++++++ tests/repo-type-hub-mode.test.cjs | 312 ++++++++++++++++++ 9 files changed, 1228 insertions(+), 5 deletions(-) create mode 100644 capabilities/hub-mode/capability.json create mode 100644 src/hub-command-router.cts create mode 100644 src/hub-query.cts create mode 100644 tests/hub-query.test.cjs create mode 100644 tests/repo-type-hub-mode.test.cjs diff --git a/.gitignore b/.gitignore index 479c85bad4..21906aa6d2 100644 --- a/.gitignore +++ b/.gitignore @@ -172,6 +172,8 @@ build/ /gsd-core/bin/lib/validate-command-router.cjs /gsd-core/bin/lib/workstream-inventory.cjs /gsd-core/bin/lib/roadmap-command-router.cjs +/gsd-core/bin/lib/hub-query.cjs +/gsd-core/bin/lib/hub-command-router.cjs /gsd-core/bin/lib/state-command-router.cjs /gsd-core/bin/lib/config.cjs /gsd-core/bin/lib/profile-output.cjs diff --git a/capabilities/hub-mode/capability.json b/capabilities/hub-mode/capability.json new file mode 100644 index 0000000000..66111a0558 --- /dev/null +++ b/capabilities/hub-mode/capability.json @@ -0,0 +1,35 @@ +{ + "id": "hub-mode", + "role": "feature", + "title": "Portfolio hub mode", + "description": "Marks a repo as a portfolio hub (multi-milestone numbering, archived phases, cross-repo STATE refs, .gsdrootallow whitelist) and relaxes `validate health` (W002/W005/W006/W007/W019) accordingly. Adds read-only `hub v1 *` query verbs (milestone/phase/phases/manifest/spoke). Only repo_type==='hub' triggers relaxations; 'spoke' is reserved and behaves as 'standalone'.", + "tier": "full", + "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, + "skills": [], + "agents": [], + "hooks": [], + "config": { + "repo_type": { + "type": "enum", + "values": ["standalone", "spoke", "hub"], + "default": "standalone", + "description": "Repo topology role. 'hub' relaxes phase-dir/roadmap/STATE/root-file validation for portfolio orchestration repos; 'spoke' is reserved (behaves as standalone today); 'standalone' is the strict default." + }, + "workflow.hub_mode": { + "type": "boolean", + "default": false, + "description": "Master toggle for hub-mode behaviors. Reserved on/off gate; the active relaxation branch keys off repo_type==='hub' read directly in validate health." + } + }, + "commands": [ + { + "family": "hub", + "module": "hub-command-router.cjs", + "router": "routeHubCommand" + } + ], + "steps": [], + "contributions": [], + "gates": [] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index f3d772670d..21fd174306 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,6 +129,8 @@ export default tseslint.config( 'gsd-core/bin/lib/validate-command-router.cjs', 'gsd-core/bin/lib/workstream-inventory.cjs', 'gsd-core/bin/lib/roadmap-command-router.cjs', + 'gsd-core/bin/lib/hub-query.cjs', + 'gsd-core/bin/lib/hub-command-router.cjs', 'gsd-core/bin/lib/state-command-router.cjs', 'gsd-core/bin/lib/gap-checker.cjs', 'gsd-core/bin/lib/config.cjs', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 0efba5681e..6e68835efd 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -831,6 +831,50 @@ const capabilities = { "extendedHookEvents": [] } }, + "hub-mode": { + "id": "hub-mode", + "role": "feature", + "title": "Portfolio hub mode", + "description": "Marks a repo as a portfolio hub (multi-milestone numbering, archived phases, cross-repo STATE refs, .gsdrootallow whitelist) and relaxes `validate health` (W002/W005/W006/W007/W019) accordingly. Adds read-only `hub v1 *` query verbs (milestone/phase/phases/manifest/spoke). Only repo_type==='hub' triggers relaxations; 'spoke' is reserved and behaves as 'standalone'.", + "tier": "full", + "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, + "skills": [], + "agents": [], + "hooks": [], + "config": { + "repo_type": { + "type": "enum", + "values": [ + "standalone", + "spoke", + "hub" + ], + "default": "standalone", + "description": "Repo topology role. 'hub' relaxes phase-dir/roadmap/STATE/root-file validation for portfolio orchestration repos; 'spoke' is reserved (behaves as standalone today); 'standalone' is the strict default." + }, + "workflow.hub_mode": { + "type": "boolean", + "default": false, + "description": "Master toggle for hub-mode behaviors. Reserved on/off gate; the active relaxation branch keys off repo_type==='hub' read directly in validate health." + } + }, + "commands": [ + { + "family": "hub", + "module": "hub-command-router.cjs", + "router": "routeHubCommand" + } + ], + "steps": [], + "contributions": [], + "gates": [] + }, "intel": { "id": "intel", "role": "feature", @@ -2353,6 +2397,8 @@ const configKeys = { "workflow.schema_drift_gate": "drift", "workflow.post_planning_gaps": "gap-analysis", "graphify.enabled": "graphify", + "repo_type": "hub-mode", + "workflow.hub_mode": "hub-mode", "intel.enabled": "intel", "mempalace.enabled": "mempalace", "mempalace.memory_mode": "mempalace", @@ -2436,6 +2482,23 @@ const configSchema = { "default": false, "description": "Enable the graphify knowledge-graph command + skill." }, + "repo_type": { + "owner": "hub-mode", + "type": "enum", + "default": "standalone", + "description": "Repo topology role. 'hub' relaxes phase-dir/roadmap/STATE/root-file validation for portfolio orchestration repos; 'spoke' is reserved (behaves as standalone today); 'standalone' is the strict default.", + "values": [ + "standalone", + "spoke", + "hub" + ] + }, + "workflow.hub_mode": { + "owner": "hub-mode", + "type": "boolean", + "default": false, + "description": "Master toggle for hub-mode behaviors. Reserved on/off gate; the active relaxation branch keys off repo_type==='hub' read directly in validate health." + }, "intel.enabled": { "owner": "intel", "type": "boolean", @@ -3517,6 +3580,11 @@ const commandFamilies = { "module": "graphify-command-router.cjs", "router": "routeGraphifyCommand" }, + "hub": { + "capId": "hub-mode", + "module": "hub-command-router.cjs", + "router": "routeHubCommand" + }, "intel": { "capId": "intel", "module": "intel-command-router.cjs", @@ -3641,6 +3709,7 @@ const _requiresGraph = { "gemini": [], "graphify": [], "hermes": [], + "hub-mode": [], "intel": [], "kilo": [], "kimi": [], diff --git a/src/hub-command-router.cts b/src/hub-command-router.cts new file mode 100644 index 0000000000..6ced6f2e24 --- /dev/null +++ b/src/hub-command-router.cts @@ -0,0 +1,87 @@ +/** + * Hub command family router — read-only `hub v1 *` query verbs. + * + * Routed via the capability registry's commandFamilies index (ADR-959): + * capabilities/hub-mode/capability.json declares {family:"hub", + * module:"hub-command-router.cjs", router:"routeHubCommand"}, and + * gsd-tools.cjs dispatchCapabilityCommand auto-routes `hub …` here. + * + * SYNCHRONOUS by contract: dispatchCapabilityCommand errors if a router returns + * a Promise (async capability routers are unsupported). All five verbs are + * read-only (mutation:false) — no lock, no writes. + * + * Versioning: requires `hub v1 …`. Unknown version tokens are rejected with an + * InvalidArgs-style error to reserve a future `v2` namespace. + * + * Invocation layout (dispatchCapabilityCommand passes the whole argv tail): + * args[0] = 'hub', args[1] = 'v1', args[2] = verb, args[3+] = verb args. + * + * Modeled on src/roadmap-command-router.cts. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- hub-query.cjs is an export= CommonJS module +import hubQuery = require('./hub-query.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module +import ioMod = require('./io.cjs'); +const { output } = ioMod; +import { parseNamedArgs } from './command-arg-projection.cjs'; + +interface RouteHubCommandOptions { + args: string[]; + cwd: string; + raw: boolean; + error: (message: string) => void; +} + +const HUB_VERBS = ['milestone', 'phase', 'phases', 'manifest', 'spoke']; + +function routeHubCommand({ args, cwd, raw, error }: RouteHubCommandOptions): void { + const version = args[1]; + if (version !== 'v1') { + error(`hub: only v1 API supported, got "${version ?? '(none)'}"`); + return; + } + + const verb = args[2]; + switch (verb) { + case 'milestone': { + const sub = args[3]; + if (sub !== 'current') { + error(`hub v1 milestone: only 'current' is supported, got "${sub ?? '(none)'}"`); + return; + } + output(hubQuery.getCurrentMilestone(cwd), raw); + return; + } + case 'phase': { + output(hubQuery.getPhase(cwd, args[3]), raw); + return; + } + case 'phases': { + const named = parseNamedArgs(args, ['milestone', 'state']); + output(hubQuery.getPhases(cwd, { + milestone: (named['milestone'] as string | null) ?? undefined, + state: (named['state'] as string | null) ?? undefined, + }), raw); + return; + } + case 'manifest': { + const named = parseNamedArgs(args, ['tier', 'role']); + output(hubQuery.getManifest(cwd, { + tier: (named['tier'] as string | null) ?? undefined, + role: (named['role'] as string | null) ?? undefined, + }), raw); + return; + } + case 'spoke': { + output(hubQuery.getSpoke(cwd, args[3]), raw); + return; + } + default: + error(`Unknown hub verb "${verb ?? '(none)'}". Available: ${HUB_VERBS.join(', ')}`); + } +} + +export = { + routeHubCommand, +}; diff --git a/src/hub-query.cts b/src/hub-query.cts new file mode 100644 index 0000000000..1b4dc2ea81 --- /dev/null +++ b/src/hub-query.cts @@ -0,0 +1,392 @@ +/** + * Hub query verbs — deterministic JSON view of planning state for agents. + * + * Exposes: + * hub v1 milestone current → current-milestone snapshot (from ROADMAP.md) + * hub v1 phase → phase-meta bundle (matches phase-meta.schema.json) + * hub v1 phases [filters] → array of phase-meta bundles + * hub v1 manifest [filters] → repo-manifest.json with optional filters + * hub v1 spoke → single repo entry from the manifest + * + * Stable contract: all responses are JSON. Field shapes mirror the schemas + * in capabilities/hub-mode/schemas/ — agents introspect those for the full + * type contract. + * + * Versioning: routed under `hub v1`. Breaking changes get a new `v2` + * namespace; additive changes stay in v1. + * + * ADR-457 build-at-publish: ported from the vendored hub-query.cjs to a typed + * .cts source of truth (compiled by tsc to a gitignored .cjs). Behaviour is + * preserved; only types are added. PORT FIX: extractCurrentMilestone is imported + * from ./roadmap-parser.cjs (the retired ./core.cjs re-export spine, epic #1267). + * + * Synchronous by contract: capability routers dispatched via + * dispatchCapabilityCommand must not return a Promise. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module +import planningWorkspace = require('./planning-workspace.cjs'); +const { planningDir } = planningWorkspace; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module +import frontmatterMod = require('./frontmatter.cjs'); +const { extractFrontmatter } = frontmatterMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module +import roadmapParserMod = require('./roadmap-parser.cjs'); +const { extractCurrentMilestone } = roadmapParserMod; + +const GENERATOR_VERSION = '1.0.0'; + +interface PhaseArtifacts { + context: string | null; + research: string | null; + plan: string | null; + summary: string | null; + validation: string | null; + review: string | null; + security: string | null; + extras: string[]; +} + +interface PhaseBundle { + id: string; + number: number | null; + slug: string; + milestone: string | null; + state: string; + owner: string | null; + dependencies: string[]; + spoke_repos: string[]; + artifacts: PhaseArtifacts; + frontmatter: Record; + generated_at: string; + generator_version: string; +} + +interface PhaseRange { + lo: number; + hi: number; +} + +type Frontmatter = Record; + +function parsePhaseDir(name: string): { number: number | null; slug: string } { + const m = name.match(/^(\d+(?:\.\d+)?)-(.+)$/); + if (!m) return { number: null, slug: name }; + const num = parseFloat(m[1]); + return { number: Number.isFinite(num) ? num : null, slug: m[2] }; +} + +function findByPattern(files: string[], regex: RegExp): string[] { + return files.filter((f) => regex.test(f)); +} + +function firstOrNull(arr: string[]): string | null { + return arr.length > 0 ? arr[0] : null; +} + +const STATUS_PASSTHROUGH = new Set([ + 'planned', 'researched', 'in_progress', 'blocked', + 'verified', 'shipped', 'archived', +]); + +function deriveState(frontmatter: Frontmatter, artifacts: PhaseArtifacts): string { + const status = frontmatter['status']; + if (typeof status === 'string' && STATUS_PASSTHROUGH.has(status)) { + return status; + } + if (artifacts.summary && artifacts.validation) return 'verified'; + if (artifacts.summary) return 'in_progress'; + if (artifacts.plan && artifacts.research) return 'researched'; + if (artifacts.plan) return 'planned'; + return 'planned'; +} + +function loadPhaseBundle(planBase: string, phaseId: string): PhaseBundle | null { + const phaseDir = path.join(planBase, 'phases', phaseId); + if (!fs.existsSync(phaseDir) || !fs.statSync(phaseDir).isDirectory()) { + return null; + } + const files = fs.readdirSync(phaseDir); + const { number, slug } = parsePhaseDir(phaseId); + + const planFile = firstOrNull(findByPattern(files, /(?:^|-)PLAN\.md$/i).sort()); + const summaryFile = firstOrNull(findByPattern(files, /(?:^|-)SUMMARY\.md$/i).sort()); + const contextFile = firstOrNull(findByPattern(files, /CONTEXT\.md$/i)); + const researchFile = firstOrNull(findByPattern(files, /RESEARCH\.md$/i)); + const validationFile = firstOrNull(findByPattern(files, /VALIDATION\.md$/i)); + const reviewFile = firstOrNull(findByPattern(files, /REVIEW\.md$/i)); + const securityFile = firstOrNull(findByPattern(files, /SECURITY\.md$/i)); + + const claimed = new Set( + [planFile, summaryFile, contextFile, researchFile, validationFile, reviewFile, securityFile] + .filter(Boolean) as string[], + ); + const extras = files.filter((f) => f.endsWith('.md') && !claimed.has(f)).sort(); + + const artifacts: PhaseArtifacts = { + context: contextFile ? path.posix.join('phases', phaseId, contextFile) : null, + research: researchFile ? path.posix.join('phases', phaseId, researchFile) : null, + plan: planFile ? path.posix.join('phases', phaseId, planFile) : null, + summary: summaryFile ? path.posix.join('phases', phaseId, summaryFile) : null, + validation: validationFile ? path.posix.join('phases', phaseId, validationFile) : null, + review: reviewFile ? path.posix.join('phases', phaseId, reviewFile) : null, + security: securityFile ? path.posix.join('phases', phaseId, securityFile) : null, + extras: extras.map((f) => path.posix.join('phases', phaseId, f)), + }; + + let frontmatter: Frontmatter = {}; + if (planFile) { + try { + const raw = fs.readFileSync(path.join(phaseDir, planFile), 'utf-8'); + const fm: unknown = extractFrontmatter(raw); + frontmatter = (fm && typeof fm === 'object' ? fm as Frontmatter : {}); + } catch { /* skip */ } + } + + const deps = frontmatter['dependencies']; + const dependsOn = frontmatter['depends_on']; + const spokeRepos = frontmatter['spoke_repos']; + + return { + id: phaseId, + number, + slug, + milestone: (frontmatter['milestone'] as string | undefined) || null, + state: deriveState(frontmatter, artifacts), + owner: (frontmatter['owner'] as string | undefined) || null, + dependencies: Array.isArray(deps) ? (deps as string[]) + : Array.isArray(dependsOn) ? (dependsOn as string[]) + : [], + spoke_repos: Array.isArray(spokeRepos) ? (spokeRepos as string[]) : [], + artifacts, + frontmatter, + generated_at: new Date().toISOString(), + generator_version: GENERATOR_VERSION, + }; +} + +function listPhaseDirs(planBase: string): string[] { + const phasesDir = path.join(planBase, 'phases'); + if (!fs.existsSync(phasesDir)) return []; + return fs.readdirSync(phasesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith('_')) + .map((e) => e.name) + .sort(); +} + +// Read the current-milestone version from STATE.md frontmatter. +// extractCurrentMilestone() returns "preamble + section" which mixes summary +// bullets from earlier milestones into the slice — fine for its callers but not +// for deterministic per-milestone phase extraction. Here we slice from the +// matching milestone heading down to the next milestone heading instead. +function readStateMilestone(cwd: string): string | null { + try { + const statePath = path.join(planningDir(cwd), 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + const m = raw.match(/^milestone:\s*(.+)/m); + return m ? m[1].trim() : null; + } catch { return null; } +} + +function sliceCurrentMilestoneSection(roadmapContent: string, version: string | null): string | null { + if (!version) return null; + const escaped = version.replace(/\./g, '\\.'); + const re = new RegExp(`(^#{1,3})\\s+.*${escaped}[^\\n]*`, 'mi'); + const m = roadmapContent.match(re); + if (!m || m.index === undefined) return null; + const headingLevel = m[1].length; + const sectionStart = m.index; + const rest = roadmapContent.slice(sectionStart + m[0].length); + // Next milestone-style heading at same-or-shallower depth ends the section. + // Phase headings (e.g. "### Phase 12:") are explicitly excluded. + const nextRe = new RegExp( + `^#{1,${headingLevel}}\\s+(?!Phase\\s)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, + 'mi', + ); + const nextMatch = rest.match(nextRe); + const sectionEnd = nextMatch && nextMatch.index !== undefined + ? sectionStart + m[0].length + nextMatch.index + : roadmapContent.length; + return roadmapContent.slice(sectionStart, sectionEnd); +} + +function getCurrentMilestone(cwd: string): Record { + const planBase = planningDir(cwd); + const roadmapPath = path.join(planBase, 'ROADMAP.md'); + if (!fs.existsSync(roadmapPath)) { + return { error: 'ROADMAP.md not found', path: roadmapPath }; + } + const raw = fs.readFileSync(roadmapPath, 'utf-8'); + const declared = readStateMilestone(cwd); + + // Prefer STATE.md's declared milestone; fall back to first 🚧 marker in ROADMAP. + let version = declared; + if (!version) { + const m = raw.match(/🚧[^\n]*\*\*?(v\d+\.\d+(?:\.\d+)?)\b/); + version = m ? m[1] : null; + } + + const section = sliceCurrentMilestoneSection(raw, version); + if (!section) { + // Last resort: defer to core's extractor; phase list will be best-effort. + const fallback: string = extractCurrentMilestone(raw, cwd); + const phaseNums: string[] = []; + const phaseRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + let mm: RegExpExecArray | null; + while ((mm = phaseRe.exec(fallback)) !== null) phaseNums.push(mm[1]); + return { + version, + title: null, + phases: phaseNums, + phase_range: null, + roadmap_path: 'ROADMAP.md', + degraded: 'milestone section not found; phases extracted from full roadmap', + generated_at: new Date().toISOString(), + generator_version: GENERATOR_VERSION, + }; + } + + const titleMatch = section.match(/^#{1,3}\s+[^\n]+/); + const titleLine = titleMatch ? titleMatch[0].replace(/^#{1,3}\s+/, '') : null; + + const phaseNums: string[] = []; + const phaseRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + let mm: RegExpExecArray | null; + while ((mm = phaseRe.exec(section)) !== null) phaseNums.push(mm[1]); + // De-dup while preserving order + const seen = new Set(); + const uniquePhases = phaseNums.filter((p) => (seen.has(p) ? false : (seen.add(p), true))); + + const intNums = uniquePhases.map((p) => parseFloat(p)).filter((n) => Number.isFinite(n)); + const range: PhaseRange | null = intNums.length > 0 + ? { lo: Math.min(...intNums), hi: Math.max(...intNums) } + : null; + + return { + version, + title: titleLine, + phases: uniquePhases, + phase_range: range, + roadmap_path: 'ROADMAP.md', + generated_at: new Date().toISOString(), + generator_version: GENERATOR_VERSION, + }; +} + +function getPhase(cwd: string, phaseId: string | undefined): Record { + const planBase = planningDir(cwd); + if (!phaseId) return { error: 'phase id is required' }; + const bundle = loadPhaseBundle(planBase, phaseId); + if (!bundle) return { error: `phase not found: ${phaseId}`, id: phaseId }; + return bundle as unknown as Record; +} + +interface PhasesOpts { milestone?: string; state?: string } + +function getPhases(cwd: string, opts: PhasesOpts = {}): Record { + const planBase = planningDir(cwd); + let bundles = listPhaseDirs(planBase) + .map((id) => loadPhaseBundle(planBase, id)) + .filter(Boolean) as PhaseBundle[]; + + if (opts.milestone) { + bundles = bundles.filter((b) => b.milestone === opts.milestone); + } + if (opts.state) { + bundles = bundles.filter((b) => b.state === opts.state); + } + + return { + count: bundles.length, + phases: bundles, + generated_at: new Date().toISOString(), + generator_version: GENERATOR_VERSION, + }; +} + +function findRepoManifest(cwd: string): string | null { + let dir = path.resolve(cwd); + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'repo-manifest.json'); + if (fs.existsSync(candidate)) return candidate; + dir = path.dirname(dir); + } + return null; +} + +interface ManifestOpts { tier?: string; role?: string } +type RepoEntry = Record & { tier?: number; role?: string }; + +function getManifest(cwd: string, opts: ManifestOpts = {}): Record { + const manifestPath = findRepoManifest(cwd); + if (!manifestPath) { + return { error: 'repo-manifest.json not found in cwd or ancestors' }; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record; + + let repos = (manifest['repos'] as Record) || {}; + if (opts.tier !== undefined && opts.tier !== null) { + const t = parseInt(opts.tier, 10); + const filtered: Record = {}; + for (const [slug, entry] of Object.entries(repos)) { + if (entry && entry.tier === t) filtered[slug] = entry; + } + repos = filtered; + } + if (opts.role) { + const filtered: Record = {}; + for (const [slug, entry] of Object.entries(repos)) { + if (entry && entry.role === opts.role) filtered[slug] = entry; + } + repos = filtered; + } + + return { + version: manifest['version'], + owner: manifest['owner'], + owner_type: manifest['owner_type'], + hub: manifest['hub'], + updated: manifest['updated'], + edge_semantics: manifest['edge_semantics'], + repos, + count: Object.keys(repos).length, + manifest_path: path.relative(cwd, manifestPath), + generated_at: new Date().toISOString(), + generator_version: GENERATOR_VERSION, + }; +} + +function getSpoke(cwd: string, slug: string | undefined): Record { + if (!slug) return { error: 'spoke slug is required' }; + const manifestPath = findRepoManifest(cwd); + if (!manifestPath) { + return { error: 'repo-manifest.json not found in cwd or ancestors' }; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record; + const entry = ((manifest['repos'] as Record) || {})[slug]; + if (!entry) return { error: `spoke not found: ${slug}`, slug }; + return { + slug, + ...entry, + manifest_path: path.relative(cwd, manifestPath), + generated_at: new Date().toISOString(), + generator_version: GENERATOR_VERSION, + }; +} + +export = { + getCurrentMilestone, + getPhase, + getPhases, + getManifest, + getSpoke, + loadPhaseBundle, + parsePhaseDir, + deriveState, + // exported for verify.cts hub-mode W007 scoping + sliceCurrentMilestoneSection, + readStateMilestone, + GENERATOR_VERSION, +}; diff --git a/src/verify.cts b/src/verify.cts index 0ba8ad168e..cd968062af 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -11,6 +11,8 @@ import path from 'node:path'; import os from 'node:os'; import { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } from './validate.cjs'; import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem } from './validate.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- hub-query.cjs is an export= CommonJS module +import hubQueryMod = require('./hub-query.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module import planningWorkspace = require('./planning-workspace.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module @@ -1241,6 +1243,25 @@ function cmdValidateHealth( const statePath = path.join(planBase, 'STATE.md'); const configPath = path.join(planBase, 'config.json'); const phasesDir = path.join(planBase, 'phases'); + + // ── hub-mode (repo_type) gate ────────────────────────────────────────────── + // Read repo_type DIRECTLY from config.json (NOT loadConfig): this runs before + // other config-dependent checks and must fail-open to 'standalone'. Only + // repo_type==='hub' relaxes W002/W005/W006/W007/W019; 'spoke'/invalid/absent + // all behave as 'standalone' (an invalid value additionally fires W022 in the + // config-validation block below). Mirrors the deferred spec's raw-read ordering. + const VALID_REPO_TYPES = ['standalone', 'spoke', 'hub']; + let repoType = 'standalone'; + try { + const rawCfgForType = fs.readFileSync(configPath, 'utf-8'); + const parsedForType = JSON.parse(rawCfgForType) as Record; + const rt = parsedForType['repo_type']; + if (typeof rt === 'string' && VALID_REPO_TYPES.includes(rt)) repoType = rt; + } catch { + /* config.json missing or unparseable — default to standalone (W003/E005 surface it) */ + } + const isHub = repoType === 'hub'; + const _slashRuntime = resolveRuntime(cwd); const slash = (name: string) => formatGsdSlash(name, _slashRuntime) as string; @@ -1295,10 +1316,33 @@ function cmdValidateHealth( repairs.push('regenerateState'); } else { const stateContent = fs.readFileSync(statePath, 'utf-8'); - const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map( - (m) => m[1], - ); + // W002: hub-mode drops cross-repo refs ("Envision-MCP Phase 27" via negative + // lookbehind) and plan-code refs ("Phase 27-01" via negative lookahead on + // BOTH a hyphen and a digit, so greedy \d+ can't backtrack to a short prefix). + // Standalone keeps the original bare regex. + const phaseRefRe = isHub + ? /(? m[1]); const validPhases = collectDiskPhases(planBase); + // W002: hub-mode unions archived phase prefixes (phases/_archive/**) so STATE.md + // may reference historical phases without flagging. Advisory: fail-open. + if (isHub) { + try { + const archiveRoot = path.join(phasesDir, '_archive'); + const walkArchive = (dir: string) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (!ent.isDirectory()) continue; + const pm = ent.name.match(/^(\d+[A-Z]?(?:\.\d+)*)-/); + if (pm) validPhases.add(pm[1]); + walkArchive(path.join(dir, ent.name)); + } + }; + if (fs.existsSync(archiveRoot)) walkArchive(archiveRoot); + } catch { + /* advisory — fail-open */ + } + } try { if (fs.existsSync(roadmapPath)) { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); @@ -1359,6 +1403,18 @@ function cmdValidateHealth( `Valid values: ${validProfiles.join(', ')}`, ); } + // W022: invalid repo_type (hub-mode). W021 is already taken twice elsewhere + // in this command (phase-prefix mismatch ~1719, STATE-complete mismatch ~1822), + // so the next free code is W022. An invalid value falls back to standalone + // strictness (see the isHub gate above) AND fires this warning. + if (parsed['repo_type'] !== undefined && !VALID_REPO_TYPES.includes(parsed['repo_type'] as string)) { + addIssue( + 'warning', + 'W022', + `config.json: invalid repo_type "${parsed['repo_type'] as string}"`, + 'Valid values: standalone, spoke, hub', + ); + } } catch (err) { addIssue( 'error', @@ -1418,12 +1474,20 @@ function cmdValidateHealth( /* intentionally empty */ } + // W005: phase-dir naming. The shared phaseDirNameRe already accepts \d{2,} + // (2+ digit prefixes, milestone-prefixed, deep, and project-code-prefixed + // variants) — multi-milestone numbering like 250-foo / 999.1-foo is valid in + // BOTH modes by upstream contract. Hub-mode's only W005 relaxation is skipping + // '_'-prefixed dirs (the conventional ignore marker, e.g. _archive). Standalone + // still flags such dirs; the regex/hint are otherwise identical across modes. + const w005Hint = isHub ? 'NNNN-name (2-4 digits)' : 'NN-name'; for (const e of phaseDirEntries) { + if (isHub && e.name.startsWith('_')) continue; if (!e.name.match(phaseDirNameRe)) { addIssue( 'warning', 'W005', - `Phase directory "${e.name}" doesn't follow NN-name format`, + `Phase directory "${e.name}" doesn't follow ${w005Hint} format`, 'Rename to match pattern (e.g., 01-setup)', ); } @@ -1526,12 +1590,62 @@ function cmdValidateHealth( const notStartedPhases = buildNotStartedPhaseVariants(roadmapContent); + // ── hub-mode W006: archive-aware skip ──────────────────────────────────── + // Walk phases/_archive/** for phase-prefix dir names so a roadmap phase that + // was archived (not on the phases/ top level) does not trip W006. Advisory: + // fail-open to standalone behavior on any read error. + const hubArchivedPhases = new Set(); + if (isHub) { + try { + const archiveRoot = path.join(phasesDir, '_archive'); + const walk = (dir: string) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (!ent.isDirectory()) continue; + const pm = ent.name.match(/^(\d+[A-Z]?(?:\.\d+)*)-/); + if (pm) hubArchivedPhases.add(pm[1]); + walk(path.join(dir, ent.name)); + } + }; + if (fs.existsSync(archiveRoot)) walk(archiveRoot); + } catch { + /* advisory — fail-open */ + } + } + + // ── hub-mode W007: scope to current-milestone numeric range ─────────────── + // Compute {lo,hi} from the CURRENT MILESTONE SECTION ONLY (sliceCurrentMilestone + // section, not extractCurrentMilestone which leaks earlier-milestone headings). + // Advisory require in try/catch; fall back to standalone (no range) on error. + let hubPhaseRange: { lo: number; hi: number } | null = null; + if (isHub) { + try { + const version = hubQueryMod.readStateMilestone(cwd); + const section = hubQueryMod.sliceCurrentMilestoneSection(roadmapContentRaw, version); + if (section) { + const nums: number[] = []; + const re = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + let mm: RegExpExecArray | null; + while ((mm = re.exec(section)) !== null) { + const n = parseInt(mm[1], 10); + if (Number.isFinite(n)) nums.push(n); + } + if (nums.length > 0) hubPhaseRange = { lo: Math.min(...nums), hi: Math.max(...nums) }; + } + } catch { + /* advisory — fall back to standalone (no range scoping) */ + } + } + for (const p of roadmapPhases) { const variants = phaseVariants(p); const existsOnDisk = [...variants].some((v) => diskPhases.has(v)); if (!existsOnDisk) { const isNotStarted = [...variants].some((v) => notStartedPhases.has(v)); if (isNotStarted) continue; + if (isHub) { + const padded = /^\d+$/.test(p) ? p.padStart(2, '0') : p; + if (hubArchivedPhases.has(p) || hubArchivedPhases.has(padded)) continue; + } addIssue( 'warning', 'W006', @@ -1544,6 +1658,10 @@ function cmdValidateHealth( for (const p of activeDiskPhases) { const variants = phaseVariants(p); if (![...variants].some((v) => fullRoadmapPhaseVariants.has(v))) { + if (isHub && hubPhaseRange) { + const n = parseInt(p, 10); + if (!Number.isFinite(n) || n < hubPhaseRange.lo || n > hubPhaseRange.hi) continue; + } addIssue( 'warning', 'W007', @@ -1764,16 +1882,36 @@ function cmdValidateHealth( } try { + // W019: hub-mode consults .planning/.gsdrootallow (one filename per line, + // '#' comments, blank lines ignored, each trimmed) as a whitelist of + // hub-specific root docs. Advisory read: fail-open on error. + const hubRootAllow = new Set(); + if (isHub) { + try { + const allowRaw = fs.readFileSync(path.join(planBase, '.gsdrootallow'), 'utf-8'); + for (const line of allowRaw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + hubRootAllow.add(trimmed); + } + } catch { + /* advisory — fail-open */ + } + } + const w019Fix = isHub + ? 'Move to .planning/milestones/ archive subdir or delete if stale, or add to .planning/.gsdrootallow if hub-specific. See templates/README.md for the canonical artifact list.' + : 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.'; const entries = fs.readdirSync(planBase, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; + if (isHub && hubRootAllow.has(entry.name)) continue; if (!isCanonicalPlanningFile(entry.name)) { addIssue( 'warning', 'W019', `Unrecognized .planning/ file: ${entry.name} — not a canonical GSD artifact`, - 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', + w019Fix, false, ); } diff --git a/tests/hub-query.test.cjs b/tests/hub-query.test.cjs new file mode 100644 index 0000000000..944e12e1db --- /dev/null +++ b/tests/hub-query.test.cjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Integration tests for hub query verbs (lib/hub-query.cjs via `hub v1 *`). + * + * Ported from the deferred get-shit-done-cc spec + * (claude-code-memory/global/gsd-hub-mode-deferred/tests/hub-query.test.cjs). + * Adjustment vs the spec source: invokes the engine via tests/helpers.cjs + * runGsdTools (resolves ../gsd-core/bin/gsd-tools.cjs) instead of a hardcoded + * SDK bin path. + * + * Self-contained — builds a synthetic .planning/ + repo-manifest.json per + * scenario, exercises one verb, asserts on the JSON shape. Exits non-zero on + * failure. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +let passes = 0; +let failures = 0; + +function assert(cond, msg) { + if (cond) { console.log(` ok: ${msg}`); passes++; } + else { console.error(` FAIL: ${msg}`); failures++; } +} + +function mkRepo() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hub-q-')); + const planning = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(planning, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(planning, 'PROJECT.md'), + '# X\n\n## What This Is\nx\n\n## Core Value\nx\n\n## Requirements\nx\n'); + fs.writeFileSync(path.join(planning, 'STATE.md'), + '---\ngsd_state_version: 1.0\nmilestone: v2.0\nstatus: executing\n---\n\n**Current Phase:** 10\n'); + return { tmpDir, planning }; +} + +function writeRoadmap(planning, body) { + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), body); +} + +function writePhase(planning, id, planFrontmatter = null) { + const dir = path.join(planning, 'phases', id); + fs.mkdirSync(dir, { recursive: true }); + const fm = planFrontmatter + ? `---\n${Object.entries(planFrontmatter).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join('\n')}\n---\n` + : ''; + fs.writeFileSync(path.join(dir, `${id.replace(/-.*$/, '')}-01-PLAN.md`), `${fm}# Plan\n\nbody\n`); +} + +function writeFile(dir, name, content) { + fs.writeFileSync(path.join(dir, name), content); +} + +function writeManifest(tmpDir, manifest) { + fs.writeFileSync(path.join(tmpDir, 'repo-manifest.json'), JSON.stringify(manifest, null, 2)); +} + +function run(cwd, ...verb) { + const res = runGsdTools(['hub', 'v1', ...verb], cwd); + return JSON.parse(res.output); +} + +function rmrf(p) { cleanup(p); } + +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT1: hub v1 milestone current'); +{ + const { tmpDir, planning } = mkRepo(); + writeRoadmap(planning, + '# Roadmap\n\n## Milestones\n\n' + + '### v1.0 Old Milestone — SHIPPED 2026-01-01\n' + + '### Phase 1: thing\nx\n\n' + + '### v2.0 Current Milestone (Phases 10-12)\n' + + '### Phase 10: A\nx\n### Phase 11: B\nx\n### Phase 12: C\nx\n'); + const r = run(tmpDir, 'milestone', 'current'); + assert(r.version === 'v2.0', `version is v2.0 (got ${r.version})`); + assert(r.phases.length === 3, `3 phases returned (got ${r.phases.length})`); + assert(r.phases.join(',') === '10,11,12', 'phases are 10,11,12'); + assert(r.phase_range && r.phase_range.lo === 10 && r.phase_range.hi === 12, 'range 10..12'); + assert(r.generator_version, 'generator_version present'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT2: hub v1 phase — bundle shape'); +{ + const { tmpDir, planning } = mkRepo(); + writeRoadmap(planning, '## Milestones\n\n### v2.0 (Phases 10-10)\n### Phase 10: A\n'); + writePhase(planning, '10-foo', { milestone: 'v2.0', owner: 'avi', dependencies: ['09-bar'] }); + // Add a SUMMARY to push state forward + writeFile(path.join(planning, 'phases', '10-foo'), '10-01-SUMMARY.md', '# Summary\nx\n'); + const r = run(tmpDir, 'phase', '10-foo'); + assert(r.id === '10-foo', 'id matches'); + assert(r.number === 10, 'number=10'); + assert(r.slug === 'foo', 'slug=foo'); + assert(r.milestone === 'v2.0', 'milestone from frontmatter'); + assert(r.owner === 'avi', 'owner from frontmatter'); + assert(Array.isArray(r.dependencies) && r.dependencies.length === 1, 'deps array'); + assert(r.state === 'in_progress', `state=in_progress (got ${r.state})`); + assert(r.artifacts.plan && r.artifacts.plan.includes('PLAN.md'), 'plan artifact present'); + assert(r.artifacts.summary && r.artifacts.summary.includes('SUMMARY.md'), 'summary artifact present'); + assert(r.artifacts.validation === null, 'validation absent → null'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT3: hub v1 phase — missing phase returns error'); +{ + const { tmpDir } = mkRepo(); + const r = run(tmpDir, 'phase', 'does-not-exist'); + assert(r.error && r.error.includes('not found'), 'error message returned'); + assert(r.id === 'does-not-exist', 'id echoed back'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT4: hub v1 phases — filters'); +{ + const { tmpDir, planning } = mkRepo(); + writeRoadmap(planning, '## Milestones\n\n### v2.0\n'); + writePhase(planning, '10-a', { milestone: 'v2.0' }); + writePhase(planning, '11-b', { milestone: 'v1.0' }); + writePhase(planning, '12-c', { milestone: 'v2.0' }); + const all = run(tmpDir, 'phases'); + assert(all.count === 3, `unfiltered count=3 (got ${all.count})`); + const v2 = run(tmpDir, 'phases', '--milestone', 'v2.0'); + assert(v2.count === 2, `v2.0 filter count=2 (got ${v2.count})`); + const planned = run(tmpDir, 'phases', '--state', 'planned'); + assert(planned.count === 3, 'all are state=planned (no SUMMARYs)'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT5: hub v1 manifest — tier + role filters'); +{ + const { tmpDir } = mkRepo(); + writeManifest(tmpDir, { + version: '2.0', + owner: 'test-org', + owner_type: 'organization', + hub: 'test-hub', + updated: '2026-01-01T00:00:00Z', + edge_semantics: { + depends_on: 'runtime', data_depends_on: 'schema', governed_by: 'hub', + }, + repos: { + 'a': { path: '~/a', github: 'org/a', tier: 1, role: 'service' }, + 'b': { path: '~/b', github: 'org/b', tier: 1, role: 'gateway' }, + 'c': { path: '~/c', github: 'org/c', tier: 2, role: 'frontend' }, + 'd': { path: null, github: 'org/d', tier: 4, role: 'external' }, + }, + }); + const all = run(tmpDir, 'manifest'); + assert(all.count === 4, `unfiltered=4 (got ${all.count})`); + const t1 = run(tmpDir, 'manifest', '--tier', '1'); + assert(t1.count === 2, `tier-1=2 (got ${t1.count})`); + const gw = run(tmpDir, 'manifest', '--role', 'gateway'); + assert(gw.count === 1 && Object.keys(gw.repos)[0] === 'b', 'role=gateway → b'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT6: hub v1 spoke '); +{ + const { tmpDir } = mkRepo(); + writeManifest(tmpDir, { + version: '2.0', owner: 'org', owner_type: 'organization', hub: 'h', + updated: '2026-01-01T00:00:00Z', + edge_semantics: { depends_on: '', data_depends_on: '', governed_by: '' }, + repos: { 'svc-x': { path: '~/svc-x', github: 'org/svc-x', tier: 2, role: 'service' } }, + }); + const r = run(tmpDir, 'spoke', 'svc-x'); + assert(r.slug === 'svc-x', 'slug echoed'); + assert(r.tier === 2 && r.role === 'service', 'fields passed through'); + const missing = run(tmpDir, 'spoke', 'does-not-exist'); + assert(missing.error && missing.error.includes('not found'), 'missing → error'); + rmrf(tmpDir); +} + +console.log(`\n${passes} passed, ${failures} failed`); +process.exit(failures === 0 ? 0 : 1); diff --git a/tests/repo-type-hub-mode.test.cjs b/tests/repo-type-hub-mode.test.cjs new file mode 100644 index 0000000000..00fc66bf55 --- /dev/null +++ b/tests/repo-type-hub-mode.test.cjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Integration tests for repo_type hub mode (see verify.cts W002/W005/W006/W007/W019 + * + the W022 invalid-repo_type warning). + * + * Ported from the deferred get-shit-done-cc spec + * (claude-code-memory/global/gsd-hub-mode-deferred/tests/repo-type-hub-mode.test.cjs). + * Adjustments vs the spec source: + * - invokes the engine via tests/helpers.cjs runGsdTools (resolves + * ../gsd-core/bin/gsd-tools.cjs) instead of a hardcoded SDK bin path; + * - the invalid-repo_type warning is W022, not W021 (W021 is already used twice + * in verify.cts: phase-prefix mismatch + STATE-complete mismatch), so T5 + * asserts W022. + * + * Self-contained — builds a synthetic .planning/ tree per test, runs + * `validate health`, asserts on the JSON warnings. Exits non-zero on failure. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +let failures = 0; +let passes = 0; + +function assert(cond, msg) { + if (!cond) { + console.error(` FAIL: ${msg}`); + failures++; + } else { + console.log(` ok: ${msg}`); + passes++; + } +} + +function mkTmpRepo(milestone = 'v1.0') { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hub-test-')); + const planning = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(planning, 'phases'), { recursive: true }); + + fs.writeFileSync(path.join(planning, 'PROJECT.md'), + '# Project\n\n## What This Is\n\nx\n\n## Core Value\n\nx\n\n## Requirements\n\nx\n'); + // Real GSD STATE.md has frontmatter with milestone field — required for + // hub-mode W007 section-scoping to work. + fs.writeFileSync(path.join(planning, 'STATE.md'), + `---\ngsd_state_version: 1.0\nmilestone: ${milestone}\nstatus: executing\n---\n\n**Current Phase:** 260\n**Status:** in-progress\n`); + + return { tmpDir, planning }; +} + +function writeRoadmap(planning, currentMilestone, phaseNums) { + const phaseSections = phaseNums.map(n => `### Phase ${n}: Test phase ${n}\nbody\n`).join('\n'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), + `# Roadmap\n\n## Milestones\n\n### ${currentMilestone}\n\n${phaseSections}\n`); +} + +function writeConfig(planning, obj) { + fs.writeFileSync(path.join(planning, 'config.json'), JSON.stringify(obj, null, 2)); +} + +function writePhaseDir(planning, dirname) { + fs.mkdirSync(path.join(planning, 'phases', dirname), { recursive: true }); +} + +function runHealth(tmpDir) { + const res = runGsdTools(['validate', 'health'], tmpDir); + return JSON.parse(res.output); +} + +function rmrf(dir) { + cleanup(dir); +} + +function countByCode(result, code) { + return (result.warnings || []).filter(w => w.code === code).length; +} + +function hasCode(result, code) { + return countByCode(result, code) > 0; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 1: standalone (default) — W005 phase-dir naming. +// RECONCILED vs the deferred spec: the live gsd-core phaseDirNameRe accepts +// \d{2,} prefixes (2+ digits, incl. 999.1-foo sub-phases) by upstream contract +// (tests/26-w005-w006-i001-cjs-drift-regression.test.cjs asserts 3-digit dirs +// are ACCEPTED in standalone). The original spec's "standalone flags 3-digit" +// premise pre-dates that drift and would regress the upstream test, so T1 here +// asserts the engine's real behavior: standalone accepts valid multi-digit +// numeric prefixes and flags only genuinely malformed (non-numeric-prefix) dirs. +// Hub-mode's W005 relaxation is the '_'-prefix skip (T2/T6), not digit-width. +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT1: standalone mode — W005 flags malformed dirs, accepts multi-digit prefixes'); +{ + const { tmpDir, planning } = mkTmpRepo(); + writeRoadmap(planning, 'v1.0 Test', ['250']); + writePhaseDir(planning, '250-three-digit-phase'); + writePhaseDir(planning, '01-two-digit-phase'); + writePhaseDir(planning, 'bad-no-prefix'); + // no repo_type set — defaults to standalone + + const r = runHealth(tmpDir); + const w005Names = (r.warnings || []).filter(w => w.code === 'W005').map(w => w.message); + assert(w005Names.some(m => m.includes('bad-no-prefix')), 'standalone flags non-numeric-prefix dir'); + assert(!w005Names.some(m => m.includes('250-three-digit-phase')), 'standalone accepts multi-digit prefix (upstream contract)'); + assert(!w005Names.some(m => m.includes('01-two-digit-phase')), 'standalone accepts 2-digit prefix'); + + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 2: hub mode — relaxed regex allows 3-digit; _-prefixed dir skipped +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT2: hub mode — relaxed NNNN regex + _-prefix skip'); +{ + const { tmpDir, planning } = mkTmpRepo('v1.0'); + writeRoadmap(planning, 'v1.0 Test', ['250']); + writePhaseDir(planning, '250-three-digit-phase'); + writePhaseDir(planning, '_archive'); + writePhaseDir(planning, 'bad-no-prefix'); + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w005Names = (r.warnings || []).filter(w => w.code === 'W005').map(w => w.message); + assert(!w005Names.some(m => m.includes('250-three-digit-phase')), 'hub does NOT flag 250-three-digit-phase'); + assert(!w005Names.some(m => m.includes('_archive')), 'hub does NOT flag _-prefixed dir'); + assert(w005Names.some(m => m.includes('bad-no-prefix')), 'hub still flags non-numeric prefix'); + + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 3: hub mode — W007 scoped to current milestone numeric range +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT3: hub mode — W007 scoped to current-milestone range'); +{ + const { tmpDir, planning } = mkTmpRepo('v47.0'); + writeRoadmap(planning, 'v47.0 (Phases 280-285)', ['280', '281', '283', '285']); + for (const n of [250, 280, 281, 282, 283, 284, 285, 290]) { + writePhaseDir(planning, `${n}-x`); + } + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w007Names = (r.warnings || []).filter(w => w.code === 'W007').map(w => w.message); + assert(!w007Names.some(m => m.includes('Phase 250')), 'hub does NOT flag historical Phase 250 (below range lo)'); + assert(!w007Names.some(m => m.includes('Phase 290')), 'hub does NOT flag future Phase 290 (above range hi)'); + assert(w007Names.some(m => m.includes('Phase 282')), 'hub DOES flag in-range gap Phase 282'); + assert(w007Names.some(m => m.includes('Phase 284')), 'hub DOES flag in-range gap Phase 284'); + + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 4: hub mode — .gsdrootallow whitelists non-canonical root files +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT4: hub mode — .gsdrootallow whitelist for W019'); +{ + const { tmpDir, planning } = mkTmpRepo(); + writeRoadmap(planning, 'v1.0', ['10']); + writePhaseDir(planning, '10-x'); + fs.writeFileSync(path.join(planning, 'DECISIONS.md'), '# decisions\n'); + fs.writeFileSync(path.join(planning, 'GCP-PROJECT-MAP.md'), '# gcp\n'); + fs.writeFileSync(path.join(planning, 'ROGUE.md'), '# rogue\n'); + fs.writeFileSync(path.join(planning, '.gsdrootallow'), + '# Hub-specific docs\nDECISIONS.md\nGCP-PROJECT-MAP.md\n'); + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w019Names = (r.warnings || []).filter(w => w.code === 'W019').map(w => w.message); + assert(!w019Names.some(m => m.includes('DECISIONS.md')), 'hub whitelists DECISIONS.md'); + assert(!w019Names.some(m => m.includes('GCP-PROJECT-MAP.md')), 'hub whitelists GCP-PROJECT-MAP.md'); + assert(w019Names.some(m => m.includes('ROGUE.md')), 'hub still flags non-whitelisted ROGUE.md'); + + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 5: invalid repo_type → W022 (W021 is already taken in verify.cts) +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT5: invalid repo_type → W022'); +{ + const { tmpDir, planning } = mkTmpRepo(); + writeRoadmap(planning, 'v1.0', ['10']); + writePhaseDir(planning, '10-x'); + writeConfig(planning, { repo_type: 'galaxy' }); + + const r = runHealth(tmpDir); + assert(hasCode(r, 'W022'), 'invalid repo_type fires W022'); + // Also: since invalid value falls back to standalone, W005 / W019 strictness still applies + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 6: explicit repo_type='standalone' matches default — the hub '_'-prefix +// skip is hub-ONLY. RECONCILED vs spec (which asserted "standalone flags 3-digit"): +// the engine accepts multi-digit prefixes in standalone (see T1), so the +// observable standalone-vs-hub W005 difference is the '_'-dir skip. Standalone +// flags a '_'-prefixed dir (fails phaseDirNameRe); hub skips it (see T2). +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT6: explicit standalone does NOT skip _-prefixed dirs (hub-only relaxation)'); +{ + const { tmpDir, planning } = mkTmpRepo(); + writeRoadmap(planning, 'v1.0', ['250']); + writePhaseDir(planning, '250-x'); + writePhaseDir(planning, '_archive'); + writeConfig(planning, { repo_type: 'standalone' }); + const r = runHealth(tmpDir); + const w005Names = (r.warnings || []).filter(w => w.code === 'W005').map(w => w.message); + assert(w005Names.some(m => m.includes('_archive')), 'explicit standalone flags _-prefixed dir (no hub skip)'); + assert(!w005Names.some(m => m.includes('250-x')), 'explicit standalone accepts multi-digit prefix'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 7: hub mode W002 — cross-repo phase refs skipped via negative lookbehind +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT7: hub mode — W002 skips cross-repo phase refs'); +{ + const { tmpDir, planning } = mkTmpRepo('v1.0'); + writeRoadmap(planning, 'v1.0 Test', ['10']); + writePhaseDir(planning, '10-x'); + fs.writeFileSync(path.join(planning, 'STATE.md'), + `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n` + + `**Current Phase:** 10\n\n` + + `Track A (SDK Migration, Envision-MCP Phase 27 plans 27-01 through 27-04).\n` + + `Local follow-up needed: Phase 99.\n`); + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w002Msgs = (r.warnings || []).filter(w => w.code === 'W002').map(w => w.message); + assert(!w002Msgs.some(m => /references phase 27\b/.test(m)), + 'hub skips cross-repo "Envision-MCP Phase 27" ref'); + assert(w002Msgs.some(m => /references phase 99\b/.test(m)), + 'hub still flags local "Phase 99" ref'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 9: hub mode W002 — plan-code refs "Phase N-M" skipped +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT9: hub mode — W002 skips plan-code refs (Phase N-M)'); +{ + const { tmpDir, planning } = mkTmpRepo('v1.0'); + writeRoadmap(planning, 'v1.0 Test', ['10']); + writePhaseDir(planning, '10-x'); + fs.writeFileSync(path.join(planning, 'STATE.md'), + `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n` + + `**Current Phase:** 10\n\n` + + `Wave 1 = Phase 27-01/27-02 (cross-repo plan codes — skip).\n` + + `Local follow-up: Phase 99 (bare local ref — flag).\n`); + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w002Msgs = (r.warnings || []).filter(w => w.code === 'W002').map(w => w.message); + assert(!w002Msgs.some(m => /references phase 27\b/.test(m)), + 'hub skips "Phase 27-01" plan-code ref'); + assert(w002Msgs.some(m => /references phase 99\b/.test(m)), + 'hub still flags bare local "Phase 99" ref'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 10: hub mode W006 — archived phase dirs satisfy "on disk" check +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT10: hub mode — W006 accepts archived phase dirs'); +{ + const { tmpDir, planning } = mkTmpRepo('v2.0'); + writeRoadmap(planning, 'v2.0', + ['10', '11', '50'].map(String)); + writePhaseDir(planning, '10-x'); + writePhaseDir(planning, '11-y'); + // Phase 50 is in roadmap but archived (not in phases/ top-level). + fs.mkdirSync(path.join(planning, 'phases', '_archive', 'v1.0', '50-old'), { recursive: true }); + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w006Msgs = (r.warnings || []).filter(w => w.code === 'W006').map(w => w.message); + assert(!w006Msgs.some(m => /Phase 50\b/.test(m)), + 'hub does NOT W006 archived Phase 50'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 8: hub mode W002 — archived phase refs (phases/_archive/) treated as valid +// ───────────────────────────────────────────────────────────────────────────── +console.log('\nT8: hub mode — W002 accepts archived phase refs'); +{ + const { tmpDir, planning } = mkTmpRepo('v2.0'); + writeRoadmap(planning, 'v2.0 Test', ['20']); + writePhaseDir(planning, '20-x'); + // Archive structure: phases/_archive/v1.0/270-old-phase/ + fs.mkdirSync(path.join(planning, 'phases', '_archive', 'v1.0', '270-old-phase'), { recursive: true }); + fs.writeFileSync(path.join(planning, 'STATE.md'), + `---\ngsd_state_version: 1.0\nmilestone: v2.0\nstatus: executing\n---\n\n` + + `**Current Phase:** 20\n\n` + + `Phase 270 was archived 2026-04-26 to phases/_archive/v1.0/.\n`); + writeConfig(planning, { repo_type: 'hub' }); + + const r = runHealth(tmpDir); + const w002Msgs = (r.warnings || []).filter(w => w.code === 'W002').map(w => w.message); + assert(!w002Msgs.some(m => /references phase 270\b/.test(m)), + 'hub treats archived phase 270 as valid (not W002)'); + rmrf(tmpDir); +} + +// ───────────────────────────────────────────────────────────────────────────── +console.log(`\n${passes} passed, ${failures} failed`); +process.exit(failures === 0 ? 0 : 1);