Repository navigation
Commit 147a0d1
Fixes #22902
Clause-②: no (narrowing)
> **Held out of the merge queue** by #22949 (the queue-wide
suite-duration ceiling red, live since `1bd612bd6`; not this PR's).
Accepted, reviewed at tier and green; the seat re-arms it when #22949
resolves.
## What this does
`defineStack`'s cross-reference aggregate gains
`collectDuplicateFunctionNameErrors`, a sibling of
`collectDuplicateActionKeyErrors` in `packages/spec/src/stack.zod.ts`.
When two or more entries of one stack's `functions` **array** author the
same `name`, the stack is refused. There is one finding per colliding
name, and it names the name and every index:
```text
✗ Function 'kc_a' is declared twice in the `functions` array: stack.functions[0] and stack.functions[1]. Functions are registered and resolved by name, so only one of these entries can own 'kc_a' and the others would be silently renamed or dropped. Rename one of them, or merge them into a single entry.
```
The refusal uses the existing envelope: `StackCrossReferenceError`,
`code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, one `issues`
entry per finding. It runs before the object-count early return, so an
object-less stack is refused too. Every entry counts, byte-identical
twins included.
Unchanged, per the triage fences:
- The map form. It cannot carry a duplicate, and the collector never
visits it.
- The build. `objectstack build` and `lowerCallables` are not touched,
so a suffix stays an internal ref.
- The runtime. The cross-stack case stays `composeStacks`' own refusal
(`STACK_COMPOSE_FUNCTION_CONFLICT`).
Landing point: `packages/spec/src/stack.zod.ts`, as the order expected.
## Where the refusal sits, measured (PM assumption 3)
`validateCrossReferences` runs inside `defineStack` in strict mode,
which is the default. `objectstack validate`, `objectstack build` and
`objectstack serve` all load the config module through `loadConfig`,
which runs the author's `defineStack(...)` call. A refusal raised there
therefore reaches all three doors, before each door's own parse. So the
sibling placement refuses at the contract and at every public door. No
second placement is needed.
`os validate`'s own parse reads the stack after `lowerCallables`, and by
then the names are already suffixed (`kc_a`, `kc_a__2`). A schema-level
refinement would never see the duplicate there. That is one more reason
to place the check in the aggregate and not in the `functions` schema.
## Before and after, through the real CLI
Fixture: `defineStack({ manifest, objects, functions: [{ name: 'kc_a',
handler: first }, { name: 'kc_a', handler: second }] })`. Before: `main`
at `7dbb89dc5`, after #22901 merged. After: this branch.
| door | before (`7dbb89dc5`) | after (this branch) |
|:--|:--|:--|
| `os validate --json` | exit 0, `valid: true` | exit 1, `valid: false`,
`code: STACK_CROSS_REFERENCE_INVALID`, the line above |
| `os build` then `loadArtifactBundle` | exit 0. Artifact
`[{"name":"kc_a","handler":"kc_a"},{"name":"kc_a__2","handler":"kc_a__2"}]`.
Loaded: `kc_a` answers `first`, `kc_a__2` answers `second` | exit 2, the
line above, no artifact |
| source boot (`os serve --no-server`, no build) | boots with no notice.
A probe in `onEnable` reads `resolveFunction('kc_a')`, which answers
`second`. `first` is dropped, and `kc_a__2` is unregistered | exit 1 at
`Loading objectstack.config.ts...`, the line above, kernel never boots |
The card's two "Not measured" rows are now measured. The source boot ran
the opposite entry from the build: `kc_a` ran `first` when built and
`second` when booted from source.
Three entries under one name plus two under another, `[kc_a, kc_b, kc_a,
kc_b, kc_a]`: before, the artifact shipped `kc_a`, `kc_b`, `kc_a__2`,
`kc_b__2` and `kc_a__3` at exit 0. After, the stack is refused with 2
issues: `stack.functions[0], stack.functions[2] and stack.functions[4]`
for `kc_a`, and `stack.functions[1] and stack.functions[3]` for `kc_b`.
**CONTROL.** Two distinct names (`kc_a`, `kc_b`), and the map form `{
kc_a: first, kc_b: { handler: second, effect: 'writes' } }`. Both give
`os validate` exit 0 `valid: true` and `os build` exit 0, and both load
callable. The control rows of the before and after runs are
byte-identical.
**The four example apps.** `os validate --json` on `examples/app-crm`,
`app-todo`, `app-showcase` and `app-multi-package` exits 0 with `valid:
true`. Each output is byte-identical to the run with the collector call
removed (the ablated build below), apart from the `duration` value.
Sizes: 11513, 8478, 75343 and 3665 bytes.
## Census: array-of-named-entries members of the stack schema
Method: every `STACK_DEFINITION_KEYS` member whose entries carry a
string `name`. Corpus keys: take the `defineStack` output of
`examples/app-showcase` (with bound actions de-echoed), duplicate the
key's first entry, and re-run `defineStack`. The corpus rows were also
run through `os validate --json` on a fixture that does exactly that.
Keys the examples do not author: hand-written minimal entries, through
`defineStack` only.
**Refused at the contract (same stack):**
| key | how |
|:--|:--|
| `functions` (array form) | this PR |
| `actions` | `collectDuplicateActionKeyErrors`, by scope-qualified key
|
| `apis` | by METHOD + path (`applyApiEndpointGates`). Two endpoints
sharing a `name` on different paths are **accepted** |
| `apps` | only through the single-app rule on a `manifest.type: 'app'`
package (a count, not a name). On a `module` package, two apps sharing a
name are **accepted** |
**No same-stack uniqueness refusal on `name`.** `defineStack` accepts
two entries with one name. Where marked, `os validate --json` was also
measured: exit 0, `valid: true`, and no new finding names the duplicate
(the one new finding per fixture is the fixture-path SDUI-manifest info
line).
| key | duplicated name | `os validate` |
|:--|:--|:--|
| `objects` | `showcase_account` | exit 0 (its only new findings are
`field-no-consumers` on the copy's fields) |
| `datasources` | `showcase_external` | exit 0 |
| `pages` | `showcase_capability_map` | exit 0 |
| `dashboards` | `showcase_chart_gallery` | exit 0 |
| `reports` | `showcase_hours_by_status` | exit 0 |
| `datasets` | `showcase_task_metrics` | exit 0 |
| `flows` | `showcase_task_completed` | exit 0 |
| `jobs` | `showcase_health_sweep` | exit 0 |
| `emailTemplates` | `showcase_task_done_email` | exit 0 |
| `books` | `showcase_manual` | exit 0 |
| `positions` | `contributor` | exit 0 |
| `permissions` | `showcase_contributor` | exit 0 |
| `capabilities` | `showcase.export_data` | exit 0 |
| `sharingRules` | `share_new_inquiries_with_field_ops` | exit 0 |
| `webhooks` | `showcase_task_changed` | exit 0 |
| `hooks` | `showcase_normalize_task_title` | exit 0 |
| `mappings` | `showcase_inquiry_feed` | exit 0 |
| `analyticsCubes` | `showcase_delivery` | exit 0 |
| `connectors` | `showcase_status_api` | exit 0 |
| `plugins` | `com.objectstack.connector.openapi` | exit 0 |
| `picklists`, `docs`, `agents`, `tools`, `skills`, `views` (a named
view) | hand-written `cen_*` entries | `defineStack` only |
| `apis` by name on different paths, `apps` on a `module` | (above) |
`defineStack` only |
Not named collections, so out of the census: `packages` (wrapper over
`manifest`), `translations` (keyed by locale), `objectExtensions` and
`picklistExtensions` (keyed by `extend`, additive), `data` (keyed by
`object`), `datasourceMapping`, `devLogins` (keyed by `email`),
`devPlugins`, `requires` and `tiers` (strings). `viewItems` is
`z.never`.
What happens downstream to each sibling (last-wins, merge, both kept)
was **not measured**. This census measures only the contract door. Per
the triage fence this PR fixes `functions` only. The siblings go to one
batch card, which the seat files.
## Tests
New pin file: `packages/spec/src/stack-duplicate-function-name.test.ts`,
10 cases. Each refusal case asserts the ADR-0112 envelope (`code`,
`status: 422`, the `issues` count, the header) and the named subjects of
each finding: the name, and exactly the indices it lists. It does not
assert a bare `toThrow()`.
- Refused: two entries; 3+2 entries; byte-identical twins; an
object-less stack; the lowered string-ref form repeating a name; one
aggregate with a duplicate action key (2 issues).
- Kept: skipped under `strict: false`, like every cross-reference check.
- CONTROL: distinct names accepted in order, with each entry's `effect`;
the map form, keys unchanged; and the suffixed refs a build emits
(`kc_a`, `kc_a__2`), accepted as two names.
Ablation 1 (source; the suite imports `./stack.zod` directly).
`scripts/ablation-replace.mjs` deleted
`errors.push(...collectDuplicateFunctionNameErrors(config));`: anchor 1
→ 0, blob `a3a6790a16c0` → `a8e4e3300fe3`. The run went **6 failed / 4
passed**: the six refusal cases are red, and the three CONTROLs and
`strict: false` stay green, which is the predicted direction. Restored:
blob == HEAD, `git diff HEAD` empty.
Ablation 2 (dist, through the CLI). The same deletion went through
`--hold`, then `OS_SKIP_DTS=1` spec build. `ablation-dist-preflight
--absent` reported the marker absent from all 98 built files. `os
validate --json` on the duplicate fixture then answered exit 0, `valid:
true`. Restore leg: `git checkout HEAD`, blob == HEAD, full spec build,
preflight marker present in 4 built files with the tree clean. After
that, `os validate` answered exit 1 with
`STACK_CROSS_REFERENCE_INVALID`.
Package readings at `6d555670a` (spec unchanged by the later `main`
merge):
- `pnpm --filter @objectstack/spec test`: 649 files, **19404 passed** |
1 todo, exit 0.
- `pnpm --filter @objectstack/spec typecheck`: exit 0. This covers `tsc
--noEmit`, `check:scripts-typecheck` and `check:test-typecheck` (52
files / 246 errors / 135 pinned signatures held, the new test file
adding none).
- `@objectstack/cli` unit tier: `src/utils/lower-callables.test.ts`,
`test/lower-callables.test.ts` and
`src/utils/boot-preparation-parity.test.ts`. 3 files, **95 passed**,
exit 0.
- `test/build-callable-names.test.ts` lives in the `integration` tier
and is declared to CI. Its array-form fixtures author distinct names
under `strict: false`, so this check cannot reach them.
- `@objectstack/runtime`: `artifact-function-declarations`,
`bundle-function-entries`, `artifact-collections` and `app-plugin`. 4
files, **68 passed**, exit 0.
- `pnpm --filter @objectstack/spec build`, then `check:generated`: all
14 generated artifacts up to date, no file changed.
Fixture triage (PM assumption 5). A repo-wide grep of array-form
`functions` covered `packages/cli` (`lower-callables` ×2,
`build-callable-names`, `lint-hook-rules-reach-handler-hooks`),
`packages/runtime` (`app-plugin`, `artifact-collections`,
`artifact-function-declarations`, `bundle-function-entries`),
`packages/objectql`, `packages/spec` and `examples/**`. No hit authors
one name twice. The two example apps that declare `functions` use the
map form.
## ADR-0087 and the changeset
`.changeset/22902-stack-functions-array-duplicate-name-refused.md`:
`@objectstack/spec` `minor`, `Clause-②: no (narrowing)`, a **BREAKING**
banner, FROM → TO and the one-line fix. Because the body carries a
migration prescription, `check:adr-0087-registration` refuses
`not-required (no-migration-prescription)`. Measured on a detached probe
commit: "contradicts the changeset's own body, which carries a migration
prescription". So the disposition is `registered`, with the D3 semantic
entry
`packages/spec/src/migrations/entries/semantic/18.stack-functions-array-duplicate-name-refused.ts`
(protocol 18) and no D2 conversion. There is no `STEP18_RATIONALE`
fragment (114 fragments for 346 step-18 entries, so one is not required)
and no `relevantWhen`.
## Gates
Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) on `70c36c115`, which is this
PR's head. That is 90 commands. Each one ran with its exit code captured
before any pipe, then everything was reconciled with `--ran`. Result:
**90 derived, 89 run, 1 NOT-MEASURED, 0 UNRUN**.
- 87 exited 0 on the first pass. These include
`check-adr-0087-registration` (1 declared-breaking changeset,
`registered`, new here), `check-changeset-no-major` (no major; pre-mode
lockstep evidenced), `check:nul-bytes`, `check:doc-authoring`,
`check:issue-citations`, and every spec `check:*` the derivation named
(`check:api-surface` "unchanged", `check:authorable-surface`,
`check:docs`, `check:liveness`, `check:spec-changes`,
`check:upgrade-guide` and the rest). Also `check:type-check-coverage`
and `check:type-check-debt`, the latter re-measured in 754 s with no
entry above its record.
- `check-plugin-teardown-shape.mjs --self-test` exited 3 on the first
pass. Its pinned fixture commit `621a487607` was outside this shallow
clone. After `git fetch --depth=1` of that one commit, it exits 0 (48
cases).
- `check:dts-closure` exited 1, from local build state and not from this
diff. The 58 packages outside `@objectstack/spec` were built with
`OS_SKIP_DTS=1`, so they carry no `.d.ts`. `@objectstack/spec` is not on
its list. **NOT MEASURED**, reason: no full DTS build of the closure
locally. CI builds it.
- `check:dual-build-cjs-loads` exited 3, `PREREQUISITE NOT MET`: 8
packages outside the built closure have no `dist/`. **NOT MEASURED**,
reason: as reported.
- Not run locally, CI's: the integration tier of `@objectstack/cli`, the
whole-workspace type-check lanes, and the path-scheduled CI jobs the
derivation lists outside its 90.
## Acceptance notes
- **Boundary, shared with every cross-reference check.**
`defineStack(..., { strict: false })` skips the check. `os validate` and
`os build` then accept the duplicate: their own parse reads the lowered,
already-suffixed names. This is the same reach as the duplicate
action-key refusal, and it is pinned as kept, not widened here.
- **PM assumption 4 re-measured.** On current `main` (after #22901),
both entries are callable after a build, and the second ships under
`kc_a__2`. That is the card's "PR #22901 branch" reading, and it is now
the `main` reading.
- **Grade.** Pre mode (`next`) is open, so `check:changeset-no-major`
admits either `minor` or `major`. The order named `minor`, and the
triage comment called it "a major's change". `minor` follows the order
and the launch-window banner precedent, and in the fixed group it moves
no version number.
- **Doors, measured.** The refusal surfaces at load on all three doors,
with door-specific exits: validate 1, build 2, serve 1.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74c4b34 commit 147a0d1
4 files changed
Lines changed: 334 additions & 2 deletions
File tree
- .changeset
- packages/spec/src
- migrations/entries/semantic
| 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 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 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 | + | |
| 40 | + | |
| 41 | + | |
| 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 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2229 | 2229 | | |
2230 | 2230 | | |
2231 | 2231 | | |
| 2232 | + | |
| 2233 | + | |
| 2234 | + | |
| 2235 | + | |
| 2236 | + | |
| 2237 | + | |
| 2238 | + | |
| 2239 | + | |
| 2240 | + | |
| 2241 | + | |
| 2242 | + | |
| 2243 | + | |
| 2244 | + | |
| 2245 | + | |
| 2246 | + | |
| 2247 | + | |
| 2248 | + | |
| 2249 | + | |
| 2250 | + | |
| 2251 | + | |
| 2252 | + | |
| 2253 | + | |
| 2254 | + | |
| 2255 | + | |
| 2256 | + | |
| 2257 | + | |
| 2258 | + | |
| 2259 | + | |
| 2260 | + | |
| 2261 | + | |
| 2262 | + | |
| 2263 | + | |
| 2264 | + | |
| 2265 | + | |
| 2266 | + | |
| 2267 | + | |
| 2268 | + | |
| 2269 | + | |
| 2270 | + | |
| 2271 | + | |
| 2272 | + | |
| 2273 | + | |
| 2274 | + | |
| 2275 | + | |
| 2276 | + | |
| 2277 | + | |
| 2278 | + | |
| 2279 | + | |
| 2280 | + | |
| 2281 | + | |
| 2282 | + | |
| 2283 | + | |
| 2284 | + | |
2232 | 2285 | | |
2233 | 2286 | | |
2234 | 2287 | | |
| |||
2303 | 2356 | | |
2304 | 2357 | | |
2305 | 2358 | | |
2306 | | - | |
2307 | | - | |
| 2359 | + | |
| 2360 | + | |
2308 | 2361 | | |
2309 | 2362 | | |
2310 | 2363 | | |
| |||
2945 | 2998 | | |
2946 | 2999 | | |
2947 | 3000 | | |
| 3001 | + | |
| 3002 | + | |
| 3003 | + | |
2948 | 3004 | | |
2949 | 3005 | | |
2950 | 3006 | | |
| |||
0 commit comments