Skip to content

Commit c32bd82

Browse files
fix(cli): objectstack build ships each functions key under its own name and code; hook and action refs resolve to their own code (#22901)
Fixes #22886 Clause-②: no ## Summary The runtime resolves a hook's `handler`, an action's `target` and a `functions` key against one merged namespace, `bundle.functions`, which the runtime module (`objectstack-runtime.*.mjs`) fills. Two sides used to name that namespace's entries, and they disagreed: - `lowerCallables` minted refs in walk order (hooks, then actions, then `functions`), deduplicated them by function identity, and re-keyed each `functions` entry by the ref it got. - The runtime-module template re-derived names from the config. It keyed hooks by `name` and `functions` entries by their authored key, and never reached an action's `target` or a `packages[i].manifest` body. This PR makes the build's ref assignment the one reading (the triage direction in force): - **`lower-callables.ts`**: every authored `functions` name (a map key, or an array entry's `name`) is taken before any internal ref is minted, and is always its own ref, bound to its own callable. A hook or action ref is internal. On an equal name it is the one that takes the `__2` suffix; when it binds the same function as a `functions` entry, it re-uses that entry's name. Nothing is refused (no new build refusal). - **`build-runtime.ts`**: the template no longer derives names. `buildRuntimeBundle` takes `lowerCallables`' `functions` (ref → callable) and the loaded config. `locateCallables` finds each callable in the config by identity (breadth-first, shortest path). The template then exports exactly those refs, each bound to the callable at its path. A top-level key resolves the way `loadConfig` merged it: the default export's own keys first, then named exports. - **`compile.ts`**: the one call site passes `callables: lowering.functions, loadedConfig: config` instead of `refs: Object.keys(lowering.functions)`. See the deviation below. ## Measured through `objectstack build` and then `loadArtifactBundle` Both trees were run through a scratch probe: every registration site × {a function shared with a `functions` entry, a name equal to a `functions` key}. Each callable names a module-scope constant, so the ref is the path that runs. | fixture | `main` `2e11d9006c` | this branch | |:--|:--|:--| | hook, shared function (shape i) | `functions: {kc_hook}`, authored `kc_fn` gone | `functions: {kc_fn}`, hook ref `kc_fn` answers its own code | | hook, equal name (shape ii) | `kc_same__2` bare string; hook ref `kc_same` answers the `functions` entry's code | `kc_same` answers the entry; hook ref `kc_same__2` answers the hook | | object action, shared / equal | authored key gone / re-keyed; action ref unresolved / answers the entry | each authored key and each action ref answers its own code | | global action, shared / equal | same as the object action | same as the object action | | two map keys on one function (bare, declared) | second key gone | both keys ship, both answer | | two array names on one function | second entry renamed to the first's name | both names ship, both answer | | CONTROL, no collision | artifact refs as pinned below; both action refs **unresolved** | artifact byte-identical apart from the module's content hash; every ref answers its own code | | `functions` map exported by name beside the default export | ships `kc_named` as a bare string | `kc_named` answers its own code | The last two rows are shipped defects this change touches, so they are fixed here: the template never carried an action's callable, and it read the default export only. Neither involves a collision. ## Deviation: `compile.ts` is outside the claim's file surface The claim's surface is `lower-callables.ts`, `build-runtime.ts`, their tests and the changeset. The decision in force says the template reads the ref assignment from `lowerCallables`' output. Measured: `compile.ts:1070-1074` is the one path from `lowerCallables` to `buildRuntimeBundle`, and it carried only `Object.keys(lowering.functions)`, the ref names with no callable. That falsifies the PM's mechanism assumption 2 ("whether a ref → authored-name map already exists in its output or has to be added"). It has to be added, and it has to cross that call site. The edit is the call site's arguments only (+2/−1). There is no new call, so `validate-build-gate-parity.test.ts`'s closed roster of `compile.ts` calls is untouched. Following the role file's rule for a falsified mechanism assumption (switch route by the decision's intent) conflicts with the dispatch's "stop on any other breach". The report names the conflict for the seat to ratify. ## Tests All at `13401ef927`; the final head `9736807dde` differs from it only in the changeset text (+2/−2). - **Enumeration pin, new**: `packages/cli/test/build-callable-names.test.ts`, 13/13 passed. - The census reads every `register…(` call and every `lowerActionCallable(` call site off `lower-callables.ts` with the TypeScript AST. Its derived kinds are `hook`, `object-action`, `global-action`, `functions-map`, `functions-map-declared`, `functions-array-named` and `functions-array-nameless`, and it requires one fixture per derived kind (closed both ways). - Each fixture round-trips through `objectstack build` and `loadArtifactBundle`. - Plus the CONTROL, the named-export case and two `locateCallables` unit cases. - `test/lower-callables.test.ts`: the test that pinned shape (ii) (hook kept `dup`, authored key became `dup__2`) is rewritten to the decided invariant. Four unit cases were added: shared function, two keys on one function, a pass-through key still owns its name, and the ADR-0130 D4 package re-walk agreeing. - `test/build-runtime-bundle-bodyless.test.ts`: `hook-and-function` (shape i itself) now asserts `functions: { pb_score: 'pb_score' }` instead of "one key resolves". - `pnpm --filter @objectstack/cli typecheck`: exit 0, test layer included. The 3 touched test files are in `tsconfig.test.json`'s program and the 3 touched sources in `tsconfig.json`'s, counted with `--listFilesOnly`. - `vitest run --project unit`: 283/283 files. - 281 passed on the full run. - 2 (`published-subpath-*.pin`) refused because `packages/cli` was not built; after `pnpm --filter @objectstack/cli build` they pass, 29/29. - `vitest run --project integration` (run locally, since this diff touches integration-tier files and the build path): 112/112 files, 1020 passed, 2 skipped, `VERDICT command-exit 0`. **Reverse verification** (one-time, no permanent file): - The three `src` files were restored to `BASE` in the working tree only (`git restore --source=2e11d9006c`), under an `EXIT INT TERM` trap that restores from `HEAD`. - Landed on disk: `registerAuthored` count 0, `locateCallables` count 0, the old `refs:` line count 1, and all 3 tree hashes equal the `BASE` blobs. - The new pin went **9 failed / 0 passed**. Examples: `hook: expected [ 'kc_shared_hook', 'kc_same__2' ] to deeply equal [ 'kc_shared_fn', 'kc_same' ]`; CONTROL `expected 'unresolved: undefined' to be 'action:kc'`; the census named `register(value)` unclassified. - Restore proven: 3/3 hashes equal the `HEAD` blobs, `git diff HEAD` empty, status clean. - The CLI runs from source through `bin/run-dev.js` and tsx, so no `dist/` rebuild was needed for either leg. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` with no paths, at `9736807dde`, equals the dispatch's list exactly (65 commands). - All 65 ran at `9736807dde` and exited 0. - `--ran` with recorded exit codes: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (prerequisites: other packages unbuilt). They pass after `turbo run build` of the named packages and the example closures (`check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new)`). **Lint (proven narrowing, `pnpm lint` itself is CI's):** 1. Population, read from `eslint.config.mjs`: `**/*.{ts,…}` and `packages/cli/src/**`. The 6 touched `.ts` files are all inside it (0 reported as ignored). 2. Count, read from `--format json`: 6 results, 0 errors, 0 warnings. 3. Invariance: the config never enables type-aware linting (no `parserOptions.project`, no typed rules; its own comment at about line 327 says so), so this diff cannot move a verdict on any untouched file. ## Acceptance notes - **The array form accepts two entries authoring one `name`** (measured: `objectstack build` exit 0 on both trees). - It is one name for two codes in one flat namespace, not a collision between kinds, so the pin records it as having no equal-name fixture, with that reason. - On `main` the second entry shipped as an uncallable `kc_a__2`. Here it ships callable, but still under the suffixed name. - A real fix is a refusal at the schema (the `domain:spec` seat's). It is reported in the dev report rather than worked around here. - **Two `packages[i].manifest` bodies binding one authored `functions` name to different code** cannot share one flat runtime-module namespace. The later one keeps today's suffixed ref, consistently across walks. No door producing that shape was measured. - `LoweringResult.count` now counts refs, so two authored keys on one function count 2. Its readers are only the build's "Bundling N handlers" line and `--json`'s `handlersBundled`. ## Size 7 files, +738 / −65 = 803 lines against the merge base `45e206e498`: | file | + | − | |:--|--:|--:| | `test/build-callable-names.test.ts` (new) | 400 | 0 | | `src/utils/build-runtime.ts` | 128 | 42 | | `src/utils/lower-callables.ts` | 127 | 15 | | `test/lower-callables.test.ts` | 59 | 3 | | `.changeset/22886-functions-key-keeps-authored-name.md` | 18 | 0 | | `test/build-runtime-bundle-bodyless.test.ts` | 4 | 4 | | `src/commands/compile.ts` | 2 | 1 | Dev session `session_01VoSxBQujKLZKPwK2u5ehQ6` (`domain:cli` seat 2 dispatch, mode:subagent). --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f69471 commit c32bd82

7 files changed

Lines changed: 738 additions & 65 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `objectstack build` ships every `functions` name under its own name, bound to its own code, and every hook and action ref resolves to its own code (#22886)
6+
7+
Clause-②: no
8+
9+
The runtime resolves a hook's `handler`, an action's `target` and a `functions` key against one namespace, filled from the runtime module (`objectstack-runtime.*.mjs`). The build and the module used to name its entries twice, and the two disagreed:
10+
11+
- **A `functions` key that shared its function with a hook or an action was lost.** `functions: { score: tidy }` beside a hook whose handler is `tidy` shipped as `functions: { tidy_hook: 'tidy_hook' }`, and two keys bound to one function shipped as one.
12+
- **A `functions` key equal to a hook's name, or to an action's `OBJECT_ACTION` ref, was re-keyed and its hook ran the wrong code.** The key shipped as `NAME__2`, a bare string with no callable after load, while the hook's ref resolved to the `functions` entry's code.
13+
- **An action's handler never reached the runtime module**, collision or not, so a script action whose handler could not be lowered to a `body` shipped with a ref that resolved to nothing.
14+
- **A `functions` map exported by name** beside the default export shipped as bare strings: the module read the default export only.
15+
16+
Now an authored `functions` name (a map key, or an array entry's `name`) is always its own ref: it ships under that name and runs its own code. A hook or action ref is internal, so on a collision it is the one that takes a numeric suffix (`hooks[i].handler: 'NAME__2'`); the hook's `name` is unchanged. A hook or action bound to the same function as a `functions` entry re-uses that entry's name. The runtime module is generated from the build's own ref → callable map, locating each callable in the loaded config by identity, so it serves every ref the artifact names. A stack with no collision ships the same artifact refs as before.
17+
18+
No action is needed: rebuild to pick up the fix. An artifact built before this release keeps the old names until it is rebuilt.

‎packages/cli/src/commands/compile.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1069,7 +1069,8 @@ export default class Compile extends Command {
10691069
try {
10701070
runtimeBundle = await buildRuntimeBundle({
10711071
sourceConfigPath: absolutePath,
1072-
refs: Object.keys(lowering.functions),
1072+
callables: lowering.functions,
1073+
loadedConfig: config,
10731074
outputDir: artifactDir,
10741075
});
10751076
finalBundle.runtimeModule = `./${runtimeBundle.outputFileName}`;

‎packages/cli/src/utils/build-runtime.ts‎

Lines changed: 128 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,23 @@ import { dirname, basename, resolve as resolvePath, relative, sep as PATH_SEP }
1919
* even though the JSON artifact is pure metadata.
2020
*/
2121

22+
type AnyFn = (...args: unknown[]) => unknown;
23+
2224
export interface BuildRuntimeOptions {
2325
/** Absolute path to the user's `objectstack.config.{ts,js,mjs}`. */
2426
sourceConfigPath: string;
25-
/** Lowered handler refs (`name → handlerName`) — mirrors `LoweringResult.functions`. */
26-
refs: string[];
27+
/**
28+
* `LoweringResult.functions`: ref → the callable it names. The module serves
29+
* exactly these refs, each with exactly this callable.
30+
*/
31+
callables: Readonly<Record<string, AnyFn>>;
32+
/**
33+
* The config `loadConfig` answered for `sourceConfigPath` (its `config`), the
34+
* value `lowerCallables`' input was normalized from. Every callable is
35+
* located in it by identity, so the module can pick the same function back
36+
* up when it re-evaluates the config.
37+
*/
38+
loadedConfig: unknown;
2739
/** Output directory (e.g. `<cwd>/dist`). Created if missing. */
2840
outputDir: string;
2941
/** Base file name without hash, e.g. `objectstack-runtime`. */
@@ -46,68 +58,142 @@ export interface BuildRuntimeResult {
4658
const DEFAULT_BASE = 'objectstack-runtime';
4759

4860
/**
49-
* Generate the entry source that re-extracts callable handlers from the
50-
* compiled config. We bundle the user's config transitively (so every
51-
* `*.hook.ts` import is captured), then walk the same shape the lowering
52-
* pass walked and re-export the matching handlers under the same string
53-
* keys. The runtime side merges this map into `bundle.functions`.
61+
* Where a callable lives in the loaded config: a property path from its top
62+
* level (`['hooks', 0, 'handler']`, `['functions', 'score_lead']`).
63+
*/
64+
export type CallableLocator = readonly (string | number)[];
65+
66+
/**
67+
* Locate every ref's callable in `loadedConfig` by IDENTITY, breadth-first
68+
* over its own enumerable properties, so each path is the shortest one.
69+
*
70+
* The ref → callable pairing is `lowerCallables`' (`callables`), and this
71+
* function adds only WHERE each callable is, so the module never re-derives a
72+
* name. The template used to: it walked `hooks`, then `functions`, keyed each
73+
* callable by the hook's name or the entry's authored key, and kept those
74+
* present in the ref list. It therefore disagreed with the lowering whenever
75+
* one name or one function served two slots (a hook's ref resolved to a
76+
* `functions` entry's code, an authored key shipped as a bare string), and it
77+
* never reached an action's `target` or a `packages[i].manifest` body at all.
78+
*
79+
* Every callable `lowerCallables` registered was read off this same config
80+
* (normalizing copies containers, never functions), so a callable it cannot
81+
* find is an internal inconsistency. It throws, naming the refs, rather than
82+
* writing a module that silently leaves them out.
5483
*/
55-
function generateEntrySource(sourceConfigPath: string, refs: string[], entryDir: string): string {
84+
export function locateCallables(
85+
callables: Readonly<Record<string, AnyFn>>,
86+
loadedConfig: unknown,
87+
): Record<string, CallableLocator> {
88+
const wanted = new Map<AnyFn, string[]>();
89+
for (const [ref, fn] of Object.entries(callables)) {
90+
const refs = wanted.get(fn);
91+
if (refs === undefined) wanted.set(fn, [ref]);
92+
else refs.push(ref);
93+
}
94+
const at = new Map<AnyFn, CallableLocator>();
95+
const seen = new Set<object>();
96+
let frontier: Array<[unknown, CallableLocator]> = [[loadedConfig, []]];
97+
while (frontier.length > 0 && at.size < wanted.size) {
98+
const next: Array<[unknown, CallableLocator]> = [];
99+
for (const [value, path] of frontier) {
100+
if (typeof value === 'function') {
101+
const fn = value as AnyFn;
102+
if (path.length > 0 && wanted.has(fn) && !at.has(fn)) at.set(fn, path);
103+
continue;
104+
}
105+
if (value === null || typeof value !== 'object' || seen.has(value)) continue;
106+
seen.add(value);
107+
if (Array.isArray(value)) {
108+
value.forEach((item, i) => next.push([item, [...path, i]]));
109+
continue;
110+
}
111+
for (const key of Object.keys(value)) {
112+
let item: unknown;
113+
try {
114+
item = (value as Record<string, unknown>)[key];
115+
} catch {
116+
continue; // a throwing getter holds no callable this pass could have read either
117+
}
118+
next.push([item, [...path, key]]);
119+
}
120+
}
121+
frontier = next;
122+
}
123+
const locators: Record<string, CallableLocator> = {};
124+
const missing: string[] = [];
125+
for (const [ref, fn] of Object.entries(callables)) {
126+
const path = at.get(fn);
127+
if (path === undefined) missing.push(ref);
128+
else locators[ref] = path;
129+
}
130+
if (missing.length > 0) {
131+
throw new Error(
132+
`[build-runtime] ${missing.length} lowered callable(s) are not reachable from the loaded config, ` +
133+
`so the runtime module could not serve them: ${missing.map((r) => `'${r}'`).join(', ')}`,
134+
);
135+
}
136+
return locators;
137+
}
138+
139+
/**
140+
* Generate the entry source of the runtime module. It bundles the user's
141+
* config transitively (so every `*.hook.ts` import is captured) and exports
142+
* `functions`: each ref of `locators`, bound to the callable found at its
143+
* path. The runtime merges that map into `bundle.functions`.
144+
*
145+
* A path starts at the config `loadConfig` assembled: the default export's own
146+
* keys, then the module's named exports for the keys it does not have (the
147+
* named-export merge `loadConfig` performs, read back the same way).
148+
*/
149+
function generateEntrySource(
150+
sourceConfigPath: string,
151+
locators: Record<string, CallableLocator>,
152+
entryDir: string,
153+
): string {
56154
// Use a POSIX-style relative path so esbuild's resolver bundles the
57155
// user's config + every transitively-imported `*.hook.ts` into the
58156
// emitted ESM. A `file://` URL would be treated as external.
59157
let importPath = relative(entryDir, sourceConfigPath).split(PATH_SEP).join('/');
60158
if (!importPath.startsWith('.')) importPath = './' + importPath;
61-
const refLiteral = JSON.stringify(refs);
62159

63160
return `// AUTO-GENERATED by @objectstack/cli — do not edit.
64-
import config from ${JSON.stringify(importPath)};
161+
import * as configModule from ${JSON.stringify(importPath)};
65162
66-
const REFS = new Set(${refLiteral});
163+
// ref → where \`lowerCallables\` found its callable in the loaded config.
164+
const LOCATORS = ${JSON.stringify(locators)};
67165
68-
function collect(cfg) {
69-
const out = Object.create(null);
70-
if (!cfg || typeof cfg !== 'object') return out;
71-
// Hooks: { name, handler }
72-
if (Array.isArray(cfg.hooks)) {
73-
for (const h of cfg.hooks) {
74-
if (h && typeof h === 'object' && typeof h.handler === 'function' && typeof h.name === 'string') {
75-
if (REFS.has(h.name)) out[h.name] = h.handler;
76-
}
77-
}
166+
const base = configModule.default;
167+
function top(key) {
168+
if (base && (typeof base === 'object' || typeof base === 'function') && Object.prototype.hasOwnProperty.call(base, key)) {
169+
return base[key];
78170
}
79-
// Top-level functions map — a value is the handler, or a declaration
80-
// record stating its effect (\`{ handler, effect: 'writes' }\`).
81-
if (cfg.functions && !Array.isArray(cfg.functions) && typeof cfg.functions === 'object') {
82-
for (const [k, v] of Object.entries(cfg.functions)) {
83-
if (!REFS.has(k)) continue;
84-
if (typeof v === 'function') out[k] = v;
85-
else if (v && typeof v === 'object' && typeof v.handler === 'function') out[k] = v.handler;
86-
}
87-
}
88-
// Top-level functions array
89-
if (Array.isArray(cfg.functions)) {
90-
for (const entry of cfg.functions) {
91-
if (entry && typeof entry === 'object' && typeof entry.handler === 'function' && typeof entry.name === 'string') {
92-
if (REFS.has(entry.name)) out[entry.name] = entry.handler;
93-
}
94-
}
171+
return key === 'default' ? undefined : configModule[key];
172+
}
173+
174+
function collect() {
175+
const out = Object.create(null);
176+
for (const ref of Object.keys(LOCATORS)) {
177+
const path = LOCATORS[ref];
178+
let value = top(path[0]);
179+
for (let i = 1; i < path.length && value != null; i++) value = value[path[i]];
180+
if (typeof value === 'function') out[ref] = value;
95181
}
96182
return out;
97183
}
98184
99-
const cfg = config && typeof config === 'object' && 'default' in config ? config.default : config;
100-
export const functions = collect(cfg);
185+
export const functions = collect();
101186
export const meta = {
102187
builtAt: new Date().toISOString(),
103-
refCount: ${refs.length},
188+
refCount: ${Object.keys(locators).length},
104189
};
105190
`;
106191
}
107192

108193
export async function buildRuntimeBundle(opts: BuildRuntimeOptions): Promise<BuildRuntimeResult> {
109-
const { sourceConfigPath, refs, outputDir } = opts;
194+
const { sourceConfigPath, callables, loadedConfig, outputDir } = opts;
110195
const baseName = opts.baseName ?? DEFAULT_BASE;
196+
const locators = locateCallables(callables, loadedConfig);
111197

112198
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
113199

@@ -116,7 +202,7 @@ export async function buildRuntimeBundle(opts: BuildRuntimeOptions): Promise<Bui
116202
entryDir,
117203
`.${baseName}.entry.${process.pid}.${Date.now()}.mjs`,
118204
);
119-
writeFileSync(entryPath, generateEntrySource(sourceConfigPath, refs, entryDir));
205+
writeFileSync(entryPath, generateEntrySource(sourceConfigPath, locators, entryDir));
120206

121207
let esbuild: typeof import('esbuild');
122208
try {

0 commit comments

Comments
 (0)