diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 7053696a67..f95dc24aa5 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -1471,8 +1471,10 @@ below for the half of that mechanism which is still live. workflow; manual. **It carries no `pull_request` trigger, on purpose.** No published package's build output may contain tooling material — `__tests__/`, `__mocks__/`, -`__benchmarks__/`, `*.test.*`, `*.spec.*`, `*.bench.*`, `*.stories.*`. The gate is -`scripts/check-published-dist-tooling.mjs` (`pnpm check:published-dist`); it builds every +`__benchmarks__/`, `*.test.*`, `*.spec.*`, `*.bench.*`, `*.stories.*`, or a `*.tsbuildinfo` build +record ([#7003](https://github.com/objectstack-ai/objectui/issues/7003): a record has no tooling +source to be traced back to, and it names every input path on the machine that produced it). The +gate is `scripts/check-published-dist-tooling.mjs` (`pnpm check:published-dist`); it builds every published package itself, then reads each one's tarball file list from `npm pack --dry-run`. Three things about it are easy to get wrong and are written down in the script's own header diff --git a/scripts/__tests__/check-published-dist-tooling.test.ts b/scripts/__tests__/check-published-dist-tooling.test.ts index 4d876109d3..cea6e6a39d 100644 --- a/scripts/__tests__/check-published-dist-tooling.test.ts +++ b/scripts/__tests__/check-published-dist-tooling.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { TOOLING_FILE } from '../check-phantom-dependencies.mjs'; import { BUILD_OUTPUT_DIRS, + BUILD_RECORD, MIN_PACKAGES, PUBLISHED_TOOLING_FILE, analyze, @@ -47,6 +48,13 @@ import { * 7. **The gate is wired into the publish path**, which is where the ruling put * it (comment 5307574139), and into a nightly workflow — and NOT into a * per-PR job. + * 8. **A build record is refused even though it has no tooling SOURCE.** + * objectui#7003 measured the blind spot: every criterion above traces an + * artifact back to a source the convention names, and a `*.tsbuildinfo` is + * a compiler by-product with no such source, so `PUBLISHED_TOOLING_FILE` + * matches nothing about it. `BUILD_RECORD` is the second, artifact-only + * term; the cases below read it from the gate rather than retyping the + * suffix, so the two cannot disagree about what a build record IS. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const GATE = 'scripts/check-published-dist-tooling.mjs'; @@ -161,6 +169,58 @@ describe('the artifact criterion vs. the source criterion', () => { }); }); +// ── 2b. build records, which have no tooling source at all ─────────────────── + +describe('build records — objectui#7003', () => { + /** Spellings `tsc` writes, built from the gate's own output-directory list. */ + const records = BUILD_OUTPUT_DIRS.flatMap((dir) => [ + `${dir}/tsconfig.tsbuildinfo`, + `${dir}/tsconfig.build.tsbuildinfo`, + `${dir}/.tsbuildinfo`, + `${dir}/nested/deep/tsconfig.tsbuildinfo`, + ]); + + it('is exactly the blind spot the card measured: invisible to the derived convention', () => { + // The whole finding of objectui#7003 in one assertion. `PUBLISHED_TOOLING_FILE` + // matches a tooling MARKER — a directory name or a stem — because every + // artifact it grades was emitted from a file somebody wrote. A build record + // was written by the compiler about the build, so it carries no marker, and + // the gate built for tooling material in published output saw nothing. + for (const record of records) { + expect(PUBLISHED_TOOLING_FILE.test(record), `${record} via PUBLISHED_TOOLING_FILE`).toBe(false); + expect(TOOLING_FILE.test(record), `${record} via TOOLING_FILE`).toBe(false); + expect(isToolingArtifact(record), `${record} via isToolingArtifact`).toBe(true); + } + }); + + it('reads the pattern off the gate instead of retyping the suffix', () => { + // The derived-never-retyped property, applied to the new term: the predicate + // is the union of the two exported patterns and nothing else, so a third + // spelling cannot appear in the gate without appearing here. + for (const path of [ + ...records, + 'tsconfig.tsbuildinfo', + 'dist/index.js', + 'dist/a.test.d.ts', + 'src/a.test.ts', + 'dist/__tests__/a.d.ts', + ]) { + expect(isToolingArtifact(path), path).toBe(PUBLISHED_TOOLING_FILE.test(path) || BUILD_RECORD.test(path)); + } + }); + + it('does not fire on names that merely contain the word', () => { + for (const clean of [ + 'dist/index.js', + 'dist/tsbuildinfo.js', + 'dist/tsconfig.tsbuildinfo.js', + 'dist/a.tsbuildinfo/index.js', // a DIRECTORY so named — anchored to the last segment + ]) { + expect(isToolingArtifact(clean), clean).toBe(false); + } + }); +}); + // ── 3. the verdicts, including the vacuous one ─────────────────────────────── describe('outputDirOf', () => { @@ -201,6 +261,40 @@ describe('auditPackedFiles', () => { expect(findings[0].dir).toBe('packages/core'); }); + it('names a build record inside the build output — objectui#7003', () => { + const { findings, counters } = auditPackedFiles(pkg('@object-ui/core'), [ + 'dist/index.js', + 'dist/tsconfig.tsbuildinfo', + 'dist/chunks/tsconfig.build.tsbuildinfo', + ]); + expect(findings.map((f: Finding) => f.reason)).toEqual([ + 'tooling-in-published-output', + 'tooling-in-published-output', + ]); + expect(findings.map((f: Finding) => f.file)).toEqual([ + 'dist/tsconfig.tsbuildinfo', + 'dist/chunks/tsconfig.build.tsbuildinfo', + ]); + expect(findings[0].pkg).toBe('@object-ui/core'); + expect(findings[0].dir).toBe('packages/core'); + expect(counters.tooling).toBe(2); + }); + + it('leaves the record at its REAL location alone — the package root', () => { + // Where every one of this repository's 30 composite packages writes its + // record today, and the state option B would change. It is not in any + // package's `files` list, so it does not reach a tarball at all — but this + // function must not flag it even when handed one, or the gate would red a + // tree that ships nothing wrong. + const { findings, counters } = auditPackedFiles(pkg('@object-ui/core'), [ + 'tsconfig.tsbuildinfo', + 'dist/index.js', + 'dist/index.d.ts', + ]); + expect(findings).toEqual([]); + expect(counters.tooling).toBe(0); + }); + it('reports a tarball with NO build output instead of passing it — the vacuous verdict', () => { // The whole reason this gate is not "check dist/ if it exists": with no // per-PR full-repo build, `dist/` is usually absent, and a gate that skips diff --git a/scripts/check-published-dist-tooling.mjs b/scripts/check-published-dist-tooling.mjs index 281fb2ff0b..6dd438573b 100644 --- a/scripts/check-published-dist-tooling.mjs +++ b/scripts/check-published-dist-tooling.mjs @@ -110,6 +110,22 @@ * per-package (a tsconfig `exclude` that names `*.test.ts` but not * `__tests__/`, a bundler entry that reaches a mock) and the fix belongs in * that package's build config, as PR #4845 did for four of them. + * + * ## Build RECORDS are refused too, and they have no tooling source (objectui#7003) + * + * Everything above traces an ARTIFACT back to a tooling SOURCE: `dist/a.test.d.ts` + * is refused because `src/a.test.ts` exists and the convention names that stem. + * An incremental build record (`*.tsbuildinfo`) has no such source — it is a + * by-product of the compiler, not the emit of a file anyone wrote — so it + * matches nothing `PUBLISHED_TOOLING_FILE` describes, and objectui#7003 measured + * that blind spot on this gate before anything had shipped through it. It is + * material a consumer must not receive for the same reason the rest of this + * gate exists, and a sharper one: the record NAMES EVERY INPUT PATH on the + * machine that produced it. Every affected package publishes by directory + * (`files: ["dist", …]`), so a record written inside the build output ships + * whole. `BUILD_RECORD` below is therefore a SECOND, artifact-only term rather + * than an addition to `TOOLING_FILE`: see its own docblock for why the shared + * convention is the wrong home for it. */ import { execFileSync } from 'node:child_process'; @@ -186,8 +202,51 @@ export const PUBLISHED_TOOLING_FILE = new RegExp( `(^|/)(${CONVENTION.directories})/|\\.(${CONVENTION.stems})\\.[^/]*$`, ); -/** Whether a tarball entry is tooling material by this repository's convention. */ -export const isToolingArtifact = (path) => PUBLISHED_TOOLING_FILE.test(path); +/** + * An incremental build RECORD, wherever a build wrote one. + * + * ## Why this is a separate term and not a third alternation in `TOOLING_FILE` + * + * Three reasons, and the first is mechanical (objectui#7003): + * + * 1. It would not arrive. `toolingConventionFrom` extracts exactly TWO halves + * out of `TOOLING_FILE.source` — the directory alternation and the stem + * alternation — and `PUBLISHED_TOOLING_FILE` is rebuilt from those two. A + * third alternation added over there is dropped here silently: the sibling + * gate would change behaviour and this one would not, which is the exact + * drift the derivation exists to prevent. + * 2. `TOOLING_FILE` grades SOURCE files, and a build record is not one. Its + * five other readers walk source trees filtered by `SOURCE_FILE` + * (`\.[cm]?[jt]sx?$`), so the term would be inert in all of them — a rule + * declared in a place that never honours it. + * 3. `check-published-tsconfig-tooling-exclude.mjs` turns that convention into + * tsconfig `exclude` patterns. Excluding a `.tsbuildinfo` from a program is + * meaningless: `tsc` writes the record, it never reads one as an input. + * + * ## What it recognises, and what it cannot + * + * Any basename, at any depth, ending `.tsbuildinfo` — `tsconfig.tsbuildinfo`, + * `tsconfig.build.tsbuildinfo`, a bare `.tsbuildinfo`, and the same names in a + * nested directory. That suffix IS this repository's spelling for the artifact: + * `turbo.json`'s build `outputs` name a recursive glob over `*.tsbuildinfo` and + * `.gitignore` ignores the same one, so a record renamed away from that suffix + * would already be uncached and untracked. + * + * It cannot recognise a record whose `tsBuildInfoFile` points at an arbitrary + * name with another extension; nothing readable from the tarball distinguishes + * that file from an emitted one. Stated rather than papered over with a list of + * guessed artifact names — a wrong guess would red a clean package, and the + * repository-wide convention above is the thing actually worth enforcing. + */ +export const BUILD_RECORD = /(^|\/)[^/]*\.tsbuildinfo$/; + +/** + * Whether a tarball entry is tooling material by this repository's convention. + * + * The union of the two terms: material traced back to a tooling SOURCE, and + * build records, which have none (objectui#7003). + */ +export const isToolingArtifact = (path) => PUBLISHED_TOOLING_FILE.test(path) || BUILD_RECORD.test(path); /** The build output directory an entry belongs to, or `null`. */ export function outputDirOf(path) { @@ -390,7 +449,12 @@ const HINTS = { `(${CONVENTION.directories.split('|').join(', ')}) from the EMITTING program, not just the ` + '`*.test.*` name — that name-vs-directory mismatch is what shipped in objectui#4006 and again ' + 'in objectui#4836. If the file loses its type coverage with the emit, name it in the package\'s ' + - '`tsconfig.test.json` (PR #4845 did exactly this for `core.bench.ts`).', + '`tsconfig.test.json` (PR #4845 did exactly this for `core.bench.ts`). If the file is a ' + + '`*.tsbuildinfo` BUILD RECORD the remedy is a different one, because it has no tooling source ' + + 'and no `exclude` can stop it: point that package\'s `tsBuildInfoFile` outside the published ' + + 'build output, or leave it at its default (the package root), since `files: ["dist", …]` ' + + 'publishes that directory whole and the record names every input path on the machine that ' + + 'produced it (objectui#7003).', 'no-build-output': 'A published package produced nothing this gate could inspect. Either its build did not run ' + '(re-run without `--no-build`), or it now emits outside BUILD_OUTPUT_DIRS, or its `files` field ' + @@ -461,8 +525,9 @@ if (invokedDirectly) { console.log( `src-tier (reported, not enforced — see the scope note in this gate): ${counters.srcTierTooling} ` + `tooling file(s) ship OUTSIDE the build output of ${srcTierPackages.join(', ')}, because those ` + - 'packages list `src` in `files`. That is objectui#4851, a different card — see the scope note ' + - 'in scripts/check-published-dist-tooling.mjs.', + 'packages list `src` in `files` (objectui#4851) or publish a build record from outside a ' + + 'build output directory (objectui#7003). Both are reported rather than enforced — see the ' + + 'scope note in scripts/check-published-dist-tooling.mjs.', ); }