From ba4a876ccc7892f46abba2a516c7180dcf45ce2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:28:23 +0000 Subject: [PATCH 1/6] feat(create-objectstack): the blank starter ships a test story on @objectstack/verify The blank template gains a `test` script, @objectstack/verify and vitest devDependencies, a vitest.config.ts and ONE note.test.ts that boots the project's own stack with bootStack and asserts one business fact through the in-process handle. The Scaffold E2E workflow runs it after install. Ported from the held scaffold branch (812d8dbf94) without its superseded docs page; the template's vitest range is ~4.0.0, the shared scaffold policy's value, instead of the held branch's ^4.0.0. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/15952-blank-template-test-story.md | 17 ++++++ .github/workflows/scaffold-e2e.yml | 18 ++++++- .../src/templates/blank/README.md | 10 ++++ .../src/templates/blank/note.test.ts | 54 +++++++++++++++++++ .../src/templates/blank/package.json | 7 ++- .../src/templates/blank/tsconfig.json | 2 +- .../src/templates/blank/vitest.config.ts | 21 ++++++++ packages/create-objectstack/vitest.config.ts | 10 +++- 8 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 .changeset/15952-blank-template-test-story.md create mode 100644 packages/create-objectstack/src/templates/blank/note.test.ts create mode 100644 packages/create-objectstack/src/templates/blank/vitest.config.ts diff --git a/.changeset/15952-blank-template-test-story.md b/.changeset/15952-blank-template-test-story.md new file mode 100644 index 00000000000..9796971c5d3 --- /dev/null +++ b/.changeset/15952-blank-template-test-story.md @@ -0,0 +1,17 @@ +--- +'create-objectstack': minor +--- + +The blank starter ships a test story: `pnpm test` boots the scaffolded app's own stack with `bootStack` from `@objectstack/verify` and asserts one business fact through its in-process handle + +Clause-②: no + +A project scaffolded with `npx create-objectstack` used to start with no `test` script and no way to assert its own behaviour, so the first test an author wrote was a hand-assembled kernel or a faked `ctx`. The blank template now carries: + +- **`note.test.ts`**, one starter test. It boots the app the project declares (its objects, its plugins, the services it requires) on the real engine, in-process, with an in-memory database and no server, and asserts that a note without a title is refused with the engine's own `VALIDATION_FAILED` error naming `title`, beside the same note with a title being saved. It addresses the object as `Note.name`, so it follows the namespace scaffolding writes. +- **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log (138 of the 341 lines the starter test printed). +- **`package.json`**: a `test` script (`vitest run`), and `@objectstack/verify` (at the project's `@objectstack/*` range) and `vitest` as devDependencies. The vitest range is `~4.0.0`, the one `objectstack init -t plugin` and `objectstack create plugin` declare, and it is generated from that shared policy rather than written by hand, so the three scaffolders cannot drift apart. `~4.0.0` is the range that installs on npm 10, the npm Node 22 bundles; `^4.0.0` does not. +- **`tsconfig.json`** includes every root `*.ts` (`"*.ts"`, as `objectstack init` writes it), so `pnpm typecheck` checks the starter test and the vitest config. +- **`README.md`** lists `pnpm test` beside `validate`, `lint` and `typecheck`. + +The project's CI workflow is unchanged: its steps are the script set every scaffolder's project must declare, and `objectstack init -t app` declares no `test` script. diff --git a/.github/workflows/scaffold-e2e.yml b/.github/workflows/scaffold-e2e.yml index 1097bade31d..623d89da06d 100644 --- a/.github/workflows/scaffold-e2e.yml +++ b/.github/workflows/scaffold-e2e.yml @@ -9,7 +9,7 @@ # # Job 1 (PRs touching the scaffolder / docker example) scaffolds with the # repo-built scaffolder and runs the generated project against the registry: -# install → validate → build → boot → health probes → docker build/run. That +# install → validate → build → test → boot → health probes → docker build/run. That # run scaffolds with `--skip-install` and installs the project itself, so it # exercises the scaffolder's `--skip-install` shape; a second, throwaway # project is scaffolded WITHOUT it purely to assert the shape a real user @@ -148,6 +148,16 @@ jobs: npm run validate npm run build + # The starter's own test (`note.test.ts`): it boots the generated + # project's stack in-process through @objectstack/verify, as installed + # from the registry, and asserts one business fact on the real engine. + # A scaffold whose `test` script is red out of the box teaches its user + # to ignore tests, so the starter ships it green or not at all. + - name: Test the generated project + run: | + cd "$RUNNER_TEMP/e2e-app" + npm test + # Two questions this step must answer about its own server, and used to # answer neither (#9779). Both belong here rather than in the CLI: `os # start` is already correct — the shell around it was not. @@ -486,7 +496,8 @@ jobs: # `build` is the gate every template must pass; `validate` is run only # when the scaffolded project defines it (the now-retired remote - # templates did not always ship it). + # templates did not always ship it), and `test` likewise: a published + # scaffolder older than the starter's `note.test.ts` declares none. - name: Gate the generated project run: | cd "$RUNNER_TEMP/canary-app" @@ -494,6 +505,9 @@ jobs: npm run validate fi npm run build + if node -e "process.exit(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts?.test ? 0 : 1)"; then + npm test + fi # Same two guards, same reasoning, as `Boot from the artifact and probe # health` in scaffold-local — see the measurement written out there. Kept diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 3f248a03293..a2cacfa1321 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -74,6 +74,8 @@ for OAuth, API keys, and which objects/actions become tools. stack, so `objectstack generate flow NAME` (or `view`, `action`, …) adds a file and one export line, and the item is loaded, and counted by `pnpm validate`, with no edit to the config +- `note.test.ts` — the starter test `pnpm test` runs, configured by + `vitest.config.ts` ## Connectors (default providers) @@ -112,6 +114,7 @@ pnpm validate # schema + CEL predicates + widget bindings (no artifact) pnpm lint # authoring rules validate does not run — e.g. a handler that # stopped lowering to a metadata-only body pnpm typecheck # TypeScript types against @objectstack/spec +pnpm test # your app's own tests, against the real engine ``` `pnpm validate` runs the same gates as `pnpm build` and catches mistakes that @@ -119,6 +122,13 @@ otherwise fail *silently at runtime* — e.g. a bare `done` (instead of `record.done`) in an action predicate that would hide the action on every record. See `AGENTS.md` for the full convention. +`pnpm test` checks what the app *does*. `note.test.ts` boots this app's stack +in-process with `bootStack` from `@objectstack/verify` (in-memory database, no +server) and asserts one business fact through its handle: a note without a +title is refused. Write your own the same way: `stack.hooks.run` makes a real +write through validation, permissions and hooks, and the same handle runs flows +and actions, seeds fixtures and reads rows as a given user. + ## Deploy The project ships container-ready — the `Dockerfile` builds your metadata into diff --git a/packages/create-objectstack/src/templates/blank/note.test.ts b/packages/create-objectstack/src/templates/blank/note.test.ts new file mode 100644 index 00000000000..e801ca9f510 --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/note.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from './objectstack.config'; +import { Note } from './src/objects'; + +// Boots THIS app (its objects, its plugins, the services it requires) on the +// real ObjectStack engine, in this process, with an in-memory database and no +// server port, then asserts on what the engine does. Nothing is mocked: a +// write through `stack.hooks.run` takes the same path as a write over the REST +// API, through validation, the permission checks and every hook the app binds. +// +// The same handle runs a flow (`stack.flows.run`), an action +// (`stack.actions.run`), a validation pass without writing (`stack.validate`), +// seeds fixtures (`stack.seed`) and reads rows as a given user (`stack.rows`). +// The README of `@objectstack/verify` (in node_modules) documents each one. +// +// The object is addressed as `Note.name`, never as a typed-out string: +// scaffolding renamed it to carry this project's namespace, and adding your +// own objects works the same way. +describe('notes', () => { + let stack: VerifyStack; + let author: string; + + beforeAll(async () => { + stack = await bootStack(config); + // The development admin the stack seeds: a bearer token to act as. + author = await stack.signIn(); + }); + + afterAll(async () => { + await stack?.stop(); + }); + + it('cannot be saved without a title', async () => { + // `title` is declared `required` in src/objects/note.object.ts, and the + // engine refuses the write with its own error, naming the field. + await expect( + stack.hooks.run(Note.name, 'insert', { body: 'Agenda for Monday' }, { as: author }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: [{ field: 'title', code: 'required' }], + }); + + // The same note with a title is saved, so the refusal above was the + // missing title and nothing else. + const note = await stack.hooks.run( + Note.name, + 'insert', + { title: 'Weekly sync', body: 'Agenda for Monday' }, + { as: author }, + ); + expect(note.title).toBe('Weekly sync'); + }); +}); diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 48dca9cd5c2..74bf16a22de 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -12,7 +12,8 @@ "build": "objectstack build", "validate": "objectstack validate", "lint": "objectstack lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@objectstack/spec": "^17.0.0", @@ -24,6 +25,8 @@ }, "devDependencies": { "@objectstack/cli": "^17.0.0", - "typescript": "^5.3.0" + "@objectstack/verify": "^17.0.0", + "typescript": "^5.3.0", + "vitest": "~4.0.0" } } diff --git a/packages/create-objectstack/src/templates/blank/tsconfig.json b/packages/create-objectstack/src/templates/blank/tsconfig.json index 7c5f22bfb66..f529ef6f765 100644 --- a/packages/create-objectstack/src/templates/blank/tsconfig.json +++ b/packages/create-objectstack/src/templates/blank/tsconfig.json @@ -11,5 +11,5 @@ "noEmit": true, "isolatedModules": true }, - "include": ["objectstack.config.ts", "src/**/*"] + "include": ["*.ts", "src/**/*"] } diff --git a/packages/create-objectstack/src/templates/blank/vitest.config.ts b/packages/create-objectstack/src/templates/blank/vitest.config.ts new file mode 100644 index 00000000000..21573f46e0d --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +// `pnpm test` runs every `*.test.ts` in this project. A test that boots the +// app (see note.test.ts) runs the real engine in this process, so two settings +// differ from vitest's defaults. +export default defineConfig({ + test: { + // Booting the stack takes a few seconds, longer on a busy CI runner, and + // the boot runs in a `beforeAll` hook: allow it more than the default 10s. + hookTimeout: 60_000, + // The stack logs while it boots and while it shuts down. A log line that + // arrives while vitest is closing a test worker can fail a run in which + // every test passed. Printing logs straight to the terminal, instead of + // through vitest's console capture, removes that failure. + disableConsoleIntercept: true, + // At its default level the engine logs one `[Registry]` line for every + // item it registers while booting, over a hundred for this starter alone. + // `warn` keeps the engine's warnings and errors and drops that chatter. + env: { OS_REGISTRY_LOG: 'warn' }, + }, +}); diff --git a/packages/create-objectstack/vitest.config.ts b/packages/create-objectstack/vitest.config.ts index 93fc82cc1c0..d7ace906a70 100644 --- a/packages/create-objectstack/vitest.config.ts +++ b/packages/create-objectstack/vitest.config.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -29,6 +29,14 @@ export default defineConfig({ // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, include: ['test/**/*.test.ts', 'src/**/*.test.ts'], + // `src/templates/` is PAYLOAD: copied byte-for-byte into a scaffolded + // project and never compiled here (tsconfig.json excludes it too). The + // blank template ships its own `note.test.ts`, which boots THAT project's + // stack through `@objectstack/verify` — a dependency of the scaffolded + // project, not of this package. It runs where it lands: the Scaffold E2E + // workflow scaffolds a project, installs it from the registry and runs + // its `test` script. + exclude: [...configDefaults.exclude, 'src/templates/**'], environment: 'node', }, }); From b61ab0e5ed362167529fdbd5a15cb6fe52a602d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:32:50 +0000 Subject: [PATCH 2/6] fix(scripts): two test gates read the tsconfig-exclude payload boundary check-registry-log-declared and check-test-source-alias walked every test file under a package directory, so the blank template's starter test, payload create-objectstack copies into a scaffolded project and never runs, reddened both: one demanded OS_REGISTRY_LOG in a config that boots nothing, the other six source aliases for imports no run loads. Both now read the boundary check-undeclared-dep-imports already reads, imported from there (tsconfigExcludedDirs): a directory the package's own tsconfig.json excludes, as a literal entry inside src/, is payload and not a test source. A glob such as **/*.test.ts is not payload. One self-test case each, with a control for the glob shape. The registry-log green line names the honoured payload directories. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- scripts/check-registry-log-declared.mjs | 95 ++++++++++++++++++++++--- scripts/check-test-source-alias.mjs | 61 +++++++++++++++- 2 files changed, 146 insertions(+), 10 deletions(-) diff --git a/scripts/check-registry-log-declared.mjs b/scripts/check-registry-log-declared.mjs index ba7d5b71604..436c6164181 100644 --- a/scripts/check-registry-log-declared.mjs +++ b/scripts/check-registry-log-declared.mjs @@ -58,6 +58,19 @@ // fixtures boot stacks too — `packages/qa/dogfood/test/shared-showcase.ts` is // the memoized boot the whole shared-showcase project runs on). // +// ⭐ A directory the package's OWN `tsconfig.json` excludes is PAYLOAD, not a +// source of that package, and is not read at all. The payload boundary is the +// one `check-undeclared-dep-imports.mjs` already reads, imported from there +// (`tsconfigExcludedDirs`: a literal `exclude` entry inside `src/`, never a +// glob — so the `**/*.test.ts` most packages exclude is NOT payload). It is +// how `packages/create-objectstack/src/templates/**` is judged: those files are +// copied verbatim into a scaffolded project, and the blank template's starter +// test boots THAT project's stack through `@objectstack/verify`, a dependency +// of the scaffolded project. `create-objectstack`'s own vitest excludes the +// directory too and never runs it, so a declaration demanded of this package +// would quiet nothing. The honoured payload directories are printed on the +// green line. +// // ⭐ COMMENT MASKING IS WHAT MAKES THE PREDICATE HONEST, and it is not a // precaution: measured on this tree, `packages/cli` names `@objectstack/verify` // and its `bootStack` in EIGHT places, every one of them a comment or docblock @@ -130,6 +143,7 @@ import { fileURLToPath } from 'node:url'; import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { workspacePackageDirs } from './check-console-intercept-disarm.mjs'; +import { tsconfigExcludedDirs } from './check-undeclared-dep-imports.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, '..'); @@ -243,10 +257,19 @@ function sourceFiles(dir, out = []) { /** * Which of S1/S2/S3 this package's own sources satisfy. * + * A file under a directory the package's own `tsconfig.json` excludes is + * payload (see the header) and is not one of its sources. + * + * @param {string[]} [payload] package-relative payload dirs; read from the + * package's tsconfig when omitted * @returns {string[]} the reasons, empty when the package is out of population. */ -export function bootSignals(dir) { - const files = sourceFiles(dir); +export function bootSignals(dir, payload = tsconfigExcludedDirs(dir)) { + const inPayload = (f) => { + const relFromPkg = f.slice(dir.length + 1).split(sep).join('/'); + return payload.some((p) => relFromPkg === p || relFromPkg.startsWith(`${p}/`)); + }; + const files = sourceFiles(dir).filter((f) => !inPayload(f)); const reasons = []; const isTest = (f) => { const rest = f.slice(dir.length).split(sep).join('/'); @@ -370,10 +393,14 @@ function describe(verdict, where, levels) { ); } -/** @returns {{findings: string[], vitestPackages: number, selected: Array<[string,string[]]>}} */ +/** + * @returns {{findings: string[], vitestPackages: number, selected: Array<[string,string[]]>, + * payload: string[]}} `payload` is every honoured payload dir, repo-relative + */ export function scan(root, levels = readRegistryLogLevels(root)) { const findings = []; const selected = []; + const payloadDirs = []; let vitestPackages = 0; for (const dir of workspacePackageDirs(root)) { let manifest; @@ -387,7 +414,9 @@ export function scan(root, levels = readRegistryLogLevels(root)) { if (!scripts.some((s) => typeof s === 'string' && VITEST_SCRIPT_RE.test(s))) continue; vitestPackages += 1; - const reasons = bootSignals(dir); + const payload = tsconfigExcludedDirs(dir); + for (const p of payload) payloadDirs.push(`${rel(root, dir)}/${p}`); + const reasons = bootSignals(dir, payload); if (reasons.length === 0) continue; const name = rel(root, dir); selected.push([name, reasons]); @@ -444,7 +473,7 @@ export function scan(root, levels = readRegistryLogLevels(root)) { ); } } - return { findings, vitestPackages, selected }; + return { findings, vitestPackages, selected, payload: payloadDirs }; } function rel(root, path) { @@ -461,7 +490,7 @@ function main() { console.error(`check-registry-log-declared: MEASUREMENT FAILED — ${error.message}`); process.exit(2); } - const { findings, vitestPackages, selected } = result; + const { findings, vitestPackages, selected, payload } = result; if (vitestPackages === 0) { console.error( 'check-registry-log-declared: MEASUREMENT FAILED — the scan found ZERO vitest-running ' @@ -492,6 +521,10 @@ function main() { + `(${levels.join('/')}).`, ); for (const [name, reasons] of selected) console.log(` ${name} — ${reasons.join('; ')}`); + console.log( + ` payload, not read (a dir the package's own tsconfig.json excludes): ` + + `${payload.length > 0 ? payload.join(', ') : 'none'}`, + ); } // ── self-test ─────────────────────────────────────────────────────────────── @@ -566,13 +599,14 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'projects config with only a ROOT env is RED (root is inert for projects)': 1, 'projects config declaring in every project passes': 1, 'a template-literal spelling of the key does not count': 1, + "a test under a dir the package's tsconfig excludes is payload and does not SELECT": 1, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. This pin is also half of // the duplicate-label refusal: two rows sharing a label collapse to ONE key in // the literal above, so the roster falls below this number. -const SELF_TEST_BATTERY_FLOOR = 16; +const SELF_TEST_BATTERY_FLOOR = 17; // The key an assertion is filed under when a row carries no label. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -799,6 +833,42 @@ function selfTest() { expectSelected: 1, expectText: 'declares no OS_REGISTRY_LOG', }, + { + // The create-objectstack shape: a template's starter test is payload. + // Package `a` excludes the directory that holds it, so it is SILENT. + // Package `b` carries the same file with no exclusion, so a predicate + // that stopped honouring the boundary selects a SECOND package rather + // than nothing changing. Package `c` excludes `**/*.test.ts`, the glob + // most packages here put in their tsconfig: a glob is not a payload + // directory, so a predicate that read it as one would lose `c` too. + name: "a test under a dir the package's tsconfig excludes is payload and does not SELECT", + packages: { + a: { + 'package.json': TEST_MANIFEST, + 'tsconfig.json': JSON.stringify({ exclude: ['src/templates'] }), + 'src/templates/blank/starter.test.ts': + `import { bootStack } from '@objectstack/verify';\nexport const s = bootStack;\n`, + 'vitest.config.ts': `export default { test: { globals: true } };\n`, + }, + b: { + 'package.json': TEST_MANIFEST, + 'src/templates/blank/starter.test.ts': + `import { bootStack } from '@objectstack/verify';\nexport const s = bootStack;\n`, + 'vitest.config.ts': `export default { test: { globals: true } };\n`, + }, + c: { + 'package.json': TEST_MANIFEST, + 'tsconfig.json': JSON.stringify({ exclude: ['node_modules', 'dist', '**/*.test.ts'] }), + 'src/x.test.ts': BOOTS, + 'vitest.config.ts': `export default { test: { globals: true } };\n`, + }, + }, + expectFindings: 2, + expectSelected: 2, + expectText: 'packages/b/vitest.config.ts declares no OS_REGISTRY_LOG', + expectSelectedNames: ['packages/b', 'packages/c'], + expectPayload: ['packages/a/src/templates'], + }, ]; // The ledger this self-test's floor is evaluated against (#13489). @@ -814,7 +884,7 @@ function selfTest() { const caseDir = mkdtempSync(join(tmpdir(), 'ci-reglog-')); try { buildWorkspace(caseDir, testCase.packages); - const { findings, vitestPackages, selected } = scan(caseDir, LEVELS); + const { findings, vitestPackages, selected, payload } = scan(caseDir, LEVELS); const problems = []; if (findings.length !== testCase.expectFindings) { problems.push(`expected ${testCase.expectFindings} finding(s), got ${findings.length}`); @@ -825,6 +895,15 @@ function selfTest() { if (testCase.expectText && !findings.some((f) => f.includes(testCase.expectText))) { problems.push(`no finding contains '${testCase.expectText}'`); } + if (testCase.expectSelectedNames !== undefined) { + const names = selected.map(([n]) => n).sort().join(', '); + if (names !== testCase.expectSelectedNames.join(', ')) { + problems.push(`expected selected [${testCase.expectSelectedNames.join(', ')}], got [${names}]`); + } + } + if (testCase.expectPayload !== undefined && payload.join(', ') !== testCase.expectPayload.join(', ')) { + problems.push(`expected payload [${testCase.expectPayload.join(', ')}], got [${payload.join(', ')}]`); + } if ( testCase.expectVitestPackages !== undefined && vitestPackages !== testCase.expectVitestPackages diff --git a/scripts/check-test-source-alias.mjs b/scripts/check-test-source-alias.mjs index e7475347403..50eadb58b18 100644 --- a/scripts/check-test-source-alias.mjs +++ b/scripts/check-test-source-alias.mjs @@ -294,6 +294,22 @@ // such a call never enters that measurement and is still not reported; // widening the population is a different card. // +// ── Payload: a dir the package's own tsconfig excludes holds no test ROOT ─── +// +// A test file under a directory the package's own `tsconfig.json` excludes is +// PAYLOAD, not a test of that package, and does not seed the walk. The boundary +// is the one `check-undeclared-dep-imports.mjs` already reads, imported from +// there (`tsconfigExcludedDirs`: a literal `exclude` entry inside `src/`, never +// a glob, so the `**/*.test.ts` most packages exclude is NOT payload). It is +// how `packages/create-objectstack/src/templates/**` is judged: those files are +// copied verbatim into a scaffolded project, and the blank template's starter +// test imports `@objectstack/verify`, a dependency of THAT project, which +// `create-objectstack`'s own vitest excludes and never runs. An alias for it in +// this package's config would resolve nothing any run loads. +// +// Only the walk's ROOTS move. A real test that imports a payload file still +// walks into it, because then vitest really loads it. +// // ── The registry, and why it is shaped like this ──────────────────────────── // // `KNOWN_UNALIASED_TEST_IMPORTS` is the measured state of the repo on the day @@ -339,6 +355,7 @@ import { selfTest as workspaceEnumeratorSelfTest, workspaceEnumeratorFloorFailures, } from './workspace-enumerator.mjs'; +import { tsconfigExcludedDirs } from './check-undeclared-dep-imports.mjs'; import { tmpdir } from 'node:os'; import process from 'node:process'; @@ -1279,7 +1296,14 @@ function resolveModulePath(baseDir, spec) { */ function testReachableWorkspaceImports(pkg, workspaceNames, crossInto) { const files = walkFiles(pkg.dir); - const tests = files.filter((f) => TEST_FILE.test(f)); + // Payload (see the header): a test under a dir the package's own tsconfig + // excludes is not one of its tests, so it is not a ROOT of the walk. + const payload = tsconfigExcludedDirs(pkg.dir); + const inPayload = (f) => { + const relFromPkg = relative(pkg.dir, f).split('\\').join('/'); + return payload.some((p) => relFromPkg === p || relFromPkg.startsWith(`${p}/`)); + }; + const tests = files.filter((f) => TEST_FILE.test(f) && !inPayload(f)); if (tests.length === 0) return null; const seen = new Set(); @@ -2238,6 +2262,24 @@ function buildFixtureTree() { 'src/thing.test.ts': "export default 1;\n", }); + // (6b) PAYLOAD: the create-objectstack shape. The package's own tsconfig + // excludes `src/templates`, so the starter test under it is not one of its + // tests and seeds no walk, although it imports an artifact with no alias. + fixture(root, 'packages/template-payload', { + 'package.json': ARTIFACT_MANIFEST('@fx/template-payload'), + 'tsconfig.json': JSON.stringify({ include: ['src'], exclude: ['src/templates'] }), + 'src/templates/blank/starter.test.ts': "import { alive } from '@fx/core';\nexport default alive;\n", + }); + + // (6c) …and its control: the same file, in a package whose tsconfig excludes + // only the `**/*.test.ts` GLOB most packages here carry. A glob is not a + // payload directory, so this one is still reported. + fixture(root, 'packages/glob-excluded-tests', { + 'package.json': ARTIFACT_MANIFEST('@fx/glob-excluded-tests'), + 'tsconfig.json': JSON.stringify({ exclude: ['node_modules', 'dist', '**/*.test.ts'] }), + 'src/templates/blank/starter.test.ts': "import { alive } from '@fx/core';\nexport default alive;\n", + }); + // ── (7) THE CANARY ──────────────────────────────────────────────────────── // // A byte-for-byte copy of the shape `plugin-audit` and `service-knowledge` @@ -2723,11 +2765,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the import clause is bounded to ONE statement (#12555)': 11, 'the declared population must stay READABLE by the dispatch deriver': 11, 'the declaration must still BE the workspace (#11510)': 22, + "the payload boundary: a dir the package's own tsconfig excludes holds no test": 2, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 9; +const SELF_TEST_BATTERY_FLOOR = 10; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -2767,6 +2810,20 @@ function selfTest() { expect(has(bare.failures, 'ENOTDIR'), 'the prefix/ENOTDIR alias trap was not detected'); expect(has(bare.failures, 'cannot be read statically'), 'a config with spread aliases was read as aliasing nothing'); + // ── the payload boundary ───────────────────────────────────────────── + // A test under a dir the package's own tsconfig excludes is payload (the + // create-objectstack template), and the control beside it carries the same + // file under a GLOB exclusion, which is not payload. + battery("the payload boundary: a dir the package's own tsconfig excludes holds no test"); + expect( + !has(bare.failures, 'packages/template-payload'), + 'a starter test under a tsconfig-excluded dir was walked as a test of the package that ships it', + ); + expect( + has(bare.failures, 'packages/glob-excluded-tests'), + 'a `**/*.test.ts` glob exclusion was read as a payload dir, which would drop the tests of most packages', + ); + // ── the remediation hint is MEASURED, not a template (#8256) ────────── // // The old text named the bare dependency and printed one anchored-BARE From ebb8b095c3b4ffdf020a6cbf90d16b8066d408ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:33:39 +0000 Subject: [PATCH 3/6] feat(scripts): the scaffold emission policy stamps the blank template's vitest range POLICY_STAMPS gains a devDependencies.vitest row generated from SCAFFOLD_VITEST_RANGE, so the blank starter's vitest range is written by create-objectstack's build from the constant the CLI scaffolders import, and check:scaffold-emission-policy reddens if they drift apart. The self-test fixtures carry the third constant and assert it is stamped. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- scripts/sync-scaffold-emission-policy.mjs | 33 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/scripts/sync-scaffold-emission-policy.mjs b/scripts/sync-scaffold-emission-policy.mjs index 62206d25743..c42ff79acd0 100644 --- a/scripts/sync-scaffold-emission-policy.mjs +++ b/scripts/sync-scaffold-emission-policy.mjs @@ -143,6 +143,15 @@ export const POLICY_STAMPS = [ // The floor the docs already promise. This is the value that split three // ways across three scaffolders (#16485). }, + { + key: 'devDependencies.vitest', + block: 'devDependencies', + field: 'vitest', + constant: 'SCAFFOLD_VITEST_RANGE', + // The range the blank starter's `pnpm test` installs, and the one the + // plugin scaffolds of `os init` and `os create` emit. The comment on the + // constant holds why it is `~4.0.0` (npm 10 cannot install `^4.0.0`). + }, ]; /** @@ -604,8 +613,12 @@ const SELF_TEST_BATTERY_FLOOR = 9; const UNATTRIBUTED_BATTERY = '(no battery open)'; const SELF_TEST_VERDICT = 'sync-scaffold-emission-policy self-test reached its verdict'; -const SELF_TEST_POLICY = { SCAFFOLD_PNPM_RANGE: '>=99.1', SCAFFOLD_TYPESCRIPT_RANGE: '^9.9.9' }; -const STALE = { SCAFFOLD_PNPM_RANGE: '>=1.0', SCAFFOLD_TYPESCRIPT_RANGE: '^1.0.0' }; +const SELF_TEST_POLICY = { + SCAFFOLD_PNPM_RANGE: '>=99.1', + SCAFFOLD_TYPESCRIPT_RANGE: '^9.9.9', + SCAFFOLD_VITEST_RANGE: '~8.8.0', +}; +const STALE = { SCAFFOLD_PNPM_RANGE: '>=1.0', SCAFFOLD_TYPESCRIPT_RANGE: '^1.0.0', SCAFFOLD_VITEST_RANGE: '^1.1.0' }; function policySource(policy = SELF_TEST_POLICY) { return [ @@ -614,6 +627,8 @@ function policySource(policy = SELF_TEST_POLICY) { '', `export const SCAFFOLD_TYPESCRIPT_RANGE = '${policy.SCAFFOLD_TYPESCRIPT_RANGE}';`, '', + `export const SCAFFOLD_VITEST_RANGE = '${policy.SCAFFOLD_VITEST_RANGE}';`, + '', ].join('\n'); } @@ -624,7 +639,11 @@ function templatePkg(policy) { private: true, engines: { pnpm: policy.SCAFFOLD_PNPM_RANGE }, dependencies: { '@objectstack/spec': '^17.0.0' }, - devDependencies: { '@objectstack/cli': '^17.0.0', typescript: policy.SCAFFOLD_TYPESCRIPT_RANGE }, + devDependencies: { + '@objectstack/cli': '^17.0.0', + typescript: policy.SCAFFOLD_TYPESCRIPT_RANGE, + vitest: policy.SCAFFOLD_VITEST_RANGE, + }, }, null, 2, @@ -753,7 +772,13 @@ function selfTest() { ok(write.status === 0, 'the write run exits 0'); const after = readTemplates(dir); ok( - after.every((t) => t.includes('"typescript": "^9.9.9"') && t.includes('"pnpm": ">=99.1"') && t.includes('"@objectstack/spec": "^17.0.0"')), + after.every( + (t) => + t.includes('"typescript": "^9.9.9"') && + t.includes('"vitest": "~8.8.0"') && + t.includes('"pnpm": ">=99.1"') && + t.includes('"@objectstack/spec": "^17.0.0"'), + ), 'EVERY template now emits the policy, with the @objectstack/* ranges untouched', ); ok(runFixture(script, ['--check']).status === 0, '--check is green after the rewrite (idempotent)'); From 34924cb0a0e0deb23fb814ea58dccf0fcfc406f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:34:28 +0000 Subject: [PATCH 4/6] test(scripts): the payload controls also carry a src-rooted glob exclusion `**/*.test.ts` alone is dropped by tsconfigExcludedDirs's inside-src/ filter before its glob filter is consulted, so the control could not see that guard. `src/**/*.test.ts` reaches it. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- scripts/check-registry-log-declared.mjs | 7 ++++--- scripts/check-test-source-alias.mjs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/check-registry-log-declared.mjs b/scripts/check-registry-log-declared.mjs index 436c6164181..a60abe41124 100644 --- a/scripts/check-registry-log-declared.mjs +++ b/scripts/check-registry-log-declared.mjs @@ -839,8 +839,9 @@ function selfTest() { // Package `b` carries the same file with no exclusion, so a predicate // that stopped honouring the boundary selects a SECOND package rather // than nothing changing. Package `c` excludes `**/*.test.ts`, the glob - // most packages here put in their tsconfig: a glob is not a payload - // directory, so a predicate that read it as one would lose `c` too. + // most packages here put in their tsconfig, and its `src/`-rooted twin: + // a glob is not a payload directory, so a predicate that read either as + // one would lose `c` too. name: "a test under a dir the package's tsconfig excludes is payload and does not SELECT", packages: { a: { @@ -858,7 +859,7 @@ function selfTest() { }, c: { 'package.json': TEST_MANIFEST, - 'tsconfig.json': JSON.stringify({ exclude: ['node_modules', 'dist', '**/*.test.ts'] }), + 'tsconfig.json': JSON.stringify({ exclude: ['node_modules', 'dist', '**/*.test.ts', 'src/**/*.test.ts'] }), 'src/x.test.ts': BOOTS, 'vitest.config.ts': `export default { test: { globals: true } };\n`, }, diff --git a/scripts/check-test-source-alias.mjs b/scripts/check-test-source-alias.mjs index 50eadb58b18..1311511c584 100644 --- a/scripts/check-test-source-alias.mjs +++ b/scripts/check-test-source-alias.mjs @@ -2272,11 +2272,11 @@ function buildFixtureTree() { }); // (6c) …and its control: the same file, in a package whose tsconfig excludes - // only the `**/*.test.ts` GLOB most packages here carry. A glob is not a - // payload directory, so this one is still reported. + // only the `**/*.test.ts` GLOB most packages here carry, and its `src/`-rooted + // twin. A glob is not a payload directory, so this one is still reported. fixture(root, 'packages/glob-excluded-tests', { 'package.json': ARTIFACT_MANIFEST('@fx/glob-excluded-tests'), - 'tsconfig.json': JSON.stringify({ exclude: ['node_modules', 'dist', '**/*.test.ts'] }), + 'tsconfig.json': JSON.stringify({ exclude: ['node_modules', 'dist', '**/*.test.ts', 'src/**/*.test.ts'] }), 'src/templates/blank/starter.test.ts': "import { alive } from '@fx/core';\nexport default alive;\n", }); From c8cfe2e8c27bdd71815d2349aa6d1760ae858693 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:50:07 +0000 Subject: [PATCH 5/6] docs(changeset): drop a line count this branch did not re-measure Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/15952-blank-template-test-story.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15952-blank-template-test-story.md b/.changeset/15952-blank-template-test-story.md index 9796971c5d3..e2a50fac742 100644 --- a/.changeset/15952-blank-template-test-story.md +++ b/.changeset/15952-blank-template-test-story.md @@ -9,7 +9,7 @@ Clause-②: no A project scaffolded with `npx create-objectstack` used to start with no `test` script and no way to assert its own behaviour, so the first test an author wrote was a hand-assembled kernel or a faked `ctx`. The blank template now carries: - **`note.test.ts`**, one starter test. It boots the app the project declares (its objects, its plugins, the services it requires) on the real engine, in-process, with an in-memory database and no server, and asserts that a note without a title is refused with the engine's own `VALIDATION_FAILED` error naming `title`, beside the same note with a title being saved. It addresses the object as `Note.name`, so it follows the namespace scaffolding writes. -- **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log (138 of the 341 lines the starter test printed). +- **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log. - **`package.json`**: a `test` script (`vitest run`), and `@objectstack/verify` (at the project's `@objectstack/*` range) and `vitest` as devDependencies. The vitest range is `~4.0.0`, the one `objectstack init -t plugin` and `objectstack create plugin` declare, and it is generated from that shared policy rather than written by hand, so the three scaffolders cannot drift apart. `~4.0.0` is the range that installs on npm 10, the npm Node 22 bundles; `^4.0.0` does not. - **`tsconfig.json`** includes every root `*.ts` (`"*.ts"`, as `objectstack init` writes it), so `pnpm typecheck` checks the starter test and the vitest config. - **`README.md`** lists `pnpm test` beside `validate`, `lint` and `typecheck`. From 604bd2e22fcae868571a8af8b7d7935a8d2dcf73 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:43:35 +0000 Subject: [PATCH 6/6] docs(getting-started): the first-project page lists the starter test and @objectstack/verify The blank starter now ships note.test.ts, vitest.config.ts and @objectstack/verify as a devDependency, so the "What was generated" tree gains the two root files and the package table, which calls itself the entire framework surface a project installs, gains the verify row. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- content/docs/getting-started/your-first-project.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index c9d7e4ad1d3..9c57edd70d0 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -89,6 +89,8 @@ my-app/ ├── objectstack.manifest.json # name, namespace, specVersion ├── package.json ├── tsconfig.json +├── note.test.ts # the starter test `npm test` runs +├── vitest.config.ts # its test-runner config ├── AGENTS.md # conventions for coding agents └── src/ ├── objects/ @@ -232,6 +234,7 @@ framework surface you install: | `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. | | `@objectstack/connector-rest` · `-openapi` · `-mcp` | The three generic connector executors — register the `rest` / `openapi` / `mcp` provider factories so declarative `connectors:` entries materialize ([ADR-0097](/docs/automation/flows)). | | `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. | +| `@objectstack/verify` *(dev)* | The test harness: `bootStack` boots this app's own stack in-process, on an in-memory database and with no server port, and returns the handle a test asserts through. `npm test` runs the starter test `note.test.ts` with it. | No driver package is listed because none is installed directly: `@objectstack/runtime` depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries