fix(build): include shared UI stylesheets in Nx cache inputs (#1360)

* fix(build): include shared UI stylesheets in Nx cache inputs

`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.

Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.

Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.

`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.

Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(build): spawn git without a shell in the stylesheet check

`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.

Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.

Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.

Reported by Codex review on #1360.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(styles): move nav-list partial into ui-styles (#1361)

* fix(build): count every target of a comma-separated Sass @import

`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.

Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.

The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-08-02 23:18:37 +02:00
1 parent 0010dd7351
commit c741815b97
26 files changed
+537 -7

No files matched your search

@@ -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.
+1 -1
View File
@@ -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/`
+3
View File
@@ -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
+7
View File
@@ -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@<target> --skipInstall`, regenerate the
lockfile, run generated migrations when present, and validate before opening
a PR. Major updates are always manual. Replace incomplete Dependabot security
+7
View File
@@ -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@<target> --skipInstall`, regenerate the
lockfile, run generated migrations when present, and validate before opening
a PR. Major updates are always manual. Replace incomplete Dependabot security
+1 -1
View File
@@ -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:
@@ -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
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "nx:run-commands",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -1,4 +1,4 @@
@use '../../../../../../../../apps/web/src/nav-list.scss';
@use '../../../../../../../ui/styles/nav-list';
:host {
display: block;
@@ -1,4 +1,4 @@
@use '../../../../../../../apps/web/src/nav-list.scss';
@use '../../../../../../ui/styles/nav-list';
:host {
display: block;
+1
View File
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:stalker", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
+1
View File
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:xtream", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
+1
View File
@@ -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",
@@ -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;
+1
View File
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:ui"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "nx:run-commands",
+1
View File
@@ -10,3 +10,4 @@
@forward 'panel-header';
@forward 'detail-view';
@forward 'detail-view-actions';
@forward 'nav-list';
@@ -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;
+7
View File
@@ -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"]
}
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
@@ -1,4 +1,4 @@
@use '../../../../../../../../apps/web/src/nav-list.scss';
@use '../../../nav-list.scss';
:host {
display: block;
@@ -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';
+3
View File
@@ -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",
+226
View File
@@ -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.`
);
}
}
+190
View File
@@ -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')
);
});