From 4065abc65de8dc56d8a06e484bc39e1e91637eab Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 1 Jun 2026 06:20:16 +0000 Subject: [PATCH 1/2] fix(codegen): preserve hand-written directories during cleanStaleTargets removeStaleTargetDirs now checks for the @generated marker before deleting a directory. Directories that contain only hand-written code (e.g. config/, utils/ in constructive-cli) are preserved automatically. This fixes the schema-propagation workflow deleting sdk/constructive-cli support files (config/ and utils/) that cli-commands.ts depends on. --- .../__tests__/codegen/expand-targets.test.ts | 67 ++++++++++++++++--- graphql/codegen/src/core/generate.ts | 49 +++++++++++++- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts index 6d405cf4b..e4728d5d5 100644 --- a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts +++ b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts @@ -142,6 +142,7 @@ describe('expandSchemaDirToMultiTarget', () => { describe('removeStaleTargetDirs', () => { let tempDir: string; + const GENERATED_HEADER = '/** @generated by @constructive-io/graphql-codegen */\nexport {};\n'; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stale-targets-')); @@ -151,11 +152,25 @@ describe('removeStaleTargetDirs', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('removes directories not in current target list', () => { - fs.mkdirSync(path.join(tempDir, 'admin')); - fs.mkdirSync(path.join(tempDir, 'auth')); - fs.mkdirSync(path.join(tempDir, 'public')); - fs.mkdirSync(path.join(tempDir, 'objects')); + /** Create a directory with a generated marker file inside it. */ + function mkGeneratedDir(name: string) { + const dir = path.join(tempDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.ts'), GENERATED_HEADER); + } + + /** Create a directory with only hand-written (non-generated) files. */ + function mkHandwrittenDir(name: string) { + const dir = path.join(tempDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.ts'), 'export function hello() {}\n'); + } + + it('removes generated directories not in current target list', () => { + mkGeneratedDir('admin'); + mkGeneratedDir('auth'); + mkGeneratedDir('public'); + mkGeneratedDir('objects'); const removed = removeStaleTargetDirs(tempDir, ['admin', 'auth']); @@ -166,8 +181,31 @@ describe('removeStaleTargetDirs', () => { expect(fs.existsSync(path.join(tempDir, 'objects'))).toBe(false); }); + it('preserves hand-written directories even when not in target list', () => { + mkGeneratedDir('admin'); + mkHandwrittenDir('config'); + mkHandwrittenDir('utils'); + + const removed = removeStaleTargetDirs(tempDir, ['admin']); + + expect(removed).toEqual([]); + expect(fs.existsSync(path.join(tempDir, 'admin'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'config'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'utils'))).toBe(true); + }); + + it('detects generated marker in nested subdirectories', () => { + const nested = path.join(tempDir, 'old-target', 'orm'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'client.ts'), GENERATED_HEADER); + + const removed = removeStaleTargetDirs(tempDir, []); + + expect(removed).toEqual(['old-target']); + }); + it('preserves files (only removes directories)', () => { - fs.mkdirSync(path.join(tempDir, 'admin')); + mkGeneratedDir('admin'); fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export {}'); const removed = removeStaleTargetDirs(tempDir, ['admin']); @@ -182,19 +220,28 @@ describe('removeStaleTargetDirs', () => { }); it('returns empty array when no stale directories exist', () => { - fs.mkdirSync(path.join(tempDir, 'admin')); - fs.mkdirSync(path.join(tempDir, 'auth')); + mkGeneratedDir('admin'); + mkGeneratedDir('auth'); const removed = removeStaleTargetDirs(tempDir, ['admin', 'auth']); expect(removed).toEqual([]); }); - it('removes all directories when target list is empty', () => { - fs.mkdirSync(path.join(tempDir, 'old-target')); + it('removes all generated directories when target list is empty', () => { + mkGeneratedDir('old-target'); const removed = removeStaleTargetDirs(tempDir, []); expect(removed).toEqual(['old-target']); expect(fs.existsSync(path.join(tempDir, 'old-target'))).toBe(false); }); + + it('skips empty directories (no generated files)', () => { + fs.mkdirSync(path.join(tempDir, 'empty-dir')); + + const removed = removeStaleTargetDirs(tempDir, []); + + expect(removed).toEqual([]); + expect(fs.existsSync(path.join(tempDir, 'empty-dir'))).toBe(true); + }); }); diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 026d117f6..80e33091f 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -626,9 +626,47 @@ function applySharedPgpmDb( }; } +/** + * Check whether a directory looks like codegen output by scanning for + * a `.ts` file containing the `@generated` marker in its first few lines. + */ +function isGeneratedDir(dirPath: string): boolean { + const MARKER = '@generated'; + const MAX_BYTES = 256; + + const queue = [dirPath]; + while (queue.length > 0) { + const current = queue.shift()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + queue.push(full); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + try { + const fd = fs.openSync(full, 'r'); + const buf = Buffer.alloc(MAX_BYTES); + fs.readSync(fd, buf, 0, MAX_BYTES, 0); + fs.closeSync(fd); + if (buf.toString('utf8').includes(MARKER)) return true; + } catch { + continue; + } + } + } + } + return false; +} + /** * Remove subdirectories in `outputRoot` that are not in `currentTargetNames`. - * Useful for cleaning up stale target output before a fresh multi-target generate. + * Only removes directories that contain `@generated` marker files — hand-written + * directories (e.g. `config/`, `utils/`) are preserved automatically. * Returns the list of directory names that were removed. */ export function removeStaleTargetDirs( @@ -643,7 +681,14 @@ export function removeStaleTargetDirs( const entries = fs.readdirSync(outputRoot, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !currentTargets.has(entry.name)) { - fs.rmSync(path.join(outputRoot, entry.name), { recursive: true, force: true }); + const dirPath = path.join(outputRoot, entry.name); + if (!isGeneratedDir(dirPath)) { + if (verbose) { + console.log(`Preserved non-generated directory: ${entry.name}`); + } + continue; + } + fs.rmSync(dirPath, { recursive: true, force: true }); removed.push(entry.name); if (verbose) { console.log(`Removed stale target directory: ${entry.name}`); From f5a19fd75f428194831c38312a846144ff3fd446 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 1 Jun 2026 06:34:51 +0000 Subject: [PATCH 2/2] refactor: use .generated sentinel instead of file content scanning Drop a .generated sentinel file in each target dir during codegen. removeStaleTargetDirs checks for this sentinel (single fs.existsSync) instead of scanning .ts file contents for @generated markers. --- .../__tests__/codegen/expand-targets.test.ts | 21 ++------ graphql/codegen/src/core/generate.ts | 48 ++++--------------- graphql/codegen/src/index.ts | 2 +- 3 files changed, 16 insertions(+), 55 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts index e4728d5d5..b1dbd34f4 100644 --- a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts +++ b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs } from '../../core/generate'; +import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, GENERATED_SENTINEL } from '../../core/generate'; describe('expandApiNamesToMultiTarget', () => { it('returns null for no apiNames', () => { @@ -142,7 +142,6 @@ describe('expandSchemaDirToMultiTarget', () => { describe('removeStaleTargetDirs', () => { let tempDir: string; - const GENERATED_HEADER = '/** @generated by @constructive-io/graphql-codegen */\nexport {};\n'; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stale-targets-')); @@ -152,14 +151,14 @@ describe('removeStaleTargetDirs', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - /** Create a directory with a generated marker file inside it. */ + /** Create a directory with the .generated sentinel (codegen output). */ function mkGeneratedDir(name: string) { const dir = path.join(tempDir, name); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'index.ts'), GENERATED_HEADER); + fs.writeFileSync(path.join(dir, GENERATED_SENTINEL), 'generated by codegen\n'); } - /** Create a directory with only hand-written (non-generated) files. */ + /** Create a plain directory without the sentinel (hand-written code). */ function mkHandwrittenDir(name: string) { const dir = path.join(tempDir, name); fs.mkdirSync(dir, { recursive: true }); @@ -194,16 +193,6 @@ describe('removeStaleTargetDirs', () => { expect(fs.existsSync(path.join(tempDir, 'utils'))).toBe(true); }); - it('detects generated marker in nested subdirectories', () => { - const nested = path.join(tempDir, 'old-target', 'orm'); - fs.mkdirSync(nested, { recursive: true }); - fs.writeFileSync(path.join(nested, 'client.ts'), GENERATED_HEADER); - - const removed = removeStaleTargetDirs(tempDir, []); - - expect(removed).toEqual(['old-target']); - }); - it('preserves files (only removes directories)', () => { mkGeneratedDir('admin'); fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export {}'); @@ -236,7 +225,7 @@ describe('removeStaleTargetDirs', () => { expect(fs.existsSync(path.join(tempDir, 'old-target'))).toBe(false); }); - it('skips empty directories (no generated files)', () => { + it('skips directories without sentinel', () => { fs.mkdirSync(path.join(tempDir, 'empty-dir')); const removed = removeStaleTargetDirs(tempDir, []); diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 80e33091f..899019654 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -404,6 +404,12 @@ export async function generate( } allFilesWritten.push(...(writeResult.filesWritten ?? [])); + // Drop sentinel so removeStaleTargetDirs knows this is a generated dir + fs.writeFileSync( + path.join(outputRoot, GENERATED_SENTINEL), + `generated by @constructive-io/graphql-codegen\n`, + ); + if (skillsToWrite.length > 0) { const skillsOutputDir = resolveSkillsOutputDir(config, outputRoot); const skillsWriteResult = await writeGeneratedFiles(skillsToWrite, skillsOutputDir, [], { @@ -626,46 +632,12 @@ function applySharedPgpmDb( }; } -/** - * Check whether a directory looks like codegen output by scanning for - * a `.ts` file containing the `@generated` marker in its first few lines. - */ -function isGeneratedDir(dirPath: string): boolean { - const MARKER = '@generated'; - const MAX_BYTES = 256; - - const queue = [dirPath]; - while (queue.length > 0) { - const current = queue.shift()!; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - queue.push(full); - } else if (entry.isFile() && entry.name.endsWith('.ts')) { - try { - const fd = fs.openSync(full, 'r'); - const buf = Buffer.alloc(MAX_BYTES); - fs.readSync(fd, buf, 0, MAX_BYTES, 0); - fs.closeSync(fd); - if (buf.toString('utf8').includes(MARKER)) return true; - } catch { - continue; - } - } - } - } - return false; -} +/** Sentinel file written into every generated target directory. */ +export const GENERATED_SENTINEL = '.generated'; /** * Remove subdirectories in `outputRoot` that are not in `currentTargetNames`. - * Only removes directories that contain `@generated` marker files — hand-written + * Only removes directories that contain a `.generated` sentinel file — hand-written * directories (e.g. `config/`, `utils/`) are preserved automatically. * Returns the list of directory names that were removed. */ @@ -682,7 +654,7 @@ export function removeStaleTargetDirs( for (const entry of entries) { if (entry.isDirectory() && !currentTargets.has(entry.name)) { const dirPath = path.join(outputRoot, entry.name); - if (!isGeneratedDir(dirPath)) { + if (!fs.existsSync(path.join(dirPath, GENERATED_SENTINEL))) { if (verbose) { console.log(`Preserved non-generated directory: ${entry.name}`); } diff --git a/graphql/codegen/src/index.ts b/graphql/codegen/src/index.ts index a8a417da4..8bff0db6a 100644 --- a/graphql/codegen/src/index.ts +++ b/graphql/codegen/src/index.ts @@ -23,7 +23,7 @@ export { defineConfig } from './types/config'; // Main generate function (orchestrates the entire pipeline) export type { GenerateOptions, GenerateResult, GenerateMultiOptions, GenerateMultiResult } from './core/generate'; -export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs } from './core/generate'; +export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, GENERATED_SENTINEL } from './core/generate'; // Config utilities export { findConfigFile, loadConfigFile } from './core/config';