diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index cbdf3606..af504a01 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -20,6 +20,8 @@ "ajv-draft-04": "^1.0.0", "js-yaml": "^5.4.1", "re2js": "^2.8.6", + "typescript": "^5.6.0", + "undici": "^7.29.1", "yaml": "^2.5.1" }, "bin": { @@ -28,7 +30,6 @@ "devDependencies": { "@agent-relay/cli-surface": "^12.2.4", "@types/node": "^22.7.0", - "typescript": "^5.6.0", "vitest": "^2.1.0" }, "peerDependencies": { @@ -3172,7 +3173,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 24d7a13e..21811ec5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -57,12 +57,13 @@ "ajv-draft-04": "^1.0.0", "js-yaml": "^5.4.1", "re2js": "^2.8.6", + "typescript": "^5.6.0", + "undici": "^7.29.1", "yaml": "^2.5.1" }, "devDependencies": { "@agent-relay/cli-surface": "^12.2.4", "@types/node": "^22.7.0", - "typescript": "^5.6.0", "vitest": "^2.1.0" }, "repository": { diff --git a/packages/sdk/tests/cli-package-gate.test.ts b/packages/sdk/tests/cli-package-gate.test.ts new file mode 100644 index 00000000..53bac3a8 --- /dev/null +++ b/packages/sdk/tests/cli-package-gate.test.ts @@ -0,0 +1,13 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { it } from 'vitest'; + +const skip = process.env.FLOWS_SKIP_PACKAGE_GATE === '1'; +if (skip) console.error('FLOWS_SKIP_PACKAGE_GATE=1: skipping network-dependent packed CLI installation test; packaging is unverified.'); +it.skipIf(skip)('checks authored flows with locally and globally installed release tarballs', () => { + execFileSync(process.execPath, ['scripts/cli-package-gate.mjs'], { + cwd: fileURLToPath(new URL('../../..', import.meta.url)), + stdio: 'inherit', + timeout: 115_000, + }); +}, 120_000); diff --git a/packages/sdk/tests/runtime-dependencies.test.ts b/packages/sdk/tests/runtime-dependencies.test.ts new file mode 100644 index 00000000..db7120ee --- /dev/null +++ b/packages/sdk/tests/runtime-dependencies.test.ts @@ -0,0 +1,55 @@ +import { builtinModules } from 'node:module'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse, type Node, type AnyNode } from 'acorn'; +import { expect, it } from 'vitest'; + +const sdk = fileURLToPath(new URL('..', import.meta.url)); +const builtins = new Set(builtinModules); + +it('declares every literal runtime import in the built SDK', () => { + const manifest = JSON.parse(readFileSync(join(sdk, 'package.json'), 'utf8')); + const declared = new Set(Object.keys({ + ...manifest.dependencies, ...manifest.optionalDependencies, ...manifest.peerDependencies, + })); + const missing = new Set(); + let files = 0; + function specifier(value: unknown) { + if (typeof value !== 'string' || /^(\.|\/|node:)/.test(value) || builtins.has(value)) return; + const name = value.startsWith('@') ? value.split('/').slice(0, 2).join('/') : value.split('/')[0]!; + if (!declared.has(name)) missing.add(name); + } + // Computed imports/require calls cannot be checked by a literal AST scan. + function walk(node: Node) { + const n = node as AnyNode; + if (n.type === 'ImportDeclaration' || n.type === 'ExportNamedDeclaration' || + n.type === 'ExportAllDeclaration' || n.type === 'ImportExpression') { + if (n.source?.type === 'Literal') specifier(n.source.value); + } + if (n.type === 'CallExpression' && ( + (n.callee.type === 'Identifier' && n.callee.name === 'require') || + (n.callee.type === 'MemberExpression' && !n.callee.computed && + n.callee.object.type === 'Identifier' && n.callee.object.name === 'require' && + n.callee.property.type === 'Identifier' && n.callee.property.name === 'resolve'))) { + if (n.arguments[0]?.type === 'Literal') specifier(n.arguments[0].value); + } + for (const value of Object.values(n)) { + if (Array.isArray(value)) value.forEach(child => { if (child?.type) walk(child); }); + else if (value && typeof value === 'object' && 'type' in value) walk(value as Node); + } + } + function scan(directory: string) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) scan(path); + else if (entry.name.endsWith('.js')) { + files++; + walk(parse(readFileSync(path, 'utf8'), { ecmaVersion: 'latest', sourceType: 'module' })); + } + } + } + scan(join(sdk, 'dist')); + expect(files, 'Build the SDK before checking runtime dependencies').toBeGreaterThan(0); + expect([...missing].sort(), 'Undeclared runtime dependencies').toEqual([]); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 04045eaa..773c991c 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -12,6 +12,8 @@ "include": [ "src/**/*.ts", "tests/isolate-workspace.ts", + "tests/runtime-dependencies.test.ts", + "tests/cli-package-gate.test.ts", "tests/run-projection.test.ts", "tests/observer-link.test.ts", "tests/bundle.test.ts", diff --git a/scripts/cli-package-gate.mjs b/scripts/cli-package-gate.mjs new file mode 100644 index 00000000..dd645cf1 --- /dev/null +++ b/scripts/cli-package-gate.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// Requires built surface + SDK. Install real release tarballs outside the repo. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const [major, minor] = process.versions.node.split('.').map(Number); +assert(major > 22 || (major === 22 && minor >= 18), + 'CLI packaging gate requires Node >=22.18.0 for .flow.ts type stripping; the published engines range is broader than this authored-flow requirement.'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(join(root, 'packages/sdk/package.json')); +const ts = require('typescript'); +const fixture = 'dependency-upgrade-bot.flow.ts'; +const source = readFileSync(join(root, 'examples/dependency-upgrade-bot', fixture), 'utf8'); +const imports = new Set(); +function visit(node) { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier) imports.add(node.moduleSpecifier.text); + } + if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || + node.expression.getText() === 'require')) imports.add(node.arguments[0]?.text); + ts.forEachChild(node, visit); +} +visit(ts.createSourceFile(fixture, source, ts.ScriptTarget.Latest, true)); +assert.deepEqual([...imports], ['@relayflows/surface'], + 'Packaging fixture must import only @relayflows/surface; consider task-graph, social-post-pipeline, pr-review-pipeline, or software-factory.'); +const temporary = realpathSync(mkdtempSync(join(tmpdir(), 'flows-cli-package-'))); +const env = { ...process.env }; +for (const key of ['NODE_PATH', 'NODE_OPTIONS', 'GITHUB_OUTPUT']) delete env[key]; +function run(command, args, cwd = root) { + console.log(`$ ${command} ${args.join(' ')}`); + try { + const output = execFileSync(command, args, { cwd, env, encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'] }); + process.stdout.write(output); + return output; + } catch (error) { + process.stdout.write(error.stdout ?? ''); + process.stderr.write(error.stderr ?? ''); + throw error; + } +} +function project(name) { + const directory = join(temporary, name); + mkdirSync(join(directory, 'examples'), { recursive: true }); + writeFileSync(join(directory, 'package.json'), '{"private":true,"type":"module"}\n'); + writeFileSync(join(directory, 'examples', fixture), source); + return directory; +} +function check(binary, directory, args = []) { + const output = run(binary, [...args, 'check', `examples/${fixture}`], directory); + assert.match(output, /CHECK PASSED/); +} +function resolvedInside(manifest, boundary) { + const resolved = realpathSync(createRequire(manifest).resolve('typescript')); + assert(resolved.startsWith(`${boundary}${sep}`), `TypeScript resolved outside install: ${resolved}`); +} +try { + // Reject contaminated temp roots as well as temp directories inside the repo. + assert(!temporary.startsWith(`${root}${sep}`), 'Temp directory must be outside the repository'); + for (let parent = dirname(temporary); ; parent = dirname(parent)) { + assert(!existsSync(join(parent, 'node_modules')), `Ancestor node_modules contaminates packaging test: ${parent}`); + if (parent === dirname(parent)) break; + } + const tarballs = join(temporary, 'tarballs'); + for (const name of ['surface', 'sdk', 'relayflows']) { + run(process.execPath, ['scripts/pack-release.mjs', name, tarballs]); + } + const archives = readdirSync(tarballs).filter(name => name.endsWith('.tgz')).map(name => join(tarballs, name)); + assert.equal(archives.length, 3); + // Optional platform binaries are unrelated to Node dependency resolution. + const flags = ['--ignore-scripts', '--omit=optional', '--no-audit', '--no-fund']; + const local = project('local'); + run('npm', ['install', ...flags, ...archives], local); + check(join(local, 'node_modules/.bin/flows'), local); + check(process.execPath, local, [join(local, 'node_modules/relayflows/bin/flows.js')]); + resolvedInside(join(local, 'node_modules/@relayflows/sdk/package.json'), local); + + const prefix = join(temporary, 'prefix'); + run('npm', ['install', '-g', '--prefix', prefix, ...flags, ...archives], temporary); + assert.deepEqual(readdirSync(join(prefix, 'bin')).sort(), ['flows'], 'Global install must not expose tsc or tsserver'); + // Both SDK and wrapper own a flows bin; also exercise the wrapper explicitly. + const wrapper = join(prefix, 'lib/node_modules/relayflows'); + const globalProject = project('global-project'); + const surface = archives.find(path => path.endsWith(`/relayflows-surface-${require('./package.json').version}.tgz`)); + assert(surface, 'Missing surface archive'); + run('npm', ['install', ...flags, surface], globalProject); + assert(!existsSync(join(globalProject, 'node_modules/typescript')), 'Global consumer must not supply TypeScript'); + check(join(prefix, 'bin/flows'), globalProject); + check(process.execPath, globalProject, [join(wrapper, 'bin/flows.js')]); + resolvedInside(join(prefix, 'lib/node_modules/@relayflows/sdk/package.json'), prefix); + console.log('CLI_PACKAGE_OK: local and global installs'); +} finally { + rmSync(temporary, { recursive: true, force: true }); +}