Skip to content

Commit c61f75d

Browse files
tooling(ui-wiring): four objectstack gates read the merged tree right once ui/ exists, inert today (#25697 PR A2) (#25738)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoEDFo5P2cDc4CEUUjCkLmP8)_ Part of #25697 (PR A2; the card stays open for PR B, the pin-gate retirement in the cutover window). Part of objectstack#25677. Clause-②: no Metadata-protocol: none ## What this is The second inert objectstack-side patch of the `ui/` CI wiring (PR A was #25710). On today's tree, with no `ui/` directory, every changed script and the root `build` give byte-identical results (measured below). The change only matters once objectui's tree sits under `ui/`. On the verification PR #25718 (runs 1–5) four gates stayed red in the merged layout because of objectstack's own tooling. This PR repairs those four. Every exclusion is **by path** (a workspace member whose directory is `ui` or under it), never by the `@object-ui/` scope. The ui root package is named `object-ui-monorepo`, so a scope filter would have missed it. | # | Gate | Red on the merged tree | Change | |---|---|---|---| | 1 | Test Core partitioner, `scripts/partition-test-shards.mjs` | `turbo ls` lists 47 `ui/` members (46 `@object-ui/*` plus the ui root at path `ui`), and every shard received some. Shard 1 got the ui root alone (18783 s estimated, max/mean 2.17x). Under `OS_SKIP_DTS` that means TS7016, and run 4 spent 38 min running the ui root's whole-suite `test`. | `planRun()` drops items whose path is under `ui/` before weighing. This is the one path `main()` runs. The drop happens only when such items exist, and only then does it print one stderr line (`ui/ tree: N member(s) not partitioned here -- Test UI runs them`). Test UI keeps its own matrix. | | 2 | Root `build` (`package.json`) | `turbo run build --filter=!@objectstack/docs` includes `@object-ui/site#build` (`next build`), which objectui's own root build excluded. | Adds `--filter=!./ui*/apps/site`. Turbo's path form, with a glob. Why it is spelled this way is under Acceptance notes. | | 3 | `scripts/check-type-check-coverage.mjs` (Type Check · source gates, and the debt ledger lane through `--re-measure`) | `JSON.parse` throws on `ui/tsconfig.json`, which is JSONC with block comments. tsc accepts it. Three more ui configs (`ui/apps/console/tsconfig.json`, `ui/apps/console/tsconfig.node.json`, `ui/packages/runner/tsconfig.json`) have the same shape. | **JSONC reader, not the exclusion fallback.** A new `parseTsconfigText()` uses TypeScript's own `parseConfigFileTextToJson` and replaces all three tsconfig reads in the file (`readTsconfig`, `tsconfigChainFacts`, `measureTestDebt`). The reason is that what tsc compiles is what this gate claims to describe, and the fix also protects objectstack's own future tsconfigs. The refusals are kept. A syntax error, a merge-conflict marker, a non-object root and an empty or comment-only document are still refused (`readTsconfig` still throws, so the exit-code pin still sees it). | | 4 | `scripts/check-published-readme-exports.mjs` (Type Check · consumer gates) | Exit 3 (PREREQUISITE NOT MET). The `@object-ui/*` built type entries are not in the closure this lane builds, so no objectstack README was judged. | `run()` drops members under `ui/`, and the documents they publish, from the population (`outsideUiTree()`). The scan now covers the members outside `ui/`. Objectui judges its own README claims with `ui/scripts/check-readme-exports.mjs` (see Acceptance notes for where that runs today). | Rulings followed: path, not scope. Nothing under `.github/workflows/**`. The dispatch-gates `maxBuffer` item is not redone (#25732). ## Verification record Measured on base `origin/main` f3fea0a with the fix commit 0263a19. The head 898990e then merges `origin/main` 4cc17db. Those 4 commits touch none of the four files, and the derived command list is identical after the merge. The scoped re-check on the head was the three touched self-tests plus `pnpm check:type-check-coverage`, `check-ci-filter-parity` and `check:turbo-task-graph`, all exit 0. Verification tree: #25718 head `cfa1676afc` with this diff applied (`git diff f3fea0a..HEAD | git apply`), after `pnpm install --frozen-lockfile`. Exit codes were captured before any pipe. **Inert on `main` (no `ui/`):** - Partitioner. `turbo ls --output=json` on the full list, then `--shard N/6 --exclude @objectstack/dogfood` for N = 1..6, base script against new script. stdout and stderr are **byte-identical on all six shards** (exit 0/0; 12/13/14/14/21/11 items). - Root build. `turbo run build --filter=!@objectstack/docs --dry=json` before, and `pnpm build --dry=json` after. Both give the **same 83 task ids**, and the task hashes are the same too, compared with the filter passed by hand. With `!./ui/apps/site` or `!@object-ui/site` instead, turbo exits 1 on this tree (see notes). - `node scripts/check-type-check-coverage.mjs`, base against new: exit 0/0, **byte-identical output**. - `node scripts/check-published-readme-exports.mjs` on a built closure, base against new: exit 0/0, **byte-identical output**. Both report 105 documents, 84 members and 225/225 own-scope specifiers. **Effect on the verification tree (`cfa1676afc` + this diff):** | Reproduction | Before | After | |---|---|---| | partitioner, 6 shards | 47 `ui/` items placed (shard 1 = `object-ui-monorepo`) | **0 `ui/` items**. All 83 non-ui members placed exactly once. Max/mean 1.00x, and the CLI is sliced x3 as on `main`. | | `node scripts/check-type-check-coverage.mjs` | exit 1, uncaught `Error: ui/tsconfig.json is not parseable` | **reads `ui/tsconfig.json`**. Exit 1 with 489 findings, all of them on `ui/` members (see notes). | | `node scripts/check-published-readme-exports.mjs` (objectstack closure built: `turbo run build --filter='./packages/*' --filter='./packages/*/*'`, the lane's step) | **exit 3**, 33 `@object-ui/*` entries unbuilt | **exit 0**, a verdict. 105 documents, 84 members, 225/225 own-scope specifiers, 366 symbols checked. | | `turbo run build` dry-run of the root filter | 130 tasks incl. `@object-ui/site#build` | 129 tasks. The only one removed is `@object-ui/site#build`. | **Self-tests with the `ui/` case** (each one green on `main` and on the verification tree): - `node scripts/partition-test-shards.mjs --self-test`, exit 0. New battery "the `ui/` tree is Test UI's, not Test Core's" (6 cases). It runs `planRun()` on a merged document with the ui root at path `ui`, an `@object-ui` member, and a member given an absolute path. None reaches a bin. The bins equal the split of the same document without them. A package at `packages/ui` is kept, and an entry with no path is kept so that `weighItems()` refuses it. The roster floor goes from 14 to 15. - `node scripts/check-type-check-coverage.mjs --self-test`, exit 0. New battery "JSONC tsconfig reader" (8 cases). Four shapes tsc accepts are read: a block comment between keys (objectui's root config), trailing commas, a whole-line comment, and a `//` inside a string. Four shapes stay refused: a truncated file, a merge-conflict marker, a non-object root, and a comment-only file. The roster floor goes from 65 to 66. - `node scripts/check-published-readme-exports.mjs --self-test`, exit 0. New battery "THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH" (5 cases). It runs the production `run()` on a scratch workspace. An unbuilt member under `ui/` gives exit 0 and is not named in the output. The **control** puts the same unbuilt member outside `ui/`, which still gives exit 3 and names it. The roster floor goes from 26 to 27. **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 46 commands. **All 46 exit 0** on the head, including `check:type-check-coverage`, `check:type-check-debt`, `check:published-readme-exports`, `check:pm-dispatch-gates` (fast tier), `check:parse-guard`, `check:entry-guard`, `check:ci-filter-parity` and `check:turbo-task-graph`. Reconciled with `--ran` (each line `command :: exit N`): `46 derived, 46 run, 0 NOT-MEASURED (a DERIVED zero)`. `pnpm exec eslint --no-inline-config` on the three scripts: exit 0. `node scripts/pm/check-governed-merges.mjs --branch HEAD`: **NOT governed**, 344 changed lines. No changeset: the root package is private and `scripts/**` ships in no package's `files[]`. The PR carries `skip-changeset`. ## Acceptance notes (for the cutover card) 1. **The coverage gate is still red on the merged tree, now with findings instead of a crash.** With the JSONC reader it reads every ui config and judges the 47 ui members: **489 findings, 0 of them outside `ui/`**. They break down as 47 "no `typecheck` script and no ledger entry" (objectui spells the script `type-check`), 392 `@ts-expect-error` pins in files no `typecheck` program compiles (a consequence of the first class), 40 hidden test layers and 10 unread source directories. So Type Check · source gates and Type Check · debt ledger go from exit 1 by an uncaught throw to exit 1 with a verdict. Turning them green is a coverage-semantics decision for the cutover. One option is to give ui packages the `typecheck` spelling or ledger rows. The other is the path exclusion this PR did not take. Either is a follow-up for the cutover card, not part of this patch. 2. **The order says objectui's README gate runs in the Type Check UI job. Measured, it does not.** `pnpm check:readme-exports` is in objectui's `repo-gates.yml` (README Export Check), which the roster routes to sub-issue 4. In the merged tree, `ui/` README export claims are checked by neither gate until that workflow is ported, or until Type Check UI gains the step. 3. **Why the root build filter is a glob.** On a tree without `ui/`, turbo 2.11.5 exits 1 on `--filter=!./ui/apps/site` ("Directory … specified in filter does not exist"), on `!./ui/apps/[s]ite`, and on `!@object-ui/site` ("No package found with name"). `!./ui*/apps/site` is accepted because turbo walks the glob from the repo root. Measured: it removes exactly `@object-ui/site#build` on the verification tree and nothing on `main`. Once `ui/` exists the spelling can be tightened to `!./ui/apps/site`. That is optional. 4. **The ui root's own `test` script** (objectui's whole-suite `vitest run`) is untouched here. Its drop stays on the cutover adaptation bill. After this PR, Test Core never schedules it. 5. The root build dry-run on the verification tree still lists `object-ui-monorepo#build` with command NONEXISTENT, because the adaptation commit already dropped the script. Turbo skips it. Nothing to do. 6. The readme gate refused 33 unbuilt `@object-ui` entries locally against 19 in run 4's lane. The lane builds part of the ui closure through its dependency edges. Both refuse without this diff. With it, the local run exits 0. The lane's own reading comes from this PR's CI and from the next verification run. --- _Generated by [Claude Code](https://claude.ai/code/session_01Y9KdeUcccMQ3ReF85Xyf5o)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f871670 commit c61f75d

4 files changed

Lines changed: 324 additions & 20 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations",
66
"scripts": {
7-
"build": "turbo run build --filter=!@objectstack/docs",
7+
"build": "turbo run build --filter=!@objectstack/docs --filter=!./ui*/apps/site",
88
"dev": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev",
99
"dev:showcase": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev",
1010
"dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev",

‎scripts/check-published-readme-exports.mjs‎

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2217,8 +2217,44 @@ function prerequisiteNotMetText(message) {
22172217
* result depends on whether it was passed. `root` is the self-test's
22182218
* scratch workspace and defaults to the repo — see `publishedDocs`.
22192219
*/
2220+
/**
2221+
* The directory objectui's tree is imported under (objectstack#25677), as a
2222+
* bare segment: a literal with no path separator is no watch hint, so this
2223+
* declares no population (see DECLARED_WORKSPACE_POPULATION).
2224+
*/
2225+
const UI_TREE = 'ui';
2226+
2227+
/** Is a repo-relative POSIX path the `ui/` tree or inside it? */
2228+
export function inUiTree(rel) {
2229+
return rel === UI_TREE || rel.startsWith(`${UI_TREE}/`);
2230+
}
2231+
2232+
/**
2233+
* This gate's population with the `ui/` tree taken out, by PATH: every member
2234+
* whose directory is `ui` or under it, and every document it publishes.
2235+
*
2236+
* Those packages' README claims are objectui's own gate's to judge
2237+
* (`ui/scripts/check-readme-exports.mjs`, which objectui runs in its
2238+
* `repo-gates.yml` after a full build), and their built type entries are not
2239+
* in the closure this gate's lane builds -- so left
2240+
* in, they turn every run on the merged tree into PREREQUISITE NOT MET over
2241+
* packages it was never asked about, and the objectstack READMEs go unjudged
2242+
* with them. By path and not by `@object-ui/` scope: the `ui/` tree is the unit
2243+
* the import defined, and a scope test would miss a member of it with another
2244+
* name. A tree with no `ui/` directory comes back unchanged.
2245+
*
2246+
* An objectstack README that imports a `ui/` member now reads it as a
2247+
* non-member, the same as `react`: not this repo's own scope, so skipped.
2248+
*/
2249+
export function outsideUiTree({ byName, docs }) {
2250+
return {
2251+
byName: new Map([...byName].filter(([, member]) => !inUiTree(member.dir))),
2252+
docs: docs.filter((doc) => !inUiTree(doc.file)),
2253+
};
2254+
}
2255+
22202256
function run({ unreadReport: wantsUnreadReport = false, root = ROOT } = {}) {
2221-
const { byName, docs } = publishedDocs(SELF, { root });
2257+
const { byName, docs } = outsideUiTree(publishedDocs(SELF, { root }));
22222258

22232259
// Pass 1: which workspace type entries do the READMEs actually reach?
22242260
const { importStatements, ownScope, unresolvable, targets: reached } = reachedTargets(
@@ -2497,11 +2533,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
24972533
'THE FOURTH STATE ON THAT AXIS (#10417)': 4,
24982534
'THE UNBUILT-CLOSURE PREDICATE, and the two states it excludes (#15600)': 5,
24992535
'THE EXIT-CODE SPLIT, END TO END on a scratch workspace (#15600)': 8,
2536+
'THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH (#25697)': 5,
25002537
});
25012538

25022539
// DELETING an entry silences that battery's floor exactly as effectively as
25032540
// zeroing it, so the roster's own size is pinned too.
2504-
const SELF_TEST_BATTERY_FLOOR = 26;
2541+
const SELF_TEST_BATTERY_FLOOR = 27;
25052542

25062543
// The key an assertion is filed under when no battery is open. It is not a
25072544
// declared battery, so it reds by the same set difference rather than silently
@@ -4298,6 +4335,91 @@ function selfTest() {
42984335
rmSync(fixtureRoot, { recursive: true, force: true });
42994336
}
43004337

4338+
// -- THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH ---------------------
4339+
//
4340+
// Driven through the production `run` over a scratch workspace, like the
4341+
// split above. The `ui/` member is UNBUILT on purpose: that is the merged
4342+
// tree's state in this gate's lane, and left in the population it refuses the
4343+
// whole run. The control puts the same unbuilt member outside `ui/` and must
4344+
// still refuse, so the green run is the exclusion and not a fixture that
4345+
// never reached the refusal.
4346+
battery('THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH (#25697)');
4347+
eq(
4348+
'inUiTree — the tree and its descendants, by path segment',
4349+
['ui', 'ui/packages/types', 'ui/README.md', 'uikit/x', 'packages/ui', 'packages/ui/README.md'].map(inUiTree),
4350+
[true, true, true, false, false, false],
4351+
);
4352+
eq(
4353+
'outsideUiTree — drops the members under `ui/` and the documents they publish, keeps the rest',
4354+
(() => {
4355+
const kept = outsideUiTree({
4356+
byName: new Map([
4357+
['@objectstack/a', { dir: 'packages/a' }],
4358+
['@object-ui/b', { dir: 'ui/packages/b' }],
4359+
['object-ui-root', { dir: 'ui' }],
4360+
['@objectstack/ui-kit', { dir: 'packages/ui' }],
4361+
]),
4362+
docs: [{ file: 'packages/a/README.md' }, { file: 'ui/packages/b/README.md' }, { file: 'ui/README.md' }],
4363+
});
4364+
return [[...kept.byName.keys()], kept.docs.map((d) => d.file)];
4365+
})(),
4366+
[['@objectstack/a', '@objectstack/ui-kit'], ['packages/a/README.md']],
4367+
);
4368+
const uiRoot = mkdtempSync(join(tmpdir(), 'readme-exports-ui-tree-'));
4369+
try {
4370+
const member = (rel, name, built) => {
4371+
const dir = join(uiRoot, rel);
4372+
mkdirSync(join(dir, 'dist'), { recursive: true });
4373+
writeFileSync(
4374+
join(dir, 'package.json'),
4375+
`${JSON.stringify(
4376+
{
4377+
name,
4378+
version: '0.0.0',
4379+
files: ['dist', 'README.md'],
4380+
exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' } },
4381+
},
4382+
null,
4383+
2,
4384+
)}\n`,
4385+
);
4386+
writeFileSync(join(dir, 'README.md'), ['```typescript', `import { Documented } from '${name}';`, '```', ''].join('\n'));
4387+
if (built) writeFileSync(join(dir, 'dist', 'index.d.ts'), 'export declare const Documented: string;\n');
4388+
};
4389+
mkdirSync(join(uiRoot, 'scripts'), { recursive: true });
4390+
writeFileSync(join(uiRoot, BASELINE_REL), `${JSON.stringify({ entries: [] }, null, 2)}\n`);
4391+
member('packages/fixture', '@objectstack/ui-tree-fixture', true);
4392+
const drive = (globs) => {
4393+
writeFileSync(join(uiRoot, WORKSPACE_FILE), `packages:\n${globs.map((g) => ` - ${g}\n`).join('')}`);
4394+
const printed = [];
4395+
const { log, error } = console;
4396+
console.log = (...args) => printed.push(args.join(' '));
4397+
console.error = (...args) => printed.push(args.join(' '));
4398+
try {
4399+
return { exit: run({ root: uiRoot }), text: printed.join('\n') };
4400+
} finally {
4401+
console.log = log;
4402+
console.error = error;
4403+
}
4404+
};
4405+
4406+
member('ui/packages/unbuilt', '@object-ui/ui-tree-unbuilt', false);
4407+
const merged = drive(['packages/*', 'ui/packages/*']);
4408+
eq('END TO END — an unbuilt member under `ui/` does not refuse the run', merged.exit, EXIT_OK);
4409+
eq('END TO END — and the run does not name it', /ui-tree-unbuilt/.test(merged.text), false);
4410+
4411+
member('packages/unbuilt', '@object-ui/ui-tree-unbuilt', false);
4412+
rmSync(join(uiRoot, 'ui'), { recursive: true, force: true });
4413+
const control = drive(['packages/*']);
4414+
eq(
4415+
'END TO END — CONTROL: the same unbuilt member outside `ui/` still refuses, naming it',
4416+
[control.exit, /@object-ui\/ui-tree-unbuilt -> packages\/unbuilt\/dist\/index\.d\.ts/.test(control.text)],
4417+
[EXIT_PREREQUISITE_NOT_MET, true],
4418+
);
4419+
} finally {
4420+
rmSync(uiRoot, { recursive: true, force: true });
4421+
}
4422+
43014423
// The authority convention (#8435): the baseline path must never be offered
43024424
// as a co-equal author remedy.
43034425
const remedy = freshRemedy();

‎scripts/check-type-check-coverage.mjs‎

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync,
528528
import { tmpdir, totalmem } from 'node:os';
529529
import { join, posix, resolve } from 'node:path';
530530
import { getHeapStatistics } from 'node:v8';
531+
import ts from 'typescript';
531532
import { gitFreeEnv } from './git-env.mjs';
533+
import { maskComments } from './js-comment-mask.mjs';
532534
import {
533535
selfTest as workspaceEnumeratorSelfTest,
534536
workspaceEnumeratorFloorFailures,
@@ -1623,14 +1625,38 @@ function includeRoot(glob) {
16231625
return glob.split('*')[0].replace(/\/$/, '');
16241626
}
16251627

1628+
/**
1629+
* A tsconfig's text read the way tsc reads it: JSON with comments, line AND
1630+
* block, and trailing commas. `JSON.parse` over the text with whole-line `//`
1631+
* comments stripped was the reader here, and it refused every config tsc
1632+
* accepts with a block comment or a trailing comma -- objectui's root
1633+
* `tsconfig.json` and three of its package configs are written that way, so
1634+
* the gate stopped on the first of them the moment `ui/` became a workspace
1635+
* member (objectstack#25697).
1636+
*
1637+
* TypeScript's own parser is the authority, not a second JSONC dialect typed
1638+
* here: what tsc compiles is what this gate claims to describe. Its refusals
1639+
* stay refusals -- a syntax error, a merge-conflict marker, a root that is not
1640+
* an object -- and so does a document holding no value at all, which the
1641+
* parser reads as `{}` and the old reader refused: an empty config read as
1642+
* "include everything, exclude nothing" is the silent pass this gate exists to
1643+
* refuse.
1644+
*
1645+
* @returns {{ config: object } | { error: string }}
1646+
*/
1647+
function parseTsconfigText(text, fileName) {
1648+
if (maskComments(text).trim() === '') return { error: 'the file holds no JSON value' };
1649+
const { config, error } = ts.parseConfigFileTextToJson(fileName, text);
1650+
if (error) return { error: ts.flattenDiagnosticMessageText(error.messageText, '\n') };
1651+
return { config };
1652+
}
1653+
16261654
function readTsconfig(dir, file) {
1627-
const raw = readFileSync(join(ROOT, dir, file), 'utf8').replace(/^\s*\/\/.*$/gm, '');
1628-
let parsed;
1629-
try {
1630-
parsed = JSON.parse(raw);
1631-
} catch (cause) {
1632-
throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged`, { cause });
1655+
const read = parseTsconfigText(readFileSync(join(ROOT, dir, file), 'utf8'), `${dir}/${file}`);
1656+
if (read.error !== undefined) {
1657+
throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged: ${read.error}`);
16331658
}
1659+
const parsed = read.config;
16341660
const include = parsed.include ?? null;
16351661
const declared = Array.isArray(include) ? include.filter((g) => typeof g === 'string') : [];
16361662
const roots = declared.length > 0
@@ -4169,12 +4195,15 @@ function tsconfigChainFacts(dir) {
41694195
let configAbs = join(ROOT, dir, 'tsconfig.json');
41704196
const facts = { selectsFiles: false, declaresTypeRoots: false };
41714197
for (let depth = 0; depth < 8; depth++) {
4172-
let parsed;
4198+
let text;
41734199
try {
4174-
parsed = JSON.parse(readFileSync(configAbs, 'utf8').replace(/^\s*\/\/.*$/gm, ''));
4200+
text = readFileSync(configAbs, 'utf8');
41754201
} catch {
41764202
return facts;
41774203
}
4204+
const read = parseTsconfigText(text, configAbs);
4205+
if (read.error !== undefined) return facts;
4206+
const parsed = read.config;
41784207
if (Array.isArray(parsed.include) && parsed.include.length > 0) facts.selectsFiles = true;
41794208
if (Array.isArray(parsed.files)) facts.selectsFiles = true;
41804209
if (parsed.compilerOptions?.typeRoots !== undefined) facts.declaresTypeRoots = true;
@@ -4348,10 +4377,12 @@ function remeasureProject({ pkgAbs, rootAbs, parsed, unreachable, chain }) {
43484377
function measureTestDebt(dir, hiddenTests = []) {
43494378
const rootAbs = ROOT.replaceAll('\\', '/').replace(/\/$/, '');
43504379
const pkgAbs = join(ROOT, dir).replaceAll('\\', '/').replace(/\/$/, '');
4351-
const parsed = JSON.parse(
4352-
readFileSync(join(ROOT, dir, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, ''),
4353-
);
4380+
// readTsconfig first: it is the reader that refuses an unparseable config.
43544381
const { roots } = readTsconfig(dir, 'tsconfig.json');
4382+
const { config: parsed } = parseTsconfigText(
4383+
readFileSync(join(ROOT, dir, 'tsconfig.json'), 'utf8'),
4384+
`${dir}/tsconfig.json`,
4385+
);
43554386
const unreachable = hiddenTests.filter(
43564387
(rel) => !roots.some((root) => root === '' || rel === root || rel.startsWith(`${root}/`)),
43574388
);
@@ -5051,6 +5082,8 @@ const SELF_TEST_BATTERIES = Object.freeze({
50515082
// The closure build's shared-verify-lock contract: 11 verdict-reader rows, 2
50525083
// wiring pins, 6 real-lock cases on a private lock file.
50535084
'verify-lock cases (#23470)': 19,
5085+
// parseTsconfigText: what tsc accepts reads, what tsc refuses still refuses.
5086+
'JSONC tsconfig reader (#25697)': 8,
50545087
});
50555088

50565089
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -5059,7 +5092,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
50595092
// key in the literal above, so the roster falls below this number; the table
50605093
// cross-check in `batteryFloorFailures()` is the other half, and names WHICH
50615094
// label collided.
5062-
const SELF_TEST_BATTERY_FLOOR = 65;
5095+
const SELF_TEST_BATTERY_FLOOR = 66;
50635096

50645097
// The ledger `batteryFloorFailures()` reads.
50655098
//
@@ -7695,6 +7728,57 @@ function selfTest() {
76957728
if (!c.ok) failures.push(`prerequisiteNotMetText — ${c.label}`);
76967729
}
76977730

7731+
// ── THE JSONC TSCONFIG READER (#25697) ─────────────────────────────────────
7732+
//
7733+
// tsc reads tsconfig files as JSON with comments and trailing commas, and so
7734+
// must this gate: the accepted rows below are all shapes tsc compiles, and
7735+
// the first is objectui's root config's own (a block comment between keys).
7736+
// The refused rows are what keeps "tolerant" from meaning "anything parses":
7737+
// each one used to throw under JSON.parse and must still come back as an
7738+
// error, never as a config that excludes nothing.
7739+
const jsoncCases = [
7740+
{
7741+
label: 'a block comment between keys reads',
7742+
text: '{\n "compilerOptions": {\n "skipLibCheck": true,\n\n /* Bundler mode */\n'
7743+
+ ' "moduleResolution": "bundler"\n },\n "include": ["src"]\n}\n',
7744+
include: ['src'],
7745+
},
7746+
{
7747+
label: 'trailing commas in an object and an array read',
7748+
text: '{ "include": ["src", "test",], "exclude": ["**/*.test.ts",], }',
7749+
include: ['src', 'test'],
7750+
exclude: ['**/*.test.ts'],
7751+
},
7752+
{
7753+
label: 'a whole-line `//` comment still reads, as it did before',
7754+
text: '// the base config\n{ "include": ["src/**/*"] }',
7755+
include: ['src/**/*'],
7756+
},
7757+
{
7758+
label: 'a `//` inside a string value is the value, not a comment',
7759+
text: '{ "include": ["src"], "compilerOptions": { "baseUrl": "http://x//y" } }',
7760+
include: ['src'],
7761+
},
7762+
{ label: 'a truncated document is refused', text: '{ "include": [', refused: true },
7763+
{ label: 'a merge-conflict marker is refused', text: '<<<<<<< HEAD\n{ "include": ["src"] }', refused: true },
7764+
{ label: 'a root that is not an object is refused', text: '["src"]', refused: true },
7765+
{ label: 'a document holding only a comment is refused, not read as {}', text: '// nothing here\n', refused: true },
7766+
];
7767+
for (const c of jsoncCases) {
7768+
registerCase('JSONC tsconfig reader (#25697)');
7769+
const got = parseTsconfigText(c.text, 'fixture/tsconfig.json');
7770+
if (c.refused) {
7771+
if (got.error === undefined) {
7772+
failures.push(`parseTsconfigText — ${c.label}: read as ${JSON.stringify(got.config)} instead of refused`);
7773+
}
7774+
continue;
7775+
}
7776+
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
7777+
if (got.error !== undefined || !same(got.config.include, c.include) || !same(got.config.exclude, c.exclude)) {
7778+
failures.push(`parseTsconfigText — ${c.label}: got ${JSON.stringify(got)}`);
7779+
}
7780+
}
7781+
76987782
// The shared workspace enumerator is a plain module with no CI invocation of
76997783
// its own (#11510); every gate that consolidated onto it folds in its checks.
77007784
// ⛔ NOT a battery of this file either, for the same reason as the fold-in

0 commit comments

Comments
 (0)