diff --git a/package.json b/package.json index cd0fed1a6..07a7fd3c9 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "scripts": { "clean": "npm run clean --workspaces", "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", - "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke", + "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", + "check:pinned-deps": "node scripts/check-pinned-deps.mjs", + "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", @@ -24,6 +26,7 @@ "prepublishOnly": "npm run clean && npm run build && npm run check", "publish": "npm run prepublishOnly && npm publish -ws --access public", "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "release:local": "node scripts/local-release.mjs", "release:patch": "node scripts/release.mjs patch", "release:minor": "node scripts/release.mjs minor", "release:major": "node scripts/release.mjs major", diff --git a/scripts/check-pinned-deps.mjs b/scripts/check-pinned-deps.mjs new file mode 100644 index 000000000..a2b389424 --- /dev/null +++ b/scripts/check-pinned-deps.mjs @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"]; +const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const ignoredDirectories = new Set([".git", "dist", "node_modules"]); +const packageJsonFiles = []; + +function collectPackageJsonFiles(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectPackageJsonFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name === "package.json") { + packageJsonFiles.push(join(directory, entry.name)); + } + } +} + +function isInternalWorkspaceDependency(name) { + return name.startsWith("@earendil-works/pi-"); +} + +function isNonRegistrySpecifier(specifier) { + return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier); +} + +function getVersionSpecifier(specifier) { + if (!specifier.startsWith("npm:")) return specifier; + const aliasTarget = specifier.slice("npm:".length); + const versionSeparator = aliasTarget.lastIndexOf("@"); + if (versionSeparator <= 0) return specifier; + return aliasTarget.slice(versionSeparator + 1); +} + +const failures = []; + +collectPackageJsonFiles("."); + +for (const file of packageJsonFiles.sort()) { + const packageJson = JSON.parse(readFileSync(file, "utf8")); + + for (const section of dependencySections) { + const dependencies = packageJson[section]; + if (!dependencies) continue; + + for (const [name, specifier] of Object.entries(dependencies)) { + if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue; + if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue; + failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`); + } + } +} + +if (failures.length > 0) { + console.error("Direct external dependencies must use exact versions:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} diff --git a/scripts/check-ts-relative-imports.mjs b/scripts/check-ts-relative-imports.mjs new file mode 100644 index 000000000..bf21a5d0a --- /dev/null +++ b/scripts/check-ts-relative-imports.mjs @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]); +const files = []; + +function collectTypescriptFiles(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectTypescriptFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { + files.push(join(directory, entry.name)); + } + } +} + +function isRelativeJavaScriptSpecifier(specifier) { + return /^\.\.?\//.test(specifier) && /\.js(?:[?#].*)?$/.test(specifier); +} + +function getImportTypeSpecifier(node) { + if (!ts.isLiteralTypeNode(node.argument)) return undefined; + if (!ts.isStringLiteralLike(node.argument.literal)) return undefined; + return node.argument.literal; +} + +const failures = []; + +collectTypescriptFiles("."); + +for (const file of files.sort()) { + const sourceText = readFileSync(file, "utf8"); + const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + + function checkSpecifier(node) { + if (!isRelativeJavaScriptSpecifier(node.text)) return; + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + failures.push(`${file}:${line + 1}:${character + 1}: ${node.text}`); + } + + function visit(node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) { + checkSpecifier(node.moduleSpecifier); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { + checkSpecifier(node.moduleSpecifier); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) { + checkSpecifier(node.arguments[0]); + } else if (ts.isImportTypeNode(node)) { + const specifier = getImportTypeSpecifier(node); + if (specifier) checkSpecifier(specifier); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); +} + +if (failures.length > 0) { + console.error("Relative .js imports are not allowed in non-declaration .ts files:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs new file mode 100644 index 000000000..31e07901c --- /dev/null +++ b/scripts/local-release.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const packages = [ + { directory: "packages/ai", name: "@earendil-works/pi-ai" }, + { directory: "packages/tui", name: "@earendil-works/pi-tui" }, + { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, + { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, +]; + +function printUsage() { + console.log(`Usage: node scripts/local-release.mjs [options] + +Builds and packs the publishable packages, then installs the tarballs into an +isolated directory outside the repository for local release testing. + +Options: + --out