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>
This commit is contained in:
Abdul Wahab
2026-09-01 15:03:10 +05:00
co-authored by Cursor
parent 94b7f34f6e
commit 6d973cdea6
10 changed files with 9781 additions and 24 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
+1
View File
@@ -45,6 +45,7 @@
"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: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",
+46
View File
@@ -0,0 +1,46 @@
#!/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
*/
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');
fs.mkdirSync(OUT_DIR, { recursive: true });
const result = spawnSync(
'bun',
['build', ENTRY, '--outfile', OUTPUT, '--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 bundled = fs.readFileSync(OUTPUT, 'utf8');
const output = `/**
* GENERATED -- do not edit. Source: scripts/lib/static-html-parsers.entry.mjs
* Rebuild: node scripts/build-static-html-parsers.js
*
* Bundles htmlparser2, css-select, css-tree, and domutils for skill/plugin installs.
* Third-party licenses: see NOTICE.md.
*/
${bundled}`;
fs.writeFileSync(OUTPUT, output);
console.log(`Generated ${path.relative(ROOT, OUTPUT)} (${(output.length / 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 };
+3 -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)/,
/^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,7 @@ 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$/,
/^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/,
/^tests\/fixtures\/antipatterns/,
],
@@ -116,6 +116,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')));
});
});
+1
View File
@@ -16,6 +16,7 @@ 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('critique references the bundled detector command', () => {