Skip to content

Commit bb11e48

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15325-ratify-hook-body-subpath-export
2 parents 8595b71 + 17ec4b1 commit bb11e48

10 files changed

Lines changed: 1716 additions & 83 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/types": patch
3+
---
4+
5+
`createHostImporter` now loads the `import` build of an ALIASED dual-published package, instead of silently keeping its `require` build.
6+
7+
An alias declaration — `{"dependencies": {"foo": "npm:bar@1"}}` — installs a package whose manifest is named `bar` under the key `foo`. On the path where CommonJS resolution SUCCEEDS, the importer re-decides only the CONDITION (it asks the package which entry an `import()` gets, so the caller's ESM chain and this load share one instance). That re-decision recognised the package root by walking up from the resolved entry until it found a manifest named after the DECLARATION KEY — `foo` — while an aliased install's manifest is named `bar`. The walk therefore never matched, the re-decision produced nothing, and the load fell back to whatever the CommonJS resolver had answered: the `require` condition.
8+
9+
For an aliased dual publish that left the process holding two live copies of one package — the CommonJS build behind the host importer, the `import` build in the caller's own chain — which is exactly the split the condition re-decision exists to remove: a plugin registry, a singleton kernel, a module-level cache, one copy each.
10+
11+
The expectation now comes from the host's own declaration (`npm:name@range`, aliased `workspace:name@range`), the same reading the ESM-only fallback finder has used since it learned about aliases. Nothing about the check's strictness moves: an alias naming one package still does not license a directory holding another, and a non-aliased declaration is still verified against its key. Declarations that name a LOCATION rather than a package (`link:`, `file:`) carry no name to expect, so they keep today's behaviour unchanged.
12+
13+
Measured population for the behaviour change: zero aliased declarations exist across this workspace's 875 dependency declarations, and 867 of 867 installed declarations already match their key — no ordinary, non-aliased install reaches this path.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os lint --eval --json` reports an unscorable generated stack as a failed case instead of crashing with no JSON at all.
6+
7+
The eval harness promised totality in writing — *"Never throws — generation failures become failed cases"* — and the promise was false as written. Its `try` wrapped only the call to your `--generator` module; the `scoreMetadata(stack)` call that follows sat outside it. So a generator that **threw** became a failed case, exactly as documented, while a generator that **returned** a value nobody could walk took the whole process down:
8+
9+
```
10+
os lint --eval --json --generator ./g.mjs
11+
exit 1 · stdout 0 bytes · stderr " Error: poison getter"
12+
```
13+
14+
A caller that asked for `--json` got the framework's human error text on stderr and no document at all to parse. Eval mode dispatches above the project-lint `try`, so the catch-all JSON exit that mode has could never see it either.
15+
16+
Scoring a stack means walking it, and there are two walks: the normalizer spreads the stack's top level, and the schema parse walks everything below it. A throw from **either** now becomes that case's `generationError` — the same per-case channel a throwing generator already used — so the report exit that was always there emits its JSON, names the cause, and still exits non-zero:
17+
18+
```json
19+
{ "id": "invoice_with_line_items",
20+
"generationError": "Failed to score the generated stack: poison getter",
21+
"passed": false,
22+
"score": { "score": 0, "grade": "F", "valid": false } }
23+
```
24+
25+
Nothing new appears on the `--json` face: no new key, no new payload shape. The failing exit was already reachable for a throwing generator; it is now reachable for a poisonous one too.
26+
27+
The failed case is scored `0 / F / valid: false` rather than as an empty stack. An empty stack scores 100 / A / valid, and stamping that on a stack nobody could parse would have put a clean-looking verdict next to a failure — the crash replaced by a quiet wrong answer.
28+
29+
Unchanged: offline mode, and every off-shape stack a generator can return. Bad metadata is still **scored**, with its schema errors as the reason it fails — it is not rerouted into the failure channel.

packages/cli/src/lint/metadata-eval.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ export interface MetadataEvalCase {
3737
export interface MetadataEvalCaseResult {
3838
id: string;
3939
prompt: string;
40-
/** True when the (generated or fixture) stack failed to materialize. */
40+
/**
41+
* Set when this case has no usable stack to judge — the generator threw, or
42+
* the value it returned could not be scored. Present ⇒ the case FAILED, and
43+
* the string names the cause.
44+
*/
4145
generationError?: string;
4246
score: MetadataScore;
4347
minScore: number;
@@ -62,7 +66,8 @@ export interface RunMetadataEvalOptions {
6266
/**
6367
* Live generator. When provided, the harness scores `generate(prompt, id)`
6468
* instead of the case fixture. Returning a rejected promise / throwing marks
65-
* that case as a generation error (failed).
69+
* that case as a generation error (failed) — and so does returning a value
70+
* that cannot be scored, since scoring it is what the harness does next.
6671
*/
6772
generate?: (prompt: string, caseId: string) => unknown | Promise<unknown>;
6873
/** Default pass threshold for cases that don't set their own `minScore`. */
@@ -71,9 +76,47 @@ export interface RunMetadataEvalOptions {
7176

7277
const DEFAULT_MIN_SCORE = 75;
7378

79+
/**
80+
* The score attached to a case whose stack could not be scored AT ALL.
81+
*
82+
* ⛔ Deliberately NOT `scoreMetadata({})`, even though the throwing-generator
83+
* path above substitutes an empty stack: the empty stack scores 100 / A /
84+
* `valid: true` (pinned in `score.test.ts`), and stamping that on a stack
85+
* nobody could parse would put a benign-looking verdict next to a failure.
86+
* A stack that cannot be walked is not an empty stack, and `valid: true` for
87+
* one that was never parsed is simply false.
88+
*
89+
* ⛔ This is not a measurement and must never be read as one — the case is
90+
* already failed by its `generationError`. It exists so `MetadataScore` stays
91+
* total and the report's shape never varies between a scored and an unscorable
92+
* case. Fresh object per call: the report is handed to callers who may mutate.
93+
*/
94+
function unscorableScore(): MetadataScore {
95+
return {
96+
score: 0,
97+
valid: false,
98+
grade: 'F',
99+
counts: { schemaErrors: 0, errors: 0, warnings: 0, suggestions: 0 },
100+
schemaErrors: [],
101+
issues: [],
102+
};
103+
}
104+
74105
/**
75106
* Run the eval over a set of cases. Offline (fixtures) unless `generate` is
76-
* supplied. Never throws — generation failures become failed cases.
107+
* supplied.
108+
*
109+
* Never throws over its own work: producing a stack and scoring it are both
110+
* inside the loop's guards, so a generator that throws, a generator that
111+
* returns a value nobody can walk, and a fixture that cannot be scored all
112+
* become that case's `generationError` — a FAILED case in the returned report,
113+
* never an escaping error.
114+
*
115+
* ⚠️ The one thing outside that promise, stated rather than implied: reading
116+
* the caller's own `cases` entries (`c.id`, `c.prompt`, `c.fixture`,
117+
* `c.minScore`). A case object whose property reads themselves throw is a
118+
* broken argument, not a failed case — there is no id to report it under. No
119+
* in-repo caller can reach it: `os lint --eval` passes a static corpus.
77120
*/
78121
export async function runMetadataEval(
79122
cases: MetadataEvalCase[],
@@ -99,7 +142,25 @@ export async function runMetadataEval(
99142
}
100143
}
101144

102-
const score = scoreMetadata(stack);
145+
// Scoring walks a value this harness did not build, and walking it can
146+
// throw. Two sites are driven: the normalizer spreads the stack's top
147+
// level, and the schema parse one call further in walks the rest — so a
148+
// poisoned property enumeration anywhere in a generated stack surfaces
149+
// here, NOT inside the `try` above, which only ever covered `generate`
150+
// itself. That is why the docblock's "Never throws" was false as written.
151+
//
152+
// ⛔ The throw is never absorbed: it becomes THIS CASE's failure rather
153+
// than the process's, routed through the same per-case channel a throwing
154+
// generator uses, so `passed` below is false and the caller's report says
155+
// `ok: false`. Swallowing it into a passing case would be worse than the
156+
// crash it replaces.
157+
let score: MetadataScore;
158+
try {
159+
score = scoreMetadata(stack);
160+
} catch (err: any) {
161+
generationError = `Failed to score the ${source} stack: ${err?.message || String(err)}`;
162+
score = unscorableScore();
163+
}
103164
results.push({
104165
id: c.id,
105166
prompt: c.prompt,
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os lint --eval --json` had NO machine face for an uncaught throw.
5+
*
6+
* ## The measured before-shape
7+
*
8+
* `run()` dispatches eval mode and returns ENTIRELY ABOVE the project-lint
9+
* `try`, so nothing thrown out of `runEval` can reach that mode's catch-all
10+
* JSON exit; and `lint.ts` hand-rolls `json` as a plain `Flags.boolean` rather
11+
* than oclif's `enableJsonFlag` (zero occurrences anywhere in
12+
* `packages/cli/src`), so no framework envelope sits underneath either. Driven
13+
* on the published entry before the fix:
14+
*
15+
* os lint --eval --json --generator ./poison.mjs
16+
* exit 1 · stdout 0 BYTES · stderr " Error: poison getter"
17+
*
18+
* ⇒ a caller that asked for `--json` got oclif's human text on stderr and no
19+
* document at all to parse.
20+
*
21+
* ## What was actually broken, and what was NOT
22+
*
23+
* ⛔ Nothing new appears on the `--json` face and nothing was added to it. The
24+
* eval report exit already emits JSON and already `process.exit(1)`s when
25+
* `!report.ok`. The defect was that a whole class of failure could never REACH
26+
* that exit, because `runMetadataEval` — whose docblock says *"Never throws"* —
27+
* wrapped only `options.generate(...)` in its `try` and left the
28+
* `scoreMetadata(stack)` call outside it. A generator that THREW became a
29+
* failed case; one that RETURNED a value nobody could walk escaped. The fix
30+
* makes the existing exit reachable; it does not widen it.
31+
*
32+
* ## ⛔ The trap this file exists to keep shut
33+
*
34+
* A guard that swallowed the throw and let the poisoned case be reported as
35+
* PASSING would be worse than the crash — it turns a loud failure into a quiet
36+
* wrong answer. So `stdout parses` is never asserted alone here: every positive
37+
* requires `ok: false`, the case FAILED, the cause NAMED in `generationError`,
38+
* and a nonzero exit. `a silent swallow would be caught` pins the negative
39+
* directly, against the specific benign shape a swallow would produce
40+
* (`scoreMetadata({})` is 100 / A / `valid: true`).
41+
*
42+
* ## Why the negative controls are here
43+
*
44+
* The reachable class is narrow, and that narrowness is a MEASUREMENT: every
45+
* off-shape stack below already produced valid JSON before this change, and
46+
* must still. They are the guard against a fix that "solved" the crash by
47+
* routing ordinary bad metadata into the failure channel too — an off-shape
48+
* stack is a SCORED case with schema errors, never a `generationError`.
49+
*
50+
* ## Why no `dist/` sits on the measured path
51+
*
52+
* These run the CLI through `bin/run-dev.js`, the SOURCE entry — same CLI, run
53+
* from `src/` through tsx — so `metadata-eval.ts` is loaded from source by the
54+
* child and an ablation of it is measured without a rebuild. Its dependency
55+
* `@objectstack/spec`, which owns `normalizeStackInput`, resolves through
56+
* `exports` to `dist/`, and this change does not touch it.
57+
*/
58+
59+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
60+
import { execFile } from 'node:child_process';
61+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
62+
import { tmpdir } from 'node:os';
63+
import { join, resolve } from 'node:path';
64+
import { fileURLToPath } from 'node:url';
65+
import { childEnv } from './helpers/serve-process.js';
66+
67+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
68+
const CLI = resolve(HERE, '../bin/run-dev.js');
69+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
70+
71+
interface Run {
72+
code: number;
73+
stdout: string;
74+
stderr: string;
75+
}
76+
77+
let dir: string;
78+
79+
function generator(name: string, source: string): string {
80+
const file = join(dir, `${name}.mjs`);
81+
writeFileSync(file, source, 'utf8');
82+
return file;
83+
}
84+
85+
function runEval(generatorPath?: string): Promise<Run> {
86+
const args = [CLI, 'lint', '--eval', '--json', ...(generatorPath ? ['--generator', generatorPath] : [])];
87+
return new Promise((resolvePromise) => {
88+
execFile(
89+
TSX,
90+
args,
91+
{ cwd: dir, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
92+
(err, stdout, stderr) => {
93+
resolvePromise({
94+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
95+
stdout: String(stdout),
96+
stderr: String(stderr),
97+
});
98+
},
99+
);
100+
});
101+
}
102+
103+
interface EvalCaseResult {
104+
id: string;
105+
generationError?: string;
106+
passed: boolean;
107+
score: { score: number; grade: string; valid: boolean; counts: { schemaErrors: number } };
108+
}
109+
110+
interface EvalReport {
111+
ok: boolean;
112+
total: number;
113+
passed: number;
114+
failed: number;
115+
meanScore: number;
116+
results: EvalCaseResult[];
117+
}
118+
119+
/** stdout as ONE JSON document, or a failure that quotes what was there instead. */
120+
function payloadOf(run: Run, label: string): EvalReport {
121+
try {
122+
return JSON.parse(run.stdout) as EvalReport;
123+
} catch {
124+
throw new Error(
125+
`${label}: stdout was not one JSON document (exit ${run.code}, ${run.stdout.length} stdout bytes)\n` +
126+
`stdout: ${JSON.stringify(run.stdout)}\nstderr: ${JSON.stringify(run.stderr)}`,
127+
);
128+
}
129+
}
130+
131+
/** Poison on a TOP-LEVEL key — throws in `normalizeStackInput`'s `{ ...input }`. */
132+
const TOP_LEVEL_POISON = `export default function () {
133+
return { name: 'poison', get objects() { throw new Error('poison getter'); } };
134+
}
135+
`;
136+
137+
/** Poison one level DOWN — survives the shallow spread, throws inside the schema parse. */
138+
const NESTED_POISON = `export default function () {
139+
return {
140+
name: 'poison_nested',
141+
objects: [{ name: 'account', label: 'Account', get fields() { throw new Error('nested poison getter'); } }],
142+
};
143+
}
144+
`;
145+
146+
beforeAll(() => {
147+
dir = mkdtempSync(join(tmpdir(), 'os-lint-eval-json-'));
148+
});
149+
150+
afterAll(() => {
151+
rmSync(dir, { recursive: true, force: true });
152+
});
153+
154+
describe('os lint --eval --json — an unscorable generated stack has a machine face', () => {
155+
it('a top-level poisoned getter: stdout is JSON, the case FAILED, the cause is named', async () => {
156+
const run = await runEval(generator('top-level-poison', TOP_LEVEL_POISON));
157+
const payload = payloadOf(run, 'top-level poison');
158+
159+
// The failure is LOUD: nonzero exit, ok:false, every case failed.
160+
expect(run.code).toBe(1);
161+
expect(payload.ok).toBe(false);
162+
expect(payload.failed).toBe(payload.total);
163+
expect(payload.passed).toBe(0);
164+
165+
// …and the cause is NAMED, on the per-case channel a throwing generator uses.
166+
expect(payload.results[0].generationError).toContain('poison getter');
167+
expect(payload.results[0].passed).toBe(false);
168+
169+
// Nothing leaked to the human channel on a --json run.
170+
expect(run.stderr).toBe('');
171+
}, 120_000);
172+
173+
it('a poisoned getter BELOW the top level is caught too — the schema parse walks there', async () => {
174+
// The SITE control. This throw never reaches `normalizeStackInput`: the
175+
// top-level spread copies `objects` by reference and the getter fires later,
176+
// inside zod. A guard around the normalizer alone would leave this red.
177+
const run = await runEval(generator('nested-poison', NESTED_POISON));
178+
const payload = payloadOf(run, 'nested poison');
179+
180+
expect(run.code).toBe(1);
181+
expect(payload.ok).toBe(false);
182+
expect(payload.results[0].generationError).toContain('nested poison getter');
183+
expect(payload.results[0].passed).toBe(false);
184+
}, 120_000);
185+
186+
it('⛔ a silent swallow would be caught: the unscorable case is not scored as clean', async () => {
187+
const run = await runEval(generator('swallow-control', TOP_LEVEL_POISON));
188+
const payload = payloadOf(run, 'swallow control');
189+
const first = payload.results[0];
190+
191+
// A swallow that substituted the empty stack would report 100 / A / valid.
192+
expect(first.score.score).toBe(0);
193+
expect(first.score.grade).toBe('F');
194+
expect(first.score.valid).toBe(false);
195+
expect(payload.meanScore).toBe(0);
196+
}, 120_000);
197+
});
198+
199+
describe('os lint --eval --json — the negative controls still answer the same', () => {
200+
it('offline mode is untouched: exit 0 and every golden case passes', async () => {
201+
const run = await runEval();
202+
const payload = payloadOf(run, 'offline baseline');
203+
204+
expect(run.code).toBe(0);
205+
expect(payload.ok).toBe(true);
206+
expect(payload.failed).toBe(0);
207+
expect(payload.results.every((r) => r.generationError === undefined)).toBe(true);
208+
}, 120_000);
209+
210+
it('a generator that THROWS is still a generation error, not a scoring one', async () => {
211+
const run = await runEval(
212+
generator('throws', `export default function () { throw new Error('model unavailable'); }\n`),
213+
);
214+
const payload = payloadOf(run, 'throwing generator');
215+
216+
expect(run.code).toBe(1);
217+
expect(payload.results[0].generationError).toBe('model unavailable');
218+
}, 120_000);
219+
220+
it.each([
221+
['manifest-as-string', `export default () => ({ manifest: 'not-an-object' });\n`],
222+
['objects-as-string', `export default () => ({ objects: 'not-an-array' });\n`],
223+
['objects-as-number', `export default () => ({ objects: 42 });\n`],
224+
['objects-as-null', `export default () => ({ objects: null });\n`],
225+
['nested-wrong-types', `export default () => ({ objects: [{ name: 123, label: [], fields: 'nope' }] });\n`],
226+
['bare-string', `export default () => 'just a string';\n`],
227+
])('off-shape stack %s is a SCORED case with schema errors, never a generationError', async (name, source) => {
228+
const run = await runEval(generator(name, source));
229+
const payload = payloadOf(run, name);
230+
const first = payload.results[0];
231+
232+
expect(run.code).toBe(1);
233+
expect(payload.ok).toBe(false);
234+
// ⭐ The line that keeps the fix honest: ordinary bad metadata must NOT be
235+
// rerouted into the failure channel — it is scored, and its schema errors
236+
// are what fail it.
237+
expect(first.generationError).toBeUndefined();
238+
expect(first.score.counts.schemaErrors).toBeGreaterThan(0);
239+
}, 120_000);
240+
241+
it('a generator that cannot be loaded still takes the generator-load JSON exit', async () => {
242+
const run = await runEval(join(dir, 'does-not-exist.mjs'));
243+
244+
expect(run.code).toBe(1);
245+
const payload = JSON.parse(run.stdout) as { error?: string };
246+
expect(payload.error).toContain('Failed to load generator');
247+
}, 120_000);
248+
});

0 commit comments

Comments
 (0)