Skip to content

feat(software-factory): target:init generates a target from a pnpm package at a pin - #858

Open
blove wants to merge 23 commits into
mainfrom
blove/targets-init
Open

blove wants to merge 23 commits into
mainfrom
blove/targets-init

Conversation

@blove

@blove blove commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Framework-gaps item 5, PR 1 of 2. Plan: docs/superpowers/plans/2026-09-25-targets-generated-from-a-package.md, Tasks 1-11. It includes the plan itself and its decisions D1-D16, which Brian accepted.

What it adds

pnpm --filter @b4-example/software-factory-controller target:init <package> [--pin <sha>] [--id <id>] [--with-dev-builds] [--targets-dir <dir>] [--write]

It generates targets/<id>/target.json and its Dockerfile from a pnpm package at a pin. It reads only the git object store, never the working tree, and the output is deterministic. It prints a unified diff with notes, and writes only with --write. A target is an oracle input, so a person reviews that diff before committing it.

  • Pin tree and workspace graph. The tree is listed once per pin. The build closure follows runtime and peer edges, and the install closure matches pnpm's --filter <pkg>.... Config packages are captured whole.
  • Build. One tsc -b over the build closure in dependency order. It adds --builders 1 when the root's TypeScript is 7 or later.
  • Tsconfig. The whole extends chain is read. Any build output outside the recorded outDir is refused, including TypeScript's default build-info location, declarationDir and outFile.
  • Test command. The generated vitest command is refused by name when it contains anything it does not understand.
  • Dockerfile. One template, the cli Dockerfile generalised. It has fail-fast loops and a reviewed promotion tripwire.
  • Visible omissions. Every omission appears as a note: uncaptured package directories and top-level files, and sibling packages a vitest config reaches. Setup files outside the capture are refused.
  • Regeneration keeps decisions. Resources, excludes, test scope, drafting notes and the promotion set are carried. Hand-added imageAssertResolves, capture.include and runnerConfig entries are carried as supersets. An unchanged target regenerates to an empty diff.
  • Fail safe until PR 2. A target with placeholder resources is not offered to the drafter, and dispatch on it is refused. The README says target:measure is a follow-up and that a generated target should not be committed until measured.

Proof

  • Unit reproduction. init reproduces the shipped devkit and cli targets at their pins. The test compares whole objects with each named difference applied. A mutation to the build order or the resources makes it fail.
  • Docker lane. The generated devkit Dockerfile builds in sandbox-docker with the empty promotion set.
  • cli by hand, before merge. This case is opt-in. Its first build learned exactly @hono/node-server commander hono typescript, the hand-written target's set, and its second build succeeded. The timing was on a warm cache, so treat the first CI run as the cold-build figure.
  • Dry run across all packages at main. 17 of 21 packages generate a target. Four are refused with one actionable line each: config-biome and config-typescript have no test script, inspector builds with next build, and create-b4-app's globalSetup reads outside the capture, so every test would fail. Every generated target loads through the catalog and passes target:prepare's CAPTURED check.

Review

Each task had a spec review and a quality review, and the branch then had a final review. Every fix is recorded in the plan's per-task "As landed" notes.

The follow-ups are listed in the plan. One matters now: the shipped targets/cli/Dockerfile has the ignored-failure loop bug the template fixes, and it stays unchanged per D14.

Verification (local, Node 24)

  • Unit tests: controller 964 passed, server 21 passed, drafter 17 passed.
  • test:sandbox: 12 files passed and 1 skipped (the opt-in cli case); 31 tests passed and 5 skipped, in 219 s on a warm cache.
  • pnpm lint, node scripts/check-docs.mjs and the factory typechecks pass.

CI's sandbox-docker job took 11 minutes 23 seconds of its 30-minute budget on this PR, building from a cold cache.

Examples only, so there is no changeset.

🤖 Generated with Claude Code

blove and others added 23 commits September 25, 2026 16:08
…amework gaps item 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nerated plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es and build order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… where it writes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…only regular files

gitPinTree resolves the pin once to its full sha (git's own error on failure) and lists it with
one `git ls-tree -r -t --full-tree`, answering kind, children and files from memory: a git
failure is reported at construction instead of reading as absence, paths are top-level relative
whatever directory the tree was opened from, and ~one spawn replaces one per query. kind names a
symlink a link and a submodule a submodule, as children always did; read throws for anything
but a regular blob; files("") is the root and a directory never matches a sibling it prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecs init cannot read

peerDependencies join both closures: pnpm's workspace graph follows them, and a package imports
its workspace peers, so the build closure builds them. expandGlob refuses extglob characters,
never lets * match a dot-directory, node_modules or bower_components, and refuses by name a
symlink or submodule where a package directory could be. A workspace alias
(workspace:@m/util@*), a workspace: path, and a link:/file: spec naming a workspace directory
are refused by name instead of misreported or silently dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h a learnable promotion set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d outDir

buildConfig now reads path options through the whole extends chain, config
packages included (every external file is recorded in externalExtends),
resolving each against the file that sets it. It computes tsc -b's default
build-info path by TypeScript's getTsBuildInfoEmitOutputFilePath rule
(confirmed against TS 7.0.2) and refuses it, a declarationDir or an outFile
outside the outDir, an outDir overlapping src/ or test/, ${...} substitution,
non-string path options and extends cycles; `extends: "./base"` falls back to
base.json as TypeScript does; the build config's references are returned.

buildScriptTsconfig reads a compile only as exactly `tsc -b <one config>`
followed by `&&` or the end, refuses cd anywhere in the chain, a tsc that is
not -b, a non-package-relative config, and unbalanced quotes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseVitestCommand accepts only run (first), --run, --no-cache,
--passWithNoTests, --config and --exclude with values, and positional files;
every other flag (-t, -c, --shard, ...) is refused by name, replacing the
UNREAD list. --config values must be package-relative literals in both the
script and the command, and withExcludes refuses any exclude that is not a
literal, package-relative file path (vitest reads excludes as globs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…move

The promotion and relink loops sit inside the RUN's top-level && list,
where the shell ignores set -e, so a failed rm/mkdir/mv inside a loop was
ignored and the build passed with the package gone. Each chain in the
loops now ends in `|| exit 1`; the shell tests run under dash (the
image's /bin/sh) where the host has it.

Also: the nested-typescript check ignores a symlink; promoting a name the
image relinks fails the build by name; renderDockerfile and
withExpectedPromoted refuse a repeated promotion name; CAPTURED and the
links are sorted by directory; a name starts with a letter or digit; and
promotionMismatch reads the promoted marker only from the BuildKit step
that printed the expected one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ifests at a pin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g what was decided

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oes and names what the capture omits

- Walk up from the package to the repository root for the vitest config, per
  directory then names (vitest's order); a root config is captured and kept
  immutable with a note, one between the package and the root is refused.
- Note the target package's uncaptured top-level files, and every relative
  path the vitest config names that the capture does not hold (a workspace
  package once; `"../tooling"` and `"../../x"` read correctly).
- Refuse a capture or runnerConfig entry that overlaps a snapshotIgnore prefix.
- Read TypeScript from the package, then the root's devDependencies and
  dependencies; refuse a version whose major cannot be read.
- Note a carried scope file the capture does not hold; suggest
  --with-dev-builds only for packages the flag would capture; drop a carried
  capture link or submodule with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gets, differences named

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tes proposals safely

A target.json that is not JSON or fails the schema is now refused, naming
the parse error, instead of skipping the ownership check and being replaced
with defaults (which --write would have used to destroy decided resources,
excludes and drafting notes).

- writeProposal re-reads each file and refuses one changed since the
  proposal, refuses a symlinked target directory, and writes through a
  temporary file renamed into place
- notes say "read from <dir>" (counts precede deriveTarget's drops), name
  the real catalog directory, omit empty draftingNotes/EXPECTED_PROMOTED,
  and say when only a Dockerfile's EXPECTED_PROMOTED is carried
- a carried test scope replaces the "no test is excluded" note
- initTarget validates the pin itself; a missing controller Biome is named
- a relative --targets-dir resolves against INIT_CWD, else the cwd
- the script prints a refusal as one `target:init:` line and exits 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… build through the registry

The devkit lane runs in test:sandbox; the cli case is opt-in under
FACTORY_TEST_CLI_TARGET=1 (test:sandbox:cli) and learns the hand-written
promotion set from its first build before building. The devkit lane reads the
promotion step's printed set from its own BuildKit step, and accepts a cached
step, which prints nothing (a second build of the same recipe on one host).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed note and the plan's

The plan records, under Tasks 1-10, what review changed as each landed, and
adds the shipped cli Dockerfile's ignored-failure loops (not changed, D14) to
its follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed targets, setup files

- --builders reads only the root's TypeScript, the tsc the image builds with
  (the Dockerfile shims nested copies onto it): packages/core, which nests
  npm:@typescript/typescript6@6.0.2, was refused and now generates.
- A target whose resources are target:init's placeholders is never offered
  to the drafter, and a draft or task naming it is refused
  (unmeasuredProblem: "resources are placeholders: run target:measure").
  recipeProblem is unchanged, so such a target still builds for measuring.
- A vitest setupFiles/globalSetup entry the capture omits is refused by name:
  every test would fail.
- Refusals no longer repeat target:init under the script's prefix, and start
  lowercase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion and scope require

The README and target-init.ts header no longer describe target:measure as
available: a generated target must not be committed until measured. The README
says a re-generation does not carry --with-dev-builds and that the scope needs a
root packageManager pnpm@x.y.z and a build of exactly one tsc -b <file>. The
plan records Task 11's final-review fixes and the proposalProblem/recipeProblem
shared-core follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
b4-run Ignored Ignored Preview Sep 26, 2026 2:32am UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

This branch was successfully deployed

1 active deployment
vercel-preview — ef3aa1ed Deployed Sep 26, 2026 by blove via vercel-native #2432
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant