diff --git a/.husky/pre-commit b/.husky/pre-commit index 59a8ac8bd8..de75948bd4 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npx lint-staged && npm run pre-commit:encrypt +npx lint-staged && npm run pre-commit:encrypt && bash cookbook/scripts/pre-commit.sh diff --git a/cookbook/README.md b/cookbook/README.md index f2fe2abe0e..a24c59cd02 100644 --- a/cookbook/README.md +++ b/cookbook/README.md @@ -26,6 +26,12 @@ - [Schema](#schema) - [Syntax](#syntax-6) - [Example](#example-6) + - [Skeleton Files](#skeleton-files) + - [Syntax](#syntax-7) + - [Example](#example-7) + - [Affected Recipes](#affected-recipes) + - [Syntax](#syntax-8) + - [Example](#example-8) This is the Hydrogen Cookbook, a collection of example _recipes_ to showcase specific scenarios and usecases for Hydrogen projects. @@ -52,12 +58,14 @@ The cookbook comes with a set of commands for creating and managing recipes. All cookbook.ts Commands: - cookbook.ts generate Generate a recipe from the skeleton's changes - cookbook.ts render Render a recipe to a given format - cookbook.ts apply Apply a recipe to the current project - cookbook.ts validate Validate a recipe - cookbook.ts regenerate Regenerate a recipe - cookbook.ts update Update a recipe + cookbook.ts generate Generate a recipe from the skeleton's changes + cookbook.ts render Render a recipe to a given format + cookbook.ts apply Apply a recipe to the current project + cookbook.ts validate Validate a recipe + cookbook.ts regenerate Regenerate a recipe + cookbook.ts update Update a recipe + cookbook.ts skeleton-files List all skeleton files referenced by recipes + cookbook.ts affected-recipes List recipes affected by changes to skeleton files Options: --version Show version number [boolean] @@ -283,3 +291,108 @@ Options: ```sh npm run cookbook -- schema ``` + +### Skeleton Files + +`skeleton-files` lists all skeleton template files that are referenced by recipes (via diffs or ingredients). It maps each file to the recipes that touch it. + +Note: `deletedFiles` entries in a recipe are intentionally excluded — files that are unconditionally deleted by a recipe cannot become stale and do not need to be tracked. + +#### Syntax + +```plain +cookbook.ts skeleton-files + +List all skeleton files referenced by recipes (or a specific recipe) + +Options: + --version Show version number [boolean] + --help Show help [boolean] + --recipe Recipe name(s) to query (default: all recipes) [array] + --json Output as JSON object instead of formatted text + [boolean] [default: false] + --existing-only Only show files that currently exist in the skeleton + [boolean] [default: false] +``` + +#### Example + +```sh +# List all skeleton files referenced by any recipe +npm run cookbook -- skeleton-files + +# List files for a specific recipe +npm run cookbook -- skeleton-files --recipe my-recipe + +# List files for multiple recipes, as JSON +npm run cookbook -- skeleton-files --recipe recipe-a --recipe recipe-b --json + +# Only show files that currently exist on disk +npm run cookbook -- skeleton-files --existing-only +``` + +Default (text) output format: + +``` +templates/skeleton/app/root.tsx -> [recipe-a, recipe-b] +templates/skeleton/app/server.ts -> [recipe-a] +``` + +JSON output format (`--json`): + +```json +{ + "templates/skeleton/app/root.tsx": ["recipe-a", "recipe-b"], + "templates/skeleton/app/server.ts": ["recipe-a"] +} +``` + +### Affected Recipes + +`affected-recipes` takes a list of changed skeleton file paths and returns the names of all recipes that reference any of those files. It is called by the pre-commit hook to warn developers when skeleton changes may require recipe updates. + +Note: `deletedFiles` entries in a recipe are intentionally excluded — changes to files that a recipe unconditionally deletes cannot make the recipe stale. + +#### Syntax + +```plain +cookbook.ts affected-recipes [files..] + +List recipes affected by changes to the given skeleton files + +Options: + --version Show version number [boolean] + --help Show help [boolean] + --files Repo-relative paths to changed skeleton files + [array] [default: []] + --json Output as JSON array instead of newline-separated names + [boolean] [default: false] +``` + +#### Example + +```sh +# Check which recipes are affected by a changed file +npm run cookbook -- affected-recipes templates/skeleton/app/root.tsx + +# Pass multiple files +npm run cookbook -- affected-recipes \ + templates/skeleton/app/root.tsx \ + templates/skeleton/app/server.ts + +# Output as a JSON array (useful for scripting / CI matrix jobs) +npm run cookbook -- affected-recipes --json templates/skeleton/app/root.tsx +``` + +Default (text) output — one recipe name per line: + +``` +recipe-a +recipe-b +``` + +JSON output (`--json`): + +```json +["recipe-a", "recipe-b"] +``` diff --git a/cookbook/scripts/pre-commit.sh b/cookbook/scripts/pre-commit.sh new file mode 100755 index 0000000000..a1126db3fa --- /dev/null +++ b/cookbook/scripts/pre-commit.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Detects skeleton file changes and warns about recipes that may need updating. +# Always exits 0 — this check is informational only. +# Bypass: git commit --no-verify + +REPO_ROOT="$(git rev-parse --show-toplevel)" + +# 1. Find staged skeleton files (read into array to safely handle spaces/special chars) +STAGED_ARGS=() +while IFS= read -r f; do + [[ -n "$f" ]] && STAGED_ARGS+=("$f") +done < <(git diff --cached --name-only -- 'templates/skeleton/') + +if [ ${#STAGED_ARGS[@]} -eq 0 ]; then + exit 0 +fi + +# 2. Find affected recipes (one name per line) +# Capture stderr separately so errors surface as warnings without mixing into output. +STDERR_FILE=$(mktemp) +AFFECTED=$(cd "$REPO_ROOT/cookbook" && npm run --silent cookbook -- affected-recipes "${STAGED_ARGS[@]}" 2>"$STDERR_FILE") +STATUS=$? +STDERR_CONTENT=$(cat "$STDERR_FILE") +rm -f "$STDERR_FILE" + +if [ $STATUS -ne 0 ]; then + echo "⚠️ Skeleton recipe check encountered an error — skipping." >&2 + [ -n "$STDERR_CONTENT" ] && echo "$STDERR_CONTENT" >&2 + exit 0 +fi + +if [ -z "$AFFECTED" ]; then + exit 0 +fi + +# 3. Print actionable warning +echo "" +echo "⚠️ Skeleton changes detected. The following recipes may need updating:" +echo "" +while IFS= read -r recipe; do + echo " - $recipe" +done <<< "$AFFECTED" +echo "" +echo " After committing your skeleton changes, run the following to update affected recipes:" +while IFS= read -r recipe; do + echo " cd cookbook && npm run cookbook -- regenerate --recipe $recipe --format github" +done <<< "$AFFECTED" +echo "" +echo " Note: the regenerate command requires a clean working tree — run it after committing." +echo " To skip this skeleton changes warning: git commit --no-verify" +echo "" + +exit 0 diff --git a/cookbook/src/commands/affected-recipes.test.ts b/cookbook/src/commands/affected-recipes.test.ts new file mode 100644 index 0000000000..94c0f325fc --- /dev/null +++ b/cookbook/src/commands/affected-recipes.test.ts @@ -0,0 +1,72 @@ +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import {affectedRecipes} from './affected-recipes'; + +vi.mock('../lib/dependency-graph', () => ({getAffectedRecipes: vi.fn()})); + +import {getAffectedRecipes} from '../lib/dependency-graph'; + +const mockGetAffectedRecipes = vi.mocked(getAffectedRecipes); + +// Extract the handler for direct invocation in tests +const handler = affectedRecipes.handler as (args: { + files: string[]; + json: boolean; +}) => void; + +describe('affected-recipes command handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('passes the files array to getAffectedRecipes', () => { + mockGetAffectedRecipes.mockReturnValue([]); + const files = [ + 'templates/skeleton/app/root.tsx', + 'templates/skeleton/app/server.ts', + ]; + + handler({files, json: false}); + + expect(mockGetAffectedRecipes).toHaveBeenCalledWith(files); + }); + + it('prints one recipe name per line by default', () => { + mockGetAffectedRecipes.mockReturnValue(['multipass', 'b2b', 'markets']); + + handler({files: ['templates/skeleton/app/root.tsx'], json: false}); + + expect(console.log).toHaveBeenCalledTimes(3); + expect(console.log).toHaveBeenNthCalledWith(1, 'multipass'); + expect(console.log).toHaveBeenNthCalledWith(2, 'b2b'); + expect(console.log).toHaveBeenNthCalledWith(3, 'markets'); + }); + + it('prints JSON array when --json flag is set', () => { + mockGetAffectedRecipes.mockReturnValue(['multipass', 'b2b']); + + handler({files: ['templates/skeleton/app/root.tsx'], json: true}); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + JSON.stringify(['multipass', 'b2b']), + ); + }); + + it('produces no output when no recipes are affected', () => { + mockGetAffectedRecipes.mockReturnValue([]); + + handler({files: ['templates/skeleton/app/root.tsx'], json: false}); + + expect(console.log).not.toHaveBeenCalled(); + }); + + it('prints empty JSON array when --json flag is set and no recipes are affected', () => { + mockGetAffectedRecipes.mockReturnValue([]); + + handler({files: ['templates/skeleton/app/root.tsx'], json: true}); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith(JSON.stringify([])); + }); +}); diff --git a/cookbook/src/commands/affected-recipes.ts b/cookbook/src/commands/affected-recipes.ts new file mode 100644 index 0000000000..412afe91ba --- /dev/null +++ b/cookbook/src/commands/affected-recipes.ts @@ -0,0 +1,36 @@ +import {CommandModule} from 'yargs'; +import {getAffectedRecipes} from '../lib/dependency-graph'; + +type AffectedRecipesArgs = { + files: string[]; + json: boolean; +}; + +export const affectedRecipes: CommandModule<{}, AffectedRecipesArgs> = { + command: 'affected-recipes [files..]', + describe: 'List recipes affected by changes to the given skeleton files', + builder: { + files: { + type: 'array', + string: true, + description: 'Repo-relative paths to changed skeleton files', + default: [], + }, + json: { + type: 'boolean', + description: 'Output as JSON array instead of newline-separated names', + default: false, + }, + }, + handler({files, json}) { + const affected = getAffectedRecipes(files); + + if (json) { + console.log(JSON.stringify(affected)); + } else { + for (const name of affected) { + console.log(name); + } + } + }, +}; diff --git a/cookbook/src/commands/index.ts b/cookbook/src/commands/index.ts index 71e844b2ba..bae16da051 100644 --- a/cookbook/src/commands/index.ts +++ b/cookbook/src/commands/index.ts @@ -1,7 +1,9 @@ +export {affectedRecipes} from './affected-recipes'; export {apply} from './apply'; export {generate} from './generate'; export {regenerate} from './regenerate'; export {render} from './render'; +export {skeletonFiles} from './skeleton-files'; export {update} from './update'; export {validate} from './validate'; export {schema} from './schema'; diff --git a/cookbook/src/commands/skeleton-files.test.ts b/cookbook/src/commands/skeleton-files.test.ts new file mode 100644 index 0000000000..3f9d06226d --- /dev/null +++ b/cookbook/src/commands/skeleton-files.test.ts @@ -0,0 +1,146 @@ +import fs from 'fs'; +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import {skeletonFiles} from './skeleton-files'; + +vi.mock('../lib/dependency-graph', () => ({getSkeletonFileMap: vi.fn()})); +vi.mock('../lib/constants', () => ({REPO_ROOT: '/repo'})); + +import {getSkeletonFileMap} from '../lib/dependency-graph'; + +const mockGetSkeletonFileMap = vi.mocked(getSkeletonFileMap); + +const handler = skeletonFiles.handler as (args: { + recipe: string[]; + json: boolean; + existingOnly: boolean; +}) => void; + +describe('skeleton-files command handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('passes undefined to getSkeletonFileMap when no --recipe flags given', () => { + mockGetSkeletonFileMap.mockReturnValue(new Map()); + + handler({recipe: [], json: false, existingOnly: false}); + + expect(mockGetSkeletonFileMap).toHaveBeenCalledWith(undefined); + }); + + it('passes recipe names array to getSkeletonFileMap when --recipe flags given', () => { + mockGetSkeletonFileMap.mockReturnValue(new Map()); + + handler({recipe: ['multipass', 'b2b'], json: false, existingOnly: false}); + + expect(mockGetSkeletonFileMap).toHaveBeenCalledWith(['multipass', 'b2b']); + }); + + it('prints "file -> [recipes]" format by default', () => { + mockGetSkeletonFileMap.mockReturnValue( + new Map([ + ['templates/skeleton/app/root.tsx', ['multipass', 'b2b']], + ['templates/skeleton/app/server.ts', ['gtm']], + ]), + ); + + handler({recipe: [], json: false, existingOnly: false}); + + expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenNthCalledWith( + 1, + 'templates/skeleton/app/root.tsx -> [multipass, b2b]', + ); + expect(console.log).toHaveBeenNthCalledWith( + 2, + 'templates/skeleton/app/server.ts -> [gtm]', + ); + }); + + it('prints JSON object when --json flag is set', () => { + mockGetSkeletonFileMap.mockReturnValue( + new Map([['templates/skeleton/app/root.tsx', ['multipass', 'b2b']]]), + ); + + handler({recipe: [], json: true, existingOnly: false}); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + JSON.stringify( + {'templates/skeleton/app/root.tsx': ['multipass', 'b2b']}, + null, + 2, + ), + ); + }); + + it('produces no output when no files are referenced', () => { + mockGetSkeletonFileMap.mockReturnValue(new Map()); + + handler({recipe: [], json: false, existingOnly: false}); + + expect(console.log).not.toHaveBeenCalled(); + }); + + describe('--existing-only', () => { + it('filters out files that do not exist on disk', () => { + mockGetSkeletonFileMap.mockReturnValue( + new Map([ + ['templates/skeleton/app/root.tsx', ['multipass']], + ['templates/skeleton/app/components/NewFile.tsx', ['multipass']], + ]), + ); + + vi.spyOn(fs, 'existsSync').mockImplementation((p) => + String(p).endsWith('root.tsx'), + ); + + handler({recipe: [], json: false, existingOnly: true}); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + 'templates/skeleton/app/root.tsx -> [multipass]', + ); + }); + + it('resolves paths against REPO_ROOT when checking existence', () => { + mockGetSkeletonFileMap.mockReturnValue( + new Map([['templates/skeleton/app/root.tsx', ['multipass']]]), + ); + + const existsSyncSpy = vi + .spyOn(fs, 'existsSync') + .mockReturnValue(false); + + handler({recipe: [], json: false, existingOnly: true}); + + expect(existsSyncSpy).toHaveBeenCalledWith( + '/repo/templates/skeleton/app/root.tsx', + ); + }); + + it('also filters when --json is set', () => { + mockGetSkeletonFileMap.mockReturnValue( + new Map([ + ['templates/skeleton/app/root.tsx', ['multipass']], + ['templates/skeleton/app/components/NewFile.tsx', ['multipass']], + ]), + ); + + vi.spyOn(fs, 'existsSync').mockImplementation((p) => + String(p).endsWith('root.tsx'), + ); + + handler({recipe: [], json: true, existingOnly: true}); + + expect(console.log).toHaveBeenCalledWith( + JSON.stringify( + {'templates/skeleton/app/root.tsx': ['multipass']}, + null, + 2, + ), + ); + }); + }); +}); diff --git a/cookbook/src/commands/skeleton-files.ts b/cookbook/src/commands/skeleton-files.ts new file mode 100644 index 0000000000..416f62cd4e --- /dev/null +++ b/cookbook/src/commands/skeleton-files.ts @@ -0,0 +1,55 @@ +import fs from 'fs'; +import path from 'path'; +import {CommandModule} from 'yargs'; +import {REPO_ROOT} from '../lib/constants'; +import {getSkeletonFileMap} from '../lib/dependency-graph'; + +type SkeletonFilesArgs = { + recipe: string[]; + json: boolean; + existingOnly: boolean; +}; + +export const skeletonFiles: CommandModule<{}, SkeletonFilesArgs> = { + command: 'skeleton-files', + describe: + 'List all skeleton files referenced by recipes (or a specific recipe)', + builder: { + recipe: { + type: 'array', + string: true, + description: 'Recipe name(s) to query (default: all recipes)', + default: [], + }, + json: { + type: 'boolean', + description: 'Output as JSON object instead of formatted text', + default: false, + }, + 'existing-only': { + type: 'boolean', + description: 'Only show files that currently exist in the skeleton', + default: false, + }, + }, + handler({recipe, json, existingOnly}) { + let fileMap = getSkeletonFileMap(recipe.length > 0 ? recipe : undefined); + + if (existingOnly) { + fileMap = new Map( + Array.from(fileMap.entries()).filter(([file]) => + fs.existsSync(path.join(REPO_ROOT, file)), + ), + ); + } + + if (json) { + const obj = Object.fromEntries(fileMap); + console.log(JSON.stringify(obj, null, 2)); + } else { + fileMap.forEach((recipes, file) => { + console.log(`${file} -> [${recipes.join(', ')}]`); + }); + } + }, +}; diff --git a/cookbook/src/index.ts b/cookbook/src/index.ts index e0f37530e2..03bc7b234f 100644 --- a/cookbook/src/index.ts +++ b/cookbook/src/index.ts @@ -5,11 +5,13 @@ import yargs from 'yargs/yargs'; import * as commands from './commands'; const cli = yargs(process.argv.slice(2)) + .command(commands.affectedRecipes) .command(commands.generate) .command(commands.render) .command(commands.apply) .command(commands.validate) .command(commands.regenerate) + .command(commands.skeletonFiles) .command(commands.update) .command(commands.schema); diff --git a/cookbook/src/lib/dependency-graph.test.ts b/cookbook/src/lib/dependency-graph.test.ts new file mode 100644 index 0000000000..6f419b5b85 --- /dev/null +++ b/cookbook/src/lib/dependency-graph.test.ts @@ -0,0 +1,319 @@ +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import {getAffectedRecipes, getSkeletonFileMap} from './dependency-graph'; + +vi.mock('./util', () => ({listRecipes: vi.fn()})); +vi.mock('./recipe', () => ({loadRecipe: vi.fn()})); +vi.mock('./constants', () => ({ + COOKBOOK_PATH: '/repo/cookbook', + TEMPLATE_DIRECTORY: 'templates/skeleton/', +})); + +import {listRecipes} from './util'; +import {loadRecipe} from './recipe'; + +const mockListRecipes = vi.mocked(listRecipes); +const mockLoadRecipe = vi.mocked(loadRecipe); + +function makeRecipe({ + diffs = [] as {file: string; patchFile: string}[], + ingredients = [] as {path: string}[], + deletedFiles, +}: { + diffs?: {file: string; patchFile: string}[]; + ingredients?: {path: string}[]; + deletedFiles?: string[]; +}) { + return { + gid: 'test-gid', + title: 'Test', + summary: 'Test', + description: 'Test', + ingredients: ingredients.map((i) => ({...i, description: null})), + steps: + diffs.length > 0 + ? [{type: 'PATCH' as const, step: '1', name: 'step', diffs}] + : [], + deletedFiles, + commit: 'abc123', + llms: {userQueries: [], troubleshooting: []}, + } as any; +} + +describe('getAffectedRecipes', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('returns empty array immediately when no changed files provided', () => { + const result = getAffectedRecipes([]); + expect(result).toEqual([]); + expect(mockListRecipes).not.toHaveBeenCalled(); + }); + + it('returns empty array when there are no recipes', () => { + mockListRecipes.mockReturnValue([]); + expect(getAffectedRecipes(['templates/skeleton/app/root.tsx'])).toEqual([]); + }); + + it('returns empty array when no recipe references the changed file', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + diffs: [{file: 'app/server.ts', patchFile: 'server.ts.abc.patch'}], + }), + ); + + const result = getAffectedRecipes(['templates/skeleton/app/root.tsx']); + expect(result).toEqual([]); + }); + + it('detects recipe affected via a diff file', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ); + + const result = getAffectedRecipes(['templates/skeleton/app/root.tsx']); + expect(result).toEqual(['my-recipe']); + }); + + it('normalizes diff.file path by prepending TEMPLATE_DIRECTORY before matching', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ); + + // template-relative path alone does NOT match + expect(getAffectedRecipes(['app/root.tsx'])).toEqual([]); + + // repo-relative path (with prefix) DOES match + expect(getAffectedRecipes(['templates/skeleton/app/root.tsx'])).toEqual([ + 'my-recipe', + ]); + }); + + it('detects recipe affected via an ingredient path', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + ingredients: [{path: 'templates/skeleton/app/components/Foo.tsx'}], + }), + ); + + const result = getAffectedRecipes([ + 'templates/skeleton/app/components/Foo.tsx', + ]); + expect(result).toEqual(['my-recipe']); + }); + + it('does NOT treat deleted files as a reason to flag a recipe as affected', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + deletedFiles: ['templates/skeleton/app/old-routes/removed.tsx'], + }), + ); + + const result = getAffectedRecipes([ + 'templates/skeleton/app/old-routes/removed.tsx', + ]); + expect(result).toEqual([]); + }); + + it('returns only the recipes that match among multiple recipes', () => { + mockListRecipes.mockReturnValue(['recipe-a', 'recipe-b', 'recipe-c']); + mockLoadRecipe + .mockReturnValueOnce( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ) + .mockReturnValueOnce( + makeRecipe({ + ingredients: [{path: 'templates/skeleton/app/components/Bar.tsx'}], + }), + ) + .mockReturnValueOnce( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.def.patch'}], + }), + ); + + const result = getAffectedRecipes(['templates/skeleton/app/root.tsx']); + expect(result).toEqual(['recipe-a', 'recipe-c']); + }); + + it('skips recipes that fail to load and continues checking the rest', () => { + mockListRecipes.mockReturnValue(['bad-recipe', 'good-recipe']); + mockLoadRecipe + .mockImplementationOnce(() => { + throw new Error('YAML parse error'); + }) + .mockReturnValueOnce( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ); + + const result = getAffectedRecipes(['templates/skeleton/app/root.tsx']); + expect(result).toEqual(['good-recipe']); + }); + + it('returns a recipe when any one of multiple changed files matches', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + diffs: [{file: 'app/server.ts', patchFile: 'server.ts.abc.patch'}], + }), + ); + + const result = getAffectedRecipes([ + 'templates/skeleton/app/root.tsx', + 'templates/skeleton/app/server.ts', + ]); + expect(result).toEqual(['my-recipe']); + }); + + it('does not crash when a step has no diffs property', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue({ + gid: 'test-gid', + title: 'Test', + summary: 'Test', + description: 'Test', + ingredients: [], + steps: [{type: 'INFO' as const, step: '1', name: 'Info step'}], + commit: 'abc123', + llms: {userQueries: [], troubleshooting: []}, + } as any); + + const result = getAffectedRecipes(['templates/skeleton/app/root.tsx']); + expect(result).toEqual([]); + }); + + it('does not crash when deletedFiles is undefined', () => { + mockListRecipes.mockReturnValue(['my-recipe']); + mockLoadRecipe.mockReturnValue(makeRecipe({})); + + const result = getAffectedRecipes(['templates/skeleton/app/root.tsx']); + expect(result).toEqual([]); + }); +}); + +describe('getSkeletonFileMap', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('returns empty map when there are no recipes', () => { + mockListRecipes.mockReturnValue([]); + expect(getSkeletonFileMap()).toEqual(new Map()); + }); + + it('maps each file to the recipes that reference it', () => { + mockListRecipes.mockReturnValue(['recipe-a', 'recipe-b']); + mockLoadRecipe + .mockReturnValueOnce( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ) + .mockReturnValueOnce( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.def.patch'}], + ingredients: [{path: 'templates/skeleton/app/components/Foo.tsx'}], + }), + ); + + const result = getSkeletonFileMap(); + + expect(result.get('templates/skeleton/app/root.tsx')).toEqual([ + 'recipe-a', + 'recipe-b', + ]); + expect(result.get('templates/skeleton/app/components/Foo.tsx')).toEqual([ + 'recipe-b', + ]); + }); + + it('collects ingredient paths as repo-relative keys', () => { + mockListRecipes.mockReturnValue(['recipe-a']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + ingredients: [{path: 'templates/skeleton/app/components/Foo.tsx'}], + }), + ); + + const result = getSkeletonFileMap(); + + expect(result.get('templates/skeleton/app/components/Foo.tsx')).toEqual([ + 'recipe-a', + ]); + }); + + it('does not include deletedFiles entries as map keys', () => { + mockListRecipes.mockReturnValue(['recipe-a']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + deletedFiles: ['templates/skeleton/app/old.tsx'], + }), + ); + + expect(getSkeletonFileMap()).toEqual(new Map()); + }); + + it('returns entries sorted alphabetically by file path', () => { + mockListRecipes.mockReturnValue(['recipe-a']); + mockLoadRecipe.mockReturnValue( + makeRecipe({ + diffs: [ + {file: 'app/server.ts', patchFile: 'server.ts.abc.patch'}, + {file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}, + ], + }), + ); + + expect(Array.from(getSkeletonFileMap().keys())).toEqual([ + 'templates/skeleton/app/root.tsx', + 'templates/skeleton/app/server.ts', + ]); + }); + + it('filters to specified recipe names when provided', () => { + mockLoadRecipe.mockReturnValue( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ); + + getSkeletonFileMap(['recipe-a']); + + expect(mockLoadRecipe).toHaveBeenCalledTimes(1); + expect(mockListRecipes).not.toHaveBeenCalled(); + }); + + it('skips recipes that fail to load', () => { + mockListRecipes.mockReturnValue(['bad-recipe', 'good-recipe']); + mockLoadRecipe + .mockImplementationOnce(() => { + throw new Error('YAML parse error'); + }) + .mockReturnValueOnce( + makeRecipe({ + diffs: [{file: 'app/root.tsx', patchFile: 'root.tsx.abc.patch'}], + }), + ); + + const result = getSkeletonFileMap(); + expect(result.get('templates/skeleton/app/root.tsx')).toEqual([ + 'good-recipe', + ]); + expect(result.has('bad-recipe')).toBe(false); + }); +}); diff --git a/cookbook/src/lib/dependency-graph.ts b/cookbook/src/lib/dependency-graph.ts new file mode 100644 index 0000000000..9527cecd44 --- /dev/null +++ b/cookbook/src/lib/dependency-graph.ts @@ -0,0 +1,102 @@ +import path from 'path'; +import {COOKBOOK_PATH, TEMPLATE_DIRECTORY} from './constants'; +import {loadRecipe} from './recipe'; +import {listRecipes} from './util'; + +/** + * Collect all repo-relative skeleton file paths referenced by a single recipe + * (diffs and ingredients; deleted files are intentionally excluded). + */ +function getFilesForRecipe(recipe: { + steps: Array<{diffs?: Array<{file: string}>}>; + ingredients: Array<{path: string}>; +}): Set { + const files = new Set(); + + for (const step of recipe.steps) { + if (step.diffs) { + for (const diff of step.diffs) { + // Patch targets are stored relative to the template root (e.g. "app/root.tsx"). + // Normalise to repo-relative by prepending the template directory prefix. + files.add(`${TEMPLATE_DIRECTORY}${diff.file}`); + } + } + } + + // Ingredient paths are already repo-relative (e.g. "templates/skeleton/app/routes/…") + for (const ingredient of recipe.ingredients) { + files.add(ingredient.path); + } + + return files; +} + +/** + * Collect all skeleton files referenced by the given recipes (or all recipes + * if none specified) and map each file to the recipes that touch it. + * Returns a Map sorted by file path, e.g.: + * "templates/skeleton/app/root.tsx" → ["b2b", "multipass"] + */ +export function getSkeletonFileMap( + recipeNames?: string[], +): Map { + const names = recipeNames ?? listRecipes(); + const fileMap = new Map(); + + const addFile = (file: string, recipeName: string) => { + const recipes = fileMap.get(file) ?? []; + recipes.push(recipeName); + fileMap.set(file, recipes); + }; + + for (const recipeName of names) { + const recipeDir = path.join(COOKBOOK_PATH, 'recipes', recipeName); + let recipe; + try { + recipe = loadRecipe({directory: recipeDir}); + } catch (e) { + console.warn( + `Warning: could not load recipe "${recipeName}": ${e instanceof Error ? e.message : e}`, + ); + continue; + } + + getFilesForRecipe(recipe).forEach((file) => addFile(file, recipeName)); + } + + return new Map( + Array.from(fileMap.entries()).sort((a, b) => a[0].localeCompare(b[0])), + ); +} + +/** + * Given a list of changed skeleton file paths (repo-relative, e.g. + * "templates/skeleton/app/root.tsx"), return the names of recipes that + * reference any of those files. + */ +export function getAffectedRecipes(changedFiles: string[]): string[] { + if (changedFiles.length === 0) return []; + + const recipes = listRecipes(); + const affected: string[] = []; + + for (const recipeName of recipes) { + const recipeDir = path.join(COOKBOOK_PATH, 'recipes', recipeName); + let recipe; + try { + recipe = loadRecipe({directory: recipeDir}); + } catch (e) { + console.warn( + `Warning: could not load recipe "${recipeName}": ${e instanceof Error ? e.message : e}`, + ); + continue; + } + + const recipeFiles = getFilesForRecipe(recipe); + if (changedFiles.some((f) => recipeFiles.has(f))) { + affected.push(recipeName); + } + } + + return affected; +}