mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* 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>
227 lines
8.2 KiB
JavaScript
227 lines
8.2 KiB
JavaScript
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.`
|
|
);
|
|
}
|
|
}
|