diff --git a/package.json b/package.json index 9f213149..b02b44f5 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "perf:react-hook-form": "npm run perf -w @interactive-os/json-document-react-hook-form", "perf:ajv": "npm run perf -w @interactive-os/json-document-ajv", "perf:rich-text": "npm run build -w @interactive-os/json-document-rich-text && node packages/json-document-rich-text/benchmarks/editor.mjs", - "release:check": "npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate && npm run perf:core && npm run pack:library && npm run pack:companions", + "release:check": "npm run workspace:check && npm run verify:libraries && npm run standard:check && npm run docs:evaluate && npm run perf:core && npm run pack:libraries", "standard:check": "node standards/json-document-v3/evaluate.mjs && node standards/json-document-rich-text-v1/evaluate.mjs && node standards/json-document-rich-text-v1/evaluate-corpora.mjs && npm test -w @interactive-os/json-document -- conformance", - "verify": "npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run docs:evaluate && npm run typecheck -w @interactive-os/json-document-site && npm test -w @interactive-os/json-document-site && npm run site:verify:pages && npm run browser:test", - "verify:companions": "npm run verify -w @interactive-os/json-document-selection && npm run verify -w @interactive-os/json-document-editing && npm run verify -w @interactive-os/json-document-react && npm run verify -w @interactive-os/json-document-react-hook-form && npm run verify -w @interactive-os/json-document-ajv && npm run verify -w @interactive-os/json-document-zod && npm run verify -w @interactive-os/json-document-tanstack-table && npm run verify -w @interactive-os/json-document-web && npm run verify -w @interactive-os/json-document-contenteditable && npm run verify -w @interactive-os/json-document-rich-text && npm run verify -w @interactive-os/json-document-rich-text-web && npm run verify -w @interactive-os/json-document-rich-text-react && npm run verify -w @interactive-os/json-document-collaboration && npm run verify -w @interactive-os/json-document-contenteditable-collaboration", - "pack:library": "npm pack -w @interactive-os/json-document --dry-run --cache ./.npm-cache", - "pack:companions": "npm pack -w @interactive-os/json-document-selection --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-editing --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-react --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-react-hook-form --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-ajv --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-zod --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-tanstack-table --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-web --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-contenteditable --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-rich-text --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-rich-text-web --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-rich-text-react --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-collaboration --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-contenteditable-collaboration --dry-run --cache ./.npm-cache", + "verify": "npm run workspace:check && npm run verify:libraries && npm run docs:evaluate && npm run typecheck -w @interactive-os/json-document-site && npm test -w @interactive-os/json-document-site && npm run site:verify:pages && npm run browser:test", + "workspace:check": "node scripts/workspace-tasks.mjs check", + "verify:libraries": "node scripts/workspace-tasks.mjs verify", + "pack:libraries": "node scripts/workspace-tasks.mjs pack", "browser:test": "playwright test --config site/playwright.config.ts", "browser:test:headed": "playwright test --config site/playwright.config.ts --headed", "browser:install": "playwright install chromium", diff --git a/packages/contenteditable-collaboration/package.json b/packages/contenteditable-collaboration/package.json index 35a3f1a5..0efe5581 100644 --- a/packages/contenteditable-collaboration/package.json +++ b/packages/contenteditable-collaboration/package.json @@ -38,11 +38,10 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document-collaboration", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "prepack": "npm run build", "test": "vitest run --config vitest.config.ts", - "pretypecheck": "npm run build -w @interactive-os/json-document-collaboration", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-ajv/package.json b/packages/json-document-ajv/package.json index d9bd16d0..55c40330 100644 --- a/packages/json-document-ajv/package.json +++ b/packages/json-document-ajv/package.json @@ -26,11 +26,10 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", "perf": "npm run build && node benchmarks/validation.mjs", - "pretypecheck": "npm run build -w @interactive-os/json-document", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index eba23ccc..bb9850e2 100644 --- a/packages/json-document-collaboration/package.json +++ b/packages/json-document-collaboration/package.json @@ -39,8 +39,7 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "prepack": "npm run build", "test": "vitest run --config vitest.config.ts", "test:soak": "JSON_DOCUMENT_COLLABORATION_SOAK_CASES=24 JSON_DOCUMENT_COLLABORATION_SOAK_STEPS=96 vitest run --config vitest.config.ts tests/convergence/soak.test.ts", diff --git a/packages/json-document-contenteditable/package.json b/packages/json-document-contenteditable/package.json index c00df20c..73c3fbe3 100644 --- a/packages/json-document-contenteditable/package.json +++ b/packages/json-document-contenteditable/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", - "pretypecheck": "npm run build -w @interactive-os/json-document", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-editing/package.json b/packages/json-document-editing/package.json index ea853d43..37704800 100644 --- a/packages/json-document-editing/package.json +++ b/packages/json-document-editing/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-selection", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "check:structure": "node scripts/check-responsibility-layout.mjs", - "pretypecheck": "npm run build -w @interactive-os/json-document", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "test": "vitest run --config vitest.config.ts", "perf": "npm run build && node benchmarks/editors.mjs", "typecheck": "tsc -p tsconfig.test.json --noEmit", diff --git a/packages/json-document-react-hook-form/package.json b/packages/json-document-react-hook-form/package.json index 7934b514..92425ac6 100644 --- a/packages/json-document-react-hook-form/package.json +++ b/packages/json-document-react-hook-form/package.json @@ -26,11 +26,10 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document-selection && npm run build -w @interactive-os/json-document-editing && npm run build -w @interactive-os/json-document-react", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", "perf": "npm run build && node benchmarks/form.mjs", - "pretypecheck": "npm run build -w @interactive-os/json-document-selection && npm run build -w @interactive-os/json-document-editing && npm run build -w @interactive-os/json-document-react && npm run build -w @interactive-os/json-document-zod", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-react/package.json b/packages/json-document-react/package.json index 23252bda..8d7b06e7 100644 --- a/packages/json-document-react/package.json +++ b/packages/json-document-react/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-editing", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", - "pretypecheck": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-editing", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-rich-text-react/package.json b/packages/json-document-rich-text-react/package.json index d7749d87..73de6837 100644 --- a/packages/json-document-rich-text-react/package.json +++ b/packages/json-document-rich-text-react/package.json @@ -21,9 +21,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document-rich-text && npm run build -w @interactive-os/json-document-rich-text-web && npm run build -w @interactive-os/json-document-react", - "build": "npm run clean && tsc -p tsconfig.json", - "pretypecheck": "npm run prebuild", + "build": "npm run clean && tsc -b tsconfig.json", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm test && npm run build" diff --git a/packages/json-document-rich-text-web/package.json b/packages/json-document-rich-text-web/package.json index dbc79e5a..1808cbc3 100644 --- a/packages/json-document-rich-text-web/package.json +++ b/packages/json-document-rich-text-web/package.json @@ -21,9 +21,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document-rich-text && npm run build -w @interactive-os/json-document-web", - "build": "npm run clean && tsc -p tsconfig.json", - "pretypecheck": "npm run prebuild", + "build": "npm run clean && tsc -b tsconfig.json", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm test && npm run build" diff --git a/packages/json-document-rich-text/package.json b/packages/json-document-rich-text/package.json index 0e12ddec..f8d1e9bf 100644 --- a/packages/json-document-rich-text/package.json +++ b/packages/json-document-rich-text/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-selection && npm run build -w @interactive-os/json-document-editing", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "check:structure": "node scripts/check-editor-responsibilities.mjs", - "pretypecheck": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-selection && npm run build -w @interactive-os/json-document-editing", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.test.json --noEmit", "perf": "npm run build && node benchmarks/editor.mjs", diff --git a/packages/json-document-selection/package.json b/packages/json-document-selection/package.json index ce32329c..00075f60 100644 --- a/packages/json-document-selection/package.json +++ b/packages/json-document-selection/package.json @@ -26,7 +26,7 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tests/dom-free/tsconfig.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" diff --git a/packages/json-document-tanstack-table/package.json b/packages/json-document-tanstack-table/package.json index ae2db651..09ded1ef 100644 --- a/packages/json-document-tanstack-table/package.json +++ b/packages/json-document-tanstack-table/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document-editing", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", - "pretypecheck": "npm run build -w @interactive-os/json-document-editing", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-web/package.json b/packages/json-document-web/package.json index 520cfd6e..824229c0 100644 --- a/packages/json-document-web/package.json +++ b/packages/json-document-web/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document-selection && npm run build -w @interactive-os/json-document-editing", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", - "pretypecheck": "npm run build -w @interactive-os/json-document-selection && npm run build -w @interactive-os/json-document-editing", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-zod/package.json b/packages/json-document-zod/package.json index f150e844..88d0e926 100644 --- a/packages/json-document-zod/package.json +++ b/packages/json-document-zod/package.json @@ -26,10 +26,9 @@ }, "scripts": { "clean": "rm -rf dist", - "prebuild": "npm run build -w @interactive-os/json-document", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", - "pretypecheck": "npm run build -w @interactive-os/json-document", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document/package.json b/packages/json-document/package.json index 5b8e5871..5482ebd1 100644 --- a/packages/json-document/package.json +++ b/packages/json-document/package.json @@ -47,7 +47,7 @@ "scripts": { "clean": "rm -rf dist", "layers:check": "node ./scripts/evaluate-layers.mjs", - "build": "npm run clean && tsc -p tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json", "prepack": "npm run build", "prepublishOnly": "npm --prefix ../.. run release:check", "smoke:package": "node ./tests/package/smoke.mjs", diff --git a/scripts/workspace-tasks.mjs b/scripts/workspace-tasks.mjs new file mode 100644 index 00000000..7e0c285c --- /dev/null +++ b/scripts/workspace-tasks.mjs @@ -0,0 +1,168 @@ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function readJson(path) { + return JSON.parse(readFileSync(join(repositoryRoot, path), "utf8")); +} + +const rootPackage = readJson("package.json"); +const libraryDirectories = rootPackage.workspaces.filter((directory) => + directory.startsWith("packages/"), +); +const libraries = libraryDirectories.map((directory) => ({ + directory, + manifest: readJson(`${directory}/package.json`), + tsconfig: readJson(`${directory}/tsconfig.json`), +})); +const libraryByName = new Map(libraries.map((library) => [library.manifest.name, library])); + +function internalDependencies(library) { + const manifest = library.manifest; + const names = Object.keys({ + ...manifest.dependencies, + ...manifest.optionalDependencies, + ...manifest.peerDependencies, + ...manifest.devDependencies, + }); + return names.flatMap((name) => { + const dependency = libraryByName.get(name); + return dependency === undefined ? [] : [dependency]; + }); +} + +function topologicalLibraries() { + const result = []; + const visiting = new Set(); + const visited = new Set(); + function visit(library) { + const name = library.manifest.name; + if (visited.has(name)) return; + if (visiting.has(name)) throw new Error(`workspace dependency cycle: ${name}`); + visiting.add(name); + for (const dependency of internalDependencies(library)) visit(dependency); + visiting.delete(name); + visited.add(name); + result.push(library); + } + for (const library of libraries) visit(library); + return result; +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + env: process.env, + stdio: "inherit", + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function runCaptured(command, args) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + env: process.env, + encoding: "utf8", + }); + if (result.stderr) process.stderr.write(result.stderr); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + if (result.stdout) process.stdout.write(result.stdout); + process.exit(result.status ?? 1); + } + return result.stdout; +} + +function runOptionalScript(script) { + for (const library of topologicalLibraries()) { + if (library.manifest.scripts?.[script] === undefined) { + console.log(`[workspace:${script}] skip ${library.manifest.name}: no script`); + continue; + } + console.log(`[workspace:${script}] ${library.manifest.name}`); + run("npm", ["run", script, "--workspace", library.manifest.name]); + } +} + +function packLibraries() { + run("npm", ["run", "build"]); + for (const library of topologicalLibraries()) { + if (library.manifest.private === true) { + console.log(`[workspace:pack] skip ${library.manifest.name}: private`); + continue; + } + console.log(`[workspace:pack] ${library.manifest.name}`); + const output = runCaptured("npm", [ + "pack", + "--workspace", + library.manifest.name, + "--dry-run", + "--json", + "--cache", + "./.npm-cache", + ]); + const packed = JSON.parse(output)[0]; + if (packed.files.some(({ path }) => path.endsWith(".tsbuildinfo"))) { + throw new Error(`${library.manifest.name}: TypeScript build metadata leaked into package`); + } + console.log(`[workspace:pack] ok ${packed.name}: ${packed.entryCount} files`); + } +} + +function buildDependencies() { + const currentDirectory = normalize(process.cwd()); + const library = libraries.find(({ directory }) => + currentDirectory === normalize(join(repositoryRoot, directory)), + ); + if (library === undefined) throw new Error("build-dependencies must run from a library workspace"); + const dependencies = new Set(internalDependencies(library).map(({ manifest }) => manifest.name)); + const configs = topologicalLibraries() + .filter(({ manifest }) => dependencies.has(manifest.name)) + .map(({ directory }) => `${directory}/tsconfig.json`); + if (configs.length === 0) return; + run("npm", ["exec", "tsc", "--", "-b", ...configs]); +} + +function checkWorkspaceContract() { + const solution = readJson("tsconfig.build.json"); + const solutionDirectories = solution.references.map(({ path }) => normalize(path.replace(/^\.\//, ""))); + if (JSON.stringify(solutionDirectories) !== JSON.stringify(libraryDirectories)) { + throw new Error("tsconfig.build.json references must match library workspaces"); + } + for (const library of libraries) { + if (library.manifest.private === true) throw new Error(`library workspace must be publishable: ${library.directory}`); + for (const script of ["build", "verify"]) { + if (library.manifest.scripts?.[script] === undefined) throw new Error(`${library.directory}: missing ${script} script`); + } + const manifest = library.manifest; + const expected = internalDependencies(library) + .filter(({ manifest: dependency }) => + manifest.dependencies?.[dependency.name] !== undefined || + manifest.optionalDependencies?.[dependency.name] !== undefined || + manifest.peerDependencies?.[dependency.name] !== undefined) + .map(({ directory }) => directory) + .sort(); + const actual = (library.tsconfig.references ?? []) + .map(({ path }) => normalize(join(library.directory, path))) + .sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${library.directory}: project references do not match runtime dependencies`); + } + } + console.log(`workspace task contract ok: ${libraries.length} libraries`); +} + +const command = process.argv[2]; +if (command === "check") checkWorkspaceContract(); +else if (command === "verify") runOptionalScript("verify"); +else if (command === "pack") packLibraries(); +else if (command === "build-dependencies") buildDependencies(); +else if (command === "list") console.log(topologicalLibraries().map(({ manifest }) => manifest.name).join("\n")); +else { + console.error("usage: node scripts/workspace-tasks.mjs "); + process.exit(2); +}