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

+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')
);
});