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..ffacd82700881 --- /dev/null +++ b/packages/cli/test/drift/docs-example-apps.test.mjs @@ -0,0 +1,228 @@ +// 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. 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'; + +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/'; + +/** + * 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, 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 = 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 + // 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); + }); + + 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); + }); +});