diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index 82e2a1ac22..8d5a812f7d 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -117,7 +117,6 @@ export async function generateAppLocksInternal( }, justUpdateMetadataLock?: boolean, noStaleMessage?: boolean, - legacyBehaviour?: boolean, tree?: DoubleLinkedDependencyTree ): Promise { if (appFolder.endsWith(SEP)) { @@ -143,8 +142,8 @@ export async function generateAppLocksInternal( let filteredDeps: Record = {}; const conf = await readLockfile(); - // New behaviour: tree-based dependency tracking - if (!legacyBehaviour && tree) { + // Tree-based dependency tracking + if (tree) { if (dryRun) { const hashes = await generateAppHash({}, appFolder, rawApp, opts.defaultTs); const isDirectlyStale = !(await checkifMetadataUptodate(appFolder, hashes[TOP_HASH], conf, TOP_HASH)); @@ -245,7 +244,7 @@ export async function generateAppLocksInternal( // In tree mode, the tree already verified this app is stale (possibly via dependency change). // Per-script hashes only detect content changes, not transitive dependency changes, // so we must regenerate locks for all inline scripts regardless. - if (changedScripts.length > 0 || (tree && !legacyBehaviour)) { + if (changedScripts.length > 0 || tree) { if (!noStaleMessage) { log.info( `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` @@ -309,8 +308,8 @@ export async function generateAppLocksInternal( } } - // Non-legacy mode excludes workspace deps from hash (tracked via tree instead) - const depsForHash = (tree && !legacyBehaviour) ? {} : filteredDeps; + // In tree mode, workspace deps are tracked via the tree — exclude from hash + const depsForHash = tree ? {} : filteredDeps; const finalHashes = await generateAppHash( depsForHash, appFolder, diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index 57a9289fa1..e59d27bab9 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -70,7 +70,6 @@ export async function generateFlowLockInternal( }, justUpdateMetadataLock?: boolean, noStaleMessage?: boolean, - legacyBehaviour?: boolean, tree?: DoubleLinkedDependencyTree ): Promise { if (folder.endsWith(SEP)) { @@ -96,7 +95,7 @@ export async function generateFlowLockInternal( let filteredDeps: Record = {}; const conf = await readLockfile(); - if (!legacyBehaviour && tree) { + if (tree) { if (dryRun) { const inlineScriptPaths: string[] = []; for (const script of inlineScriptsForTree) { @@ -201,7 +200,7 @@ export async function generateFlowLockInternal( // In tree mode, use the tree's staleness info (which includes transitive dependency changes) // to determine which scripts need relocking, instead of only content-changed ones. - const locksToRemove = (tree && !legacyBehaviour) + const locksToRemove = tree ? Object.keys(hashes).filter(k => { if (k === TOP_HASH) return false; const treePath = fileToTreePath.get(k) @@ -271,8 +270,8 @@ export async function generateFlowLockInternal( ); } - // Non-legacy mode excludes workspace deps from hash (tracked via tree instead) - const depsForHash = (tree && !legacyBehaviour) ? {} : filteredDeps; + // In tree mode, workspace deps are tracked via the tree — exclude from hash + const depsForHash = tree ? {} : filteredDeps; const finalHashes = await generateFlowHash( depsForHash, folder, @@ -288,7 +287,7 @@ export async function generateFlowLockInternal( // Return the list of updated scripts (extract just the filename from the path) // In tree mode, use the same staleness-aware list we used for lock removal - const relocked = (tree && !legacyBehaviour) + const relocked = tree ? Object.keys(finalHashes).filter(k => { if (k === TOP_HASH) return false; const treePath = fileToTreePath.get(k) diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index 883e69c826..ee867fe9fc 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -110,7 +110,6 @@ async function generateMetadata( rawWorkspaceDependencies, codebases, false, - false, // legacyBehaviour tree ); } @@ -142,7 +141,6 @@ async function generateMetadata( opts, false, true, // noStaleMessage - false, // legacyBehaviour tree ); } @@ -176,7 +174,6 @@ async function generateMetadata( opts, false, true, // noStaleMessage - false, // legacyBehaviour tree ); } @@ -190,7 +187,6 @@ async function generateMetadata( opts, false, true, // noStaleMessage - false, // legacyBehaviour tree ); } @@ -372,7 +368,6 @@ async function generateMetadata( mismatchedWorkspaceDeps, codebases, false, - false, // legacyBehaviour tree ); } catch (e) { @@ -393,7 +388,6 @@ async function generateMetadata( opts, false, true, // noStaleMessage - false, // legacyBehaviour tree ); const flowResult = result as FlowLocksResult | undefined; @@ -421,7 +415,6 @@ async function generateMetadata( opts, false, true, // noStaleMessage - false, // legacyBehaviour tree ); const appResult = result as AppLocksResult | undefined; diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 7ba95234e5..f545dfa318 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -191,7 +191,6 @@ export async function generateScriptMetadataInternal( rawWorkspaceDependencies: Record, codebases: SyncCodebase[], justUpdateMetadataLock?: boolean, - legacyBehaviour?: boolean, tree?: DoubleLinkedDependencyTree ): Promise { // Detect folder layout: my_script__mod/script.ts @@ -228,15 +227,15 @@ export async function generateScriptMetadataInternal( const hasModules = existsSync(moduleFolderPath) && statSync(moduleFolderPath).isDirectory(); - // In non-legacy mode, workspace deps are tracked via the tree — exclude from hash - const depsForHash = (!legacyBehaviour && tree) ? {} : filteredRawWorkspaceDependencies; + // In tree mode, workspace deps are tracked via the tree — exclude from hash + const depsForHash = tree ? {} : filteredRawWorkspaceDependencies; let hash = await generateScriptHash(depsForHash, scriptContent, metadataContent); // Compute per-module hashes for stale detection (like flow inline scripts) let moduleHashes: Record = {}; if (hasModules) { moduleHashes = await computeModuleHashes( - moduleFolderPath, opts.defaultTs, (!legacyBehaviour && tree) ? {} : rawWorkspaceDependencies, isFolderLayout + moduleFolderPath, opts.defaultTs, tree ? {} : rawWorkspaceDependencies, isFolderLayout ); } const hasModuleHashes = Object.keys(moduleHashes).length > 0; @@ -255,8 +254,8 @@ export async function generateScriptMetadataInternal( // Use checkHash (includes module hashes) so module changes are detected as stale const isDirectlyStale = !(await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath)); - // New behaviour: tree-based dependency tracking - if (!legacyBehaviour && tree) { + // Tree-based dependency tracking + if (tree) { if (dryRun) { // First pass: populate tree with script and its imports const imports = await extractRelativeImports(scriptContent, remotePath, language); @@ -265,7 +264,7 @@ export async function generateScriptMetadataInternal( } // Second pass: proceed to generate (caller verified this script is stale via tree) } else { - // Legacy behaviour: use existing staleness check + // Legacy path: use existing staleness check if (await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath)) { if (!noStaleMessage) { log.info( diff --git a/cli/test/unified_generate_metadata.test.ts b/cli/test/unified_generate_metadata.test.ts index ba14f79a15..bb73e54b37 100644 --- a/cli/test/unified_generate_metadata.test.ts +++ b/cli/test/unified_generate_metadata.test.ts @@ -774,3 +774,239 @@ describe("generate-metadata with script modules", () => { }); }); }); + +// ============================================================================= +// Per-resource-type lockfile generation and idempotency +// ============================================================================= + +describe("generate-metadata: script lockfile generation", () => { + test("generates lock content and is idempotent", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "script_lock_gen_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run: should detect stale and generate locks + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "script_lock_gen_test" + ); + expect(result1.code).toEqual(0); + expect(result1.stdout).toContain("my_script"); + expect(result1.stdout).toContain("Done"); + + // Verify the script metadata file now has a non-empty lock + const metaContent = await readFile( + `${tempDir}/f/test/my_script.script.yaml`, + "utf-8" + ); + // Lock should no longer be empty — it was populated by generate-metadata + expect(metaContent).not.toContain('lock: ""'); + + // Second run: should be up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "script_lock_gen_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + }); + }); + + test("detects content change after initial generation", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "script_change_detect_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Generate metadata + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "script_change_detect_test" + ); + expect(result1.code).toEqual(0); + + // Verify up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "script_change_detect_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + + // Modify script content + await writeFile( + `${tempDir}/f/test/my_script.ts`, + 'export async function main() {\n return "modified";\n}', + "utf-8" + ); + + // Should detect as stale + const result3 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "script_change_detect_test" + ); + expect(result3.code).toEqual(0); + expect(result3.stdout).toContain("my_script"); + expect(result3.stdout).not.toContain("up-to-date"); + }); + }); +}); + +describe("generate-metadata: flow lockfile generation", () => { + test("generates lock files for inline scripts and is idempotent", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "flow_lock_gen_test"); + + await createLocalFlow(tempDir, "f/test", "my_flow"); + + // First run: should detect stale and generate locks + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "flow_lock_gen_test" + ); + expect(result1.code).toEqual(0); + expect(result1.stdout).toContain("my_flow"); + expect(result1.stdout).toContain("Done"); + + // Verify the inline script lock file was created + const flowDir = `${tempDir}/f/test/my_flow.flow`; + expect(await fileExists(`${flowDir}/a.inline_script.lock`)).toEqual(true); + + // Second run: should be up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "flow_lock_gen_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + }); + }); + + test("detects inline script change after initial generation", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "flow_change_detect_test"); + + await createLocalFlow(tempDir, "f/test", "my_flow"); + + // Generate metadata + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "flow_change_detect_test" + ); + expect(result1.code).toEqual(0); + + // Verify up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "flow_change_detect_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + + // Modify the inline script + await writeFile( + `${tempDir}/f/test/my_flow.flow/a.inline_script.ts`, + 'export async function main() {\n return "modified flow script";\n}', + "utf-8" + ); + + // Should detect as stale + const result3 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "flow_change_detect_test" + ); + expect(result3.code).toEqual(0); + expect(result3.stdout).toContain("my_flow"); + expect(result3.stdout).not.toContain("up-to-date"); + }); + }); +}); + +describe("generate-metadata: app lockfile generation", () => { + test("generates lock content and is idempotent", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "app_lock_gen_test"); + + await createLocalApp(tempDir, "f/test", "my_app"); + + // First run: should detect stale and generate locks + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "app_lock_gen_test" + ); + expect(result1.code).toEqual(0); + expect(result1.stdout).toContain("my_app"); + expect(result1.stdout).toContain("Done"); + + // Second run: should be up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "app_lock_gen_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + }); + }); + + test("detects app change after initial generation", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "app_change_detect_test"); + + await createLocalApp(tempDir, "f/test", "my_app"); + + // Generate metadata + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "app_change_detect_test" + ); + expect(result1.code).toEqual(0); + + // Verify up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "app_change_detect_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + + // After generation, inline scripts are extracted to separate files. + // Find and modify the extracted inline script file. + const appDir = `${tempDir}/f/test/my_app.app`; + const { readdir: readdirAsync } = await import("node:fs/promises"); + const files = await readdirAsync(appDir); + const inlineScriptFile = files.find((f: string) => f.endsWith(".ts")); + expect(inlineScriptFile).toBeDefined(); + + await writeFile( + `${appDir}/${inlineScriptFile}`, + 'export async function main() {\n return "modified app";\n}', + "utf-8" + ); + + // Should detect as stale + const result3 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "app_change_detect_test" + ); + expect(result3.code).toEqual(0); + expect(result3.stdout).toContain("my_app"); + expect(result3.stdout).not.toContain("up-to-date"); + }); + }); +});