Skip to content

Commit 6910797

Browse files
build(scenarios): a minimal unpublished build, and a narrow ./score entry that reads no objectql or plugin-sharing (#30938)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D6fapbH2zHLk3JPBFybbtnd)_ domain:devx#1 · os-elon-musk · session_016WV4cwgUKpQgCE3UWsA74d Fixes #30915 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:私有包 `@objectstack/scenarios` 加了一个不发布的最小 tsup 构建(`dist/` 下 ESM + CJS + `.d.ts`),四个导出子路径全部指向构建产物;`./score` 改为窄入口,只含图形打分与答题/写操作评分,不再牵出 `objectql` 和 `plugin-sharing`;原先的完整入口改名为 `./score/full`。 - **为什么改**:按裁决 D 打分器搬进了这个包,但包里只有 `.ts` 源码,cloud 打包后用纯 Node 加载会报 ERR_MODULE_NOT_FOUND;内联打包又等于把源码复制进 cloud(2.87 MB → 3.95 MB),违反裁决。 - **风险与回滚**:包仍是 private、不发布;仓内的测试和 `run-scenarios` 仍直接读 `src/`,行为零改动。唯一外部消费者是 cloud#3604(尚未落地)。回滚即 revert。 - **先看**:① `packages/qa/scenarios/package.json` 的 `exports`(`./score` 与 `./score/full` 的分工);② `src/score/narrow.ts`(只做 re-export,没有搬文件);③ `turbo.json` 一行:本包 `test` 改为先跑本包 `build`。 - **两分钟验证**:`pnpm --filter @objectstack/scenarios build && pnpm --filter @objectstack/scenarios exec vitest run test/build`,预期 5 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes `@objectstack/scenarios` (`packages/qa/scenarios`) exported `.ts` sources only. A consumer that links the framework tree and bundles its own entry with tsup leaves the dependency external, so plain Node then loads `src/score/index.ts`, whose `.js` specifiers only a TypeScript resolver follows: `ERR_MODULE_NOT_FOUND`, ESM and CJS alike (measured by the cloud#3604 dev; reproduced here, see the ablation below). 1. **A minimal build, still private and unpublished.** `tsup.config.ts` builds one entry per export subpath to `dist/` (ESM `.js`, CJS `.cjs`, `.d.ts` / `.d.cts`, `splitting: false`, dependencies external), using `tsconfig.build.json` (`rootDir: src`). `build` ends with `check-dts-emitted.mjs` (8/8 declarations present). Every `exports` subpath (`.`, `./score`, `./score/full`, `./runner`) now has `import` / `require` conditions on the built files, wired the way `packages/lint` is (a `type: module` multi-entry tsup package; no private package under `packages/` had a tsup build to copy). The suites and `run-scenarios` (`tsx src/runner/cli.ts`) import `src/` relatively and are untouched; no in-repo package imports `@objectstack/scenarios` by specifier. 2. **A narrow `./score` entry.** `src/score/narrow.ts` re-exports `scenario-graph`, `scenario-naming`, `scenario-operation-grade`, `scenario-run-grade` and `scenario-score` (so `scoreGraphShape`, `artifactToEvalGraph`, `gradeRun`, `gradePersonaRun`, `gradeOperation`, …). Its import graph reads `@objectstack/core` and `@objectstack/spec` only. The previous whole entry, `src/score/index.ts`, is unchanged code and is now `./score/full`: it adds the capability-tag checks (`scenario-tags.ts`, which read `@objectstack/objectql` and `@objectstack/plugin-sharing`) and the card-level `scoreCardArtifact` / `gradeCardOperation` / `gradeCardAnswer`. No score module moved, so #30883 (editing score behaviour in `src/score/**`) does not collide. 3. **Wiring.** `turbo.json`: `@objectstack/scenarios#test` depends on `build` (was `^build`), so the package's own `dist/` exists before its suite runs; `turbo run test --filter=@objectstack/scenarios --dry=json` lists `@objectstack/scenarios#build` as the test's dependency. Any `turbo run build` / `pnpm build` (the root script) now builds the package, which is how a pinned framework tree is built. `docker/Dockerfile` in this repo installs published packages and builds no workspace package, so it needs nothing. READMEs (`packages/qa/scenarios/README.md`, `docs/qa/scenarios/README.md`) and the `index.ts` header name the two entries. ## Tests - `test/build/built-consumer.test.ts`: a throwaway package that depends on `@objectstack/scenarios`, imports `scoreGraphShape` statically and `import()`s `@objectstack/scenarios/score`, is built with the repo's tsup (`--format esm,cjs`). The test asserts the bundle keeps the specifier external and does not carry the scorer, then loads `dist/index.js` and `dist/index.cjs` with plain `node`. A preloaded `module.registerHooks` resolve recorder lists every URL the child resolves: the scorer loads from `packages/qa/scenarios/dist/`, nothing from `src/`, nothing matching `objectql` or `plugin-sharing`, and (control) `@objectstack/core` is seen. - `test/build/narrow-score-graph.test.ts`: walks the source import graph from `src/score/narrow.ts` (type-only imports included) and asserts no `@objectstack/objectql` / `@objectstack/plugin-sharing` specifier, and the exact `@objectstack/*` set it reads. Control: the same walker from `src/score/index.ts` finds both. ## Acceptance notes - **Surface move inside a private package.** `./score` no longer exports `scoreCardArtifact`, the `scenario-tags` exports, `gradeCardOperation` or `gradeCardAnswer`; they are under `./score/full`. The card's acceptance requires `./score`'s graph to exclude objectql and plugin-sharing, and the two card-level graders live in `index.ts` beside `scoreCardArtifact`, so keeping them in `./score` would have meant moving code out of `index.ts`, which the claim's serial constraint with #30883 rules out. Its only out-of-repo consumer, cloud#3604, reads `scoreGraphShape` and the run grader, both in `./score`. - **Changeset.** `skip-changeset`: the package is private (the dispatch order's fast lane). Measured: the Check Changeset job (`.github/workflows/pr-automation.yml`) exempts on the label alone and has no private-package branch, so nothing there asks a private package with a build for a changeset. Earlier PRs on this package did carry `'@objectstack/scenarios': patch` changesets (`.changeset/30289-…`, `30291-…`, `30292-…`); `.changeset/config.json` has `privatePackages: { version: true, tag: false }`, so a changeset would only bump the private version. - **Twin declarations.** tsup emits a `.d.ts` and a `.d.cts` per entry, the simple `packages/lint` pattern, not `packages/core`'s single-declaration pass. The runner exports classes, but no consumer reaches this package through both resolution modes today. ## Verification All at `e748dfb0a0`, the final head (`origin/main` `26c965cb8f` merged, which brought #30846's loader change). - `pnpm --filter @objectstack/scenarios build`: exit 0, `check-dts-emitted: @objectstack/scenarios - 8/8 declared declaration file(s) present.` - `pnpm --filter @objectstack/scenarios test`: `Test Files 20 passed (20)`, `Tests 256 passed (256)`. `typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (exit 0) derived 65 commands; all 65 ran, every one exit 0. `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET, no dist for 61 packages) and exited 0 after a whole-tree `turbo run build` (119/119 tasks). `dispatch-gates.mjs --ran`: exit 0, "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED". <details><summary>Ablation 1: the pre-fix export (`./score` import condition back on src/score/index.ts)</summary> `node scripts/ablation-replace.mjs --file packages/qa/scenarios/package.json --anchor '"default": "./dist/score.js"' --replacement '"default": "./src/score/index.ts"' -- … vitest run test/build/built-consumer.test.ts`: mutation landed (anchor 1 → 0, blob 144f294 → 0d0da83a). Both plain-Node legs red with the card's error, `Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/packages/qa/scenarios/src/schema.js' imported from …/src/score/index.ts`. Restored: blob == HEAD, `git diff HEAD` empty. </details> <details><summary>Ablation 2: the narrow entry re-exports scenario-tags</summary> `ablation-replace.mjs --file packages/qa/scenarios/src/score/narrow.ts --anchor "export * from './scenario-graph.js';" --replacement "export { artifactToEvalGraph } from './scenario-graph.js';\nexport * from './scenario-tags.js';"` then `pnpm build`: `ablation-dist-preflight.mjs @objectstack/scenarios "@objectstack/plugin-sharing"` → marker live in dist. `vitest run test/build`: 3 failed | 2 passed (the source-graph leg and both plain-Node legs red; the full-entry control and the external-bundle check stay green). Restored: blob == HEAD, `git diff HEAD` empty; rebuilt, `grep -c 'plugin-sharing\|objectql'` on `dist/score.js`, `dist/score.cjs`, `dist/score.d.ts` = 0 each, and `test/build` 5/5 green. </details> <details><summary>65 gates, exit codes at e748dfb</summary> ```text node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-console-intercept-disarm.mjs :: exit 0 node scripts/check-console-intercept-disarm.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-osv-exemptions.mjs :: exit 0 node scripts/check-osv-exemptions.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/osv-base-relative.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:manifest-repository-directory :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:override-consistency :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:turbo-task-graph :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-export-contract-resolve :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:workspace-manifest-cycles :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cwgUKpQgCE3UWsA74d --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cwgUKpQgCE3UWsA74d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af1d59b commit 6910797

10 files changed

Lines changed: 358 additions & 16 deletions

File tree

‎docs/qa/scenarios/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,6 @@ meet this floor:
165165
## Running the cards
166166

167167
`pnpm --filter @objectstack/scenarios run-scenarios` builds, runs and iterates every card here with a local
168-
Claude Code session, and writes one run record per card. Grading uses `@objectstack/scenarios/score`; no
168+
Claude Code session, and writes one run record per card. Grading uses `@objectstack/scenarios/score/full`; no
169169
API-billed model is used. The runner reads a card's names relative to the card's namespace
170170
(`account` → `democrm_account`), and a build that names any item without the prefix fails its Build half. Flags and modes: [the package README](../../../packages/qa/scenarios/README.md#running-the-cards--objectstackscenariosrunner).

‎packages/qa/scenarios/README.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,28 @@ for (const r of refusals) console.error(formatRefusal(r)); // FILE: FIELD.PATH:
1818
seed does not determine.
1919
- `src/loader.ts` — reads a directory of cards, validates shape and cross-references, and
2020
returns typed cards or located refusals.
21-
- `src/score/` — the deterministic scorer, entry `@objectstack/scenarios/score` (below).
21+
- `src/score/` — the deterministic scorer, entries `@objectstack/scenarios/score` and
22+
`@objectstack/scenarios/score/full` (below).
2223

23-
No build step (`exports` points at `src`), and nothing here calls a model or the network.
24+
Not published. `pnpm --filter @objectstack/scenarios build` (tsup) writes `dist/` as ESM, CJS and
25+
`.d.ts`, and every `exports` subpath points there, so a consumer that links the framework tree
26+
and bundles its own entry can load the scorer under plain Node. The suites and `run-scenarios`
27+
read `src/` directly. Nothing here calls a model or the network.
2428

2529
## Scoring a run against a card — `@objectstack/scenarios/score`
2630

27-
Every function takes a card the loader returned, so it judges against the loader's computed
28-
truths and declared outcomes:
31+
`@objectstack/scenarios/score` is the narrow entry (`src/score/narrow.ts`): the graph-shape
32+
scorer (`scoreGraphShape`, `artifactToEvalGraph`), the item-name check and the run and
33+
operation graders. Its import graph reads `@objectstack/core` and `@objectstack/spec` only
34+
(pinned by `test/build/`). `@objectstack/scenarios/score/full` (`src/score/index.ts`) adds the
35+
capability-tag checks, which read `@objectstack/objectql` and `@objectstack/plugin-sharing`, and
36+
the card-level functions below.
37+
38+
Every card-level function takes a card the loader returned, so it judges against the loader's
39+
computed truths and declared outcomes:
2940

3041
```ts
31-
import { gradeCardAnswer, gradeCardOperation, scoreCardArtifact } from '@objectstack/scenarios/score';
42+
import { gradeCardAnswer, gradeCardOperation, scoreCardArtifact } from '@objectstack/scenarios/score/full';
3243

3344
scoreCardArtifact(card, artifact); // a built dist/objectstack.json: expect, tags, agent actions
3445
gradeCardOperation(card, 'remove_ben', observed); // an observed write against its declared outcome

‎packages/qa/scenarios/package.json‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,64 @@
33
"version": "0.1.0",
44
"private": true,
55
"license": "Apache-2.0",
6-
"description": "Scenario-card schema and loader for the open scenario library under docs/qa/scenarios/. A card states an authoring prompt, the structural expectation a generated app must meet, seed rows, data questions whose truth the loader COMPUTES from a declarative spec over those rows, personas, write operations with their guardrail checks, and one iteration request \u2014 plus the deterministic scorer that judges a built artifact, an observed write or an agent's answer against a loaded card (`@objectstack/scenarios/score`). Not published, no build step, no model calls: a devx instrument, not a protocol (packages/spec carries no obligation for this format).",
6+
"description": "Scenario-card schema and loader for the open scenario library under docs/qa/scenarios/. A card states an authoring prompt, the structural expectation a generated app must meet, seed rows, data questions whose truth the loader COMPUTES from a declarative spec over those rows, personas, write operations with their guardrail checks, and one iteration request \u2014 plus the deterministic scorer that judges a built artifact, an observed write or an agent's answer against a loaded card (`@objectstack/scenarios/score`). Not published (a minimal tsup build to `dist/` so a linked consumer's bundled entry can load it under plain Node; `./score` is the narrow scorer, `./score/full` adds the capability-tag checks), no model calls: a devx instrument, not a protocol (packages/spec carries no obligation for this format).",
77
"type": "module",
8+
"main": "dist/index.js",
9+
"types": "dist/index.d.ts",
810
"exports": {
9-
".": "./src/index.ts",
10-
"./score": "./src/score/index.ts",
11-
"./runner": "./src/runner/index.ts",
12-
"./export": "./src/export/index.ts"
11+
".": {
12+
"import": {
13+
"types": "./dist/index.d.ts",
14+
"default": "./dist/index.js"
15+
},
16+
"require": {
17+
"types": "./dist/index.d.cts",
18+
"default": "./dist/index.cjs"
19+
}
20+
},
21+
"./score": {
22+
"import": {
23+
"types": "./dist/score.d.ts",
24+
"default": "./dist/score.js"
25+
},
26+
"require": {
27+
"types": "./dist/score.d.cts",
28+
"default": "./dist/score.cjs"
29+
}
30+
},
31+
"./score/full": {
32+
"import": {
33+
"types": "./dist/score-full.d.ts",
34+
"default": "./dist/score-full.js"
35+
},
36+
"require": {
37+
"types": "./dist/score-full.d.cts",
38+
"default": "./dist/score-full.cjs"
39+
}
40+
},
41+
"./runner": {
42+
"import": {
43+
"types": "./dist/runner.d.ts",
44+
"default": "./dist/runner.js"
45+
},
46+
"require": {
47+
"types": "./dist/runner.d.cts",
48+
"default": "./dist/runner.cjs"
49+
}
50+
},
51+
"./export": {
52+
"import": {
53+
"types": "./dist/export.d.ts",
54+
"default": "./dist/export.js"
55+
},
56+
"require": {
57+
"types": "./dist/export.d.cts",
58+
"default": "./dist/export.cjs"
59+
}
60+
}
1361
},
1462
"scripts": {
63+
"build": "tsup && node ../../../scripts/check-dts-emitted.mjs",
1564
"test": "vitest run",
1665
"typecheck": "tsc --noEmit",
1766
"run-scenarios": "tsx src/runner/cli.ts",

‎packages/qa/scenarios/src/score/index.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* `@objectstack/scenarios/score` — score a LOADED card against what a run
5-
* produced: a built artifact (`dist/objectstack.json`), an observed write, or
6-
* an agent's answer. The entry the scenario runner calls; private, like the
7-
* rest of this package.
4+
* `@objectstack/scenarios/score/full` — score a LOADED card against what a
5+
* run produced: a built artifact (`dist/objectstack.json`), an observed write,
6+
* or an agent's answer. The entry the scenario runner calls; private, like the
7+
* rest of this package. The narrow `@objectstack/scenarios/score` is
8+
* `./narrow.ts`: everything here except the capability-tag checks and the
9+
* card-level functions below.
810
*
911
* Every function takes a {@link LoadedScenario} (what `loadScenarios` /
1012
* `loadScenarioFile` return), so the card it judges against has already passed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `@objectstack/scenarios/score` — the NARROW scorer entry (#30915): the
5+
* graph-shape scorer (`scoreGraphShape`, `scoreGraph`, `artifactToEvalGraph`),
6+
* the item-name check, the operation grader and the run graders
7+
* (`gradeRun`, `gradePersonaRun`).
8+
*
9+
* Its import graph reaches `@objectstack/core` and `@objectstack/spec` and no
10+
* other runtime package. The capability-tag checks (`scenario-tags.ts`) read
11+
* `@objectstack/objectql` and `@objectstack/plugin-sharing`, so they and the
12+
* card-level wrappers beside them (`scoreCardArtifact`, `gradeCardOperation`,
13+
* `gradeCardAnswer`) are `@objectstack/scenarios/score/full`
14+
* (`./index.ts`), which re-exports everything here as well.
15+
*
16+
* Re-exports only: the modules stay where they are. Pinned by
17+
* test/build/narrow-score-graph.test.ts.
18+
*/
19+
20+
export * from './scenario-graph.js';
21+
export * from './scenario-naming.js';
22+
export * from './scenario-operation-grade.js';
23+
export * from './scenario-run-grade.js';
24+
export * from './scenario-score.js';
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A BUILT consumer of `@objectstack/scenarios/score` (#30915): a throwaway
5+
* package that depends on this one, bundles its own entry with tsup (which
6+
* leaves a `dependencies` entry external), and is then loaded by plain Node,
7+
* the way a linked framework tree's consumer loads it at boot. Before the
8+
* package had a build step its exports named `.ts` files whose `.js`
9+
* specifiers only a TypeScript resolver follows, so this load failed with
10+
* ERR_MODULE_NOT_FOUND under both module kinds.
11+
*
12+
* The child process records every module it resolves (`module.registerHooks`),
13+
* so the narrow entry's runtime graph is read, not inferred: no
14+
* `@objectstack/objectql` or `@objectstack/plugin-sharing` file loads.
15+
*
16+
* Reads `dist/`: turbo runs this package's own `build` before its `test`.
17+
*/
18+
19+
import { execFileSync } from 'node:child_process';
20+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
21+
import { createRequire } from 'node:module';
22+
import { tmpdir } from 'node:os';
23+
import { dirname, join, resolve } from 'node:path';
24+
import { fileURLToPath, pathToFileURL } from 'node:url';
25+
26+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
27+
28+
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
29+
const REPO = resolve(PKG, '../../..');
30+
// The JS is what plain Node loads, so it is required. The declarations are
31+
// not built under `OS_SKIP_DTS` (CI's build sets it); when they are built,
32+
// the package's own `build` script already fails without them
33+
// (`check-dts-emitted.mjs`), so this test does not ask for them.
34+
const BUILT = ['score.js', 'score.cjs'].map((f) => join(PKG, 'dist', f));
35+
36+
const TSUP_PKG = createRequire(join(REPO, 'package.json')).resolve('tsup/package.json');
37+
const TSUP_BIN = join(dirname(TSUP_PKG), (JSON.parse(readFileSync(TSUP_PKG, 'utf8')) as { bin: { tsup: string } }).bin.tsup);
38+
39+
const ENTRY = `
40+
import { scoreGraphShape } from '@objectstack/scenarios/score';
41+
42+
export const shape = () =>
43+
scoreGraphShape({ objects: [{ name: 'loan', fields: [{ name: 'title', type: 'text' }] }] }, { minObjects: 1 });
44+
45+
export const loadScore = () => import('@objectstack/scenarios/score');
46+
`;
47+
48+
// Preloaded into the child: every URL Node resolves, written to the file
49+
// named by SCENARIOS_RESOLVE_RECORD. A FILE, written synchronously: the record
50+
// is ~140 KB, larger than a pipe's buffer, and a stdout write from an `exit`
51+
// handler is not flushed past what the pipe takes before the process ends
52+
// (a truncated record on a loaded CI runner).
53+
const RECORDER = `
54+
const { writeFileSync } = require('node:fs');
55+
const { registerHooks } = require('node:module');
56+
const seen = [];
57+
registerHooks({ resolve(specifier, context, next) { const r = next(specifier, context); seen.push(r.url); return r; } });
58+
process.on('exit', () => writeFileSync(process.env.SCENARIOS_RESOLVE_RECORD, JSON.stringify(seen)));
59+
`;
60+
61+
let fixture = '';
62+
63+
function runNode(code: string, kind: 'module' | 'commonjs'): { out: Record<string, unknown>; resolved: string[] } {
64+
const record = join(fixture, `resolved-${kind}.json`);
65+
rmSync(record, { force: true });
66+
const stdout = execFileSync(process.execPath, ['--require', join(fixture, 'recorder.cjs'), `--input-type=${kind}`, '-e', code], {
67+
cwd: fixture,
68+
encoding: 'utf8',
69+
env: { ...process.env, SCENARIOS_RESOLVE_RECORD: record },
70+
});
71+
return { out: JSON.parse(stdout.trim()) as Record<string, unknown>, resolved: JSON.parse(readFileSync(record, 'utf8')) as string[] };
72+
}
73+
74+
const probe = (mod: string) => `
75+
const m = ${mod};
76+
const s = await m.loadScore();
77+
const r = m.shape();
78+
console.log(JSON.stringify({ shape: typeof s.scoreGraphShape, run: typeof s.gradePersonaRun, tags: typeof s.scoreScenarioArtifact, score: r.score, total: r.total }));
79+
`;
80+
81+
describe('a tsup-built consumer of @objectstack/scenarios/score', () => {
82+
beforeAll(() => {
83+
const missing = BUILT.filter((f) => !existsSync(f));
84+
if (missing.length > 0) throw new Error(`build @objectstack/scenarios first (pnpm --filter @objectstack/scenarios build): missing ${missing.join(', ')}`);
85+
86+
fixture = mkdtempSync(join(tmpdir(), 'scenarios-built-consumer-'));
87+
writeFileSync(
88+
join(fixture, 'package.json'),
89+
JSON.stringify({ name: 'scenarios-built-consumer', private: true, type: 'module', dependencies: { '@objectstack/scenarios': 'workspace:*' } }),
90+
);
91+
mkdirSync(join(fixture, 'src'));
92+
writeFileSync(join(fixture, 'src/index.ts'), ENTRY);
93+
writeFileSync(join(fixture, 'recorder.cjs'), RECORDER);
94+
mkdirSync(join(fixture, 'node_modules/@objectstack'), { recursive: true });
95+
symlinkSync(PKG, join(fixture, 'node_modules/@objectstack/scenarios'), 'dir');
96+
97+
execFileSync(process.execPath, [TSUP_BIN, 'src/index.ts', '--format', 'esm,cjs', '--target', 'es2020', '--silent'], {
98+
cwd: fixture,
99+
stdio: 'pipe',
100+
});
101+
}, 120_000);
102+
103+
afterAll(() => {
104+
if (fixture) rmSync(fixture, { recursive: true, force: true });
105+
});
106+
107+
it('leaves the scorer external: the bundle imports it, it does not carry it', () => {
108+
for (const out of ['dist/index.js', 'dist/index.cjs']) {
109+
const text = readFileSync(join(fixture, out), 'utf8');
110+
expect(text, out).toContain('@objectstack/scenarios/score');
111+
expect(text, out).not.toContain('PLATFORM_OBJECT_PREFIX');
112+
}
113+
});
114+
115+
it.each([
116+
['ESM', 'module', `await import(${JSON.stringify('PLACEHOLDER')})`, 'dist/index.js'],
117+
['CJS', 'commonjs', `require(${JSON.stringify('PLACEHOLDER')})`, 'dist/index.cjs'],
118+
] as const)('loads under plain Node (%s) and reaches neither objectql nor plugin-sharing', (_kind, inputType, load, file) => {
119+
const target = inputType === 'module' ? pathToFileURL(join(fixture, file)).href : join(fixture, file);
120+
const body = probe(load.replace('PLACEHOLDER', target));
121+
const { out, resolved } = runNode(inputType === 'module' ? body : `(async () => { ${body} })()`, inputType);
122+
123+
expect(out).toEqual({ shape: 'function', run: 'function', tags: 'undefined', score: 1, total: 1 });
124+
const scorer = resolved.filter((u) => u.includes('/qa/scenarios/dist/'));
125+
expect(scorer.length, 'the scorer is loaded from its dist').toBeGreaterThan(0);
126+
expect(resolved.filter((u) => u.includes('/qa/scenarios/src/'))).toEqual([]);
127+
expect(resolved.filter((u) => /objectql|plugin-sharing/.test(u))).toEqual([]);
128+
// Control: the recorder sees the scorer's own runtime imports.
129+
expect(resolved.some((u) => u.includes('/packages/core/'))).toBe(true);
130+
});
131+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The narrow scorer entry's SOURCE import graph (#30915): everything
5+
* `src/score/narrow.ts` (`@objectstack/scenarios/score`) reaches through
6+
* relative imports, type-only ones included, names no `@objectstack/objectql`
7+
* or `@objectstack/plugin-sharing` specifier. Read from the checkout, so it
8+
* holds without a build; test/build/built-consumer.test.ts reads the same
9+
* fact off the built entry at runtime.
10+
*
11+
* The control leg walks the full entry (`src/score/index.ts`, `./score/full`)
12+
* with the same walker and must find both packages: a walker that sees no
13+
* import at all would pass the narrow leg too.
14+
*/
15+
16+
import { readFileSync } from 'node:fs';
17+
import { dirname, resolve } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
20+
import { describe, expect, it } from 'vitest';
21+
22+
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '../../src');
23+
const FORBIDDEN = ['@objectstack/objectql', '@objectstack/plugin-sharing'];
24+
25+
// `import … from 'x'`, `export … from 'x'`, `import 'x'`, `import('x')`.
26+
const SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g;
27+
28+
/** Bare specifiers reached from `entry`, each with the first file that imports it. */
29+
function bareImports(entry: string): Map<string, string> {
30+
const bare = new Map<string, string>();
31+
const seen = new Set<string>();
32+
const queue = [resolve(SRC, entry)];
33+
while (queue.length > 0) {
34+
const file = queue.shift()!;
35+
if (seen.has(file)) continue;
36+
seen.add(file);
37+
const text = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
38+
for (const [, spec] of text.matchAll(SPECIFIER)) {
39+
if (spec.startsWith('.')) queue.push(resolve(dirname(file), spec.replace(/\.js$/, '.ts')));
40+
else if (!bare.has(spec)) bare.set(spec, file.slice(SRC.length + 1));
41+
}
42+
}
43+
return bare;
44+
}
45+
46+
const reaches = (bare: Map<string, string>, pkg: string) =>
47+
[...bare.keys()].filter((s) => s === pkg || s.startsWith(`${pkg}/`));
48+
49+
describe('the ./score entry import graph', () => {
50+
it('reaches neither objectql nor plugin-sharing', () => {
51+
const bare = bareImports('score/narrow.ts');
52+
for (const pkg of FORBIDDEN) expect(reaches(bare, pkg), `${pkg} via ${bare.get(pkg)}`).toEqual([]);
53+
// What it does read: the core stack-collection rule and the spec.
54+
expect([...bare.keys()].filter((s) => s.startsWith('@objectstack/')).sort()).toEqual([
55+
'@objectstack/core',
56+
'@objectstack/spec/api',
57+
'@objectstack/spec/data',
58+
'@objectstack/spec/meta-spelling',
59+
]);
60+
});
61+
62+
it('control: the same walker finds both packages from ./score/full', () => {
63+
const bare = bareImports('score/index.ts');
64+
for (const pkg of FORBIDDEN) expect(reaches(bare, pkg)).toEqual([pkg]);
65+
});
66+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"extends": "../../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"rootDir": "./src",
6+
"types": [
7+
"node"
8+
]
9+
},
10+
"include": [
11+
"src/**/*"
12+
],
13+
"exclude": [
14+
"node_modules",
15+
"dist"
16+
]
17+
}

0 commit comments

Comments
 (0)