Commit 11905a4
fix(cli)!: os generate binds declared metadata (--object, --flow), never the item name, and every scaffold passes validate, build and lint (#21369)
Fixes #21325
Clause-②: yes (narrowing)
The `Clause-②` arm was measured on the real diff, and it is
`(narrowing)`, not the `(widening)` the claim expected. `--object` and
`--flow` widen the command. The diff also narrows it: an invocation that
used to write a scaffold bound by name is now refused. `os g flow
customer` in a project that declares several objects is one example, the
`cli.mdx` example `os g action customer` another. The changeset carries
the BREAKING banner and its ADR-0087 disposition (`not-required
(no-migration-prescription)`). `check-adr-0087-registration` and
`check-changeset-no-major` both pass on it.
## What changed
Triage's direction (`5945870485`): `flow`, `action` and `app` take their
target object from an argument or from the stack's existing objects,
never from the new item's name. With nothing named and nothing to
choose, the command refuses. `view` writes the shape `os lint` requires.
Every generator kind, on a fresh scaffold, passes `os validate`, `os
build` and `os lint` with zero findings.
`packages/cli/src/commands/generate.ts`
- **Bindings are resolved, not derived.** Each generator declares
`binds` (`ScaffoldBinds`). `view` is named after its object. `flow`,
`action` and `app` take the object from `--object`, or the stack's only
object. `action` takes its flow from `--flow`, or the stack's only flow.
`resolveScaffoldBindings` resolves every reference against the loaded
stack before anything is rendered. It runs below the namespace gates and
above the render, the parse check and `--dry-run`. It is a pure
function: it returns a verdict, and the command prints the verdict and
exits. `generate` receives the resolved `ScaffoldBindings`, and a
binding scaffold rendered without them throws. Nothing falls back to the
item's name.
- **Refusals, each listing what the stack declares and naming the next
command, with nothing written:** no object or several and no `--object`;
an `--object` or `--flow` that names nothing declared; no flow or
several and no `--flow`; a view whose name is not a declared object; a
binding scaffold outside a project (no config, so no stack to check
against); `--object` or `--flow` on a type that takes neither
(`unusedBindingFlagRefusal`), including the `types`, `client` and
`migration` routes, which now sit in one `SUB_COMMANDS` table.
- **Scaffold content.** Each change below removes a finding that was
measured.
- `object`: no `description` field (`field-no-consumers` on every
generated object once the stack had any consumer root).
- `view`: no container `name` or `label` (two `liveness-dead-property`
warnings). It writes `list.label` (`os lint` `required/label` error),
and its columns are every field the bound object declares (a fixed
`name` column was a `list-view-field-unknown` error on any object
without one). It is sorted by the title field.
- `flow`: `status: 'active'` (`flow-draft-status-ambiguous`; draft
already fires, so the runtime behaviour is unchanged).
- `action`: `locations: ['record_header']` (`action-no-placement`).
- `app`: the nav entry is labelled with the bound object's plural label.
- `--object` and `--flow` flags. Their help text names the types that
read them, read off the roster.
`packages/cli/src/utils/project-namespace.ts`: a `loaded` result also
carries the evaluated `config`, so the bindings are read off the same
load that supplies the namespace.
`packages/cli/src/utils/scaffold-wiring.ts`: `registeredItemName`
returns the key an item is registered under. For an aggregated views
container that key is `deriveViewContainerObject`, the boot registrar's
own derivation. `stackCarries` (the reach report) reads it, so a
container with no `name` is still found.
## Argument shape, and why
- **`--object OBJECT` and `--flow FLOW`** are long flags with a value,
the way every other option on `os generate` is spelled (`--dir`,
`--output`, `--format`). The object is accepted as declared
(`tasks_app_task`), or without the namespace prefix (`task`). In the
second case it is looked up through the same prefix rule `os g object`
applies, never re-spelled.
- **No flag means the stack's only object (or flow), said out loud** in
the header: `Object: tasks_app_note (the only object this stack
declares; --object binds another)`. This is the non-interactive reading
of the direction's "or from the stack's existing objects". With several
declared, the command lists them and asks for the flag instead of
choosing. No `os generate` code path prompts, and this change adds no
prompt (`os migrate` prompts only for a yes/no confirmation).
- **A view is named after its object** (`os g view task`), unchanged. A
views container is registered under the object it binds, so the name and
the object are one fact. `--object` on `view` is refused with that
explanation.
- **`--flow` goes beyond the single object argument the claim
anticipated.** An action's flow was derived from its name in exactly the
same way (`complete_task_flow`). `defineStack` refused it whenever flows
existed. With no flows declared, the action loaded with a dangling flow
reference: measured on `97239c3c8a`, `os g action approve` then exits 0
and `os validate` exits 0, `target: 'approve_flow'`. The same rule
(argument, or the stack's only one) is applied to the second reference.
## The view container's `name` and `label`: decided from the code that
reads them
The scaffold's comment said the server refuses a container whose `name`
disagrees. `os validate` said both keys are dead. Read from their
readers, **both statements are true and they do not conflict:**
- `name`: the boot registrar files a container under
`deriveViewContainerObject` (`packages/metadata/src/view-container.ts`):
its own `object`, else `list.data.object` / `form.data.object`, and only
then `name`. With `object` set, `name` is never the key. Its one reader
is `viewContainerNameRefusal`
(`packages/objectql/src/view-container-name-refusal.ts`). That function
refuses a `name` that is set AND differs from the key, and passes a
container that has no `name`. So `name` can only restate the key or
contradict it, which is the liveness ledger's `dead`
(`packages/spec/liveness/view.json`). The refusal's own remedy is "drop
`name`".
- `label`: `expandViewContainerWithDiagnostics`
(`packages/spec/src/ui/view.zod.ts`) gives every expanded ViewItem the
label of its list or form entry, never the container's. The ledger
records no Studio reader either.
So both keys are dropped, and the label a person sees is `list.label`,
which `os lint` requires.
## Measured: the card's scenario
`npm create objectstack` (the in-repo on-ramp `bin/`, `--skip-install`,
namespace `tasks_app`), then `os g object project` and `os g object
task`, run with the in-repo CLI build.
| step | before (`97239c3c8a`) | after (`de6107c8e1`) |
| --- | --- | --- |
| `os g flow task_done` | exit 0, bound `tasks_app_task_done` | exit 1,
lists the three objects, asks for `--object` |
| `os g flow task_done --object task` | (no such flag) | exit 0, bound
`tasks_app_task` |
| `os g view task` | exit 0 | exit 0 |
| `os g action complete_task` | exit 1 (`defineStack`: object
`tasks_app_complete_task`, flow `complete_task_flow`) | exit 1, asks for
`--object` |
| `os g action complete_task --object task` | (no such flag) | exit 0,
runs `task_done_flow` (the only flow) |
| `os g app tasks` | exit 1 (`defineStack`: object `tasks_app_tasks`) |
exit 1, asks for `--object` |
| `os g app tasks --object task` | (no such flag) | exit 0 |
| `os validate` | exit 0, 7 warnings | exit 0, 1 warning |
| `os build` | exit 0, 7 warnings | exit 0, 1 warning |
| `os lint` | **exit 1**: `required/label` at views[0].list.label, plus
7 warnings | exit 0, 1 warning |
The remaining warning in the "after" column is `field-no-consumers` at
`objects[0].fields.body`. It sits on the STARTER's own `note` object and
is not written by `os g`; see Acceptance notes.
## Measured: every generator kind, three gates
A fresh starter plus one `os g` invocation per leg. "Before" binds by
name (`os g KIND probe_thing`, and the view on the starter's `note`).
"After" runs the pin's own chain: the prerequisites `binds` declares,
then the kind. Findings are counted from the `--json` output of each
gate, at every severity.
| kind | before: validate / build / lint | after: findings beyond the
starter ledger |
| --- | --- | --- |
| object | exit 0 / 0 / 0, 0 findings | 0 |
| view | exit **1** / **1** / **1**: `list-view-field-unknown`,
`sort-field-unknown` and (lint) `required/label` errors;
`liveness-dead-property` on `name` and `label` | 0 (`os g view note`
also leaves zero, the ledger finding included) |
| action | `os g` exit **1** (object derived from its name) | 0 |
| flow | exit 0, 3 warnings at each gate (`flow-trigger-unknown-object`,
`flow-draft-status-ambiguous`, starter `note.body`) | 0 |
| dashboard | exit 0, 1 warning at each gate (starter `note.body`) | 0 |
| app | `os g` exit **1** (object derived from its name) | 0 |
| skill | exit 0, 1 warning at each gate (starter `note.body`) | 0 |
| picklist | exit 0, 0 findings | 0 |
## The pin, family close-out
- **Per-PR, in-process:
`packages/cli/test/generate-scaffold-validates.test.ts` (extended).**
For every kind in `GENERATOR_SCAFFOLD_TARGETS`, each prerequisite named
by `binds` (an object, a flow) is generated first. The bindings are
resolved through `stackBindingCandidates`, the reader the command uses.
The target is deliberately not named like the item. The test then runs
what the three commands run: `defineStack`; `normalizeStackInput` with
the unknown-key lints, the parse and `runAuthoringRules('validate')`;
`runAuthoringRules('build')`; and `lintConfig`. It asserts zero findings
at every severity. Before this change it judged only the error half of
`os validate`, beside an object named like the item, which is why none
of the card's cases showed up there. A kind added later is measured with
no edit to the file. Also added: binding scaffolds write exactly the
binding they are handed, and throw without one.
- **Nightly, real commands:
`packages/cli/test/generate-scaffold-gates.e2e.test.ts` (new).** One
fresh starter per kind (the on-ramp `bin/`), the prerequisite chain
derived from `binds`, then `os validate --json`, `os build --json` and
`os lint --json`. Each gate must exit 0 with no finding beyond a
one-entry starter ledger. A CONTROL leg (the bare starter) must report
zero findings at all three gates. The ledger is shrink-only. Its entry
must sit on an object the bare starter declares, read from the compiled
artifact, and must still fire in at least one leg. It is in e2e because
the run is about forty oclif + tsx cold starts, roughly 4 minutes on
this box. That is the same reason
`generate-scaffolds-reach-stack.e2e.test.ts` is nightly.
- **Per-PR: `packages/cli/test/generate-binds-from-stack.test.ts`
(new).** It covers every branch of `resolveScaffoldBindings` and
`unusedBindingFlagRefusal`, asserting on the named subjects, not on the
prose.
Existing tests the behaviour change required updating. Each needed a
binding handed over or a flag added; none had an assertion weakened:
- `generate-object-namespace-prefix.test.ts`: the set is composed object
first. A binding scaffold must write the object it is handed, whatever
the namespace.
- `generate-scaffold-wiring.test.ts`: the registered key, and the
container writes no `name`/`label`.
- `generate-emission-parses.test.ts`,
`scaffold-emission-typechecks.test.ts` and
`generate-refuses-name-outside-charset.test.ts`: the probe bindings from
the new `test/helpers/scaffold-bindings.ts`.
- `generate-refuses-unimportable-alias.test.ts`: `dashboard` and `skill`
carry the alias-layer cases that `view` and `flow` carried. In
config-less directories a binding scaffold is now refused first.
`dashboard` suffixes its binding the same way, so the layer it pins is
unchanged.
- `generate-stack-reach.test.ts`: an action whose `--object` names
nothing; a view outside a project; the action control generating once an
object and a flow exist; the no-config "Not wired" case carried by
`dashboard`.
- `create-objectstack-stack-reach.test.ts` and
`generate-scaffolds-reach-stack.e2e.test.ts`: `--object`. The order is
derived from `binds`.
## Ablations
The fix was committed first. Each leg went through
`scripts/ablation-replace.mjs` (wrap mode: the anchor must hit, the
counts are checked on disk, and the file is restored to blob == HEAD
with an empty `git diff HEAD`), inside a script with an EXIT/INT/TERM
trap. Every leg below ended with the file restored (blob `2c8b969c1f07`
for generate.ts, `0d902b75dff7` for scaffold-wiring.ts).
| leg | mutation | red |
| --- | --- | --- |
| A1 | flow start node binds the object derived from its own name | 4
(flow and action legs, the bound-object pin, the start-node pin) |
| A2 | flow `status: 'draft'` | 2 (flow and action legs) |
| A3 | view container writes `name` and `label` again | 1 (view leg) |
| A4 | view `list.label` removed | 1 (view leg) |
| A5 | action `locations` removed | 1 (action leg) |
| A6 | action object derived from its name | 2 |
| A7 | app nav object derived from its name | 2 |
| A8 | object scaffold declares `description` again | 3 (flow, action,
app legs) |
| A9 | resolver picks the first object when several are declared | 1 |
| A10 | resolver drops the prefixed `--object` lookup | 1 |
| A11 | `--object`/`--flow` on a non-taking type ignored | 8 |
| A12 | reach reader identifies a container by `name` only | 2 |
| E1 | e2e: flow `status: 'draft'`, through dist | 6 (flow and action
legs × three gates) |
Two first attempts were void and were redone with new anchors: the tool
refused them because the counts did not move. In A3 the replacement
re-contained the anchor. In A10 the replacement `;` was already
everywhere. **E1's first run was also void, and in a way worth
knowing:** the mutation landed in `src/` and the e2e stayed green, 38 of
38. The spawned `bin/run-dev.js` serves `packages/cli/dist` when dist is
built. Measured: with `src` mutated to `status: 'draft'`, `tsx
bin/run-dev.js g flow … --dry-run` printed `status: 'active'`. E1 was
then rerun as a dist ablation: mutate, rebuild the CLI, check with
`ablation-dist-preflight` (marker present in
`dist/commands/generate.js`), run the e2e (6 red), restore, rebuild,
check with `--absent` (clean). See Acceptance notes.
## Verification (on `de6107c8e1`, which merges `origin/main` at
`f9bcd08bef`)
- The CLI dependency closure was rebuilt after the merge: `turbo run
build --filter=@objectstack/cli...`, 59 of 59 tasks.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: 245
files, 3485 tests passed.
- `--project integration` on `test/generate-` and
`test/create-objectstack`: 7 files, 67 tests passed, run against a dist
built from this head.
- Nightly e2e (`OS_TEST_TIERS=nightly`): `generate-scaffold-gates`,
`generate-scaffolds-reach-stack`, `generate-object-namespace-prefix` and
`generate-skill`. 4 files, 69 tests passed.
- `pnpm --filter @objectstack/cli typecheck`: exit 0. That includes
`check:test-typecheck`; the debt ledger did not change.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 95 commands, every one exit 0. Three first exited 3
(PREREQUISITE NOT MET: `check:skill-examples`,
`check:dual-build-cjs-loads`, `check:i18n-coverage`) and were rerun
after building their prerequisites. `--ran` reconciliation: 95 derived,
95 run, 0 NOT MEASURED, a derived zero.
- `pnpm lint` (repo-wide eslint, not narrowed): exit 0.
## Docs and changeset
- `content/docs/deployment/cli.mdx`, the `#### os generate` entry under
`### Scaffolding`: where bindings come from, the refusals, the new
options, the examples, and the view no longer writing `name`.
- `.changeset/21325-generate-binds-from-stack.md`: `@objectstack/cli`
`minor`, BREAKING banner, `Clause-②: yes (narrowing)`, ADR-0087
`not-required (no-migration-prescription)`.
## Acceptance notes
- **Docs edits outside the claimed region.** This change made two
examples in `cli.mdx` false, so they were edited too. The Quick Start
"Add more metadata" block, `os generate flow customer` / `os generate
action customer`, is refused on the starter, which declares two objects
once `customer` exists. The "Typical Workflow" step `os g flow
opportunity` comes after four objects. Each now passes `--object`.
Neither block is in `### Quality`, the region of sibling card #21323.
- **`packages/cli/README.md` is fixed in this PR.** The claim fenced it
to #21310, which has since closed (landed), so the seat lifted the fence
under the same claim, 5946394138. Its Quick Start line `os generate flow
task` would be refused after this change, because the `app` template's
object plus `task` makes two objects. It now reads `os generate flow
task_changed --object task`, measured on a real `os init my-app` project
(exit 0, then `os validate` / `os build` / `os lint` exit 0). The
Typical Workflow line `os generate view customer` still holds and is
unchanged.
- **Starter latent finding (the one e2e ledger entry).** The `npm create
objectstack` starter's `note` object declares `body`, which nothing
reads. `field-no-consumers` stays silent while a stack holds no consumer
root. The first view, flow, action, app, dashboard or skill, generated
or hand-written, wakes it on the starter's object. `os init -t app`'s
`item` object has the same shape (`description`, `status`). `os g`
cannot give someone else's field a consumer, so the literal "zero
findings on a fresh starter" holds only for kinds that add no consumer
root. Each kind's own output is measured at zero, and the pin records
the remainder in a shrink-only ledger that turns red when the starter is
fixed. The template fix is outside this card's surface. It is reported
to the seat for a separate card.
- **`bin/run-dev.js` serves `dist/` when it exists.** It is documented
in several CLI test headers as the source entry, and the suite says it
does not depend on dist. Measured above (E1), it serves the built tree.
So a spawned CLI pin reads build state. A test written against an
unbuilt change reads stale behaviour, and an ablation of `src/` alone
stays green. This PR's spawned tests were rerun against a dist built
from this head. No carrier is named for the test infrastructure.
- Two new internal-tool edits are within the claim's "generate.ts and
the CLI internals it directly references": `project-namespace.ts` and
`scaffold-wiring.ts`. Also new: one test helper,
`test/helpers/scaffold-bindings.ts`, and two test files.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d78bd01 commit 11905a4
19 files changed
Lines changed: 1785 additions & 359 deletions
File tree
- .changeset
- content/docs/deployment
- packages/cli
- src
- commands
- utils
- test
- helpers
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
36 | | - | |
37 | | - | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
43 | | - | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
44 | 45 | | |
45 | 46 | | |
46 | 47 | | |
| |||
1440 | 1441 | | |
1441 | 1442 | | |
1442 | 1443 | | |
1443 | | - | |
1444 | | - | |
1445 | | - | |
1446 | | - | |
| 1444 | + | |
| 1445 | + | |
| 1446 | + | |
| 1447 | + | |
| 1448 | + | |
| 1449 | + | |
| 1450 | + | |
| 1451 | + | |
| 1452 | + | |
| 1453 | + | |
| 1454 | + | |
| 1455 | + | |
| 1456 | + | |
| 1457 | + | |
| 1458 | + | |
| 1459 | + | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + | |
| 1463 | + | |
| 1464 | + | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
1447 | 1468 | | |
1448 | 1469 | | |
1449 | 1470 | | |
| |||
1464 | 1485 | | |
1465 | 1486 | | |
1466 | 1487 | | |
1467 | | - | |
1468 | | - | |
1469 | | - | |
1470 | | - | |
1471 | | - | |
1472 | | - | |
1473 | | - | |
| 1488 | + | |
| 1489 | + | |
| 1490 | + | |
| 1491 | + | |
| 1492 | + | |
| 1493 | + | |
1474 | 1494 | | |
1475 | 1495 | | |
1476 | 1496 | | |
1477 | 1497 | | |
1478 | 1498 | | |
1479 | | - | |
1480 | | - | |
1481 | | - | |
1482 | | - | |
1483 | | - | |
1484 | | - | |
1485 | | - | |
1486 | | - | |
1487 | | - | |
1488 | | - | |
1489 | | - | |
| 1499 | + | |
| 1500 | + | |
| 1501 | + | |
| 1502 | + | |
| 1503 | + | |
| 1504 | + | |
| 1505 | + | |
| 1506 | + | |
| 1507 | + | |
| 1508 | + | |
| 1509 | + | |
| 1510 | + | |
1490 | 1511 | | |
1491 | 1512 | | |
1492 | 1513 | | |
| |||
1554 | 1575 | | |
1555 | 1576 | | |
1556 | 1577 | | |
| 1578 | + | |
| 1579 | + | |
1557 | 1580 | | |
1558 | 1581 | | |
1559 | | - | |
| 1582 | + | |
1560 | 1583 | | |
1561 | 1584 | | |
1562 | 1585 | | |
| |||
2352 | 2375 | | |
2353 | 2376 | | |
2354 | 2377 | | |
2355 | | - | |
| 2378 | + | |
2356 | 2379 | | |
2357 | 2380 | | |
2358 | 2381 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
0 commit comments