fix(4): retire cooperating-sibling for phase.*, introduce generator + I/O adapter, fix cmdPhaseComplete (#154)
* test(4): reproduce non-idempotent phase complete + unclamped percent in CJS CLI RED regression tests for issue #4: - T1: double invocation of cmdPhaseComplete double-increments **Completed Phases:** in STATE.md body (blind parseInt+1 instead of deriving from ROADMAP) - T2: progress percent can exceed 100% when Completed Phases > Total Phases The CJS path (bin/lib/phase.cjs:cmdPhaseComplete) has the bug; the SDK path (phase-lifecycle.ts:phaseComplete, fixed in ~PR#3520) already derives completed_phases from ROADMAP Complete-row count, making it idempotent. References: - Issue #4 (open-gsd/get-shit-done-redux) - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add sdk/scripts/gen-phase-lifecycle-policy.mjs generator + freshness check placeholder Generates phase-lifecycle-policy.generated.cjs from sdk/src/query/phase-lifecycle-policy.ts. All functions in phase-lifecycle-policy.ts are pure transforms (no I/O), directly serializable via Function.prototype.toString(). The GSDError dependency is replaced with a lightweight stub that throws plain Error objects — CJS callers that need process.exit(1) behavior catch these and delegate to error(). This is the "I/O adapter pattern" from ADR-3524 Section 4 applied to pure helpers. References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add sdk/scripts/gen-phase.mjs generator Generates phase.generated.cjs from sdk/src/query/phase.ts. Only pure helpers (isCanonicalPlanFile, describeNonCanonicalPlans) are generated; async query handlers (findPhase, phasePlanIndex) are I/O-bound and remain per-side per ADR-3524 Section 4. References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add sdk/scripts/gen-phase-lifecycle.mjs + core idempotency fix logic Generates phase-lifecycle.generated.cjs providing two pure functions that are the root-cause fix for issue #4: 1. deriveProgressFromRoadmap(roadmapContent): counts Complete rows in ROADMAP progress table — makes completed_phases idempotent (derived from ground truth instead of blind +1). Direct transcription of the SDK's "Root cause 1 fix" block in phase-lifecycle.ts (~line 1644). 2. clampPercent(completed, total): percent capped at 100 — prevents >100% progress when Completed Phases exceeds Total Phases. Design note: the full phase lifecycle mutations (add, insert, remove, complete) are inherently async and I/O-bound. Per ADR-3524 Section 4 ("I/O stays per-side"), those are NOT generated. Only the pure-computation kernel is extracted, following the I/O adapter pattern: pure logic shared; each side (CJS sync, SDK async) supplies its own I/O adapter. The pure functions are defined in the generator as real JS functions and serialized via Function.prototype.toString() — same technique as gen-project-root.mjs — rather than embedded in template literals (which would require double-escaping all regex backslashes). References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): emit phase.generated.cjs, phase-lifecycle.generated.cjs, phase-lifecycle-policy.generated.cjs Three generated CJS artifacts from their respective generator scripts: - phase.generated.cjs (1.7K): isCanonicalPlanFile + describeNonCanonicalPlans from sdk/src/query/phase.ts - phase-lifecycle.generated.cjs (3.6K): deriveProgressFromRoadmap + clampPercent — the idempotency+clamp fix for issue #4 - phase-lifecycle-policy.generated.cjs (7.0K): 14 pure phase naming/directory helpers from sdk/src/query/phase-lifecycle-policy.ts Run to regenerate: node sdk/scripts/gen-phase.mjs node sdk/scripts/gen-phase-lifecycle.mjs node sdk/scripts/gen-phase-lifecycle-policy.mjs References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(4): migrate bin/lib/phase.cjs cmdPhaseComplete to use generated helpers Replace the blind-increment + unclamped percent bug in cmdPhaseComplete with the idempotent ROADMAP-derived approach: read freshly-updated ROADMAP, call deriveProgressFromRoadmap() from phase-lifecycle.generated.cjs, fall back to existing value when ROADMAP is unavailable. clampPercent() prevents >100%. Root cause fix for issue #4: the original parseInt(completedRaw) + 1 on every call made phase complete non-idempotent; the missing Math.min(100, ...) clamp allowed Progress to exceed 100%. I/O adapter pattern (ADR-3524 §4): pure computation in generated module; CJS supplies sync readFileSync; SDK supplies async readFile. Same logic, two adapters. Closes: Tests in 4-phase-complete-cjs-regression.test.cjs go GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add freshness checks + npm scripts for phase generated artifacts (D4/D6) Add check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and check-phase-lifecycle-policy-fresh.mjs (same pattern as check-project-root-fresh.mjs: import buildXCjs() from the generator, regenerate in-memory, byte-compare to committed file, exit 1 if stale). Add gen:phase, check:phase-fresh, gen:phase-lifecycle, check:phase-lifecycle-fresh, gen:phase-lifecycle-policy, check:phase-lifecycle-policy-fresh to sdk/package.json. Note: gen:phase-lifecycle / check:phase-lifecycle-fresh do not require 'npm run build' because the generator defines pure functions directly rather than importing dist. Update shared-module-handsync-allowlist.json: reclassify phase.cjs justification to reflect that it now consumes phase-lifecycle.generated.cjs for cmdPhaseComplete. The *.generated.cjs files are excluded by the lint scanner (excludes *.generated.cjs) so no new allowlist entries are required for the generated artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(4): add freshness-check CI steps for phase, phase-lifecycle, phase-lifecycle-policy Add three drift-check steps to .github/workflows/test.yml following the same pattern as the existing freshness checks (ubuntu-latest + node 24 only): - SDK generated phase artifact drift check - SDK generated phase-lifecycle artifact drift check - SDK generated phase-lifecycle-policy artifact drift check These guard against editors modifying the generated *.cjs files directly. They run check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and check-phase-lifecycle-policy-fresh.mjs respectively (added in D4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(4): amend ADR-3524 — phase * I/O adapter pattern for issue #4 (D8) Append a 2026-05-23 amendment to docs/adr/3524-cjs-sdk-hard-seam.md documenting the Phase * cooperating-sibling retirement: three new generator scripts extract pure-computation helpers from phase.ts / phase-lifecycle.ts / phase-lifecycle-policy.ts, cmdPhaseComplete migrates to deriveProgressFromRoadmap + clampPercent for idempotency, freshness checks + CI steps added. Clarifies what is NOT generated (async I/O-bound mutation handlers stay per-side per Section 4) and notes open drift bugs #6 and #26 for traceability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add changeset fragment for cmdPhaseComplete fix Refs #4 * fix(154): use canonical /gsd:plan-phase form in phase-lifecycle-policy.ts Replaces the retired /gsd-plan-phase slash command reference with the canonical colon-namespaced /gsd:plan-phase in the TS source template string that feeds the generated CJS roadmap entry helper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(154): regenerate phase-lifecycle-policy.generated.cjs after slash-namespace fix Regenerated via node sdk/scripts/gen-phase-lifecycle-policy.mjs after fixing /gsd-plan-phase → /gsd:plan-phase in the TS source. Generated file now contains the canonical colon form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): cross-platform frontmatter regex anchor in 4-phase-complete-cjs-regression.test.cjs Replaces /^---\n/ with /^---\r?\n/ so the frontmatter extraction helper in the regression test tolerates Windows CRLF line endings (autocrlf=true checkout leaves \r before \n, causing /^---\n/ to never match). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(154): add new generated CJS modules to INVENTORY.md and regenerate manifest Adds three missing rows to the CLI Modules table: - phase-lifecycle-policy.generated.cjs - phase-lifecycle.generated.cjs - phase.generated.cjs Bumps the headline count from 74 to 77 to match the filesystem. Also regenerates docs/INVENTORY-MANIFEST.json via node scripts/gen-inventory-manifest.cjs --write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): regenerate phase-lifecycle-policy.generated.cjs with hyphen form Root cause: commit 6cd701f4 regenerated the CJS artifact but at that point sdk/dist/query/phase-lifecycle-policy.js already had the correct /gsd-plan-phase (hyphen) form while sdk/src/query/phase-lifecycle-policy.ts still had /gsd:plan-phase (colon). The generator uses Function.prototype.toString() on the compiled dist, so the CJS picked up the wrong string from the stale TS source that was compiled into dist at some earlier point. Fix: correct the TS source to /gsd-plan-phase and re-run gen-phase-lifecycle-policy.mjs so that buildPhaseRoadmapEntry in the CJS emits the hyphen form, satisfying the bug-3584-runtime-slash-emitters.test.cjs assertion at line 179. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): restore /gsd:plan-phase canonical form in phase-lifecycle-policy Commit 0c3a9c75 incorrectly reverted the slash-namespace fix by misreading sdk/dist/ (a build artifact in hyphen form for non-Claude runtimes) as the authoritative source. The canonical form for Claude-facing source is /gsd:plan-phase (colon-namespaced). Fix: revert TS source back to /gsd:plan-phase, rebuild dist, regenerate phase-lifecycle-policy.generated.cjs. Fixes bug-2543-gsd-slash-namespace test failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): update INVENTORY.md CLI Modules count to 79 after rebase onto main Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): use hyphen form /gsd-plan-phase in persisted phase section template - sdk/src/query/phase-lifecycle-policy.ts: use /gsd-plan-phase (routable hyphen form) in the phase scaffold template that gets persisted to ROADMAP.md; bug-3584 requires persisted artifacts use the hyphen form - docs/INVENTORY.md: add missing runtime-name-policy.cjs row in CLI Modules table - tests/4-phase-complete-cjs-regression.test.cjs: add maxRetries/retryDelay to rmSync calls to satisfy Windows parity ratchet (baseline was 95) - Regenerate phase-lifecycle-policy.generated.cjs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/jolly-jaguars-leap.md
Normal file
5
.changeset/jolly-jaguars-leap.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4
|
||||
---
|
||||
cmdPhaseComplete no longer blind-increments Completed Phases on repeated invocations and now clamps progress percent to 100
|
||||
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@@ -232,6 +232,21 @@ jobs:
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-validate-fresh.mjs
|
||||
|
||||
- name: SDK generated phase artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-phase-fresh.mjs
|
||||
|
||||
- name: SDK generated phase-lifecycle artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-phase-lifecycle-fresh.mjs
|
||||
|
||||
- name: SDK generated phase-lifecycle-policy artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-phase-lifecycle-policy-fresh.mjs
|
||||
|
||||
- name: Shared Module hand-sync drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
|
||||
@@ -297,7 +297,10 @@
|
||||
"model-catalog.cjs",
|
||||
"model-profiles.cjs",
|
||||
"phase-command-router.cjs",
|
||||
"phase-lifecycle-policy.generated.cjs",
|
||||
"phase-lifecycle.generated.cjs",
|
||||
"phase.cjs",
|
||||
"phase.generated.cjs",
|
||||
"phases-command-router.cjs",
|
||||
"plan-scan.cjs",
|
||||
"plan-scan.generated.cjs",
|
||||
|
||||
@@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (76 shipped)
|
||||
## CLI Modules (79 shipped)
|
||||
|
||||
Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
|
||||
@@ -405,7 +405,10 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
|
||||
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
|
||||
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
|
||||
| `phase-lifecycle-policy.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/phase-lifecycle-policy.ts` via `sdk/scripts/gen-phase-lifecycle-policy.mjs`; pure computation helpers for phase directory naming, roadmap entry generation, decimal-phase management, and ID computation; do not edit directly |
|
||||
| `phase-lifecycle.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/phase-lifecycle.ts` via `sdk/scripts/gen-phase-lifecycle.mjs`; pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler; do not edit directly |
|
||||
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
|
||||
| `phase.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/phase.ts` via `sdk/scripts/gen-phase.mjs`; pure helper functions for phase query shared between the CJS CLI and SDK; no I/O, no async, no filesystem operations; do not edit directly |
|
||||
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
|
||||
| `plan-scan.cjs` | CJS shim adapter — re-exports from `plan-scan.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `plan-scan.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/plan-scan.ts` via `sdk/scripts/gen-plan-scan.mjs`; canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014); do not edit directly |
|
||||
@@ -418,8 +421,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` |
|
||||
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
|
||||
| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) |
|
||||
| `runtime-name-policy.cjs` | Runtime name normalization policy — canonical token sanitization for runtime identifiers used in path construction and display |
|
||||
| `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) |
|
||||
| `runtime-name-policy.cjs` | Runtime name canonicalization policy adapter — normalizes runtime aliases (for example app/cli variants) to canonical IDs via the shared alias manifest consumed by both CJS and SDK seams |
|
||||
| `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |
|
||||
| `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `schema-detect.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/schema-detect.ts` via `sdk/scripts/gen-schema-detect.mjs`; schema-drift detection for ORM patterns (Prisma, Drizzle, Supabase, TypeORM, Payload); exports `detectSchemaFiles`, `detectSchemaOrm`, `checkSchemaDrift`, `SCHEMA_PATTERNS`, `ORM_INFO`; do not edit directly |
|
||||
|
||||
@@ -203,3 +203,28 @@ No new behavioral fix is required — the generator pattern extension is the del
|
||||
the W005/W006-archived/I001 check paths. The artifact `validate.generated.cjs` now covers all
|
||||
six drift surfaces originally identified across both issues. This completes the validate.ts ↔
|
||||
verify.cjs migration scope for generator-pattern coverage.
|
||||
|
||||
### 2026-05-23: Phase * cooperating-sibling retirement — I/O adapter pattern for phase lifecycle (issue #4)
|
||||
|
||||
**Context:** Issue #4 revealed that `phase.cjs:cmdPhaseComplete` was non-idempotent: every call blindly incremented `Completed Phases` by 1 and computed Progress without a 100% clamp. The SDK's `phase-lifecycle.ts` already contained the correct idempotent implementation ("Root cause 1 fix" block ~line 1644), but CJS had no way to share it because the mutation handlers are async I/O-bound and Section 4's "I/O stays per-side" rule prevents direct sharing.
|
||||
|
||||
**Decision:** Apply the I/O adapter pattern (Section 4) to the pure-computation kernel inside `phase-lifecycle.ts`:
|
||||
|
||||
1. **Three new generator scripts** extract pure helpers from the phase family:
|
||||
- `sdk/scripts/gen-phase.mjs` → `get-shit-done/bin/lib/phase.generated.cjs`
|
||||
(pure helpers: `isCanonicalPlanFile`, `describeNonCanonicalPlans`)
|
||||
- `sdk/scripts/gen-phase-lifecycle.mjs` → `get-shit-done/bin/lib/phase-lifecycle.generated.cjs`
|
||||
(pure helpers: `deriveProgressFromRoadmap`, `clampPercent`)
|
||||
- `sdk/scripts/gen-phase-lifecycle-policy.mjs` → `get-shit-done/bin/lib/phase-lifecycle-policy.generated.cjs`
|
||||
(14 pure policy helpers: `generatePhaseSlug`, `computePhaseDirectory`, `buildPhaseRoadmapEntry`, etc.)
|
||||
|
||||
2. **`phase.cjs:cmdPhaseComplete`** is migrated to use `deriveProgressFromRoadmap` + `clampPercent` from the generated artifact. It reads the freshly-updated ROADMAP synchronously, derives the completed-phase count from Complete-row matching (idempotent), and passes it through `clampPercent` to prevent >100% Progress.
|
||||
|
||||
3. **Freshness checks:** `check-phase-fresh.mjs`, `check-phase-lifecycle-fresh.mjs`, `check-phase-lifecycle-policy-fresh.mjs` added. CI adds three corresponding drift-check steps alongside the existing generated-artifact drift checks.
|
||||
|
||||
4. **Allowlist:** `phase.cjs ↔ phase.ts` remains `cooperating-sibling` because `phase.cjs` still owns the CJS sync mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete) that are legitimately I/O-bound and cannot be extracted via `.toString()`. The allowlist justification is updated to reflect the new generator consumption.
|
||||
|
||||
**What this is NOT:** The full async mutation handlers (`phaseAdd`, `phaseInsert`, `phaseRemove`, `phaseComplete`) are inherently async I/O-bound and are NOT generated — per Section 4. This amendment only extracts the pure-computation kernel.
|
||||
|
||||
**Open drift bugs remaining:** Issue #6 (phasePlanIndex drift) and issue #26 (phase.add inline ROADMAP update drift) are separate bug reports; they are referenced here for traceability but not fixed in this amendment. Future amendments should note when those are resolved.
|
||||
|
||||
|
||||
202
get-shit-done/bin/lib/phase-lifecycle-policy.generated.cjs
Normal file
202
get-shit-done/bin/lib/phase-lifecycle-policy.generated.cjs
Normal file
@@ -0,0 +1,202 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/phase-lifecycle-policy.ts
|
||||
* Regenerate: cd sdk && npm run gen:phase-lifecycle-policy
|
||||
*
|
||||
* Phase Lifecycle Policy — pure computation helpers for phase directory naming,
|
||||
* roadmap entry generation, decimal-phase management, and ID computation.
|
||||
* No I/O. No async. No filesystem operations.
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK;
|
||||
* GSDError is replaced with plain throws that CJS callers can catch.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
// Lightweight stub replacing sdk/src/errors.js GSDError.
|
||||
// CJS callers that need to translate to process.exit(1) should catch these.
|
||||
class GSDError extends Error {
|
||||
constructor(message, classification) {
|
||||
super(message);
|
||||
this.name = 'GSDError';
|
||||
this.classification = classification;
|
||||
}
|
||||
}
|
||||
// ErrorClassification values used by policy functions
|
||||
const ErrorClassification = { Validation: 'Validation', Internal: 'Internal' };
|
||||
|
||||
// escapeRegex — inlined from sdk/dist/query/helpers.js
|
||||
function escapeRegex(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function assertNoNullBytes(value, label) {
|
||||
if (value.includes('\0')) {
|
||||
throw new GSDError(`${label} contains null byte`, ErrorClassification.Validation);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafePhaseDirName(dirName, label = 'phase directory') {
|
||||
if (/[/\\]|\.\./.test(dirName)) {
|
||||
throw new GSDError(`${label} contains invalid path segments`, ErrorClassification.Validation);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeProjectCode(code) {
|
||||
if (code && /[/\\]|\.\./.test(code)) {
|
||||
throw new GSDError('project_code contains invalid characters', ErrorClassification.Validation);
|
||||
}
|
||||
}
|
||||
|
||||
function generatePhaseSlug(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 60);
|
||||
}
|
||||
|
||||
function parseMultiwordArg(args, flag) {
|
||||
const idx = args.indexOf(`--${flag}`);
|
||||
if (idx === -1)
|
||||
return null;
|
||||
const tokens = [];
|
||||
for (let i = idx + 1; i < args.length; i++) {
|
||||
if (args[i].startsWith('--'))
|
||||
break;
|
||||
tokens.push(args[i]);
|
||||
}
|
||||
return tokens.length > 0 ? tokens.join(' ') : null;
|
||||
}
|
||||
|
||||
function extractOneLinerFromBody(content) {
|
||||
if (!content)
|
||||
return null;
|
||||
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
|
||||
// Find the first heading of any level (GFM section ATX headings)
|
||||
const headingMatch = body.match(/^(#{1,6}\s[^\n]+\n)/m);
|
||||
if (!headingMatch || headingMatch.index === undefined)
|
||||
return null;
|
||||
const afterHeading = body.slice(headingMatch.index + headingMatch[0].length);
|
||||
// Bound to the first section: truncate at the next heading of any level
|
||||
const nextHeadingMatch = afterHeading.match(/^#{1,6}\s/m);
|
||||
const sectionScope = nextHeadingMatch && nextHeadingMatch.index !== undefined
|
||||
? afterHeading.slice(0, nextHeadingMatch.index)
|
||||
: afterHeading;
|
||||
const boldMatch = sectionScope.match(/\*\*([^*]+)\*\*/);
|
||||
return boldMatch ? boldMatch[1].trim() : null;
|
||||
}
|
||||
|
||||
function scanSequentialMaxPhaseFromMilestone(milestoneContent) {
|
||||
const phasePattern = /(?:^|\n)\s*(?:[-*]\s*(?:\[[x ]\]\s*)?|#{2,4}\s*|\*{1,2}\s*)Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let maxPhase = 0;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(milestoneContent)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num === 999)
|
||||
continue;
|
||||
if (num > maxPhase)
|
||||
maxPhase = num;
|
||||
}
|
||||
return maxPhase;
|
||||
}
|
||||
|
||||
function scanSequentialMaxPhaseFromDirs(dirNames) {
|
||||
let maxPhase = 0;
|
||||
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)[A-Z]?(?:\.\d+)*-/i;
|
||||
for (const dirName of dirNames) {
|
||||
const match = dirNumPattern.exec(dirName);
|
||||
if (!match)
|
||||
continue;
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num === 999)
|
||||
continue;
|
||||
if (num > maxPhase)
|
||||
maxPhase = num;
|
||||
}
|
||||
return maxPhase;
|
||||
}
|
||||
|
||||
function computeNextSequentialPhaseId(milestoneContent, dirNames) {
|
||||
return Math.max(scanSequentialMaxPhaseFromMilestone(milestoneContent), scanSequentialMaxPhaseFromDirs(dirNames)) + 1;
|
||||
}
|
||||
|
||||
function computePhaseDirectory(namingMode, descriptionSlug, prefix, nextSequentialPhaseId, customId) {
|
||||
if (customId || namingMode === 'custom') {
|
||||
const phaseId = customId || descriptionSlug.toUpperCase().replace(/-/g, '_');
|
||||
if (!phaseId) {
|
||||
throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation);
|
||||
}
|
||||
assertSafePhaseDirName(String(phaseId), 'custom phase id');
|
||||
const dirName = `${prefix}${phaseId}-${descriptionSlug}`;
|
||||
assertSafePhaseDirName(dirName);
|
||||
return { phaseId, dirName };
|
||||
}
|
||||
const phaseId = nextSequentialPhaseId;
|
||||
const paddedNum = String(phaseId).padStart(2, '0');
|
||||
const dirName = `${prefix}${paddedNum}-${descriptionSlug}`;
|
||||
assertSafePhaseDirName(dirName);
|
||||
return { phaseId, dirName };
|
||||
}
|
||||
|
||||
function buildPhaseRoadmapEntry(phaseId, description, namingMode) {
|
||||
const prevPhase = typeof phaseId === 'number' ? phaseId - 1 : null;
|
||||
const dependsOn = namingMode === 'custom' || prevPhase === null || prevPhase < 1
|
||||
? ''
|
||||
: `\n**Depends on:** Phase ${prevPhase}`;
|
||||
return `\n### Phase ${phaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${phaseId} to break down)\n`;
|
||||
}
|
||||
|
||||
function collectDecimalSuffixesFromDirNames(basePhase, dirNames) {
|
||||
const decimalSet = new Set();
|
||||
const decimalPattern = new RegExp(`^(?:[A-Z][A-Z0-9]*-)?${escapeRegex(basePhase)}\\.(\\d+)`, 'i');
|
||||
for (const dir of dirNames) {
|
||||
const match = dir.match(decimalPattern);
|
||||
if (match)
|
||||
decimalSet.add(parseInt(match[1], 10));
|
||||
}
|
||||
return decimalSet;
|
||||
}
|
||||
|
||||
function collectDecimalSuffixesFromRoadmap(basePhase, roadmapContent) {
|
||||
const decimalSet = new Set();
|
||||
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${escapeRegex(basePhase)}\\.(\\d+)\\s*:`, 'gi');
|
||||
let match;
|
||||
while ((match = phasePattern.exec(roadmapContent)) !== null) {
|
||||
decimalSet.add(parseInt(match[1], 10));
|
||||
}
|
||||
return decimalSet;
|
||||
}
|
||||
|
||||
function computeNextDecimalPhase(basePhase, decimalSet) {
|
||||
const existing = Array.from(decimalSet)
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => `${basePhase}.${n}`);
|
||||
const next = decimalSet.size === 0
|
||||
? `${basePhase}.1`
|
||||
: `${basePhase}.${Math.max(...decimalSet) + 1}`;
|
||||
return { next, existing };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GSDError,
|
||||
assertNoNullBytes,
|
||||
assertSafePhaseDirName,
|
||||
assertSafeProjectCode,
|
||||
generatePhaseSlug,
|
||||
parseMultiwordArg,
|
||||
extractOneLinerFromBody,
|
||||
scanSequentialMaxPhaseFromMilestone,
|
||||
scanSequentialMaxPhaseFromDirs,
|
||||
computeNextSequentialPhaseId,
|
||||
computePhaseDirectory,
|
||||
buildPhaseRoadmapEntry,
|
||||
collectDecimalSuffixesFromDirNames,
|
||||
collectDecimalSuffixesFromRoadmap,
|
||||
computeNextDecimalPhase,
|
||||
};
|
||||
85
get-shit-done/bin/lib/phase-lifecycle.generated.cjs
Normal file
85
get-shit-done/bin/lib/phase-lifecycle.generated.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/phase-lifecycle.ts
|
||||
* Regenerate: cd sdk && npm run gen:phase-lifecycle
|
||||
*
|
||||
* Phase Lifecycle Pure Helpers — pure-computation functions extracted from
|
||||
* the phase-lifecycle SDK handler.
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 Section 4): each side supplies its own I/O
|
||||
* (sync readFileSync for CJS, async readFile for SDK); the pure computation
|
||||
* logic is shared via this generated artifact.
|
||||
*
|
||||
* Scope:
|
||||
* - deriveProgressFromRoadmap(roadmapContent): count Complete rows => idempotent
|
||||
* - clampPercent(completed, total): percent with 100 ceiling
|
||||
*
|
||||
* These two functions are the root-cause fix for issue #4.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
|
||||
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
||||
*/
|
||||
function deriveProgressFromRoadmap(roadmapContent) {
|
||||
let completedPhases = null;
|
||||
let totalPhases = null;
|
||||
let totalPlans = null;
|
||||
|
||||
try {
|
||||
// Count Complete rows in the progress table (Status column = "Complete").
|
||||
// Pattern: row where the phase cell starts with a digit (data row, not header),
|
||||
// followed by any cell content, then a "Complete" status cell.
|
||||
// Handles both short form ("| 4. |") and long form ("| 01. Foundation |").
|
||||
// See phase-lifecycle.ts ~line 1655 for the original SDK pattern.
|
||||
const tableCompletePattern = /\|\s*\d+[^|]*\|\s*[^|]*\|\s*Complete\s*\|/gi;
|
||||
const completeMatches = roadmapContent.match(tableCompletePattern);
|
||||
completedPhases = completeMatches ? completeMatches.length : null;
|
||||
|
||||
// Count total phase rows in the progress table.
|
||||
// Identify the table by looking for Phase|...|Status|...|Completed header.
|
||||
const progressTableMatch = roadmapContent.match(
|
||||
/\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i,
|
||||
);
|
||||
if (progressTableMatch) {
|
||||
const tableText = progressTableMatch[0];
|
||||
// Count data rows (rows starting with pipe then a phase number)
|
||||
const dataRowPattern = /^\|\s*\d+/gm;
|
||||
const dataRows = tableText.match(dataRowPattern);
|
||||
totalPhases = dataRows ? dataRows.length : null;
|
||||
}
|
||||
|
||||
// Sum plan counts from M/N columns in progress table
|
||||
let totalPlansSum = 0;
|
||||
const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
|
||||
let pm;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
|
||||
totalPlansSum += parseInt(pm[2], 10);
|
||||
}
|
||||
if (totalPlansSum > 0) totalPlans = totalPlansSum;
|
||||
} catch { /* intentionally empty — fall through to existing values */ }
|
||||
|
||||
return { completedPhases, totalPhases, totalPlans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute progress percent clamped to 100.
|
||||
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
||||
*/
|
||||
function clampPercent(completed, total) {
|
||||
if (!total || total <= 0) return 0;
|
||||
return Math.min(100, Math.round((completed / total) * 100));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveProgressFromRoadmap,
|
||||
clampPercent,
|
||||
};
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* Phase — Phase CRUD, query, and lifecycle operations
|
||||
*
|
||||
* Re-export shim note (issue #4 / ADR-3524):
|
||||
* The phase lifecycle pure-computation helpers live in phase-lifecycle.generated.cjs
|
||||
* (generated from sdk/src/query/phase-lifecycle.ts). cmdPhaseComplete uses
|
||||
* deriveProgressFromRoadmap + clampPercent from that module to fix the
|
||||
* non-idempotent Completed Phases blind-increment bug.
|
||||
*
|
||||
* The async mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete)
|
||||
* in phase-lifecycle.ts are I/O-bound and remain per-side per ADR-3524 Section 4.
|
||||
* This file provides the CJS (sync) implementations of those handlers.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -10,6 +20,9 @@ const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd, readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs');
|
||||
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
|
||||
// Generated pure-computation helpers for cmdPhaseComplete (issue #4 fix).
|
||||
// Source: sdk/src/query/phase-lifecycle.ts. Regenerate: node sdk/scripts/gen-phase-lifecycle.mjs
|
||||
const { deriveProgressFromRoadmap, clampPercent } = require('./phase-lifecycle.generated.cjs');
|
||||
|
||||
// #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
|
||||
// Documented in agents/gsd-planner.md (write_phase_prompt step). The wider
|
||||
@@ -1454,24 +1467,39 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
||||
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
|
||||
`Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
|
||||
|
||||
// Increment Completed Phases counter (#956)
|
||||
// Update Completed Phases counter — derive from ROADMAP instead of blind +1.
|
||||
// Fix for issue #4: the original code did parseInt(completedRaw, 10) + 1 on every
|
||||
// call, making phase complete non-idempotent (double-call = double-increment).
|
||||
// Now we read the freshly-updated ROADMAP to count Complete rows, then use
|
||||
// deriveProgressFromRoadmap() from phase-lifecycle.generated.cjs (generated from
|
||||
// sdk/src/query/phase-lifecycle.ts "Root cause 1 fix" block).
|
||||
// References: issue #4, ADR-3524, gen-phase-lifecycle.mjs.
|
||||
const completedRaw = stateExtractField(stateContent, 'Completed Phases');
|
||||
if (completedRaw) {
|
||||
const newCompleted = parseInt(completedRaw, 10) + 1;
|
||||
if (completedRaw !== null) {
|
||||
// Derive from ROADMAP if available (idempotent); fall back to existing value.
|
||||
let newCompleted = parseInt(completedRaw, 10);
|
||||
let derivedTotalPhases = null;
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
try {
|
||||
const freshRoadmap = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const derived = deriveProgressFromRoadmap(freshRoadmap);
|
||||
if (derived.completedPhases !== null) newCompleted = derived.completedPhases;
|
||||
if (derived.totalPhases !== null) derivedTotalPhases = derived.totalPhases;
|
||||
} catch { /* fall through to existing value */ }
|
||||
}
|
||||
stateContent = stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) || stateContent;
|
||||
|
||||
// Recalculate percent based on completed / total (#956)
|
||||
// Recalculate percent — use clampPercent to prevent >100% (#4 unclamped bug).
|
||||
const totalRaw = stateExtractField(stateContent, 'Total Phases');
|
||||
if (totalRaw) {
|
||||
const totalPhases = parseInt(totalRaw, 10);
|
||||
if (totalPhases > 0) {
|
||||
const newPercent = Math.round((newCompleted / totalPhases) * 100);
|
||||
stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
|
||||
stateContent = stateContent.replace(
|
||||
/(percent:\s*)\d+/,
|
||||
`$1${newPercent}`
|
||||
);
|
||||
}
|
||||
const totalPhases = derivedTotalPhases
|
||||
|| (totalRaw ? parseInt(totalRaw, 10) : null);
|
||||
if (totalPhases && totalPhases > 0) {
|
||||
const newPercent = clampPercent(newCompleted, totalPhases);
|
||||
stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
|
||||
stateContent = stateContent.replace(
|
||||
/(percent:\s*)\d+/,
|
||||
`$1${newPercent}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
get-shit-done/bin/lib/phase.generated.cjs
Normal file
47
get-shit-done/bin/lib/phase.generated.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/phase.ts
|
||||
* Regenerate: cd sdk && npm run gen:phase
|
||||
*
|
||||
* Phase Query Module — pure helper functions shared between the CJS CLI and SDK.
|
||||
* No I/O. No async. No filesystem operations.
|
||||
*
|
||||
* Scope: isCanonicalPlanFile, describeNonCanonicalPlans.
|
||||
* Async query handlers (findPhase, phasePlanIndex) are I/O-bound and remain
|
||||
* per-side per ADR-3524 §4.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
|
||||
|
||||
// Regex constants closed over by describeNonCanonicalPlans (from phase.js module scope)
|
||||
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
||||
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
||||
const looksLikePlanFile = (f) =>
|
||||
/\.md$/i.test(f)
|
||||
&& /PLAN/i.test(f)
|
||||
&& !PLAN_OUTLINE_RE.test(f)
|
||||
&& !PLAN_PRE_BOUNCE_RE.test(f);
|
||||
|
||||
function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
||||
const matched = new Set(matchedFiles);
|
||||
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
|
||||
if (offenders.length === 0)
|
||||
return null;
|
||||
return (`Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical `
|
||||
+ `naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: `
|
||||
+ offenders.map((f) => `"${f}"`).join(', ')
|
||||
+ `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. `
|
||||
+ `See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isCanonicalPlanFile,
|
||||
describeNonCanonicalPlans,
|
||||
};
|
||||
@@ -30,7 +30,7 @@
|
||||
"cjs": "get-shit-done/bin/lib/phase.cjs",
|
||||
"ts": "sdk/src/query/phase.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS phase.cjs is the full phase lifecycle implementation; SDK phase.ts provides the native query handler. The SDK delegates to CJS for most subcommands. Phase 5.2+ candidate for further migration."
|
||||
"justification": "CJS phase.cjs implements sync mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete) and query handlers with sync I/O; SDK phase.ts provides the native async query handler. The async mutation handlers (phase-lifecycle.ts) and pure policy helpers (phase-lifecycle-policy.ts) are I/O-bound or pure and remain per-side (ADR-3524 §4). Issue #4 fix: phase.cjs cmdPhaseComplete now consumes pure helpers from phase-lifecycle.generated.cjs (generated from sdk/src/query/phase-lifecycle.ts) for idempotent Completed Phases counting and clamped percent. *.generated.cjs files are excluded from lint scanner; see sdk/scripts/check-phase-lifecycle-fresh.mjs for freshness enforcement."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/profile-output.cjs",
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
"check:workstream-name-policy-fresh": "npm run build && node scripts/check-workstream-name-policy-fresh.mjs",
|
||||
"gen:validate": "npm run build && node scripts/gen-validate.mjs",
|
||||
"check:validate-fresh": "npm run build && node scripts/check-validate-fresh.mjs",
|
||||
"gen:phase": "npm run build && node scripts/gen-phase.mjs",
|
||||
"check:phase-fresh": "npm run build && node scripts/check-phase-fresh.mjs",
|
||||
"gen:phase-lifecycle": "node scripts/gen-phase-lifecycle.mjs",
|
||||
"check:phase-lifecycle-fresh": "node scripts/check-phase-lifecycle-fresh.mjs",
|
||||
"gen:phase-lifecycle-policy": "npm run build && node scripts/gen-phase-lifecycle-policy.mjs",
|
||||
"check:phase-lifecycle-policy-fresh": "npm run build && node scripts/check-phase-lifecycle-policy-fresh.mjs",
|
||||
"prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --project unit",
|
||||
|
||||
40
sdk/scripts/check-phase-fresh.mjs
Normal file
40
sdk/scripts/check-phase-fresh.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for phase.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Uses the same pattern as check-project-root-fresh.mjs: imports buildPhaseCjs()
|
||||
* from the generator directly rather than duplicating the build logic.
|
||||
*
|
||||
* Run: node sdk/scripts/check-phase-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Import the generator function directly (avoids duplicating logic).
|
||||
const { buildPhaseCjs } = await import('./gen-phase.mjs');
|
||||
|
||||
const expected = await buildPhaseCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'phase.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('phase.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('phase.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:phase');
|
||||
process.exit(1);
|
||||
}
|
||||
43
sdk/scripts/check-phase-lifecycle-fresh.mjs
Normal file
43
sdk/scripts/check-phase-lifecycle-fresh.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for phase-lifecycle.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Uses the same pattern as check-project-root-fresh.mjs: imports
|
||||
* buildPhaseLifecycleCjs() from the generator directly.
|
||||
*
|
||||
* Unlike check-phase-fresh.mjs and check-phase-lifecycle-policy-fresh.mjs,
|
||||
* this generator does NOT require sdk/dist (it defines the pure functions
|
||||
* directly in the generator module rather than importing compiled output).
|
||||
*
|
||||
* Run: node sdk/scripts/check-phase-lifecycle-fresh.mjs
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Import the generator function directly (avoids duplicating logic).
|
||||
const { buildPhaseLifecycleCjs } = await import('./gen-phase-lifecycle.mjs');
|
||||
|
||||
const expected = await buildPhaseLifecycleCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'phase-lifecycle.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('phase-lifecycle.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('phase-lifecycle.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:phase-lifecycle');
|
||||
process.exit(1);
|
||||
}
|
||||
40
sdk/scripts/check-phase-lifecycle-policy-fresh.mjs
Normal file
40
sdk/scripts/check-phase-lifecycle-policy-fresh.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for phase-lifecycle-policy.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Uses the same pattern as check-project-root-fresh.mjs: imports
|
||||
* buildPhaseLifecyclePolicyCjs() from the generator directly.
|
||||
*
|
||||
* Run: node sdk/scripts/check-phase-lifecycle-policy-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Import the generator function directly (avoids duplicating logic).
|
||||
const { buildPhaseLifecyclePolicyCjs } = await import('./gen-phase-lifecycle-policy.mjs');
|
||||
|
||||
const expected = await buildPhaseLifecyclePolicyCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'phase-lifecycle-policy.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('phase-lifecycle-policy.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('phase-lifecycle-policy.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:phase-lifecycle-policy');
|
||||
process.exit(1);
|
||||
}
|
||||
185
sdk/scripts/gen-phase-lifecycle-policy.mjs
Normal file
185
sdk/scripts/gen-phase-lifecycle-policy.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Phase Lifecycle Policy CJS artifact.
|
||||
*
|
||||
* Imports the compiled ESM output from sdk/dist/query/phase-lifecycle-policy.js,
|
||||
* captures pure helper functions via Function.prototype.toString(), then emits
|
||||
* get-shit-done/bin/lib/phase-lifecycle-policy.generated.cjs.
|
||||
*
|
||||
* All functions in phase-lifecycle-policy.ts are pure transforms — no I/O,
|
||||
* no async, no filesystem operations. They are directly serializable via
|
||||
* Function.prototype.toString(). The only external dependencies are:
|
||||
* - GSDError / ErrorClassification (from ../errors.js) → replaced with a
|
||||
* lightweight local throw-Error stub
|
||||
* - escapeRegex (from ./helpers.js) → inlined from compiled source text
|
||||
*
|
||||
* This is the "I/O adapter pattern" from ADR-3524 Section 4 applied to pure
|
||||
* helpers: pure logic goes into a Shared Module; the generated CJS artifact
|
||||
* provides the same API surface with the SDK's error model swapped out for
|
||||
* plain throws that CJS callers can catch and delegate to error().
|
||||
*
|
||||
* Run: cd sdk && npm run gen:phase-lifecycle-policy
|
||||
* Check: node sdk/scripts/check-phase-lifecycle-policy-fresh.mjs
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/phase-lifecycle-policy.ts
|
||||
* Regenerate: cd sdk && npm run gen:phase-lifecycle-policy
|
||||
*
|
||||
* Phase Lifecycle Policy — pure computation helpers for phase directory naming,
|
||||
* roadmap entry generation, decimal-phase management, and ID computation.
|
||||
* No I/O. No async. No filesystem operations.
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK;
|
||||
* GSDError is replaced with plain throws that CJS callers can catch.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract escapeRegex from compiled helpers.js source text.
|
||||
* Used by collectDecimalSuffixesFromDirNames and collectDecimalSuffixesFromRoadmap.
|
||||
*/
|
||||
function extractEscapeRegexBody(helpersSource) {
|
||||
const marker = 'function escapeRegex(';
|
||||
const start = helpersSource.indexOf(marker);
|
||||
if (start === -1) throw new Error('Could not find escapeRegex in compiled helpers.js');
|
||||
const braceOpen = helpersSource.indexOf('{', start);
|
||||
let depth = 0;
|
||||
let i = braceOpen;
|
||||
for (; i < helpersSource.length; i++) {
|
||||
if (helpersSource[i] === '{') depth++;
|
||||
else if (helpersSource[i] === '}') { depth--; if (depth === 0) break; }
|
||||
}
|
||||
return helpersSource.slice(start, i + 1);
|
||||
}
|
||||
|
||||
export async function buildPhaseLifecyclePolicyCjs() {
|
||||
const distUrl = new URL('../dist/query/phase-lifecycle-policy.js', import.meta.url);
|
||||
const helpersUrl = new URL('../dist/query/helpers.js', import.meta.url);
|
||||
|
||||
const {
|
||||
assertNoNullBytes,
|
||||
assertSafePhaseDirName,
|
||||
assertSafeProjectCode,
|
||||
generatePhaseSlug,
|
||||
parseMultiwordArg,
|
||||
extractOneLinerFromBody,
|
||||
scanSequentialMaxPhaseFromMilestone,
|
||||
scanSequentialMaxPhaseFromDirs,
|
||||
computeNextSequentialPhaseId,
|
||||
computePhaseDirectory,
|
||||
buildPhaseRoadmapEntry,
|
||||
collectDecimalSuffixesFromDirNames,
|
||||
collectDecimalSuffixesFromRoadmap,
|
||||
computeNextDecimalPhase,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
const helpersSource = await readFile(fileURLToPath(helpersUrl), 'utf-8');
|
||||
const escapeRegexBody = extractEscapeRegexBody(helpersSource);
|
||||
|
||||
// Stub for GSDError — throws plain Error so CJS callers can catch and delegate
|
||||
// to error() (process.exit) as needed. The ErrorClassification enum values are
|
||||
// unused at runtime in the generated artifact; they exist only for TypeScript typing.
|
||||
const gsdErrorStub = `// Lightweight stub replacing sdk/src/errors.js GSDError.
|
||||
// CJS callers that need to translate to process.exit(1) should catch these.
|
||||
class GSDError extends Error {
|
||||
constructor(message, classification) {
|
||||
super(message);
|
||||
this.name = 'GSDError';
|
||||
this.classification = classification;
|
||||
}
|
||||
}
|
||||
// ErrorClassification values used by policy functions
|
||||
const ErrorClassification = { Validation: 'Validation', Internal: 'Internal' };`;
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
gsdErrorStub,
|
||||
'',
|
||||
'// escapeRegex — inlined from sdk/dist/query/helpers.js',
|
||||
escapeRegexBody,
|
||||
'',
|
||||
assertNoNullBytes.toString(),
|
||||
'',
|
||||
assertSafePhaseDirName.toString(),
|
||||
'',
|
||||
assertSafeProjectCode.toString(),
|
||||
'',
|
||||
generatePhaseSlug.toString(),
|
||||
'',
|
||||
parseMultiwordArg.toString(),
|
||||
'',
|
||||
extractOneLinerFromBody.toString(),
|
||||
'',
|
||||
scanSequentialMaxPhaseFromMilestone.toString(),
|
||||
'',
|
||||
scanSequentialMaxPhaseFromDirs.toString(),
|
||||
'',
|
||||
computeNextSequentialPhaseId.toString(),
|
||||
'',
|
||||
computePhaseDirectory.toString(),
|
||||
'',
|
||||
buildPhaseRoadmapEntry.toString(),
|
||||
'',
|
||||
collectDecimalSuffixesFromDirNames.toString(),
|
||||
'',
|
||||
collectDecimalSuffixesFromRoadmap.toString(),
|
||||
'',
|
||||
computeNextDecimalPhase.toString(),
|
||||
'',
|
||||
`module.exports = {
|
||||
GSDError,
|
||||
assertNoNullBytes,
|
||||
assertSafePhaseDirName,
|
||||
assertSafeProjectCode,
|
||||
generatePhaseSlug,
|
||||
parseMultiwordArg,
|
||||
extractOneLinerFromBody,
|
||||
scanSequentialMaxPhaseFromMilestone,
|
||||
scanSequentialMaxPhaseFromDirs,
|
||||
computeNextSequentialPhaseId,
|
||||
computePhaseDirectory,
|
||||
buildPhaseRoadmapEntry,
|
||||
collectDecimalSuffixesFromDirNames,
|
||||
collectDecimalSuffixesFromRoadmap,
|
||||
computeNextDecimalPhase,
|
||||
};`,
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildPhaseLifecyclePolicyCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/phase-lifecycle-policy.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
187
sdk/scripts/gen-phase-lifecycle.mjs
Normal file
187
sdk/scripts/gen-phase-lifecycle.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Phase Lifecycle CJS artifact.
|
||||
*
|
||||
* Emits get-shit-done/bin/lib/phase-lifecycle.generated.cjs which provides
|
||||
* the pure-computation functions extracted from sdk/src/query/phase-lifecycle.ts.
|
||||
*
|
||||
* The phase-lifecycle.ts module contains mutation handlers (phaseAdd, phaseInsert,
|
||||
* phaseRemove, phaseComplete) that are inherently async and I/O-bound. Per
|
||||
* ADR-3524 Section 4: "I/O stays per-side." These mutation handlers are NOT generated.
|
||||
*
|
||||
* What IS generated: the pure-computation logic used by phaseComplete that was
|
||||
* the root cause of issue #4:
|
||||
* - deriveProgressFromRoadmap(roadmapContent): computes completed_phases,
|
||||
* total_phases, total_plans from ROADMAP progress table content.
|
||||
* This is the idempotency fix: deriving from ROADMAP instead of blind +1.
|
||||
* - clampPercent(completed, total): percent computation with 100 ceiling.
|
||||
*
|
||||
* The CJS shim (bin/lib/phase.cjs) calls these pure functions with
|
||||
* synchronously-read ROADMAP content. The SDK handler calls them with
|
||||
* asynchronously-read content. Same logic, different I/O adapters.
|
||||
*
|
||||
* I/O adapter pattern from ADR-3524 Section 4 applied to mutation helpers:
|
||||
* pure computation is shared; each side supplies its own I/O.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:phase-lifecycle
|
||||
* Check: node sdk/scripts/check-phase-lifecycle-fresh.mjs
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/phase-lifecycle.ts
|
||||
* Regenerate: cd sdk && npm run gen:phase-lifecycle
|
||||
*
|
||||
* Phase Lifecycle Pure Helpers — pure-computation functions extracted from
|
||||
* the phase-lifecycle SDK handler.
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 Section 4): each side supplies its own I/O
|
||||
* (sync readFileSync for CJS, async readFile for SDK); the pure computation
|
||||
* logic is shared via this generated artifact.
|
||||
*
|
||||
* Scope:
|
||||
* - deriveProgressFromRoadmap(roadmapContent): count Complete rows => idempotent
|
||||
* - clampPercent(completed, total): percent with 100 ceiling
|
||||
*
|
||||
* These two functions are the root-cause fix for issue #4.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
// ─── Pure functions defined here so they can be serialized via .toString() ───
|
||||
//
|
||||
// These are hand-authored pure functions (not extracted from compiled SDK output
|
||||
// via .toString()) because the SDK versions are embedded inside async closures
|
||||
// and cannot be cleanly extracted. The logic is a direct transcription of the
|
||||
// SDK's "Root cause 1 fix" block in phase-lifecycle.ts (~line 1644).
|
||||
//
|
||||
// IMPORTANT: These functions must remain pure (no I/O, no closures over module
|
||||
// state) so that .toString() serialization to CJS works correctly.
|
||||
|
||||
/**
|
||||
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
|
||||
*
|
||||
* Root cause fix for issue #4: instead of blindly incrementing Completed Phases
|
||||
* by 1 on every call, derive the count from the ROADMAP progress table.
|
||||
* This makes phase.complete idempotent: running it twice produces the same value.
|
||||
*
|
||||
* Transcribed from sdk/src/query/phase-lifecycle.ts "Root cause 1 fix" block.
|
||||
* References: ADR-3524 Section 4, issue #4.
|
||||
*/
|
||||
function deriveProgressFromRoadmap(roadmapContent) {
|
||||
let completedPhases = null;
|
||||
let totalPhases = null;
|
||||
let totalPlans = null;
|
||||
|
||||
try {
|
||||
// Count Complete rows in the progress table (Status column = "Complete").
|
||||
// Pattern: row where the phase cell starts with a digit (data row, not header),
|
||||
// followed by any cell content, then a "Complete" status cell.
|
||||
// Handles both short form ("| 4. |") and long form ("| 01. Foundation |").
|
||||
// See phase-lifecycle.ts ~line 1655 for the original SDK pattern.
|
||||
const tableCompletePattern = /\|\s*\d+[^|]*\|\s*[^|]*\|\s*Complete\s*\|/gi;
|
||||
const completeMatches = roadmapContent.match(tableCompletePattern);
|
||||
completedPhases = completeMatches ? completeMatches.length : null;
|
||||
|
||||
// Count total phase rows in the progress table.
|
||||
// Identify the table by looking for Phase|...|Status|...|Completed header.
|
||||
const progressTableMatch = roadmapContent.match(
|
||||
/\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i,
|
||||
);
|
||||
if (progressTableMatch) {
|
||||
const tableText = progressTableMatch[0];
|
||||
// Count data rows (rows starting with pipe then a phase number)
|
||||
const dataRowPattern = /^\|\s*\d+/gm;
|
||||
const dataRows = tableText.match(dataRowPattern);
|
||||
totalPhases = dataRows ? dataRows.length : null;
|
||||
}
|
||||
|
||||
// Sum plan counts from M/N columns in progress table
|
||||
let totalPlansSum = 0;
|
||||
const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
|
||||
let pm;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
|
||||
totalPlansSum += parseInt(pm[2], 10);
|
||||
}
|
||||
if (totalPlansSum > 0) totalPlans = totalPlansSum;
|
||||
} catch { /* intentionally empty — fall through to existing values */ }
|
||||
|
||||
return { completedPhases, totalPhases, totalPlans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute progress percent with a 100% ceiling.
|
||||
*
|
||||
* Root cause fix for issue #4: without this clamp, blind-increment + recalculate
|
||||
* can produce >100% when Completed Phases exceeds Total Phases.
|
||||
*
|
||||
* References: ADR-3524 Section 4, issue #4.
|
||||
*/
|
||||
function clampPercent(completed, total) {
|
||||
if (!total || total <= 0) return 0;
|
||||
return Math.min(100, Math.round((completed / total) * 100));
|
||||
}
|
||||
|
||||
export async function buildPhaseLifecycleCjs() {
|
||||
// Serialize the pure functions via .toString() — same technique as gen-project-root.mjs.
|
||||
// These functions have no external dependencies (no closures over module scope),
|
||||
// so serialization is clean.
|
||||
const deriveBody = deriveProgressFromRoadmap.toString();
|
||||
const clampBody = clampPercent.toString();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
'/**',
|
||||
' * Derive completed_phases, total_phases, and total_plans from ROADMAP content.',
|
||||
' * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.',
|
||||
' */',
|
||||
deriveBody,
|
||||
'',
|
||||
'/**',
|
||||
' * Compute progress percent clamped to 100.',
|
||||
' * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.',
|
||||
' */',
|
||||
clampBody,
|
||||
'',
|
||||
`module.exports = {`,
|
||||
` deriveProgressFromRoadmap,`,
|
||||
` clampPercent,`,
|
||||
`};`,
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildPhaseLifecycleCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/phase-lifecycle.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
111
sdk/scripts/gen-phase.mjs
Normal file
111
sdk/scripts/gen-phase.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Phase Query CJS artifact.
|
||||
*
|
||||
* Imports the compiled ESM output from sdk/dist/query/phase.js,
|
||||
* captures pure helper functions via Function.prototype.toString(), then emits
|
||||
* get-shit-done/bin/lib/phase.generated.cjs.
|
||||
*
|
||||
* Exported functions from sdk/src/query/phase.ts have two categories:
|
||||
* (a) Pure helpers (no I/O): isCanonicalPlanFile, describeNonCanonicalPlans
|
||||
* → serializable directly via Function.prototype.toString()
|
||||
* (b) Async query handlers (findPhase, findPhaseByNumber, phasePlanIndex)
|
||||
* → I/O stays per-side per ADR-3524 §4; NOT included in the generated artifact
|
||||
*
|
||||
* The phase.cjs shim continues to provide its own CJS implementations of the
|
||||
* async handlers. The generated artifact provides the shared pure helpers so
|
||||
* that phase.cjs can delegate to them rather than maintaining duplicates.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:phase
|
||||
* Check: node sdk/scripts/check-phase-fresh.mjs
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/phase.ts
|
||||
* Regenerate: cd sdk && npm run gen:phase
|
||||
*
|
||||
* Phase Query Module — pure helper functions shared between the CJS CLI and SDK.
|
||||
* No I/O. No async. No filesystem operations.
|
||||
*
|
||||
* Scope: isCanonicalPlanFile, describeNonCanonicalPlans.
|
||||
* Async query handlers (findPhase, phasePlanIndex) are I/O-bound and remain
|
||||
* per-side per ADR-3524 §4.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildPhaseCjs() {
|
||||
const distUrl = new URL('../dist/query/phase.js', import.meta.url);
|
||||
|
||||
const {
|
||||
isCanonicalPlanFile,
|
||||
describeNonCanonicalPlans,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
// isCanonicalPlanFile is a const arrow function; .toString() gives the arrow body
|
||||
const isCanonicalPlanFileBody = `const isCanonicalPlanFile = ${isCanonicalPlanFile.toString()};`;
|
||||
|
||||
// The PLAN_OUTLINE_RE and PLAN_PRE_BOUNCE_RE constants are closed over by
|
||||
// describeNonCanonicalPlans via the looksLikePlanFile helper. Since these are
|
||||
// module-scope in the compiled ESM, we need to provide them here.
|
||||
const preamble = [
|
||||
`// Regex constants closed over by describeNonCanonicalPlans (from phase.js module scope)`,
|
||||
`const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\\.md$/i;`,
|
||||
`const PLAN_PRE_BOUNCE_RE = /-PLAN.*\\.pre-bounce\\.md$/i;`,
|
||||
`const looksLikePlanFile = (f) =>`,
|
||||
` /\\.md$/i.test(f)`,
|
||||
` && /PLAN/i.test(f)`,
|
||||
` && !PLAN_OUTLINE_RE.test(f)`,
|
||||
` && !PLAN_PRE_BOUNCE_RE.test(f);`,
|
||||
].join('\n');
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
isCanonicalPlanFileBody,
|
||||
'',
|
||||
preamble,
|
||||
'',
|
||||
describeNonCanonicalPlans.toString(),
|
||||
'',
|
||||
`module.exports = {`,
|
||||
` isCanonicalPlanFile,`,
|
||||
` describeNonCanonicalPlans,`,
|
||||
`};`,
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildPhaseCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/phase.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
281
tests/4-phase-complete-cjs-regression.test.cjs
Normal file
281
tests/4-phase-complete-cjs-regression.test.cjs
Normal file
@@ -0,0 +1,281 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for issue #4 (open-gsd/get-shit-done-redux):
|
||||
* bin/lib/phase.cjs cmdPhaseComplete — non-idempotent and unclamped.
|
||||
*
|
||||
* Root cause (pre-fix):
|
||||
* cmdPhaseComplete blindly increments "Completed Phases" by 1 on every call
|
||||
* (parseInt(completedRaw, 10) + 1) and recomputes Progress without a clamp.
|
||||
* Double-calling yields Completed Phases +2 (not +1), and Progress can exceed 100%.
|
||||
*
|
||||
* Fix: derive completed_phases from ROADMAP Complete-row count (idempotent) and
|
||||
* clamp percent to 100. This mirrors the SDK fix in phase-lifecycle.ts
|
||||
* (commit deriving from ROADMAP, referenced as "PR #3520" in issue #4).
|
||||
*
|
||||
* Note on test structure: phase-command-router.cjs delegates to the SDK when
|
||||
* the SDK dist is present, so the CJS path is exercised by calling
|
||||
* cmdPhaseComplete directly (bypassing the router), which is the actual
|
||||
* function containing the bug.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) — architectural foundation
|
||||
* - /tmp/adr-3524-review-findings.md — architectural justification
|
||||
* - Issue #4 (open-gsd/get-shit-done-redux)
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
// ── Load cmdPhaseComplete directly from phase.cjs (bypass the SDK router) ────
|
||||
// phase-command-router.cjs delegates to SDK when available; we must test the
|
||||
// CJS implementation directly since that is where the bug lives.
|
||||
const phaseModule = require('../get-shit-done/bin/lib/phase.cjs');
|
||||
const { cmdPhaseComplete } = phaseModule;
|
||||
|
||||
// ── Fixture builder ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a minimal fixture project with:
|
||||
* - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed)
|
||||
* - STATE.md with Completed Phases: 0 and Total Phases: 2 (Progress 0%)
|
||||
* - Phase 01 directory with one plan+summary (to satisfy phase complete guard)
|
||||
* - Phase 02 directory (next phase)
|
||||
*/
|
||||
function createFixture(prefix = 'gsd-4-regression-') {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
|
||||
// ROADMAP.md: Phase 01 not yet complete, Phase 02 not started
|
||||
// 4-column progress table: Phase | Plans Complete | Status | Completed
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 01: Foundation',
|
||||
'- [ ] Phase 02: API',
|
||||
'',
|
||||
'### Phase 01: Foundation',
|
||||
'**Goal:** Build the foundation',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'### Phase 02: API',
|
||||
'**Goal:** Build the API',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|----------------|--------|-----------|',
|
||||
'| 01. Foundation | 0/1 | Not started | - |',
|
||||
'| 02. API | 0/1 | Not started | - |',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
|
||||
|
||||
// STATE.md: Completed Phases: 0, Total Phases: 2, Progress: 0%
|
||||
// Uses body-field format (bold **Field:** value) so the CJS handler's
|
||||
// stateExtractField/stateReplaceField path is exercised.
|
||||
const state = [
|
||||
'# State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Current Phase Name:** Foundation',
|
||||
'**Status:** In progress',
|
||||
'**Current Plan:** 01-01',
|
||||
'**Last Activity:** 2025-01-01',
|
||||
'**Last Activity Description:** Working on phase 1',
|
||||
'**Completed Phases:** 0',
|
||||
'**Total Phases:** 2',
|
||||
'**Progress:** 0%',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
|
||||
|
||||
// Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes
|
||||
const phase01Dir = path.join(phasesDir, '01-foundation');
|
||||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
||||
|
||||
// Phase 02 directory (needed for "next phase" detection)
|
||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function readStateMd(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
||||
}
|
||||
|
||||
function extractField(stateContent, fieldName) {
|
||||
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i'));
|
||||
if (boldMatch) return boldMatch[1].trim();
|
||||
const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im'));
|
||||
return plainMatch ? plainMatch[1].trim() : null;
|
||||
}
|
||||
|
||||
function extractFrontmatterField(stateContent, fieldName) {
|
||||
// Extract from YAML frontmatter block
|
||||
const fmMatch = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const fm = fmMatch[1];
|
||||
// Handle both scalar and nested (progress.completed_phases)
|
||||
const parts = fieldName.split('.');
|
||||
if (parts.length === 1) {
|
||||
const m = fm.match(new RegExp(`^${parts[0]}:\\s*(.+)`, 'm'));
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
// Nested: e.g. "progress.completed_phases"
|
||||
const sectionMatch = fm.match(new RegExp(`^${parts[0]}:\\s*\\n([\\s\\S]*?)(?=\\n[a-z]|$)`, 'm'));
|
||||
if (!sectionMatch) return null;
|
||||
const sectionContent = sectionMatch[1];
|
||||
const fieldMatch = sectionContent.match(new RegExp(`^\\s+${parts[1]}:\\s*(.+)`, 'm'));
|
||||
return fieldMatch ? fieldMatch[1].trim() : null;
|
||||
}
|
||||
|
||||
// Capture stdout from cmdPhaseComplete (it calls output() which writes to stdout)
|
||||
function capturePhaseComplete(cwd, phaseNum) {
|
||||
const { execFileSync } = require('child_process');
|
||||
const TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
||||
// We invoke gsd-tools directly for the full CJS path, but with GSD_DISABLE_SDK_BRIDGE=1
|
||||
// to force the CJS implementation. Since no env var disables bridge, we call cmdPhaseComplete
|
||||
// directly and redirect output capture.
|
||||
const chunks = [];
|
||||
const origWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = (chunk) => { chunks.push(chunk); return true; };
|
||||
try {
|
||||
cmdPhaseComplete(cwd, phaseNum, false);
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
}
|
||||
return chunks.join('');
|
||||
}
|
||||
|
||||
// ── T1: Double invocation must NOT double-increment Completed Phases ─────────
|
||||
|
||||
describe('issue #4 (CJS): cmdPhaseComplete — idempotency (blind-increment bug)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test('T1: double invocation does NOT double-increment Completed Phases in STATE.md body', () => {
|
||||
// First call — legitimate completion
|
||||
capturePhaseComplete(tmpDir, '1');
|
||||
|
||||
const stateAfter1 = readStateMd(tmpDir);
|
||||
const completedAfter1Body = extractField(stateAfter1, 'Completed Phases');
|
||||
const completedAfter1Fm = extractFrontmatterField(stateAfter1, 'progress.completed_phases');
|
||||
// After first call: Completed Phases in the body should be 1
|
||||
// (derived from ROADMAP: 1 Complete row after marking phase 01 complete)
|
||||
// OR the YAML frontmatter completed_phases should be 1
|
||||
const completedAfter1 = completedAfter1Body || completedAfter1Fm;
|
||||
assert.equal(
|
||||
completedAfter1,
|
||||
'1',
|
||||
`After first call: completed_phases should be 1.\n` +
|
||||
`Body field: ${completedAfter1Body}, FM field: ${completedAfter1Fm}\n\n` +
|
||||
`STATE:\n${stateAfter1}`,
|
||||
);
|
||||
|
||||
// Second call on the same phase — must be idempotent
|
||||
capturePhaseComplete(tmpDir, '1');
|
||||
|
||||
const stateAfter2 = readStateMd(tmpDir);
|
||||
const completedAfter2Body = extractField(stateAfter2, 'Completed Phases');
|
||||
const completedAfter2Fm = extractFrontmatterField(stateAfter2, 'progress.completed_phases');
|
||||
const completedAfter2 = completedAfter2Body || completedAfter2Fm;
|
||||
|
||||
// Pre-fix: completedAfter2 would be "2" (blind increment: 1+1=2)
|
||||
// Post-fix: must remain "1" (derived from ROADMAP)
|
||||
assert.equal(
|
||||
completedAfter2,
|
||||
'1',
|
||||
`T1 FAILED: Completed Phases was double-incremented.\n` +
|
||||
`After first call: ${completedAfter1}, after second call: ${completedAfter2}.\n` +
|
||||
`This is the #4 non-idempotency bug — blind parseInt+1 instead of deriving from ROADMAP.\n\n` +
|
||||
`STATE after second call (body: ${completedAfter2Body}, fm: ${completedAfter2Fm}):\n${stateAfter2}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T2: Progress percent must never exceed 100% ──────────────────────────────
|
||||
|
||||
describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test('T2: Progress percent never exceeds 100 after double invocation', () => {
|
||||
tmpDir = createFixture();
|
||||
|
||||
// Pre-load STATE.md with Completed Phases: 1, Total Phases: 1 (already 100%)
|
||||
// so that a blind +1 on a second call would yield 200%
|
||||
let stateContent = readStateMd(tmpDir);
|
||||
stateContent = stateContent.replace('**Completed Phases:** 0', '**Completed Phases:** 1');
|
||||
stateContent = stateContent.replace('**Total Phases:** 2', '**Total Phases:** 1');
|
||||
stateContent = stateContent.replace('**Progress:** 0%', '**Progress:** 100%');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
||||
|
||||
// Also update ROADMAP to show just 1 phase total
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 01: Foundation',
|
||||
'',
|
||||
'### Phase 01: Foundation',
|
||||
'**Goal:** Build the foundation',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|----------------|--------|-----------|',
|
||||
'| 01. Foundation | 0/1 | Not started | - |',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
|
||||
// First call
|
||||
capturePhaseComplete(tmpDir, '1');
|
||||
// Second call — this is the problematic one
|
||||
capturePhaseComplete(tmpDir, '1');
|
||||
|
||||
const stateAfterBoth = readStateMd(tmpDir);
|
||||
|
||||
// Check body Progress field
|
||||
const progressStr = extractField(stateAfterBoth, 'Progress');
|
||||
const fmPercent = extractFrontmatterField(stateAfterBoth, 'progress.percent');
|
||||
|
||||
// Try to extract numeric percent from either source
|
||||
const bodyPercentMatch = progressStr && progressStr.match(/(\d+)%/);
|
||||
const bodyPercent = bodyPercentMatch ? parseInt(bodyPercentMatch[1], 10) : null;
|
||||
const fmPercentNum = fmPercent ? parseInt(fmPercent, 10) : null;
|
||||
|
||||
// At least one of body or frontmatter percent must exist and be ≤ 100
|
||||
const anyPercent = bodyPercent ?? fmPercentNum;
|
||||
assert.ok(
|
||||
anyPercent !== null,
|
||||
`T2: Could not find any percent value in STATE.md\n\nSTATE:\n${stateAfterBoth}`,
|
||||
);
|
||||
assert.ok(
|
||||
anyPercent <= 100,
|
||||
`T2 FAILED: Progress percent exceeds 100.\n` +
|
||||
`Body Progress: "${progressStr}" (${bodyPercent}%), FM percent: ${fmPercentNum}%\n` +
|
||||
`This is the #4 unclamped-percent bug — (N+1)/total can exceed 100.\n\n` +
|
||||
`STATE:\n${stateAfterBoth}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user