Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor e095c53819 Fix: compare parser-bundle freshness by source digest.
Byte-comparing bun's generated vendor file fails on Linux CI because bundler output is not portable across bun versions. Stamp the header with a digest of the entry and parser package versions instead.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 20:05:00 +05:00
Abdul WahabandCursor 3fcd656aaf Fix: fail CI when the vendored HTML parser bundle is stale.
Greptile caught that editing the bundle entry did not refresh the committed vendor file, and neither build nor the detector suite would notice. --check compares a fresh rebuild, bun run build runs that check, and the detector suite now triggers on the entry.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:17:45 +05:00
Abdul WahabandCursor 6d973cdea6 Fix: vendor static-HTML parsers so skill installs detect fully (#434)
Skill and plugin copies of the detector had no htmlparser2/css-select/css-tree/domutils, so HTML scans silently fell back to regex and exited 0. Bundle those parsers into the engine tree and exit 1 if the fallback still fires.

AI assistance: Cursor Grok 4.6 implemented this change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:03:10 +05:00
10 changed files with 9848 additions and 26 deletions
+12
View File
@@ -9,3 +9,15 @@ The `skill/reference/ios.md` and `skill/reference/android.md` platform reference
**Original work:** https://github.com/ehmo/platform-design-skills
**Original license:** MIT
**Author:** ehmo
## Static HTML parser bundle
`cli/engine/vendor/static-html-parsers.mjs` is a generated bundle of the parser packages the static-HTML detector needs at runtime. Skill and plugin installs copy that file with the detector; they do not install these packages from npm.
| Package | License |
|---|---|
| htmlparser2 | MIT |
| css-select | BSD-2-Clause |
| css-tree | MIT |
| domutils | BSD-2-Clause |
| source-map-js (via css-tree) | BSD-3-Clause |
+4 -3
View File
@@ -423,10 +423,11 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(primary.length > 0 ? 2 : 0);
} else if (jsonMode) {
process.stdout.write('[]\n');
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
if (globalThis.__impeccableStaticHtmlDegraded) process.exit(1);
process.exit(primary.length > 0 ? 2 : 0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
+14 -19
View File
@@ -122,12 +122,8 @@ async function detectHtml(filePath, options = {}) {
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
const parsers = await import(new URL('../../vendor/static-html-parsers.mjs', import.meta.url).href);
const { htmlparser2, cssSelect, csstree, domutils } = parsers;
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
@@ -137,21 +133,20 @@ async function detectHtml(filePath, options = {}) {
domutils,
};
});
} catch (err) {
if (!globalThis.__impeccableStaticHtmlWarned) {
globalThis.__impeccableStaticHtmlWarned = true;
process.stderr.write(
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
'(htmlparser2, css-select, css-tree, domutils).\n' +
'Falling back to regex matching. Custom properties, selector matching and computed ' +
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
);
} catch {
if (!globalThis.__impeccableStaticHtmlWarned) {
globalThis.__impeccableStaticHtmlWarned = true;
process.stderr.write(
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
'(htmlparser2, css-select, css-tree, domutils).\n' +
'Falling back to regex matching. Custom properties, selector matching and computed ' +
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n',
);
}
globalThis.__impeccableStaticHtmlDegraded = true;
return detectText(html, filePath, options);
}
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -42,9 +42,10 @@
"scripts": {
"build:skills": "bun run scripts/build.js --skip-root-sync",
"build:skills:release": "bun run scripts/build.js",
"build": "bun run build:skills && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist",
"build:release": "bun run build:skills:release && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist",
"build": "node scripts/build-static-html-parsers.js --check && bun run build:skills && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist",
"build:release": "node scripts/build-static-html-parsers.js --check && bun run build:skills:release && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist",
"build:browser": "node scripts/build-browser-detector.js",
"build:static-html-parsers": "node scripts/build-static-html-parsers.js",
"build:extension": "node scripts/build-extension.js",
"clean": "rm -rf dist build",
"rebuild": "bun run clean && bun run build",
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Generates cli/engine/vendor/static-html-parsers.mjs
* by bundling htmlparser2, css-select, css-tree, and domutils for skill/plugin installs.
*
* Run: node scripts/build-static-html-parsers.js
* Check: node scripts/build-static-html-parsers.js --check
*/
import { createHash } from 'node:crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const ENTRY = path.join(__dirname, 'lib/static-html-parsers.entry.mjs');
const OUT_DIR = path.join(ROOT, 'cli/engine/vendor');
const OUTPUT = path.join(OUT_DIR, 'static-html-parsers.mjs');
const PARSER_PACKAGES = ['htmlparser2', 'css-select', 'css-tree', 'domutils'];
const DIGEST_RE = /^\s*\* Source digest: ([0-9a-f]+)\s*$/m;
function sourceDigest() {
const hash = createHash('sha256');
hash.update(fs.readFileSync(ENTRY));
hash.update('\n');
for (const name of PARSER_PACKAGES) {
const pkgPath = path.join(ROOT, 'node_modules', name, 'package.json');
const version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
hash.update(`${name}@${version}\n`);
}
return hash.digest('hex').slice(0, 16);
}
function header(digest) {
return `/**
* GENERATED -- do not edit. Source: scripts/lib/static-html-parsers.entry.mjs
* Rebuild: node scripts/build-static-html-parsers.js
* Source digest: ${digest}
*
* Bundles htmlparser2, css-select, css-tree, and domutils for skill/plugin installs.
* Third-party licenses: see NOTICE.md.
*/
`;
}
function generate(outfile) {
fs.mkdirSync(path.dirname(outfile), { recursive: true });
const result = spawnSync(
'bun',
['build', ENTRY, '--outfile', outfile, '--target', 'node', '--format', 'esm'],
{ cwd: ROOT, encoding: 'utf8' },
);
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout || 'bun build failed\n');
process.exit(result.status ?? 1);
}
const output = header(sourceDigest()) + fs.readFileSync(outfile, 'utf8');
fs.writeFileSync(outfile, output);
return output;
}
if (process.argv.includes('--check')) {
const committed = fs.readFileSync(OUTPUT, 'utf8');
const found = committed.match(DIGEST_RE)?.[1];
const expected = sourceDigest();
if (found !== expected) {
process.stderr.write(
'cli/engine/vendor/static-html-parsers.mjs is stale. Run: node scripts/build-static-html-parsers.js\n',
);
process.exit(1);
}
process.exit(0);
}
generate(OUTPUT);
console.log(`Generated ${path.relative(ROOT, OUTPUT)} (${(fs.statSync(OUTPUT).size / 1024).toFixed(1)} KB)`);
@@ -0,0 +1,6 @@
export * as htmlparser2 from 'htmlparser2';
export * as cssSelect from 'css-select';
export * as domutils from 'domutils';
import parse from 'css-tree/parser';
import generate from 'css-tree/generator';
export const csstree = { parse, generate };
+4 -2
View File
@@ -25,7 +25,7 @@ export const SUITES = {
description: 'Build, provider transforms, CLI helpers, context, and storage unit tests.',
triggers: [
...COMMON_INFRA_PATTERNS,
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
/^scripts\/(?!benchmark-detector|build-browser-detector|build-static-html-parsers|build-extension|lib\/static-html-parsers\.entry)/,
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|data\/font-index|concept-seed|generate-image|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|font-fingerprint|font-index|hero-checks|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
/^README(\.npm)?\.md$/,
/^cli\/bin\//,
@@ -92,7 +92,8 @@ export const SUITES = {
...COMMON_INFRA_PATTERNS,
/^cli\/engine\//,
/^extension\/(background|content|detector|devtools|popup|manifest\.json)/,
/^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/,
/^scripts\/(benchmark-detector|build-browser-detector|build-static-html-parsers|build-extension)\.js$/,
/^scripts\/lib\/static-html-parsers\.entry\.mjs$/,
/^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/,
/^tests\/fixtures\/antipatterns/,
],
@@ -116,6 +117,7 @@ export const SUITES = {
'tests/detect-cli-design-contamination.test.mjs',
'tests/detect-cli-design-monorepo.test.mjs',
'tests/detect-cli-stdin-dispatch.test.mjs',
'tests/detect-static-html-skill-install.test.mjs',
],
},
],
@@ -0,0 +1,103 @@
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const engineDir = path.join(root, 'cli', 'engine');
const skillDetect = path.join(root, 'skill', 'scripts', 'detect.mjs');
const configDep = path.join(root, 'cli', 'lib', 'impeccable-config.mjs');
const CSS = ':root{--grad:linear-gradient(90deg,#7C3AED,#EC4899)}\n.hero h1{background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}';
const HTML = '<html><head><link rel=stylesheet href=s.css></head><body><div class=hero><h1>Hi</h1></div></body></html>';
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(srcPath, destPath);
else fs.copyFileSync(srcPath, destPath);
}
}
function copyDetectorExternalDeps(scriptsDir) {
fs.mkdirSync(path.join(scriptsDir, 'lib'), { recursive: true });
fs.copyFileSync(configDep, path.join(scriptsDir, 'lib', 'impeccable-config.mjs'));
}
function writeFixture(dir) {
fs.writeFileSync(path.join(dir, 's.css'), CSS);
fs.writeFileSync(path.join(dir, 'p.html'), HTML);
}
function runDetect(cwd, detectRel, htmlRel = 'p.html') {
const detectPath = path.join(cwd, detectRel);
return spawnSync(
process.execPath,
[detectPath, '--json', '--no-config', '--no-design-system', htmlRel],
{ cwd, encoding: 'utf8' },
);
}
function assertFullEngine(result) {
assert.doesNotMatch(result.stderr, /DEGRADED/);
const findings = JSON.parse(result.stdout);
assert.ok(findings.some((item) => item.antipattern === 'gradient-text'));
assert.equal(result.status, 2);
}
function assertDegraded(result) {
assert.match(result.stderr, /DEGRADED/);
assert.equal(result.status, 1);
JSON.parse(result.stdout);
}
const tempDirs = [];
after(() => {
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
});
describe('static HTML parsers in skill/plugin installs', () => {
it('resolves parsers from a skill-shaped scripts/ tree with no node_modules', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-skill-detect-'));
tempDirs.push(tmp);
fs.mkdirSync(path.join(tmp, 'scripts'), { recursive: true });
fs.copyFileSync(skillDetect, path.join(tmp, 'scripts', 'detect.mjs'));
copyDir(engineDir, path.join(tmp, 'scripts', 'detector'));
copyDetectorExternalDeps(path.join(tmp, 'scripts'));
writeFixture(tmp);
assertFullEngine(runDetect(tmp, path.join('scripts', 'detect.mjs')));
});
it('resolves parsers from a plugin cache skills/impeccable/scripts/ tree', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-plugin-detect-'));
tempDirs.push(tmp);
const scriptsDir = path.join(tmp, 'skills', 'impeccable', 'scripts');
fs.mkdirSync(scriptsDir, { recursive: true });
fs.copyFileSync(skillDetect, path.join(scriptsDir, 'detect.mjs'));
copyDir(engineDir, path.join(scriptsDir, 'detector'));
copyDetectorExternalDeps(scriptsDir);
writeFixture(tmp);
assertFullEngine(runDetect(tmp, path.join('skills', 'impeccable', 'scripts', 'detect.mjs')));
});
it('exits 1 when the vendor bundle is missing', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-skill-degraded-'));
tempDirs.push(tmp);
fs.mkdirSync(path.join(tmp, 'scripts'), { recursive: true });
fs.copyFileSync(skillDetect, path.join(tmp, 'scripts', 'detect.mjs'));
copyDir(engineDir, path.join(tmp, 'scripts', 'detector'));
copyDetectorExternalDeps(path.join(tmp, 'scripts'));
fs.unlinkSync(path.join(tmp, 'scripts', 'detector', 'vendor', 'static-html-parsers.mjs'));
writeFixture(tmp);
assertDegraded(runDetect(tmp, path.join('scripts', 'detect.mjs')));
});
});
+30
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import fs from 'fs';
import path from 'path';
import { readSourceFiles } from '../../scripts/lib/utils.js';
@@ -16,6 +17,35 @@ describe('skill detector bundle', () => {
expect(scriptNames.has('detector/detect-antipatterns-browser.js')).toBe(true);
expect(scriptNames.has('detector/cli/main.mjs')).toBe(true);
expect(scriptNames.has('detector/engines/static-html/detect-html.mjs')).toBe(true);
expect(scriptNames.has('detector/vendor/static-html-parsers.mjs')).toBe(true);
});
test('static HTML parser vendor bundle matches the source digest', () => {
const result = spawnSync(
process.execPath,
[path.join(ROOT, 'scripts/build-static-html-parsers.js'), '--check'],
{ cwd: ROOT, encoding: 'utf8' },
);
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || `--check exited ${result.status}`);
}
expect(result.status).toBe(0);
});
test('static HTML parser --check fails when the vendor bundle is stale', () => {
const vendor = path.join(ROOT, 'cli/engine/vendor/static-html-parsers.mjs');
const original = fs.readFileSync(vendor, 'utf8');
try {
fs.writeFileSync(vendor, original.replace(/Source digest: [0-9a-f]+/, 'Source digest: deadbeefdeadbeef'));
const result = spawnSync(
process.execPath,
[path.join(ROOT, 'scripts/build-static-html-parsers.js'), '--check'],
{ cwd: ROOT, encoding: 'utf8' },
);
expect(result.status).toBe(1);
} finally {
fs.writeFileSync(vendor, original);
}
});
test('critique references the bundled detector command', () => {