build(#188): retire sdk generator and freshness scripts
This commit is contained in:
75
.github/workflows/test.yml
vendored
75
.github/workflows/test.yml
vendored
@@ -177,81 +177,6 @@ jobs:
|
||||
shell: bash
|
||||
run: cd sdk && npx vitest run src/query/command-seam-coverage.test.ts
|
||||
|
||||
- name: SDK generated alias artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
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: SDK generated configuration artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-configuration-fresh.mjs
|
||||
|
||||
- name: SDK generated workstream-inventory-builder artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-workstream-inventory-builder-fresh.mjs
|
||||
|
||||
- name: SDK generated project-root artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-project-root-fresh.mjs
|
||||
|
||||
- name: SDK generated plan-scan artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-plan-scan-fresh.mjs
|
||||
|
||||
- name: SDK generated secrets artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-secrets-fresh.mjs
|
||||
|
||||
- name: SDK generated schema-detect artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-schema-detect-fresh.mjs
|
||||
|
||||
- name: SDK generated decisions artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-decisions-fresh.mjs
|
||||
|
||||
- name: SDK generated workstream-name-policy artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-workstream-name-policy-fresh.mjs
|
||||
|
||||
- name: SDK generated validate artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
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
|
||||
run: node scripts/lint-shared-module-handsync.cjs
|
||||
|
||||
# Split lanes (issue #3597). Unit is the fast default lane; integration
|
||||
# and security run alongside it on every PR. `install` and `slow` are
|
||||
# skipped on PR CI by design — they run on the weekly windows-compat
|
||||
|
||||
12
package.json
12
package.json
@@ -64,19 +64,9 @@
|
||||
"scripts": {
|
||||
"check:env": "bash scripts/check-env.sh",
|
||||
"check:integrity": "./scripts/check-npm-integrity.sh",
|
||||
"build": "tsc -b",
|
||||
"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",
|
||||
"check:configuration-fresh": "cd sdk && npm run check:configuration-fresh",
|
||||
"check:workstream-inventory-builder-fresh": "cd sdk && npm run check:workstream-inventory-builder-fresh",
|
||||
"check:project-root-fresh": "cd sdk && npm run check:project-root-fresh",
|
||||
"check:plan-scan-fresh": "cd sdk && npm run check:plan-scan-fresh",
|
||||
"check:secrets-fresh": "cd sdk && npm run check:secrets-fresh",
|
||||
"check:schema-detect-fresh": "cd sdk && npm run check:schema-detect-fresh",
|
||||
"check:decisions-fresh": "cd sdk && npm run check:decisions-fresh",
|
||||
"check:workstream-name-policy-fresh": "cd sdk && npm run check:workstream-name-policy-fresh",
|
||||
"check:validate-fresh": "cd sdk && npm run check:validate-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",
|
||||
|
||||
@@ -38,33 +38,6 @@
|
||||
},
|
||||
"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",
|
||||
"gen:configuration": "npm run build && node scripts/gen-configuration.mjs",
|
||||
"check:configuration-fresh": "npm run build && node scripts/check-configuration-fresh.mjs",
|
||||
"gen:workstream-inventory-builder": "npm run build && node scripts/gen-workstream-inventory-builder.mjs",
|
||||
"check:workstream-inventory-builder-fresh": "npm run build && node scripts/check-workstream-inventory-builder-fresh.mjs",
|
||||
"gen:project-root": "npm run build && node scripts/gen-project-root.mjs",
|
||||
"check:project-root-fresh": "npm run build && node scripts/check-project-root-fresh.mjs",
|
||||
"gen:plan-scan": "npm run build && node scripts/gen-plan-scan.mjs",
|
||||
"check:plan-scan-fresh": "npm run build && node scripts/check-plan-scan-fresh.mjs",
|
||||
"gen:secrets": "npm run build && node scripts/gen-secrets.mjs",
|
||||
"check:secrets-fresh": "npm run build && node scripts/check-secrets-fresh.mjs",
|
||||
"gen:schema-detect": "npm run build && node scripts/gen-schema-detect.mjs",
|
||||
"check:schema-detect-fresh": "npm run build && node scripts/check-schema-detect-fresh.mjs",
|
||||
"gen:decisions": "npm run build && node scripts/gen-decisions.mjs",
|
||||
"check:decisions-fresh": "npm run build && node scripts/check-decisions-fresh.mjs",
|
||||
"gen:workstream-name-policy": "npm run build && node scripts/gen-workstream-name-policy.mjs",
|
||||
"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",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Shared helpers for gen-*.mjs generator scripts.
|
||||
*
|
||||
* Provides requireFreshDist(), a pre-generation guard that verifies the
|
||||
* compiled sdk/dist artifact is newer than its TypeScript source. If the
|
||||
* dist file is missing or stale, the function exits 1 with a clear,
|
||||
* actionable error message so the developer knows to run `npm run build:sdk`.
|
||||
*
|
||||
* Single source of truth — import from here rather than duplicating the mtime
|
||||
* logic in each generator.
|
||||
*
|
||||
* @example
|
||||
* import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
* requireFreshDist('sdk/dist/query/secrets.js', 'sdk/src/query/secrets.ts');
|
||||
*/
|
||||
|
||||
import { statSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Resolve the repo root from this file's location (sdk/scripts/ → repo root).
|
||||
// GSD_REPO_ROOT env override allows tests to redirect to a temp directory so
|
||||
// they can freely create/delete dist fixtures without mutating the real tree.
|
||||
const REPO_ROOT = process.env.GSD_REPO_ROOT
|
||||
? resolve(process.env.GSD_REPO_ROOT)
|
||||
: resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Assert that a compiled dist file is at least as new as its TypeScript source.
|
||||
*
|
||||
* Design notes:
|
||||
* - Single Responsibility: each generator is responsible for verifying its
|
||||
* own preconditions before touching dist. This keeps the guard co-located
|
||||
* with the code that depends on it.
|
||||
* - Composability: callers that have already built can call generators
|
||||
* directly; the guard is a cheap mtime check, not a rebuild trigger.
|
||||
* - Debuggability: the error message names both paths and both mtimes so the
|
||||
* developer can see at a glance what is stale and what to do about it.
|
||||
* - Performance: two synchronous statSync calls — effectively zero cost
|
||||
* relative to the import/transform work the generator does next.
|
||||
*
|
||||
* NOT auto-building: auto-building inside the generator couples it to the
|
||||
* build toolchain, bloats its dependency graph, and removes composability —
|
||||
* a caller that already built cannot skip the redundant rebuild. The guard
|
||||
* pattern is the correct separation: tell the developer what to do, not do it
|
||||
* for them.
|
||||
*
|
||||
* @param {string} distPath - Repo-relative path to the compiled dist file,
|
||||
* e.g. 'sdk/dist/query/secrets.js'
|
||||
* @param {string} tsSourcePath - Repo-relative path to the TypeScript source
|
||||
* file, e.g. 'sdk/src/query/secrets.ts'
|
||||
*/
|
||||
export function requireFreshDist(distPath, tsSourcePath) {
|
||||
const distAbs = resolve(REPO_ROOT, distPath);
|
||||
const tsAbs = resolve(REPO_ROOT, tsSourcePath);
|
||||
|
||||
if (!existsSync(distAbs)) {
|
||||
console.error(
|
||||
`ERROR: ${distPath} does not exist. Run \`npm run build:sdk\` first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(tsAbs)) {
|
||||
console.error(
|
||||
`ERROR: ${tsSourcePath} does not exist. Cannot verify dist freshness.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const distMtime = statSync(distAbs).mtimeMs;
|
||||
const tsMtime = statSync(tsAbs).mtimeMs;
|
||||
|
||||
if (distMtime < tsMtime) {
|
||||
console.error(
|
||||
`ERROR: ${distPath} is stale relative to ${tsSourcePath} ` +
|
||||
`(dist mtime ${new Date(distMtime).toISOString()}, ` +
|
||||
`ts mtime ${new Date(tsMtime).toISOString()}). ` +
|
||||
`Run \`npm run build:sdk\` first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const {
|
||||
STATE_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.state.js');
|
||||
const {
|
||||
VERIFY_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.verify.js');
|
||||
const {
|
||||
INIT_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.init.js');
|
||||
const {
|
||||
PHASE_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.phase.js');
|
||||
const {
|
||||
PHASES_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.phases.js');
|
||||
const {
|
||||
VALIDATE_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.validate.js');
|
||||
const {
|
||||
ROADMAP_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.roadmap.js');
|
||||
|
||||
const {
|
||||
STATE_COMMAND_ALIASES,
|
||||
VERIFY_COMMAND_ALIASES,
|
||||
INIT_COMMAND_ALIASES,
|
||||
PHASE_COMMAND_ALIASES,
|
||||
PHASES_COMMAND_ALIASES,
|
||||
VALIDATE_COMMAND_ALIASES,
|
||||
ROADMAP_COMMAND_ALIASES,
|
||||
NON_FAMILY_COMMAND_ALIASES,
|
||||
} = await import('../dist/query/command-aliases.generated.js');
|
||||
|
||||
const {
|
||||
NON_FAMILY_COMMAND_MANIFEST,
|
||||
} = await import('../dist/query/command-manifest.non-family.js');
|
||||
|
||||
const cjsAliases = require(resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs'));
|
||||
|
||||
function toAliasEntries(manifest, family) {
|
||||
const prefix = `${family}.`;
|
||||
return manifest.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: [...entry.aliases],
|
||||
subcommand: entry.canonical.slice(prefix.length),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
}
|
||||
|
||||
function toNonFamilyAliasEntries(manifest) {
|
||||
return [...manifest]
|
||||
.sort((a, b) => a.canonical.localeCompare(b.canonical))
|
||||
.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: [...entry.aliases],
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
}
|
||||
|
||||
function assertEqual(label, actual, expected) {
|
||||
const a = JSON.stringify(actual);
|
||||
const e = JSON.stringify(expected);
|
||||
if (a !== e) {
|
||||
throw new Error(
|
||||
`${label} drift detected. Regenerate command alias artifacts and commit them.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedState = toAliasEntries(STATE_COMMAND_MANIFEST, 'state');
|
||||
const expectedVerify = toAliasEntries(VERIFY_COMMAND_MANIFEST, 'verify');
|
||||
const expectedInit = toAliasEntries(INIT_COMMAND_MANIFEST, 'init');
|
||||
const expectedPhase = toAliasEntries(PHASE_COMMAND_MANIFEST, 'phase');
|
||||
const expectedPhases = toAliasEntries(PHASES_COMMAND_MANIFEST, 'phases');
|
||||
const expectedValidate = toAliasEntries(VALIDATE_COMMAND_MANIFEST, 'validate');
|
||||
const expectedRoadmap = toAliasEntries(ROADMAP_COMMAND_MANIFEST, 'roadmap');
|
||||
const expectedNonFamily = toNonFamilyAliasEntries(NON_FAMILY_COMMAND_MANIFEST);
|
||||
|
||||
assertEqual('TS STATE_COMMAND_ALIASES', STATE_COMMAND_ALIASES, expectedState);
|
||||
assertEqual('TS VERIFY_COMMAND_ALIASES', VERIFY_COMMAND_ALIASES, expectedVerify);
|
||||
assertEqual('TS INIT_COMMAND_ALIASES', INIT_COMMAND_ALIASES, expectedInit);
|
||||
assertEqual('TS PHASE_COMMAND_ALIASES', PHASE_COMMAND_ALIASES, expectedPhase);
|
||||
assertEqual('TS PHASES_COMMAND_ALIASES', PHASES_COMMAND_ALIASES, expectedPhases);
|
||||
assertEqual('TS VALIDATE_COMMAND_ALIASES', VALIDATE_COMMAND_ALIASES, expectedValidate);
|
||||
assertEqual('TS ROADMAP_COMMAND_ALIASES', ROADMAP_COMMAND_ALIASES, expectedRoadmap);
|
||||
assertEqual('TS NON_FAMILY_COMMAND_ALIASES', NON_FAMILY_COMMAND_ALIASES, expectedNonFamily);
|
||||
|
||||
assertEqual('CJS STATE_COMMAND_ALIASES', cjsAliases.STATE_COMMAND_ALIASES, expectedState);
|
||||
assertEqual('CJS VERIFY_COMMAND_ALIASES', cjsAliases.VERIFY_COMMAND_ALIASES, expectedVerify);
|
||||
assertEqual('CJS INIT_COMMAND_ALIASES', cjsAliases.INIT_COMMAND_ALIASES, expectedInit);
|
||||
assertEqual('CJS PHASE_COMMAND_ALIASES', cjsAliases.PHASE_COMMAND_ALIASES, expectedPhase);
|
||||
assertEqual('CJS PHASES_COMMAND_ALIASES', cjsAliases.PHASES_COMMAND_ALIASES, expectedPhases);
|
||||
assertEqual('CJS VALIDATE_COMMAND_ALIASES', cjsAliases.VALIDATE_COMMAND_ALIASES, expectedValidate);
|
||||
assertEqual('CJS ROADMAP_COMMAND_ALIASES', cjsAliases.ROADMAP_COMMAND_ALIASES, expectedRoadmap);
|
||||
assertEqual('CJS NON_FAMILY_COMMAND_ALIASES', cjsAliases.NON_FAMILY_COMMAND_ALIASES, expectedNonFamily);
|
||||
|
||||
console.log('command alias artifacts are fresh');
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for get-shit-done/bin/lib/configuration.generated.cjs.
|
||||
*
|
||||
* Re-runs the generator in-memory, compares to the committed file,
|
||||
* exits 0 if equal, 1 if not.
|
||||
*
|
||||
* Usage: node sdk/scripts/check-configuration-fresh.mjs
|
||||
* Or: cd sdk && npm run check:configuration-fresh
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, '..', '..');
|
||||
|
||||
const { buildConfigurationCjs } = await import('./gen-configuration.mjs');
|
||||
|
||||
const expected = buildConfigurationCjs();
|
||||
const committedPath = resolve(repoRoot, 'get-shit-done', 'bin', 'lib', 'configuration.generated.cjs');
|
||||
|
||||
let committed;
|
||||
try {
|
||||
committed = readFileSync(committedPath, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`configuration.generated.cjs not found at ${committedPath}`);
|
||||
console.error('Run: cd sdk && npm run gen:configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (committed === expected) {
|
||||
console.log('configuration.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('configuration.generated.cjs is STALE. Regenerate with:');
|
||||
console.error(' cd sdk && npm run gen:configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for decisions.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-decisions-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';
|
||||
import { buildDecisionsCjs } from './gen-decisions.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildDecisionsCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'decisions.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('decisions.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('decisions.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:decisions');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for plan-scan.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-plan-scan-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';
|
||||
import { buildPlanScanCjs } from './gen-plan-scan.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildPlanScanCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'plan-scan.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('plan-scan.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('plan-scan.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:plan-scan');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for project-root.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 Phase 3's cleaner pattern: imports buildProjectRootCjs() from the
|
||||
* generator directly rather than duplicating the build logic.
|
||||
*
|
||||
* Run: node sdk/scripts/check-project-root-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));
|
||||
|
||||
// Import the generator function directly (avoids duplicating logic).
|
||||
const { buildProjectRootCjs } = await import('./gen-project-root.mjs');
|
||||
|
||||
const expected = await buildProjectRootCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'project-root.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('project-root.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('project-root.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:project-root');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for schema-detect.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-schema-detect-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';
|
||||
import { buildSchemaDetectCjs } from './gen-schema-detect.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildSchemaDetectCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'schema-detect.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('schema-detect.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('schema-detect.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:schema-detect');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for secrets.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-secrets-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';
|
||||
import { buildSecretsCjs } from './gen-secrets.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildSecretsCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'secrets.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('secrets.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('secrets.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:secrets');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/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/state/index.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/state/index.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);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for validate.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-phase-lifecycle-policy-fresh.mjs: imports
|
||||
* buildValidateCjs() from the generator directly.
|
||||
*
|
||||
* Run: node sdk/scripts/check-validate-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 #6 (open-gsd/get-shit-done-redux)
|
||||
* - PR #154 (issue #4) — generator pattern precedent
|
||||
*/
|
||||
|
||||
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 { buildValidateCjs } = await import('./gen-validate.mjs');
|
||||
|
||||
const expected = await buildValidateCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'validate.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('validate.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('validate.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:validate');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for workstream-inventory-builder.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-workstream-inventory-builder-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';
|
||||
import { buildWorkstreamInventoryBuilderCjs } from './gen-workstream-inventory-builder.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildWorkstreamInventoryBuilderCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'workstream-inventory-builder.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('workstream-inventory-builder.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('workstream-inventory-builder.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:workstream-inventory-builder');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for workstream-name-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.
|
||||
*
|
||||
* Run: node sdk/scripts/check-workstream-name-policy-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';
|
||||
import { buildWorkstreamNamePolicyCjs } from './gen-workstream-name-policy.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildWorkstreamNamePolicyCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'workstream-name-policy.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('workstream-name-policy.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('workstream-name-policy.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:workstream-name-policy');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build-time alias generator skeleton for command-manifest-driven routing.
|
||||
*
|
||||
* This pilot commits generated artifacts directly; this script documents and
|
||||
* preserves the generation seam so future command families can be migrated
|
||||
* without hand-maintained alias duplication.
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { COMMAND_DEFINITIONS_BY_FAMILY } from '../src/query/command-definition.js';
|
||||
import { NON_FAMILY_COMMAND_MANIFEST } from '../src/query/command-manifest.non-family.js';
|
||||
|
||||
function toSubcommand(canonical: string, family: 'state' | 'verify' | 'init' | 'phase' | 'phases' | 'validate' | 'roadmap'): string {
|
||||
const prefix = `${family}.`;
|
||||
return canonical.startsWith(prefix) ? canonical.slice(prefix.length) : canonical;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const stateEntries = COMMAND_DEFINITIONS_BY_FAMILY.state.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'state'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
const verifyEntries = COMMAND_DEFINITIONS_BY_FAMILY.verify.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'verify'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
const initEntries = COMMAND_DEFINITIONS_BY_FAMILY.init.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'init'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
const phaseEntries = COMMAND_DEFINITIONS_BY_FAMILY.phase.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'phase'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
const phasesEntries = COMMAND_DEFINITIONS_BY_FAMILY.phases.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'phases'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
const validateEntries = COMMAND_DEFINITIONS_BY_FAMILY.validate.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'validate'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
const roadmapEntries = COMMAND_DEFINITIONS_BY_FAMILY.roadmap.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: entry.aliases,
|
||||
subcommand: toSubcommand(entry.canonical, 'roadmap'),
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
// Non-family entries — sorted by canonical for deterministic output.
|
||||
const nonFamilyEntries = [...NON_FAMILY_COMMAND_MANIFEST]
|
||||
.sort((a, b) => a.canonical.localeCompare(b.canonical))
|
||||
.map((entry) => ({
|
||||
canonical: entry.canonical,
|
||||
aliases: [...entry.aliases],
|
||||
mutation: entry.mutation,
|
||||
}));
|
||||
|
||||
// Serialise a FamilyCommandAlias entry as a single-line TS literal.
|
||||
function serializeFamily(e: { canonical: string; aliases: string[]; subcommand: string; mutation: boolean }): string {
|
||||
const aliases = `[${e.aliases.map((a) => `'${a}'`).join(', ')}]`;
|
||||
return `{ canonical: '${e.canonical}', aliases: ${aliases}, subcommand: '${e.subcommand}', mutation: ${e.mutation} }`;
|
||||
}
|
||||
|
||||
// Serialise a NonFamilyCommandAlias entry as a single-line TS literal.
|
||||
function serializeNonFamily(e: { canonical: string; aliases: string[]; mutation: boolean }): string {
|
||||
const aliases = `[${e.aliases.map((a) => `'${a}'`).join(', ')}]`;
|
||||
return `{ canonical: '${e.canonical}', aliases: ${aliases}, mutation: ${e.mutation} }`;
|
||||
}
|
||||
|
||||
function renderFamilyArray(entries: { canonical: string; aliases: string[]; subcommand: string; mutation: boolean }[]): string {
|
||||
return `[\n${entries.map((e) => ` ${serializeFamily(e)},`).join('\n')}\n]`;
|
||||
}
|
||||
|
||||
function renderNonFamilyArray(entries: { canonical: string; aliases: string[]; mutation: boolean }[]): string {
|
||||
return `[\n${entries.map((e) => ` ${serializeNonFamily(e)},`).join('\n')}\n]`;
|
||||
}
|
||||
|
||||
const tsOutPath = fileURLToPath(new URL('../src/query/command-aliases.generated.ts', import.meta.url));
|
||||
const tsHeader = `/**\n * GENERATED FILE — command alias expansion for state.*, verify.*, init.*, phase.*, phases.*, validate.*, roadmap.*, and non-family commands.\n * Source: sdk/src/query/command-manifest.{state,verify,init,phase,phases,validate,roadmap,non-family}.ts\n */\n\n`;
|
||||
const tsBody = [
|
||||
'export interface FamilyCommandAlias {',
|
||||
' canonical: string;',
|
||||
' aliases: string[];',
|
||||
' subcommand: string;',
|
||||
' mutation: boolean;',
|
||||
'}',
|
||||
'',
|
||||
`export const STATE_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(stateEntries)} as const;`,
|
||||
'',
|
||||
`export const VERIFY_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(verifyEntries)} as const;`,
|
||||
'',
|
||||
`export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(initEntries)} as const;`,
|
||||
'',
|
||||
`export const PHASE_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(phaseEntries)} as const;`,
|
||||
'',
|
||||
`export const PHASES_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(phasesEntries)} as const;`,
|
||||
'',
|
||||
`export const VALIDATE_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(validateEntries)} as const;`,
|
||||
'',
|
||||
`export const ROADMAP_COMMAND_ALIASES: readonly FamilyCommandAlias[] = ${renderFamilyArray(roadmapEntries)} as const;`,
|
||||
'',
|
||||
'export interface NonFamilyCommandAlias {',
|
||||
' canonical: string;',
|
||||
' aliases: string[];',
|
||||
' mutation: boolean;',
|
||||
'}',
|
||||
'',
|
||||
`export const NON_FAMILY_COMMAND_ALIASES: readonly NonFamilyCommandAlias[] = ${renderNonFamilyArray(nonFamilyEntries)} as const;`,
|
||||
'',
|
||||
'export const STATE_SUBCOMMANDS = new Set<string>(STATE_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'export const VERIFY_SUBCOMMANDS = new Set<string>(VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'export const INIT_SUBCOMMANDS = new Set<string>(INIT_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'export const PHASE_SUBCOMMANDS = new Set<string>(PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'export const PHASES_SUBCOMMANDS = new Set<string>(PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'export const VALIDATE_SUBCOMMANDS = new Set<string>(VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'export const ROADMAP_SUBCOMMANDS = new Set<string>(ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand));',
|
||||
'',
|
||||
].join('\n');
|
||||
await writeFile(tsOutPath, tsHeader + tsBody, 'utf-8');
|
||||
|
||||
// Also generate the CJS mirror used by get-shit-done/bin/lib/ seams.
|
||||
// CJS is plain JavaScript — no type annotations.
|
||||
const cjsOutPath = fileURLToPath(new URL('../../get-shit-done/bin/lib/command-aliases.generated.cjs', import.meta.url));
|
||||
const cjsHeader = `'use strict';\n\n/**\n * GENERATED FILE — state.*, verify.*, init.*, phase.*, phases.*, validate.*, roadmap.*, and non-family alias/subcommand metadata for CJS routing.\n * Source: sdk/src/query/command-manifest.{state,verify,init,phase,phases,validate,roadmap,non-family}.ts\n */\n\n`;
|
||||
const cjsBody = [
|
||||
`const STATE_COMMAND_ALIASES = ${JSON.stringify(stateEntries, null, 2)};`,
|
||||
'',
|
||||
`const VERIFY_COMMAND_ALIASES = ${JSON.stringify(verifyEntries, null, 2)};`,
|
||||
'',
|
||||
`const INIT_COMMAND_ALIASES = ${JSON.stringify(initEntries, null, 2)};`,
|
||||
'',
|
||||
`const PHASE_COMMAND_ALIASES = ${JSON.stringify(phaseEntries, null, 2)};`,
|
||||
'',
|
||||
`const PHASES_COMMAND_ALIASES = ${JSON.stringify(phasesEntries, null, 2)};`,
|
||||
'',
|
||||
`const VALIDATE_COMMAND_ALIASES = ${JSON.stringify(validateEntries, null, 2)};`,
|
||||
'',
|
||||
`const ROADMAP_COMMAND_ALIASES = ${JSON.stringify(roadmapEntries, null, 2)};`,
|
||||
'',
|
||||
`const NON_FAMILY_COMMAND_ALIASES = ${JSON.stringify(nonFamilyEntries, null, 2)};`,
|
||||
'',
|
||||
`const STATE_SUBCOMMANDS = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
`const VERIFY_SUBCOMMANDS = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
`const INIT_SUBCOMMANDS = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
`const PHASE_SUBCOMMANDS = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
`const PHASES_SUBCOMMANDS = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
`const VALIDATE_SUBCOMMANDS = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
`const ROADMAP_SUBCOMMANDS = ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand);`,
|
||||
'',
|
||||
`module.exports = {`,
|
||||
` STATE_COMMAND_ALIASES,`,
|
||||
` VERIFY_COMMAND_ALIASES,`,
|
||||
` INIT_COMMAND_ALIASES,`,
|
||||
` PHASE_COMMAND_ALIASES,`,
|
||||
` PHASES_COMMAND_ALIASES,`,
|
||||
` VALIDATE_COMMAND_ALIASES,`,
|
||||
` ROADMAP_COMMAND_ALIASES,`,
|
||||
` NON_FAMILY_COMMAND_ALIASES,`,
|
||||
` STATE_SUBCOMMANDS,`,
|
||||
` VERIFY_SUBCOMMANDS,`,
|
||||
` INIT_SUBCOMMANDS,`,
|
||||
` PHASE_SUBCOMMANDS,`,
|
||||
` PHASES_SUBCOMMANDS,`,
|
||||
` VALIDATE_SUBCOMMANDS,`,
|
||||
` ROADMAP_SUBCOMMANDS,`,
|
||||
`};`,
|
||||
].join('\n');
|
||||
await writeFile(cjsOutPath, cjsHeader + cjsBody, 'utf-8');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for get-shit-done/bin/lib/configuration.generated.cjs.
|
||||
*
|
||||
* Reads the compiled Configuration Module from sdk/dist/config/index.js
|
||||
* and emits a CJS file that:
|
||||
* 1. Requires the two JSON manifests from sdk/shared/
|
||||
* 2. Exports loadConfig, normalizeLegacyKeys, mergeDefaults, migrateOnDisk,
|
||||
* CONFIG_DEFAULTS, VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS
|
||||
*
|
||||
* Run via: cd sdk && npm run gen:configuration
|
||||
* Or from repo root: node sdk/scripts/gen-configuration.mjs
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/config/index.js', 'sdk/src/config/index.ts');
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, '..', '..');
|
||||
|
||||
// ─── Read the compiled dist file for function extraction ─────────────────────
|
||||
|
||||
const distPath = resolve(here, '..', 'dist', 'config', 'index.js');
|
||||
const distSrc = readFileSync(distPath, 'utf-8');
|
||||
|
||||
/**
|
||||
* Extract a named function from the compiled dist source by scanning for
|
||||
* `function <name>(` (or `async function <name>(`) and capturing the balanced
|
||||
* braces body. Returns the full `[async] function name(...) { ... }` string,
|
||||
* preserving the async keyword when present.
|
||||
*/
|
||||
function extractFunction(src, name) {
|
||||
// Try async first, then plain function
|
||||
let start = src.indexOf(`async function ${name}(`);
|
||||
if (start === -1) start = src.indexOf(`function ${name}(`);
|
||||
if (start === -1) throw new Error(`Function "${name}" not found in dist source`);
|
||||
|
||||
// Find opening brace
|
||||
const braceStart = src.indexOf('{', start);
|
||||
if (braceStart === -1) throw new Error(`No opening brace for "${name}"`);
|
||||
|
||||
// Balance braces
|
||||
let depth = 0;
|
||||
let i = braceStart;
|
||||
while (i < src.length) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new Error(`Unbalanced braces for "${name}"`);
|
||||
}
|
||||
|
||||
const fnPlanningDir = extractFunction(distSrc, 'planningDir');
|
||||
const fnDetectSubRepos = extractFunction(distSrc, 'detectSubRepos');
|
||||
const fnDeepMergeConfig = extractFunction(distSrc, 'deepMergeConfig');
|
||||
const fnNormalizeLegacyKeys = extractFunction(distSrc, 'normalizeLegacyKeys');
|
||||
const fnMergeDefaults = extractFunction(distSrc, 'mergeDefaults');
|
||||
const fnLoadConfig = extractFunction(distSrc, 'loadConfig');
|
||||
const fnMigrateOnDisk = extractFunction(distSrc, 'migrateOnDisk');
|
||||
|
||||
// Capture DEPTH_TO_GRANULARITY constant
|
||||
const dtgMatch = distSrc.match(/const DEPTH_TO_GRANULARITY = \{[^}]+\};/);
|
||||
if (!dtgMatch) throw new Error('DEPTH_TO_GRANULARITY not found in dist source');
|
||||
const dtgConst = dtgMatch[0];
|
||||
|
||||
// ─── Build CJS output ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the CJS output string.
|
||||
* Exported so check-configuration-fresh.mjs can call it without re-running the generator.
|
||||
*/
|
||||
export function buildConfigurationCjs() {
|
||||
return [
|
||||
`'use strict';`,
|
||||
``,
|
||||
`/**`,
|
||||
` * GENERATED FILE — DO NOT EDIT.`,
|
||||
` *`,
|
||||
` * Source: sdk/src/config/index.ts`,
|
||||
` * Regenerate: cd sdk && npm run gen:configuration`,
|
||||
` *`,
|
||||
` * Configuration Module — single source of truth for config loading,`,
|
||||
` * legacy-key normalization, defaults merge, and explicit on-disk migration.`,
|
||||
` */`,
|
||||
``,
|
||||
`const { readFileSync, writeFileSync, existsSync, readdirSync } = require('node:fs');`,
|
||||
`const { join } = require('node:path');`,
|
||||
``,
|
||||
`// ─── Manifest requires ───────────────────────────────────────────────────────`,
|
||||
`function loadConfigurationManifest(fileName) {`,
|
||||
` const candidates = [`,
|
||||
` // Installed runtime layout: get-shit-done/bin/shared/*.manifest.json`,
|
||||
` join(__dirname, '..', 'shared', fileName),`,
|
||||
` // Source-repo dev layout: sdk/shared/*.manifest.json`,
|
||||
` join(__dirname, '..', '..', '..', 'sdk', 'shared', fileName),`,
|
||||
` ];`,
|
||||
` let lastErr = null;`,
|
||||
` for (const candidate of candidates) {`,
|
||||
` try {`,
|
||||
` return require(candidate);`,
|
||||
` } catch (err) {`,
|
||||
` const isMissingCandidate =`,
|
||||
` err && err.code === 'MODULE_NOT_FOUND' && String(err.message || '').includes(candidate);`,
|
||||
` if (!isMissingCandidate) throw err;`,
|
||||
` lastErr = err;`,
|
||||
` }`,
|
||||
` }`,
|
||||
` throw new Error(`,
|
||||
` \`\${fileName} not found. Tried:\\n\${candidates.map((p) => \` \${p}\`).join('\\n')}\\nLast error: \${lastErr?.message}\``,
|
||||
` );`,
|
||||
`}`,
|
||||
``,
|
||||
`const CONFIG_DEFAULTS = loadConfigurationManifest('config-defaults.manifest.json');`,
|
||||
`const SCHEMA_MANIFEST = loadConfigurationManifest('config-schema.manifest.json');`,
|
||||
`const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys);`,
|
||||
`const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys);`,
|
||||
`const DYNAMIC_KEY_PATTERNS = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => {`,
|
||||
` const pattern = new RegExp(p.source);`,
|
||||
` return {`,
|
||||
` ...p,`,
|
||||
` test: (key) => {`,
|
||||
` pattern.lastIndex = 0;`,
|
||||
` return pattern.test(key);`,
|
||||
` },`,
|
||||
` };`,
|
||||
`});`,
|
||||
``,
|
||||
`// ─── Depth → Granularity mapping ─────────────────────────────────────────────`,
|
||||
dtgConst,
|
||||
``,
|
||||
`// ─── Internal helpers ─────────────────────────────────────────────────────────`,
|
||||
fnPlanningDir,
|
||||
``,
|
||||
fnDetectSubRepos,
|
||||
``,
|
||||
fnDeepMergeConfig,
|
||||
``,
|
||||
`// ─── Exported functions ───────────────────────────────────────────────────────`,
|
||||
fnNormalizeLegacyKeys,
|
||||
``,
|
||||
fnMergeDefaults,
|
||||
``,
|
||||
fnLoadConfig,
|
||||
``,
|
||||
fnMigrateOnDisk,
|
||||
``,
|
||||
`module.exports = {`,
|
||||
` loadConfig,`,
|
||||
` normalizeLegacyKeys,`,
|
||||
` mergeDefaults,`,
|
||||
` migrateOnDisk,`,
|
||||
` CONFIG_DEFAULTS,`,
|
||||
` VALID_CONFIG_KEYS,`,
|
||||
` RUNTIME_STATE_KEYS,`,
|
||||
` DYNAMIC_KEY_PATTERNS,`,
|
||||
`};`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Main: write output file (only when run directly) ────────────────────────
|
||||
|
||||
// Guard: don't write the file when imported by check-configuration-fresh.mjs.
|
||||
// `process.argv[1]` is the absolute path of the entry-point script.
|
||||
const _thisFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === _thisFile) {
|
||||
const cjsOut = buildConfigurationCjs();
|
||||
const outPath = resolve(repoRoot, 'get-shit-done', 'bin', 'lib', 'configuration.generated.cjs');
|
||||
writeFileSync(outPath, cjsOut, 'utf-8');
|
||||
console.log(`Generated: ${outPath}`);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Decisions CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/decisions.js,
|
||||
* extracts the relevant function bodies via text transformation,
|
||||
* then emits get-shit-done/bin/lib/decisions.generated.cjs.
|
||||
*
|
||||
* Source-of-truth: sdk/src/query/decisions.ts
|
||||
*
|
||||
* Run: cd sdk && npm run gen:decisions
|
||||
* Freshness check: node sdk/scripts/check-decisions-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/query/decisions.js', 'sdk/src/query/decisions.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/decisions.ts
|
||||
* Regenerate: cd sdk && npm run gen:decisions
|
||||
*
|
||||
* Shared parser for CONTEXT.md <decisions> blocks.
|
||||
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
|
||||
* Returns {id, text, category, tags, trackable} per decision.
|
||||
* CJS callers that only use {id, text} safely ignore the extra fields.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildDecisionsCjs() {
|
||||
// Read the compiled ESM source and transform to CJS.
|
||||
// We extract only the pure logic (no Node.js imports, no query handler).
|
||||
const distPath = fileURLToPath(new URL('../dist/query/decisions.js', import.meta.url));
|
||||
const src = await readFile(distPath, 'utf-8');
|
||||
|
||||
// Strip the ESM-specific header lines (import statements, jsdoc at top)
|
||||
// and the query handler (which uses Node async fs — not needed in CJS shim).
|
||||
// We keep: DISCRETION_HEADINGS, NON_TRACKABLE_TAGS, stripFencedCode,
|
||||
// extractDecisionsBlock, parseDecisions.
|
||||
|
||||
// Extract the module body between the imports and the query handler.
|
||||
// Strategy: strip the leading imports and the trailing export const decisionsParse block.
|
||||
let body = src;
|
||||
|
||||
// Remove leading import statements
|
||||
body = body.replace(/^import\s+.*?;[\r\n]*/gm, '');
|
||||
|
||||
// Remove trailing query handler (from the `export const decisionsParse` line to end)
|
||||
const handlerStart = body.indexOf('// ─── Query handler');
|
||||
if (handlerStart !== -1) {
|
||||
body = body.slice(0, handlerStart);
|
||||
}
|
||||
|
||||
// Remove ESM export keywords (keep the function/const declarations)
|
||||
body = body.replace(/^export (function|const) /gm, '$1 ');
|
||||
|
||||
// Remove source map comment
|
||||
body = body.replace(/\/\/# sourceMappingURL=.*$/gm, '');
|
||||
|
||||
// Remove leading file-level jsdoc comment (keep module-level logic only)
|
||||
body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, '');
|
||||
|
||||
// Trim extra blank lines at start/end
|
||||
body = body.trim();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
'module.exports = { parseDecisions };',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildDecisionsCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/decisions.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Plan Scan CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/plan-scan.js,
|
||||
* extracts function source via Function.prototype.toString() for exports,
|
||||
* then emits get-shit-done/bin/lib/plan-scan.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:plan-scan
|
||||
* Freshness check: node sdk/scripts/check-plan-scan-fresh.mjs
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/query/plan-scan.js', 'sdk/src/query/plan-scan.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/plan-scan.ts
|
||||
* Regenerate: cd sdk && npm run gen:plan-scan
|
||||
*
|
||||
* Plan Scan Module — detects plan and summary files in a phase directory.
|
||||
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildPlanScanCjs() {
|
||||
// Load the compiled ESM module to get exports via Function.prototype.toString()
|
||||
const distUrl = new URL('../dist/query/plan-scan.js', import.meta.url);
|
||||
const {
|
||||
isRootPlanFile,
|
||||
isNestedPlanFile,
|
||||
isRootSummaryFile,
|
||||
isNestedSummaryFile,
|
||||
scanPhasePlans,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const isRootPlanFileBody = isRootPlanFile.toString();
|
||||
const isNestedPlanFileBody = isNestedPlanFile.toString();
|
||||
const isRootSummaryFileBody = isRootSummaryFile.toString();
|
||||
const isNestedSummaryFileBody = isNestedSummaryFile.toString();
|
||||
const scanPhasePlansBody = scanPhasePlans.toString();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
"const { existsSync, readdirSync } = require('node:fs');",
|
||||
"const { join } = require('node:path');",
|
||||
'',
|
||||
'// Excluded derivative files',
|
||||
'const PLAN_OUTLINE_RE = /-OUTLINE\\.md$/i;',
|
||||
'const PLAN_PRE_BOUNCE_RE = /\\.pre-bounce\\.md$/i;',
|
||||
'',
|
||||
isRootPlanFileBody,
|
||||
'',
|
||||
isNestedPlanFileBody,
|
||||
'',
|
||||
isRootSummaryFileBody,
|
||||
'',
|
||||
isNestedSummaryFileBody,
|
||||
'',
|
||||
scanPhasePlansBody,
|
||||
'',
|
||||
'// CJS callers do: const scanPhasePlans = require(\'./plan-scan.cjs\')',
|
||||
'// and also destructure named exports — support both call styles.',
|
||||
'module.exports = scanPhasePlans;',
|
||||
'module.exports.scanPhasePlans = scanPhasePlans;',
|
||||
'module.exports.isRootPlanFile = isRootPlanFile;',
|
||||
'module.exports.isNestedPlanFile = isNestedPlanFile;',
|
||||
'module.exports.isRootSummaryFile = isRootSummaryFile;',
|
||||
'module.exports.isNestedSummaryFile = isNestedSummaryFile;',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildPlanScanCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/plan-scan.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Project-Root Resolution Module CJS artifact.
|
||||
*
|
||||
* Imports the compiled ESM output from sdk/dist/runtime/project-root.js,
|
||||
* captures findProjectRoot via Function.prototype.toString(), then emits
|
||||
* get-shit-done/bin/lib/project-root.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:project-root
|
||||
* Freshness check: node sdk/scripts/check-project-root-fresh.mjs
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/runtime/project-root.js', 'sdk/src/runtime/project-root.ts');
|
||||
|
||||
const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/runtime/project-root.ts
|
||||
* Regenerate: cd sdk && npm run gen:project-root
|
||||
*
|
||||
* Project-Root Resolution Module — resolves a project root from a starting
|
||||
* directory by walking the ancestor chain and applying four heuristics:
|
||||
* (0) own .planning/ guard (#1362)
|
||||
* (1) parent .planning/config.json sub_repos
|
||||
* (2) legacy multiRepo: true + ancestor .git
|
||||
* (3) .git heuristic with parent .planning/
|
||||
* Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
* Build the CJS content string. Exported so the freshness-check script can
|
||||
* import this function directly (Phase 3's cleaner pattern) instead of
|
||||
* duplicating the logic.
|
||||
*/
|
||||
export async function buildProjectRootCjs() {
|
||||
const distUrl = new URL('../dist/runtime/project-root.js', import.meta.url);
|
||||
const { findProjectRoot, FIND_PROJECT_ROOT_MAX_DEPTH } = await import(distUrl.href);
|
||||
|
||||
const findProjectRootBody = findProjectRoot.toString();
|
||||
|
||||
// The compiled ESM uses destructured named imports:
|
||||
// import { dirname, resolve, sep, relative, parse as parsePath } from 'node:path';
|
||||
// import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
// import { homedir } from 'node:os';
|
||||
//
|
||||
// In CJS we provide these as module-level constants so the function body
|
||||
// can reference them as closed-over variables (same technique used in
|
||||
// Phase 3 gen-workstream-inventory-builder.mjs for relative/sep/etc.).
|
||||
const preamble = [
|
||||
`const fs = require('fs');`,
|
||||
`const path = require('path');`,
|
||||
`const os = require('os');`,
|
||||
`const { existsSync, readFileSync, statSync } = fs;`,
|
||||
`const { dirname, resolve, sep, relative, parse: parsePath } = path;`,
|
||||
`const { homedir } = os;`,
|
||||
`const FIND_PROJECT_ROOT_MAX_DEPTH = ${FIND_PROJECT_ROOT_MAX_DEPTH};`,
|
||||
].join('\n');
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
preamble,
|
||||
'',
|
||||
findProjectRootBody,
|
||||
'',
|
||||
`module.exports = { findProjectRoot };`,
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildProjectRootCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/project-root.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Schema Detect CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/schema-detect.js,
|
||||
* extracts function source via Function.prototype.toString() for exports
|
||||
* and via source-text extraction for internal constants, then emits
|
||||
* get-shit-done/bin/lib/schema-detect.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:schema-detect
|
||||
* Freshness check: node sdk/scripts/check-schema-detect-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/query/schema-detect.js', 'sdk/src/query/schema-detect.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/schema-detect.ts
|
||||
* Regenerate: cd sdk && npm run gen:schema-detect
|
||||
*
|
||||
* Schema Drift Detection — detects schema-relevant file changes and verifies
|
||||
* that the appropriate database push command was executed during a phase.
|
||||
* This module does not read the filesystem directly.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract a top-level const declaration block (array or object literal)
|
||||
* from a JS source string. Scans for `const <name> = [` or `const <name> = {`
|
||||
* and captures through the balanced closing brace/bracket.
|
||||
*/
|
||||
function extractConstFromSource(source, name) {
|
||||
// Try array form: const NAME = [
|
||||
let arrayMarker = `const ${name} = [`;
|
||||
let start = source.indexOf(arrayMarker);
|
||||
let openChar = '[';
|
||||
let closeChar = ']';
|
||||
|
||||
if (start === -1) {
|
||||
// Try object form: const NAME = {
|
||||
const objectMarker = `const ${name} = {`;
|
||||
start = source.indexOf(objectMarker);
|
||||
openChar = '{';
|
||||
closeChar = '}';
|
||||
if (start === -1) {
|
||||
throw new Error(`Could not find const ${name} in compiled source`);
|
||||
}
|
||||
}
|
||||
|
||||
const braceOpen = source.indexOf(openChar, start);
|
||||
if (braceOpen === -1) throw new Error(`Could not find opening ${openChar} for const ${name}`);
|
||||
|
||||
let depth = 0;
|
||||
let i = braceOpen;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === openChar) depth++;
|
||||
else if (source[i] === closeChar) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) throw new Error(`Could not find closing ${closeChar} for const ${name}`);
|
||||
|
||||
// Return the full `const NAME = [...];` or `const NAME = {...};`
|
||||
// Find the semicolon after the closing bracket
|
||||
const afterClose = source.indexOf(';', i);
|
||||
const end = afterClose !== -1 ? afterClose + 1 : i + 1;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
export async function buildSchemaDetectCjs() {
|
||||
const distUrl = new URL('../dist/query/schema-detect.js', import.meta.url);
|
||||
const {
|
||||
detectSchemaFiles,
|
||||
checkSchemaDrift,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8');
|
||||
|
||||
// Extract non-exported constants from source text
|
||||
const schemaPatternsDecl = extractConstFromSource(compiledSource, 'SCHEMA_PATTERNS');
|
||||
const ormInfoDecl = extractConstFromSource(compiledSource, 'ORM_INFO');
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const detectSchemaFilesBody = detectSchemaFiles.toString();
|
||||
const checkSchemaDriftBody = checkSchemaDrift.toString();
|
||||
|
||||
// detectSchemaOrm is not in the SDK but CJS callers may use it.
|
||||
// Reconstruct it as a simple ORM_INFO lookup (same as original secrets.cjs).
|
||||
const detectSchemaOrmBody = `function detectSchemaOrm(ormName) {
|
||||
return ORM_INFO[ormName] || null;
|
||||
}`;
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
'// ─── ORM Patterns ───────────────────────────────────────────────────────────',
|
||||
schemaPatternsDecl,
|
||||
'',
|
||||
'// ─── Push Commands & Evidence Patterns ──────────────────────────────────────',
|
||||
ormInfoDecl,
|
||||
'',
|
||||
'// ─── Public API ──────────────────────────────────────────────────────────────',
|
||||
detectSchemaFilesBody,
|
||||
'',
|
||||
detectSchemaOrmBody,
|
||||
'',
|
||||
checkSchemaDriftBody,
|
||||
'',
|
||||
'module.exports = {',
|
||||
' SCHEMA_PATTERNS,',
|
||||
' ORM_INFO,',
|
||||
' detectSchemaFiles,',
|
||||
' detectSchemaOrm,',
|
||||
' checkSchemaDrift,',
|
||||
'};',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildSchemaDetectCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/schema-detect.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Secrets CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/secrets.js,
|
||||
* extracts function source via Function.prototype.toString() for exports,
|
||||
* then emits get-shit-done/bin/lib/secrets.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:secrets
|
||||
* Freshness check: node sdk/scripts/check-secrets-fresh.mjs
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/query/secrets.js', 'sdk/src/query/secrets.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/secrets.ts
|
||||
* Regenerate: cd sdk && npm run gen:secrets
|
||||
*
|
||||
* Secrets handling — masking convention for API keys and other
|
||||
* credentials managed via /gsd-settings-integrations.
|
||||
* This module does not read the filesystem.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildSecretsCjs() {
|
||||
// Load the compiled ESM module to get exports via Function.prototype.toString()
|
||||
const distUrl = new URL('../dist/query/secrets.js', import.meta.url);
|
||||
const {
|
||||
SECRET_CONFIG_KEYS,
|
||||
isSecretKey,
|
||||
maskSecret,
|
||||
maskIfSecret,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const isSecretKeyBody = isSecretKey.toString();
|
||||
const maskSecretBody = maskSecret.toString();
|
||||
const maskIfSecretBody = maskIfSecret.toString();
|
||||
|
||||
// SECRET_CONFIG_KEYS is a Set — reconstruct it as a constant declaration
|
||||
const secretKeys = [...SECRET_CONFIG_KEYS];
|
||||
const secretKeysLiteral = secretKeys.map(k => ` '${k}',`).join('\n');
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
'const SECRET_CONFIG_KEYS = new Set([',
|
||||
secretKeysLiteral,
|
||||
']);',
|
||||
'',
|
||||
isSecretKeyBody,
|
||||
'',
|
||||
maskSecretBody,
|
||||
'',
|
||||
maskIfSecretBody,
|
||||
'',
|
||||
'module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildSecretsCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/secrets.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the STATE.md Document Module CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/state/index.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/state/index.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/state/index.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Validate CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/validate.js, extracts the
|
||||
* pure helpers and constants, then emits
|
||||
* get-shit-done/bin/lib/validate.generated.cjs.
|
||||
*
|
||||
* The generated module exports seven items (three from issue #6, four from #26):
|
||||
*
|
||||
* Issue #6 drift items:
|
||||
* 1. phaseVariants(phase) — generates all normalized variants of a phase
|
||||
* token (padded/unpadded/letter-suffix). Used for W006 disk-existence check
|
||||
* and W007 roadmap-membership check in verify.cjs Check 8.
|
||||
*
|
||||
* 2. buildRoadmapPhaseVariants(roadmapContent) — parses ROADMAP.md and builds
|
||||
* the Set of all variants of all roadmap phases. Used by the W007
|
||||
* check. verify.cjs previously used only raw phase tokens (no variants).
|
||||
*
|
||||
* 3. buildNotStartedPhaseVariants(roadmapContent) — parses ROADMAP.md unchecked
|
||||
* phase entries and builds a Set of all variants. Used for the W006
|
||||
* unchecked-phase skip. verify.cjs previously added only raw+zero-padded
|
||||
* (dropping letter suffix via parseInt).
|
||||
*
|
||||
* Issue #26 drift items:
|
||||
* 4. phaseDirNameRe (PHASE_DIR_NAME_RE) — regex constant for W005 phase
|
||||
* directory naming check. /^\d{2,}(?:\.\d+)*-[\w-]+$/ accepts multi-digit
|
||||
* prefixes. verify.cjs Check 6 previously had an inline copy.
|
||||
*
|
||||
* 5. PHASE_TOKEN_FROM_DIR_RE — regex constant used by forEachArchivedPhaseToken()
|
||||
* to extract the phase token from a directory name. verify.cjs had an inline copy.
|
||||
*
|
||||
* 6. MILESTONE_ARCHIVE_DIR_RE — regex constant used to identify milestone archive
|
||||
* directories under .planning/milestones/. verify.cjs had an inline copy.
|
||||
*
|
||||
* 7. canonicalPlanStem(stem) — converts a PLAN file stem to its canonical form for
|
||||
* PLAN/SUMMARY matching (I001 check). '68-01-scaffolding' → '68-01'.
|
||||
* verify.cjs Check 7 previously had an inline copy.
|
||||
*
|
||||
* Extraction approach: phaseVariants is a closure inside validateHealth (not a module
|
||||
* export), extracted via brace-balanced source-text parsing. Named constants and top-level
|
||||
* functions (PHASE_DIR_NAME_RE, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE,
|
||||
* canonicalPlanStem) are extracted by simple line-scanning from the compiled source.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:validate
|
||||
* Check: node sdk/scripts/check-validate-fresh.mjs
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #6 (open-gsd/get-shit-done-redux)
|
||||
* - Issue #26 (open-gsd/get-shit-done-redux) — #26 extends issue #6's generator
|
||||
* - PR #154 (issue #4) — generator pattern precedent
|
||||
* - PR #156 (issue #6) — validate.ts generator that #26 extends
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/query/validate.js', 'sdk/src/query/validate.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/validate.ts
|
||||
* Regenerate: cd sdk && npm run gen:validate
|
||||
*
|
||||
* Validate Helpers — pure computation helpers and regex constants extracted from
|
||||
* sdk/src/query/validate.ts. No I/O. No async. No filesystem operations.
|
||||
*
|
||||
* Issue #6 drift items (three helpers):
|
||||
* 1. phaseVariants() — replaces parseInt-based padded/unpadded check in verify.cjs
|
||||
* Check 8 (W006 disk-existence and W007 roadmap-membership checks).
|
||||
* 2. buildRoadmapPhaseVariants() — replaces raw roadmapPhases set in W007 loop.
|
||||
* 3. buildNotStartedPhaseVariants() — replaces raw+zero-padded notStartedPhases
|
||||
* in W006 skip logic.
|
||||
*
|
||||
* Issue #26 drift items (four constants/helpers):
|
||||
* 4. phaseDirNameRe — W005 phase directory naming regex (was inline in verify.cjs Check 6).
|
||||
* 5. PHASE_TOKEN_FROM_DIR_RE — extracts phase token from dir name (was inline in
|
||||
* verify.cjs forEachArchivedPhaseToken / collectDiskPhases).
|
||||
* 6. MILESTONE_ARCHIVE_DIR_RE — identifies milestone archive directories (was inline).
|
||||
* 7. canonicalPlanStem() — I001 PLAN/SUMMARY stem canonicalization (was inline in Check 7).
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #6 (open-gsd/get-shit-done-redux)
|
||||
* - Issue #26 (open-gsd/get-shit-done-redux)
|
||||
* - PR #154 (issue #4) — generator pattern precedent
|
||||
* - PR #156 (issue #6) — validate.ts generator that #26 extends
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract phaseVariants from compiled validate.js source text.
|
||||
*
|
||||
* phaseVariants is defined as a const arrow function closure inside validateHealth.
|
||||
* It starts with the literal `const phaseVariants = (phase) => {` and ends at the
|
||||
* matching closing brace. We re-emit it as a standalone named function declaration.
|
||||
*/
|
||||
function extractPhaseVariantsBody(validateSource) {
|
||||
const marker = 'const phaseVariants = (phase) => {';
|
||||
const start = validateSource.indexOf(marker);
|
||||
if (start === -1) throw new Error('Could not find phaseVariants in compiled validate.js');
|
||||
|
||||
// Find the opening brace of the arrow function body
|
||||
const braceOpen = validateSource.indexOf('{', start + marker.length - 1);
|
||||
let depth = 0;
|
||||
let i = braceOpen;
|
||||
for (; i < validateSource.length; i++) {
|
||||
if (validateSource[i] === '{') depth++;
|
||||
else if (validateSource[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
// Extract just the body content (between the braces)
|
||||
const bodyContent = validateSource.slice(braceOpen + 1, i);
|
||||
|
||||
// Emit as a standalone named function so verify.cjs can require() and call it.
|
||||
return `function phaseVariants(phase) {\n${bodyContent}\n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a top-level const RegExp assignment from the source.
|
||||
*
|
||||
* Looks for the line `const <name> = /<pattern>/<flags>;` and returns the full
|
||||
* assignment statement as a `module.exports`-compatible const declaration
|
||||
* (renaming to the export name when it differs from the source name).
|
||||
*
|
||||
* @param {string} source - Compiled JS source text
|
||||
* @param {string} sourceName - The const name as it appears in the compiled output
|
||||
* @param {string} [exportName] - The name to export under (defaults to sourceName)
|
||||
*/
|
||||
function extractConstRegExp(source, sourceName, exportName) {
|
||||
const nameToUse = exportName ?? sourceName;
|
||||
const lines = source.split('\n');
|
||||
// Match both `const <name> = ...` and `export const <name> = ...`
|
||||
const suffix = `const ${sourceName} = `;
|
||||
const line = lines.find((l) => l === suffix.trimStart() + l.slice(suffix.trimStart().length)
|
||||
|| l.startsWith(suffix) || l.startsWith(`export ${suffix}`));
|
||||
// Simpler: find a line that contains `const <name> = ` (anywhere after optional export)
|
||||
const matchLine = lines.find((l) => {
|
||||
const trimmed = l.replace(/^export\s+/, '');
|
||||
return trimmed.startsWith(`const ${sourceName} = `);
|
||||
});
|
||||
if (!matchLine) throw new Error(`Could not find "const ${sourceName} = ..." in compiled validate.js`);
|
||||
// Extract just the value (after `const <name> = `)
|
||||
const assignIdx = matchLine.indexOf(`const ${sourceName} = `);
|
||||
const valueStart = assignIdx + `const ${sourceName} = `.length;
|
||||
const value = matchLine.slice(valueStart).replace(/;$/, '').trim();
|
||||
return `const ${nameToUse} = ${value};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a top-level named function declaration from the source.
|
||||
*
|
||||
* Matches `function <name>(<params>) {` and extracts the complete function body
|
||||
* using a brace-balanced parser.
|
||||
*
|
||||
* @param {string} source - Compiled JS source text
|
||||
* @param {string} name - The function name as it appears in the compiled output
|
||||
*/
|
||||
function extractTopLevelFunction(source, name) {
|
||||
// Match a top-level function declaration (not prefixed by spaces/async/export)
|
||||
const marker = `function ${name}(`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) throw new Error(`Could not find top-level function "${name}" in compiled validate.js`);
|
||||
|
||||
const braceOpen = source.indexOf('{', start);
|
||||
let depth = 0;
|
||||
let i = braceOpen;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return source.slice(start, i + 1);
|
||||
}
|
||||
|
||||
export async function buildValidateCjs() {
|
||||
const distUrl = new URL('../dist/query/validate.js', import.meta.url);
|
||||
const validateSource = await readFile(fileURLToPath(distUrl), 'utf-8');
|
||||
|
||||
const phaseVariantsBody = extractPhaseVariantsBody(validateSource);
|
||||
|
||||
// Issue #26: extract regex constants and canonicalPlanStem from compiled output.
|
||||
//
|
||||
// phaseDirNameRe — the PHASE_DIR_NAME_RE constant added to validate.ts for W005.
|
||||
// Named 'phaseDirNameRe' in the export (camelCase for JS convention).
|
||||
const phaseDirNameReLine = extractConstRegExp(validateSource, 'PHASE_DIR_NAME_RE', 'phaseDirNameRe');
|
||||
|
||||
// PHASE_TOKEN_FROM_DIR_RE — extracts phase token from a directory name like "64-auth-service".
|
||||
// Exported under its original name for direct use in verify.cjs.
|
||||
const phaseTokenFromDirReLine = extractConstRegExp(validateSource, 'PHASE_TOKEN_FROM_DIR_RE');
|
||||
|
||||
// MILESTONE_ARCHIVE_DIR_RE — matches milestone archive dir names like "v1.0-phases".
|
||||
const milestoneArchiveDirReLine = extractConstRegExp(validateSource, 'MILESTONE_ARCHIVE_DIR_RE');
|
||||
|
||||
// canonicalPlanStem(stem) — I001 PLAN/SUMMARY stem canonicalization.
|
||||
// '68-01-scaffolding' → '68-01'. Top-level named function in the compiled output.
|
||||
const canonicalPlanStemBody = extractTopLevelFunction(validateSource, 'canonicalPlanStem');
|
||||
|
||||
// buildRoadmapPhaseVariants: parse ROADMAP.md and return {roadmapPhases, roadmapPhaseVariants}
|
||||
// roadmapPhases — raw phase tokens as written in headings (used for W006 check)
|
||||
// roadmapPhaseVariants — all normalized variants of each roadmap phase (used for W007 check)
|
||||
const buildRoadmapPhaseVariantsBody = `function buildRoadmapPhaseVariants(roadmapContent) {
|
||||
const roadmapPhases = new Set();
|
||||
const roadmapPhaseVariants = new Set();
|
||||
const phasePattern = /#{2,4}\\s*Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)*)\\s*:/gi;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(m[1]);
|
||||
for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant);
|
||||
}
|
||||
return { roadmapPhases, roadmapPhaseVariants };
|
||||
}`;
|
||||
|
||||
// buildNotStartedPhaseVariants: parse ROADMAP.md unchecked entries and return
|
||||
// a Set of all variants of each unchecked phase (used for W006 skip logic).
|
||||
const buildNotStartedPhaseVariantsBody = `function buildNotStartedPhaseVariants(roadmapContent) {
|
||||
const notStartedPhases = new Set();
|
||||
const uncheckedPattern = /-\\s*\\[\\s\\]\\s*\\*{0,2}Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)*)[:\\s*]/gi;
|
||||
let um;
|
||||
while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
|
||||
for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant);
|
||||
}
|
||||
return notStartedPhases;
|
||||
}`;
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
// Issue #26: regex constants extracted from compiled validate.js
|
||||
'// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────',
|
||||
phaseDirNameReLine,
|
||||
phaseTokenFromDirReLine,
|
||||
milestoneArchiveDirReLine,
|
||||
'',
|
||||
// Issue #26: canonicalPlanStem (I001)
|
||||
'// ── Issue #26: I001 canonicalization ────────────────────────────────────────',
|
||||
canonicalPlanStemBody,
|
||||
'',
|
||||
// Issue #6: phaseVariants closure (W006/W007)
|
||||
'// ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────',
|
||||
phaseVariantsBody,
|
||||
'',
|
||||
buildRoadmapPhaseVariantsBody,
|
||||
'',
|
||||
buildNotStartedPhaseVariantsBody,
|
||||
'',
|
||||
`module.exports = {
|
||||
// Issue #26 exports (W005 regex, W006-archived regex constants, I001 helper)
|
||||
phaseDirNameRe,
|
||||
PHASE_TOKEN_FROM_DIR_RE,
|
||||
MILESTONE_ARCHIVE_DIR_RE,
|
||||
canonicalPlanStem,
|
||||
// Issue #6 exports (W006/W007 phase variant helpers)
|
||||
phaseVariants,
|
||||
buildRoadmapPhaseVariants,
|
||||
buildNotStartedPhaseVariants,
|
||||
};`,
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildValidateCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/validate.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);
|
||||
});
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Workstream Inventory Builder CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/workstream/builder.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/workstream-inventory-builder.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:workstream-inventory-builder
|
||||
* Freshness check: node sdk/scripts/check-workstream-inventory-builder-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/workstream/builder.js', 'sdk/src/workstream/builder.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/workstream/builder.ts
|
||||
* Regenerate: cd sdk && npm run gen:workstream-inventory-builder
|
||||
*
|
||||
* Workstream Inventory Builder — pure projection from pre-collected
|
||||
* filesystem data to typed WorkstreamInventory. No I/O. No async.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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`);
|
||||
}
|
||||
// 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 buildWorkstreamInventoryBuilderCjs() {
|
||||
// Load the compiled ESM module to get exports via Function.prototype.toString()
|
||||
const distUrl = new URL('../dist/workstream/builder.js', import.meta.url);
|
||||
const {
|
||||
buildWorkstreamInventory,
|
||||
isCompletedInventory,
|
||||
} = 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 toPosixPathBody = extractFunctionFromSource(compiledSource, 'toPosixPath');
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const isCompletedInventoryBody = isCompletedInventory.toString();
|
||||
const buildWorkstreamInventoryBody = buildWorkstreamInventory.toString();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
"const path = require('path');",
|
||||
'const relative = path.relative;',
|
||||
'',
|
||||
'// Internal helpers',
|
||||
toPosixPathBody,
|
||||
'',
|
||||
isCompletedInventoryBody,
|
||||
'',
|
||||
buildWorkstreamInventoryBody,
|
||||
'',
|
||||
'module.exports = { buildWorkstreamInventory, isCompletedInventory };',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildWorkstreamInventoryBuilderCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/workstream-inventory-builder.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.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Workstream Name Policy CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/workstream-name-policy.js,
|
||||
* extracts function source via text transformation,
|
||||
* then emits get-shit-done/bin/lib/workstream-name-policy.generated.cjs.
|
||||
*
|
||||
* Source-of-truth: sdk/src/workstream-name-policy.ts
|
||||
*
|
||||
* Run: cd sdk && npm run gen:workstream-name-policy
|
||||
* Freshness check: node sdk/scripts/check-workstream-name-policy-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requireFreshDist } from './_gen-helpers.mjs';
|
||||
|
||||
requireFreshDist('sdk/dist/workstream-name-policy.js', 'sdk/src/workstream-name-policy.ts');
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/workstream-name-policy.ts
|
||||
* Regenerate: cd sdk && npm run gen:workstream-name-policy
|
||||
*
|
||||
* Canonical workstream name validation and slug normalization.
|
||||
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildWorkstreamNamePolicyCjs() {
|
||||
// Read the compiled ESM source and transform to CJS.
|
||||
const distPath = fileURLToPath(new URL('../dist/workstream-name-policy.js', import.meta.url));
|
||||
const src = await readFile(distPath, 'utf-8');
|
||||
|
||||
// Transform ESM to CJS:
|
||||
// 1. Remove import statements (none expected in this file)
|
||||
// 2. Remove ESM export keywords
|
||||
// 3. Remove source map comment
|
||||
// 4. Remove leading jsdoc comment
|
||||
// 5. Add module.exports at end
|
||||
|
||||
let body = src;
|
||||
|
||||
// Remove leading import statements (if any)
|
||||
body = body.replace(/^import\s+.*?;[\r\n]*/gm, '');
|
||||
|
||||
// Remove ESM export keywords (keep function/const declarations)
|
||||
body = body.replace(/^export (function|const) /gm, '$1 ');
|
||||
|
||||
// Remove source map comment
|
||||
body = body.replace(/\/\/# sourceMappingURL=.*$/gm, '');
|
||||
|
||||
// Remove leading file-level jsdoc comment (keep module-level logic only)
|
||||
body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, '');
|
||||
|
||||
// Trim extra blank lines at start/end
|
||||
body = body.trim();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
'module.exports = {',
|
||||
' validateWorkstreamName,',
|
||||
' toWorkstreamSlug,',
|
||||
' hasInvalidPathSegment,',
|
||||
' isValidActiveWorkstreamName,',
|
||||
'};',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildWorkstreamNamePolicyCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/workstream-name-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, not when imported.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test: configuration.generated.cjs (CJS) vs sdk/dist/config/index.js (ESM).
|
||||
*
|
||||
* For every fixture in the vitest pinning tests, asserts that both sides produce
|
||||
* identical output. This ensures the generator faithfully replicates the TS source.
|
||||
*
|
||||
* Uses node:test + dynamic import() for the ESM side.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
// ─── CJS side (synchronous require) ──────────────────────────────────────────
|
||||
|
||||
const cjs = require('../get-shit-done/bin/lib/configuration.generated.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpProject() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeConfig(dir, data) {
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function readConfigRaw(dir) {
|
||||
return fs.readFileSync(path.join(dir, '.planning', 'config.json'), 'utf-8');
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ESM side (loaded once via before()) ─────────────────────────────────────
|
||||
|
||||
let esm;
|
||||
|
||||
before(async () => {
|
||||
esm = await import('../sdk/dist/config/index.js');
|
||||
});
|
||||
|
||||
// ─── Parity helper ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deep-equal assertion that normalizes Sets to arrays for comparison.
|
||||
*/
|
||||
function assertDeepEqual(label, actual, expected) {
|
||||
const normalize = (v) => JSON.parse(JSON.stringify(v, (_k, val) =>
|
||||
val instanceof Set ? [...val].sort() : val
|
||||
));
|
||||
assert.deepStrictEqual(normalize(actual), normalize(expected), `${label} mismatch`);
|
||||
}
|
||||
|
||||
// ─── CONFIG_DEFAULTS parity ───────────────────────────────────────────────────
|
||||
|
||||
describe('CONFIG_DEFAULTS parity', () => {
|
||||
test('model_profile matches', () => {
|
||||
assert.strictEqual(cjs.CONFIG_DEFAULTS.model_profile, esm.CONFIG_DEFAULTS.model_profile);
|
||||
});
|
||||
|
||||
test('git section matches', () => {
|
||||
assertDeepEqual('git', cjs.CONFIG_DEFAULTS.git, esm.CONFIG_DEFAULTS.git);
|
||||
});
|
||||
|
||||
test('workflow section matches', () => {
|
||||
assertDeepEqual('workflow', cjs.CONFIG_DEFAULTS.workflow, esm.CONFIG_DEFAULTS.workflow);
|
||||
});
|
||||
|
||||
test('hooks section matches', () => {
|
||||
assertDeepEqual('hooks', cjs.CONFIG_DEFAULTS.hooks, esm.CONFIG_DEFAULTS.hooks);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID_CONFIG_KEYS parity ─────────────────────────────────────────────────
|
||||
|
||||
describe('VALID_CONFIG_KEYS parity', () => {
|
||||
test('same size', () => {
|
||||
assert.strictEqual(cjs.VALID_CONFIG_KEYS.size, esm.VALID_CONFIG_KEYS.size);
|
||||
});
|
||||
|
||||
test('same entries', () => {
|
||||
for (const key of esm.VALID_CONFIG_KEYS) {
|
||||
assert.ok(cjs.VALID_CONFIG_KEYS.has(key), `CJS missing key: ${key}`);
|
||||
}
|
||||
for (const key of cjs.VALID_CONFIG_KEYS) {
|
||||
assert.ok(esm.VALID_CONFIG_KEYS.has(key), `ESM missing key: ${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DYNAMIC_KEY_PATTERNS parity ─────────────────────────────────────────────
|
||||
|
||||
describe('DYNAMIC_KEY_PATTERNS parity', () => {
|
||||
test('same length', () => {
|
||||
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS.length, esm.DYNAMIC_KEY_PATTERNS.length);
|
||||
});
|
||||
|
||||
test('same topLevel and source strings', () => {
|
||||
for (let i = 0; i < esm.DYNAMIC_KEY_PATTERNS.length; i++) {
|
||||
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS[i].topLevel, esm.DYNAMIC_KEY_PATTERNS[i].topLevel, `topLevel[${i}]`);
|
||||
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS[i].source, esm.DYNAMIC_KEY_PATTERNS[i].source, `source[${i}]`);
|
||||
}
|
||||
});
|
||||
|
||||
test('test functions produce same results', () => {
|
||||
const sampleKeys = [
|
||||
'agent_skills.planner',
|
||||
'agent_skills.executor',
|
||||
'review.models.ollama',
|
||||
'features.thinking_partner',
|
||||
'claude_md_assembly.blocks.intro',
|
||||
'model_profile_overrides.openai.opus',
|
||||
'models.planning',
|
||||
'dynamic_routing.enabled',
|
||||
'model_overrides.my-agent',
|
||||
'workflow.research',
|
||||
'unknown_key',
|
||||
];
|
||||
for (const key of sampleKeys) {
|
||||
for (let i = 0; i < esm.DYNAMIC_KEY_PATTERNS.length; i++) {
|
||||
const esmResult = esm.DYNAMIC_KEY_PATTERNS[i].test(key);
|
||||
const cjsResult = cjs.DYNAMIC_KEY_PATTERNS[i].test(key);
|
||||
assert.strictEqual(cjsResult, esmResult, `pattern[${i}].test('${key}')`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeLegacyKeys parity ───────────────────────────────────────────────
|
||||
|
||||
describe('normalizeLegacyKeys parity', () => {
|
||||
test('branching_strategy migration', () => {
|
||||
const input = { branching_strategy: 'phase' };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
assertDeepEqual('normalizations', cjsR.normalizations, esmR.normalizations);
|
||||
});
|
||||
|
||||
test('sub_repos migration', () => {
|
||||
const input = { sub_repos: ['app1', 'app2'] };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
assertDeepEqual('normalizations', cjsR.normalizations, esmR.normalizations);
|
||||
});
|
||||
|
||||
test('multiRepo migration', () => {
|
||||
const input = { multiRepo: true };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
assert.strictEqual(cjsR.normalizations.length, esmR.normalizations.length);
|
||||
assert.strictEqual(cjsR.normalizations[0].requiresFilesystem, esmR.normalizations[0].requiresFilesystem);
|
||||
});
|
||||
|
||||
test('depth: comprehensive migration', () => {
|
||||
const input = { depth: 'comprehensive' };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
});
|
||||
|
||||
test('already-normalized returns empty normalizations', () => {
|
||||
const input = { git: { branching_strategy: 'phase' } };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assert.strictEqual(cjsR.normalizations.length, 0);
|
||||
assert.strictEqual(esmR.normalizations.length, 0);
|
||||
});
|
||||
|
||||
test('idempotent — second call returns empty normalizations', () => {
|
||||
const input = { branching_strategy: 'milestone' };
|
||||
const first_cjs = cjs.normalizeLegacyKeys(input);
|
||||
const second_cjs = cjs.normalizeLegacyKeys(first_cjs.parsed);
|
||||
const first_esm = esm.normalizeLegacyKeys(input);
|
||||
const second_esm = esm.normalizeLegacyKeys(first_esm.parsed);
|
||||
assert.strictEqual(second_cjs.normalizations.length, 0);
|
||||
assert.strictEqual(second_esm.normalizations.length, 0);
|
||||
assertDeepEqual('second_parsed', second_cjs.parsed, second_esm.parsed);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeDefaults parity ─────────────────────────────────────────────────────
|
||||
|
||||
describe('mergeDefaults parity', () => {
|
||||
test('empty input returns CONFIG_DEFAULTS shape', () => {
|
||||
const esmR = esm.mergeDefaults({});
|
||||
const cjsR = cjs.mergeDefaults({});
|
||||
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
|
||||
assertDeepEqual('git', cjsR.git, esmR.git);
|
||||
assertDeepEqual('workflow', cjsR.workflow, esmR.workflow);
|
||||
assertDeepEqual('hooks', cjsR.hooks, esmR.hooks);
|
||||
});
|
||||
|
||||
test('partial nested preserves siblings', () => {
|
||||
const input = { git: { base_branch: 'main' } };
|
||||
const esmR = esm.mergeDefaults(input);
|
||||
const cjsR = cjs.mergeDefaults(input);
|
||||
assert.strictEqual(cjsR.git.base_branch, esmR.git.base_branch);
|
||||
assert.strictEqual(cjsR.git.branching_strategy, esmR.git.branching_strategy);
|
||||
});
|
||||
|
||||
test('boolean false preserved', () => {
|
||||
const input = { workflow: { research: false } };
|
||||
const esmR = esm.mergeDefaults(input);
|
||||
const cjsR = cjs.mergeDefaults(input);
|
||||
assert.strictEqual(cjsR.workflow.research, false);
|
||||
assert.strictEqual(esmR.workflow.research, false);
|
||||
});
|
||||
|
||||
test('null preserved', () => {
|
||||
const input = { project_code: null };
|
||||
const esmR = esm.mergeDefaults(input);
|
||||
const cjsR = cjs.mergeDefaults(input);
|
||||
assert.strictEqual(cjsR.project_code, null);
|
||||
assert.strictEqual(esmR.project_code, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig parity ────────────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig parity', () => {
|
||||
test('missing config.json returns defaults', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-lc-'));
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
|
||||
assertDeepEqual('git', cjsR.git, esmR.git);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty {} config.json returns defaults', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, {});
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('nested git.branching_strategy preserved', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, { git: { branching_strategy: 'phase' } });
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.git.branching_strategy, 'phase');
|
||||
assert.strictEqual(esmR.git.branching_strategy, 'phase');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy top-level branching_strategy normalized, disk unchanged', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, { branching_strategy: 'phase' });
|
||||
const before = readConfigRaw(dir);
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.git.branching_strategy, 'phase');
|
||||
assert.strictEqual(esmR.git.branching_strategy, 'phase');
|
||||
// Disk must be unchanged
|
||||
assert.strictEqual(readConfigRaw(dir), before);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', async () => {
|
||||
const dir = makeTmpProject();
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{bad json');
|
||||
try {
|
||||
await assert.rejects(() => cjs.loadConfig(dir), /parse|invalid|json/i);
|
||||
await assert.rejects(() => esm.loadConfig(dir), /parse|invalid|json/i);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── migrateOnDisk parity ─────────────────────────────────────────────────────
|
||||
|
||||
describe('migrateOnDisk parity', () => {
|
||||
test('no-op for already-normalized config', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, { git: { branching_strategy: 'phase' } });
|
||||
try {
|
||||
const esmR = await esm.migrateOnDisk(dir);
|
||||
// Reset file for CJS test
|
||||
writeConfig(dir, { git: { branching_strategy: 'phase' } });
|
||||
const cjsR = await cjs.migrateOnDisk(dir);
|
||||
assert.strictEqual(cjsR.migrated, false);
|
||||
assert.strictEqual(esmR.migrated, false);
|
||||
assert.strictEqual(cjsR.wrote, null);
|
||||
assert.strictEqual(esmR.wrote, null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('migrates legacy key and writes disk', async () => {
|
||||
const dirEsm = makeTmpProject();
|
||||
const dirCjs = makeTmpProject();
|
||||
writeConfig(dirEsm, { branching_strategy: 'phase' });
|
||||
writeConfig(dirCjs, { branching_strategy: 'phase' });
|
||||
try {
|
||||
const esmR = await esm.migrateOnDisk(dirEsm);
|
||||
const cjsR = await cjs.migrateOnDisk(dirCjs);
|
||||
assert.strictEqual(cjsR.migrated, true);
|
||||
assert.strictEqual(esmR.migrated, true);
|
||||
assert.ok(cjsR.wrote !== null);
|
||||
assert.ok(esmR.wrote !== null);
|
||||
// Both should have normalized the disk file
|
||||
const cjsDisk = JSON.parse(readConfigRaw(dirCjs));
|
||||
const esmDisk = JSON.parse(readConfigRaw(dirEsm));
|
||||
assert.strictEqual(cjsDisk.branching_strategy, undefined);
|
||||
assert.strictEqual(esmDisk.branching_strategy, undefined);
|
||||
assert.strictEqual(cjsDisk.git?.branching_strategy, 'phase');
|
||||
assert.strictEqual(esmDisk.git?.branching_strategy, 'phase');
|
||||
} finally {
|
||||
cleanup(dirEsm);
|
||||
cleanup(dirCjs);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing file returns migrated:false, wrote:null', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-md-'));
|
||||
try {
|
||||
const esmR = await esm.migrateOnDisk(dir);
|
||||
const cjsR = await cjs.migrateOnDisk(dir);
|
||||
assert.strictEqual(cjsR.migrated, false);
|
||||
assert.strictEqual(esmR.migrated, false);
|
||||
assert.strictEqual(cjsR.wrote, null);
|
||||
assert.strictEqual(esmR.wrote, null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test: decisions.generated.cjs vs sdk/src/query/decisions.ts
|
||||
*
|
||||
* Verifies that the generated CJS artifact matches the SDK source-of-truth
|
||||
* for all supported ID formats (numeric and alphanumeric) and edge cases.
|
||||
*
|
||||
* Covers: Phase 6 (#3575) MIGRATE_ME resolution for decisions.cjs.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { describe, test } = require('node:test');
|
||||
const { parseDecisions } = require('../get-shit-done/bin/lib/decisions.cjs');
|
||||
|
||||
// ─── Core parity: numeric IDs (legacy format) ────────────────────────────────
|
||||
|
||||
describe('decisions-generator parity — numeric IDs (legacy)', () => {
|
||||
test('extracts D-NN entries with {id, text}', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Auth
|
||||
- **D-01:** Use OAuth 2.0 with PKCE
|
||||
- **D-02:** Session storage in Redis
|
||||
|
||||
### Storage
|
||||
- **D-03:** Postgres 15 with pgvector
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02', 'D-03']);
|
||||
assert.strictEqual(ds[0].text, 'Use OAuth 2.0 with PKCE');
|
||||
});
|
||||
|
||||
test('returns [] when no <decisions> block is present', () => {
|
||||
assert.deepStrictEqual(parseDecisions('# Just a header\nno decisions here'), []);
|
||||
});
|
||||
|
||||
test('returns [] for empty / null / undefined input', () => {
|
||||
assert.deepStrictEqual(parseDecisions(''), []);
|
||||
assert.deepStrictEqual(parseDecisions(null), []);
|
||||
assert.deepStrictEqual(parseDecisions(undefined), []);
|
||||
});
|
||||
|
||||
test('ignores D-IDs outside the <decisions> block', () => {
|
||||
const md = `
|
||||
Top of file. - **D-99:** Not a real decision (outside block).
|
||||
<decisions>
|
||||
- **D-01:** Real decision
|
||||
</decisions>
|
||||
After the block. - **D-77:** Also not real.
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 6 extension: alphanumeric IDs ─────────────────────────────────────
|
||||
|
||||
describe('decisions-generator parity — alphanumeric IDs (Phase 6 extension)', () => {
|
||||
test('accepts alphanumeric IDs: D-INFRA-01', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Infrastructure
|
||||
- **D-INFRA-01:** Use Kubernetes for orchestration
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds.length, 1);
|
||||
assert.strictEqual(ds[0].id, 'D-INFRA-01');
|
||||
assert.strictEqual(ds[0].text, 'Use Kubernetes for orchestration');
|
||||
});
|
||||
|
||||
test('accepts alphanumeric IDs: D-42 (single numeric)', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Architecture
|
||||
- **D-42:** Use microservices
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].id, 'D-42');
|
||||
});
|
||||
|
||||
test('accepts mixed numeric and alphanumeric IDs in same block', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Planning
|
||||
- **D-01:** First numeric decision
|
||||
- **D-FOO_BAR:** Alphanumeric with underscore
|
||||
- **D-ARCH-123:** Mixed alphanumeric with hyphen
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
const ids = ds.map(d => d.id);
|
||||
assert.ok(ids.includes('D-01'), 'should have D-01');
|
||||
assert.ok(ids.includes('D-FOO_BAR'), 'should have D-FOO_BAR');
|
||||
assert.ok(ids.includes('D-ARCH-123'), 'should have D-ARCH-123');
|
||||
});
|
||||
|
||||
test('CJS callers can use {id, text} shape — extra fields present but safe to ignore', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Category
|
||||
- **D-INFRA-01:** Database selection
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
const d = ds[0];
|
||||
// Verify {id, text} is present as CJS callers expect
|
||||
assert.strictEqual(typeof d.id, 'string');
|
||||
assert.strictEqual(typeof d.text, 'string');
|
||||
// Extra SDK fields are present but can be ignored
|
||||
assert.ok('category' in d, 'category field present');
|
||||
assert.ok('tags' in d, 'tags field present');
|
||||
assert.ok('trackable' in d, 'trackable field present');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Richer schema fields (SDK extension) ────────────────────────────────────
|
||||
|
||||
describe('decisions-generator parity — richer schema', () => {
|
||||
test('marks decisions under "Claude\'s Discretion" as non-trackable', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Claude's Discretion
|
||||
- **D-50:** Internal naming is flexible
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('marks [informational] tagged decisions as non-trackable', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Info
|
||||
- **D-03 [informational]:** Background context only
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
assert.ok(ds[0].tags.includes('informational'));
|
||||
});
|
||||
|
||||
test('marks [folded] tagged decisions as non-trackable', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Deferred
|
||||
- **D-05 [folded]:** Will handle later
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('extracts category from ### heading', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Storage Backend
|
||||
- **D-01:** Use PostgreSQL
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].category, 'Storage Backend');
|
||||
});
|
||||
|
||||
test('parses ALL <decisions> blocks (not just first)', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### One
|
||||
- **D-01:** First batch
|
||||
</decisions>
|
||||
|
||||
Some prose.
|
||||
|
||||
<decisions>
|
||||
### Two
|
||||
- **D-02:** Second batch
|
||||
</decisions>
|
||||
`;
|
||||
const ids = parseDecisions(md).map(d => d.id);
|
||||
assert.ok(ids.includes('D-01'));
|
||||
assert.ok(ids.includes('D-02'));
|
||||
});
|
||||
|
||||
test('strips fenced code blocks before parsing', () => {
|
||||
const md = `
|
||||
\`\`\`
|
||||
<decisions>
|
||||
### Fake
|
||||
- **D-99:** Should not be parsed
|
||||
</decisions>
|
||||
\`\`\`
|
||||
|
||||
<decisions>
|
||||
### Real
|
||||
- **D-01:** Real decision
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
const ids = ds.map(d => d.id);
|
||||
assert.ok(ids.includes('D-01'));
|
||||
assert.ok(!ids.includes('D-99'));
|
||||
});
|
||||
|
||||
test('curly-quote "Claude’s Discretion" variant is non-trackable', () => {
|
||||
const content =
|
||||
'<decisions>\n### Claude’s Discretion\n- **D-50:** Should be non-trackable\n</decisions>';
|
||||
const ds = parseDecisions(content);
|
||||
const d50 = ds.find(d => d.id === 'D-50');
|
||||
assert.ok(d50, 'D-50 should be found');
|
||||
assert.strictEqual(d50.trackable, false);
|
||||
});
|
||||
});
|
||||
@@ -1,351 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Generator correctness, parity, and atomicity (#3598).
|
||||
*
|
||||
* Existing per-generator tests (configuration-generator.test.cjs et al.)
|
||||
* assert positive-path behavior and CJS/ESM API parity for one or two
|
||||
* generators each. None of them assert:
|
||||
*
|
||||
* 1. The committed `.generated.cjs` is byte-equal to a fresh re-run
|
||||
* of the generator's `build*Cjs()` export (stale-but-timestamp-valid
|
||||
* detection — issue #3598 AC #4).
|
||||
* 2. The generator is deterministic — two back-to-back calls return
|
||||
* identical strings (AC #1, defends against time/random/env-order
|
||||
* sneaking into output).
|
||||
* 3. The runtime CJS alias surface and the SDK TS alias surface
|
||||
* expose the same canonical/alias set (AC #3, beyond timestamp
|
||||
* freshness).
|
||||
* 4. The live command registry contains no duplicate aliases
|
||||
* (AC #1: "duplicate aliases" — translated to a behavioral
|
||||
* structural invariant on the in-memory registry the generator
|
||||
* reads from, since the generator has no fixture/--source seam).
|
||||
* 5. `build-hooks.js` is idempotent (running twice leaves `hooks/dist/`
|
||||
* byte-identical and clears its per-PID staging directory — proves
|
||||
* the atomic-write seam does not leak partial artifacts; AC #2).
|
||||
*
|
||||
* This suite fills those gaps without duplicating any happy-path
|
||||
* coverage that already exists.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const sdkScriptUrl = (script) =>
|
||||
new URL(`file://${path.join(REPO_ROOT, 'sdk', 'scripts', script)}`).href;
|
||||
|
||||
function sha256(buf) {
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeEol(text) {
|
||||
return String(text).replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
/** Read a directory recursively into a Map<relPath, sha256>. */
|
||||
function snapshotDir(dir) {
|
||||
const out = new Map();
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
const walk = (sub) => {
|
||||
for (const e of fs.readdirSync(sub, { withFileTypes: true })) {
|
||||
const full = path.join(sub, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else if (e.isFile()) out.set(path.relative(dir, full), sha256(fs.readFileSync(full)));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Suite 1: build*Cjs() === committed file (stale-detection) ──────────────
|
||||
|
||||
// Each row: { script, exportName, committed }
|
||||
// `committed` is the path the generator writes to. The generator's exported
|
||||
// build*Cjs() must produce a string equal to fs.readFileSync(committed). If
|
||||
// the committed file has drifted (manual edit, partial generator run,
|
||||
// stale-but-timestamp-valid), this assertion fails — which is the
|
||||
// behavioral coverage the issue's AC #4 calls for.
|
||||
const CJS_GENERATORS = [
|
||||
{ script: 'gen-configuration.mjs', exportName: 'buildConfigurationCjs', committed: 'get-shit-done/bin/lib/configuration.generated.cjs' },
|
||||
{ script: 'gen-project-root.mjs', exportName: 'buildProjectRootCjs', committed: 'get-shit-done/bin/lib/project-root.generated.cjs' },
|
||||
{ script: 'gen-state-document.ts', exportName: 'buildStateDocumentCjs', committed: 'get-shit-done/bin/lib/state-document.generated.cjs' },
|
||||
{ script: 'gen-workstream-inventory-builder.mjs', exportName: 'buildWorkstreamInventoryBuilderCjs', committed: 'get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs' },
|
||||
{ script: 'gen-decisions.mjs', exportName: 'buildDecisionsCjs', committed: 'get-shit-done/bin/lib/decisions.generated.cjs' },
|
||||
{ script: 'gen-plan-scan.mjs', exportName: 'buildPlanScanCjs', committed: 'get-shit-done/bin/lib/plan-scan.generated.cjs' },
|
||||
{ script: 'gen-schema-detect.mjs', exportName: 'buildSchemaDetectCjs', committed: 'get-shit-done/bin/lib/schema-detect.generated.cjs' },
|
||||
{ script: 'gen-secrets.mjs', exportName: 'buildSecretsCjs', committed: 'get-shit-done/bin/lib/secrets.generated.cjs' },
|
||||
{ script: 'gen-workstream-name-policy.mjs', exportName: 'buildWorkstreamNamePolicyCjs', committed: 'get-shit-done/bin/lib/workstream-name-policy.generated.cjs' },
|
||||
];
|
||||
|
||||
describe('feat-3598: build*Cjs() output matches committed .generated.cjs (stale-detection)', () => {
|
||||
for (const g of CJS_GENERATORS) {
|
||||
test(`${g.script} → ${path.basename(g.committed)} is fresh`, async () => {
|
||||
// `.ts` generators need ts-node/loader to import directly from .cjs
|
||||
// tests. Skip them here — their fresh-check covers the same property
|
||||
// via the `check-*-fresh.mjs` subprocess pathway, which runs in CI.
|
||||
if (g.script.endsWith('.ts')) {
|
||||
return; // covered by sdk/scripts/check-state-document-fresh.mjs
|
||||
}
|
||||
const mod = await import(sdkScriptUrl(g.script));
|
||||
const builder = mod[g.exportName];
|
||||
assert.equal(typeof builder, 'function',
|
||||
`${g.script} must export ${g.exportName} as a function`);
|
||||
|
||||
const fresh = await builder();
|
||||
assert.equal(typeof fresh, 'string', `${g.exportName}() must return a string`);
|
||||
const committedPath = path.join(REPO_ROOT, g.committed);
|
||||
const committed = fs.readFileSync(committedPath, 'utf-8');
|
||||
assert.equal(normalizeEol(fresh), normalizeEol(committed),
|
||||
`${g.committed} drifted from generator output — run "cd sdk && npm run gen:${g.script.replace(/^gen-/, '').replace(/\.mjs$/, '')}" to regenerate`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 2: generators are deterministic ──────────────────────────────────
|
||||
|
||||
describe('feat-3598: build*Cjs() is deterministic across calls', () => {
|
||||
for (const g of CJS_GENERATORS) {
|
||||
test(`${g.exportName} produces identical output on back-to-back calls`, async () => {
|
||||
if (g.script.endsWith('.ts')) return; // see suite 1 note
|
||||
const mod = await import(sdkScriptUrl(g.script));
|
||||
const builder = mod[g.exportName];
|
||||
const a = await builder();
|
||||
const b = await builder();
|
||||
assert.equal(a, b,
|
||||
`${g.exportName}() output differed between two calls — generator has non-deterministic input ` +
|
||||
`(time, random, env-order, Map/Set iteration)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 3: runtime CJS ↔ SDK TS alias parity ─────────────────────────────
|
||||
|
||||
describe('feat-3598: command-aliases CJS and TS surfaces expose the same alias set', () => {
|
||||
const CJS_PATH = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs');
|
||||
const TS_PATH = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-aliases.generated.ts');
|
||||
|
||||
test('both files exist', () => {
|
||||
assert.ok(fs.existsSync(CJS_PATH), `missing ${CJS_PATH}`);
|
||||
assert.ok(fs.existsSync(TS_PATH), `missing ${TS_PATH}`);
|
||||
});
|
||||
|
||||
test('canonical command names are identical between runtimes', () => {
|
||||
const cjs = require(CJS_PATH);
|
||||
const cjsArrays = [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
];
|
||||
const cjsCanonicals = new Set();
|
||||
for (const key of cjsArrays) {
|
||||
assert.ok(Array.isArray(cjs[key]), `CJS export ${key} must be an array`);
|
||||
for (const e of cjs[key]) cjsCanonicals.add(e.canonical);
|
||||
}
|
||||
|
||||
// The TS file is the same data emitted as a TS source. Read it and
|
||||
// extract canonical strings via a structural pattern (`canonical: '...'`)
|
||||
// restricted to the generated-file format the generator emits — never
|
||||
// a free-form text scan. This satisfies the CONTRIBUTING typed-IR
|
||||
// requirement because the source file *is* the generator's output:
|
||||
// its lexical shape is part of the deployed contract.
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
const ts = fs.readFileSync(TS_PATH, 'utf-8');
|
||||
const tsCanonicals = new Set(
|
||||
[...ts.matchAll(/canonical:\s*'([^']+)'/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...tsCanonicals].sort(),
|
||||
[...cjsCanonicals].sort(),
|
||||
'TS and CJS surfaces must expose the same canonical command set — regenerate via "cd sdk && npm run gen:command-aliases"',
|
||||
);
|
||||
});
|
||||
|
||||
test('alias strings are identical between runtimes (set equality)', () => {
|
||||
const cjs = require(CJS_PATH);
|
||||
const cjsAliases = new Set();
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) for (const a of e.aliases || []) cjsAliases.add(a);
|
||||
}
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
const ts = fs.readFileSync(TS_PATH, 'utf-8');
|
||||
// Aliases are emitted as: aliases: ['foo', 'bar', 'baz']
|
||||
// Match the literal-array bodies, then extract individual quoted strings.
|
||||
const tsAliases = new Set();
|
||||
for (const m of ts.matchAll(/aliases:\s*\[([^\]]*)\]/g)) {
|
||||
for (const a of m[1].matchAll(/'([^']+)'/g)) tsAliases.add(a[1]);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
[...tsAliases].sort(),
|
||||
[...cjsAliases].sort(),
|
||||
'TS and CJS alias sets must be identical — regenerate via "cd sdk && npm run gen:command-aliases"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: no duplicate aliases in the live registry ─────────────────────
|
||||
|
||||
describe('feat-3598: live command registry has no duplicate aliases', () => {
|
||||
// This is the behavioral equivalent of the issue's example test
|
||||
// "generator rejects duplicate command aliases with actionable error".
|
||||
// The generator has no fixture seam — it reads in-memory
|
||||
// COMMAND_DEFINITIONS_BY_FAMILY. The structural invariant that the
|
||||
// generator MUST emit a registry with no duplicates is what we assert
|
||||
// here, on the deployed surface. If two definitions ever collide,
|
||||
// this test fails with the colliding alias named — actionable in the
|
||||
// same way the issue's example error would be.
|
||||
test('no alias appears twice across all command families', () => {
|
||||
const cjs = require(path.join(
|
||||
REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs',
|
||||
));
|
||||
const seen = new Map(); // alias → canonical
|
||||
const collisions = [];
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) {
|
||||
for (const a of e.aliases || []) {
|
||||
if (seen.has(a)) {
|
||||
collisions.push(`alias "${a}" claimed by both "${seen.get(a)}" and "${e.canonical}"`);
|
||||
} else {
|
||||
seen.set(a, e.canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(collisions.length, 0,
|
||||
`duplicate aliases in command registry:\n ${collisions.join('\n ')}`);
|
||||
});
|
||||
|
||||
test('no canonical command appears twice', () => {
|
||||
const cjs = require(path.join(
|
||||
REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs',
|
||||
));
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) {
|
||||
if (seen.has(e.canonical)) duplicates.push(e.canonical);
|
||||
seen.add(e.canonical);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(duplicates, [],
|
||||
`canonical command appears in more than one family: ${duplicates.join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: build-hooks atomicity (idempotence + no orphan staging) ───────
|
||||
|
||||
describe('feat-3598: build-hooks.js is idempotent and leaves no staging residue', () => {
|
||||
const HOOKS_DIR = path.join(REPO_ROOT, 'hooks');
|
||||
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
|
||||
|
||||
// A baseline snapshot taken once before either run. The "fresh dist" the
|
||||
// first run produces is compared to the second run's dist. We do NOT
|
||||
// compare against the disk state before the first run, because the
|
||||
// baseline dist on disk could itself be stale at the time the suite
|
||||
// happens to run (e.g. a fresh clone with an old build artifact).
|
||||
let snapshotA;
|
||||
let stagingBefore;
|
||||
|
||||
before(() => {
|
||||
// Run build:hooks once to land a known-fresh dist.
|
||||
const r1 = spawnSync(process.execPath, [path.join('scripts', 'build-hooks.js')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert.equal(r1.status, 0,
|
||||
`build-hooks first run must exit 0; stderr=${r1.stderr.slice(0, 400)}`);
|
||||
snapshotA = snapshotDir(DIST_DIR);
|
||||
assert.ok(snapshotA.size > 0, 'build-hooks must produce at least one file in hooks/dist/');
|
||||
|
||||
stagingBefore = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((n) => n.startsWith('.dist-staging-'));
|
||||
});
|
||||
|
||||
test('second run produces byte-identical hooks/dist/ contents', () => {
|
||||
const r2 = spawnSync(process.execPath, [path.join('scripts', 'build-hooks.js')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert.equal(r2.status, 0,
|
||||
`build-hooks second run must exit 0; stderr=${r2.stderr.slice(0, 400)}`);
|
||||
|
||||
const snapshotB = snapshotDir(DIST_DIR);
|
||||
assert.equal(snapshotB.size, snapshotA.size,
|
||||
`hooks/dist/ file count must be stable: a=${snapshotA.size} b=${snapshotB.size}`);
|
||||
for (const [rel, hashA] of snapshotA) {
|
||||
const hashB = snapshotB.get(rel);
|
||||
assert.equal(hashB, hashA,
|
||||
`hooks/dist/${rel} changed between consecutive runs (atomic-write must produce identical output)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no orphaned .dist-staging-* directories remain after the run', () => {
|
||||
// The second run (just above) creates and cleans its own staging dir.
|
||||
// Any leftover with the second-run's PID would prove the cleanup
|
||||
// step failed. We can only check the *current* state — that is, any
|
||||
// staging directory that was not present before the build started.
|
||||
const stagingAfter = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((n) => n.startsWith('.dist-staging-'));
|
||||
const orphaned = stagingAfter.filter((n) => !stagingBefore.includes(n));
|
||||
assert.deepEqual(orphaned, [],
|
||||
`build-hooks left orphaned staging directories: ${orphaned.join(', ')}`);
|
||||
});
|
||||
|
||||
test('every file in hooks/dist/ that is JavaScript parses without SyntaxError', () => {
|
||||
// Positive proof of the build-hooks syntax guard: every shipped .js
|
||||
// file must parse. If a SyntaxError survives the guard and lands in
|
||||
// dist/, this assertion fails with the file named.
|
||||
const vm = require('node:vm');
|
||||
for (const [rel] of snapshotDir(DIST_DIR)) {
|
||||
if (!rel.endsWith('.js')) continue;
|
||||
const src = fs.readFileSync(path.join(DIST_DIR, rel), 'utf-8');
|
||||
assert.doesNotThrow(
|
||||
() => new vm.Script(src, { filename: rel }),
|
||||
`hooks/dist/${rel} has a SyntaxError — build-hooks syntax guard let it through`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for the requireFreshDist() staleness guard in gen-*.mjs scripts.
|
||||
*
|
||||
* For each generator, verifies:
|
||||
* 1. Exits 1 when sdk/dist file does not exist — error includes "does not exist"
|
||||
* and the `npm run build:sdk` hint.
|
||||
* 2. Exits 1 when TS source is newer than sdk/dist — error includes
|
||||
* "is stale relative to", both mtime timestamps, the TS path, and build hint.
|
||||
* 3. Exits 0 when sdk/dist is newer than TS source (fresh build).
|
||||
* Skipped per-generator if dist doesn't exist (expected before first build:sdk).
|
||||
*
|
||||
* Uses child_process.spawnSync to exercise the real gen-*.mjs entry path.
|
||||
*
|
||||
* Isolation: each subtest creates its own temp directory rooted at a unique
|
||||
* path and passes GSD_REPO_ROOT to the generator subprocess so requireFreshDist()
|
||||
* operates on temp fixtures instead of the real sdk/dist tree. This prevents
|
||||
* parallel test execution from seeing stale/missing dist files in the live tree.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SCRIPTS_DIR = path.join(REPO_ROOT, 'sdk', 'scripts');
|
||||
|
||||
// Map of generator script → { dist, ts } repo-relative paths
|
||||
const GENERATORS = [
|
||||
{
|
||||
script: 'gen-plan-scan.mjs',
|
||||
dist: 'sdk/dist/query/plan-scan.js',
|
||||
ts: 'sdk/src/query/plan-scan.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-secrets.mjs',
|
||||
dist: 'sdk/dist/query/secrets.js',
|
||||
ts: 'sdk/src/query/secrets.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-schema-detect.mjs',
|
||||
dist: 'sdk/dist/query/schema-detect.js',
|
||||
ts: 'sdk/src/query/schema-detect.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-decisions.mjs',
|
||||
dist: 'sdk/dist/query/decisions.js',
|
||||
ts: 'sdk/src/query/decisions.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-project-root.mjs',
|
||||
dist: 'sdk/dist/runtime/project-root.js',
|
||||
ts: 'sdk/src/runtime/project-root.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-workstream-inventory-builder.mjs',
|
||||
dist: 'sdk/dist/workstream/builder.js',
|
||||
ts: 'sdk/src/workstream/builder.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-workstream-name-policy.mjs',
|
||||
dist: 'sdk/dist/workstream-name-policy.js',
|
||||
ts: 'sdk/src/workstream-name-policy.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-validate.mjs',
|
||||
dist: 'sdk/dist/query/validate.js',
|
||||
ts: 'sdk/src/query/validate.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-configuration.mjs',
|
||||
dist: 'sdk/dist/config/index.js',
|
||||
ts: 'sdk/src/config/index.ts',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Run a gen script via spawnSync with an optional GSD_REPO_ROOT override.
|
||||
* Returns { status, stderr, stdout }.
|
||||
*/
|
||||
function runGen(scriptName, env = {}) {
|
||||
const scriptPath = path.join(SCRIPTS_DIR, scriptName);
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return {
|
||||
status: result.status ?? 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal temp directory tree that mirrors the repo layout for
|
||||
* the given dist and ts paths. Returns { tmpRoot, distAbs, tsAbs, cleanup }.
|
||||
*
|
||||
* The real TS source is copied into the temp tree so its content is valid,
|
||||
* but the caller controls whether the dist file exists and what its mtime is.
|
||||
*/
|
||||
function makeTempTree(dist, ts) {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staleness-'));
|
||||
const distAbs = path.join(tmpRoot, dist);
|
||||
const tsAbs = path.join(tmpRoot, ts);
|
||||
|
||||
// Always create the TS source (copy from real tree so content is valid)
|
||||
fs.mkdirSync(path.dirname(tsAbs), { recursive: true });
|
||||
const realTsAbs = path.join(REPO_ROOT, ts);
|
||||
fs.copyFileSync(realTsAbs, tsAbs);
|
||||
|
||||
function cleanup() {
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
return { tmpRoot, distAbs, tsAbs, cleanup };
|
||||
}
|
||||
|
||||
// One describe block per generator — each block is { concurrency: false } so
|
||||
// its three subtests run in order and don't race on the same files.
|
||||
for (const { script, dist, ts } of GENERATORS) {
|
||||
describe(`gen staleness guard — ${script}`, { concurrency: false }, () => {
|
||||
// ── Subtest A: dist file is missing ──────────────────────────────────────
|
||||
test('exits 1 with "does not exist" when dist file is absent', () => {
|
||||
const { tmpRoot, cleanup } = makeTempTree(dist, ts);
|
||||
// dist is NOT created — the temp tree has only the TS source
|
||||
try {
|
||||
const { status, stderr } = runGen(script, { GSD_REPO_ROOT: tmpRoot });
|
||||
assert.strictEqual(status, 1, `Expected exit 1, got ${status}. stderr: ${stderr}`);
|
||||
assert.match(stderr, /does not exist/, `Expected "does not exist". Got: ${stderr}`);
|
||||
assert.match(stderr, /npm run build/, `Expected build hint. Got: ${stderr}`);
|
||||
// Error message should name the dist path (repo-relative)
|
||||
assert.ok(stderr.includes(dist), `Expected dist path in message. Got: ${stderr}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Subtest B: TS source newer than dist ─────────────────────────────────
|
||||
test('exits 1 with stale-dist error when TS source is newer than dist', () => {
|
||||
const { tmpRoot, distAbs, tsAbs, cleanup } = makeTempTree(dist, ts);
|
||||
|
||||
// Create a fake dist file
|
||||
fs.mkdirSync(path.dirname(distAbs), { recursive: true });
|
||||
fs.writeFileSync(distAbs, '// fake dist for staleness test\n', 'utf-8');
|
||||
|
||||
// Set dist mtime 2s in the past, ts mtime to now → ts is newer
|
||||
const past = new Date(Date.now() - 2000);
|
||||
const now = new Date();
|
||||
fs.utimesSync(distAbs, past, past);
|
||||
fs.utimesSync(tsAbs, now, now);
|
||||
|
||||
try {
|
||||
const { status, stderr } = runGen(script, { GSD_REPO_ROOT: tmpRoot });
|
||||
assert.strictEqual(status, 1, `Expected exit 1 for stale dist, got ${status}. stderr: ${stderr}`);
|
||||
assert.match(stderr, /is stale relative to/, `Expected "is stale relative to". Got: ${stderr}`);
|
||||
assert.match(stderr, /npm run build/, `Expected build hint. Got: ${stderr}`);
|
||||
// Error must name the TS source path
|
||||
assert.ok(stderr.includes(ts), `Expected TS path "${ts}" in message. Got: ${stderr}`);
|
||||
// Error must include both mtime timestamps in ISO format
|
||||
assert.match(stderr, /dist mtime \d{4}-\d{2}-\d{2}T/, `Expected dist mtime in message. Got: ${stderr}`);
|
||||
assert.match(stderr, /ts mtime \d{4}-\d{2}-\d{2}T/, `Expected ts mtime in message. Got: ${stderr}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Subtest C: dist is fresh → exits 0 ───────────────────────────────────
|
||||
// This subtest requires a real built dist (the generator reads and transforms
|
||||
// its content), so it uses the actual sdk/dist tree rather than the temp dir.
|
||||
// It only mutates the real TS source mtime, not the dist file, so it is safe
|
||||
// to run in parallel: another parallel test process reading sdk/dist will not
|
||||
// be affected by a TS source mtime change.
|
||||
test('exits 0 when dist is newer than TS source', { skip: !fs.existsSync(path.join(REPO_ROOT, dist)) ? `${dist} not built` : false }, () => {
|
||||
const distAbs = path.join(REPO_ROOT, dist);
|
||||
const tsAbs = path.join(REPO_ROOT, ts);
|
||||
|
||||
const distStat = fs.statSync(distAbs);
|
||||
const origTsStat = fs.statSync(tsAbs);
|
||||
|
||||
// Set ts mtime to 2s before dist mtime so dist is definitely newer
|
||||
const tsOlderThanDist = new Date(distStat.mtimeMs - 2000);
|
||||
fs.utimesSync(tsAbs, tsOlderThanDist, tsOlderThanDist);
|
||||
|
||||
try {
|
||||
const { status, stderr, stdout } = runGen(script);
|
||||
assert.strictEqual(
|
||||
status,
|
||||
0,
|
||||
`Expected exit 0 for fresh dist, got ${status}. stderr: ${stderr}\nstdout: ${stdout}`,
|
||||
);
|
||||
} finally {
|
||||
fs.utimesSync(tsAbs, origTsStat.atime, origTsStat.mtime);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that plan-scan.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: import('../sdk/dist/query/plan-scan.js')
|
||||
* CJS side: require('../get-shit-done/bin/lib/plan-scan.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Build a unique-to-this-run path that is guaranteed not to exist. Hardcoded
|
||||
* `/tmp/...` paths are a flake source on shared CI runners where the path can
|
||||
* be left over from a prior run. We synthesize a random suffix under
|
||||
* `os.tmpdir()` and force-remove the path first.
|
||||
*/
|
||||
function uniqueMissingPath(prefix = 'gsd-missing') {
|
||||
const suffix = `${prefix}-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
|
||||
const p = path.join(os.tmpdir(), suffix);
|
||||
// The probability of collision is negligible, but force-clean anyway to make
|
||||
// the precondition explicit. Errors swallowed (path didn't exist — desired).
|
||||
try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
return p;
|
||||
}
|
||||
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/plan-scan.generated.cjs');
|
||||
|
||||
// ── isRootPlanFile ────────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isRootPlanFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts bare PLAN.md', name: 'PLAN.md', expected: true },
|
||||
{ label: 'accepts canonical -PLAN.md', name: '01-01-PLAN.md', expected: true },
|
||||
{ label: 'accepts extended PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true },
|
||||
{ label: 'rejects -PLAN-OUTLINE.md', name: 'something-PLAN-OUTLINE.md', expected: false },
|
||||
{ label: 'rejects .pre-bounce.md', name: 'PLAN.pre-bounce.md', expected: false },
|
||||
{ label: 'rejects SUMMARY.md', name: 'SUMMARY.md', expected: false },
|
||||
{ label: 'rejects unrelated file', name: 'README.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isRootPlanFile(name);
|
||||
const cjsResult = cjs.isRootPlanFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isNestedPlanFile ──────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isNestedPlanFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true },
|
||||
{ label: 'accepts 1-PLAN-01-setup.md', name: '1-PLAN-01-setup.md', expected: true },
|
||||
{ label: 'rejects PLAN-OUTLINE.md', name: 'PLAN-01-OUTLINE.md', expected: false },
|
||||
{ label: 'rejects .pre-bounce.md', name: 'PLAN-01.pre-bounce.md', expected: false },
|
||||
{ label: 'rejects bare PLAN.md', name: 'PLAN.md', expected: false },
|
||||
{ label: 'rejects unrelated file', name: 'SUMMARY-01-setup.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isNestedPlanFile(name);
|
||||
const cjsResult = cjs.isNestedPlanFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isRootSummaryFile ─────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isRootSummaryFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts bare SUMMARY.md', name: 'SUMMARY.md', expected: true },
|
||||
{ label: 'accepts 01-01-SUMMARY.md', name: '01-01-SUMMARY.md', expected: true },
|
||||
{ label: 'rejects PLAN.md', name: 'PLAN.md', expected: false },
|
||||
{ label: 'rejects unrelated file', name: 'README.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isRootSummaryFile(name);
|
||||
const cjsResult = cjs.isRootSummaryFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isNestedSummaryFile ───────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isNestedSummaryFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts SUMMARY-01-summary.md', name: 'SUMMARY-01-summary.md', expected: true },
|
||||
{ label: 'accepts 1-SUMMARY-01.md', name: '1-SUMMARY-01.md', expected: true },
|
||||
{ label: 'rejects bare SUMMARY.md', name: 'SUMMARY.md', expected: false },
|
||||
{ label: 'rejects PLAN file', name: 'PLAN-01-setup.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isNestedSummaryFile(name);
|
||||
const cjsResult = cjs.isNestedSummaryFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── scanPhasePlans ────────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: scanPhasePlans (non-existent dir)', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
test('returns zero counts for non-existent directory', () => {
|
||||
const nonExistent = uniqueMissingPath('gsd-plan-scan-nonexistent');
|
||||
const sdkResult = sdk.scanPhasePlans(nonExistent);
|
||||
const cjsResult = cjs.scanPhasePlans(nonExistent);
|
||||
assert.deepStrictEqual(sdkResult, {
|
||||
planCount: 0,
|
||||
summaryCount: 0,
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
summaryFiles: [],
|
||||
});
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: non-existent dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-scan-generator parity: scanPhasePlans (flat layout)', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
test('detects flat plan and summary files', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-test-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(tmpDir, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Readme');
|
||||
|
||||
const sdkResult = sdk.scanPhasePlans(tmpDir);
|
||||
const cjsResult = cjs.scanPhasePlans(tmpDir);
|
||||
|
||||
assert.strictEqual(sdkResult.planCount, 1, 'SDK: planCount');
|
||||
assert.strictEqual(sdkResult.summaryCount, 1, 'SDK: summaryCount');
|
||||
assert.strictEqual(sdkResult.completed, true, 'SDK: completed');
|
||||
assert.strictEqual(sdkResult.hasNestedPlans, false, 'SDK: hasNestedPlans');
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: flat layout');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-scan-generator parity: module.exports call style', async () => {
|
||||
test('default export is callable as function (CJS caller pattern)', () => {
|
||||
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
|
||||
// then call it directly: scanPhasePlans(phaseDir)
|
||||
assert.strictEqual(typeof cjs, 'function', 'default export is a function');
|
||||
const result = cjs(uniqueMissingPath('gsd-plan-scan-cjs-default'));
|
||||
assert.deepStrictEqual(result, {
|
||||
planCount: 0,
|
||||
summaryCount: 0,
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
summaryFiles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* CJS parity test — project-root module
|
||||
*
|
||||
* For every fixture from sdk/src/runtime/project-root.test.ts, asserts that
|
||||
* both the SDK (ESM, via dynamic import) and the generated CJS artifact
|
||||
* return identical paths. This confirms that the generator correctly
|
||||
* captures the function body and that all dependencies (sep, dirname,
|
||||
* relative, etc.) are properly shimmed in the CJS preamble.
|
||||
*/
|
||||
|
||||
const { describe, it, before, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
// CJS artifact — synchronous require
|
||||
const { findProjectRoot: findProjectRootCjs } = require('../get-shit-done/bin/lib/project-root.generated.cjs');
|
||||
|
||||
// SDK ESM — loaded once before all tests via dynamic import
|
||||
let findProjectRootSdk;
|
||||
before(async () => {
|
||||
const mod = await import('../sdk/dist/runtime/project-root.js');
|
||||
findProjectRootSdk = mod.findProjectRoot;
|
||||
});
|
||||
|
||||
// ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir } = require('./helpers.cjs');
|
||||
const makeTmp = () => createTempDir('gsd-parity-');
|
||||
|
||||
function writeConfig(dir, content) {
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(content));
|
||||
}
|
||||
|
||||
function assertParity(startDir) {
|
||||
const sdkResult = findProjectRootSdk(startDir);
|
||||
const cjsResult = findProjectRootCjs(startDir);
|
||||
assert.strictEqual(
|
||||
cjsResult,
|
||||
sdkResult,
|
||||
`parity failure for startDir="${startDir}": SDK="${sdkResult}" CJS="${cjsResult}"`,
|
||||
);
|
||||
return sdkResult;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('project-root CJS/SDK parity', () => {
|
||||
let workspace;
|
||||
|
||||
before(() => {
|
||||
workspace = makeTmp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean the workspace tree and recreate fresh for next test
|
||||
try { fs.rmSync(workspace, { recursive: true, force: true }); } catch {}
|
||||
workspace = makeTmp();
|
||||
});
|
||||
|
||||
it('heuristic 0: startDir has own .planning/ — returns startDir', () => {
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
assertParity(workspace);
|
||||
});
|
||||
|
||||
it('no ancestor .planning/ — returns startDir', () => {
|
||||
assertParity(workspace);
|
||||
});
|
||||
|
||||
it('heuristic 1: parent .planning/config.json lists child in sub_repos', () => {
|
||||
writeConfig(workspace, { sub_repos: ['child'] });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 1 nested: deeply nested dir inside sub_repo', () => {
|
||||
writeConfig(workspace, { sub_repos: ['child'] });
|
||||
const nested = path.join(workspace, 'child', 'src', 'utils');
|
||||
fs.mkdirSync(path.join(workspace, 'child', '.git'), { recursive: true });
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
const result = assertParity(nested);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 1 nested key: planning.sub_repos config shape', () => {
|
||||
writeConfig(workspace, { planning: { sub_repos: ['child'] } });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 2: multiRepo: true with .git in ancestor chain', () => {
|
||||
writeConfig(workspace, { multiRepo: true });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 3: parent has .planning/ and child has .git, no config', () => {
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('malformed config.json falls back to heuristic 3', () => {
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, '.planning', 'config.json'), '{ not json');
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('empty sub_repos with no .git — returns startDir', () => {
|
||||
writeConfig(workspace, { sub_repos: [] });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, child);
|
||||
});
|
||||
|
||||
it('#1362: child has own .planning/ — returns child not workspace', () => {
|
||||
writeConfig(workspace, { sub_repos: ['child'] });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.planning'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, child);
|
||||
});
|
||||
|
||||
it('depth limit: .planning/ is 12 levels up — returns startDir (depth=10 cap)', () => {
|
||||
// CANONICALIZATION NOTE: SDK has FIND_PROJECT_ROOT_MAX_DEPTH=10; CJS now
|
||||
// also uses 10 (was unbounded before). At 12 levels the walk stops before
|
||||
// reaching workspace, so startDir is returned.
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
let dir = workspace;
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
dir = path.join(dir, `l${i}`);
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const result = assertParity(dir);
|
||||
assert.strictEqual(result, dir);
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that schema-detect.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: import('../sdk/dist/query/schema-detect.js')
|
||||
* CJS side: require('../get-shit-done/bin/lib/schema-detect.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/schema-detect.generated.cjs');
|
||||
|
||||
// ── detectSchemaFiles ─────────────────────────────────────────────────────
|
||||
|
||||
describe('schema-detect-generator parity: detectSchemaFiles', async () => {
|
||||
const sdk = await import('../sdk/dist/query/schema-detect.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'detects prisma schema',
|
||||
files: ['prisma/schema.prisma'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['prisma'],
|
||||
},
|
||||
{
|
||||
label: 'detects drizzle schema',
|
||||
files: ['drizzle/schema.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['drizzle'],
|
||||
},
|
||||
{
|
||||
label: 'detects supabase migration',
|
||||
files: ['supabase/migrations/001_init.sql'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['supabase'],
|
||||
},
|
||||
{
|
||||
label: 'detects payload collection',
|
||||
files: ['src/collections/Users.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['payload'],
|
||||
},
|
||||
{
|
||||
label: 'detects typeorm entity',
|
||||
files: ['src/entities/User.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['typeorm'],
|
||||
},
|
||||
{
|
||||
label: 'no schema files returns not detected',
|
||||
files: ['src/components/Button.tsx', 'src/styles/main.css'],
|
||||
expectedDetected: false,
|
||||
expectedOrms: [],
|
||||
},
|
||||
{
|
||||
label: 'multiple ORMs detected',
|
||||
files: ['prisma/schema.prisma', 'drizzle/schema.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['prisma', 'drizzle'],
|
||||
},
|
||||
{
|
||||
label: 'normalizes Windows backslash paths',
|
||||
files: ['prisma\\schema.prisma'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['prisma'],
|
||||
},
|
||||
{
|
||||
label: 'empty file list returns not detected',
|
||||
files: [],
|
||||
expectedDetected: false,
|
||||
expectedOrms: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, files, expectedDetected, expectedOrms } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.detectSchemaFiles(files);
|
||||
const cjsResult = cjs.detectSchemaFiles(files);
|
||||
|
||||
assert.strictEqual(sdkResult.detected, expectedDetected, `SDK detected: ${label}`);
|
||||
assert.deepStrictEqual(sdkResult.orms.sort(), expectedOrms.sort(), `SDK orms: ${label}`);
|
||||
|
||||
assert.strictEqual(cjsResult.detected, expectedDetected, `CJS detected: ${label}`);
|
||||
assert.deepStrictEqual(cjsResult.orms.sort(), expectedOrms.sort(), `CJS orms: ${label}`);
|
||||
|
||||
// SDK and CJS must agree on detected and orms
|
||||
assert.strictEqual(sdkResult.detected, cjsResult.detected, `SDK/CJS parity detected: ${label}`);
|
||||
assert.deepStrictEqual(sdkResult.orms.sort(), cjsResult.orms.sort(), `SDK/CJS parity orms: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── checkSchemaDrift ──────────────────────────────────────────────────────
|
||||
|
||||
describe('schema-detect-generator parity: checkSchemaDrift', async () => {
|
||||
const sdk = await import('../sdk/dist/query/schema-detect.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'no schema files — no drift',
|
||||
changedFiles: ['src/components/Button.tsx'],
|
||||
executionLog: '',
|
||||
options: {},
|
||||
expectedDriftDetected: false,
|
||||
expectedBlocking: false,
|
||||
},
|
||||
{
|
||||
label: 'prisma changed with push evidence — no drift',
|
||||
changedFiles: ['prisma/schema.prisma'],
|
||||
executionLog: 'running: npx prisma db push --accept-data-loss',
|
||||
options: {},
|
||||
expectedDriftDetected: false,
|
||||
expectedBlocking: false,
|
||||
},
|
||||
{
|
||||
label: 'prisma changed without push — drift blocking',
|
||||
changedFiles: ['prisma/schema.prisma'],
|
||||
executionLog: 'tsc && vitest run',
|
||||
options: {},
|
||||
expectedDriftDetected: true,
|
||||
expectedBlocking: true,
|
||||
},
|
||||
{
|
||||
label: 'drift with skipCheck=true — not blocking',
|
||||
changedFiles: ['prisma/schema.prisma'],
|
||||
executionLog: 'tsc && vitest run',
|
||||
options: { skipCheck: true },
|
||||
expectedDriftDetected: true,
|
||||
expectedBlocking: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, changedFiles, executionLog, options, expectedDriftDetected, expectedBlocking } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.checkSchemaDrift(changedFiles, executionLog, options);
|
||||
const cjsResult = cjs.checkSchemaDrift(changedFiles, executionLog, options);
|
||||
|
||||
assert.strictEqual(sdkResult.driftDetected, expectedDriftDetected, `SDK driftDetected: ${label}`);
|
||||
assert.strictEqual(sdkResult.blocking, expectedBlocking, `SDK blocking: ${label}`);
|
||||
|
||||
assert.strictEqual(cjsResult.driftDetected, expectedDriftDetected, `CJS driftDetected: ${label}`);
|
||||
assert.strictEqual(cjsResult.blocking, expectedBlocking, `CJS blocking: ${label}`);
|
||||
|
||||
// Full structural parity between SDK and CJS
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── detectSchemaOrm (CJS-only compat export) ──────────────────────────────
|
||||
|
||||
describe('schema-detect-generator parity: detectSchemaOrm (CJS compat)', () => {
|
||||
test('returns ORM info for known orm', () => {
|
||||
const info = cjs.detectSchemaOrm('prisma');
|
||||
assert.ok(info !== null, 'prisma orm info should not be null');
|
||||
assert.ok(typeof info.pushCommand === 'string', 'pushCommand should be string');
|
||||
assert.ok(Array.isArray(info.evidencePatterns), 'evidencePatterns should be array');
|
||||
});
|
||||
|
||||
test('returns null for unknown orm', () => {
|
||||
const info = cjs.detectSchemaOrm('unknown_orm');
|
||||
assert.strictEqual(info, null, 'unknown orm should return null');
|
||||
});
|
||||
|
||||
test('returns info for all 5 known ORMs', () => {
|
||||
const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm'];
|
||||
for (const orm of orms) {
|
||||
const info = cjs.detectSchemaOrm(orm);
|
||||
assert.ok(info !== null, `${orm} info should not be null`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── SCHEMA_PATTERNS and ORM_INFO exports (compat) ────────────────────────
|
||||
|
||||
describe('schema-detect-generator: SCHEMA_PATTERNS and ORM_INFO exported', () => {
|
||||
test('SCHEMA_PATTERNS is an array', () => {
|
||||
assert.ok(Array.isArray(cjs.SCHEMA_PATTERNS), 'SCHEMA_PATTERNS should be an array');
|
||||
assert.ok(cjs.SCHEMA_PATTERNS.length > 0, 'SCHEMA_PATTERNS should not be empty');
|
||||
});
|
||||
|
||||
test('ORM_INFO has known orm keys', () => {
|
||||
const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm'];
|
||||
for (const orm of orms) {
|
||||
assert.ok(orm in cjs.ORM_INFO, `ORM_INFO should have key: ${orm}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that secrets.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: import('../sdk/dist/query/secrets.js')
|
||||
* CJS side: require('../get-shit-done/bin/lib/secrets.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/secrets.generated.cjs');
|
||||
|
||||
// ── SECRET_CONFIG_KEYS ────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: SECRET_CONFIG_KEYS', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
test('contains same keys as SDK', () => {
|
||||
const sdkKeys = [...sdk.SECRET_CONFIG_KEYS].sort();
|
||||
const cjsKeys = [...cjs.SECRET_CONFIG_KEYS].sort();
|
||||
assert.deepStrictEqual(cjsKeys, sdkKeys, 'SDK/CJS parity: SECRET_CONFIG_KEYS');
|
||||
});
|
||||
|
||||
test('contains brave_search', () => {
|
||||
assert.ok(cjs.SECRET_CONFIG_KEYS.has('brave_search'));
|
||||
assert.ok(sdk.SECRET_CONFIG_KEYS.has('brave_search'));
|
||||
});
|
||||
|
||||
test('contains firecrawl', () => {
|
||||
assert.ok(cjs.SECRET_CONFIG_KEYS.has('firecrawl'));
|
||||
assert.ok(sdk.SECRET_CONFIG_KEYS.has('firecrawl'));
|
||||
});
|
||||
|
||||
test('contains exa_search', () => {
|
||||
assert.ok(cjs.SECRET_CONFIG_KEYS.has('exa_search'));
|
||||
assert.ok(sdk.SECRET_CONFIG_KEYS.has('exa_search'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── isSecretKey ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: isSecretKey', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'brave_search is secret', key: 'brave_search', expected: true },
|
||||
{ label: 'firecrawl is secret', key: 'firecrawl', expected: true },
|
||||
{ label: 'exa_search is secret', key: 'exa_search', expected: true },
|
||||
{ label: 'non-secret key returns false', key: 'model', expected: false },
|
||||
{ label: 'empty string returns false', key: '', expected: false },
|
||||
{ label: 'unrelated string returns false', key: 'api_key', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, key, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isSecretKey(key);
|
||||
const cjsResult = cjs.isSecretKey(key);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── maskSecret ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: maskSecret', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'null returns (unset)', value: null, expected: '(unset)' },
|
||||
{ label: 'undefined returns (unset)', value: undefined, expected: '(unset)' },
|
||||
{ label: 'empty string returns (unset)', value: '', expected: '(unset)' },
|
||||
{ label: 'short string (< 8) returns ****', value: 'abc', expected: '****' },
|
||||
{ label: '7-char string returns ****', value: '1234567', expected: '****' },
|
||||
{ label: '8-char string returns ****<last-4>', value: '12345678', expected: '****5678' },
|
||||
{ label: 'long string returns ****<last-4>', value: 'sk-ant-abc123def456', expected: '****f456' },
|
||||
];
|
||||
|
||||
for (const { label, value, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.maskSecret(value);
|
||||
const cjsResult = cjs.maskSecret(value);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── maskIfSecret ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: maskIfSecret', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'secret key gets masked',
|
||||
key: 'brave_search',
|
||||
value: 'sk-ant-12345678',
|
||||
expectedType: 'string',
|
||||
expectedValue: '****5678',
|
||||
},
|
||||
{
|
||||
label: 'non-secret key returns value unchanged',
|
||||
key: 'model',
|
||||
value: 'claude-opus-4-5',
|
||||
expectedType: 'string',
|
||||
expectedValue: 'claude-opus-4-5',
|
||||
},
|
||||
{
|
||||
label: 'secret key with null value returns (unset)',
|
||||
key: 'firecrawl',
|
||||
value: null,
|
||||
expectedType: 'string',
|
||||
expectedValue: '(unset)',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, key, value, expectedValue } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.maskIfSecret(key, value);
|
||||
const cjsResult = cjs.maskIfSecret(key, value);
|
||||
assert.strictEqual(sdkResult, expectedValue, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expectedValue, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
'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/state/index.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/state/index.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/state/index.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/state/index.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/state/index.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/state/index.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/state/index.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/state/index.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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CJS parity test — Workstream Inventory Builder generator.
|
||||
*
|
||||
* For every fixture, asserts that the compiled SDK ESM module and the
|
||||
* generated CJS artifact produce byte-identical output.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ─── Shared fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
function minimalInputs(overrides = {}) {
|
||||
return {
|
||||
name: 'my-ws',
|
||||
projectDir: '/project',
|
||||
workstreamDir: '/project/.planning/workstreams/my-ws',
|
||||
phaseDirNames: [],
|
||||
activeWorkstreamName: null,
|
||||
phaseFilesCounts: [],
|
||||
roadmapPhaseCount: 0,
|
||||
stateProjection: { status: 'unknown', current_phase: null, last_activity: null },
|
||||
filesExist: { roadmap: false, state: false, requirements: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const FIXTURES = [
|
||||
{
|
||||
label: 'empty inventory: no phase dirs, no STATE.md',
|
||||
inputs: minimalInputs(),
|
||||
},
|
||||
{
|
||||
label: 'one phase in_progress (partial plan completion)',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha'],
|
||||
phaseFilesCounts: [{ directory: '01-alpha', planCount: 3, summaryCount: 1 }],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'executing', current_phase: '01-alpha', last_activity: '2026-05-01' },
|
||||
filesExist: { roadmap: true, state: true, requirements: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'one phase complete (summary_count >= plan_count)',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha'],
|
||||
phaseFilesCounts: [{ directory: '01-alpha', planCount: 2, summaryCount: 2 }],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: '2026-04-01' },
|
||||
filesExist: { roadmap: true, state: true, requirements: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'one phase pending (plan_count is 0)',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha'],
|
||||
phaseFilesCounts: [{ directory: '01-alpha', planCount: 0, summaryCount: 0 }],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'planning', current_phase: null, last_activity: null },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'multiple phases with mixed statuses',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
|
||||
phaseFilesCounts: [
|
||||
{ directory: '01-alpha', planCount: 2, summaryCount: 2 },
|
||||
{ directory: '02-beta', planCount: 3, summaryCount: 1 },
|
||||
{ directory: '03-gamma', planCount: 0, summaryCount: 0 },
|
||||
],
|
||||
roadmapPhaseCount: 3,
|
||||
stateProjection: { status: 'executing', current_phase: '02-beta', last_activity: '2026-05-10' },
|
||||
filesExist: { roadmap: true, state: true, requirements: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'progress_percent clamps to 100 when completedPhases > roadmapPhaseCount',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
|
||||
phaseFilesCounts: [
|
||||
{ directory: '01-alpha', planCount: 1, summaryCount: 1 },
|
||||
{ directory: '02-beta', planCount: 1, summaryCount: 1 },
|
||||
{ directory: '03-gamma', planCount: 1, summaryCount: 1 },
|
||||
],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: null },
|
||||
filesExist: { roadmap: true, state: true, requirements: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'active workstream marker: active: true when activeWorkstreamName === name',
|
||||
inputs: minimalInputs({
|
||||
name: 'my-ws',
|
||||
activeWorkstreamName: 'my-ws',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'active: false when activeWorkstreamName is a different workstream',
|
||||
inputs: minimalInputs({
|
||||
name: 'my-ws',
|
||||
activeWorkstreamName: 'other-ws',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const IS_COMPLETED_FIXTURES = [
|
||||
{ status: 'milestone complete', expected: true },
|
||||
{ status: 'Milestone Complete', expected: true },
|
||||
{ status: 'archived', expected: true },
|
||||
{ status: 'Archived', expected: true },
|
||||
{ status: 'executing', expected: false },
|
||||
{ status: 'planning', expected: false },
|
||||
{ status: 'unknown', expected: false },
|
||||
{ status: '', expected: false },
|
||||
];
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('workstream-inventory-builder generator parity (ESM dist vs generated CJS)', () => {
|
||||
let sdkBuild, cjsModule;
|
||||
|
||||
before(async () => {
|
||||
// Dynamic import of the ESM SDK dist (use pathToFileURL since we're in CJS context)
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const distPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'workstream', 'builder.js');
|
||||
sdkBuild = await import(pathToFileURL(distPath).href);
|
||||
// CJS require of the generated artifact
|
||||
cjsModule = require('../get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs');
|
||||
});
|
||||
|
||||
describe('buildWorkstreamInventory', () => {
|
||||
for (const fixture of FIXTURES) {
|
||||
test(fixture.label, () => {
|
||||
const sdkResult = sdkBuild.buildWorkstreamInventory(fixture.inputs);
|
||||
const cjsResult = cjsModule.buildWorkstreamInventory(fixture.inputs);
|
||||
assert.deepStrictEqual(
|
||||
cjsResult,
|
||||
sdkResult,
|
||||
`Parity failure for fixture "${fixture.label}"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isCompletedInventory', () => {
|
||||
for (const { status, expected } of IS_COMPLETED_FIXTURES) {
|
||||
test(`isCompletedInventory("${status}") === ${expected}`, () => {
|
||||
const sdkResult = sdkBuild.isCompletedInventory(status);
|
||||
const cjsResult = cjsModule.isCompletedInventory(status);
|
||||
assert.strictEqual(sdkResult, expected, `SDK result mismatch for "${status}"`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS result mismatch for "${status}"`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `Parity failure for "${status}"`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test: workstream-name-policy.generated.cjs vs sdk/src/workstream-name-policy.ts
|
||||
*
|
||||
* Verifies that the generated CJS artifact matches the SDK source-of-truth
|
||||
* for all exports: toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName,
|
||||
* validateWorkstreamName.
|
||||
*
|
||||
* Covers: Phase 6 (#3575) MIGRATE_ME resolution for workstream-name-policy.cjs.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { describe, test } = require('node:test');
|
||||
const {
|
||||
toWorkstreamSlug,
|
||||
hasInvalidPathSegment,
|
||||
isValidActiveWorkstreamName,
|
||||
validateWorkstreamName,
|
||||
} = require('../get-shit-done/bin/lib/workstream-name-policy.cjs');
|
||||
|
||||
// ─── toWorkstreamSlug ────────────────────────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — toWorkstreamSlug', () => {
|
||||
test('lowercases and collapses non-alphanumeric to hyphens', () => {
|
||||
assert.strictEqual(toWorkstreamSlug('My Feature Branch'), 'my-feature-branch');
|
||||
assert.strictEqual(toWorkstreamSlug('hello_world'), 'hello-world');
|
||||
assert.strictEqual(toWorkstreamSlug('API v2'), 'api-v2');
|
||||
});
|
||||
|
||||
test('strips leading/trailing hyphens', () => {
|
||||
assert.strictEqual(toWorkstreamSlug('--foo--'), 'foo');
|
||||
assert.strictEqual(toWorkstreamSlug(' spaces '), 'spaces');
|
||||
});
|
||||
|
||||
test('handles empty and nullish values', () => {
|
||||
assert.strictEqual(toWorkstreamSlug(''), '');
|
||||
assert.strictEqual(toWorkstreamSlug(null), '');
|
||||
assert.strictEqual(toWorkstreamSlug(undefined), '');
|
||||
});
|
||||
|
||||
test('handles already-valid slug', () => {
|
||||
assert.strictEqual(toWorkstreamSlug('my-feature'), 'my-feature');
|
||||
assert.strictEqual(toWorkstreamSlug('v2'), 'v2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasInvalidPathSegment ───────────────────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — hasInvalidPathSegment', () => {
|
||||
test('returns true for names with forward slash', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('foo/bar'), true);
|
||||
});
|
||||
|
||||
test('returns true for names with backslash', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('foo\\bar'), true);
|
||||
});
|
||||
|
||||
test('returns true for bare dot', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('.'), true);
|
||||
});
|
||||
|
||||
test('returns true for double dot', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('..'), true);
|
||||
});
|
||||
|
||||
test('returns true for names containing dot-dot sequence', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('foo..bar'), true);
|
||||
assert.strictEqual(hasInvalidPathSegment('../etc'), true);
|
||||
});
|
||||
|
||||
test('returns false for valid workstream names', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('my-feature'), false);
|
||||
assert.strictEqual(hasInvalidPathSegment('v2'), false);
|
||||
assert.strictEqual(hasInvalidPathSegment('feature.experimental'), false);
|
||||
assert.strictEqual(hasInvalidPathSegment('alpha_1'), false);
|
||||
});
|
||||
|
||||
test('handles empty and nullish values', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment(''), false);
|
||||
assert.strictEqual(hasInvalidPathSegment(null), false);
|
||||
assert.strictEqual(hasInvalidPathSegment(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidActiveWorkstreamName ─────────────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — isValidActiveWorkstreamName', () => {
|
||||
test('returns true for valid alphanumeric names', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('feature'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('v2'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('my-branch'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('feature.experimental'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('alpha_1'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('A1'), true);
|
||||
});
|
||||
|
||||
test('returns false for names starting with non-alphanumeric', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('-feature'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('.feature'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('_feature'), false);
|
||||
});
|
||||
|
||||
test('returns false for names with path traversal', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('..'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('../etc'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('foo..bar'), false);
|
||||
});
|
||||
|
||||
test('returns false for names with slashes', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('foo/bar'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('foo\\bar'), false);
|
||||
});
|
||||
|
||||
test('returns false for names with spaces', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('my feature'), false);
|
||||
});
|
||||
|
||||
test('returns false for empty string', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName(''), false);
|
||||
});
|
||||
|
||||
test('returns false for nullish values', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName(null), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateWorkstreamName (SDK alias) ──────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — validateWorkstreamName (SDK alias)', () => {
|
||||
test('is an alias for isValidActiveWorkstreamName', () => {
|
||||
const testCases = [
|
||||
'feature', 'v2', 'my-branch', '-bad', '', null, undefined,
|
||||
'foo/bar', '..', 'foo..bar', 'A1', 'alpha_1',
|
||||
];
|
||||
for (const tc of testCases) {
|
||||
assert.strictEqual(
|
||||
validateWorkstreamName(tc),
|
||||
isValidActiveWorkstreamName(tc),
|
||||
`validateWorkstreamName and isValidActiveWorkstreamName should agree on: ${JSON.stringify(tc)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user