diff --git a/.changes/build-shared-style-cache-inputs.md b/.changes/build-shared-style-cache-inputs.md new file mode 100644 index 000000000..c0761ce13 --- /dev/null +++ b/.changes/build-shared-style-cache-inputs.md @@ -0,0 +1,9 @@ +--- +type: internal +area: build +--- + +Shared UI stylesheets are now part of the build cache key. Edits to them +previously produced a cache hit, so a style fix could be silently missing from +a rebuilt app until the cache was bypassed. A CI check now fails any stylesheet +import that escapes its build's inputs. diff --git a/.codex/skills/iptvnator-ui-design/SKILL.md b/.codex/skills/iptvnator-ui-design/SKILL.md index b1be20fbe..83eecc133 100644 --- a/.codex/skills/iptvnator-ui-design/SKILL.md +++ b/.codex/skills/iptvnator-ui-design/SKILL.md @@ -9,7 +9,7 @@ description: Use when changing user-visible Angular UI in IPTVnator, especially - Policy: `docs/architecture/iptvnator-ui-guidelines.md` - Theme and navigation: `apps/web/src/m3-theme.scss`, - `apps/web/src/nav-list.scss` + `libs/ui/styles/_nav-list.scss` - Channel row: `libs/ui/components/src/lib/channel-list-container/channel-list-item/` - Shared EPG timeline/list: `libs/ui/epg/src/lib/epg-timeline/`, `libs/ui/epg/src/lib/epg-list-view/` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b72d2cba..afab7f93c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,9 @@ jobs: - name: Validate Nx dependency version policy run: pnpm run deps:nx:validate + - name: Validate stylesheet Nx inputs + run: pnpm run styles:inputs:validate + - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci diff --git a/AGENTS.md b/AGENTS.md index 32f1cb395..0b8dc3676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,13 @@ This file provides guidance to coding agents working in this repository. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - Keep `nx` and every official `@nx/*` package on the same exact version; run `pnpm run deps:nx:validate` after dependency updates. +- A directory holding files consumed by other projects must be an Nx project. + Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` + across project roots creates no edge and the imported file lands in no task + hash — edits then return a cache hit instead of rebuilding. Shared partials + live in `libs/ui/styles` (project `ui-styles`), and each consumer declares + `"implicitDependencies": ["ui-styles"]`. Run `pnpm run styles:inputs:validate` + after adding a cross-project stylesheet import. - Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the lockfile, run generated migrations when present, and validate before opening a PR. Major updates are always manual. Replace incomplete Dependabot security diff --git a/CLAUDE.md b/CLAUDE.md index b228386c9..cb66a3e82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,13 @@ pnpm nx show projects - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - Keep `nx` and every official `@nx/*` package on the same exact version; run `pnpm run deps:nx:validate` after dependency updates. +- A directory holding files consumed by other projects must be an Nx project. + Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` + across project roots creates no edge and the imported file lands in no task + hash — edits then return a cache hit instead of rebuilding. Shared partials + live in `libs/ui/styles` (project `ui-styles`), and each consumer declares + `"implicitDependencies": ["ui-styles"]`. Run `pnpm run styles:inputs:validate` + after adding a cross-project stylesheet import. - Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the lockfile, run generated migrations when present, and validate before opening a PR. Major updates are always manual. Replace incomplete Dependabot security diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 4ff2b5b60..734b276b4 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -37,7 +37,7 @@ Use it when changing existing views or introducing new list-based UI in the work - Shared EPG list styles: `libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss` - Shared list selection style: - `apps/web/src/nav-list.scss` + `libs/ui/styles/_nav-list.scss` - Theme tokens: `apps/web/src/m3-theme.scss` - Settings surfaces: diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 202ab7cee..63faab2bc 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -116,6 +116,60 @@ For a buildable library that has a local `package.json`, its `name` must match the scoped alias. Nx uses that package name when rewriting buildable dependency paths to `dist/` during `@nx/js:tsc` builds. +## Shared Stylesheets and Cache Inputs + +Nx derives the project graph from TypeScript imports. A relative Sass `@use` +that crosses a project root creates **no** graph edge, so without an explicit +declaration the imported partial belongs to no task's input set. The build then +reports a cache hit for a stylesheet edit and serves the previous CSS — a +silent wrong build rather than a failure. + +Two rules keep that from happening: + +1. A directory whose files are consumed by another project is itself an Nx + project. Shared partials live in `libs/ui/styles`, project `ui-styles`, + tagged `scope:shared`, `domain:shared-ui`, `type:ui`. It declares no targets; + it exists so its files are hashed. +2. Every consumer declares the dependency Nx cannot infer: + + ```json + "implicitDependencies": ["ui-styles"] + ``` + +`@nx/enforce-module-boundaries` does not read stylesheets, so tag directions are +not enforced here — keep consumers at `type:feature` or `type:ui`, both of which +may depend on `type:ui`. + +Importing a partial that the consuming **application** owns is a different case +and needs no declaration, because that partial already sits inside the app's own +build inputs. It is still the wrong direction, and it is the one case the two +rules above cannot repair: a lib → app edge would make the graph cyclic, since +the app already depends on those libraries. Move the partial into `ui-styles` +instead. No library stylesheet imports from `apps/` today — keep it that way. + +`pnpm run styles:inputs:validate` enforces both rules. It resolves every +relative `@use`/`@forward`/`@import` in the workspace against Nx's own project +graph and fails when an imported stylesheet sits outside the input closure of a +build that compiles it, naming the project to declare. Comment-only example +paths are ignored, so the documentation blocks inside the shared partials do not +register as broken imports. CI runs it in the `unit-and-typecheck` job. + +Only a module Sass actually compiles counts as an input. `@import` is the one +rule that takes a comma-separated list, and **every** target in it is a separate +dependency — reading just the first would let a later cross-project target +escape the cache key while the check still passed. A quoted string after the +module in `@use`/`@forward` belongs to a `with (...)` configuration and is a +value, and `url(...)` stays a plain CSS import the browser resolves at runtime; +neither is a build input, and treating either as one would report a phantom +broken import. + +Verify a suspected caching gap directly — add a comment to a partial, run the +consuming build, and confirm the task runs instead of reporting a cache hit: + +```bash +pnpm nx build web --verbose +``` + ## TypeScript File Size `tools/eslint/max-lines-config.mjs` is the single source of truth: @@ -145,6 +199,14 @@ An unquoted `**` can expand to a shallow subset on POSIX while still returning success. After editing such a target, compare ESLint's linted-file count with the `find` count. +Repository tooling in `tools/` has the mirror-image trap: Node's `execSync` +runs through `cmd.exe` on Windows, where single quotes are literal characters +rather than quoting, so a POSIX-quoted pattern reaches the program intact and +matches nothing. Spawn without a shell — `execFileSync('git', ['ls-files', +'*.scss'])` — and let the program expand its own patterns. Both traps report +success while covering nothing, so a check that scans an empty file set must +fail rather than pass. + ## CI Enforcement The CI lint job runs affected projects on pull requests and all projects on diff --git a/libs/playlist/m3u/feature-player/project.json b/libs/playlist/m3u/feature-player/project.json index 4a61408e9..b4f0b7fb5 100644 --- a/libs/playlist/m3u/feature-player/project.json +++ b/libs/playlist/m3u/feature-player/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:playlist", "domain:m3u", "type:feature"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "nx:run-commands", diff --git a/libs/portal/catalog/feature/project.json b/libs/portal/catalog/feature/project.json index 58afa7f34..7ef463a17 100644 --- a/libs/portal/catalog/feature/project.json +++ b/libs/portal/catalog/feature/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:portal", "domain:portal-shared", "type:feature"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/portal/shared/ui/project.json b/libs/portal/shared/ui/project.json index 26214a466..553aa9293 100644 --- a/libs/portal/shared/ui/project.json +++ b/libs/portal/shared/ui/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:portal", "domain:portal-shared", "type:ui"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss index 6425c6145..d80496297 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss @@ -1,4 +1,4 @@ -@use '../../../../../../../../apps/web/src/nav-list.scss'; +@use '../../../../../../../ui/styles/nav-list'; :host { display: block; diff --git a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss index dda2ea519..32cc9ab6a 100644 --- a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss +++ b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss @@ -1,4 +1,4 @@ -@use '../../../../../../../apps/web/src/nav-list.scss'; +@use '../../../../../../ui/styles/nav-list'; :host { display: block; diff --git a/libs/portal/stalker/feature/project.json b/libs/portal/stalker/feature/project.json index 820cd6c30..409e94294 100644 --- a/libs/portal/stalker/feature/project.json +++ b/libs/portal/stalker/feature/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:portal", "domain:stalker", "type:feature"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/portal/xtream/feature/project.json b/libs/portal/xtream/feature/project.json index 509357de9..2d6c8b60a 100644 --- a/libs/portal/xtream/feature/project.json +++ b/libs/portal/xtream/feature/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:portal", "domain:xtream", "type:feature"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/ui/components/project.json b/libs/ui/components/project.json index 305d26560..a82a83f47 100644 --- a/libs/ui/components/project.json +++ b/libs/ui/components/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:shared", "domain:shared-ui", "type:ui"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "nx:run-commands", diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index 61c8f61b4..9dd48eeed 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -1,7 +1,7 @@ @use '@angular/material' as mat; @use '../../../../../styles/portal-sidebar'; @use '../../../../../styles/panel-header' as panel; -@use '../../../../../../../apps/web/src/nav-list.scss'; +@use '../../../../../styles/nav-list'; :host { display: flex; diff --git a/libs/ui/playback/project.json b/libs/ui/playback/project.json index 5e1117fe5..fc13d965e 100644 --- a/libs/ui/playback/project.json +++ b/libs/ui/playback/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:shared", "domain:playback", "type:ui"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "nx:run-commands", diff --git a/libs/ui/styles/_index.scss b/libs/ui/styles/_index.scss index c038c32ef..88a0b1947 100644 --- a/libs/ui/styles/_index.scss +++ b/libs/ui/styles/_index.scss @@ -10,3 +10,4 @@ @forward 'panel-header'; @forward 'detail-view'; @forward 'detail-view-actions'; +@forward 'nav-list'; diff --git a/apps/web/src/nav-list.scss b/libs/ui/styles/_nav-list.scss similarity index 90% rename from apps/web/src/nav-list.scss rename to libs/ui/styles/_nav-list.scss index dfb6df175..1496622d2 100644 --- a/apps/web/src/nav-list.scss +++ b/libs/ui/styles/_nav-list.scss @@ -1,4 +1,9 @@ // ─── Shared List Navigation Styles ──────────────────────────────────────────── +// Plain CSS rules (not a mixin) for sidebar and context-panel list items, so no +// alias is needed. Consumers live in several libraries at different depths; +// pick the relative @use path from the consuming stylesheet, for example: +// @use '../../../../../../ui/styles/nav-list'; + .nav-list { flex: 1; overflow-y: auto; diff --git a/libs/ui/styles/project.json b/libs/ui/styles/project.json new file mode 100644 index 000000000..d3c87345b --- /dev/null +++ b/libs/ui/styles/project.json @@ -0,0 +1,7 @@ +{ + "name": "ui-styles", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/styles", + "projectType": "library", + "tags": ["scope:shared", "domain:shared-ui", "type:ui"] +} diff --git a/libs/workspace/shell/feature/project.json b/libs/workspace/shell/feature/project.json index b35d3333d..8e4c32d67 100644 --- a/libs/workspace/shell/feature/project.json +++ b/libs/workspace/shell/feature/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:workspace", "domain:workspace", "type:feature"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss index 01c956545..26a9bc4be 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss @@ -1,4 +1,4 @@ -@use '../../../../../../../../apps/web/src/nav-list.scss'; +@use '../../../nav-list.scss'; :host { display: block; diff --git a/libs/workspace/shell/feature/src/nav-list.scss b/libs/workspace/shell/feature/src/nav-list.scss index 79b6f4692..74e963432 100644 --- a/libs/workspace/shell/feature/src/nav-list.scss +++ b/libs/workspace/shell/feature/src/nav-list.scss @@ -1 +1,3 @@ -@use '../../../../../apps/web/src/nav-list.scss'; +// Local entry point for this library's context-panel stylesheets. +// Canonical source: libs/ui/styles/_nav-list.scss +@use '../../../../ui/styles/nav-list'; diff --git a/package.json b/package.json index 3d3247f17..7cfd3dd6b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ "deps:nx:test": "node --test tools/dependencies/check-nx-version-sync.test.mjs", "deps:nx:check": "node tools/dependencies/check-nx-version-sync.mjs", "deps:nx:validate": "pnpm run deps:nx:test && pnpm run deps:nx:check", + "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", + "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", + "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", diff --git a/tools/nx/check-stylesheet-inputs.mjs b/tools/nx/check-stylesheet-inputs.mjs new file mode 100644 index 000000000..8e009f1f3 --- /dev/null +++ b/tools/nx/check-stylesheet-inputs.mjs @@ -0,0 +1,226 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const STYLESHEET_RULE = /@(use|forward|import)\s+([^;{}]*)/g; +const QUOTED_TARGET = /(['"])([^'"]+)\1/g; +const CSS_URL = /url\([^)]*\)/g; + +/** + * `@import` is the only rule that accepts a comma-separated list, and every + * entry in it is a separate dependency: reading just the first would let a + * later cross-project target escape the cache key while the check still + * passes. `@use`/`@forward` load exactly one module, so a quoted string after + * the first belongs to a `with (...)` configuration and is a value, not a + * dependency. `url(...)` is a plain CSS import the browser resolves at + * runtime, so Sass never compiles it and it is not a build input either. + */ +function targetsOfRule(rule, clause) { + const quoted = [ + ...clause.replace(CSS_URL, ' ').matchAll(QUOTED_TARGET), + ].map((match) => match[2]); + return rule === 'import' ? quoted : quoted.slice(0, 1); +} + +/** + * Sass documents relative `@use` examples inside comments. Those paths do not + * resolve from the file that documents them, so scanning raw source reports + * them as broken imports. + */ +export function stripScssComments(source) { + const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, ' '); + return withoutBlocks + .split('\n') + .map((line) => { + const commentStart = line.search(/(^|[^:])\/\//); + if (commentStart === -1) return line; + return line.slice( + 0, + line[commentStart] === '/' ? commentStart : commentStart + 1 + ); + }) + .join('\n'); +} + +export function extractRelativeImports(source) { + const specifiers = []; + const stripped = stripScssComments(source); + for (const [, rule, clause] of stripped.matchAll(STYLESHEET_RULE)) { + for (const target of targetsOfRule(rule, clause)) { + if (target.startsWith('.')) specifiers.push(target); + } + } + return specifiers; +} + +/** Mirrors Sass partial resolution for a relative specifier. */ +export function resolveStylesheet( + fromFile, + specifier, + fileExists = (candidate) => existsSync(candidate) +) { + const target = path.resolve(path.dirname(fromFile), specifier); + const dir = path.dirname(target); + const base = path.basename(target); + const candidates = [ + target, + `${target}.scss`, + path.join(dir, `_${base}.scss`), + path.join(target, '_index.scss'), + path.join(target, 'index.scss'), + ]; + return candidates.find((candidate) => fileExists(candidate)) ?? null; +} + +function closureOf(graph, start) { + const reachable = new Set(); + const stack = [start]; + while (stack.length > 0) { + const current = stack.pop(); + if (reachable.has(current)) continue; + reachable.add(current); + for (const dependency of graph.dependencies[current] ?? []) { + stack.push(dependency.target); + } + } + return reachable; +} + +/** + * A stylesheet only participates in a build's cache key when its owning project + * is inside that build's input closure. A cross-project import that escapes the + * closure is served from a stale cache instead of being recompiled. + */ +export function validateStylesheetInputs({ imports, graph }) { + const diagnostics = []; + const buildClosures = Object.entries(graph.nodes) + .filter(([, node]) => node.data?.targets?.build) + .map(([name]) => [name, closureOf(graph, name)]); + + for (const entry of imports) { + if (!entry.sourceProject) { + diagnostics.push( + `${entry.sourceFile} belongs to no Nx project, so its contents are outside every task hash. Give the directory a project.json.` + ); + continue; + } + if (entry.sourceProject === entry.targetProject) continue; + + if (!entry.targetProject) { + diagnostics.push( + `${entry.sourceFile} imports "${entry.specifier}" (${entry.targetFile}), which belongs to no Nx project. Give that directory a project.json so edits invalidate dependent builds.` + ); + continue; + } + + for (const [buildProject, closure] of buildClosures) { + if (!closure.has(entry.sourceProject)) continue; + if (closure.has(entry.targetProject)) continue; + diagnostics.push( + `${buildProject}:build compiles ${entry.sourceFile}, which imports ${entry.targetFile} from project "${entry.targetProject}" — a project outside that build's input closure, so edits to it are served from a stale cache. Add "implicitDependencies": ["${entry.targetProject}"] to the "${entry.sourceProject}" project.` + ); + } + } + + return diagnostics; +} + +/** + * A check that scanned nothing must never report success. The workspace always + * contains stylesheets, so an empty listing means the scan broke — the failure + * mode a shell-quoted pathspec produced on Windows, where `git` received the + * quote characters literally, matched no files and still exited 0. + */ +export function validateScanCoverage(files) { + if (files.length > 0) return []; + return [ + 'No stylesheets were scanned. The workspace always contains SCSS, so an empty listing means the file scan failed rather than that the policy passed.', + ]; +} + +function ownerOf(projectRoots, absoluteFile) { + let owner = null; + for (const [name, root] of projectRoots) { + const prefix = `${root}${path.sep}`; + if (absoluteFile === root || absoluteFile.startsWith(prefix)) { + if (!owner || root.length > owner.root.length) + owner = { name, root }; + } + } + return owner?.name ?? null; +} + +export async function collectStylesheetImports({ rootDir, files, graph }) { + const projectRoots = Object.entries(graph.nodes).map(([name, node]) => [ + name, + path.resolve(rootDir, node.data.root), + ]); + const imports = []; + + for (const file of files) { + const absolute = path.resolve(rootDir, file); + const source = await readFile(absolute, 'utf8'); + for (const specifier of extractRelativeImports(source)) { + const resolved = resolveStylesheet(absolute, specifier); + if (!resolved) { + imports.push({ + sourceFile: file, + specifier, + targetFile: '(unresolved)', + sourceProject: ownerOf(projectRoots, absolute), + targetProject: null, + }); + continue; + } + imports.push({ + sourceFile: file, + specifier, + targetFile: path.relative(rootDir, resolved), + sourceProject: ownerOf(projectRoots, absolute), + targetProject: ownerOf(projectRoots, resolved), + }); + } + } + + return imports; +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const { createProjectGraphAsync } = await import('@nx/devkit'); + + const graph = await createProjectGraphAsync({ exitOnError: true }); + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + const files = execFileSync('git', ['ls-files', '*.scss'], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean); + + const imports = await collectStylesheetImports({ rootDir, files, graph }); + const diagnostics = [ + ...validateScanCoverage(files), + ...validateStylesheetInputs({ imports, graph }), + ]; + + if (diagnostics.length > 0) { + console.error('Stylesheet Nx input policy failed:'); + for (const diagnostic of diagnostics) console.error(`- ${diagnostic}`); + process.exitCode = 1; + } else { + console.log( + `Checked ${imports.length} relative stylesheet imports across ${files.length} files; every imported stylesheet is inside its consuming build's input closure.` + ); + } +} diff --git a/tools/nx/check-stylesheet-inputs.test.mjs b/tools/nx/check-stylesheet-inputs.test.mjs new file mode 100644 index 000000000..c511a3732 --- /dev/null +++ b/tools/nx/check-stylesheet-inputs.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + extractRelativeImports, + resolveStylesheet, + stripScssComments, + validateScanCoverage, + validateStylesheetInputs, +} from './check-stylesheet-inputs.mjs'; + +/** + * Mirrors the real workspace shape: one app with a build target that depends on + * a UI library, plus a shared stylesheet directory the library imports by + * relative path. + */ +function graphWithSharedStyles({ stylesProjectDeclared }) { + const nodes = { + web: { data: { root: 'apps/web', targets: { build: {} } } }, + components: { data: { root: 'libs/ui/components' } }, + }; + const dependencies = { web: [{ target: 'components' }], components: [] }; + + if (stylesProjectDeclared) { + nodes['ui-styles'] = { data: { root: 'libs/ui/styles' } }; + dependencies['ui-styles'] = []; + dependencies.components = [{ target: 'ui-styles' }]; + } + + return { nodes, dependencies }; +} + +const sharedStyleImport = (targetProject) => ({ + sourceFile: 'libs/ui/components/src/lib/a.component.scss', + specifier: '../../../../styles/detail-view', + targetFile: 'libs/ui/styles/_detail-view.scss', + sourceProject: 'components', + targetProject, +}); + +test('flags a shared stylesheet that belongs to no Nx project', () => { + const diagnostics = validateStylesheetInputs({ + imports: [sharedStyleImport(null)], + graph: graphWithSharedStyles({ stylesProjectDeclared: false }), + }); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0], /belongs to no Nx project/); + assert.match(diagnostics[0], /libs\/ui\/styles\/_detail-view\.scss/); +}); + +test('flags a stylesheet project outside the consuming build closure', () => { + const graph = graphWithSharedStyles({ stylesProjectDeclared: true }); + graph.dependencies.components = []; + + const diagnostics = validateStylesheetInputs({ + imports: [sharedStyleImport('ui-styles')], + graph, + }); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0], /web:build compiles/); + assert.match(diagnostics[0], /stale cache/); + assert.match( + diagnostics[0], + /"implicitDependencies": \["ui-styles"\] to the "components" project/ + ); +}); + +test('accepts a stylesheet project inside the consuming build closure', () => { + const diagnostics = validateStylesheetInputs({ + imports: [sharedStyleImport('ui-styles')], + graph: graphWithSharedStyles({ stylesProjectDeclared: true }), + }); + + assert.deepEqual(diagnostics, []); +}); + +test('accepts a library importing a stylesheet owned by the build itself', () => { + const diagnostics = validateStylesheetInputs({ + imports: [ + { + sourceFile: 'libs/ui/components/src/lib/groups-view.scss', + specifier: '../../../../../../../apps/web/src/nav-list.scss', + targetFile: 'apps/web/src/nav-list.scss', + sourceProject: 'components', + targetProject: 'web', + }, + ], + graph: graphWithSharedStyles({ stylesProjectDeclared: true }), + }); + + assert.deepEqual(diagnostics, []); +}); + +test('flags a stylesheet whose own directory belongs to no Nx project', () => { + const diagnostics = validateStylesheetInputs({ + imports: [ + { + sourceFile: 'libs/ui/styles/_index.scss', + specifier: './portal-layout', + targetFile: 'libs/ui/styles/_portal-layout.scss', + sourceProject: null, + targetProject: null, + }, + ], + graph: graphWithSharedStyles({ stylesProjectDeclared: false }), + }); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0], /belongs to no Nx project/); + assert.match(diagnostics[0], /project\.json/); +}); + +test('fails instead of passing when the scan finds no stylesheets', () => { + const diagnostics = validateScanCoverage([]); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0], /No stylesheets were scanned/); +}); + +test('reports no scan-coverage problem once stylesheets are found', () => { + assert.deepEqual( + validateScanCoverage(['libs/ui/styles/_detail-view.scss']), + [] + ); +}); + +test('ignores relative @use examples written inside comments', () => { + const source = [ + '// @use "../../../../../../ui/styles/portal-layout" as portal;', + '/* @use "../../nope/from-block-comment"; */', + "@use '../real/partial' as real;", + "@use 'sass:math';", + ].join('\n'); + + assert.deepEqual(extractRelativeImports(source), ['../real/partial']); +}); + +test('collects every target of a comma-separated @import list', () => { + const source = "@import './local', '../../shared/theme', 'sass:math';"; + + assert.deepEqual(extractRelativeImports(source), [ + './local', + '../../shared/theme', + ]); +}); + +test('treats a @use configuration value as a value, not a second import', () => { + const source = [ + "@use '../../styles/theme' with ($font: 'Inter', $mode: './dark');", + "@forward '../../styles/panel-header' with ($gap: './nope');", + ].join('\n'); + + assert.deepEqual(extractRelativeImports(source), [ + '../../styles/theme', + '../../styles/panel-header', + ]); +}); + +test('ignores a url() import the browser resolves at runtime', () => { + assert.deepEqual(extractRelativeImports('@import url("./plain.css");'), []); +}); + +test('keeps protocol slashes intact when stripping line comments', () => { + const stripped = stripScssComments( + "$font: url('https://example.test/f.woff2'); // trailing note" + ); + + assert.match(stripped, /https:\/\/example\.test/); + assert.doesNotMatch(stripped, /trailing note/); +}); + +test('resolves a specifier to its Sass partial file', () => { + const existing = new Set([ + path.resolve('/repo/libs/ui/styles/_detail-view.scss'), + ]); + + const resolved = resolveStylesheet( + '/repo/libs/ui/components/src/a.scss', + '../../styles/detail-view', + (candidate) => existing.has(candidate) + ); + + assert.equal( + resolved, + path.resolve('/repo/libs/ui/styles/_detail-view.scss') + ); +});