Files
msd-core/sdk/scripts/gen-state-document.ts
Tom Boucher bfd7ddbad3 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
2026-05-14 22:17:20 -04:00

143 lines
4.8 KiB
JavaScript

#!/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);
});
}