Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 41 additions & 66 deletions docs/packkit-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
119 changes: 47 additions & 72 deletions src/core/monorepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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: {
Expand All @@ -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',
Expand All @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down