Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations",
"scripts": {
"build": "turbo run build --filter=!@objectstack/docs",
"build": "turbo run build --filter=!@objectstack/docs --filter=!./ui*/apps/site",
"dev": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev",
"dev:showcase": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev",
"dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev",
Expand Down
126 changes: 124 additions & 2 deletions scripts/check-published-readme-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2217,8 +2217,44 @@ function prerequisiteNotMetText(message) {
* result depends on whether it was passed. `root` is the self-test's
* scratch workspace and defaults to the repo — see `publishedDocs`.
*/
/**
* The directory objectui's tree is imported under (objectstack#25677), as a
* bare segment: a literal with no path separator is no watch hint, so this
* declares no population (see DECLARED_WORKSPACE_POPULATION).
*/
const UI_TREE = 'ui';

/** Is a repo-relative POSIX path the `ui/` tree or inside it? */
export function inUiTree(rel) {
return rel === UI_TREE || rel.startsWith(`${UI_TREE}/`);
}

/**
* This gate's population with the `ui/` tree taken out, by PATH: every member
* whose directory is `ui` or under it, and every document it publishes.
*
* Those packages' README claims are objectui's own gate's to judge
* (`ui/scripts/check-readme-exports.mjs`, which objectui runs in its
* `repo-gates.yml` after a full build), and their built type entries are not
* in the closure this gate's lane builds -- so left
* in, they turn every run on the merged tree into PREREQUISITE NOT MET over
* packages it was never asked about, and the objectstack READMEs go unjudged
* with them. By path and not by `@object-ui/` scope: the `ui/` tree is the unit
* the import defined, and a scope test would miss a member of it with another
* name. A tree with no `ui/` directory comes back unchanged.
*
* An objectstack README that imports a `ui/` member now reads it as a
* non-member, the same as `react`: not this repo's own scope, so skipped.
*/
export function outsideUiTree({ byName, docs }) {
return {
byName: new Map([...byName].filter(([, member]) => !inUiTree(member.dir))),
docs: docs.filter((doc) => !inUiTree(doc.file)),
};
}

function run({ unreadReport: wantsUnreadReport = false, root = ROOT } = {}) {
const { byName, docs } = publishedDocs(SELF, { root });
const { byName, docs } = outsideUiTree(publishedDocs(SELF, { root }));

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

// 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 = 26;
const SELF_TEST_BATTERY_FLOOR = 27;

// 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
Expand Down Expand Up @@ -4298,6 +4335,91 @@ function selfTest() {
rmSync(fixtureRoot, { recursive: true, force: true });
}

// -- THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH ---------------------
//
// Driven through the production `run` over a scratch workspace, like the
// split above. The `ui/` member is UNBUILT on purpose: that is the merged
// tree's state in this gate's lane, and left in the population it refuses the
// whole run. The control puts the same unbuilt member outside `ui/` and must
// still refuse, so the green run is the exclusion and not a fixture that
// never reached the refusal.
battery('THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH (#25697)');
eq(
'inUiTree — the tree and its descendants, by path segment',
['ui', 'ui/packages/types', 'ui/README.md', 'uikit/x', 'packages/ui', 'packages/ui/README.md'].map(inUiTree),
[true, true, true, false, false, false],
);
eq(
'outsideUiTree — drops the members under `ui/` and the documents they publish, keeps the rest',
(() => {
const kept = outsideUiTree({
byName: new Map([
['@objectstack/a', { dir: 'packages/a' }],
['@object-ui/b', { dir: 'ui/packages/b' }],
['object-ui-root', { dir: 'ui' }],
['@objectstack/ui-kit', { dir: 'packages/ui' }],
]),
docs: [{ file: 'packages/a/README.md' }, { file: 'ui/packages/b/README.md' }, { file: 'ui/README.md' }],
});
return [[...kept.byName.keys()], kept.docs.map((d) => d.file)];
})(),
[['@objectstack/a', '@objectstack/ui-kit'], ['packages/a/README.md']],
);
const uiRoot = mkdtempSync(join(tmpdir(), 'readme-exports-ui-tree-'));
try {
const member = (rel, name, built) => {
const dir = join(uiRoot, rel);
mkdirSync(join(dir, 'dist'), { recursive: true });
writeFileSync(
join(dir, 'package.json'),
`${JSON.stringify(
{
name,
version: '0.0.0',
files: ['dist', 'README.md'],
exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' } },
},
null,
2,
)}\n`,
);
writeFileSync(join(dir, 'README.md'), ['```typescript', `import { Documented } from '${name}';`, '```', ''].join('\n'));
if (built) writeFileSync(join(dir, 'dist', 'index.d.ts'), 'export declare const Documented: string;\n');
};
mkdirSync(join(uiRoot, 'scripts'), { recursive: true });
writeFileSync(join(uiRoot, BASELINE_REL), `${JSON.stringify({ entries: [] }, null, 2)}\n`);
member('packages/fixture', '@objectstack/ui-tree-fixture', true);
const drive = (globs) => {
writeFileSync(join(uiRoot, WORKSPACE_FILE), `packages:\n${globs.map((g) => ` - ${g}\n`).join('')}`);
const printed = [];
const { log, error } = console;
console.log = (...args) => printed.push(args.join(' '));
console.error = (...args) => printed.push(args.join(' '));
try {
return { exit: run({ root: uiRoot }), text: printed.join('\n') };
} finally {
console.log = log;
console.error = error;
}
};

member('ui/packages/unbuilt', '@object-ui/ui-tree-unbuilt', false);
const merged = drive(['packages/*', 'ui/packages/*']);
eq('END TO END — an unbuilt member under `ui/` does not refuse the run', merged.exit, EXIT_OK);
eq('END TO END — and the run does not name it', /ui-tree-unbuilt/.test(merged.text), false);

member('packages/unbuilt', '@object-ui/ui-tree-unbuilt', false);
rmSync(join(uiRoot, 'ui'), { recursive: true, force: true });
const control = drive(['packages/*']);
eq(
'END TO END — CONTROL: the same unbuilt member outside `ui/` still refuses, naming it',
[control.exit, /@object-ui\/ui-tree-unbuilt -> packages\/unbuilt\/dist\/index\.d\.ts/.test(control.text)],
[EXIT_PREREQUISITE_NOT_MET, true],
);
} finally {
rmSync(uiRoot, { recursive: true, force: true });
}

// The authority convention (#8435): the baseline path must never be offered
// as a co-equal author remedy.
const remedy = freshRemedy();
Expand Down
108 changes: 96 additions & 12 deletions scripts/check-type-check-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync,
import { tmpdir, totalmem } from 'node:os';
import { join, posix, resolve } from 'node:path';
import { getHeapStatistics } from 'node:v8';
import ts from 'typescript';
import { gitFreeEnv } from './git-env.mjs';
import { maskComments } from './js-comment-mask.mjs';
import {
selfTest as workspaceEnumeratorSelfTest,
workspaceEnumeratorFloorFailures,
Expand Down Expand Up @@ -1623,14 +1625,38 @@ function includeRoot(glob) {
return glob.split('*')[0].replace(/\/$/, '');
}

/**
* A tsconfig's text read the way tsc reads it: JSON with comments, line AND
* block, and trailing commas. `JSON.parse` over the text with whole-line `//`
* comments stripped was the reader here, and it refused every config tsc
* accepts with a block comment or a trailing comma -- objectui's root
* `tsconfig.json` and three of its package configs are written that way, so
* the gate stopped on the first of them the moment `ui/` became a workspace
* member (objectstack#25697).
*
* TypeScript's own parser is the authority, not a second JSONC dialect typed
* here: what tsc compiles is what this gate claims to describe. Its refusals
* stay refusals -- a syntax error, a merge-conflict marker, a root that is not
* an object -- and so does a document holding no value at all, which the
* parser reads as `{}` and the old reader refused: an empty config read as
* "include everything, exclude nothing" is the silent pass this gate exists to
* refuse.
*
* @returns {{ config: object } | { error: string }}
*/
function parseTsconfigText(text, fileName) {
if (maskComments(text).trim() === '') return { error: 'the file holds no JSON value' };
const { config, error } = ts.parseConfigFileTextToJson(fileName, text);
if (error) return { error: ts.flattenDiagnosticMessageText(error.messageText, '\n') };
return { config };
}

function readTsconfig(dir, file) {
const raw = readFileSync(join(ROOT, dir, file), 'utf8').replace(/^\s*\/\/.*$/gm, '');
let parsed;
try {
parsed = JSON.parse(raw);
} catch (cause) {
throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged`, { cause });
const read = parseTsconfigText(readFileSync(join(ROOT, dir, file), 'utf8'), `${dir}/${file}`);
if (read.error !== undefined) {
throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged: ${read.error}`);
}
const parsed = read.config;
const include = parsed.include ?? null;
const declared = Array.isArray(include) ? include.filter((g) => typeof g === 'string') : [];
const roots = declared.length > 0
Expand Down Expand Up @@ -4169,12 +4195,15 @@ function tsconfigChainFacts(dir) {
let configAbs = join(ROOT, dir, 'tsconfig.json');
const facts = { selectsFiles: false, declaresTypeRoots: false };
for (let depth = 0; depth < 8; depth++) {
let parsed;
let text;
try {
parsed = JSON.parse(readFileSync(configAbs, 'utf8').replace(/^\s*\/\/.*$/gm, ''));
text = readFileSync(configAbs, 'utf8');
} catch {
return facts;
}
const read = parseTsconfigText(text, configAbs);
if (read.error !== undefined) return facts;
const parsed = read.config;
if (Array.isArray(parsed.include) && parsed.include.length > 0) facts.selectsFiles = true;
if (Array.isArray(parsed.files)) facts.selectsFiles = true;
if (parsed.compilerOptions?.typeRoots !== undefined) facts.declaresTypeRoots = true;
Expand Down Expand Up @@ -4348,10 +4377,12 @@ function remeasureProject({ pkgAbs, rootAbs, parsed, unreachable, chain }) {
function measureTestDebt(dir, hiddenTests = []) {
const rootAbs = ROOT.replaceAll('\\', '/').replace(/\/$/, '');
const pkgAbs = join(ROOT, dir).replaceAll('\\', '/').replace(/\/$/, '');
const parsed = JSON.parse(
readFileSync(join(ROOT, dir, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, ''),
);
// readTsconfig first: it is the reader that refuses an unparseable config.
const { roots } = readTsconfig(dir, 'tsconfig.json');
const { config: parsed } = parseTsconfigText(
readFileSync(join(ROOT, dir, 'tsconfig.json'), 'utf8'),
`${dir}/tsconfig.json`,
);
const unreachable = hiddenTests.filter(
(rel) => !roots.some((root) => root === '' || rel === root || rel.startsWith(`${root}/`)),
);
Expand Down Expand Up @@ -5051,6 +5082,8 @@ const SELF_TEST_BATTERIES = Object.freeze({
// The closure build's shared-verify-lock contract: 11 verdict-reader rows, 2
// wiring pins, 6 real-lock cases on a private lock file.
'verify-lock cases (#23470)': 19,
// parseTsconfigText: what tsc accepts reads, what tsc refuses still refuses.
'JSONC tsconfig reader (#25697)': 8,
});

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

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

// ── THE JSONC TSCONFIG READER (#25697) ─────────────────────────────────────
//
// tsc reads tsconfig files as JSON with comments and trailing commas, and so
// must this gate: the accepted rows below are all shapes tsc compiles, and
// the first is objectui's root config's own (a block comment between keys).
// The refused rows are what keeps "tolerant" from meaning "anything parses":
// each one used to throw under JSON.parse and must still come back as an
// error, never as a config that excludes nothing.
const jsoncCases = [
{
label: 'a block comment between keys reads',
text: '{\n "compilerOptions": {\n "skipLibCheck": true,\n\n /* Bundler mode */\n'
+ ' "moduleResolution": "bundler"\n },\n "include": ["src"]\n}\n',
include: ['src'],
},
{
label: 'trailing commas in an object and an array read',
text: '{ "include": ["src", "test",], "exclude": ["**/*.test.ts",], }',
include: ['src', 'test'],
exclude: ['**/*.test.ts'],
},
{
label: 'a whole-line `//` comment still reads, as it did before',
text: '// the base config\n{ "include": ["src/**/*"] }',
include: ['src/**/*'],
},
{
label: 'a `//` inside a string value is the value, not a comment',
text: '{ "include": ["src"], "compilerOptions": { "baseUrl": "http://x//y" } }',
include: ['src'],
},
{ label: 'a truncated document is refused', text: '{ "include": [', refused: true },
{ label: 'a merge-conflict marker is refused', text: '<<<<<<< HEAD\n{ "include": ["src"] }', refused: true },
{ label: 'a root that is not an object is refused', text: '["src"]', refused: true },
{ label: 'a document holding only a comment is refused, not read as {}', text: '// nothing here\n', refused: true },
];
for (const c of jsoncCases) {
registerCase('JSONC tsconfig reader (#25697)');
const got = parseTsconfigText(c.text, 'fixture/tsconfig.json');
if (c.refused) {
if (got.error === undefined) {
failures.push(`parseTsconfigText — ${c.label}: read as ${JSON.stringify(got.config)} instead of refused`);
}
continue;
}
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
if (got.error !== undefined || !same(got.config.include, c.include) || !same(got.config.exclude, c.exclude)) {
failures.push(`parseTsconfigText — ${c.label}: got ${JSON.stringify(got)}`);
}
}

// The shared workspace enumerator is a plain module with no CI invocation of
// its own (#11510); every gate that consolidated onto it folds in its checks.
// ⛔ NOT a battery of this file either, for the same reason as the fold-in
Expand Down
Loading
Loading