From 7289bfe0f4057f8f801f42d27576ecfa4bf6c93a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:48:28 -0500 Subject: [PATCH] Add lint check for hashed dist asset references --- package.json | 5 +- scripts/check-asset-references.mjs | 140 +++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 scripts/check-asset-references.mjs diff --git a/package.json b/package.json index 3f1c005b..f6751606 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "ci:beacon": "npm run build:beacon", "format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md", "audit": "node scripts/daily-design-system-audit.mjs", - "lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml", + "lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml && npm run lint:assets", "lint:file-size": "node scripts/check-file-size.mjs", "lint:innerhtml": "node scripts/check-innerhtml.mjs", "lint:css": "stylelint css/tailwind.source.css css/tokens.css css/docs.css", @@ -37,7 +37,8 @@ "test:unit:shard1": "cross-env UNIT_TEST_SHARD=1/3 node scripts/run-unit-tests.mjs", "test:unit:shard2": "cross-env UNIT_TEST_SHARD=2/3 node scripts/run-unit-tests.mjs", "test:unit:shard3": "cross-env UNIT_TEST_SHARD=3/3 node scripts/run-unit-tests.mjs", - "telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs" + "telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs", + "lint:assets": "node scripts/check-asset-references.mjs" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/scripts/check-asset-references.mjs b/scripts/check-asset-references.mjs new file mode 100644 index 00000000..b4fb80f0 --- /dev/null +++ b/scripts/check-asset-references.mjs @@ -0,0 +1,140 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DIST_DIR = 'dist'; +const HTML_FILES = ['index.html', 'embed.html']; +const MANIFEST_PATH = path.join(DIST_DIR, 'asset-manifest.json'); + +const ALLOWLISTED_STATIC_PATHS = new Set([ + 'vendor/marked.min.js', + 'vendor/highlight.min.js' +]); + +const CSS_JS_ATTR_PATTERN = /<(script|link)\b[^>]*\b(src|href)=(["'])([^"']+)\3[^>]*>/gi; + +function isLocalAssetReference(reference) { + return !/^(?:[a-zA-Z][a-zA-Z\d+.-]*:|\/\/)/.test(reference); +} + +function normalizeAssetPath(reference) { + return reference.replace(/^\/+/, '').replace(/^\.\//, ''); +} + +function extractPathAndQuery(reference) { + const [pathPart, queryPart = ''] = reference.split('?', 2); + return { + path: normalizeAssetPath(pathPart), + query: queryPart + }; +} + +function hasCssOrJsExtension(assetPath) { + return assetPath.endsWith('.css') || assetPath.endsWith('.js'); +} + +function findLineNumber(content, index) { + return content.slice(0, index).split('\n').length; +} + +function loadManifestHashedPaths() { + if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error( + `Missing ${MANIFEST_PATH}. Run \"npm run build\" before running this check.` + ); + } + + const rawManifest = fs.readFileSync(MANIFEST_PATH, 'utf8'); + const manifest = JSON.parse(rawManifest); + return new Set(Object.values(manifest).map((entry) => normalizeAssetPath(String(entry)))); +} + +function collectAssetViolations(htmlFilePath, hashedAssetPaths) { + const content = fs.readFileSync(htmlFilePath, 'utf8'); + const violations = []; + + for (const match of content.matchAll(CSS_JS_ATTR_PATTERN)) { + const fullTag = match[0]; + const rawReference = match[4]; + const line = findLineNumber(content, match.index ?? 0); + + if (!isLocalAssetReference(rawReference)) { + continue; + } + + const { path: assetPath, query } = extractPathAndQuery(rawReference); + if (!hasCssOrJsExtension(assetPath)) { + continue; + } + + if (/^v=/i.test(query) || /(?:^|&)v=/i.test(query)) { + violations.push({ + type: 'legacy-cache-busting', + htmlFilePath, + line, + tag: fullTag, + reference: rawReference, + detail: 'Legacy ?v= cache-busting query detected in built output.' + }); + continue; + } + + if (hashedAssetPaths.has(assetPath) || ALLOWLISTED_STATIC_PATHS.has(assetPath)) { + continue; + } + + violations.push({ + type: 'unapproved-asset-reference', + htmlFilePath, + line, + tag: fullTag, + reference: rawReference, + detail: + 'Local CSS/JS asset reference is not hashed in asset-manifest.json and not allowlisted.' + }); + } + + return violations; +} + +function formatViolation(violation) { + return [ + `- File: ${violation.htmlFilePath}:${violation.line}`, + ` Reference: ${violation.reference}`, + ` Issue: ${violation.detail}`, + ` Tag: ${violation.tag}` + ].join('\n'); +} + +function main() { + const hashedAssetPaths = loadManifestHashedPaths(); + const htmlPaths = HTML_FILES.map((file) => path.join(DIST_DIR, file)); + const missingHtml = htmlPaths.filter((filePath) => !fs.existsSync(filePath)); + + if (missingHtml.length > 0) { + throw new Error( + `Missing built HTML files: ${missingHtml.join(', ')}. Run \"npm run build\" before running this check.` + ); + } + + const violations = htmlPaths.flatMap((htmlPath) => + collectAssetViolations(htmlPath, hashedAssetPaths) + ); + + if (violations.length > 0) { + console.error(`\nAsset reference lint failed (${violations.length} issue${violations.length === 1 ? '' : 's'}):`); + for (const violation of violations) { + console.error(formatViolation(violation)); + } + process.exitCode = 1; + return; + } + + console.log('Asset reference lint passed: all local CSS/JS references are hashed or allowlisted, and no ?v= query cache-busting remains.'); +} + +try { + main(); +} catch (error) { + console.error(`Asset reference lint failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}