feat(3530): STATE.md Document Module via generator (Phase 1 of #3524) (#3531)

* feat(3530): STATE.md Document Module via generator (Phase 1 of #3524)

Phase 1 of the CJS↔SDK hard-seam migration (parent #3524).
Converts the hand-synced state-document.cjs/state-document.ts pair
into a generator-driven seam, modeled on the existing
command-aliases.generated.* precedent.

What landed:
- sdk/src/query/state-document.ts is the source of truth.
- sdk/scripts/gen-state-document.ts emits
  get-shit-done/bin/lib/state-document.generated.cjs from the
  compiled SDK dist via Function.prototype.toString() inspection
  for the 7 public exports and 3 internal helpers.
- sdk/scripts/check-state-document-fresh.mjs is the CI freshness
  gate; pre-commit hook also runs it when relevant files change.
- get-shit-done/bin/lib/state-document.cjs is reduced to a one-line
  re-export from state-document.generated.cjs so existing callers
  (state.cjs, workstream-inventory.cjs, init.cjs) need no changes.
- New CI step in .github/workflows/test.yml after the existing alias
  drift check.
- sdk/package.json: gen:state-document, check:state-document-fresh
  scripts. tsx added as devDep.
- Root package.json: proxy script for the freshness check.
- CONTEXT.md: one-sentence amendment on STATE.md Document Module
  recording the source-of-truth file path.

Tests:
- sdk/src/query/state-document.test.ts: 34 vitest fixtures across
  the 7 public exports (TDD pinning safety net).
- tests/state-document-generator.test.cjs: 31 node:test parity
  assertions comparing SDK source vs generated CJS for every
  fixture.
- Full suite: 9177/9177 pass (baseline was 9146; +31 new tests).

One subtle behavior change worth flagging: the old hand-written
state-document.cjs used String(str) coercion inside escapeRegex,
which the SDK source does not. The generator faithfully matches
the SDK (the source of truth per ADR-3524), so the new CJS no
longer coerces non-string input to string before regex-escaping.
No current caller passes non-string input, so no observable
regression in the test suite. Flagged in the PR body for
reviewers.

Closes #3530.

* fix(3530): address state-document review findings
This commit is contained in:
Tom Boucher
2026-05-14 22:17:20 -04:00
committed by GitHub
parent e437ded6fc
commit bfd7ddbad3
15 changed files with 1371 additions and 133 deletions

View File

@@ -4,3 +4,7 @@ set -euo pipefail
if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.generated\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then
npm run check:alias-drift
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^get-shit-done/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then
npm run check:state-document-fresh
fi

View File

@@ -103,6 +103,11 @@ jobs:
shell: bash
run: node sdk/scripts/check-command-aliases-fresh.mjs
- name: SDK generated state-document artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-state-document-fresh.mjs
- name: Run tests with coverage
shell: bash
run: npm run test:coverage

View File

@@ -26,7 +26,7 @@ Adapter Module that satisfies native query dispatch at the Dispatch Policy seam,
Module owning projection from dispatch results/errors to CLI `{ exitCode, stdoutChunks, stderrLines }` output contract.
### STATE.md Document Module
Shared CJS/SDK pure transform Module owning STATE.md parse, field extraction, field replacement, status normalization, and frontmatter reconstruction. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and CJS/SDK read-modify-write paths remain Adapters.
Shared CJS/SDK pure transform Module owning STATE.md parse, field extraction, field replacement, status normalization, and frontmatter reconstruction. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and CJS/SDK read-modify-write paths remain Adapters. Source of truth: `sdk/src/query/state-document.ts`; CJS callers consume the generator-emitted `get-shit-done/bin/lib/state-document.generated.cjs` via the thin re-export at `get-shit-done/bin/lib/state-document.cjs`.
### Query Execution Policy Module
Module owning query transport routing policy projection (`preferNative`, fallback policy, workstream subprocess forcing) at execution seam.

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-05-13",
"generated": "2026-05-14",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -306,6 +306,7 @@
"shell-command-projection.cjs",
"state-command-router.cjs",
"state-document.cjs",
"state-document.generated.cjs",
"state.cjs",
"surface.cjs",
"template.cjs",

View File

@@ -360,7 +360,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (59 shipped)
## CLI Modules (60 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -415,6 +415,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` |
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
| `state-document.cjs` | Pure STATE.md field extraction, replacement, status normalization, and progress calculation transforms |
| `state-document.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/state-document.ts` via `sdk/scripts/gen-state-document.ts`; do not edit directly |
| `surface.cjs` | Runtime surface module — manages the runtime enable/disable surface state independently of the install-time profile marker (ADR-0011 Phase 2) |
| `template.cjs` | Template selection and filling with variable substitution |
| `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support |

View File

@@ -1,122 +1,12 @@
'use strict';
/**
* STATE.md Document Module
* STATE.md Document Module — CJS adapter.
*
* Pure transforms for STATE.md text. This module does not read the filesystem
* and does not own persistence or locking.
* The implementation is generated from sdk/src/query/state-document.ts and
* lives in state-document.generated.cjs. This file is a thin re-export so
* that existing call sites (state.cjs, workstream-inventory.cjs, init.cjs,
* and tests) can continue to require('./state-document') unchanged.
*/
function escapeRegex(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stateExtractField(content, fieldName) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
function stateReplaceField(content, fieldName, newValue) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
}
return null;
}
function stateReplaceFieldWithFallback(content, primary, fallback, value) {
let result = stateReplaceField(content, primary, value);
if (result) return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result) return result;
}
return content;
}
function normalizeStateStatus(status, pausedAt) {
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
} else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
} else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
} else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
} else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
} else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
} else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
return normalizedStatus;
}
function computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases) {
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData) return null;
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
function toFiniteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function existingProgressExceedsDerived(existingProgress, derivedProgress, key) {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
function shouldPreserveExistingProgress(existingProgress, derivedProgress) {
if (!existingProgress || typeof existingProgress !== 'object') return false;
if (!derivedProgress || typeof derivedProgress !== 'object') return false;
return (
existingProgressExceedsDerived(existingProgress, derivedProgress, 'total_phases') ||
existingProgressExceedsDerived(existingProgress, derivedProgress, 'completed_phases') ||
existingProgressExceedsDerived(existingProgress, derivedProgress, 'total_plans') ||
existingProgressExceedsDerived(existingProgress, derivedProgress, 'completed_plans')
);
}
function normalizeProgressNumbers(progress) {
if (!progress || typeof progress !== 'object') return progress;
const normalized = { ...progress };
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
const number = toFiniteNumber(normalized[key]);
if (number !== null) normalized[key] = number;
}
return normalized;
}
module.exports = {
computeProgressPercent,
normalizeProgressNumbers,
normalizeStateStatus,
shouldPreserveExistingProgress,
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
};
module.exports = require('./state-document.generated.cjs');

View File

@@ -0,0 +1,127 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/state-document.ts
* Regenerate: cd sdk && npm run gen:state-document
*
* STATE.md Document Module — pure transforms for STATE.md text.
* This module does not read the filesystem and does not own persistence or locking.
*/
// Internal helpers
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function toFiniteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function existingProgressExceedsDerived(existingProgress, derivedProgress, key) {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
function stateExtractField(content, fieldName) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch)
return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
function stateReplaceField(content, fieldName, newValue) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
}
return null;
}
function stateReplaceFieldWithFallback(content, primary, fallback, value) {
let result = stateReplaceField(content, primary, value);
if (result)
return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result)
return result;
}
return content;
}
function normalizeStateStatus(status, pausedAt) {
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
}
else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
}
else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
}
else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
}
else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
}
else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
}
else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
return normalizedStatus;
}
function computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases) {
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData)
return null;
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
function shouldPreserveExistingProgress(existingProgress, derivedProgress) {
if (!existingProgress || typeof existingProgress !== 'object')
return false;
if (!derivedProgress || typeof derivedProgress !== 'object')
return false;
const existing = existingProgress;
const derived = derivedProgress;
return (existingProgressExceedsDerived(existing, derived, 'total_phases') ||
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
existingProgressExceedsDerived(existing, derived, 'completed_plans'));
}
function normalizeProgressNumbers(progress) {
if (!progress || typeof progress !== 'object')
return progress;
const normalized = { ...progress };
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
const number = toFiniteNumber(normalized[key]);
if (number !== null)
normalized[key] = number;
}
return normalized;
}
module.exports = { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, normalizeStateStatus, computeProgressPercent, shouldPreserveExistingProgress, normalizeProgressNumbers };

12
package-lock.json generated
View File

@@ -86,9 +86,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -102,9 +99,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -118,9 +112,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -134,9 +125,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [

View File

@@ -61,6 +61,7 @@
"build:hooks": "node scripts/build-hooks.js",
"build:sdk": "cd sdk && npm ci && npm run build",
"check:alias-drift": "cd sdk && npm run check:alias-drift",
"check:state-document-fresh": "cd sdk && npm run check:state-document-fresh",
"prepublishOnly": "npm run build:hooks && npm run build:sdk",
"pretest": "npm run build:sdk && npm run lint:skill-deps",
"pretest:coverage": "npm run build:sdk",

508
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@gsd-build/sdk",
"version": "1.39.0-rc.4",
"version": "1.50.0-canary.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gsd-build/sdk",
"version": "1.39.0-rc.4",
"version": "1.50.0-canary.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
@@ -18,6 +18,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.18.1",
"tsx": "^4.22.0",
"typescript": "^5.7.0",
"vitest": "^3.1.1"
},
@@ -1754,6 +1755,509 @@
"node": ">=14.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -36,6 +36,8 @@
"scripts": {
"build": "tsc",
"check:alias-drift": "npm run build && node scripts/check-command-aliases-fresh.mjs",
"gen:state-document": "npm run build && npx tsx scripts/gen-state-document.ts",
"check:state-document-fresh": "npm run build && node scripts/check-state-document-fresh.mjs",
"prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js",
"test": "vitest run",
"test:unit": "vitest run --project unit",
@@ -48,6 +50,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.18.1",
"tsx": "^4.22.0",
"typescript": "^5.7.0",
"vitest": "^3.1.1"
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Freshness check for state-document.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.
*
* Run: node sdk/scripts/check-state-document-fresh.mjs
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/state-document.ts
* Regenerate: cd sdk && npm run gen:state-document
*
* STATE.md Document Module — pure transforms for STATE.md text.
* This module does not read the filesystem and does not own persistence or locking.
*/`;
/**
* Extract a top-level function declaration (non-exported) from a JS source
* string by scanning for `function <name>(` and capturing the entire body
* including balanced braces.
*/
function extractFunctionFromSource(source, name) {
const marker = `function ${name}(`;
const start = source.indexOf(marker);
if (start === -1) throw new Error(`Could not find function ${name} in compiled source`);
const braceOpen = source.indexOf('{', start);
if (braceOpen === -1) throw new Error(`Could not find opening brace for function ${name}`);
let depth = 0;
let i = braceOpen;
for (; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') { depth--; if (depth === 0) break; }
}
if (depth !== 0) {
throw new Error(`Could not find closing brace for function ${name}`);
}
return source.slice(start, i + 1);
}
const distUrl = new URL('../dist/query/state-document.js', import.meta.url);
const {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
normalizeStateStatus,
computeProgressPercent,
shouldPreserveExistingProgress,
normalizeProgressNumbers,
} = await import(distUrl.href);
const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8');
const escapeRegexBody = extractFunctionFromSource(compiledSource, 'escapeRegex');
const toFiniteNumberBody = extractFunctionFromSource(compiledSource, 'toFiniteNumber');
const existingProgressExceedsDerivedBody = extractFunctionFromSource(compiledSource, 'existingProgressExceedsDerived');
const stateExtractFieldBody = stateExtractField.toString();
const stateReplaceFieldBody = stateReplaceField.toString();
const stateReplaceFieldWithFallbackBody = stateReplaceFieldWithFallback.toString();
const normalizeStateStatusBody = normalizeStateStatus.toString();
const computeProgressPercentBody = computeProgressPercent.toString();
const shouldPreserveExistingProgressBody = shouldPreserveExistingProgress.toString();
const normalizeProgressNumbersBody = normalizeProgressNumbers.toString();
const expected = [
BANNER,
'',
'// Internal helpers',
escapeRegexBody,
'',
toFiniteNumberBody,
'',
existingProgressExceedsDerivedBody,
'',
stateExtractFieldBody,
'',
stateReplaceFieldBody,
'',
stateReplaceFieldWithFallbackBody,
'',
normalizeStateStatusBody,
'',
computeProgressPercentBody,
'',
shouldPreserveExistingProgressBody,
'',
normalizeProgressNumbersBody,
'',
'module.exports = { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, normalizeStateStatus, computeProgressPercent, shouldPreserveExistingProgress, normalizeProgressNumbers };',
'',
].join('\n');
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'state-document.generated.cjs');
const committed = await readFile(committedPath, 'utf-8');
if (expected === committed) {
console.log('state-document.generated.cjs is fresh');
process.exit(0);
} else {
console.error('state-document.generated.cjs is STALE.');
console.error('Regenerate: cd sdk && npm run gen:state-document');
process.exit(1);
}

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Generator for the STATE.md Document Module CJS artifact.
*
* Reads the compiled ESM output from sdk/dist/query/state-document.js,
* extracts function source via Function.prototype.toString() for exports
* and via source-text extraction for internal helpers, then emits
* get-shit-done/bin/lib/state-document.generated.cjs.
*
* Run: cd sdk && npx tsx scripts/gen-state-document.ts
* Freshness check: node sdk/scripts/check-state-document-fresh.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/state-document.ts
* Regenerate: cd sdk && npm run gen:state-document
*
* STATE.md Document Module — pure transforms for STATE.md text.
* This module does not read the filesystem and does not own persistence or locking.
*/
`;
/**
* Extract a top-level function declaration (non-exported) from a JS source
* string by scanning for `function <name>(` and capturing the entire body
* including balanced braces.
*/
function extractFunctionFromSource(source: string, name: string): string {
const marker = `function ${name}(`;
const start = source.indexOf(marker);
if (start === -1) {
throw new Error(`Could not find function ${name} in compiled source`);
}
// Find the opening brace
const braceOpen = source.indexOf('{', start);
if (braceOpen === -1) {
throw new Error(`Could not find opening brace for function ${name}`);
}
// Walk forward counting braces until balanced
let depth = 0;
let i = braceOpen;
for (; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) break;
}
}
if (depth !== 0) {
throw new Error(`Could not find closing brace for function ${name}`);
}
// Return from `function name(` through the closing `}`
return source.slice(start, i + 1);
}
export async function buildStateDocumentCjs(): Promise<string> {
// Load the compiled ESM module to get exports via Function.prototype.toString()
const distUrl = new URL('../dist/query/state-document.js', import.meta.url);
const {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
normalizeStateStatus,
computeProgressPercent,
shouldPreserveExistingProgress,
normalizeProgressNumbers,
} = await import(distUrl.href);
// Also read the compiled JS as text to extract non-exported helpers
const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8');
// Extract non-exported helpers from source text
const escapeRegexBody = extractFunctionFromSource(compiledSource, 'escapeRegex');
const toFiniteNumberBody = extractFunctionFromSource(compiledSource, 'toFiniteNumber');
const existingProgressExceedsDerivedBody = extractFunctionFromSource(compiledSource, 'existingProgressExceedsDerived');
// Get exported function bodies via Function.prototype.toString()
const stateExtractFieldBody = stateExtractField.toString();
const stateReplaceFieldBody = stateReplaceField.toString();
const stateReplaceFieldWithFallbackBody = stateReplaceFieldWithFallback.toString();
const normalizeStateStatusBody = normalizeStateStatus.toString();
const computeProgressPercentBody = computeProgressPercent.toString();
const shouldPreserveExistingProgressBody = shouldPreserveExistingProgress.toString();
const normalizeProgressNumbersBody = normalizeProgressNumbers.toString();
const parts: string[] = [
BANNER.trimEnd(),
'',
'// Internal helpers',
escapeRegexBody,
'',
toFiniteNumberBody,
'',
existingProgressExceedsDerivedBody,
'',
stateExtractFieldBody,
'',
stateReplaceFieldBody,
'',
stateReplaceFieldWithFallbackBody,
'',
normalizeStateStatusBody,
'',
computeProgressPercentBody,
'',
shouldPreserveExistingProgressBody,
'',
normalizeProgressNumbersBody,
'',
'module.exports = { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, normalizeStateStatus, computeProgressPercent, shouldPreserveExistingProgress, normalizeProgressNumbers };',
'',
];
return parts.join('\n');
}
async function main(): Promise<void> {
const content = await buildStateDocumentCjs();
const outPath = fileURLToPath(
new URL('../../get-shit-done/bin/lib/state-document.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, not when imported.
const scriptPath = fileURLToPath(import.meta.url);
const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : '';
if (scriptPath === entryPath || process.argv[1] === scriptPath) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,197 @@
/**
* Unit tests for STATE.md Document Module — stateExtractField.
*/
import { describe, it, expect } from 'vitest';
import {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
normalizeStateStatus,
computeProgressPercent,
shouldPreserveExistingProgress,
normalizeProgressNumbers,
} from './state-document.js';
describe('stateExtractField', () => {
it('extracts value from bold pattern', () => {
const content = 'Some content\n**FieldName:** the value\nMore content';
expect(stateExtractField(content, 'FieldName')).toBe('the value');
});
it('extracts value from plain pattern', () => {
const content = 'Some content\nFieldName: the value\nMore content';
expect(stateExtractField(content, 'FieldName')).toBe('the value');
});
it('returns null when field is missing', () => {
const content = 'Some content\nOtherField: something\nMore content';
expect(stateExtractField(content, 'FieldName')).toBeNull();
});
});
describe('stateReplaceField', () => {
it('replaces value in bold pattern', () => {
const content = 'Some content\n**Status:** old value\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBe('Some content\n**Status:** new value\nMore content');
});
it('replaces value in plain pattern', () => {
const content = 'Some content\nStatus: old value\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBe('Some content\nStatus: new value\nMore content');
});
it('returns null when field is missing', () => {
const content = 'Some content\nOtherField: something\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBeNull();
});
});
describe('stateReplaceFieldWithFallback', () => {
it('replaces primary field when it exists', () => {
const content = 'Status: old\nState: backup';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe('Status: new\nState: backup');
});
it('replaces fallback field when primary is missing', () => {
const content = 'Other: something\nState: backup';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe('Other: something\nState: new');
});
it('returns content unchanged when neither field exists', () => {
const content = 'Other: something\nAnother: value';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe(content);
});
});
describe('normalizeStateStatus', () => {
it('returns paused for status containing "paused"', () => {
expect(normalizeStateStatus('paused')).toBe('paused');
});
it('returns paused for status containing "stopped"', () => {
expect(normalizeStateStatus('stopped')).toBe('paused');
});
it('returns paused when non-null pausedAt is provided', () => {
expect(normalizeStateStatus('active', '2024-01-01')).toBe('paused');
});
it('returns executing for status containing "executing"', () => {
expect(normalizeStateStatus('executing')).toBe('executing');
});
it('returns executing for status "in progress"', () => {
expect(normalizeStateStatus('in progress')).toBe('executing');
});
it('returns executing for status "ready to execute"', () => {
expect(normalizeStateStatus('ready to execute')).toBe('executing');
});
it('returns planning for status containing "planning"', () => {
expect(normalizeStateStatus('planning')).toBe('planning');
});
it('returns discussing for status containing "discussing"', () => {
expect(normalizeStateStatus('discussing')).toBe('discussing');
});
it('returns verifying for status containing "verif"', () => {
expect(normalizeStateStatus('verifying')).toBe('verifying');
});
it('returns completed for status containing "complete"', () => {
expect(normalizeStateStatus('completed')).toBe('completed');
});
it('returns completed for status containing "done"', () => {
expect(normalizeStateStatus('done')).toBe('completed');
});
it('returns unknown for unrecognized status', () => {
expect(normalizeStateStatus('something-else')).toBe('something-else');
});
it('returns unknown for null status', () => {
expect(normalizeStateStatus(null)).toBe('unknown');
});
});
describe('computeProgressPercent', () => {
it('uses only plans data when phases data is absent', () => {
expect(computeProgressPercent(3, 10, null, null)).toBe(30);
});
it('uses only phases data when plans data is absent', () => {
expect(computeProgressPercent(null, null, 2, 4)).toBe(50);
});
it('uses minimum fraction when both plans and phases data are present', () => {
// plans: 8/10 = 80%, phases: 3/10 = 30% → min = 30%
expect(computeProgressPercent(8, 10, 3, 10)).toBe(30);
});
it('returns null when neither plans nor phases data is present', () => {
expect(computeProgressPercent(null, null, null, null)).toBeNull();
});
it('returns null when total is 0 (treated as no data)', () => {
expect(computeProgressPercent(0, 0, null, null)).toBeNull();
});
});
describe('shouldPreserveExistingProgress', () => {
it('returns true when existing total_phases exceeds derived', () => {
expect(shouldPreserveExistingProgress({ total_phases: 10 }, { total_phases: 5 })).toBe(true);
});
it('returns false when derived exceeds existing', () => {
expect(shouldPreserveExistingProgress({ total_phases: 5 }, { total_phases: 10 })).toBe(false);
});
it('returns false when existingProgress is not an object', () => {
expect(shouldPreserveExistingProgress(null, { total_phases: 5 })).toBe(false);
});
it('returns false when both are null', () => {
expect(shouldPreserveExistingProgress(null, null)).toBe(false);
});
});
describe('normalizeProgressNumbers', () => {
it('coerces all five tracked keys to numbers', () => {
const input = {
total_phases: '10',
completed_phases: '3',
total_plans: '5',
completed_plans: '2',
percent: '60',
};
expect(normalizeProgressNumbers(input)).toEqual({
total_phases: 10,
completed_phases: 3,
total_plans: 5,
completed_plans: 2,
percent: 60,
});
});
it('returns non-object input unchanged', () => {
expect(normalizeProgressNumbers(null)).toBeNull();
expect(normalizeProgressNumbers('string')).toBe('string');
});
it('preserves extra keys untouched', () => {
const input = { total_phases: '4', extra_key: 'hello' };
const result = normalizeProgressNumbers(input) as Record<string, unknown>;
expect(result.total_phases).toBe(4);
expect(result.extra_key).toBe('hello');
});
});

View File

@@ -0,0 +1,261 @@
'use strict';
/**
* Parity test — verifies that state-document.generated.cjs produces identical
* results to the compiled SDK ESM output for all exported functions.
*
* SDK side: require('../sdk/dist/query/state-document.js') via createRequire
* CJS side: require('../get-shit-done/bin/lib/state-document.generated.cjs')
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
// The SDK dist is ESM; wrap with createRequire targeting the project root so
// Node resolves the path correctly from this CJS context.
const requireFromRoot = createRequire(__filename);
// CJS side — direct require works fine
const cjs = requireFromRoot('../get-shit-done/bin/lib/state-document.generated.cjs');
describe('state-document-generator parity: stateReplaceFieldWithFallback', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{
label: 'primary hit',
content: 'Status: old\nState: backup',
primary: 'Status',
fallback: 'State',
value: 'new',
expected: 'Status: new\nState: backup',
},
{
label: 'fallback hit',
content: 'Other: something\nState: backup',
primary: 'Status',
fallback: 'State',
value: 'new',
expected: 'Other: something\nState: new',
},
{
label: 'neither hit returns unchanged content',
content: 'Other: something\nAnother: value',
primary: 'Status',
fallback: 'State',
value: 'new',
expected: 'Other: something\nAnother: value',
},
];
for (const { label, content, primary, fallback, value, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.stateReplaceFieldWithFallback(content, primary, fallback, value);
const cjsResult = cjs.stateReplaceFieldWithFallback(content, primary, fallback, value);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
describe('state-document-generator parity: normalizeStateStatus', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{ label: 'paused via "paused"', status: 'paused', expected: 'paused' },
{ label: 'paused via "stopped"', status: 'stopped', expected: 'paused' },
{ label: 'paused via non-null pausedAt', status: 'active', pausedAt: '2024-01-01', expected: 'paused' },
{ label: 'executing via "executing"', status: 'executing', expected: 'executing' },
{ label: 'executing via "in progress"', status: 'in progress', expected: 'executing' },
{ label: 'executing via "ready to execute"', status: 'ready to execute', expected: 'executing' },
{ label: 'planning via "planning"', status: 'planning', expected: 'planning' },
{ label: 'discussing via "discussing"', status: 'discussing', expected: 'discussing' },
{ label: 'verifying via "verif"', status: 'verifying', expected: 'verifying' },
{ label: 'completed via "complete"', status: 'completed', expected: 'completed' },
{ label: 'completed via "done"', status: 'done', expected: 'completed' },
{ label: 'unknown fallback', status: 'something-else', expected: 'something-else' },
{ label: 'null status', status: null, expected: 'unknown' },
];
for (const { label, status, pausedAt, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.normalizeStateStatus(status, pausedAt);
const cjsResult = cjs.normalizeStateStatus(status, pausedAt);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
describe('state-document-generator parity: computeProgressPercent', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{ label: 'only plans data', cp: 3, tp: 10, cf: null, tf: null, expected: 30 },
{ label: 'only phases data', cp: null, tp: null, cf: 2, tf: 4, expected: 50 },
{ label: 'both present uses min', cp: 8, tp: 10, cf: 3, tf: 10, expected: 30 },
{ label: 'neither returns null', cp: null, tp: null, cf: null, tf: null, expected: null },
{ label: 'total of 0 treated as no data', cp: 0, tp: 0, cf: null, tf: null, expected: null },
];
for (const { label, cp, tp, cf, tf, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.computeProgressPercent(cp, tp, cf, tf);
const cjsResult = cjs.computeProgressPercent(cp, tp, cf, tf);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
describe('state-document-generator parity: shouldPreserveExistingProgress', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{
label: 'existing exceeds derived on total_phases → true',
existing: { total_phases: 10 },
derived: { total_phases: 5 },
expected: true,
},
{
label: 'derived exceeds existing → false',
existing: { total_phases: 5 },
derived: { total_phases: 10 },
expected: false,
},
{
label: 'malformed input (non-object) → false',
existing: null,
derived: { total_phases: 5 },
expected: false,
},
{
label: 'both null → false',
existing: null,
derived: null,
expected: false,
},
];
for (const { label, existing, derived, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.shouldPreserveExistingProgress(existing, derived);
const cjsResult = cjs.shouldPreserveExistingProgress(existing, derived);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
describe('state-document-generator parity: normalizeProgressNumbers', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{
label: 'coerces all five tracked keys to numbers',
input: { total_phases: '10', completed_phases: '3', total_plans: '5', completed_plans: '2', percent: '60' },
expected: { total_phases: 10, completed_phases: 3, total_plans: 5, completed_plans: 2, percent: 60 },
},
{
label: 'non-object null returned unchanged',
input: null,
expected: null,
},
{
label: 'extra keys preserved untouched',
input: { total_phases: '4', extra_key: 'hello' },
expected: { total_phases: 4, extra_key: 'hello' },
},
];
for (const { label, input, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.normalizeProgressNumbers(input);
const cjsResult = cjs.normalizeProgressNumbers(input);
assert.deepStrictEqual(sdkResult, expected, `SDK: ${label}`);
assert.deepStrictEqual(cjsResult, expected, `CJS: ${label}`);
assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// SDK ESM side — dynamically import so we can test both; wrap in a top-level
// async test suite.
describe('state-document-generator parity: stateExtractField', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{
label: 'bold pattern',
content: 'Some content\n**FieldName:** the value\nMore content',
fieldName: 'FieldName',
expected: 'the value',
},
{
label: 'plain pattern',
content: 'Some content\nFieldName: the value\nMore content',
fieldName: 'FieldName',
expected: 'the value',
},
{
label: 'missing field returns null',
content: 'Some content\nOtherField: something\nMore content',
fieldName: 'FieldName',
expected: null,
},
];
for (const { label, content, fieldName, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.stateExtractField(content, fieldName);
const cjsResult = cjs.stateExtractField(content, fieldName);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
describe('state-document-generator parity: stateReplaceField', async () => {
const sdk = await import('../sdk/dist/query/state-document.js');
const fixtures = [
{
label: 'bold replace',
content: 'Some content\n**Status:** old value\nMore content',
fieldName: 'Status',
newValue: 'new value',
expected: 'Some content\n**Status:** new value\nMore content',
},
{
label: 'plain replace',
content: 'Some content\nStatus: old value\nMore content',
fieldName: 'Status',
newValue: 'new value',
expected: 'Some content\nStatus: new value\nMore content',
},
{
label: 'missing field returns null',
content: 'Some content\nOtherField: something\nMore content',
fieldName: 'Status',
newValue: 'new value',
expected: null,
},
];
for (const { label, content, fieldName, newValue, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.stateReplaceField(content, fieldName, newValue);
const cjsResult = cjs.stateReplaceField(content, fieldName, newValue);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});