From 5bbe276c58d706103b98653333075f2cfa99c64a Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 15 Sep 2026 18:13:11 -0700 Subject: [PATCH] Generate acceptance entry points incrementally Every acceptance run re-parsed and regenerated all features even when only one changed. Record feature_hash and aps_commit in the per-feature metadata and skip parse/generate when both match, and drop generated tests for features that no longer exist. By specifier. --- psf-memo-client/acceptance/acceptance.js | 52 ++++++++++++++++++++- psf-memo-client/acceptance/lib/generate.js | 16 ++++++- psf-memo-db/acceptance/acceptance.js | 52 ++++++++++++++++++++- psf-memo-db/acceptance/lib/generate.js | 16 ++++++- psf-memo-indexer/acceptance/acceptance.js | 52 ++++++++++++++++++++- psf-memo-indexer/acceptance/lib/generate.js | 16 ++++++- 6 files changed, 195 insertions(+), 9 deletions(-) diff --git a/psf-memo-client/acceptance/acceptance.js b/psf-memo-client/acceptance/acceptance.js index 92caf0c..d59587e 100644 --- a/psf-memo-client/acceptance/acceptance.js +++ b/psf-memo-client/acceptance/acceptance.js @@ -15,6 +15,7 @@ 'use strict' const { execFileSync } = require('node:child_process') +const crypto = require('node:crypto') const fs = require('node:fs') const path = require('node:path') @@ -46,6 +47,49 @@ function ensureAps () { sh('git', ['clone', '--depth', '1', APS_URL, apsDir]) } +function apsCommit () { + try { + return sh('git', ['-C', apsDir, 'rev-parse', 'HEAD']).trim() + } catch (err) { + return '' + } +} + +function featureHash (featurePath) { + return crypto.createHash('sha256').update(fs.readFileSync(featurePath)).digest('hex') +} + +// True when the generated entry point and metadata already match the current +// feature text and APS checkout, so parse/generate can be skipped. +function isUpToDate (featurePath, base, commit) { + const testFile = path.join(genDir, `${base}.acceptance.test.js`) + const metaFile = path.join(genDir, 'metadata', `${base}.json`) + if (!fs.existsSync(testFile) || !fs.existsSync(metaFile)) return false + try { + const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8')) + return meta.feature_hash === featureHash(featurePath) && meta.aps_commit === commit + } catch (err) { + return false + } +} + +// Remove generated tests for features that no longer exist under specs/. +function removeStaleGeneratedTests (features) { + const bases = new Set(features.map((f) => f.replace(/\.feature$/i, ''))) + if (!fs.existsSync(genDir)) return + for (const file of fs.readdirSync(genDir)) { + if (!file.endsWith('.acceptance.test.js')) continue + const base = file.replace(/\.acceptance\.test\.js$/, '') + if (!bases.has(base)) { + try { + fs.rmSync(path.join(genDir, file), { force: true }) + } catch (err) { + // ignore cleanup errors + } + } + } +} + function main () { ensureAps() @@ -61,17 +105,23 @@ function main () { fs.mkdirSync(irDir, { recursive: true }) fs.mkdirSync(genDir, { recursive: true }) + removeStaleGeneratedTests(features) + + const commit = apsCommit() for (const featureFile of features) { const base = featureFile.replace(/\.feature$/i, '') const featurePath = path.join(specsDir, featureFile) + + if (isUpToDate(featurePath, base, commit)) continue + const irPath = path.join(irDir, `${base}.json`) // 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser. sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir }) // 2) Generate executable acceptance entry points from the IR. - sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir]) + sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir, featurePath, commit]) } // 3) Run every generated acceptance test. diff --git a/psf-memo-client/acceptance/lib/generate.js b/psf-memo-client/acceptance/lib/generate.js index 592049d..4de68d2 100644 --- a/psf-memo-client/acceptance/lib/generate.js +++ b/psf-memo-client/acceptance/lib/generate.js @@ -37,12 +37,22 @@ function relativeRequire (fromDir, targetFile) { return rel } +function hashFile (file) { + try { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') + } catch (err) { + return null + } +} + function main () { const irArg = process.argv[2] const outArg = process.argv[3] + const featureArg = process.argv[4] + const apsCommit = process.argv[5] if (!irArg || !outArg) { - console.error('usage: acceptance-entrypoint-generator ') + console.error('usage: acceptance-entrypoint-generator [feature-file] [aps-commit]') process.exit(2) } @@ -99,7 +109,9 @@ main().catch((err) => { console.error(err); process.exit(1) }) ir_path: path.resolve(irArg), implementation_hash: `sha256:${hash}`, hash_scope: 'generated_files', - generated_files: [testFile] + generated_files: [testFile], + feature_hash: featureArg ? hashFile(featureArg) : null, + aps_commit: apsCommit || null } fs.writeFileSync( path.join(metaDir, metadataName(featureKey)), diff --git a/psf-memo-db/acceptance/acceptance.js b/psf-memo-db/acceptance/acceptance.js index 006bfb4..2dd45e6 100644 --- a/psf-memo-db/acceptance/acceptance.js +++ b/psf-memo-db/acceptance/acceptance.js @@ -7,6 +7,7 @@ */ import { execFileSync } from 'node:child_process' +import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -50,6 +51,49 @@ function cleanStaleWorlds () { } } +function apsCommit () { + try { + return sh('git', ['-C', apsDir, 'rev-parse', 'HEAD']).trim() + } catch (err) { + return '' + } +} + +function featureHash (featurePath) { + return crypto.createHash('sha256').update(fs.readFileSync(featurePath)).digest('hex') +} + +// True when the generated entry point and metadata already match the current +// feature text and APS checkout, so parse/generate can be skipped. +function isUpToDate (featurePath, base, commit) { + const testFile = path.join(genDir, `${base}.acceptance.test.js`) + const metaFile = path.join(genDir, 'metadata', `${base}.json`) + if (!fs.existsSync(testFile) || !fs.existsSync(metaFile)) return false + try { + const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8')) + return meta.feature_hash === featureHash(featurePath) && meta.aps_commit === commit + } catch (err) { + return false + } +} + +// Remove generated tests for features that no longer exist under specs/. +function removeStaleGeneratedTests (features) { + const bases = new Set(features.map((f) => f.replace(/\.feature$/i, ''))) + if (!fs.existsSync(genDir)) return + for (const file of fs.readdirSync(genDir)) { + if (!file.endsWith('.acceptance.test.js')) continue + const base = file.replace(/\.acceptance\.test\.js$/, '') + if (!bases.has(base)) { + try { + fs.rmSync(path.join(genDir, file), { force: true }) + } catch (err) { + // ignore cleanup errors + } + } + } +} + function main () { cleanStaleWorlds() ensureAps() @@ -66,14 +110,20 @@ function main () { fs.mkdirSync(irDir, { recursive: true }) fs.mkdirSync(genDir, { recursive: true }) + removeStaleGeneratedTests(features) + + const commit = apsCommit() for (const featureFile of features) { const base = featureFile.replace(/\.feature$/i, '') const featurePath = path.join(specsDir, featureFile) + + if (isUpToDate(featurePath, base, commit)) continue + const irPath = path.join(irDir, `${base}.json`) sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir }) - sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir]) + sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir, featurePath, commit]) } const tests = fs diff --git a/psf-memo-db/acceptance/lib/generate.js b/psf-memo-db/acceptance/lib/generate.js index 960c391..8683f09 100644 --- a/psf-memo-db/acceptance/lib/generate.js +++ b/psf-memo-db/acceptance/lib/generate.js @@ -27,12 +27,22 @@ function relativeImport (fromDir, targetFile) { return rel } +function hashFile (file) { + try { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') + } catch (err) { + return null + } +} + function main () { const irArg = process.argv[2] const outArg = process.argv[3] + const featureArg = process.argv[4] + const apsCommit = process.argv[5] if (!irArg || !outArg) { - console.error('usage: acceptance-entrypoint-generator ') + console.error('usage: acceptance-entrypoint-generator [feature-file] [aps-commit]') process.exit(2) } @@ -87,7 +97,9 @@ main().catch((err) => { console.error(err); process.exit(1) }) ir_path: path.resolve(irArg), implementation_hash: `sha256:${hash}`, hash_scope: 'generated_files', - generated_files: [testFile] + generated_files: [testFile], + feature_hash: featureArg ? hashFile(featureArg) : null, + aps_commit: apsCommit || null } fs.writeFileSync( path.join(metaDir, metadataName(featureKey)), diff --git a/psf-memo-indexer/acceptance/acceptance.js b/psf-memo-indexer/acceptance/acceptance.js index 30d550e..9a2509d 100644 --- a/psf-memo-indexer/acceptance/acceptance.js +++ b/psf-memo-indexer/acceptance/acceptance.js @@ -3,6 +3,7 @@ */ import { execFileSync } from 'node:child_process' +import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -35,6 +36,49 @@ function ensureAps () { sh('git', ['clone', '--depth', '1', APS_URL, apsDir]) } +function apsCommit () { + try { + return sh('git', ['-C', apsDir, 'rev-parse', 'HEAD']).trim() + } catch (err) { + return '' + } +} + +function featureHash (featurePath) { + return crypto.createHash('sha256').update(fs.readFileSync(featurePath)).digest('hex') +} + +// True when the generated entry point and metadata already match the current +// feature text and APS checkout, so parse/generate can be skipped. +function isUpToDate (featurePath, base, commit) { + const testFile = path.join(genDir, `${base}.acceptance.test.js`) + const metaFile = path.join(genDir, 'metadata', `${base}.json`) + if (!fs.existsSync(testFile) || !fs.existsSync(metaFile)) return false + try { + const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8')) + return meta.feature_hash === featureHash(featurePath) && meta.aps_commit === commit + } catch (err) { + return false + } +} + +// Remove generated tests for features that no longer exist under specs/. +function removeStaleGeneratedTests (features) { + const bases = new Set(features.map((f) => f.replace(/\.feature$/i, ''))) + if (!fs.existsSync(genDir)) return + for (const file of fs.readdirSync(genDir)) { + if (!file.endsWith('.acceptance.test.js')) continue + const base = file.replace(/\.acceptance\.test\.js$/, '') + if (!bases.has(base)) { + try { + fs.rmSync(path.join(genDir, file), { force: true }) + } catch (err) { + // ignore cleanup errors + } + } + } +} + function main () { ensureAps() @@ -50,14 +94,20 @@ function main () { fs.mkdirSync(irDir, { recursive: true }) fs.mkdirSync(genDir, { recursive: true }) + removeStaleGeneratedTests(features) + + const commit = apsCommit() for (const featureFile of features) { const base = featureFile.replace(/\.feature$/i, '') const featurePath = path.join(specsDir, featureFile) + + if (isUpToDate(featurePath, base, commit)) continue + const irPath = path.join(irDir, `${base}.json`) sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir }) - sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir]) + sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir, featurePath, commit]) } const tests = fs diff --git a/psf-memo-indexer/acceptance/lib/generate.js b/psf-memo-indexer/acceptance/lib/generate.js index 51a1737..68653b8 100644 --- a/psf-memo-indexer/acceptance/lib/generate.js +++ b/psf-memo-indexer/acceptance/lib/generate.js @@ -23,12 +23,22 @@ function relativeImport (fromDir, targetFile) { return rel } +function hashFile (file) { + try { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') + } catch (err) { + return null + } +} + function main () { const irArg = process.argv[2] const outArg = process.argv[3] + const featureArg = process.argv[4] + const apsCommit = process.argv[5] if (!irArg || !outArg) { - console.error('usage: acceptance-entrypoint-generator ') + console.error('usage: acceptance-entrypoint-generator [feature-file] [aps-commit]') process.exit(2) } @@ -83,7 +93,9 @@ main().catch((err) => { console.error(err); process.exit(1) }) ir_path: path.resolve(irArg), implementation_hash: `sha256:${hash}`, hash_scope: 'generated_files', - generated_files: [testFile] + generated_files: [testFile], + feature_hash: featureArg ? hashFile(featureArg) : null, + aps_commit: apsCommit || null } fs.writeFileSync( path.join(metaDir, metadataName(featureKey)),