From a01b425c11cb6f0b9dccac060822c86da487e9d2 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 26 Jul 2026 18:38:53 -0400 Subject: [PATCH] refactor: share root scaffolding between the two monorepo builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMonorepo (libraries) and buildFullstack duplicated their entire root scaffolding — the community/agents/gitfiles reuse, eslint.config.js, .prettierrc.json, tsconfig.base.json, pnpm-workspace.yaml, ci.yml, and packkit.json were copy-pasted between them. That's the "implemented twice" the review flagged, and 2.9's fullstack addition made it a second copy. Extracted into one workspaceScaffold(cfg, { workspaceGlobs, tsconfigLib }) that both builders call; each still supplies its own root package.json, turbo tasks, and packages. −40 lines, and that scaffolding is now maintained once. Verified byte-identical across all 48 monorepo/fullstack configs (pm × license × scoped-name), via an order-insensitive file-content snapshot against pre-refactor main. Scoped deliberately: this does NOT merge the monorepo path into the single-package feature pipeline (the "every project is a set of workspaces" graph rewrite). The reviewer framed that as longer-term, and it would change monorepo output rather than refactor it. This removes the real duplication safely; the deeper merge stays a separate effort. 99 tests pass, lint clean, web configurator verified. Co-Authored-By: Claude Opus 4.8 --- ROADMAP.md | 2 +- docs/packkit-core.js | 107 +++++++++++++++----------------------- src/core/monorepo.js | 119 +++++++++++++++++-------------------------- 3 files changed, 89 insertions(+), 139 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 11a3aa1..2b6d984 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,7 +110,7 @@ _Second external review, this one of the codebase rather than the experience. Ra - [x] ~~**Make feature ordering explicit**~~ — **Shipped (3.1).** Rather than build speculative phase/requires/conflictsWith machinery, the real risk — order silently deciding a collision — is now an enforced invariant: `test/feature-registry.test.js` asserts every feature has a unique id and that **no two features collide** on a file or package.json field across the full config matrix (1683 configs). That immediately surfaced a real one — `bundler` and `service` both set `scripts.dev` on `node-service`, resolved only by array position — now fixed by making the service the explicit owner of its `dev` script. Ordering is no longer load-bearing for correctness; a colliding new feature fails the test instead of shipping. - [ ] **Pairwise combination testing** — the integration matrix covers presets, which are the *common* paths. The risk is cross-feature interaction (dual + Rollup + Jest + Biome). Generate a matrix where every option pair appears at least once. - [x] ~~**Dependency version catalog**~~ — **Shipped (3.1).** Every template version now lives in `src/core/versions.js` (`V` for specs, `PEER` for peer ranges, `HELD` for deliberately-held deps + reasons, `LAST_REVIEWED`). All 16 feature/monorepo modules reference it instead of hard-coding specs — verified **byte-identical across 3740 configs**, and a conformance test asserts every generated version comes from the catalog (it caught a wrong `ts-jest` spec and two missing deps while being built). The freshness workflow imports `HELD` instead of duplicating it. A bump is now one line: proven by fixing #19 (`release-it` ^20 → ^21) in the catalog alone. -- [ ] **Unify monorepo and single-package generation** — `generate()` returns early for monorepos, and 2.9 added a second early return inside that. Modelling a project as workspaces (a plain package being a project with one) would stop features having to be implemented twice. +- [x] ~~**Unify monorepo and single-package generation**~~ — **Shipped (3.1), scoped.** The two monorepo builders (`buildMonorepo` libraries + `buildFullstack`) duplicated their entire root scaffolding — the community/agents/gitfiles reuse, `eslint.config.js`, `.prettierrc.json`, `tsconfig.base.json`, `pnpm-workspace.yaml`, `ci.yml`, `packkit.json` — all copy-pasted. Extracted into one `workspaceScaffold(cfg, { workspaceGlobs, tsconfigLib })` both call, so that scaffolding is maintained once (−40 lines). **Verified byte-identical across all 48 monorepo/fullstack configs.** Deliberately *not* attempted: merging the monorepo path into the single-package feature pipeline (the "model every project as workspaces / a plain package is a project with one workspace" graph rewrite) — the reviewer themselves framed that as longer-term, and it would change monorepo output rather than refactor it. This removes the real duplication safely; the deeper structural merge remains a separate, larger effort if it's ever worth it. ### The strategic one diff --git a/docs/packkit-core.js b/docs/packkit-core.js index a35d035..06435f1 100644 --- a/docs/packkit-core.js +++ b/docs/packkit-core.js @@ -2488,9 +2488,7 @@ function buildMonorepo(cfg) { const core = `@${scope}/core`; const utils = `@${scope}/utils`; const wsProto = pm === "pnpm" ? "workspace:*" : "*"; - for (const feat of [community_default, agents_default, gitfiles_default]) { - if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); - } + Object.assign(files, workspaceScaffold(cfg, { workspaceGlobs: ["packages/*"] })); const rootPkg = { name: cfg.name, version: "0.0.0", @@ -2522,7 +2520,6 @@ function buildMonorepo(cfg) { } }; files["package.json"] = toJson(rootPkg); - if (pm === "pnpm") files["pnpm-workspace.yaml"] = 'packages:\n - "packages/*"\n'; files["turbo.json"] = toJson({ $schema: "https://turbo.build/schema.json", tasks: { @@ -2533,20 +2530,6 @@ function buildMonorepo(cfg) { dev: { cache: false, persistent: true } } }); - files["tsconfig.base.json"] = toJson({ - $schema: "https://json.schemastore.org/tsconfig", - compilerOptions: { - target: "ES2022", - module: "ESNext", - moduleResolution: "Bundler", - lib: ["ES2022"], - strict: true, - esModuleInterop: true, - skipLibCheck: true, - declaration: true, - noEmit: true - } - }); files[".changeset/config.json"] = toJson({ $schema: "https://unpkg.com/@changesets/config@3.0.0/schema.json", changelog: "@changesets/cli/changelog", @@ -2555,21 +2538,7 @@ function buildMonorepo(cfg) { baseBranch: "main" }); files[".changeset/README.md"] = "# Changesets\n\nRun `npx changeset` to record a version bump for your next release.\n"; - files["eslint.config.js"] = [ - `import js from '@eslint/js';`, - `import tseslint from 'typescript-eslint';`, - ``, - `export default tseslint.config(`, - ` js.configs.recommended,`, - ` ...tseslint.configs.recommended,`, - ` { ignores: ['**/dist'] },`, - `);`, - `` - ].join("\n"); - files[".prettierrc.json"] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: "all" }); files["README.md"] = rootReadme(cfg, pm, core, utils); - files["packkit.json"] = provenance(cfg); - files[".github/workflows/ci.yml"] = ciWorkflow2(cfg, pm); addPackage(files, { name: core, dir: "packages/core", @@ -2616,9 +2585,7 @@ function buildFullstack(cfg) { const scope = cfg.name.replace(/^@/, "").split("/")[0]; const shared = `@${scope}/shared`; const wsProto = pm === "pnpm" ? "workspace:*" : "*"; - for (const feat of [community_default, agents_default, gitfiles_default]) { - if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); - } + Object.assign(files, workspaceScaffold(cfg, { workspaceGlobs: ["apps/*", "packages/*"], tsconfigLib: ["ES2022", "DOM"] })); files["package.json"] = toJson({ name: cfg.name, version: "0.0.0", @@ -2646,9 +2613,6 @@ function buildFullstack(cfg) { "@types/node": `^${cfg.nodeVersion}.0.0` } }); - if (pm === "pnpm") { - files["pnpm-workspace.yaml"] = 'packages:\n - "apps/*"\n - "packages/*"\n'; - } files["turbo.json"] = toJson({ $schema: "https://turbo.build/schema.json", tasks: { @@ -2661,35 +2625,7 @@ function buildFullstack(cfg) { lint: {} } }); - files["tsconfig.base.json"] = toJson({ - $schema: "https://json.schemastore.org/tsconfig", - compilerOptions: { - target: "ES2022", - module: "ESNext", - moduleResolution: "Bundler", - lib: ["ES2022", "DOM"], - strict: true, - esModuleInterop: true, - skipLibCheck: true, - declaration: true, - noEmit: true - } - }); - files["eslint.config.js"] = [ - `import js from '@eslint/js';`, - `import tseslint from 'typescript-eslint';`, - ``, - `export default tseslint.config(`, - ` js.configs.recommended,`, - ` ...tseslint.configs.recommended,`, - ` { ignores: ['**/dist'] },`, - `);`, - `` - ].join("\n"); - files[".prettierrc.json"] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: "all" }); - files[".github/workflows/ci.yml"] = ciWorkflow2(cfg, pm); files["README.md"] = fullstackReadme(cfg, pm, shared); - files["packkit.json"] = provenance(cfg); files["packages/shared/package.json"] = toJson({ name: shared, version: "0.0.0", @@ -2943,6 +2879,45 @@ ${cfg.license}${cfg.author ? " \xA9 " + cfg.author : ""} ` : "" ].join("\n"); } +function workspaceScaffold(cfg, { workspaceGlobs, tsconfigLib = ["ES2022"] }) { + const files = {}; + for (const feat of [community_default, agents_default, gitfiles_default]) { + if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); + } + if (cfg.packageManager === "pnpm") { + files["pnpm-workspace.yaml"] = "packages:\n" + workspaceGlobs.map((g) => ` - "${g}" +`).join(""); + } + files["tsconfig.base.json"] = toJson({ + $schema: "https://json.schemastore.org/tsconfig", + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + lib: tsconfigLib, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + declaration: true, + noEmit: true + } + }); + files["eslint.config.js"] = [ + `import js from '@eslint/js';`, + `import tseslint from 'typescript-eslint';`, + ``, + `export default tseslint.config(`, + ` js.configs.recommended,`, + ` ...tseslint.configs.recommended,`, + ` { ignores: ['**/dist'] },`, + `);`, + `` + ].join("\n"); + files[".prettierrc.json"] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: "all" }); + files[".github/workflows/ci.yml"] = ciWorkflow2(cfg, cfg.packageManager); + files["packkit.json"] = provenance(cfg); + return files; +} function addPackage(files, { name, dir, src, test, deps: deps2 }) { const pkg = { name, diff --git a/src/core/monorepo.js b/src/core/monorepo.js index bd7f5f4..c4f53f2 100644 --- a/src/core/monorepo.js +++ b/src/core/monorepo.js @@ -23,10 +23,7 @@ export function buildMonorepo(cfg) { const utils = `@${scope}/utils`; const wsProto = pm === 'pnpm' ? 'workspace:*' : '*'; - // Reuse the package-agnostic root files (LICENSE, community, AGENTS.md, gitignore). - for (const feat of [community, agents, gitfiles]) { - if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); - } + Object.assign(files, workspaceScaffold(cfg, { workspaceGlobs: ['packages/*'] })); // ---- root ---- const rootPkg = { @@ -61,8 +58,6 @@ export function buildMonorepo(cfg) { }; files['package.json'] = toJson(rootPkg); - if (pm === 'pnpm') files['pnpm-workspace.yaml'] = 'packages:\n - "packages/*"\n'; - files['turbo.json'] = toJson({ $schema: 'https://turbo.build/schema.json', tasks: { @@ -74,21 +69,6 @@ export function buildMonorepo(cfg) { }, }); - files['tsconfig.base.json'] = toJson({ - $schema: 'https://json.schemastore.org/tsconfig', - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'Bundler', - lib: ['ES2022'], - strict: true, - esModuleInterop: true, - skipLibCheck: true, - declaration: true, - noEmit: true, - }, - }); - files['.changeset/config.json'] = toJson({ $schema: 'https://unpkg.com/@changesets/config@3.0.0/schema.json', changelog: '@changesets/cli/changelog', @@ -98,22 +78,7 @@ export function buildMonorepo(cfg) { }); files['.changeset/README.md'] = '# Changesets\n\nRun `npx changeset` to record a version bump for your next release.\n'; - files['eslint.config.js'] = [ - `import js from '@eslint/js';`, - `import tseslint from 'typescript-eslint';`, - ``, - `export default tseslint.config(`, - `\tjs.configs.recommended,`, - `\t...tseslint.configs.recommended,`, - `\t{ ignores: ['**/dist'] },`, - `);`, - ``, - ].join('\n'); - files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' }); - files['README.md'] = rootReadme(cfg, pm, core, utils); - files['packkit.json'] = provenance(cfg); - files['.github/workflows/ci.yml'] = ciWorkflow(cfg, pm); // ---- packages ---- addPackage(files, { @@ -174,9 +139,8 @@ function buildFullstack(cfg) { const shared = `@${scope}/shared`; const wsProto = pm === 'pnpm' ? 'workspace:*' : '*'; - for (const feat of [community, agents, gitfiles]) { - if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); - } + // apps/* + packages/*, and the DOM lib the web app's JSX needs. + Object.assign(files, workspaceScaffold(cfg, { workspaceGlobs: ['apps/*', 'packages/*'], tsconfigLib: ['ES2022', 'DOM'] })); files['package.json'] = toJson({ name: cfg.name, @@ -208,10 +172,6 @@ function buildFullstack(cfg) { }, }); - if (pm === 'pnpm') { - files['pnpm-workspace.yaml'] = 'packages:\n - "apps/*"\n - "packages/*"\n'; - } - files['turbo.json'] = toJson({ $schema: 'https://turbo.build/schema.json', tasks: { @@ -225,36 +185,7 @@ function buildFullstack(cfg) { }, }); - files['tsconfig.base.json'] = toJson({ - $schema: 'https://json.schemastore.org/tsconfig', - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'Bundler', - lib: ['ES2022', 'DOM'], - strict: true, - esModuleInterop: true, - skipLibCheck: true, - declaration: true, - noEmit: true, - }, - }); - - files['eslint.config.js'] = [ - `import js from '@eslint/js';`, - `import tseslint from 'typescript-eslint';`, - ``, - `export default tseslint.config(`, - `\tjs.configs.recommended,`, - `\t...tseslint.configs.recommended,`, - `\t{ ignores: ['**/dist'] },`, - `);`, - ``, - ].join('\n'); - files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' }); - files['.github/workflows/ci.yml'] = ciWorkflow(cfg, pm); files['README.md'] = fullstackReadme(cfg, pm, shared); - files['packkit.json'] = provenance(cfg); // ---- packages/shared: the contract both sides compile against ---- files['packages/shared/package.json'] = toJson({ @@ -514,6 +445,50 @@ function fullstackReadme(cfg, pm, shared) { ].join('\n'); } +// Root-level scaffolding shared by both monorepo layouts: workspace globs, the +// TypeScript base config, lint/format config, community files, CI, and +// provenance. Each layout adds its own root package.json, turbo tasks, and +// packages on top. Extracted so these aren't maintained twice. +function workspaceScaffold(cfg, { workspaceGlobs, tsconfigLib = ['ES2022'] }) { + const files = {}; + // Reuse the package-agnostic root files (LICENSE, community, AGENTS.md, gitignore). + for (const feat of [community, agents, gitfiles]) { + if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); + } + if (cfg.packageManager === 'pnpm') { + files['pnpm-workspace.yaml'] = 'packages:\n' + workspaceGlobs.map((g) => ` - "${g}"\n`).join(''); + } + files['tsconfig.base.json'] = toJson({ + $schema: 'https://json.schemastore.org/tsconfig', + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + lib: tsconfigLib, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + declaration: true, + noEmit: true, + }, + }); + files['eslint.config.js'] = [ + `import js from '@eslint/js';`, + `import tseslint from 'typescript-eslint';`, + ``, + `export default tseslint.config(`, + `\tjs.configs.recommended,`, + `\t...tseslint.configs.recommended,`, + `\t{ ignores: ['**/dist'] },`, + `);`, + ``, + ].join('\n'); + files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' }); + files['.github/workflows/ci.yml'] = ciWorkflow(cfg, cfg.packageManager); + files['packkit.json'] = provenance(cfg); + return files; +} + function addPackage(files, { name, dir, src, test, deps }) { const pkg = { name,