@bastani/atomic 0.9.11-alpha.6 → 0.9.11-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/skills/impeccable/SKILL.md +33 -129
- package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +11 -3
- package/dist/builtin/workflows/skills/impeccable/agents/impeccable_documenter.toml +26 -0
- package/dist/builtin/workflows/skills/impeccable/agents/impeccable_finish_reviewer.toml +35 -0
- package/dist/builtin/workflows/skills/impeccable/reference/android.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/animate.md +72 -189
- package/dist/builtin/workflows/skills/impeccable/reference/audit.md +10 -9
- package/dist/builtin/workflows/skills/impeccable/reference/audit.native.md +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +19 -108
- package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +59 -253
- package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +51 -222
- package/dist/builtin/workflows/skills/impeccable/reference/craft-floor.md +45 -0
- package/dist/builtin/workflows/skills/impeccable/reference/craft.md +3 -121
- package/dist/builtin/workflows/skills/impeccable/reference/critique.md +28 -20
- package/dist/builtin/workflows/skills/impeccable/reference/degraded/asset-producer.md +97 -0
- package/dist/builtin/workflows/skills/impeccable/reference/degraded/documenter.md +23 -0
- package/dist/builtin/workflows/skills/impeccable/reference/degraded/finish-reviewer.md +32 -0
- package/dist/builtin/workflows/skills/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/dist/builtin/workflows/skills/impeccable/reference/delight.md +47 -279
- package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/doctor.md +53 -0
- package/dist/builtin/workflows/skills/impeccable/reference/document.md +60 -73
- package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -12
- package/dist/builtin/workflows/skills/impeccable/reference/hooks.md +17 -4
- package/dist/builtin/workflows/skills/impeccable/reference/init.md +64 -163
- package/dist/builtin/workflows/skills/impeccable/reference/ios.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/layout.md +52 -153
- package/dist/builtin/workflows/skills/impeccable/reference/live.md +44 -32
- package/dist/builtin/workflows/skills/impeccable/reference/new-work.md +105 -0
- package/dist/builtin/workflows/skills/impeccable/reference/{product.md → operate.md} +6 -5
- package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +4 -4
- package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -4
- package/dist/builtin/workflows/skills/impeccable/reference/polish.md +68 -212
- package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +3 -3
- package/dist/builtin/workflows/skills/impeccable/reference/routing.md +18 -0
- package/dist/builtin/workflows/skills/impeccable/reference/shape.md +38 -144
- package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +48 -269
- package/dist/builtin/workflows/skills/impeccable/reference/visualize.md +38 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/concept-seed.mjs +584 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +117 -9
- package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +486 -59
- package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +16 -45
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +143 -26
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/design-system.mjs +181 -12
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +3187 -182
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +282 -70
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +183 -15
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +22 -7
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +7 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +16 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +155 -42
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2988 -155
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +11 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/doctor.mjs +336 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/generate-image.mjs +232 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/hook-admin.mjs +90 -10
- package/dist/builtin/workflows/skills/impeccable/scripts/hook-lib.mjs +410 -78
- package/dist/builtin/workflows/skills/impeccable/scripts/hook.mjs +25 -8
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/artifact-schema.mjs +93 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/composition-catalog.mjs +165 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/concept-catalog.mjs +329 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-config.mjs +20 -5
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-paths.mjs +16 -8
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/provider.mjs +1 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness-deep.mjs +455 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness-notice.mjs +169 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness.mjs +457 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/surface-briefs.mjs +151 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/target-slug.mjs +33 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/template-extensions.mjs +146 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/completion.mjs +10 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/live/event-validation.mjs +15 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/generation-preflight.mjs +149 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/poll-lanes.mjs +14 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/session-store.mjs +109 -32
- package/dist/builtin/workflows/skills/impeccable/scripts/live/source-lock.mjs +105 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/source-search.mjs +105 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/sveltekit-adapter.mjs +8 -6
- package/dist/builtin/workflows/skills/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +208 -68
- package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +563 -112
- package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +198 -13
- package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +24 -6
- package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +44 -14
- package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +398 -33
- package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +9 -5
- package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +81 -67
- package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +7 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +76 -81
- package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/serve-question.mjs +890 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/surface-brief.mjs +74 -0
- package/dist/builtin/workflows/src/extension/index.bundle.mjs +201 -22
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-control.ts +23 -9
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-replay.ts +1 -0
- package/dist/builtin/workflows/src/runs/foreground/postmortem-stage-chat.ts +20 -5
- package/dist/builtin/workflows/src/runs/foreground/stage-control-registry.ts +10 -0
- package/dist/builtin/workflows/src/runs/foreground/stage-delivery-activity.ts +86 -0
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-context.ts +6 -2
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-controller.ts +19 -13
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-send-user-message.ts +8 -0
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-types.ts +16 -0
- package/dist/builtin/workflows/src/tui/stage-chat-view-delivery-activity.ts +121 -0
- package/dist/builtin/workflows/src/tui/stage-chat-view-live-events.ts +42 -0
- package/dist/builtin/workflows/src/tui/stage-chat-view-state.ts +16 -0
- package/dist/builtin/workflows/src/tui/stage-chat-view-types.ts +4 -0
- package/dist/modes/interactive/components/chat-session-host-actions.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-actions.js +2 -14
- package/dist/modes/interactive/components/chat-session-host-actions.js.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-events.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-events.js +9 -0
- package/dist/modes/interactive/components/chat-session-host-events.js.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-runtime.d.ts +25 -0
- package/dist/modes/interactive/components/chat-session-host-runtime.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-runtime.js +53 -0
- package/dist/modes/interactive/components/chat-session-host-runtime.js.map +1 -1
- package/dist/modes/interactive/components/chat-session-host.d.ts +13 -0
- package/dist/modes/interactive/components/chat-session-host.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-session-host.js +22 -1
- package/dist/modes/interactive/components/chat-session-host.js.map +1 -1
- package/dist/modes/interactive/interactive-agent-events.js +9 -3
- package/dist/modes/interactive/interactive-agent-events.js.map +1 -1
- package/docs/tui.md +7 -2
- package/docs/workflows.md +1 -1
- package/npm-shrinkwrap.json +23 -23
- package/package.json +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/brand.md +0 -108
- package/dist/builtin/workflows/skills/impeccable/reference/codex.md +0 -105
- package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -189
package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
CHANGED
|
@@ -3,9 +3,68 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import { finding } from '../../findings.mjs';
|
|
6
|
-
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
|
7
6
|
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
|
8
7
|
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
|
8
|
+
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
|
|
9
|
+
|
|
10
|
+
// On Windows, puppeteer's bundled Chrome lives in a user-writable cache
|
|
11
|
+
// directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security
|
|
12
|
+
// software or the GPU sandbox because it launches from an untrusted path.
|
|
13
|
+
// Chrome then crash-loops the GPU process, and each relaunch briefly flashes a
|
|
14
|
+
// compositor surface, the black window users report during `detect <url>`
|
|
15
|
+
// (issue #372). The system-installed Chrome runs from a trusted location with a
|
|
16
|
+
// healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware
|
|
17
|
+
// GPU, so contrast measurement is unaffected. Scope this to Windows only: other
|
|
18
|
+
// platforms do not have the bug, so they keep the pinned bundled build for
|
|
19
|
+
// consistent measurement across machines. Fall back to bundled when the switch
|
|
20
|
+
// fails (Chrome not installed, or channel resolution fails). If the bundled
|
|
21
|
+
// launch then also fails, surface the original system-Chrome error as the
|
|
22
|
+
// cause so the real failure is not lost.
|
|
23
|
+
async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) {
|
|
24
|
+
let channelError;
|
|
25
|
+
if (process.platform === 'win32') {
|
|
26
|
+
try {
|
|
27
|
+
return await puppeteer.default.launch({ channel: 'chrome', headless, args });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// System Chrome unavailable or unlaunchable; fall through to the bundled
|
|
30
|
+
// browser, but keep the error in case the fallback fails too.
|
|
31
|
+
channelError = err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return await puppeteer.default.launch({ headless, args });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (channelError && err && err.cause === undefined) err.cause = channelError;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Reveal sweep + invisible-text measurement for the content-hidden-at-rest
|
|
43
|
+
// rule. Scrolls through the document with instant jumps (bypasses CSS
|
|
44
|
+
// scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers
|
|
45
|
+
// get every chance to fire, returns to the top, lets transitions settle,
|
|
46
|
+
// then measures how much text still renders invisible. A healthy
|
|
47
|
+
// reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal
|
|
48
|
+
// script died keeps most of its text at opacity 0.
|
|
49
|
+
async function measureContentHiddenAfterReveal(page) {
|
|
50
|
+
await page.evaluate(async () => {
|
|
51
|
+
const step = Math.max(200, Math.floor(window.innerHeight * 0.7));
|
|
52
|
+
const max = Math.max(
|
|
53
|
+
document.documentElement.scrollHeight || 0,
|
|
54
|
+
document.body?.scrollHeight || 0,
|
|
55
|
+
);
|
|
56
|
+
for (let y = 0; y <= max; y += step) {
|
|
57
|
+
window.scrollTo({ top: y, left: 0, behavior: 'instant' });
|
|
58
|
+
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40)));
|
|
59
|
+
}
|
|
60
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 700));
|
|
62
|
+
});
|
|
63
|
+
return page.evaluate(() => {
|
|
64
|
+
if (typeof window.impeccableMeasureHiddenText !== 'function') return null;
|
|
65
|
+
return window.impeccableMeasureHiddenText();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
9
68
|
|
|
10
69
|
function serializeDesignSystemForBrowser(designSystem) {
|
|
11
70
|
if (!designSystem?.present) return null;
|
|
@@ -151,13 +210,26 @@ async function detectUrl(url, options = {}) {
|
|
|
151
210
|
phase: 'load',
|
|
152
211
|
ruleId: 'launch-browser',
|
|
153
212
|
target: url,
|
|
154
|
-
}, () => puppeteer
|
|
213
|
+
}, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs }));
|
|
155
214
|
const page = await profileStepAsync(profile, {
|
|
156
215
|
engine: 'browser',
|
|
157
216
|
phase: 'load',
|
|
158
217
|
ruleId: 'new-page',
|
|
159
218
|
target: url,
|
|
160
219
|
}, () => browser.newPage());
|
|
220
|
+
|
|
221
|
+
// Uncaught exceptions and parse errors surface as pageerror events. The
|
|
222
|
+
// listener must attach before goto: a syntax error fires during the
|
|
223
|
+
// initial parse, long before the load event. Dedupe by message; a single
|
|
224
|
+
// broken loop can otherwise throw hundreds of identical errors.
|
|
225
|
+
const pageErrors = [];
|
|
226
|
+
if (options?.scriptErrors !== false) {
|
|
227
|
+
page.on('pageerror', (err) => {
|
|
228
|
+
const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160);
|
|
229
|
+
if (message && !pageErrors.includes(message)) pageErrors.push(message);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
161
233
|
let results = [];
|
|
162
234
|
try {
|
|
163
235
|
await profileStepAsync(profile, {
|
|
@@ -213,9 +285,29 @@ async function detectUrl(url, options = {}) {
|
|
|
213
285
|
return window.impeccableDetect({ decorate: false, serialize: true });
|
|
214
286
|
});
|
|
215
287
|
return serializedGroups.flatMap(({ findings }) =>
|
|
216
|
-
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
|
|
288
|
+
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
|
|
217
289
|
);
|
|
218
290
|
});
|
|
291
|
+
// Content invisible at rest: reveal sweep, then re-measure. Runs after
|
|
292
|
+
// the main scan (which must see the true at-rest state) and before the
|
|
293
|
+
// visual contrast fallback (the sweep restores scroll to the top).
|
|
294
|
+
if (options?.contentHidden !== false) {
|
|
295
|
+
const hiddenFindings = await profileFindingsAsync(profile, {
|
|
296
|
+
engine: 'browser',
|
|
297
|
+
phase: 'scan',
|
|
298
|
+
ruleId: 'content-hidden-at-rest',
|
|
299
|
+
target: url,
|
|
300
|
+
}, async () => {
|
|
301
|
+
const measured = await measureContentHiddenAfterReveal(page);
|
|
302
|
+
return measured ? checkContentHiddenAtRest(measured) : [];
|
|
303
|
+
});
|
|
304
|
+
results.push(...hiddenFindings);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const message of pageErrors.slice(0, 3)) {
|
|
308
|
+
results.push({ id: 'script-error', snippet: message });
|
|
309
|
+
}
|
|
310
|
+
|
|
219
311
|
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
|
|
220
312
|
results.push(...visualFindings);
|
|
221
313
|
} finally {
|
|
@@ -234,11 +326,14 @@ async function detectUrl(url, options = {}) {
|
|
|
234
326
|
}, () => browser.close());
|
|
235
327
|
}
|
|
236
328
|
}
|
|
237
|
-
return
|
|
329
|
+
return results.map(f => {
|
|
238
330
|
const item = finding(f.id, url, f.snippet);
|
|
239
331
|
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
|
|
332
|
+
// Per-finding severity promotion (e.g. hero-region pulsing dot)
|
|
333
|
+
// overrides the registry default carried by finding().
|
|
334
|
+
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
|
|
240
335
|
return item;
|
|
241
|
-
})
|
|
336
|
+
});
|
|
242
337
|
}
|
|
243
338
|
|
|
244
339
|
async function createBrowserDetector(options = {}) {
|
|
@@ -249,7 +344,7 @@ async function createBrowserDetector(options = {}) {
|
|
|
249
344
|
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
|
|
250
345
|
}
|
|
251
346
|
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
|
|
252
|
-
const browser = options.browser || await puppeteer
|
|
347
|
+
const browser = options.browser || await launchBrowser(puppeteer, {
|
|
253
348
|
headless: options.headless ?? true,
|
|
254
349
|
args: launchArgs,
|
|
255
350
|
});
|
|
@@ -274,4 +369,4 @@ async function createBrowserDetector(options = {}) {
|
|
|
274
369
|
};
|
|
275
370
|
}
|
|
276
371
|
|
|
277
|
-
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
|
|
372
|
+
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
|
package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
|
1
|
+
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
|
2
2
|
import { isNeutralColor } from '../../shared/color.mjs';
|
|
3
3
|
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
|
4
4
|
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
|
5
|
+
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForPseudoStripe, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
|
5
6
|
import { isFullPage, stripHtmlBlocks } from '../../shared/page.mjs';
|
|
6
7
|
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
|
7
8
|
import { finding } from '../../findings.mjs';
|
|
8
|
-
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
|
9
9
|
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -16,6 +16,7 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
|
|
16
16
|
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
|
17
17
|
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
|
18
18
|
|
|
19
|
+
|
|
19
20
|
/** Strip HTML to plain text — drops script/style/comments/tags so
|
|
20
21
|
* content-text analyzers don't false-positive on code or CSS. */
|
|
21
22
|
function stripHtmlToText(html) {
|
|
@@ -40,25 +41,96 @@ function firstOverusedGoogleFont(text) {
|
|
|
40
41
|
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
// CSS named colors whose channels are equal (achromatic). Anything outside
|
|
45
|
+
// this set falls through to the format parsers, and an unrecognized spelling
|
|
46
|
+
// stays non-neutral so a real accent is never skipped.
|
|
47
|
+
const NEUTRAL_COLOR_KEYWORDS = new Set([
|
|
48
|
+
'transparent', 'currentcolor',
|
|
49
|
+
'black', 'white', 'gray', 'grey', 'silver',
|
|
50
|
+
'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey',
|
|
51
|
+
'gainsboro', 'whitesmoke',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
function hexChannels(color) {
|
|
55
|
+
const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i);
|
|
56
|
+
if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)];
|
|
57
|
+
const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i);
|
|
58
|
+
if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Split one box-shadow layer into top-level tokens.
|
|
64
|
+
*
|
|
65
|
+
* Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and
|
|
66
|
+
* `var(--x, 4px)` are each a single value, and splitting them on spaces would
|
|
67
|
+
* read their innards as separate lengths.
|
|
68
|
+
*/
|
|
69
|
+
function tokenizeShadowLayer(layer) {
|
|
70
|
+
const tokens = [];
|
|
71
|
+
let depth = 0;
|
|
72
|
+
let current = '';
|
|
73
|
+
for (const char of String(layer || '')) {
|
|
74
|
+
if (char === '(') depth++;
|
|
75
|
+
else if (char === ')') depth--;
|
|
76
|
+
else if (depth === 0 && /\s/.test(char)) {
|
|
77
|
+
if (current) tokens.push(current);
|
|
78
|
+
current = '';
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
current += char;
|
|
53
82
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
if (current) tokens.push(current);
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lastMatch(text, re) {
|
|
88
|
+
const all = [...String(text || '').matchAll(re)];
|
|
89
|
+
return all.length ? all[all.length - 1] : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isShadowLength(token) {
|
|
93
|
+
return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || ''));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Neutrality test for colors as written in source CSS.
|
|
98
|
+
*
|
|
99
|
+
* shared/color.mjs's isNeutralColor only parses the computed function forms a
|
|
100
|
+
* browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
|
|
101
|
+
* other spelling as chromatic so an unknown format is never silently skipped.
|
|
102
|
+
* That default is wrong for authored CSS, where `#000` and `black` are the
|
|
103
|
+
* normal spellings: calling it directly reports a plain black hairline as a
|
|
104
|
+
* colored stripe. Handle hex and named neutrals here, then defer.
|
|
105
|
+
*/
|
|
106
|
+
function isNeutralAuthoredColor(rawColor) {
|
|
107
|
+
const c = String(rawColor || '').trim().toLowerCase();
|
|
108
|
+
if (!c) return false;
|
|
109
|
+
if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true;
|
|
110
|
+
// Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs
|
|
111
|
+
// parses only the comma form a browser's getComputedStyle emits, so authored
|
|
112
|
+
// space-separated neutrals fell through it and reported as chromatic — the
|
|
113
|
+
// exemption this function exists for, missed. Normalize before delegating.
|
|
114
|
+
if (/^rgba?\(/i.test(c)) {
|
|
115
|
+
const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
|
|
116
|
+
if (channels) {
|
|
117
|
+
const values = [1, 2, 3].map((i) => Number(channels[i]));
|
|
118
|
+
return (Math.max(...values) - Math.min(...values)) < 30;
|
|
119
|
+
}
|
|
120
|
+
return isNeutralColor(c);
|
|
58
121
|
}
|
|
122
|
+
if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
|
123
|
+
const channels = hexChannels(c);
|
|
124
|
+
if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30;
|
|
59
125
|
return false;
|
|
60
126
|
}
|
|
61
127
|
|
|
128
|
+
function isNeutralBorderColor(str) {
|
|
129
|
+
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
|
130
|
+
if (!m) return false;
|
|
131
|
+
return isNeutralAuthoredColor(m[1]);
|
|
132
|
+
}
|
|
133
|
+
|
|
62
134
|
const REGEX_MATCHERS = [
|
|
63
135
|
// --- Side-tab ---
|
|
64
136
|
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
|
@@ -232,15 +304,34 @@ const REGEX_ANALYZERS = [
|
|
|
232
304
|
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
233
305
|
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
|
234
306
|
},
|
|
235
|
-
// Em-dash overuse:
|
|
236
|
-
//
|
|
237
|
-
//
|
|
307
|
+
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
|
308
|
+
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
|
309
|
+
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
|
310
|
+
// its threshold is deliberately conservative. Two gates must both hold:
|
|
311
|
+
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
|
312
|
+
// never fires, no matter how short.
|
|
313
|
+
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
|
314
|
+
// of body text, so a long article that uses eight across several thousand
|
|
315
|
+
// words is left alone while a short, dash-per-clause landing page is not.
|
|
316
|
+
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
|
317
|
+
//
|
|
318
|
+
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
|
319
|
+
// a model that writes `—`, `—`, or `—` renders an em-dash
|
|
320
|
+
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
|
321
|
+
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
|
322
|
+
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
|
323
|
+
// was never counted either.
|
|
238
324
|
(content, filePath) => {
|
|
239
|
-
const text = stripHtmlToText(content)
|
|
325
|
+
const text = stripHtmlToText(content)
|
|
326
|
+
.replace(/—|�*8212;|�*2014;/gi, '—');
|
|
240
327
|
let count = 0;
|
|
241
328
|
const re = /[—]|--(?=\S)/g;
|
|
242
329
|
while (re.exec(text) !== null) count++;
|
|
243
|
-
if (count <
|
|
330
|
+
if (count < EM_DASH_FLOOR) return [];
|
|
331
|
+
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
|
332
|
+
// a long document. textLength <= count * chars-per-dash means the density is
|
|
333
|
+
// at or above the threshold.
|
|
334
|
+
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
|
244
335
|
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
|
245
336
|
},
|
|
246
337
|
// Marketing buzzwords: SaaS phrase list
|
|
@@ -276,22 +367,6 @@ const REGEX_ANALYZERS = [
|
|
|
276
367
|
if (count === 0) return [];
|
|
277
368
|
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
|
|
278
369
|
},
|
|
279
|
-
// Numbered section markers (01 / 02 / 03 ...)
|
|
280
|
-
(content, filePath) => {
|
|
281
|
-
const text = stripHtmlToText(content);
|
|
282
|
-
const re = /\b(0[1-9]|1[0-2])\b/g;
|
|
283
|
-
const seen = new Set();
|
|
284
|
-
let m;
|
|
285
|
-
while ((m = re.exec(text)) !== null) seen.add(m[1]);
|
|
286
|
-
if (seen.size < 3) return [];
|
|
287
|
-
const sorted = [...seen].sort();
|
|
288
|
-
let sequential = 0;
|
|
289
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
290
|
-
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
|
|
291
|
-
}
|
|
292
|
-
if (sequential < 2) return [];
|
|
293
|
-
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
|
|
294
|
-
},
|
|
295
370
|
// Aphoristic cadence: manufactured-contrast + short-rebuttal
|
|
296
371
|
(content, filePath) => {
|
|
297
372
|
const text = stripHtmlToText(content);
|
|
@@ -313,41 +388,143 @@ const REGEX_ANALYZERS = [
|
|
|
313
388
|
if (count < 3) return [];
|
|
314
389
|
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
|
|
315
390
|
},
|
|
316
|
-
// Dark glow (page-level
|
|
391
|
+
// Dark glow / chromatic halo shadows (page-level). Shared scanner handles
|
|
392
|
+
// any color format, single-level var() resolution, zero-offset halos on
|
|
393
|
+
// any background, and text-shadow glows.
|
|
317
394
|
(content, filePath) => {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
if (!hasDarkBg) return [];
|
|
323
|
-
|
|
324
|
-
// Check for colored box-shadow with blur > 4px
|
|
325
|
-
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
|
|
326
|
-
let m;
|
|
327
|
-
while ((m = shadowRe.exec(content)) !== null) {
|
|
328
|
-
const val = m[1];
|
|
329
|
-
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
330
|
-
if (!colorMatch) continue;
|
|
331
|
-
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
|
|
332
|
-
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
|
|
333
|
-
// Check blur: look for pattern like "0 0 20px" (third number > 4)
|
|
334
|
-
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
|
|
335
|
-
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
336
|
-
const lines = content.substring(0, m.index).split('\n');
|
|
337
|
-
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
return [];
|
|
395
|
+
const hits = scanCssTextForGlow(content);
|
|
396
|
+
if (hits.length === 0) return [];
|
|
397
|
+
const lines = content.substring(0, hits[0].index).split('\n');
|
|
398
|
+
return [finding('dark-glow', filePath, hits[0].snippet, lines.length)];
|
|
341
399
|
},
|
|
400
|
+
// Radial-gradient background halo on a dark page (the gradient sibling
|
|
401
|
+
// of the dark-glow shadow tell).
|
|
402
|
+
(content, filePath) => {
|
|
403
|
+
const hits = scanCssTextForRadialHalo(content);
|
|
404
|
+
if (hits.length === 0) return [];
|
|
405
|
+
const lines = content.substring(0, hits[0].index).split('\n');
|
|
406
|
+
return [finding('radial-halo', filePath, hits[0].snippet, lines.length)];
|
|
407
|
+
},
|
|
408
|
+
// Auto-scrolling marquees (<marquee> or infinite horizontal loop
|
|
409
|
+
// animations).
|
|
410
|
+
(content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)),
|
|
342
411
|
];
|
|
343
412
|
|
|
344
413
|
// ---------------------------------------------------------------------------
|
|
345
|
-
//
|
|
414
|
+
// Structural CSS checks used by source files whose styles are not parsed by
|
|
415
|
+
// the static HTML engine.
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i;
|
|
419
|
+
|
|
420
|
+
function insetStripeColorIsChromatic(rawColor) {
|
|
421
|
+
const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, '');
|
|
422
|
+
if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false;
|
|
423
|
+
const variable = color.match(/^var\(\s*(--[\w-]+)/i);
|
|
424
|
+
if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]);
|
|
425
|
+
if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false;
|
|
426
|
+
return !isNeutralAuthoredColor(color);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Blank out comment bodies while preserving every byte offset (and therefore
|
|
431
|
+
* every line number) so commented-out CSS is not scanned as live rules.
|
|
432
|
+
*/
|
|
433
|
+
function blankCssComments(css) {
|
|
434
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
|
|
438
|
+
const content = blankCssComments(rawContent);
|
|
439
|
+
const findings = [];
|
|
440
|
+
const ruleRe = /([^{};]+)\{([^{}]*)\}/g;
|
|
441
|
+
let match;
|
|
442
|
+
// Deriving each line with content.slice(0, offset).split('\n') re-scans the
|
|
443
|
+
// whole prefix per rule, which is O(n^2) on a large stylesheet. Rule matches
|
|
444
|
+
// arrive in source order, so carry a monotonic cursor instead: one pass total.
|
|
445
|
+
let scanOffset = 0;
|
|
446
|
+
let scanLine = 1;
|
|
447
|
+
const lineAtOffset = (offset) => {
|
|
448
|
+
while (scanOffset < offset) {
|
|
449
|
+
if (content[scanOffset] === '\n') scanLine++;
|
|
450
|
+
scanOffset++;
|
|
451
|
+
}
|
|
452
|
+
return scanLine;
|
|
453
|
+
};
|
|
454
|
+
while ((match = ruleRe.exec(content)) !== null) {
|
|
455
|
+
// The selector group is `[^{};]+`, which greedily absorbs the whitespace and
|
|
456
|
+
// newlines trailing the previous rule. Advance past that run before deriving
|
|
457
|
+
// the line, or every rule after the first reports the preceding line.
|
|
458
|
+
const selectorStart = match.index + (match[1].length - match[1].trimStart().length);
|
|
459
|
+
const selector = match[1].trim().replace(/\s+/g, ' ');
|
|
460
|
+
if (!selector) continue;
|
|
461
|
+
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
|
|
462
|
+
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
|
|
463
|
+
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
|
|
464
|
+
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
|
|
465
|
+
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
|
|
466
|
+
|
|
467
|
+
// Read the last of a repeated declaration, not the first: that is what the
|
|
468
|
+
// cascade paints. Taking the first both flagged stripes that a later
|
|
469
|
+
// `box-shadow: none` had cancelled and missed stripes that overrode an
|
|
470
|
+
// earlier value, and mis-skipped rules whose narrow width was overridden.
|
|
471
|
+
const width = lastMatch(match[2], /(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/gi);
|
|
472
|
+
if (width && Number(width[1]) <= 40) continue;
|
|
473
|
+
const declaration = lastMatch(match[2], /(?:^|;)\s*box-shadow\s*:\s*([^;]+)/gi);
|
|
474
|
+
if (!declaration || !/\binset\b/i.test(declaration[1])) continue;
|
|
475
|
+
// `!important` qualifies the declaration, not the shadow value, so strip it
|
|
476
|
+
// before the layers are read. Tokenizing split it into its own token, which
|
|
477
|
+
// made the color count wrong and silently stopped flagging stripes declared
|
|
478
|
+
// with it — a shape the previous regex handled.
|
|
479
|
+
const shadowValue = declaration[1].replace(/\s*!\s*important\s*$/i, '').trim();
|
|
480
|
+
|
|
481
|
+
for (const rawLayer of shadowValue.split(/,(?![^(]*\))/)) {
|
|
482
|
+
const layer = rawLayer.trim();
|
|
483
|
+
// Parse the layer by its grammar rather than by one spelling of it.
|
|
484
|
+
// A box-shadow layer is `inset? && <length>{2,4} && <color>?` in any
|
|
485
|
+
// order, so `inset 4px 0 red`, `4px 0 0 red inset`, and `red 4px 0 inset`
|
|
486
|
+
// all paint the same stripe. Matching a fixed token order missed three
|
|
487
|
+
// valid spellings in a row; enumerate the tokens instead. Tokenizing must
|
|
488
|
+
// respect parens: `rgb(0 0 0)` is one color token, and splitting it on
|
|
489
|
+
// whitespace would read its channels as lengths.
|
|
490
|
+
const tokens = tokenizeShadowLayer(layer);
|
|
491
|
+
if (!tokens.some((token) => /^inset$/i.test(token))) continue;
|
|
492
|
+
const rest = tokens.filter((token) => !/^inset$/i.test(token));
|
|
493
|
+
const lengths = rest.filter(isShadowLength);
|
|
494
|
+
const colors = rest.filter((token) => !isShadowLength(token));
|
|
495
|
+
// Only the two offsets are required; omitted blur/spread default to 0,
|
|
496
|
+
// which is exactly the stripe shape. More than one non-length token is a
|
|
497
|
+
// layer shape we do not claim to understand, so leave it alone.
|
|
498
|
+
if (lengths.length < 2 || lengths.length > 4 || colors.length !== 1) continue;
|
|
499
|
+
const values = lengths.map((token) => ({
|
|
500
|
+
n: Number(token.replace(/px$/i, '')),
|
|
501
|
+
hasPx: /px$/i.test(token),
|
|
502
|
+
}));
|
|
503
|
+
const x = values[0];
|
|
504
|
+
const y = values[1];
|
|
505
|
+
const blur = values[2] ? values[2].n : 0;
|
|
506
|
+
const spread = values[3] ? values[3].n : 0;
|
|
507
|
+
if ((x.n !== 0 && !x.hasPx) || (y.n !== 0 && !y.hasPx) || blur !== 0 || spread !== 0) continue;
|
|
508
|
+
const ax = Math.abs(x.n);
|
|
509
|
+
const ay = Math.abs(y.n);
|
|
510
|
+
if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue;
|
|
511
|
+
if (!insetStripeColorIsChromatic(colors[0])) continue;
|
|
512
|
+
const edge = ay === 0 ? (x.n > 0 ? 'left' : 'right') : (y.n > 0 ? 'top' : 'bottom');
|
|
513
|
+
const line = lineOffset + lineAtOffset(selectorStart);
|
|
514
|
+
findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line));
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return findings;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Style block extraction (Astro/Vue/Svelte <style> blocks)
|
|
346
523
|
// ---------------------------------------------------------------------------
|
|
347
524
|
|
|
348
525
|
function extractStyleBlocks(content, ext) {
|
|
349
526
|
ext = ext.toLowerCase();
|
|
350
|
-
if (ext !== '.vue' && ext !== '.svelte') return [];
|
|
527
|
+
if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
|
|
351
528
|
const blocks = [];
|
|
352
529
|
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
353
530
|
let m;
|
|
@@ -432,21 +609,20 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null,
|
|
|
432
609
|
}
|
|
433
610
|
|
|
434
611
|
/** Page-level analyzers that scan rendered text content (em-dash use,
|
|
435
|
-
* buzzword phrases,
|
|
612
|
+
* buzzword phrases, aphoristic cadence).
|
|
436
613
|
* These are detector-agnostic — they work on any HTML/text source
|
|
437
614
|
* and don't need a parsed DOM. Exported so detectHtml can call them
|
|
438
615
|
* for `.html` files (which otherwise skip the regex engine). */
|
|
439
616
|
const TEXT_CONTENT_ANALYZER_IDS = [
|
|
440
617
|
'em-dash-overuse',
|
|
441
618
|
'marketing-buzzword',
|
|
442
|
-
'numbered-section-markers',
|
|
443
619
|
'aphoristic-cadence',
|
|
444
620
|
];
|
|
445
621
|
|
|
446
622
|
function runTextContentAnalyzers(content, filePath, options = {}) {
|
|
447
623
|
const profile = options?.profile;
|
|
448
624
|
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
|
449
|
-
// The
|
|
625
|
+
// The 3 text-content analyzers are at indices 3-5 in REGEX_ANALYZERS.
|
|
450
626
|
const findings = [];
|
|
451
627
|
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
|
452
628
|
const analyzer = REGEX_ANALYZERS[3 + i];
|
|
@@ -474,8 +650,36 @@ function detectText(content, filePath, options = {}) {
|
|
|
474
650
|
profile,
|
|
475
651
|
phase: 'source',
|
|
476
652
|
}));
|
|
653
|
+
// Pseudo-element stripes (::before/::after absolute bars) carry the same
|
|
654
|
+
// side-tab silhouette without any border token, so the line matchers can't
|
|
655
|
+
// see them (issue #394). The shared scanner already runs on full HTML pages
|
|
656
|
+
// via checkHtmlPatterns; give standalone stylesheets, component style
|
|
657
|
+
// blocks, and CSS-in-JS templates the same coverage. Each hit carries the
|
|
658
|
+
// rule's source offset, so the finding gets a real line and line-scoped
|
|
659
|
+
// inline ignores keep working.
|
|
660
|
+
const pseudoStripeFindings = (text, lineOffset) =>
|
|
661
|
+
scanCssTextForPseudoStripe(text).map(hit =>
|
|
662
|
+
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
|
|
663
|
+
|
|
664
|
+
if (cssLike.has(ext)) {
|
|
665
|
+
findings.push(...scanInsetStripeCss(content, filePath));
|
|
666
|
+
findings.push(...pseudoStripeFindings(content, 0));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Block-level CSS checks that need multiple declarations must run over the
|
|
670
|
+
// complete source, not line-by-line. This covers standalone stylesheets,
|
|
671
|
+
// component style blocks, inline styles, and CSS-in-JS templates.
|
|
672
|
+
findings.push(...profileFindings(profile, {
|
|
673
|
+
engine: 'regex',
|
|
674
|
+
phase: 'source',
|
|
675
|
+
ruleId: 'codex-grid-background',
|
|
676
|
+
target: filePath,
|
|
677
|
+
}, () => scanCssTextForGridBackground(content).map(hit => {
|
|
678
|
+
const line = content.substring(0, hit.index).split('\n').length;
|
|
679
|
+
return finding('codex-grid-background', filePath, hit.snippet, line);
|
|
680
|
+
})));
|
|
477
681
|
|
|
478
|
-
// Extract and scan <style> blocks from Vue/Svelte
|
|
682
|
+
// Extract and scan <style> blocks from Astro/Vue/Svelte components.
|
|
479
683
|
const styleBlocks = profile
|
|
480
684
|
? profileStep(profile, {
|
|
481
685
|
engine: 'regex',
|
|
@@ -490,6 +694,14 @@ function detectText(content, filePath, options = {}) {
|
|
|
490
694
|
profile,
|
|
491
695
|
phase: 'style-block',
|
|
492
696
|
}));
|
|
697
|
+
// block.startLine is the first line *after* the <style> tag, but block.content
|
|
698
|
+
// begins at the character right after that tag — so its own line 1 sits on the
|
|
699
|
+
// tag's line, whether or not a newline follows immediately. lineAtOffset is
|
|
700
|
+
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
|
|
701
|
+
// reported every selector one line low. runRegexMatchers keeps startLine - 1
|
|
702
|
+
// because it indexes its split lines from zero.
|
|
703
|
+
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
|
|
704
|
+
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
|
493
705
|
}
|
|
494
706
|
|
|
495
707
|
// Extract and scan CSS-in-JS template literals
|
|
@@ -507,6 +719,8 @@ function detectText(content, filePath, options = {}) {
|
|
|
507
719
|
profile,
|
|
508
720
|
phase: 'css-in-js',
|
|
509
721
|
}));
|
|
722
|
+
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
|
723
|
+
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
|
510
724
|
}
|
|
511
725
|
|
|
512
726
|
if (options?.designSystem) {
|
|
@@ -537,7 +751,6 @@ function detectText(content, filePath, options = {}) {
|
|
|
537
751
|
'monotonous-spacing',
|
|
538
752
|
'em-dash-overuse',
|
|
539
753
|
'marketing-buzzword',
|
|
540
|
-
'numbered-section-markers',
|
|
541
754
|
'aphoristic-cadence',
|
|
542
755
|
'dark-glow',
|
|
543
756
|
];
|
|
@@ -552,10 +765,9 @@ function detectText(content, filePath, options = {}) {
|
|
|
552
765
|
}
|
|
553
766
|
}
|
|
554
767
|
|
|
555
|
-
const byProvider = filterByProviders(deduped, options?.providers);
|
|
556
768
|
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
|
557
769
|
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
|
558
|
-
return options?.inlineIgnores === false ?
|
|
770
|
+
return options?.inlineIgnores === false ? deduped : applyInlineIgnores(deduped, content);
|
|
559
771
|
}
|
|
560
772
|
|
|
561
773
|
export {
|