From 8d7bfabd2d9d4ff92a3a24a176656a27b5c296e0 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Sun, 20 Sep 2026 06:00:34 +0000 Subject: [PATCH 1/2] fix(cli): stop citing a Babel example app as the SWC StyleX setup `astryx docs styling` told readers the working Next.js path is an SWC StyleX transform and to see `apps/example-nextjs-stylex` "for a complete, working Next.js + StyleX + SWC configuration". That app carries no SWC transform: it compiles StyleX with @stylexjs/babel-plugin and @stylexjs/postcss-plugin, the setup the same section tells readers not to use on App Router. A reader who follows the citation copies the configuration the docs just warned against. Point the reader at the snippet that follows instead, and say what the example app is actually good for. Add a drift guard so a doc sentence can only offer an in-repo example app as an SWC setup when that app declares a @stylexswc/* compiler. --- packages/cli/assets/docs/styling.doc.mjs | 2 +- .../cli/test/drift/docs-example-apps.test.mjs | 117 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/drift/docs-example-apps.test.mjs diff --git a/packages/cli/assets/docs/styling.doc.mjs b/packages/cli/assets/docs/styling.doc.mjs index 34e8b77c5bc17..1a1e7dadbe72e 100644 --- a/packages/cli/assets/docs/styling.doc.mjs +++ b/packages/cli/assets/docs/styling.doc.mjs @@ -366,7 +366,7 @@ const styles = stylex.create({ }, { type: 'prose', - text: 'The working path on Next.js is an SWC-based StyleX transform (e.g. the community `@stylexswc/nextjs-plugin`) wired into `next.config`, which keeps SWC and `next/font` intact. See the example app `apps/example-nextjs-stylex` in the repo for a complete, working Next.js + StyleX + SWC configuration.', + text: 'The working path on Next.js is an SWC-based StyleX transform (e.g. the community `@stylexswc/nextjs-plugin`) wired into `next.config`, which keeps SWC and `next/font` intact. The snippet below is the whole wiring. The repo ships no SWC example app: `apps/example-nextjs-stylex` compiles StyleX with the Babel plugin and PostCSS, so read it for the PostCSS layer setup, not for the transform.', }, { type: 'code', diff --git a/packages/cli/test/drift/docs-example-apps.test.mjs b/packages/cli/test/drift/docs-example-apps.test.mjs new file mode 100644 index 0000000000000..2a6105fe2b158 --- /dev/null +++ b/packages/cli/test/drift/docs-example-apps.test.mjs @@ -0,0 +1,117 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Drift guard for the example apps the shipped docs point readers at. + * + * `astryx docs ` renders `packages/cli/assets/docs/*.doc.mjs` verbatim, + * so a claim about an in-repo example app is not a stale comment in our repo — + * it is wrong instruction in someone else's terminal. The claim that rots + * silently is the *kind* of build an app demonstrates: a doc can recommend an + * SWC StyleX transform and cite an example that compiles StyleX with Babel, + * and nothing in the repo notices, because the cited path still exists. + * + * What is checked — derived from live sources, never hardcoded: a sentence that + * offers an in-repo example app *as* an SWC configuration must name an app that + * actually carries one, i.e. depends on a `@stylexswc/*` compiler. The unit is + * the sentence, not the prose block: a block may legitimately recommend SWC and + * then name a Babel example for a different purpose, and saying so is what a + * correction looks like. + * + * @position packages/cli/test/drift — colocated-docs drift harness + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {describe, it, expect} from 'vitest'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../../..'); +const DOCS_DIR = path.join(REPO_ROOT, 'packages/cli/assets/docs'); + +const EXAMPLE_APP = /apps\/(example-[a-z0-9-]+)/g; +const SWC_SCOPE = '@stylexswc/'; + +/** + * A sentence presents an app as an SWC setup when it says SWC and does not + * say the app is something else. `not` / `rather than` / `instead of` are how + * the docs disclaim a citation, so a sentence carrying one is a correction + * about that app, not a recommendation of it. + */ +const SWC_CLAIM = /\bSWC\b/; +const DISCLAIMED = /\b(not|rather than|instead of|no SWC)\b/; + +/** @returns {Promise<{file: string, doc: any}[]>} the shipped reference docs. */ +async function collectShippedDocs() { + const files = fs + .readdirSync(DOCS_DIR) + .filter(f => f.endsWith('.mjs')) + .map(f => path.join(DOCS_DIR, f)); + const out = []; + for (const f of files) { + const mod = await import(pathToFileURL(f).href); + const doc = mod.doc ?? mod.docs; + if (doc != null) out.push({file: path.relative(REPO_ROOT, f), doc}); + } + return out; +} + +/** @returns {string[]} every prose string in a doc, in render order. */ +function proseBlocks(doc) { + return (doc.sections ?? []).flatMap(section => + (section.content ?? []) + .filter( + block => block?.type === 'prose' && typeof block.text === 'string', + ) + .map(block => block.text), + ); +} + +/** @returns {boolean} whether an example app compiles StyleX through SWC. */ +function carriesSwcTransform(app) { + const pkgPath = path.join(REPO_ROOT, 'apps', app, 'package.json'); + if (!fs.existsSync(pkgPath)) return false; + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const deps = {...pkg.dependencies, ...pkg.devDependencies}; + return Object.keys(deps).some(name => name.startsWith(SWC_SCOPE)); +} + +describe('shipped docs vs the example apps they cite', () => { + it('offers an example app as an SWC setup only when it carries one', async () => { + const docs = await collectShippedDocs(); + expect(docs.length).toBeGreaterThan(0); + + const wrong = []; + for (const {file, doc} of docs) { + for (const text of proseBlocks(doc)) { + for (const sentence of text.split(/(?<=\.)\s+/)) { + if (!SWC_CLAIM.test(sentence) || DISCLAIMED.test(sentence)) continue; + for (const [, app] of sentence.matchAll(EXAMPLE_APP)) { + if (!carriesSwcTransform(app)) { + wrong.push( + `${file}: offers apps/${app} as an SWC configuration, but ` + + `that app declares no ${SWC_SCOPE}* compiler`, + ); + } + } + } + } + } + + expect(wrong).toEqual([]); + }); + + it('(control) reads the example apps that back the claim', () => { + // Guards the check above against silently passing because it found no apps + // to inspect: `carriesSwcTransform` returns false for a missing package.json + // exactly as it does for a Babel app, so the corpus has to be real. + const apps = fs + .readdirSync(path.join(REPO_ROOT, 'apps'), {withFileTypes: true}) + .filter(e => e.isDirectory() && e.name.startsWith('example-')) + .map(e => e.name); + + expect(apps).toContain('example-nextjs-stylex'); + expect(carriesSwcTransform('example-nextjs-stylex')).toBe(false); + expect(carriesSwcTransform('example-does-not-exist')).toBe(false); + }); +}); From 4493e2603acf1245d15679bc674548c53256e628 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:33:04 +0000 Subject: [PATCH 2/2] test(cli): pin the rendered docs path and the citation rule's boundaries Adds an end-to-end variant over the astryx docs styling output (the text a reader actually receives) plus controls for the dependency-field scope, the multi-app loop, the prose-only corpus and the two known sentence-scope limits. --- .../cli/test/drift/docs-example-apps.test.mjs | 149 +++++++++++++++--- 1 file changed, 130 insertions(+), 19 deletions(-) diff --git a/packages/cli/test/drift/docs-example-apps.test.mjs b/packages/cli/test/drift/docs-example-apps.test.mjs index 2a6105fe2b158..ffacd82700881 100644 --- a/packages/cli/test/drift/docs-example-apps.test.mjs +++ b/packages/cli/test/drift/docs-example-apps.test.mjs @@ -15,12 +15,15 @@ * actually carries one, i.e. depends on a `@stylexswc/*` compiler. The unit is * the sentence, not the prose block: a block may legitimately recommend SWC and * then name a Babel example for a different purpose, and saying so is what a - * correction looks like. + * correction looks like. Both ends of the pipeline are checked — the doc source + * and what the shipped CLI actually prints. * * @position packages/cli/test/drift — colocated-docs drift harness */ +import {spawnSync} from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; import {describe, it, expect} from 'vitest'; @@ -28,6 +31,7 @@ import {describe, it, expect} from 'vitest'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '../../../..'); const DOCS_DIR = path.join(REPO_ROOT, 'packages/cli/assets/docs'); +const BIN = path.join(REPO_ROOT, 'packages/cli/clients/cli/bin/astryx.mjs'); const EXAMPLE_APP = /apps\/(example-[a-z0-9-]+)/g; const SWC_SCOPE = '@stylexswc/'; @@ -68,39 +72,75 @@ function proseBlocks(doc) { } /** @returns {boolean} whether an example app compiles StyleX through SWC. */ -function carriesSwcTransform(app) { - const pkgPath = path.join(REPO_ROOT, 'apps', app, 'package.json'); +function carriesSwcTransform(app, root = REPO_ROOT) { + const pkgPath = path.join(root, 'apps', app, 'package.json'); if (!fs.existsSync(pkgPath)) return false; const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); const deps = {...pkg.dependencies, ...pkg.devDependencies}; return Object.keys(deps).some(name => name.startsWith(SWC_SCOPE)); } +/** + * The rule itself, over one body of text. Sentence-scoped so that a correction + * ("the repo ships no SWC example app: `apps/x` is the Babel one") reads as a + * correction rather than as the defect it describes. + * + * @param {string} text + * @param {string} where label used in the report + * @param {(app: string) => boolean} hasSwc + * @returns {string[]} one message per app offered as SWC that carries none + */ +function badSwcCitations(text, where, hasSwc = carriesSwcTransform) { + const wrong = []; + for (const sentence of text.split(/(?<=\.)\s+/)) { + if (!SWC_CLAIM.test(sentence) || DISCLAIMED.test(sentence)) continue; + for (const [, app] of sentence.matchAll(EXAMPLE_APP)) { + if (!hasSwc(app)) { + wrong.push( + `${where}: offers apps/${app} as an SWC configuration, but ` + + `that app declares no ${SWC_SCOPE}* compiler`, + ); + } + } + } + return wrong; +} + +/** Writes `apps//package.json` under a throwaway root. @returns {string} */ +function fixtureRoot(apps) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-docs-apps-')); + for (const [name, pkg] of Object.entries(apps)) { + const dir = path.join(root, 'apps', name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); + } + return root; +} + describe('shipped docs vs the example apps they cite', () => { it('offers an example app as an SWC setup only when it carries one', async () => { const docs = await collectShippedDocs(); expect(docs.length).toBeGreaterThan(0); - const wrong = []; - for (const {file, doc} of docs) { - for (const text of proseBlocks(doc)) { - for (const sentence of text.split(/(?<=\.)\s+/)) { - if (!SWC_CLAIM.test(sentence) || DISCLAIMED.test(sentence)) continue; - for (const [, app] of sentence.matchAll(EXAMPLE_APP)) { - if (!carriesSwcTransform(app)) { - wrong.push( - `${file}: offers apps/${app} as an SWC configuration, but ` + - `that app declares no ${SWC_SCOPE}* compiler`, - ); - } - } - } - } - } + const wrong = docs.flatMap(({file, doc}) => + proseBlocks(doc).flatMap(text => badSwcCitations(text, file)), + ); expect(wrong).toEqual([]); }); + it('renders no such citation through `astryx docs styling`', () => { + // The doc source is the input; this is the output a reader actually gets. + const res = spawnSync('node', [BIN, 'docs', 'styling'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + expect(res.status).toBe(0); + expect(res.stdout).toMatch(/@stylexswc\/nextjs-plugin/); + + expect(badSwcCitations(res.stdout, 'astryx docs styling')).toEqual([]); + }); + it('(control) reads the example apps that back the claim', () => { // Guards the check above against silently passing because it found no apps // to inspect: `carriesSwcTransform` returns false for a missing package.json @@ -114,4 +154,75 @@ describe('shipped docs vs the example apps they cite', () => { expect(carriesSwcTransform('example-nextjs-stylex')).toBe(false); expect(carriesSwcTransform('example-does-not-exist')).toBe(false); }); + + it('(control) reads an SWC compiler from either dependency field', () => { + // Controls for the scope test itself: `@stylexjs/` and `@stylexswc/` differ + // by two characters, and the whole rule rests on telling them apart. + const root = fixtureRoot({ + 'example-swc-dep': {dependencies: {'@stylexswc/nextjs-plugin': '^0.7.0'}}, + 'example-swc-dev': { + devDependencies: {'@stylexswc/rs-compiler': '^0.7.0'}, + }, + 'example-babel': {devDependencies: {'@stylexjs/babel-plugin': '^0.19.0'}}, + 'example-empty': {name: 'no deps at all'}, + }); + + expect(carriesSwcTransform('example-swc-dep', root)).toBe(true); + expect(carriesSwcTransform('example-swc-dev', root)).toBe(true); + expect(carriesSwcTransform('example-babel', root)).toBe(false); + expect(carriesSwcTransform('example-empty', root)).toBe(false); + }); + + it('(control) checks every app a sentence names, not just the first', () => { + const hasSwc = app => app === 'example-swc'; + const both = + 'Use SWC: see apps/example-swc and apps/example-babel for the setup.'; + + expect(badSwcCitations(both, 'fixture', hasSwc)).toEqual([ + 'fixture: offers apps/example-babel as an SWC configuration, but ' + + 'that app declares no @stylexswc/* compiler', + ]); + expect( + badSwcCitations('Use SWC: see apps/example-swc.', 'fixture', hasSwc), + ).toEqual([]); + }); + + it('(control) scans prose only, and no other block type cites an app as SWC', async () => { + // `proseBlocks` drops tables, lists and code, so a citation in a table cell + // is invisible to the rule. That is a bounded gap only while no non-prose + // block makes the claim — which is what the second half asserts, over the + // live corpus. + const docs = await collectShippedDocs(); + const nonProse = docs.flatMap(({file, doc}) => + (doc.sections ?? []).flatMap(section => + (section.content ?? []) + .filter(block => block?.type !== 'prose') + .map(block => ({file, text: JSON.stringify(block)})), + ), + ); + expect(nonProse.length).toBeGreaterThan(0); + + const escaped = nonProse.flatMap(({file, text}) => + badSwcCitations(text, `${file} (non-prose)`), + ); + expect(escaped).toEqual([]); + }); + + it('(control) is sentence-scoped, so a split claim and a bare "not" slip through', () => { + // Both are known limits of the rule, pinned so a future widening is a + // deliberate edit to this file rather than a silent behaviour change. + const hasSwc = () => false; + + const split = 'The working path is SWC. See apps/example-babel.'; + expect(badSwcCitations(split, 'fixture', hasSwc)).toEqual([]); + + const incidental = + 'Do not skip this: apps/example-babel is the complete SWC setup.'; + expect(badSwcCitations(incidental, 'fixture', hasSwc)).toEqual([]); + + // The same claim without the incidental disclaimer word IS caught, so the + // two cases above are about the disclaimer heuristic, not a dead rule. + const plain = 'apps/example-babel is the complete SWC setup.'; + expect(badSwcCitations(plain, 'fixture', hasSwc)).toHaveLength(1); + }); });