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

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');
});
});