From b6a34de55a83dd5929ea7a6b30154044e0510fe7 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 17 Aug 2026 21:27:45 -0700 Subject: [PATCH] Tests: gate behavior on the oracle and the engine binary Unit tests of the deleted Node scripts and the JS detector are removed; their behavior is pinned by tests/oracle goldens (frozen JS behavior plus reviewed deltas) and the engine's own tests. tests/oracle.test.mjs replays the corpus against the binary (IMPECCABLE_BIN or skill/scripts/bin//, via tests/lib/engine-bin.mjs) and skips cleanly without one; the framework fixture sweep drives live-inject, live-wrap, and detect-csp through the binary the same way. record.mjs learns --bin. The function-level vectors under tests/oracle/vectors/calls are committed as the frozen snapshot they can no longer be regenerated from. Suites: core trimmed to build and transformer tests, oracle added to the default run, detector/live reduced to packaging and reference checks, the live-e2e helper tests move to the opt-in live-e2e lane pending its retarget, cli-remote-e2e is an empty placeholder. Prepared with AI assistance (Claude Code). --- package.json | 2 +- scripts/ci-test-plan.mjs | 36 +- scripts/lib/cli-args.mjs | 87 - scripts/test-suites.mjs | 185 +- tests/ci-test-plan.test.mjs | 10 +- tests/cli-args.test.mjs | 175 - tests/cli-ignores.test.js | 147 - tests/concept-seed.test.mjs | 811 -- tests/context-signals.test.mjs | 682 -- tests/context.test.mjs | 1647 ---- tests/critique-storage.test.mjs | 881 --- tests/design-parser.test.mjs | 342 - tests/design-system.test.mjs | 688 -- tests/detect-antipatterns-browser.test.mjs | 1373 ---- tests/detect-antipatterns-fixtures.test.mjs | 1609 ---- tests/detect-antipatterns.test.js | 3766 --------- .../detect-cli-design-contamination.test.mjs | 132 - tests/detect-cli-design-monorepo.test.mjs | 591 -- tests/detect-cli-stdin-dispatch.test.mjs | 65 - tests/detect-url-launch.test.mjs | 294 - tests/doctor.test.mjs | 717 -- tests/framework-fixtures.test.mjs | 56 +- tests/hook.test.mjs | 4637 ----------- tests/impeccable-paths.test.mjs | 173 - tests/inline-ignores.test.mjs | 210 - tests/lib/engine-bin.mjs | 39 + tests/live-accept-css.test.mjs | 259 - tests/live-accept-scrub.test.mjs | 152 - tests/live-accept.test.mjs | 742 -- tests/live-browser-dom.test.mjs | 158 - tests/live-browser-regression.test.mjs | 1412 ---- tests/live-browser-script-parts.test.mjs | 99 - tests/live-browser-session.test.mjs | 64 - tests/live-browser-source.test.mjs | 450 -- tests/live-commit-manual-edits.test.mjs | 1530 ---- tests/live-completion.test.mjs | 83 - tests/live-copy-edit-agent.test.mjs | 478 -- tests/live-discard-manual-edits.test.mjs | 78 - tests/live-event-validation.test.mjs | 224 - tests/live-frameworks.test.mjs | 491 -- tests/live-generation-preflight.test.mjs | 226 - tests/live-inject.test.mjs | 636 -- tests/live-insert-ui.test.mjs | 469 -- tests/live-insert.test.mjs | 169 - tests/live-manual-edits-buffer.test.mjs | 184 - tests/live-poll-lanes.test.mjs | 99 - tests/live-poll-stream.test.mjs | 203 - tests/live-poll.test.mjs | 271 - tests/live-recovery-commands.test.mjs | 183 - tests/live-reference.test.mjs | 12 +- tests/live-roots.test.mjs | 411 - tests/live-server.test.mjs | 3984 ---------- tests/live-session-store.test.mjs | 768 -- tests/live-source-lock.test.mjs | 109 - tests/live-source-search.test.mjs | 106 - tests/live-svelte-ast.test.mjs | 318 - tests/live-svelte-component-accept.test.mjs | 594 -- tests/live-svelte-props-script.test.mjs | 177 - tests/live-tanstack-adapter.test.mjs | 175 - tests/live-target-context.test.mjs | 428 - tests/live-ui-surfaces.test.mjs | 94 - tests/live-wrap-buffer-aware.test.mjs | 221 - tests/live-wrap.test.mjs | 922 --- tests/oracle.test.mjs | 41 + tests/oracle/README.md | 26 +- tests/oracle/record.mjs | 29 +- tests/oracle/vectors/README.md | 9 +- tests/oracle/vectors/calls/_skipped.json | 125 + .../compareStaticPriority.jsonl | 6932 +++++++++++++++++ .../cssPropToCamel.jsonl | 166 + .../expandStaticBoxValues.jsonl | 175 + .../expandStaticDeclaration.jsonl | 1812 +++++ .../extractStaticColor.jsonl | 301 + .../normalizeStaticCssValue.jsonl | 26 + .../parseStaticAnimation.jsonl | 22 + .../parseStaticBorder.jsonl | 176 + .../parseStaticColor.jsonl | 439 ++ .../parseStaticFont.jsonl | 7 + .../parseStaticStyleAttribute.jsonl | 344 + .../parseStaticTransition.jsonl | 35 + .../splitCssList.jsonl | 1197 +++ .../splitCssTokens.jsonl | 408 + .../staticColorToCss.jsonl | 398 + .../staticSpecificity.jsonl | 1150 +++ .../calls/rules.checks/checkBorders.jsonl | 490 ++ .../calls/rules.checks/checkColors.jsonl | 1870 +++++ .../checkContentHiddenAtRest.jsonl | 22 + .../rules.checks/checkEmDashOveruse.jsonl | 4 + .../calls/rules.checks/checkGlow.jsonl | 86 + .../checkGptThinBorderWideShadow.jsonl | 284 + .../calls/rules.checks/checkHeroEyebrow.jsonl | 44 + .../rules.checks/checkHtmlPatterns.jsonl | 101 + .../calls/rules.checks/checkIconTile.jsonl | 412 + .../calls/rules.checks/checkItalicSerif.jsonl | 313 + .../checkKickerAboveHeading.jsonl | 9 + .../calls/rules.checks/checkMotion.jsonl | 187 + .../checkNumberedSectionLabels.jsonl | 6 + .../calls/rules.checks/checkOversizedH1.jsonl | 112 + .../rules.checks/checkRadialSpotlight.jsonl | 26 + .../rules.checks/collectCssCustomProps.jsonl | 255 + .../collectMarqueeKeyframes.jsonl | 112 + .../calls/rules.checks/cssLengthToPx.jsonl | 30 + .../calls/rules.checks/isAccentColor.jsonl | 4 + .../rules.checks/isCardLikeFromProps.jsonl | 30 + .../calls/rules.checks/isCreamColor.jsonl | 29 + .../calls/rules.checks/isEmojiOnlyText.jsonl | 2643 +++++++ .../rules.checks/isKickerCandidate.jsonl | 364 + .../isNumberedSectionLabelCandidate.jsonl | 12 + .../rules.checks/parseColorResolved.jsonl | 8 + .../rules.checks/parseNumberedLabelText.jsonl | 534 ++ .../calls/rules.checks/parseRadiusToPx.jsonl | 141 + .../calls/rules.checks/resolveLengthPx.jsonl | 526 ++ .../calls/rules.checks/resolveSerif.jsonl | 10 + .../calls/rules.checks/resolveVarRefs.jsonl | 1667 ++++ .../rules.checks/scanCssTextForGlow.jsonl | 112 + .../scanCssTextForGridBackground.jsonl | 160 + .../scanCssTextForInsetStripe.jsonl | 98 + .../rules.checks/scanCssTextForMarquee.jsonl | 115 + .../scanCssTextForPseudoStripe.jsonl | 142 + .../scanCssTextForPulsingDot.jsonl | 101 + .../scanCssTextForRadialHalo.jsonl | 112 + ...canHtmlForShapeAssembledIllustration.jsonl | 101 + .../calls/rules.checks/shadowMaxBlurPx.jsonl | 82 + .../shared.color/colorFunctionToRgb.jsonl | 18 + .../calls/shared.color/colorToHex.jsonl | 157 + .../shared.color/compositeColorOver.jsonl | 48 + .../calls/shared.color/contrastRatio.jsonl | 318 + .../extractColorFunctionTokens.jsonl | 84 + .../vectors/calls/shared.color/getHue.jsonl | 32 + .../calls/shared.color/hasChroma.jsonl | 457 ++ .../vectors/calls/shared.color/hslToRgb.jsonl | 2 + .../calls/shared.color/isNeutralColor.jsonl | 121 + .../shared.color/isNoPaintColorValue.jsonl | 6 + .../vectors/calls/shared.color/labToRgb.jsonl | 4 + .../vectors/calls/shared.color/lchToRgb.jsonl | 4 + .../calls/shared.color/oklabToRgb.jsonl | 66 + .../calls/shared.color/oklchToRgb.jsonl | 66 + .../calls/shared.color/parseAnyColor.jsonl | 935 +++ .../calls/shared.color/parseColorMix.jsonl | 4 + .../shared.color/parseGradientColors.jsonl | 84 + .../vectors/calls/shared.color/parseRgb.jsonl | 351 + .../shared.color/relativeLuminance.jsonl | 284 + .../shared.color/splitTopLevelCommas.jsonl | 154 + .../applyInlineIgnores.jsonl | 64 + .../isInlineIgnored.jsonl | 14 + .../parseInlineIgnores.jsonl | 51 + tests/pin.test.mjs | 51 - tests/serve-question.test.mjs | 763 -- tests/skills-cli.test.js | 2468 ------ tests/staleness.test.mjs | 570 -- tests/surface-brief.test.mjs | 91 - tests/target-args.test.mjs | 20 - tests/template-extensions.test.mjs | 150 - tests/windows-path-fix.test.js | 73 - tests/zip.test.mjs | 173 - 155 files changed, 28629 insertions(+), 40560 deletions(-) delete mode 100644 scripts/lib/cli-args.mjs delete mode 100644 tests/cli-args.test.mjs delete mode 100644 tests/cli-ignores.test.js delete mode 100644 tests/concept-seed.test.mjs delete mode 100644 tests/context-signals.test.mjs delete mode 100644 tests/context.test.mjs delete mode 100644 tests/critique-storage.test.mjs delete mode 100644 tests/design-parser.test.mjs delete mode 100644 tests/design-system.test.mjs delete mode 100644 tests/detect-antipatterns-browser.test.mjs delete mode 100644 tests/detect-antipatterns-fixtures.test.mjs delete mode 100644 tests/detect-antipatterns.test.js delete mode 100644 tests/detect-cli-design-contamination.test.mjs delete mode 100644 tests/detect-cli-design-monorepo.test.mjs delete mode 100644 tests/detect-cli-stdin-dispatch.test.mjs delete mode 100644 tests/detect-url-launch.test.mjs delete mode 100644 tests/doctor.test.mjs delete mode 100644 tests/hook.test.mjs delete mode 100644 tests/impeccable-paths.test.mjs delete mode 100644 tests/inline-ignores.test.mjs create mode 100644 tests/lib/engine-bin.mjs delete mode 100644 tests/live-accept-css.test.mjs delete mode 100644 tests/live-accept-scrub.test.mjs delete mode 100644 tests/live-accept.test.mjs delete mode 100644 tests/live-browser-dom.test.mjs delete mode 100644 tests/live-browser-regression.test.mjs delete mode 100644 tests/live-browser-script-parts.test.mjs delete mode 100644 tests/live-browser-session.test.mjs delete mode 100644 tests/live-browser-source.test.mjs delete mode 100644 tests/live-commit-manual-edits.test.mjs delete mode 100644 tests/live-completion.test.mjs delete mode 100644 tests/live-copy-edit-agent.test.mjs delete mode 100644 tests/live-discard-manual-edits.test.mjs delete mode 100644 tests/live-event-validation.test.mjs delete mode 100644 tests/live-frameworks.test.mjs delete mode 100644 tests/live-generation-preflight.test.mjs delete mode 100644 tests/live-inject.test.mjs delete mode 100644 tests/live-insert-ui.test.mjs delete mode 100644 tests/live-insert.test.mjs delete mode 100644 tests/live-manual-edits-buffer.test.mjs delete mode 100644 tests/live-poll-lanes.test.mjs delete mode 100644 tests/live-poll-stream.test.mjs delete mode 100644 tests/live-poll.test.mjs delete mode 100644 tests/live-recovery-commands.test.mjs delete mode 100644 tests/live-roots.test.mjs delete mode 100644 tests/live-server.test.mjs delete mode 100644 tests/live-session-store.test.mjs delete mode 100644 tests/live-source-lock.test.mjs delete mode 100644 tests/live-source-search.test.mjs delete mode 100644 tests/live-svelte-ast.test.mjs delete mode 100644 tests/live-svelte-component-accept.test.mjs delete mode 100644 tests/live-svelte-props-script.test.mjs delete mode 100644 tests/live-tanstack-adapter.test.mjs delete mode 100644 tests/live-target-context.test.mjs delete mode 100644 tests/live-ui-surfaces.test.mjs delete mode 100644 tests/live-wrap-buffer-aware.test.mjs delete mode 100644 tests/live-wrap.test.mjs create mode 100644 tests/oracle.test.mjs create mode 100644 tests/oracle/vectors/calls/_skipped.json create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/compareStaticPriority.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/cssPropToCamel.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/expandStaticBoxValues.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/expandStaticDeclaration.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/extractStaticColor.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/normalizeStaticCssValue.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/parseStaticAnimation.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/parseStaticBorder.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/parseStaticColor.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/parseStaticFont.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/parseStaticStyleAttribute.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/parseStaticTransition.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/splitCssList.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/splitCssTokens.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/staticColorToCss.jsonl create mode 100644 tests/oracle/vectors/calls/engines.static-html.css-cascade/staticSpecificity.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkBorders.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkColors.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkContentHiddenAtRest.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkEmDashOveruse.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkGlow.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkGptThinBorderWideShadow.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkHeroEyebrow.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkHtmlPatterns.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkIconTile.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkItalicSerif.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkKickerAboveHeading.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkMotion.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkNumberedSectionLabels.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkOversizedH1.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/checkRadialSpotlight.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/collectCssCustomProps.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/collectMarqueeKeyframes.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/cssLengthToPx.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/isAccentColor.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/isCardLikeFromProps.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/isCreamColor.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/isEmojiOnlyText.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/isKickerCandidate.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/isNumberedSectionLabelCandidate.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/parseColorResolved.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/parseNumberedLabelText.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/parseRadiusToPx.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/resolveLengthPx.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/resolveSerif.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/resolveVarRefs.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForGlow.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForGridBackground.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForInsetStripe.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForMarquee.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForPseudoStripe.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForPulsingDot.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanCssTextForRadialHalo.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/scanHtmlForShapeAssembledIllustration.jsonl create mode 100644 tests/oracle/vectors/calls/rules.checks/shadowMaxBlurPx.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/colorFunctionToRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/colorToHex.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/compositeColorOver.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/contrastRatio.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/extractColorFunctionTokens.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/getHue.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/hasChroma.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/hslToRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/isNeutralColor.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/isNoPaintColorValue.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/labToRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/lchToRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/oklabToRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/oklchToRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/parseAnyColor.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/parseColorMix.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/parseGradientColors.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/parseRgb.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/relativeLuminance.jsonl create mode 100644 tests/oracle/vectors/calls/shared.color/splitTopLevelCommas.jsonl create mode 100644 tests/oracle/vectors/calls/shared.inline-ignores/applyInlineIgnores.jsonl create mode 100644 tests/oracle/vectors/calls/shared.inline-ignores/isInlineIgnored.jsonl create mode 100644 tests/oracle/vectors/calls/shared.inline-ignores/parseInlineIgnores.jsonl delete mode 100644 tests/pin.test.mjs delete mode 100644 tests/serve-question.test.mjs delete mode 100644 tests/skills-cli.test.js delete mode 100644 tests/staleness.test.mjs delete mode 100644 tests/surface-brief.test.mjs delete mode 100644 tests/target-args.test.mjs delete mode 100644 tests/template-extensions.test.mjs delete mode 100644 tests/windows-path-fix.test.js delete mode 100644 tests/zip.test.mjs diff --git a/package.json b/package.json index 5a6d1a3a5..c503159aa 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,10 @@ "rebuild:release": "bun run clean && bun run build:release", "test": "node scripts/run-tests.mjs default", "test:core": "node scripts/run-tests.mjs core", + "test:oracle": "node scripts/run-tests.mjs oracle", "test:detector": "node scripts/run-tests.mjs detector", "test:framework": "node scripts/run-tests.mjs framework", "test:live": "node scripts/run-tests.mjs live", - "test:cli-e2e": "node scripts/run-tests.mjs cli-e2e", "test:cli-remote-e2e": "node scripts/run-tests.mjs cli-remote-e2e", "test:plugin-e2e": "node scripts/run-tests.mjs plugin-e2e", "test:live-e2e": "node scripts/run-tests.mjs live-e2e", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 39764af77..f489917c5 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -15,17 +15,31 @@ const changedFiles = localNoChanges || isSchedule ? [] : getChangedFiles(); const forceDeterministic = localNoChanges || isSchedule || eventName === 'push' || eventName === 'workflow_dispatch'; const forceOptIn = eventName === 'workflow_dispatch'; -const plan = { - core: true, - detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), - live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), - framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), - cli_remote_e2e: forceOptIn, - live_e2e: isSchedule || forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), - live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), - skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), - live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), -}; +const plan = isSchedule + ? { + core: true, + oracle: true, + detector: true, + live: true, + framework: true, + cli_remote_e2e: false, + live_e2e: true, + live_e2e_accept_cleanup: false, + skill_behavior: false, + live_svelte_adapter_deepseek: false, + } + : { + core: true, + oracle: forceDeterministic || matchesSuiteTriggers('oracle', changedFiles), + detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), + live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), + framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), + cli_remote_e2e: forceOptIn, + live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), + live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), + skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), + live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), + }; writeGithubOutputs(plan); printSummary(plan, changedFiles); diff --git a/scripts/lib/cli-args.mjs b/scripts/lib/cli-args.mjs deleted file mode 100644 index 8e0a58efd..000000000 --- a/scripts/lib/cli-args.mjs +++ /dev/null @@ -1,87 +0,0 @@ -/** - * One argv parser for the Live benchmark / judging scripts. - * - * These scripts had four subtly different hand-rolled parsers, and the gaps - * failed silently rather than loudly: a parser without the `argv[i + 1]` - * lookahead turned `--iterations 20` into `iterations: true` and benchmarked - * the default 5 runs; a parser without kebab→camel mapping turned - * `--median-target=0.4` into a key nothing read, so the comparison ran against - * the default threshold. Both produce a clean-looking report of the wrong thing. - * - * Supported forms, per flag: - * --flag → true - * --flag=value → 'value' - * --flag value → 'value' (unless `value` itself starts with `--`) - * - * Keys are camel-cased, so `--simulated-tail-ms` and `--simulatedTailMs` both - * land on `simulatedTailMs`. - */ -export function parseArgs(argv) { - const out = {}; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (!arg.startsWith('--')) continue; - const body = arg.slice(2); - if (!body) continue; - const equals = body.indexOf('='); - if (equals !== -1) { - out[toCamel(body.slice(0, equals))] = body.slice(equals + 1); - continue; - } - const next = argv[index + 1]; - if (next !== undefined && !next.startsWith('--')) { - out[toCamel(body)] = next; - index += 1; - } else { - out[toCamel(body)] = true; - } - } - return out; -} - -export function toCamel(value) { - return String(value).replace(/-([a-z0-9])/gi, (_, char) => char.toUpperCase()); -} - -/** - * Read a boolean flag. `--headed` and `--headed=true` must mean the same thing; - * comparing the raw value against `true` silently ignores the second form. - */ -export function boolFlag(value, fallback = false) { - if (value === undefined) return fallback; - if (typeof value === 'boolean') return value; - const normalized = String(value).trim().toLowerCase(); - if (['', 'true', '1', 'yes', 'on'].includes(normalized)) return true; - if (['false', '0', 'no', 'off'].includes(normalized)) return false; - return fallback; -} - -/** - * Parse a positive integer flag, falling back when absent. Throws on a value - * that was clearly meant as a number but isn't one, so `--iterations abc` - * fails instead of quietly benchmarking the default. - */ -export function positiveIntFlag(value, fallback) { - if (value === undefined || value === true) return fallback; - const parsed = Number.parseInt(String(value), 10); - if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== String(value).trim()) { - throw new Error(`expected a positive integer, got: ${value}`); - } - return parsed; -} - -/** - * Resolve a flag that must be one of a fixed set. - * - * A silent `x === 'known' ? 'known' : fallback` is the trap this replaces: the - * private evals Live runner passes `--agent=codex`, which fell through to the - * canned fake agent and produced a clean-looking report of a deterministic stub - * labelled as a real harness run. An unrecognized value is a mistake, not a - * request for the default. - */ -export function resolveEnum(value, allowed, fallback, flagName) { - if (value === undefined || value === true) return fallback; - const normalized = String(value).trim().toLowerCase(); - if (allowed.includes(normalized)) return normalized; - throw new Error(`${flagName} must be one of ${allowed.join(', ')}; got: ${value}`); -} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 9a241e769..b0fed8df3 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -export const DEFAULT_SUITES = ['core', 'detector', 'live', 'framework', 'plugin-e2e']; +export const DEFAULT_SUITES = ['core', 'oracle', 'detector', 'live', 'framework', 'plugin-e2e']; export const OPT_IN_SUITES = [ 'cli-remote-e2e', 'live-e2e', @@ -22,11 +22,12 @@ const COMMON_INFRA_PATTERNS = [ export const SUITES = { core: { - description: 'Build, provider transforms, CLI helpers, context, and storage unit tests.', + description: 'Build, provider transforms, hook manifests, plugin validators, and prose gates.', triggers: [ ...COMMON_INFRA_PATTERNS, - /^scripts\/(?!benchmark-detector|build-browser-detector|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))/, + /^scripts\/(?!build-browser-detector|build-extension)/, + /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/)/, + /^ENGINE_VERSION$/, /^README(\.npm)?\.md$/, /^cli\/bin\//, ], @@ -35,15 +36,11 @@ export const SUITES = { runner: 'bun', files: [ 'tests/build.test.js', - 'tests/cli-ignores.test.js', - 'tests/windows-path-fix.test.js', 'tests/lib/provider-blocks.test.js', 'tests/lib/transformers/provider-blocks.test.js', 'tests/lib/utils.test.js', - 'tests/lib/impeccable-config.test.js', 'tests/lib/transformers/factory.test.js', 'tests/lib/transformers/providers.test.js', - 'tests/skills-cli.test.js', 'tests/validate-plugin-versions.test.js', 'tests/validate-plugin-manifest.test.js', 'tests/plugin-paths.test.js', @@ -52,147 +49,89 @@ export const SUITES = { { runner: 'node', files: [ - 'tests/ci-test-plan.test.mjs', - 'tests/cli-args.test.mjs', - 'tests/concept-seed.test.mjs', - 'tests/generate-image-embed.test.mjs', - 'tests/comp-diff.test.mjs', 'tests/build-phase.test.mjs', + 'tests/ci-test-plan.test.mjs', + 'tests/comp-diff.test.mjs', 'tests/font-match.test.mjs', - 'tests/hero-checks.test.mjs', - 'tests/serve-question.test.mjs', - 'tests/context.test.mjs', - 'tests/context-signals.test.mjs', - 'tests/critique-storage.test.mjs', - 'tests/design-parser.test.mjs', 'tests/github-sheriff.test.mjs', + 'tests/hero-checks.test.mjs', 'tests/hook-build.test.mjs', - 'tests/hook.test.mjs', - 'tests/impeccable-paths.test.mjs', 'tests/openai-plugin.test.mjs', - 'tests/pin.test.mjs', 'tests/release.test.mjs', - 'tests/doctor.test.mjs', - 'tests/staleness.test.mjs', 'tests/skill-reference.test.mjs', 'tests/readme-gitignore.test.mjs', - 'tests/target-args.test.mjs', - 'tests/surface-brief.test.mjs', - 'tests/template-extensions.test.mjs', 'tests/test-suites.test.mjs', - 'tests/zip.test.mjs', ], }, ], }, + // The verbs live in the engine binary; this repo pins its behavior with the + // oracle goldens (tests/oracle) and drives its live-mode verbs over the + // framework fixtures. Both skip when no binary is present (bun run + // fetch:engine, or IMPECCABLE_BIN). + oracle: { + description: 'Oracle corpus replay against the engine binary; skips without a binary.', + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^ENGINE_VERSION$/, + /^tests\/oracle\//, + /^tests\/fixtures\//, + /^tests\/lib\/engine-bin\.mjs$/, + /^skill\/(reference\/|scripts\/)/, + ], + commands: [ + { + runner: 'node', + timeoutMs: 900000, + files: ['tests/oracle.test.mjs'], + }, + ], + }, detector: { - description: 'Anti-pattern detector tests across text, jsdom fixtures, and Puppeteer browser paths.', - needsPuppeteer: true, + description: 'Extension packaging checks (the detector engine itself is tested in the engine repo and by the oracle).', triggers: [ ...COMMON_INFRA_PATTERNS, - /^cli\/engine\//, /^extension\/(background|content|detector|devtools|popup|manifest\.json)/, - /^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/, - /^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/, - /^tests\/fixtures\/antipatterns/, + /^scripts\/(build-browser-detector|build-extension)\.js$/, ], commands: [ - { - runner: 'bun', - files: [ - 'tests/detect-antipatterns.test.js', - 'tests/detect-url-launch.test.mjs', - 'tests/inline-ignores.test.mjs', - 'tests/lib/detector-bundle.test.js', - ], - }, { runner: 'node', - files: [ - 'tests/extension-build.test.mjs', - 'tests/design-system.test.mjs', - 'tests/detect-antipatterns-fixtures.test.mjs', - 'tests/detect-antipatterns-browser.test.mjs', - 'tests/detect-cli-design-contamination.test.mjs', - 'tests/detect-cli-design-monorepo.test.mjs', - 'tests/detect-cli-stdin-dispatch.test.mjs', - ], + files: ['tests/extension-build.test.mjs'], }, ], }, live: { - description: 'Fast live-mode unit and local-server integration tests, excluding full browser fixture sweeps.', + description: 'Live-mode reference contract checks plus the live-e2e helper units (agent output, CLI options, LLM agent parsing, steer loop against the binary); the live verbs themselves are covered by the oracle and framework suites.', triggers: [ ...COMMON_INFRA_PATTERNS, - // `palette` is deliberately absent: skill/scripts/palette.mjs has no - // test anywhere, and listing it here made edits run a suite that never - // touches it, which reads as coverage that does not exist. - /^skill\/(reference\/live\.md|scripts\/(detect-csp|lib\/is-generated|lib\/template-extensions|live\/|live|live-|modern-screenshot|pin))/, - /^tests\/live-/, + /^skill\/(reference\/live\.md|scripts\/live-browser)/, + /^tests\/live-e2e\//, + /^tests\/lib\/engine-bin\.mjs$/, ], commands: [ { runner: 'node', files: [ - 'tests/live-accept.test.mjs', - 'tests/live-accept-css.test.mjs', - 'tests/live-accept-scrub.test.mjs', - 'tests/live-browser-dom.test.mjs', + 'tests/live-reference.test.mjs', 'tests/live-browser-ignores.test.mjs', - 'tests/live-browser-script-parts.test.mjs', - 'tests/live-browser-regression.test.mjs', - 'tests/live-browser-session.test.mjs', - 'tests/live-browser-source.test.mjs', - 'tests/live-commit-manual-edits.test.mjs', - 'tests/live-completion.test.mjs', - 'tests/live-copy-edit-agent.test.mjs', - 'tests/live-discard-manual-edits.test.mjs', 'tests/live-e2e-agent-output.test.mjs', 'tests/live-e2e-cli-options.test.mjs', 'tests/live-e2e-llm-agent.test.mjs', 'tests/live-e2e-steer-agent.test.mjs', 'tests/live-e2e/agent-insert.test.mjs', - 'tests/live-event-validation.test.mjs', - 'tests/live-frameworks.test.mjs', - 'tests/live-generation-preflight.test.mjs', - 'tests/live-inject.test.mjs', - 'tests/live-insert.test.mjs', - 'tests/live-insert-ui.test.mjs', - 'tests/live-manual-edits-buffer.test.mjs', - 'tests/live-poll.test.mjs', - 'tests/live-project-ignores.test.mjs', - 'tests/live-poll-lanes.test.mjs', - 'tests/live-poll-stream.test.mjs', - 'tests/live-recovery-commands.test.mjs', - 'tests/live-reference.test.mjs', - 'tests/live-roots.test.mjs', - 'tests/live-server.test.mjs', - 'tests/live-session-store.test.mjs', - 'tests/live-source-lock.test.mjs', - 'tests/live-source-search.test.mjs', - 'tests/live-svelte-ast.test.mjs', - 'tests/live-svelte-component-accept.test.mjs', - 'tests/live-svelte-props-script.test.mjs', - 'tests/live-tanstack-adapter.test.mjs', - 'tests/live-target-context.test.mjs', - 'tests/live-ui-surfaces.test.mjs', - 'tests/live-wrap.test.mjs', - 'tests/live-wrap-buffer-aware.test.mjs', ], }, ], }, framework: { - description: 'Framework fixture coverage for live injection, CSP, generated-file detection, and wrapping.', + description: 'Framework fixture coverage for live injection, CSP detection, and wrapping through the engine binary; skips without a binary.', triggers: [ ...COMMON_INFRA_PATTERNS, + /^ENGINE_VERSION$/, /^tests\/framework-fixtures/, /^tests\/framework-fixtures\.test\.mjs$/, - /^skill\/scripts\/(detect-csp|live-inject|live-wrap)\.mjs$/, - /^skill\/scripts\/lib\/is-generated\.mjs$/, - /^skill\/scripts\/lib\/template-extensions\.mjs$/, - /^skill\/scripts\/live\/(source-search|sveltekit-adapter|tanstack-adapter)\.mjs$/, - /^skill\/scripts\/live\/frameworks\//, + /^tests\/lib\/engine-bin\.mjs$/, ], commands: [ { @@ -201,30 +140,14 @@ export const SUITES = { }, ], }, - 'cli-e2e': { - description: 'Deterministic CLI install/update tests against a local universal bundle.', - commands: [ - { - runner: 'bun', - files: ['tests/skills-cli.test.js'], - }, - ], - }, + // `impeccable install/update/check` and their remote smoke moved into the + // engine binary and its repo; the deterministic coverage here is the oracle + // corpus. The lane name stays so ci.yml and package.json keep resolving. 'cli-remote-e2e': { - description: 'Remote CLI install/update smoke tests against impeccable.style.', + description: 'Remote CLI install/update smoke (moved to the engine repo; no tests here).', optIn: true, - triggers: [ - ...COMMON_INFRA_PATTERNS, - /^cli\/bin\/commands\/skills\.mjs$/, - /^tests\/skills-cli\.test\.js$/, - ], - commands: [ - { - runner: 'bun', - env: { IMPECCABLE_CLI_REMOTE_E2E: '1' }, - files: ['tests/skills-cli.test.js'], - }, - ], + triggers: [...COMMON_INFRA_PATTERNS], + commands: [], }, 'plugin-e2e': { description: 'Install the committed ./plugin subtree into a real (sandboxed) Claude Code and assert skills, agents, and hooks all load. Skips when the claude CLI is not on PATH.', @@ -234,7 +157,6 @@ export const SUITES = { /^skill\/agents\//, /^scripts\/build\.js$/, /^scripts\/lib\/validate-plugin-manifest\.js$/, - /^scripts\/lib\/plugin-paths\.js$/, /^tests\/plugin-e2e\.test\.mjs$/, ], commands: [ @@ -252,7 +174,8 @@ export const SUITES = { needsPlaywright: true, triggers: [ ...COMMON_INFRA_PATTERNS, - /^skill\/scripts\/live/, + /^skill\/scripts\/live-browser/, + /^ENGINE_VERSION$/, /^tests\/framework-fixtures/, /^tests\/live-e2e(\.test\.mjs|\/)/, ], @@ -271,7 +194,7 @@ export const SUITES = { needsPlaywright: true, triggers: [ ...COMMON_INFRA_PATTERNS, - /^skill\/scripts\/(serve-question|generate-image|concept-seed)\.mjs$/, + /^ENGINE_VERSION$/, /^tests\/new-work-e2e(\.test\.mjs|\/)/, ], commands: [ @@ -289,8 +212,7 @@ export const SUITES = { needsPlaywright: true, triggers: [ ...COMMON_INFRA_PATTERNS, - /^skill\/scripts\/(live-accept|live-browser|live-server|live-wrap)\.mjs$/, - /^skill\/scripts\/live\/sveltekit-adapter\.mjs$/, + /^ENGINE_VERSION$/, /^tests\/live-e2e-accept-cleanup-regression\.test\.mjs$/, /^tests\/live-e2e\//, ], @@ -318,7 +240,7 @@ export const SUITES = { ...COMMON_INFRA_PATTERNS, /^skill\/SKILL\.src\.md$/, /^skill\/reference\/(init|document|brand|product|shape|craft|audit|polish|live)\.md$/, - /^skill\/scripts\/(context|context-signals|detect|detect-csp)\.mjs$/, + /^ENGINE_VERSION$/, /^tests\/skill-behavior\//, ], commands: [ @@ -346,8 +268,7 @@ export const SUITES = { needsPlaywright: true, triggers: [ ...COMMON_INFRA_PATTERNS, - /^skill\/scripts\/(live-server|live-wrap)\.mjs$/, - /^skill\/scripts\/live\/(sveltekit-adapter|svelte-component)\.mjs$/, + /^ENGINE_VERSION$/, /^tests\/framework-fixtures\/vite8-sveltekit-stateful\//, /^tests\/live-svelte-adapter-deepseek\.test\.mjs$/, ], diff --git a/tests/ci-test-plan.test.mjs b/tests/ci-test-plan.test.mjs index 2a9c16124..d87c53aa1 100644 --- a/tests/ci-test-plan.test.mjs +++ b/tests/ci-test-plan.test.mjs @@ -23,10 +23,10 @@ describe('ci-test-plan', () => { assert.equal(outputs.live_svelte_adapter_deepseek, 'false'); }); - it('routes detector changes to detector tests only', () => { + it('routes extension changes to detector tests only', () => { const outputs = runPlan({ GITHUB_EVENT_NAME: 'pull_request', - CI_CHANGED_FILES: 'cli/engine/detect-antipatterns.mjs', + CI_CHANGED_FILES: 'extension/manifest.json', }); assert.equal(outputs.detector, 'true'); @@ -34,13 +34,13 @@ describe('ci-test-plan', () => { assert.equal(outputs.framework, 'false'); }); - it('routes live server changes to live unit and full live E2E lanes', () => { + it('routes an engine version bump to every binary-driven lane', () => { const outputs = runPlan({ GITHUB_EVENT_NAME: 'pull_request', - CI_CHANGED_FILES: 'skill/scripts/live-server.mjs', + CI_CHANGED_FILES: 'ENGINE_VERSION', }); - assert.equal(outputs.live, 'true'); + assert.equal(outputs.framework, 'true'); assert.equal(outputs.live_e2e, 'true'); assert.equal(outputs.live_e2e_accept_cleanup, 'true'); assert.equal(outputs.live_svelte_adapter_deepseek, 'true'); diff --git a/tests/cli-args.test.mjs b/tests/cli-args.test.mjs deleted file mode 100644 index afbf2d296..000000000 --- a/tests/cli-args.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Tests for scripts/lib/cli-args.mjs — the shared argv parser for the Live - * benchmark / judging scripts. - * Run with: node --test tests/cli-args.test.mjs - */ - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { boolFlag, parseArgs, positiveIntFlag, resolveEnum, toCamel } from '../scripts/lib/cli-args.mjs'; - -const PROVIDER_SMOKE_SCRIPT = fileURLToPath(new URL('../scripts/smoke-provider-hooks.mjs', import.meta.url)); - -describe('parseArgs', () => { - it('reads space-separated values', () => { - // The regression: without the argv[i+1] lookahead this yielded - // {fixture: true, iterations: true}, silently benchmarking the defaults. - assert.deepEqual( - parseArgs(['--fixture', 'vite8-react-modal', '--iterations', '20']), - { fixture: 'vite8-react-modal', iterations: '20' }, - ); - }); - - it('reads --flag=value values', () => { - assert.deepEqual(parseArgs(['--fixture=vite8-react-plain']), { fixture: 'vite8-react-plain' }); - }); - - it('treats a flag followed by another flag as boolean', () => { - assert.deepEqual(parseArgs(['--headed', '--quiet']), { headed: true, quiet: true }); - }); - - it('treats a trailing flag as boolean', () => { - assert.deepEqual(parseArgs(['--append']), { append: true }); - }); - - it('camel-cases kebab keys so both spellings land on one key', () => { - assert.deepEqual(parseArgs(['--simulated-tail-ms=250']), { simulatedTailMs: '250' }); - assert.deepEqual(parseArgs(['--simulatedTailMs=250']), { simulatedTailMs: '250' }); - assert.deepEqual(parseArgs(['--median-target', '0.4']), { medianTarget: '0.4' }); - }); - - it('keeps a value that contains an equals sign intact', () => { - assert.deepEqual(parseArgs(['--model=claude-sonnet-4-6=x']), { model: 'claude-sonnet-4-6=x' }); - }); - - it('ignores positional args and a bare --', () => { - assert.deepEqual(parseArgs(['positional', '--', '--real', 'v']), { real: 'v' }); - }); - - it('lets a later occurrence win', () => { - assert.deepEqual(parseArgs(['--agent', 'fake', '--agent', 'llm']), { agent: 'llm' }); - }); -}); - -describe('toCamel', () => { - it('upcases after hyphens only', () => { - assert.equal(toCamel('simulated-tail-ms'), 'simulatedTailMs'); - assert.equal(toCamel('p95-target'), 'p95Target'); - assert.equal(toCamel('already'), 'already'); - }); -}); - -describe('boolFlag', () => { - it('accepts the bare-flag sentinel and the explicit spellings alike', () => { - // --headed and --headed=true must not diverge. - assert.equal(boolFlag(true), true); - assert.equal(boolFlag('true'), true); - assert.equal(boolFlag('1'), true); - assert.equal(boolFlag('yes'), true); - assert.equal(boolFlag(''), true); - }); - - it('recognizes negative spellings', () => { - assert.equal(boolFlag('false'), false); - assert.equal(boolFlag('0'), false); - assert.equal(boolFlag('no'), false); - }); - - it('falls back when absent or unrecognized', () => { - assert.equal(boolFlag(undefined), false); - assert.equal(boolFlag(undefined, true), true); - assert.equal(boolFlag('maybe', true), true); - }); -}); - -describe('positiveIntFlag', () => { - it('parses positive integers', () => { - assert.equal(positiveIntFlag('20', 5), 20); - }); - - it('falls back when absent or given as a bare flag', () => { - assert.equal(positiveIntFlag(undefined, 5), 5); - assert.equal(positiveIntFlag(true, 5), 5); - }); - - it('throws rather than silently using the default', () => { - // Quietly benchmarking 5 iterations when 20 were asked for is the failure - // this replaces. - for (const bad of ['abc', '0', '-3', '2.5', '20x']) { - assert.throws(() => positiveIntFlag(bad, 5), /positive integer/, `accepted ${bad}`); - } - }); -}); - -describe('resolveEnum', () => { - it('accepts an allowed value, case-insensitively', () => { - assert.equal(resolveEnum('llm', ['fake', 'llm'], 'fake', '--agent'), 'llm'); - assert.equal(resolveEnum('LLM', ['fake', 'llm'], 'fake', '--agent'), 'llm'); - }); - - it('falls back when absent or given as a bare flag', () => { - assert.equal(resolveEnum(undefined, ['fake', 'llm'], 'fake', '--agent'), 'fake'); - assert.equal(resolveEnum(true, ['fake', 'llm'], 'fake', '--agent'), 'fake'); - }); - - it('throws on an unrecognized value instead of silently using the default', () => { - // The private evals Live runner passes --agent=codex. Falling back to the - // canned fake agent produced a clean report of a deterministic stub labelled - // as a real harness run. - assert.throws( - () => resolveEnum('codex', ['fake', 'llm'], 'fake', '--agent'), - /--agent must be one of fake, llm; got: codex/, - ); - assert.throws( - () => resolveEnum('progresive', ['atomic', 'progressive'], 'atomic', '--delivery'), - /--delivery must be one of atomic, progressive/, - ); - }); -}); - -describe('provider hook smoke CLI', () => { - it('prints help without requiring a target repository', () => { - const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT, '--help'], { encoding: 'utf8' }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /^Usage: bun run smoke:hooks/); - assert.match(result.stdout, /target repo must be explicit/); - assert.equal(result.stderr, ''); - }); - - it('fails with the same usage guidance when the target repository is omitted', () => { - const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT], { encoding: 'utf8' }); - - assert.equal(result.status, 1); - assert.equal(result.stdout, ''); - assert.match(result.stderr, /^Usage: bun run smoke:hooks/); - assert.match(result.stderr, /target repo must be explicit/); - }); - - it('preserves the legacy string sentinel for value-less options', () => { - const cases = [ - { args: ['--repo'], error: /target repo does not exist: .*\/true/ }, - { args: ['--repo', '.', '--bundle'], error: /universal bundle does not exist: .*\/true/ }, - { args: ['--repo', '.', '--bundle', './missing.zip', '--providers'], error: /universal bundle does not exist: .*\/missing\.zip/ }, - ]; - - for (const { args, error } of cases) { - const cwd = mkdtempSync(join(tmpdir(), 'impeccable-provider-smoke-cli-')); - try { - const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT, ...args], { cwd, encoding: 'utf8' }); - - assert.equal(result.status, 1); - assert.doesNotMatch(result.stderr, /TypeError/); - assert.match(result.stderr, error); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - } - }); -}); diff --git a/tests/cli-ignores.test.js b/tests/cli-ignores.test.js deleted file mode 100644 index 8049106fb..000000000 --- a/tests/cli-ignores.test.js +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, rmSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const CLI = resolve('cli/bin/cli.js'); - -describe('impeccable ignores CLI', () => { - let root; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'imp-ignores-')); - }); - - afterEach(() => { - rmSync(root, { recursive: true, force: true }); - }); - - function run(args, options = {}) { - const result = spawnSync(process.execPath, [CLI, 'ignores', ...args], { - cwd: root, - encoding: 'utf-8', - ...options, - }); - if (result.error) throw result.error; - return result; - } - - function detect(args, options = {}) { - const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...args], { - cwd: root, - encoding: 'utf-8', - ...options, - }); - if (result.error) throw result.error; - return result; - } - - function readConfig(name = 'config.json') { - return JSON.parse(readFileSync(join(root, '.impeccable', name), 'utf-8')); - } - - test('adds and lists shared file and value ignores under detector', () => { - expect(run(['add-file', 'src/legacy/**']).status).toBe(0); - expect(run(['add-value', 'overused-font', 'Inter', '--reason', 'Brand font']).status).toBe(0); - - const raw = readConfig(); - expect(raw.hook).toBeUndefined(); - expect(raw.detector.ignoreFiles).toEqual(['src/legacy/**']); - expect(raw.detector.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason }))).toEqual([ - { rule: 'overused-font', value: 'inter', reason: 'Brand font' }, - ]); - expect(raw.detector.designSystem).toBeUndefined(); - - const listed = run(['list']); - expect(listed.status).toBe(0); - expect(listed.stdout).toContain('ignoreFiles: src/legacy/**'); - expect(listed.stdout).toContain('overused-font=inter'); - }); - - test('supports scoped wildcard value ignores and removal', () => { - expect(run(['add-value', 'design-system-color', '*', '--file', 'site/styles/demo.css']).status).toBe(0); - let raw = readConfig(); - expect(raw.detector.ignoreValues).toEqual([ - expect.objectContaining({ - rule: 'design-system-color', - value: '*', - files: ['site/styles/demo.css'], - }), - ]); - - expect(run(['remove-value', 'design-system-color', '*', '--file', 'site/styles/demo.css']).status).toBe(0); - raw = readConfig(); - expect(raw.detector.ignoreValues).toEqual([]); - }); - - test('file-scoped wildcard value ignores suppress non-value-bearing rules only in matching files', () => { - mkdirSync(join(root, 'components'), { recursive: true }); - const triangle = [ - 'export function TopicCard() {', - ' return (', - '
', - ' );', - '}', - '', - ].join('\n'); - writeFileSync(join(root, 'components', 'TopicCard.jsx'), triangle); - writeFileSync(join(root, 'components', 'Other.jsx'), triangle.replace('TopicCard', 'Other')); - - const before = detect(['components/TopicCard.jsx']); - expect(before.status).toBe(2); - expect(before.stdout).toContain('side-tab'); - - expect(run(['add-value', 'side-tab', '*', '--file', '**/TopicCard.jsx']).status).toBe(0); - - const afterTarget = detect(['components/TopicCard.jsx']); - expect(afterTarget.status).toBe(0); - expect(afterTarget.stdout.trim()).toBe('[]'); - - const afterOther = detect(['components/Other.jsx']); - expect(afterOther.status).toBe(2); - expect(afterOther.stdout).toContain('side-tab'); - }); - - test('rejects broad wildcard value ignores', () => { - const result = run(['add-value', 'design-system-color', '*']); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain('Wildcard value ignores must be scoped'); - expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false); - }); - - test('rejects exact values for rules that cannot extract one', () => { - const result = run(['add-value', 'side-tab', 'Inter']); - expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/side-tab has no extractable ignore value.*add-value side-tab "\*" --file /); - expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false); - }); - - test('removes an existing broad wildcard value ignore', () => { - mkdirSync(join(root, '.impeccable'), { recursive: true }); - writeFileSync(join(root, '.impeccable', 'config.json'), JSON.stringify({ - detector: { - ignoreValues: [{ rule: 'design-system-color', value: '*' }], - }, - })); - - const result = run(['remove-value', 'design-system-color', '*']); - expect(result.status).toBe(0); - expect(readConfig().detector.ignoreValues).toEqual([]); - }); - - test('writes local ignores without overriding shared design-system config', () => { - expect(run(['add-value', 'overused-font', 'Inter', '--local']).status).toBe(0); - - const local = readConfig('config.local.json'); - expect(local.detector.ignoreValues.map(({ rule, value }) => ({ rule, value }))).toEqual([ - { rule: 'overused-font', value: 'inter' }, - ]); - expect(local.detector.designSystem).toBeUndefined(); - }); -}); diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs deleted file mode 100644 index 1e04ab613..000000000 --- a/tests/concept-seed.test.mjs +++ /dev/null @@ -1,811 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { createServer } from 'node:http'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - readConceptCatalog, - validateConceptCatalog, - validateConceptEntry, -} from '../skill/scripts/lib/concept-catalog.mjs'; -import { readCompositionCatalog } from '../skill/scripts/lib/composition-catalog.mjs'; -import { dealCompositions, pingChosen, renderChallenger, selectApprovedChallengers, selectApprovedComposition, selectApprovedCompositions } from '../skill/scripts/concept-seed.mjs'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'concept-seed.mjs'); -// The live catalog is service-side (impeccable-site); the public repo tests -// the seed mechanics against this fixture catalog, which passes the same -// validators the real one does. -const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'concept-catalog'); - -const fixtureState = readConceptCatalog( - path.join(FIXTURE_DIR, 'concept-ingredients.json'), - path.join(FIXTURE_DIR, 'concept-reviews.json') -); -const fixtureConcepts = fixtureState.concepts; -const fixtureCompositions = readCompositionCatalog( - path.join(FIXTURE_DIR, 'composition-ingredients.json'), - path.join(FIXTURE_DIR, 'composition-reviews.json') -).compositions; - -function run(scope, extraArgs = [], env = {}) { - return spawnSync(process.execPath, [SCRIPT, '--scope', scope, '--from', 'stable-test', ...extraArgs], { - cwd: ROOT, - encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, ...env }, - }); -} - -describe('concept seed scopes', () => { - it('keeps complete-direction and established-world surface rolls reproducible but independent', () => { - const directionA = run('direction'); - const directionB = run('direction'); - const surface = run('surface'); - assert.equal(directionA.status, 0); - assert.equal(directionA.stdout, directionB.stdout); - assert.notEqual(directionA.stdout, surface.stdout); - assert.match(directionA.stdout, /DIRECTION CONCEPT SEED/); - assert.match(directionA.stdout, /source: local/); - assert.match(directionA.stdout, /selected\s+independently/); - assert.match(directionA.stdout, /substantially different future surface/); - assert.match(directionA.stdout, /Never expose assignment metadata/); - assert.match(directionA.stdout, /SYSTEM GRAMMAR:/); - assert.match(directionA.stdout, /CREATIVE SPARK:/); - assert.match(directionA.stdout, /WEB LEVERAGE:/); - assert.match(directionA.stdout, /credible\s+interface language/); - assert.match(directionA.stdout, /commit to it across navigation/); - assert.doesNotMatch(directionA.stdout, /undefined/); - assert.match(surface.stdout, /SURFACE CONCEPT SEED/); - assert.match(surface.stdout, /committed visual identity/); - }); - - it('rejects unknown scopes', () => { - const result = run('unknown'); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /direction or surface/); - }); - - it('never promotes a rank outside the grounded candidate ledger', () => { - for (const count of [5, 6, 7]) { - for (let index = 0; index < 30; index += 1) { - const result = spawnSync(process.execPath, [SCRIPT, '--scope', 'direction', '--from', `count-${count}-${index}`, '--candidate-count', String(count)], { - cwd: ROOT, - encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR }, - }); - assert.equal(result.status, 0); - const promoted = Number(result.stdout.match(/ASSIGNED INDEX: (\d+)/)?.[1]); - assert.equal(promoted >= 3 && promoted <= count, true, `rank ${promoted} must fit ${count} candidates`); - } - } - const invalid = run('direction', ['--candidate-count', '4']); - assert.notEqual(invalid.status, 0); - assert.match(invalid.stderr, /integer from 5 to 7/); - }); - - it('degrades to a promotion-only seed when catalog and API are both unreachable', () => { - const degraded = run('direction', ['--mode', 'persuade'], { - IMPECCABLE_CATALOG_DIR: '/nonexistent-catalog-dir', - IMPECCABLE_API_URL: 'http://127.0.0.1:9/api', - IMPECCABLE_API_TIMEOUT: '400', - }); - assert.equal(degraded.status, 0); - assert.match(degraded.stdout, /source: degraded/); - assert.match(degraded.stdout, /ASSIGNED INDEX: [3-7]/); - assert.match(degraded.stdout, /No challengers this run/); - assert.doesNotMatch(degraded.stdout, /CHALLENGERS:/); - }); - - it('keeps composition challengers inside the requested surface mode', () => { - const pool = [ - { id: 'persuade-stage', surface: 'persuade', status: 'approved' }, - { id: 'experience-stage', surface: 'experience', status: 'approved' }, - ]; - const experience = selectApprovedComposition({ - scope: 'direction', - key: 'mode-match', - mode: 'experience', - sourceCompositions: pool, - }); - const read = selectApprovedComposition({ - scope: 'direction', - key: 'mode-missing', - mode: 'read', - sourceCompositions: pool, - }); - assert.equal(experience?.id, 'experience-stage'); - assert.equal(read, null, 'a missing mode must not borrow an unrelated composition'); - - const rendered = run('direction', ['--mode', 'experience']); - assert.equal(rendered.status, 0); - assert.match(rendered.stdout, /mode: experience/); - assert.match(rendered.stdout, /--scope direction --mode experience --from stable-test/); - // Compositions are pulled from the deal by default until the expanded - // catalog ships; the draw machinery stays live behind the env gate. - assert.doesNotMatch(rendered.stdout, /COMPOSITION/); - const gated = run('direction', ['--mode', 'experience'], { IMPECCABLE_COMPOSITIONS: '1' }); - assert.equal(gated.status, 0); - assert.match(gated.stdout, /FIRST-SURFACE COMPOSITION/); - }); - - it('draws several composition inputs from distinct families when the approved pool allows it', () => { - const pool = [ - { id: 'a', familyId: 'first', surface: 'persuade', status: 'approved' }, - { id: 'b', familyId: 'scroll', surface: 'persuade', status: 'approved' }, - { id: 'c', familyId: 'physics', surface: 'persuade', status: 'approved' }, - { id: 'd', familyId: 'first', surface: 'persuade', status: 'approved' }, - { id: 'e', familyId: 'other', surface: 'operate', status: 'approved' }, - ]; - const picks = selectApprovedCompositions({ scope: 'direction', key: 'several', mode: 'persuade', sourceCompositions: pool }); - assert.equal(picks.length, 3); - assert.equal(new Set(picks.map(pick => pick.familyId)).size, 3); - assert.equal(picks.every(pick => pick.surface === 'persuade'), true); - }); - - it('validates the fixture catalog with the real gates', () => { - const result = validateConceptCatalog(fixtureState.catalog, fixtureState.reviewData); - assert.deepEqual(result.errors, []); - assert.equal(result.stats.approved >= 24, true); - for (const tier of ['graphic', 'interaction', 'atmosphere']) { - const approved = fixtureConcepts.filter(concept => concept.status === 'approved' && concept.wellTier === tier); - assert.equal(approved.length >= 6, true, `${tier} needs re-roll depth`); - } - }); - - it('rejects concepts that name a motif without system grammar and web leverage', () => { - const errors = validateConceptEntry({ - id: 'fixture-newspaper', - form: 'a newspaper front page, with headline hierarchy and columns', - lineage: 'editorial publishing', - tags: ['hierarchy', 'columns', 'serial'], - strength: 'dual', - }); - assert.equal(errors.some(error => error.includes('system grammar')), true); - assert.equal(errors.some(error => error.includes('web leverage')), true); - }); - - it('rejects literal operations archetypes even when their UI grammar is complete', () => { - const errors = validateConceptEntry({ - id: 'fixture-mission-control', - form: 'a mission control room, where panels, alerts, and operator stations coordinate a launch', - lineage: 'immersive environmental experience', - tags: ['depth', 'threshold', 'rhythm'], - strength: 'dual', - spark: 'Cold reflected light moves across the room as the countdown opens one route, closes another, and leaves a bright trace of every recent decision on the main board.', - system: [ - 'Palette/material: wet basalt grays, cold green light, and one warm seam marking the active route', - 'Type/composition: narrow engraved capitals for room names over a quiet cartographic body voice', - 'Topology/navigation: move by chamber, threshold, and revealed route', - 'Controls/state: inspect open, pending, closed, and remembered states', - 'Responsive/motion: turn depth into a stepwise route with orientation kept', - ], - webLeverage: 'WebGL depth rendering with a complete keyboard-readable route index', - }); - assert.equal(errors.some(error => error.includes('operations archetype')), true); - }); - - it('welcomes materially specific craft tools instead of confusing them with operational software', () => { - const errors = validateConceptEntry({ - id: 'cabinetmaker-workbench', - form: "a cabinetmaker's workbench, where holdfasts, planing stops, shavings, and old cuts turn careful joinery into visible memory", - lineage: 'Cabinetmaking benches, workholding craft, hand-planing practice, and tool-wear conservation', - tags: ['workholding', 'joinery', 'patina'], - strength: 'world', - spark: 'Morning light crosses a blackened bench top as pale curls gather behind a plane, a holdfast rings into place, and fresh dovetails rise from a century of cuts.', - system: [ - 'Palette/material: blackened beech, pale shaving curls, and brass holdfast glints over a workshop-gray ground', - 'Type/composition: stamped maker marks and penciled layout lines ranked against a strong horizontal bench datum', - 'Topology/navigation: follow stock from reference face through marked joints and fitted assemblies', - 'Controls/state: clamp, mark, plane, pare, dry-fit, revise, and preserve grain direction', - 'Responsive/motion: unfold the bench into a project sequence and follow each hand gesture', - ], - webLeverage: 'Grain-aware direct manipulation, reversible project history, and a keyboard-readable construction diagram', - }); - assert.deepEqual(errors, []); - }); - - it('selects six approved challengers, two from every translation tier', () => { - for (let index = 0; index < 100; index += 1) { - const { picks } = selectApprovedChallengers({ scope: 'surface', key: `coverage-${index}`, sourceConcepts: fixtureConcepts }); - assert.equal(picks.length, 6); - for (const tier of ['graphic', 'interaction', 'atmosphere']) { - assert.equal(picks.filter(pick => pick.wellTier === tier).length, 2); - } - assert.equal(new Set(picks.map(pick => pick.id)).size, 6); - assert.equal(picks.every(pick => pick.status === 'approved'), true); - } - }); - - it('re-rolls draw disjoint challengers and stay reproducible from the base key', () => { - const rounds = [0, 1, 2].map(reroll => - selectApprovedChallengers({ scope: 'direction', key: 'reroll-chain', reroll, sourceConcepts: fixtureConcepts }) - ); - const again = selectApprovedChallengers({ scope: 'direction', key: 'reroll-chain', reroll: 2, sourceConcepts: fixtureConcepts }); - assert.deepEqual(again.picks.map(pick => pick.id), rounds[2].picks.map(pick => pick.id)); - const seen = new Set(); - for (const round of rounds) { - assert.equal(round.picks.length, 6); - assert.equal(round.picks.every(pick => !seen.has(pick.id)), true); - for (const pick of round.picks) seen.add(pick.id); - for (const tier of ['graphic', 'interaction', 'atmosphere']) { - assert.equal(round.picks.filter(pick => pick.wellTier === tier).length, 2); - } - } - }); - - it('renders re-roll rounds with elimination framing and a chained reproduction key', () => { - const round0 = run('direction'); - const round1A = run('direction', ['--reroll', '1']); - const round1B = run('direction', ['--reroll', '1']); - assert.equal(round1A.status, 0); - assert.equal(round1A.stdout, round1B.stdout); - assert.notEqual(round1A.stdout, round0.stdout); - assert.match(round1A.stdout, /RE-ROLL ROUND 1/); - assert.match(round1A.stdout, /may not return reworded/); - assert.match(round1A.stdout, /--from stable-test --reroll 1/); - assert.doesNotMatch(round0.stdout, /RE-ROLL ROUND/); - const invalid = run('direction', ['--reroll', 'nope']); - assert.notEqual(invalid.status, 0); - assert.match(invalid.stderr, /non-negative integer/); - }); - - it('registers steer the round presentation without changing the deal', () => { - const plain = run('direction', ['--reroll', '1']); - const bolder = run('direction', ['--reroll', '1', '--register', 'bolder']); - const safer = run('direction', ['--reroll', '1', '--register', 'safer']); - assert.equal(bolder.status, 0); - assert.equal(safer.status, 0); - // A register is presentation-only: the same key and reroll count deal the - // same challengers, so the exclusion chain never forks on register. - const dealtIds = (out) => [...out.matchAll(/SOURCE ID: ([a-z0-9-]+)/g)].map((m) => m[1]).sort(); - assert.deepEqual(dealtIds(bolder.stdout), dealtIds(plain.stdout), 'bolder presents the same deal the plain round drew'); - assert.match(bolder.stdout, /BOLDER REGISTER/); - assert.match(bolder.stdout, /FIRST dealt challenger leads/); - assert.match(bolder.stdout, /--register bolder/); - assert.doesNotMatch(bolder.stdout, /ASSIGNED INDEX:/); - // The generic weighing instruction measures against the assigned - // direction, which a bolder round suspended; bolder weighs against the - // leader instead, and the contradiction must not ship. - assert.match(bolder.stdout, /against the fused LEADER/); - assert.doesNotMatch(bolder.stdout, /against the assigned direction/); - assert.match(safer.stdout, /SAFER REGISTER/); - assert.match(safer.stdout, /sanctioned lineup/); - assert.doesNotMatch(safer.stdout, /^CHALLENGERS:/m, 'the safer round spends its hand unseen'); - // Degraded safer must not contradict itself: "the user picks" and a - // mandatory numbered build order cannot share one output. - const degradedSafer = run('direction', ['--reroll', '1', '--register', 'safer'], { - IMPECCABLE_CATALOG_DIR: '/nonexistent-catalog-dir', - IMPECCABLE_API_URL: 'http://127.0.0.1:1', - }); - assert.equal(degradedSafer.status, 0); - assert.match(degradedSafer.stdout, /source: degraded/); - assert.match(degradedSafer.stdout, /SAFER REGISTER/); - assert.doesNotMatch(degradedSafer.stdout, /ASSIGNED INDEX/, 'degraded safer suppresses the assignment machinery'); - assert.doesNotMatch(degradedSafer.stdout, /Build candidate/, 'degraded safer mandates no numbered candidate'); - const degradedBolder = run('direction', ['--reroll', '1', '--register', 'bolder'], { - IMPECCABLE_CATALOG_DIR: '/nonexistent-catalog-dir', - IMPECCABLE_API_URL: 'http://127.0.0.1:1', - }); - assert.equal(degradedBolder.status, 0); - assert.match(degradedBolder.stdout, /BOLDER REGISTER UNAVAILABLE/); - assert.match(degradedBolder.stdout, /ASSIGNED INDEX: /, 'degraded bolder falls back to the plain grounded assignment'); - const invalidRegister = run('direction', ['--reroll', '1', '--register', 'wilder']); - assert.notEqual(invalidRegister.status, 0); - assert.match(invalidRegister.stderr, /must be safer or bolder/); - const noReroll = run('direction', ['--register', 'bolder']); - assert.notEqual(noReroll.status, 0); - assert.match(noReroll.stderr, /re-roll round/); - }); - - it('filters challengers by strength per scope and falls back when a tier has no match', () => { - const make = (id, tier, strength) => ({ - id, - familyId: `${id}-family`, - wellId: `${id}-well`, - wellTier: tier, - strength, - status: 'approved', - form: `${id} form`, - spark: `${id} spark`, - system: [], - webLeverage: `${id} web`, - }); - const pool = [ - make('poster', 'graphic', 'world'), - make('flipbook', 'graphic', 'composition'), - make('radar', 'interaction', 'dual'), - make('cavern', 'atmosphere', 'world'), - ]; - for (let index = 0; index < 40; index += 1) { - const direction = selectApprovedChallengers({ scope: 'direction', key: `d-${index}`, sourceConcepts: pool }); - assert.equal(direction.picks.every(pick => pick.strength !== 'composition'), true); - const surface = selectApprovedChallengers({ scope: 'surface', key: `s-${index}`, sourceConcepts: pool }); - const surfaceGraphic = surface.picks.find(pick => pick.wellTier === 'graphic'); - assert.equal(surfaceGraphic.id, 'flipbook'); - // atmosphere has no composition|dual entries, so surface falls back to its full pool - const surfaceAtmosphere = surface.picks.find(pick => pick.wellTier === 'atmosphere'); - assert.equal(surfaceAtmosphere.id, 'cavern'); - } - }); - - it('weights challenger draws by approval rating without shrinking the pool', () => { - const make = (id, rating) => ({ - id, - familyId: `${id}-family`, - wellId: `${id}-well`, - wellTier: 'graphic', - strength: 'world', - status: 'approved', - form: `${id} form`, - spark: `${id} spark`, - system: [], - webLeverage: `${id} web`, - review: rating ? { status: 'approved', rating } : { status: 'approved' }, - }); - const filler = (tier, id) => ({ - ...make(id, undefined), - wellTier: tier, - }); - const pool = [ - make('flagship', 3), - make('solid-a', 2), - make('solid-b', undefined), - make('marginal', 1), - filler('interaction', 'radar'), - filler('atmosphere', 'cavern'), - ]; - const counts = { flagship: 0, 'solid-a': 0, 'solid-b': 0, marginal: 0 }; - for (let index = 0; index < 300; index += 1) { - const { picks } = selectApprovedChallengers({ scope: 'direction', key: `weight-${index}`, sourceConcepts: pool }); - const graphicFirst = picks.find(pick => pick.wellTier === 'graphic'); - counts[graphicFirst.id] += 1; - } - // A 1-star draws at half weight rather than not at all. Excluding it made a - // rating do the job breadth already does, and a marginal keep records - // "narrow or unexceptional" rather than "wrong". - assert.equal(counts.marginal > 0, true, `marginal ${counts.marginal} should draw`); - assert.equal(counts.marginal < counts['solid-b'], true, - `marginal ${counts.marginal} should draw below solid-b ${counts['solid-b']}`); - - // A 3-star no longer outdraws a 2-star. The multiplier concentrated the - // draw hard on a thin pool: measured on the live catalog, 3-star worlds took - // 75% of the interaction draw from 15 of 25 eligible worlds. - const spread = Math.abs(counts.flagship - counts['solid-b']) / Math.max(counts.flagship, counts['solid-b']); - assert.equal(spread < 0.4, true, - `flagship ${counts.flagship} and solid-b ${counts['solid-b']} should draw comparably`); - - // A tier holding only 1-star approvals still yields challengers. - const onlyMarginal = [ - make('lone-marginal', 1), - filler('interaction', 'radar2'), - filler('atmosphere', 'cavern2'), - ]; - const { picks } = selectApprovedChallengers({ scope: 'direction', key: 'lone', sourceConcepts: onlyMarginal }); - assert.equal(picks.some(pick => pick.id === 'lone-marginal'), true); - }); - - it('holds niche worlds out of the challenger pool however strong their rating', () => { - const make = (id, rating, breadth) => ({ - id, - familyId: `${id}-family`, - wellId: `${id}-well`, - wellTier: 'graphic', - strength: 'world', - status: 'approved', - form: `${id} form`, - spark: `${id} spark`, - system: [], - webLeverage: `${id} web`, - review: { status: 'approved', ...(rating ? { rating } : {}), ...(breadth ? { breadth } : {}) }, - }); - const filler = (tier, id) => ({ ...make(id), wellTier: tier }); - const pool = [ - make('broad-flagship', 3), - make('narrow-flagship', 3, 'niche'), - make('broad-solid', 2), - filler('interaction', 'radar3'), - filler('atmosphere', 'cavern3'), - ]; - // Breadth excludes independently of rating: over many keys a niche 3-star - // never challenges while its broad peers keep rotating. - for (let index = 0; index < 200; index += 1) { - const { picks } = selectApprovedChallengers({ scope: 'direction', key: `breadth-${index}`, sourceConcepts: pool }); - assert.equal(picks.some(pick => pick.id === 'narrow-flagship'), false, `niche world drawn at key breadth-${index}`); - } - // A tier holding only niche approvals falls back rather than starving, - // exactly like the marginal-only tier above. - const onlyNiche = [ - make('lone-niche', 3, 'niche'), - filler('interaction', 'radar4'), - filler('atmosphere', 'cavern4'), - ]; - const { picks } = selectApprovedChallengers({ scope: 'direction', key: 'lone-niche', sourceConcepts: onlyNiche }); - assert.equal(picks.some(pick => pick.id === 'lone-niche'), true); - }); - - it('weights composition draws by rating without letting the ticket dedupe erase the weight', () => { - const pool = [ - { id: 'flagship-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 3 } }, - { id: 'plain-stage', surface: 'persuade', status: 'approved', review: { status: 'approved' } }, - { id: 'marginal-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 1 } }, - ]; - const counts = { 'flagship-stage': 0, 'plain-stage': 0, 'marginal-stage': 0 }; - for (let index = 0; index < 300; index += 1) { - const picks = selectApprovedCompositions({ scope: 'direction', key: `stage-weight-${index}`, mode: 'persuade', sourceCompositions: pool, count: 1 }); - counts[picks[0].id] += 1; - } - // Same weighting as challengers: a 1-star draws at half rather than not at - // all, and a 3-star no longer outdraws a 2-star. - assert.equal(counts['marginal-stage'] > 0, true, 'a 1-star composition still draws, at half weight'); - assert.equal(counts['marginal-stage'] < counts['plain-stage'], true, - `marginal ${counts['marginal-stage']} should draw below plain ${counts['plain-stage']}`); - const stageSpread = Math.abs(counts['flagship-stage'] - counts['plain-stage']) - / Math.max(counts['flagship-stage'], counts['plain-stage']); - assert.equal(stageSpread < 0.4, true, - `flagship ${counts['flagship-stage']} and plain ${counts['plain-stage']} should draw comparably`); - - // A pool of nothing but 1-star keeps still yields compositions. - const onlyMarginal = [ - { id: 'lone-marginal-stage', surface: 'persuade', status: 'approved', review: { status: 'approved', rating: 1 } }, - ]; - const fallback = selectApprovedCompositions({ scope: 'direction', key: 'stage-lone', mode: 'persuade', sourceCompositions: onlyMarginal }); - assert.equal(fallback.some(pick => pick.id === 'lone-marginal-stage'), true); - }); - - it('gates compositions by breadth and falls back when every composition is niche', () => { - const pool = [ - { id: 'broad-stage', surface: 'persuade', status: 'approved' }, - { id: 'niche-stage', surface: 'persuade', status: 'approved', review: { breadth: 'niche' } }, - ]; - for (let index = 0; index < 60; index += 1) { - const picks = selectApprovedCompositions({ scope: 'direction', key: `stage-breadth-${index}`, mode: 'persuade', sourceCompositions: pool }); - assert.equal(picks.some(pick => pick.id === 'niche-stage'), false, `niche composition dealt at key stage-breadth-${index}`); - } - const allNiche = [ - { id: 'only-niche-stage', surface: 'persuade', status: 'approved', review: { breadth: 'niche' } }, - ]; - const fallback = selectApprovedCompositions({ scope: 'direction', key: 'all-niche', mode: 'persuade', sourceCompositions: allNiche }); - assert.equal(fallback.some(pick => pick.id === 'only-niche-stage'), true, 'an all-niche pool must fall back instead of dealing nothing'); - }); - - it('mode-filters the fixture composition pool per surface register', () => { - const operate = selectApprovedComposition({ scope: 'surface', key: 'fix-mode', mode: 'operate', sourceCompositions: fixtureCompositions }); - assert.equal(operate.surface, 'operate'); - const experience = selectApprovedComposition({ scope: 'surface', key: 'fix-mode', mode: 'experience', sourceCompositions: fixtureCompositions }); - assert.equal(experience.surface, 'experience'); - }); - - it('renders the vivid spark before system and browser leverage', () => { - const output = renderChallenger({ - form: 'a spiral galaxy, where gravity, orbit, density, and darkness organize attention across radical scales', - spark: 'A brilliant core holds the central promise while related ideas travel through spiral arms and distant fragments wait at the edge of perception.', - system: [ - 'Palette/material: deep space black, star-white points, and one warm core glow reserved for the focus', - 'Type/composition: hairline astronomical labels orbiting a monumental numeral voice', - 'Topology/navigation: orbit a stable core and travel by arm or scale', - 'Controls/state: focus, compare, capture, and release orbiting material', - 'Responsive/motion: collapse depth into a radial sequence with orientation kept', - ], - webLeverage: 'WebGL semantic zoom with a complete keyboard-readable DOM index', - }, 0); - assert.match(output, /spiral galaxy/); - assert.match(output, /CREATIVE SPARK: A brilliant core/); - assert.equal(output.indexOf('CREATIVE SPARK:') < output.indexOf('SYSTEM GRAMMAR:'), true); - }); -}); - -describe('init gate', () => { - const gateRun = (cwd) => spawnSync(process.execPath, [SCRIPT, '--scope', 'direction', '--from', 'gate-test'], { - cwd, - encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_CONTEXT_DIR: '' }, - }); - - it('refuses to deal when no PRODUCT.md exists and routes to init', () => { - const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-noproduct-')); - const result = gateRun(dir); - assert.equal(result.status, 1); - assert.match(result.stdout, /NO_PRODUCT_MD/); - assert.match(result.stdout, /init/); - assert.doesNotMatch(result.stdout, /ASSIGNED INDEX/); - }); - - it('deals normally once PRODUCT.md exists', () => { - const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-product-')); - writeFileSync(path.join(dir, 'PRODUCT.md'), '# Test Product\n\n## Register\n\nbrand\n'); - const result = gateRun(dir); - assert.equal(result.status, 0); - assert.doesNotMatch(result.stdout, /NO_PRODUCT_MD/); - }); - - it('never gates the choice ping', () => { - const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-ping-')); - const result = spawnSync(process.execPath, [SCRIPT, '--chosen', 'assigned', '--from', 'gate-test'], { - cwd: dir, - encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_NO_TELEMETRY: '1' }, - }); - assert.equal(result.status, 0); - assert.doesNotMatch(result.stdout, /NO_PRODUCT_MD/); - // --kind alone is a valid ping invocation (assigned/pick/canon outcomes - // have no catalog id) and is equally ungated. - const kindOnly = spawnSync(process.execPath, [SCRIPT, '--kind', 'assigned', '--from', 'gate-test'], { - cwd: dir, - encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_NO_TELEMETRY: '1' }, - }); - assert.equal(kindOnly.status, 0); - assert.doesNotMatch(kindOnly.stdout, /NO_PRODUCT_MD/); - assert.match(kindOnly.stdout, /choice ping skipped/, 'telemetry-disabled kind ping reports skipped, not an error'); - }); - - it('pingChosen validates kinds, requires ids only for challenger wins, and honors opt-out', async () => { - const calls = []; - const realFetch = globalThis.fetch; - globalThis.fetch = async (url, opts) => { calls.push(JSON.parse(opts.body)); return { ok: true }; }; - // telemetryDisabled() honors DO_NOT_TRACK too, so a developer shell with - // it set must not fail the success-path assertions below. - const savedDnt = process.env.DO_NOT_TRACK; - const savedNoTelemetry = process.env.IMPECCABLE_NO_TELEMETRY; - try { - delete process.env.DO_NOT_TRACK; - process.env.IMPECCABLE_NO_TELEMETRY = '1'; - assert.equal(await pingChosen({ kind: 'assigned', key: 'k' }), false, 'opt-out wins over everything'); - delete process.env.IMPECCABLE_NO_TELEMETRY; - assert.equal(await pingChosen({ kind: 'assigned', key: 'k', scope: 'direction' }), true, 'kind-only ping for a non-challenger outcome'); - assert.equal(await pingChosen({ kind: 'challenger', key: 'k' }), false, 'a challenger win without an id is not a ping'); - assert.equal(await pingChosen({ kind: 'weird', chosenId: 'x', key: 'k' }), false, 'unknown kinds are dropped'); - assert.equal(await pingChosen({ kind: 'assigned', register: 'wilder', key: 'k' }), false, 'unknown registers are dropped'); - assert.equal(await pingChosen({ chosenId: 'legacy-id', key: 'k' }), true, 'legacy id-only shape stays valid'); - assert.equal(await pingChosen({ kind: 'canon', register: 'safer', key: 'k' }), true, 'register rides along on a steered round'); - const bodies = calls; - assert.equal(bodies[0].kind, 'assigned'); - assert.equal(bodies[0].chosenId, undefined, 'no id field on kind-only pings'); - assert.equal(bodies[1].chosenId, 'legacy-id'); - assert.equal(bodies[1].kind, undefined, 'legacy pings carry no kind'); - assert.equal(bodies[2].register, 'safer'); - } finally { - globalThis.fetch = realFetch; - if (savedDnt === undefined) delete process.env.DO_NOT_TRACK; - else process.env.DO_NOT_TRACK = savedDnt; - if (savedNoTelemetry === undefined) delete process.env.IMPECCABLE_NO_TELEMETRY; - else process.env.IMPECCABLE_NO_TELEMETRY = savedNoTelemetry; - } - }); - - // Mode eligibility on worlds. Before this, selectApprovedChallengers never - // received the mode at all, so a build asking for an app UI could draw six - // worlds that only make sense on a landing page. - it('keeps worlds out of modes their reviewer excluded, and treats absent as all modes', () => { - const make = (id, tier, allowedModes) => ({ - id, - familyId: `${id}-family`, - wellTier: tier, - strength: 'world', - status: 'approved', - form: `${id} form`, - spark: `${id} spark`, - system: [], - webLeverage: `${id} web`, - review: { status: 'approved', ...(allowedModes ? { allowedModes } : {}) }, - }); - const pool = [ - make('persuade-only', 'graphic', ['persuade']), - make('anywhere', 'graphic'), - make('operate-capable', 'graphic', ['operate', 'read']), - make('radar', 'interaction'), - make('cavern', 'atmosphere'), - ]; - - for (let index = 0; index < 25; index += 1) { - const operate = selectApprovedChallengers({ - scope: 'direction', key: `mode-gate-${index}`, mode: 'operate', sourceConcepts: pool, - }).picks.map(pick => pick.id); - assert.equal(operate.includes('persuade-only'), false, `persuade-only dealt for operate at ${index}`); - } - - // Absent allowedModes stays eligible in every mode. - const seen = new Set(); - for (let index = 0; index < 25; index += 1) { - for (const mode of ['persuade', 'operate', 'read', 'experience']) { - for (const pick of selectApprovedChallengers({ - scope: 'direction', key: `mode-any-${index}`, mode, sourceConcepts: pool, - }).picks) seen.add(pick.id); - } - } - assert.equal(seen.has('anywhere'), true, 'a world with no allowedModes must stay eligible'); - }); - - it('falls back rather than starving a tier whose every world excludes the mode', () => { - const make = (id, tier, allowedModes) => ({ - id, - familyId: `${id}-family`, - wellTier: tier, - strength: 'world', - status: 'approved', - form: `${id} form`, - spark: `${id} spark`, - system: [], - webLeverage: `${id} web`, - review: { status: 'approved', ...(allowedModes ? { allowedModes } : {}) }, - }); - // The whole interaction tier is persuade-only. Selection must degrade to it - // rather than throw or deal fewer than six. - const pool = [ - make('graphic-any', 'graphic'), - make('graphic-two', 'graphic'), - make('radar-persuade', 'interaction', ['persuade']), - make('cavern-any', 'atmosphere'), - ]; - const picks = selectApprovedChallengers({ - scope: 'direction', key: 'starve', mode: 'operate', sourceConcepts: pool, - }).picks; - assert.equal(picks.some(pick => pick.id === 'radar-persuade'), true, 'an emptied tier must fall back to its full pool'); - }); - - // Grain: how much of the product a composition composes. Framed by what the - // skill can be asked for (a docs site, an onboarding flow, a landing page, a - // data table) rather than by what the catalog happens to hold. - it('prefers the requested grain and tops up from the register', () => { - const make = (id, grain) => ({ - id, familyId: `${id}-family`, surface: 'operate', status: 'approved', - ...(grain ? { grain } : {}), review: { status: 'approved' }, - }); - const pool = [ - make('flow-one', 'flow'), - make('flow-two', 'flow'), - make('view-one', 'view'), - make('view-two', 'view'), - make('untagged', null), - ]; - const dealt = dealCompositions({ scope: 'surface', key: 'grain-pref', mode: 'operate', grain: 'flow', sourceCompositions: pool }); - assert.equal(dealt.picks.length, 3, 'a thin grain tops up rather than dealing fewer'); - const ids = dealt.picks.map(pick => pick.id); - assert.equal(ids.includes('flow-one') && ids.includes('flow-two'), true, 'both grain matches deal first'); - assert.equal(dealt.match.atGrain, 2); - assert.equal(dealt.match.grainAvailable, 2); - }); - - // The report is the point. Three plausible view-grain compositions dealt - // against a flow request, with no signal that none matched, is the same silent - // plausibility this axis exists to remove. - it('reports a grain miss instead of passing off borrowed structure', () => { - const make = id => ({ id, familyId: `${id}-family`, surface: 'operate', status: 'approved', grain: 'view', review: { status: 'approved' } }); - const pool = [make('a'), make('b'), make('c')]; - const dealt = dealCompositions({ scope: 'surface', key: 'grain-miss', mode: 'operate', grain: 'flow', sourceCompositions: pool }); - assert.equal(dealt.picks.length, 3, 'still deals three'); - assert.equal(dealt.match.atGrain, 0, 'and says none matched'); - assert.equal(dealt.match.grainAvailable, 0); - }); - - // Platform is a hard filter, not a preference: a composition that needs hover - // does not degrade on a phone, it stops working. - it('excludes compositions the platform cannot carry, with no fallback', () => { - const make = (id, platforms) => ({ - id, familyId: `${id}-family`, surface: 'operate', status: 'approved', - ...(platforms ? { platforms } : {}), review: { status: 'approved' }, - }); - const pool = [make('web-only', ['web']), make('anywhere', null), make('native', ['ios', 'android'])]; - const onIos = dealCompositions({ scope: 'surface', key: 'plat', mode: 'operate', platform: 'ios', sourceCompositions: pool }); - const ids = onIos.picks.map(pick => pick.id); - assert.equal(ids.includes('web-only'), false, 'a web-only composition must not reach an iOS build'); - assert.equal(onIos.match.platformExcluded, 1); - - const allWebOnly = [make('x', ['web']), make('y', ['web'])]; - const starved = dealCompositions({ scope: 'surface', key: 'plat2', mode: 'operate', platform: 'android', sourceCompositions: allWebOnly }); - assert.deepEqual(starved.picks, [], 'an empty deal beats dealing something that cannot work'); - }); - - it('reproduces a grain-scoped deal from the same key', () => { - const make = (id, grain) => ({ - id, familyId: `${id}-family`, surface: 'read', status: 'approved', - ...(grain ? { grain } : {}), review: { status: 'approved' }, - }); - const pool = [make('p1', 'product'), make('v1', 'view'), make('v2', 'view'), make('r1', 'region')]; - const args = { scope: 'surface', key: 'grain-stable', mode: 'read', grain: 'product', sourceCompositions: pool }; - assert.deepEqual( - selectApprovedCompositions(args).map(p => p.id), - selectApprovedCompositions(args).map(p => p.id) - ); - }); -}); - -// The Windows abort in issue #504 (nodejs/node#56645) needs three things at -// once: a successful roll over Node's undici-backed fetch, the keep-alive -// socket that success leaves pooled, and the explicit process.exit at the end -// of the CLI. The suite's other API test exercises only the unreachable-API -// fallback, which leaves no pooled socket and so never walked the crashing -// path. This one serves a real roll from a local server and asserts the CLI -// destroys fetch's global dispatcher before exiting, so the teardown cannot -// silently regress. The teardown is Node fetch internals, so the CLI is -// spawned with node even when the suite itself runs under bun. -describe('API roll path', () => { - const NODE = process.versions.bun ? 'node' : process.execPath; - - const ROLL_PAYLOAD = { - poolRevision: 'api-test-rev', - approvedCount: 6, - catalogCount: 9, - challengers: [{ - id: 'api-test-world', - form: 'a letterpress print shop, where type, ink, and impression organize the page', - spark: 'Deep impressions hold the central promise while loose sorts wait in the case.', - system: ['Palette/material: dense ink black bitten into soft cotton paper'], - webLeverage: 'Variable-font impression depth with a keyboard-readable page structure', - }], - compositions: [], - }; - - // Wraps the global dispatcher's destroy so the parent test can observe the - // CLI's exit teardown. The warmup fetch makes fetch install the dispatcher - // before the wrap, and parks a keep-alive socket in its pool, which is the - // exact state the Windows crash needs at exit. - const PRELOAD = [ - "const KEY = Symbol.for('undici.globalDispatcher.1');", - 'await fetch(`${process.env.IMPECCABLE_API_URL}/warmup`).then(r => r.arrayBuffer()).catch(() => {});', - 'const dispatcher = globalThis[KEY];', - "if (dispatcher && typeof dispatcher.destroy === 'function') {", - ' const destroy = dispatcher.destroy.bind(dispatcher);', - ' dispatcher.destroy = (...args) => {', - " process.stderr.write('DISPATCHER_DESTROY_CALLED\\n');", - ' return destroy(...args);', - ' };', - '}', - '', - ].join('\n'); - - it('resolves a successful roll and destroys the fetch dispatcher before the explicit exit', async () => { - const requests = []; - const server = createServer((req, res) => { - requests.push(req.url); - if (req.url.startsWith('/api/roll?')) { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(ROLL_PAYLOAD)); - return; - } - res.statusCode = 404; - res.end('not found'); - }); - await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)); - try { - const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-api-')); - writeFileSync(path.join(dir, 'PRODUCT.md'), '# Test Product\n\n## Platform\n\nweb\n'); - const preloadPath = path.join(dir, 'wrap-dispatcher.mjs'); - writeFileSync(preloadPath, PRELOAD); - const result = await new Promise((resolveRun, rejectRun) => { - const child = spawn(NODE, [ - '--import', pathToFileURL(preloadPath).href, - SCRIPT, '--scope', 'direction', '--mode', 'persuade', '--from', 'api-test', - ], { - cwd: dir, - env: { - ...process.env, - IMPECCABLE_CATALOG_DIR: '/nonexistent-catalog-dir', - IMPECCABLE_API_URL: `http://127.0.0.1:${server.address().port}/api`, - }, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', chunk => { stdout += chunk; }); - child.stderr.on('data', chunk => { stderr += chunk; }); - child.on('error', rejectRun); - child.on('close', status => resolveRun({ status, stdout, stderr })); - }); - assert.equal(result.status, 0, `stderr: ${result.stderr}`); - assert.equal(requests.some(url => url.startsWith('/api/roll?')), true, 'the CLI must hit the roll endpoint'); - assert.match(result.stdout, /source: api/); - assert.match(result.stdout, /letterpress print shop/); - // The choice-recording command rides on build-phase start now (the - // separate TELEMETRY ping was the step every comp-round-skipping run - // suppressed); an API roll names it with the --chosen slot. - assert.match(result.stdout, /AFTER THE CHOICE, run exactly one command/); - assert.match(result.stdout, /build-phase\.mjs start --direction [\w-]+ --kind \[--chosen \]/); - assert.match(result.stderr, /DISPATCHER_DESTROY_CALLED/, 'the dispatcher must be destroyed before process.exit'); - } finally { - server.close(); - } - }); -}); diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs deleted file mode 100644 index 8766abe82..000000000 --- a/tests/context-signals.test.mjs +++ /dev/null @@ -1,682 +0,0 @@ -/** - * Tests for context-signals.mjs — the signal gatherer behind the - * context-aware bare `/impeccable` (no-argument) path. - * - * The script collects deterministic project signals and emits JSON; it does - * not score or rank (the agent reasons over the raw signals). These tests - * cover signal collection and the never-throw / always-valid-JSON contract. - * - * Each test runs in its own scratch dir under os.tmpdir(). - */ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { fileURLToPath } from 'node:url'; - -import { gatherSignals } from '../skill/scripts/context-signals.mjs'; - -const SCRIPT_PATH = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '..', 'skill', 'scripts', 'context-signals.mjs', -); - -let scratch; -beforeEach(() => { - scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-signals-')); -}); -afterEach(() => { - fs.rmSync(scratch, { recursive: true, force: true }); -}); - -function write(rel, body) { - const abs = path.join(scratch, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, body); -} - -describe('gatherSignals', () => { - it('reports no setup context in an empty dir', async () => { - const s = await gatherSignals(scratch); - assert.equal(s.setup.hasProduct, false); - assert.equal(s.setup.hasDesign, false); - assert.equal(Object.hasOwn(s.setup, 'register'), false); - assert.equal(s.setup.hasCode, false); - assert.equal(s.critique.latest, null); - }); - - it('detects PRODUCT.md, platform, and code presence', async () => { - write('PRODUCT.md', '# Product\n\n## Platform\n\nweb\n'); - write('package.json', '{"name":"x"}'); - const s = await gatherSignals(scratch); - assert.equal(s.setup.hasProduct, true); - assert.equal(s.setup.platform, 'web'); - assert.equal(s.setup.hasCode, true); - }); - - it('flags missing DESIGN.md when code exists', async () => { - write('PRODUCT.md', '# Product\n\n## Platform\n\nweb\n'); - write('src/App.tsx', 'export default 1;'); - const s = await gatherSignals(scratch); - assert.equal(s.setup.hasProduct, true); - assert.equal(s.setup.hasDesign, false); - assert.equal(s.setup.hasCode, true); - assert.equal(s.setup.platform, 'web'); - }); - - it('reads the newest critique snapshot score', async () => { - write('.impeccable/critique/2026-05-01T10-00-00Z__home.md', - '---\nslug: home\nscore: 6\np0: 1\np1: 3\ntimestamp: 2026-05-01T10-00-00Z\n---\nbody\n'); - write('.impeccable/critique/2026-05-02T10-00-00Z__home.md', - '---\nslug: home\nscore: 8\np0: 0\np1: 1\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n'); - const s = await gatherSignals(scratch); - assert.equal(s.critique.latest.score, 8); // newest by timestamp prefix - assert.equal(s.critique.latest.p0, 0); - assert.equal(s.critique.latest.slug, 'home'); - }); - - it('reads the documented critique snapshot metadata keys', async () => { - write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md', - '---\nslug: pricing\ntotal_score: 24\np0_count: 2\np1_count: 5\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n'); - const s = await gatherSignals(scratch); - assert.equal(s.critique.latest.score, 24); - assert.equal(s.critique.latest.p0, 2); - assert.equal(s.critique.latest.p1, 5); - }); - - it('reports missing critique metrics as null', async () => { - write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md', - '---\nslug: pricing\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n'); - const s = await gatherSignals(scratch); - assert.equal(s.critique.latest.score, null); - assert.equal(s.critique.latest.p0, null); - assert.equal(s.critique.latest.p1, null); - }); - - it('reports empty and invalid critique metrics as null', async () => { - write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md', - '---\nslug: pricing\ntotal_score: \np0_count: \np1_count: nope\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n'); - const s = await gatherSignals(scratch); - assert.equal(s.critique.latest.score, null); - assert.equal(s.critique.latest.p0, null); - assert.equal(s.critique.latest.p1, null); - }); - - it('reads the newest critique snapshot across target slugs', async () => { - write('.impeccable/critique/2026-05-01T10-00-00Z__home.md', - '---\nslug: home\nscore: 6\np0: 1\np1: 3\ntimestamp: 2026-05-01T10-00-00Z\n---\nbody\n'); - write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md', - '---\nslug: pricing\nscore: 9\np0: 0\np1: 1\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n'); - write('.impeccable/critique/ignore.md', '# Critique ignores\n'); - write('.impeccable/critique/9999-not-a-snapshot.md', '# Draft\n'); - const s = await gatherSignals(scratch); - assert.equal(s.critique.latest.slug, 'pricing'); - assert.equal(s.critique.latest.score, 9); - assert.equal( - s.critique.latest.file, - '.impeccable/critique/2026-05-02T10-00-00Z__pricing.md', - ); - }); - - it('handles a non-git dir without throwing', async () => { - const s = await gatherSignals(scratch); - assert.equal(s.git.isRepo, false); - assert.deepEqual(s.git.changedFiles, []); - assert.equal(s.git.changedCount, 0); - }); - - it('reports working-tree changes with full, untruncated paths', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('site/styles/home.css', 'a{}\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - // Modify it so it shows as ` M ...` (leading-space porcelain line) — the - // exact shape that a naive global trim would truncate to "ite/...". - write('site/styles/home.css', 'a{color:red}\n'); - const s = await gatherSignals(scratch); - assert.equal(s.git.isRepo, true); - assert.ok( - s.git.changedFiles.includes('site/styles/home.css'), - `expected full path, got: ${JSON.stringify(s.git.changedFiles)}`, - ); - }); - - it('always includes a well-formed devServer probe', async () => { - const s = await gatherSignals(scratch); - assert.equal(typeof s.devServer.running, 'boolean'); - assert.ok(Array.isArray(s.devServer.ports)); - }); - - it('targets a local source dir (never a URL), even with a dev server up', async () => { - write('src/App.tsx', 'export default 1;'); - const s = await gatherSignals(scratch); - assert.equal(s.scan.via, 'source-dir'); - assert.deepEqual(s.scan.targets, ['src']); - // No target is ever an http(s) URL. - assert.ok(s.scan.targets.every((t) => !/^https?:/.test(t))); - }); - - it('prefers the dirty tree: scans changed markup/style files', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - write('README.md', 'x\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty - write('README.md', 'y\n'); // dirty but not scannable - const s = await gatherSignals(scratch); - assert.equal(s.scan.via, 'git-changes'); - assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // README.md filtered out - }); - - it('filters harness-dir files out of git-changes scan targets (#303)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty app code - write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // dirty vendored skill - const s = await gatherSignals(scratch); - assert.equal(s.scan.via, 'git-changes'); - assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // harness path filtered out - }); - - it('keeps hidden-source-dir files (VitePress/Storybook) in scan targets', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('.vitepress/theme/Layout.vue', '\n'); - write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - write('.vitepress/theme/Layout.vue', '\n'); // real UI source - write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // vendored - const s = await gatherSignals(scratch); - assert.equal(s.scan.via, 'git-changes'); - assert.deepEqual(s.scan.targets, ['.vitepress/theme/Layout.vue']); - }); - - it('falls through to source dirs when only harness files changed (#303)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - write('.cursor/skills/impeccable/example.css', 'a{}\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - write('.cursor/skills/impeccable/example.css', 'a{color:red}\n'); // only harness dirty - const s = await gatherSignals(scratch); - assert.equal(s.scan.via, 'source-dir'); - assert.deepEqual(s.scan.targets, ['src']); - }); - - it('diffs a feature branch against a develop integration branch (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'develop'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('checkout', '-q', '-b', 'feature/x'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - // The hardcoded main/master candidate list found no base here, so the - // committed feature work was invisible to the scan targets. - assert.equal(s.git.base, 'develop'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); - }); - - it('prefers the remote default branch (origin/HEAD) as the diff base (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'trunk'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - // Fabricate the remote's default-branch symref without a network remote. - git('update-ref', 'refs/remotes/origin/trunk', 'HEAD'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk'); - git('checkout', '-q', '-b', 'feature/y'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'trunk'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a branch tracking the integration branch diffs against its upstream (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'release'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - // A self-pointing remote gives git the fetch refspec it needs to map - // refs/heads/release -> refs/remotes/origin/release; no network involved. - git('remote', 'add', 'origin', '.'); - git('update-ref', 'refs/remotes/origin/release', 'HEAD'); - git('checkout', '-q', '-b', 'feature/z'); - git('branch', '-q', '--set-upstream-to=origin/release'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'release'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('sitting on the integration branch itself falls back to the working tree', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'develop'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - write('src/App.tsx', 'export default 2;\n'); // dirty, uncommitted - const s = await gatherSignals(scratch); - // No self-diff: base must be null and the dirty working tree is the scope. - assert.equal(s.git.base, null); - assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); - }); - - it('uses the remote-tracking ref when the base has no local branch (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'develop'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('update-ref', 'refs/remotes/origin/develop', 'HEAD'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop'); - git('checkout', '-q', '-b', 'feature/w'); - git('branch', '-q', '-D', 'develop'); // remote default exists, local doesn't - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'develop'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('honors an upstream on a non-origin remote (fork workflow) (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'release'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('remote', 'add', 'upstream', '.'); - git('update-ref', 'refs/remotes/upstream/release', 'HEAD'); - git('checkout', '-q', '-b', 'feature/v'); - git('branch', '-q', '--set-upstream-to=upstream/release'); - git('branch', '-q', '-D', 'release'); // the tracked base lives only on the fork parent - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'release'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('an existing develop outranks a main-pointing origin/HEAD (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'main'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('branch', '-q', 'develop'); - // Classic git-flow with the platform default never flipped off main. - git('update-ref', 'refs/remotes/origin/main', 'main'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); - git('checkout', '-q', 'develop'); - git('checkout', '-q', '-b', 'feature/g'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - // Features merge to develop here; picking origin/HEAD's main would drag - // the develop-vs-main divergence into scan targets. - assert.equal(s.git.base, 'develop'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('remote signals cannot bypass the integration-branch guard (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'main'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('branch', '-q', 'develop'); - git('checkout', '-q', 'develop'); - // The remote default is main; sitting on develop must still not produce - // a develop-vs-main integration diff via the origin/HEAD signal. - git('update-ref', 'refs/remotes/origin/main', 'main'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); - write('src/App.tsx', 'export default 2;\n'); // dirty on develop - const s = await gatherSignals(scratch); - assert.equal(s.git.base, null); - assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); - }); - - it('honors a local (slashless) upstream branch (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'canary'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('checkout', '-q', '-b', 'feature/u'); - git('branch', '-q', '--set-upstream-to=canary'); // local upstream, no remote - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - // canary is neither conventional nor remote, but the configured - // upstream names it as the merge target. - assert.equal(s.git.base, 'canary'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('sitting on a non-standard default branch keeps the working-tree scope (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'trunk'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('branch', '-q', 'develop'); // a conventional name also exists - git('update-ref', 'refs/remotes/origin/trunk', 'HEAD'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk'); - write('src/App.tsx', 'export default 2;\n'); // dirty on trunk - const s = await gatherSignals(scratch); - // trunk IS the integration branch (origin/HEAD says so); develop must - // not win the candidate scan and produce a trunk-vs-develop diff. - assert.equal(s.git.base, null); - assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); - }); - - it('a detached HEAD keeps the working-tree scope (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'main'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('branch', '-q', 'develop'); - git('checkout', '-q', '--detach'); - write('src/App.tsx', 'export default 2;\n'); // dirty on a detached tip - const s = await gatherSignals(scratch); - // A detached checkout has no branch identity to diff for; picking - // develop here would refill changedFiles with integration divergence. - assert.equal(s.git.base, null); - assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); - }); - - it('the integration guard sees non-origin remote defaults (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'trunk'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('branch', '-q', 'develop'); - // The only remote is upstream (fork-parent layout, no origin at all); - // its default branch is trunk, which is exactly where we're sitting. - git('remote', 'add', 'upstream', '.'); - git('update-ref', 'refs/remotes/upstream/trunk', 'HEAD'); - git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/trunk'); - write('src/App.tsx', 'export default 2;\n'); // dirty on trunk - const s = await gatherSignals(scratch); - assert.equal(s.git.base, null); - assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); - }); - - it('finds a develop that exists only on a non-origin remote (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'feature/f'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - // Fork-parent layout: develop lives only as upstream/develop, no local - // copy, no origin remote, and the feature branch has no upstream. - git('remote', 'add', 'upstream', '.'); - git('update-ref', 'refs/remotes/upstream/develop', 'HEAD'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'develop'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a same-name default on a second remote still resolves (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'feature/h'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('remote', 'add', 'origin', '.'); - git('remote', 'add', 'upstream', '.'); - // origin advertises main but its tracking ref is gone (pruned); the - // real main lives only as upstream/main. Name-level dedup must not - // discard the upstream rev. - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); - git('update-ref', 'refs/remotes/upstream/main', 'HEAD'); - git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'main'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a local upstream with a slash in its name is not misparsed (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'release/2.0'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('checkout', '-q', '-b', 'hotfix/x'); - git('branch', '-q', '--set-upstream-to=release/2.0'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'hotfix work'); - const s = await gatherSignals(scratch); - // "release" is not a remote here; the whole ref is the local base name. - assert.equal(s.git.base, 'release/2.0'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a local upstream sharing the branch leaf name is not self-skipped (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'feature/foo'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('checkout', '-q', '-b', 'foo'); - git('branch', '-q', '--set-upstream-to=feature/foo'); - // Adversarial twist: a remote literally named "feature" exists, so any - // prefix-based guess would still misread the LOCAL feature/foo upstream - // as remote-tracking. Only the full symbolic ref disambiguates. - git('remote', 'add', 'feature', '.'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'work'); - const s = await gatherSignals(scratch); - // Truncating feature/foo to "foo" made it look like the current branch - // and the valid upstream was discarded. - assert.equal(s.git.base, 'feature/foo'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a pruned upstream tracking ref falls back to other remotes (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'feature/p'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('remote', 'add', 'origin', '.'); - git('remote', 'add', 'upstream', '.'); - // The branch tracks origin/main, but that tracking ref was pruned; the - // live main exists only on the upstream remote. - git('config', 'branch.feature/p.remote', 'origin'); - git('config', 'branch.feature/p.merge', 'refs/heads/main'); - git('update-ref', 'refs/remotes/upstream/main', 'HEAD'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'main'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a remote-advertised default outranks a stale local checkout (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'main'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/base.css', 'a{}\n'); - git('add', '.'); - git('commit', '-qm', 'A'); - write('src/extra.css', 'b{}\n'); - git('add', '.'); - git('commit', '-qm', 'A2'); - git('update-ref', 'refs/remotes/origin/main', 'HEAD'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); - git('checkout', '-q', '-b', 'feature/s'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - // The local main checkout is stale (still at A); the remote default is - // at A2. Diffing against the stale local would drag src/extra.css in. - git('branch', '-f', 'main', 'HEAD~2'); - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'main'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('a develop-pointing remote default outranks a stale local develop (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'develop'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/base.css', 'a{}\n'); - git('add', '.'); - git('commit', '-qm', 'A'); - write('src/extra.css', 'b{}\n'); - git('add', '.'); - git('commit', '-qm', 'A2'); - git('update-ref', 'refs/remotes/origin/develop', 'HEAD'); - git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop'); - git('checkout', '-q', '-b', 'feature/t'); - write('src/Hero.tsx', 'export const Hero = () => null;\n'); - git('add', '.'); - git('commit', '-qm', 'feature work'); - git('branch', '-f', 'develop', 'HEAD~2'); // local develop is stale at A - const s = await gatherSignals(scratch); - assert.equal(s.git.base, 'develop'); - assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); - }); - - it('never diffs one integration branch against another (#302)', async () => { - const { execFileSync } = await import('node:child_process'); - const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); - git('init', '-q', '-b', 'main'); - git('config', 'user.email', 't@example.com'); - git('config', 'user.name', 'Test'); - write('src/App.tsx', 'export default 1;\n'); - git('add', '.'); - git('commit', '-qm', 'init'); - git('branch', '-q', 'develop'); // both integration branches exist - write('src/App.tsx', 'export default 2;\n'); // dirty on main - const s = await gatherSignals(scratch); - // Sitting on main must not pick develop as a base; the dirty working - // tree is the scope, exactly as before this change. - assert.equal(s.git.base, null); - assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); - }); - - it('has empty scan.targets only when there is no code at all', async () => { - const s = await gatherSignals(scratch); - assert.deepEqual(s.scan.targets, []); - assert.equal(s.scan.via, null); - }); -}); - -describe('context-signals CLI', () => { - it('emits valid JSON with all top-level signal groups', async () => { - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8' }); - assert.equal(res.status, 0); - const parsed = JSON.parse(res.stdout); - for (const k of ['setup', 'critique', 'git', 'devServer']) { - assert.ok(k in parsed, `expected "${k}" in signals output`); - } - }); -}); diff --git a/tests/context.test.mjs b/tests/context.test.mjs deleted file mode 100644 index 76db0648d..000000000 --- a/tests/context.test.mjs +++ /dev/null @@ -1,1647 +0,0 @@ -/** - * Tests for the shared context loader (PRODUCT.md / DESIGN.md resolver). - * Run with: node --test tests/load-context.test.mjs - * - * Covers the resolution order: - * 1. cwd, when canonical files are at the root - * 2. Auto-fallback to .agents/context/ then docs/ - * 3. IMPECCABLE_CONTEXT_DIR env var as a power-user escape hatch (only - * consulted when the default paths come up empty) - * 4. Default to cwd when nothing is found - * - * Each test runs in its own scratch dir under os.tmpdir() so the suite stays - * independent of the project root and parallel-safe. - */ - -import { describe, it, beforeEach, afterEach } from 'node:test'; -import { spawnSync, spawn } from 'node:child_process'; -import http from 'node:http'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -import { loadContext, resolveContextDir, resolveProjectRoot, extractPlatform, hasVisualImplementation } from '../skill/scripts/context.mjs'; - -import { fileURLToPath } from 'node:url'; -const SCRIPT_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts', 'context.mjs'); - -let scratch; -let savedEnv; - -beforeEach(() => { - scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-loadctx-')); - savedEnv = process.env.IMPECCABLE_CONTEXT_DIR; - delete process.env.IMPECCABLE_CONTEXT_DIR; -}); - -afterEach(() => { - if (savedEnv === undefined) delete process.env.IMPECCABLE_CONTEXT_DIR; - else process.env.IMPECCABLE_CONTEXT_DIR = savedEnv; - fs.rmSync(scratch, { recursive: true, force: true }); -}); - -function write(rel, body = '# placeholder\n') { - const abs = path.join(scratch, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, body); - return abs; -} - -// Stage a runnable copy of context.mjs plus its whole lib/ directory. -// The bundle tests used to enumerate the helpers they needed, which turned -// every new import in context.mjs into a mysterious non-zero exit here. -// Copying the directory keeps them honest about what the real script loads. -function stageContextBundle(scriptsDir, { providerId } = {}) { - const libSrc = path.join(path.dirname(SCRIPT_PATH), 'lib'); - const libDest = path.join(scriptsDir, 'lib'); - fs.mkdirSync(scriptsDir, { recursive: true }); - fs.copyFileSync(SCRIPT_PATH, path.join(scriptsDir, 'context.mjs')); - fs.cpSync(libSrc, libDest, { recursive: true }); - if (providerId) { - const providerPath = path.join(libDest, 'provider.mjs'); - fs.writeFileSync( - providerPath, - fs.readFileSync(providerPath, 'utf8') - .replace("IMPECCABLE_PROVIDER_ID = 'source'", `IMPECCABLE_PROVIDER_ID = '${providerId}'`), - ); - } - return path.join(scriptsDir, 'context.mjs'); -} - -function parseTargetSelection(stdout) { - const tail = stdout.split('TARGET_SELECTION_REQUIRED:\n')[1]; - assert.ok(tail, `missing TARGET_SELECTION_REQUIRED block in:\n${stdout}`); - return JSON.parse(tail.split('\n\n')[0].trim()); -} - -describe('resolveContextDir', () => { - it('returns cwd when PRODUCT.md is at the root', () => { - write('PRODUCT.md'); - assert.equal(resolveContextDir(scratch), scratch); - }); - - it('returns cwd when DESIGN.md is at the root', () => { - write('DESIGN.md'); - assert.equal(resolveContextDir(scratch), scratch); - }); - - it('falls back to .agents/context/ when root is clean', () => { - write('.agents/context/PRODUCT.md'); - assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context')); - }); - - it('falls back to docs/ when root is clean and .agents/context/ is empty', () => { - write('docs/PRODUCT.md'); - assert.equal(resolveContextDir(scratch), path.join(scratch, 'docs')); - }); - - it('prefers .agents/context/ over docs/ when both exist', () => { - write('.agents/context/PRODUCT.md'); - write('docs/PRODUCT.md'); - assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context')); - }); - - it('prefers cwd over fallback dirs when canonical files are at the root', () => { - write('PRODUCT.md'); - write('.agents/context/PRODUCT.md'); - assert.equal(resolveContextDir(scratch), scratch); - }); - - it('uses IMPECCABLE_CONTEXT_DIR as a fallback when defaults are empty (relative path)', () => { - write('design/PRODUCT.md'); - process.env.IMPECCABLE_CONTEXT_DIR = 'design'; - assert.equal(resolveContextDir(scratch), path.join(scratch, 'design')); - }); - - it('uses IMPECCABLE_CONTEXT_DIR as a fallback when defaults are empty (absolute path)', () => { - const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-elsewhere-')); - try { - process.env.IMPECCABLE_CONTEXT_DIR = elsewhere; - assert.equal(resolveContextDir(scratch), elsewhere); - } finally { - fs.rmSync(elsewhere, { recursive: true, force: true }); - } - }); - - it('default paths win over IMPECCABLE_CONTEXT_DIR (lazy escape hatch)', () => { - write('PRODUCT.md', 'root'); - write('design/PRODUCT.md', 'overridden'); - process.env.IMPECCABLE_CONTEXT_DIR = 'design'; - assert.equal(resolveContextDir(scratch), scratch); - }); - - it('ignores empty IMPECCABLE_CONTEXT_DIR', () => { - write('PRODUCT.md'); - process.env.IMPECCABLE_CONTEXT_DIR = ' '; - assert.equal(resolveContextDir(scratch), scratch); - }); - - it('returns cwd when nothing is found anywhere', () => { - assert.equal(resolveContextDir(scratch), scratch); - }); -}); - -describe('loadContext', () => { - it('reads PRODUCT.md and DESIGN.md from the root', () => { - write('PRODUCT.md', '# product content\n'); - write('DESIGN.md', '# design content\n'); - const ctx = loadContext(scratch); - assert.equal(ctx.hasProduct, true); - assert.equal(ctx.hasDesign, true); - assert.match(ctx.product, /product content/); - assert.match(ctx.design, /design content/); - assert.equal(ctx.productPath, 'PRODUCT.md'); - assert.equal(ctx.designPath, 'DESIGN.md'); - assert.equal(ctx.contextDir, scratch); - }); - - it('reads from .agents/context/ when the root is clean', () => { - write('.agents/context/PRODUCT.md', '# product in agents\n'); - write('.agents/context/DESIGN.md', '# design in agents\n'); - const ctx = loadContext(scratch); - assert.equal(ctx.hasProduct, true); - assert.equal(ctx.hasDesign, true); - assert.match(ctx.product, /product in agents/); - assert.equal(ctx.contextDir, path.join(scratch, '.agents', 'context')); - // productPath/designPath are relative to cwd, not contextDir - assert.equal(ctx.productPath, path.join('.agents', 'context', 'PRODUCT.md')); - assert.equal(ctx.designPath, path.join('.agents', 'context', 'DESIGN.md')); - }); - - it('reads from docs/ when .agents/context/ is empty', () => { - write('docs/PRODUCT.md', '# product in docs\n'); - const ctx = loadContext(scratch); - assert.equal(ctx.hasProduct, true); - assert.equal(ctx.contextDir, path.join(scratch, 'docs')); - assert.equal(ctx.productPath, path.join('docs', 'PRODUCT.md')); - }); -}); - -describe('loadContext (monorepo project context)', () => { - function writeMonorepo() { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*', 'packages/*'], - }, null, 2)); - write('turbo.json', JSON.stringify({ tasks: {} })); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - for (const app of ['marketing', 'dashboard', 'admin']) { - write(`apps/${app}/src/App.jsx`, `export default function App() { return ${JSON.stringify(app)}; }\n`); - } - } - - it('inherits root PRODUCT.md and DESIGN.md for child apps without project context', () => { - writeMonorepo(); - - for (const app of ['marketing', 'dashboard', 'admin']) { - const ctx = loadContext(scratch, { targetPath: `apps/${app}/src/App.jsx` }); - assert.equal(ctx.hasProduct, true); - assert.equal(ctx.hasDesign, true); - assert.match(ctx.product, /Root product/); - assert.match(ctx.design, /Root design/); - assert.equal(ctx.productPath, 'PRODUCT.md'); - assert.equal(ctx.designPath, 'DESIGN.md'); - assert.equal(ctx.projectRoot, path.join(scratch, 'apps', app)); - assert.equal(ctx.repoRoot, scratch); - assert.equal(ctx.isMonorepo, true); - } - }); - - it('lets child app context override root files independently', () => { - writeMonorepo(); - write('apps/marketing/PRODUCT.md', '# Marketing product\n'); - write('apps/marketing/DESIGN.md', '# Marketing design\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - - const marketing = loadContext(scratch, { targetPath: 'apps/marketing/src/App.jsx' }); - assert.match(marketing.product, /Marketing product/); - assert.match(marketing.design, /Marketing design/); - assert.equal(marketing.productPath, path.join('apps', 'marketing', 'PRODUCT.md')); - assert.equal(marketing.designPath, path.join('apps', 'marketing', 'DESIGN.md')); - - const dashboard = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' }); - assert.match(dashboard.product, /Dashboard product/); - assert.match(dashboard.design, /Root design/); - assert.equal(dashboard.productPath, path.join('apps', 'dashboard', 'PRODUCT.md')); - assert.equal(dashboard.designPath, 'DESIGN.md'); - - const admin = loadContext(scratch, { targetPath: 'apps/admin/src/App.jsx' }); - assert.match(admin.product, /Root product/); - assert.match(admin.design, /Root design/); - assert.equal(admin.productPath, 'PRODUCT.md'); - assert.equal(admin.designPath, 'DESIGN.md'); - }); - - it('resolves child project roots from cwd inside a workspace', () => { - writeMonorepo(); - const appDir = path.join(scratch, 'apps', 'dashboard'); - const ctx = loadContext(appDir); - assert.match(ctx.product, /Root product/); - assert.match(ctx.design, /Root design/); - assert.equal(ctx.productPath, path.join('..', '..', 'PRODUCT.md')); - assert.equal(ctx.designPath, path.join('..', '..', 'DESIGN.md')); - assert.equal(resolveProjectRoot(appDir), appDir); - }); - - it('supports pnpm workspace patterns when resolving the active project', () => { - write('pnpm-workspace.yaml', 'packages:\n - "services/*"\n'); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('services/checkout/src/App.jsx'); - - const ctx = loadContext(scratch, { targetPath: 'services/checkout/src/App.jsx' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'services', 'checkout')); - assert.match(ctx.product, /Root product/); - assert.match(ctx.design, /Root design/); - }); - - it('supports pnpm workspace patterns with inline comments and flow arrays', () => { - write('pnpm-workspace.yaml', 'packages: ["services/*", "tools/*"] # workspace packages\n'); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('tools/inspector/PRODUCT.md', '# Inspector product\n'); - write('tools/inspector/src/App.jsx'); - - const ctx = loadContext(scratch, { targetPath: 'tools/inspector/src/App.jsx' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'tools', 'inspector')); - assert.match(ctx.product, /Inspector product/); - assert.match(ctx.design, /Root design/); - }); - - it('honors negated pnpm workspace patterns', () => { - write('pnpm-workspace.yaml', 'packages:\n - "packages/**"\n - "!packages/private/**"\n'); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('packages/private/app/src/index.ts', 'export const hidden = true;\n'); - - const ctx = loadContext(scratch, { targetPath: 'packages/private/app/src/index.ts' }); - assert.equal(ctx.projectRoot, scratch); - assert.match(ctx.product, /Root product/); - assert.match(ctx.design, /Root design/); - }); - - it('keeps unmatched child projects from being hijacked by an ancestor workspace', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*'], - }, null, 2)); - write('PRODUCT.md', '# Ancestor product\n'); - write('side-project/PRODUCT.md', '# Side project product\n'); - write('side-project/src/App.jsx', 'export default null;\n'); - - const ctx = loadContext(path.join(scratch, 'side-project'), { targetPath: 'src/App.jsx' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'side-project')); - assert.match(ctx.product, /Side project product/); - assert.equal(ctx.productPath, 'PRODUCT.md'); - }); - - it('does not reuse stale project resolution after workspace markers change', () => { - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/package.json', JSON.stringify({ name: 'dashboard' }, null, 2)); - write('apps/dashboard/src/App.jsx', 'export default null;\n'); - - const before = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' }); - assert.equal(before.projectRoot, scratch); - assert.equal(before.isMonorepo, false); - assert.match(before.product, /Root product/); - - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*'], - }, null, 2)); - - const after = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' }); - assert.equal(after.projectRoot, path.join(scratch, 'apps', 'dashboard')); - assert.equal(after.isMonorepo, true); - assert.match(after.product, /Root product/); - }); - - it('resolves an explicit target onto a nested product context in a non-monorepo repo', () => { - write('nested/product/PRODUCT.md', '# Nested product\n'); - write('nested/product/DESIGN.md', '# Nested design\n'); - write('nested/product/file.ts', 'export const x = 1;\n'); - - const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); - assert.equal(ctx.isMonorepo, false); - assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); - assert.equal(ctx.repoRoot, scratch); - assert.match(ctx.product, /Nested product/); - assert.match(ctx.design, /Nested design/); - assert.equal(ctx.productPath, path.join('nested', 'product', 'PRODUCT.md')); - assert.equal(ctx.designPath, path.join('nested', 'product', 'DESIGN.md')); - }); - - it('resolves a nested target whose context lives in .agents/context/', () => { - write('nested/product/.agents/context/PRODUCT.md', '# Nested product\n'); - write('nested/product/file.ts', 'export const x = 1;\n'); - - const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); - assert.equal(ctx.isMonorepo, false); - assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); - assert.match(ctx.product, /Nested product/); - assert.equal(ctx.productPath, path.join('nested', 'product', '.agents', 'context', 'PRODUCT.md')); - }); - - it('resolves a nested target whose context lives in docs/', () => { - write('nested/product/docs/DESIGN.md', '# Nested design\n'); - write('nested/product/file.ts', 'export const x = 1;\n'); - - const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); - assert.equal(ctx.isMonorepo, false); - assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); - assert.match(ctx.design, /Nested design/); - assert.equal(ctx.designPath, path.join('nested', 'product', 'docs', 'DESIGN.md')); - }); - - it('does not treat the root fallback context dirs as a nested product', () => { - write('.agents/context/PRODUCT.md', '# Root product\n'); - write('.agents/context/notes/file.md', '# Notes\n'); - - const ctx = loadContext(scratch, { targetPath: '.agents/context/notes/file.md' }); - assert.equal(ctx.projectRoot, scratch); - assert.match(ctx.product, /Root product/); - }); - - it('inherits missing root context per-file for a nested target in a non-monorepo repo', () => { - write('DESIGN.md', '# Root design\n'); - write('nested/product/PRODUCT.md', '# Nested product\n'); - write('nested/product/file.ts', 'export const x = 1;\n'); - - const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); - assert.match(ctx.product, /Nested product/); - assert.match(ctx.design, /Root design/); - assert.equal(ctx.designPath, 'DESIGN.md'); - }); - - it('keeps the repo root project for targets without nearby context files', () => { - write('PRODUCT.md', '# Root product\n'); - write('src/App.jsx', 'export default null;\n'); - - const ctx = loadContext(scratch, { targetPath: 'src/App.jsx' }); - assert.equal(ctx.isMonorepo, false); - assert.equal(ctx.projectRoot, scratch); - assert.match(ctx.product, /Root product/); - assert.equal(ctx.productPath, 'PRODUCT.md'); - }); - - it('does not escape a nested git repo to an ancestor workspace', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['repos/*'], - }, null, 2)); - write('PRODUCT.md', '# Outer product\n'); - write('DESIGN.md', '# Outer design\n'); - write('repos/standalone/.git/HEAD', 'ref: refs/heads/main\n'); - write('repos/standalone/PRODUCT.md', '# Standalone product\n'); - write('repos/standalone/src/App.jsx', 'export default null;\n'); - - const project = path.join(scratch, 'repos', 'standalone'); - const ctx = loadContext(project, { targetPath: 'src/App.jsx' }); - assert.equal(ctx.isMonorepo, false); - assert.equal(ctx.projectRoot, project); - assert.equal(ctx.repoRoot, project); - assert.match(ctx.product, /Standalone product/); - assert.equal(ctx.productPath, 'PRODUCT.md'); - assert.equal(ctx.designPath, null); - }); - - it('resolves an explicit root target into a nested-git workspace child', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['repos/*'], - }, null, 2)); - write('PRODUCT.md', '# Outer product\n'); - write('DESIGN.md', '# Outer design\n'); - write('repos/standalone/.git/HEAD', 'ref: refs/heads/main\n'); - write('repos/standalone/PRODUCT.md', '# Standalone product\n'); - write('repos/standalone/src/App.jsx', 'export default null;\n'); - - const project = path.join(scratch, 'repos', 'standalone'); - const ctx = loadContext(scratch, { targetPath: 'repos/standalone/src/App.jsx' }); - assert.equal(ctx.isMonorepo, true); - assert.equal(ctx.projectRoot, project); - assert.equal(ctx.repoRoot, scratch); - assert.match(ctx.product, /Standalone product/); - assert.match(ctx.design, /Outer design/); - assert.equal(ctx.productPath, path.join('repos', 'standalone', 'PRODUCT.md')); - assert.equal(ctx.designPath, 'DESIGN.md'); - }); - - it('supports double-star workspace patterns by resolving the shallow child project', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['libs/**'], - }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('libs/ui/PRODUCT.md', '# UI product\n'); - write('libs/ui/src/index.ts', 'export const ui = true;\n'); - - const ctx = loadContext(scratch, { targetPath: 'libs/ui/src/index.ts' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'libs', 'ui')); - assert.match(ctx.product, /UI product/); - assert.match(ctx.design, /Root design/); - assert.equal(ctx.productPath, path.join('libs', 'ui', 'PRODUCT.md')); - assert.equal(ctx.designPath, 'DESIGN.md'); - }); - - it('supports packages/**/* workspace patterns without promoting src folders to projects', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['packages/**/*'], - }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('packages/dashboard/PRODUCT.md', '# Dashboard package product\n'); - write('packages/dashboard/src/index.ts', 'export const dashboard = true;\n'); - - const ctx = loadContext(scratch, { targetPath: 'packages/dashboard/src/index.ts' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'packages', 'dashboard')); - assert.match(ctx.product, /Dashboard package product/); - assert.match(ctx.design, /Root design/); - assert.equal(ctx.productPath, path.join('packages', 'dashboard', 'PRODUCT.md')); - assert.equal(ctx.designPath, 'DESIGN.md'); - }); - - it('supports packages/** workspace patterns for nested package roots', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['packages/**'], - }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('packages/group/app/package.json', JSON.stringify({ name: '@acme/app' }, null, 2)); - write('packages/group/app/PRODUCT.md', '# Group app product\n'); - write('packages/group/app/src/index.ts', 'export const app = true;\n'); - - const ctx = loadContext(scratch, { targetPath: 'packages/group/app/src/index.ts' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'packages', 'group', 'app')); - assert.match(ctx.product, /Group app product/); - assert.match(ctx.design, /Root design/); - }); - - it('does not discover dependency or generated directories as workspace candidates', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['packages/**'], - }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('packages/ui/package.json', JSON.stringify({ name: '@acme/ui' }, null, 2)); - write('packages/ui/src/index.ts', 'export const ui = true;\n'); - write('packages/ui/node_modules/dep/package.json', JSON.stringify({ name: 'dep' }, null, 2)); - write('packages/ui/node_modules/dep/src/index.ts', 'export const dep = true;\n'); - write('packages/ui/dist/package.json', JSON.stringify({ name: '@acme/ui-dist' }, null, 2)); - write('packages/ui/dist/src/index.ts', 'export const dist = true;\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - const selection = parseTargetSelection(res.stdout); - - assert.deepEqual(selection.targetCandidates.map((candidate) => candidate.path), ['packages/ui']); - }); - - it('uses apps and packages folders as a fallback when a monorepo marker exists', () => { - write('nx.json', '{}\n'); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('packages/ui/src/index.ts'); - - const ctx = loadContext(scratch, { targetPath: 'packages/ui/src/index.ts' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'packages', 'ui')); - assert.match(ctx.product, /Root product/); - assert.match(ctx.design, /Root design/); - }); - - it('does not treat turbo.json alone as a monorepo marker', () => { - write('turbo.json', JSON.stringify({ tasks: {} })); - write('PRODUCT.md', '# Root product\n'); - write('src/App.jsx', 'export default null;\n'); - - const ctx = loadContext(scratch); - assert.equal(ctx.isMonorepo, false); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.doesNotMatch(res.stdout, /MONOREPO_TARGET_REQUIRED/); - }); - - it('supports --target in the CLI', async () => { - writeMonorepo(); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n\n## Platform\n\nweb\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/src/App.jsx'], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /# Dashboard product/); - assert.match(res.stdout, /# DESIGN\.md\n\n# Root design/); - assert.match(res.stdout, /RESOLVED_CONTEXT:/); - assert.match(res.stdout, /"targetPath": "apps\/dashboard\/src\/App\.jsx"/); - assert.match(res.stdout, /"productPath": "apps\/dashboard\/PRODUCT\.md"/); - assert.match(res.stdout, /"designPath": "DESIGN\.md"/); - assert.doesNotMatch(res.stdout, /REGISTER:/); - }); - - it('asks for an app when the CLI runs from a monorepo root without selection', () => { - writeMonorepo(); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/); - assert.match(res.stdout, /"targetPath": null/); - assert.match(res.stdout, /"path": "apps\/dashboard"/); - assert.match(res.stdout, /"targetExample": "apps\/dashboard\/src\/App\.jsx"/); - assert.match(res.stdout, /"productStatus": "inherited"/); - assert.match(res.stdout, /"productPath": "PRODUCT\.md"/); - assert.match(res.stdout, /"designStatus": "inherited"/); - assert.match(res.stdout, /"designPath": "DESIGN\.md"/); - assert.doesNotMatch(res.stdout, /# PRODUCT\.md/); - assert.doesNotMatch(res.stdout, /# DESIGN\.md/); - assert.doesNotMatch(res.stdout, /MONOREPO_TARGET_REQUIRED/); - }); - - it('describes child, inherited, and mixed context sources in app selection candidates', () => { - writeMonorepo(); - write('apps/admin/PRODUCT.md', '# Admin product\n'); - write('apps/admin/DESIGN.md', '# Admin design\n'); - write('apps/marketing/PRODUCT.md', '# Marketing product\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - const selection = parseTargetSelection(res.stdout); - const byPath = Object.fromEntries(selection.targetCandidates.map((candidate) => [candidate.path, candidate])); - - assert.deepEqual( - { - productStatus: byPath['apps/admin'].productStatus, - productPath: byPath['apps/admin'].productPath, - designStatus: byPath['apps/admin'].designStatus, - designPath: byPath['apps/admin'].designPath, - }, - { - productStatus: 'child', - productPath: 'apps/admin/PRODUCT.md', - designStatus: 'child', - designPath: 'apps/admin/DESIGN.md', - }, - ); - assert.deepEqual( - { - productStatus: byPath['apps/dashboard'].productStatus, - productPath: byPath['apps/dashboard'].productPath, - designStatus: byPath['apps/dashboard'].designStatus, - designPath: byPath['apps/dashboard'].designPath, - }, - { - productStatus: 'inherited', - productPath: 'PRODUCT.md', - designStatus: 'inherited', - designPath: 'DESIGN.md', - }, - ); - assert.deepEqual( - { - productStatus: byPath['apps/marketing'].productStatus, - productPath: byPath['apps/marketing'].productPath, - designStatus: byPath['apps/marketing'].designStatus, - designPath: byPath['apps/marketing'].designPath, - }, - { - productStatus: 'child', - productPath: 'apps/marketing/PRODUCT.md', - designStatus: 'inherited', - designPath: 'DESIGN.md', - }, - ); - }); - - it('marks missing context files in app selection candidates', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*'], - }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/src/App.jsx', 'export default null;\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - const selection = parseTargetSelection(res.stdout); - const dashboard = selection.targetCandidates.find((candidate) => candidate.path === 'apps/dashboard'); - assert.equal(dashboard.productStatus, 'inherited'); - assert.equal(dashboard.productPath, 'PRODUCT.md'); - assert.equal(dashboard.designStatus, 'missing'); - assert.equal(dashboard.designPath, null); - }); - - it('asks for app selection before init when root context is missing but child context exists', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*'], - }, null, 2)); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - write('apps/dashboard/src/App.jsx', 'export default null;\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/); - assert.match(res.stdout, /"path": "apps\/dashboard"/); - assert.doesNotMatch(res.stdout, /^NO_PRODUCT_MD:/); - }); - - it('excludes negated workspace packages from the selection candidates', () => { - write('pnpm-workspace.yaml', 'packages:\n - "packages/*"\n - "!packages/internal"\n'); - write('PRODUCT.md', '# Root product\n'); - write('packages/web/src/App.jsx', 'export default null;\n'); - write('packages/internal/src/App.jsx', 'export default null;\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - const selection = parseTargetSelection(res.stdout); - const paths = selection.targetCandidates.map((candidate) => candidate.path); - assert.ok(paths.includes('packages/web'), `expected packages/web in ${JSON.stringify(paths)}`); - assert.ok(!paths.includes('packages/internal'), `packages/internal should be excluded: ${JSON.stringify(paths)}`); - }); - - it('does not block on target selection when the monorepo has no child apps', () => { - write('package.json', JSON.stringify({ private: true, workspaces: ['.'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.doesNotMatch(res.stdout, /TARGET_SELECTION_REQUIRED/); - assert.match(res.stdout, /# Root product/); - }); - - it('lets --target . explicitly select the monorepo root', () => { - writeMonorepo(); - const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', '.'], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /# PRODUCT\.md\n\n# Root product/); - assert.match(res.stdout, /# DESIGN\.md\n\n# Root design/); - assert.match(res.stdout, /"targetPath": "\."/); - assert.doesNotMatch(res.stdout, /TARGET_SELECTION_REQUIRED/); - }); - - it('does not parse --help as a --target value', () => { - writeMonorepo(); - const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', '--help'], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 1); - assert.match(res.stderr, /--target requires a path value/); - assert.equal(res.stdout, ''); - }); - - it('uses the last --target value when duplicate target flags are provided', () => { - writeMonorepo(); - write('apps/marketing/PRODUCT.md', '# Marketing product\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - - const res = spawnSync(process.execPath, [ - SCRIPT_PATH, - '--target', 'apps/marketing/src/App.jsx', - '--target', 'apps/dashboard/src/App.jsx', - ], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /# Dashboard product/); - assert.match(res.stdout, /"targetPath": "apps\/dashboard\/src\/App\.jsx"/); - assert.doesNotMatch(res.stdout, /# Marketing product/); - }); - - it('warns when --target names a missing path in a monorepo', () => { - writeMonorepo(); - const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/routes/pricing'], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /RESOLVED_CONTEXT:/); - assert.match(res.stdout, /"targetExists": false/); - assert.match(res.stdout, /MONOREPO_TARGET_REQUIRED/); - }); - - it('asks for app selection even when root PRODUCT.md is absent', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*'], - }, null, 2)); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - write('apps/dashboard/src/App.jsx', 'export default null;\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/); - assert.match(res.stdout, /"path": "apps\/dashboard"/); - assert.doesNotMatch(res.stdout, /^NO_PRODUCT_MD:/); - }); -}); - -describe('loadContext (impeccable projectRoots config)', () => { - function writeSkinsConfig(extra = {}) { - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'], ...extra }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - } - - it('treats a config-declared context root as a monorepo with no package-manager files', () => { - writeSkinsConfig(); - write('docs/design/skins/neon-seoul/DESIGN.md', '# Neon Seoul design\n'); - - const ctx = loadContext(scratch, { targetPath: 'docs/design/skins/neon-seoul' }); - assert.equal(ctx.isMonorepo, true); - assert.equal(ctx.projectRoot, path.join(scratch, 'docs', 'design', 'skins', 'neon-seoul')); - assert.equal(ctx.repoRoot, scratch); - // The skin uses its own DESIGN.md and inherits the root PRODUCT.md per file. - assert.match(ctx.design, /Neon Seoul design/); - assert.match(ctx.product, /Root product/); - assert.equal(ctx.designPath, path.join('docs', 'design', 'skins', 'neon-seoul', 'DESIGN.md')); - assert.equal(ctx.productPath, 'PRODUCT.md'); - }); - - it('resolves a config-declared child from cwd inside the folder', () => { - writeSkinsConfig(); - write('docs/design/skins/marble/DESIGN.md', '# Marble design\n'); - - const skinDir = path.join(scratch, 'docs', 'design', 'skins', 'marble'); - const ctx = loadContext(skinDir); - assert.equal(ctx.isMonorepo, true); - assert.equal(ctx.projectRoot, skinDir); - assert.match(ctx.design, /Marble design/); - assert.match(ctx.product, /Root product/); - }); - - it('extends shared projectRoots with config.local.json', () => { - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2)); - write('.impeccable/config.local.json', JSON.stringify({ projectRoots: ['experiments/*'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('experiments/wip/DESIGN.md', '# WIP design\n'); - - const ctx = loadContext(scratch, { targetPath: 'experiments/wip' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'experiments', 'wip')); - assert.match(ctx.design, /WIP design/); - assert.match(ctx.product, /Root product/); - }); - - it('does not treat an .impeccable config without projectRoots as a monorepo', () => { - write('.impeccable/config.json', JSON.stringify({ hook: { consent: 'accepted' } }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('docs/design/skins/marble/DESIGN.md', '# Marble design\n'); - - const ctx = loadContext(scratch, { targetPath: 'docs/design/skins/marble' }); - assert.equal(ctx.isMonorepo, false); - }); - - it('asks for app selection from a config-declared monorepo root', () => { - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('DESIGN.md', '# Root design\n'); - write('docs/design/skins/neon-seoul/DESIGN.md', '# Neon Seoul\n'); - write('docs/design/skins/marble/DESIGN.md', '# Marble\n'); - - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - const selection = parseTargetSelection(res.stdout); - const paths = selection.targetCandidates.map((candidate) => candidate.path).sort(); - assert.deepEqual(paths, ['docs/design/skins/marble', 'docs/design/skins/neon-seoul']); - }); - - // Composition with package-manager workspaces: a path matched by any - // projectRoots pattern (positive or negated) is governed by the impeccable - // config alone; package-manager patterns fill in the paths it does not match. - describe('composition with package-manager workspaces', () => { - function selectionPaths() { - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0, res.stderr); - const selection = parseTargetSelection(res.stdout); - return selection.targetCandidates.map((candidate) => candidate.path).sort(); - } - - it('lets an impeccable negation exclude a package-manager workspace', () => { - write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2)); - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['!apps/internal'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - write('apps/internal/PRODUCT.md', '# Internal product\n'); - - const ctx = loadContext(scratch, { targetPath: 'apps/internal' }); - assert.equal(ctx.isMonorepo, true); - assert.equal(ctx.projectRoot, scratch); - assert.match(ctx.product, /Root product/); - assert.deepEqual(selectionPaths(), ['apps/dashboard']); - }); - - it('keeps a package-manager negation scoped to its own source', () => { - write('package.json', JSON.stringify({ - private: true, - workspaces: ['apps/*', '!docs/design/skins/marble'], - }, null, 2)); - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - write('docs/design/skins/marble/DESIGN.md', '# Marble design\n'); - - const ctx = loadContext(scratch, { targetPath: 'docs/design/skins/marble' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'docs', 'design', 'skins', 'marble')); - assert.match(ctx.design, /Marble design/); - assert.deepEqual(selectionPaths(), ['apps/dashboard', 'docs/design/skins/marble']); - }); - - it('gives a broad impeccable pattern the boundary over a deeper package workspace', () => { - write('package.json', JSON.stringify({ private: true, workspaces: ['apps/web/packages/ui'] }, null, 2)); - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['apps/*'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('apps/web/PRODUCT.md', '# Web product\n'); - write('apps/web/packages/ui/PRODUCT.md', '# UI product\n'); - write('apps/web/packages/ui/src/Button.jsx', 'export default null;\n'); - - const ctx = loadContext(scratch, { targetPath: 'apps/web/packages/ui/src/Button.jsx' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'web')); - assert.match(ctx.product, /Web product/); - // The subsumed package workspace must not appear as its own pick: - // choosing it would silently resolve to apps/web. - assert.deepEqual(selectionPaths(), ['apps/web']); - }); - - it('falls through to package workspaces for paths impeccable does not match', () => { - write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2)); - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - write('docs/design/skins/marble/DESIGN.md', '# Marble design\n'); - - const ctx = loadContext(scratch, { targetPath: 'apps/dashboard' }); - assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'dashboard')); - assert.match(ctx.product, /Dashboard product/); - }); - - it('resolves other workspaces normally when impeccable config only negates', () => { - write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2)); - write('.impeccable/config.json', JSON.stringify({ projectRoots: ['!apps/internal'] }, null, 2)); - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); - write('apps/internal/PRODUCT.md', '# Internal product\n'); - - const ctx = loadContext(scratch, { targetPath: 'apps/dashboard' }); - assert.equal(ctx.isMonorepo, true); - assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'dashboard')); - assert.match(ctx.product, /Dashboard product/); - }); - }); -}); - -describe('loadContext (IMPECCABLE_CONTEXT_DIR escape hatch)', () => { - it('reads from the override path when defaults are empty', () => { - write('design/PRODUCT.md', '# overridden product\n'); - write('design/DESIGN.md', '# overridden design\n'); - process.env.IMPECCABLE_CONTEXT_DIR = 'design'; - const ctx = loadContext(scratch); - assert.equal(ctx.hasProduct, true); - assert.equal(ctx.hasDesign, true); - assert.match(ctx.product, /overridden product/); - assert.equal(ctx.contextDir, path.join(scratch, 'design')); - }); - - it('does not override defaults when both exist (lazy escape hatch)', () => { - write('PRODUCT.md', '# root product\n'); - write('design/PRODUCT.md', '# overridden product\n'); - process.env.IMPECCABLE_CONTEXT_DIR = 'design'; - const ctx = loadContext(scratch); - assert.match(ctx.product, /root product/); - assert.equal(ctx.contextDir, scratch); - }); - - it('reports a missing override directory as no-context, not as a crash', () => { - process.env.IMPECCABLE_CONTEXT_DIR = 'no/such/dir'; - const ctx = loadContext(scratch); - assert.equal(ctx.hasProduct, false); - assert.equal(ctx.hasDesign, false); - assert.equal(ctx.product, null); - assert.equal(ctx.design, null); - assert.equal(ctx.contextDir, path.resolve(scratch, 'no/such/dir')); - }); -}); - -describe('extractPlatform', () => { - it('returns null when the product is empty or platform-less', () => { - assert.equal(extractPlatform(null), null); - assert.equal(extractPlatform('# P\n\nno platform here\n'), null); - }); - - it('reads web / ios / android / adaptive case-insensitively', () => { - assert.equal(extractPlatform('## Platform\n\nweb\n'), 'web'); - assert.equal(extractPlatform('## Platform\n\nios\n'), 'ios'); - assert.equal(extractPlatform('## platform\n\nANDROID\n'), 'android'); - assert.equal(extractPlatform('## Platform\n\nAdaptive\n'), 'adaptive'); - }); - - it('reads a line naming both native targets as adaptive', () => { - assert.equal(extractPlatform('## Platform\n\nios, android\n'), 'adaptive'); - assert.equal(extractPlatform('## Platform\n\nandroid and ios\n'), 'adaptive'); - assert.equal(extractPlatform('## Platform\n\nios/android\n'), 'adaptive'); - }); - - it('does not read prose mentioning both targets as adaptive', () => { - // Negations and explanations must fall through to the unrecognized-value - // warning, never silently classify as cross-platform native. - assert.equal(extractPlatform('## Platform\n\nweb only, not ios or android\n'), null); - assert.equal(extractPlatform('## Platform\n\nios first, android later this year\n'), null); - }); - - it('returns null for an unrecognized value', () => { - assert.equal(extractPlatform('## Platform\n\ndesktop\n'), null); - assert.equal(extractPlatform('## Platform\n\nflutter\n'), null); - }); - - it('ignores a near-miss heading and reads the real one', () => { - // `## Platform notes` must not be mistaken for the `## Platform` field. - const product = '## Platform notes\n\nsome prose here\n\n## Platform\n\nios\n'; - assert.equal(extractPlatform(product), 'ios'); - }); - - it('reads the first non-empty line after the heading', () => { - assert.equal(extractPlatform('## Platform\n\n\nios\n'), 'ios'); - }); - - it('treats an empty section followed by another heading as absent', () => { - // An empty `## Platform` must not swallow the next heading as its value - // (which would surface a nonsense "value `## Product Purpose` is not - // recognized" warning from the CLI). - assert.equal(extractPlatform('## Platform\n\n## Product Purpose\n\nAn app.\n'), null); - }); -}); - -describe('context.mjs CLI', () => { - it('emits NO_PRODUCT_MD directive when no PRODUCT.md is found', async () => { - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /^NO_PRODUCT_MD:/); - assert.match(res.stdout, /reference\/init\.md/); - assert.match(res.stdout, /structured simulated-user interview/); - assert.match(res.stdout, /PRODUCT_INIT_REQUIRED:/); - }); - - it('prints a PRODUCT.md markdown block when only PRODUCT.md exists', async () => { - write('PRODUCT.md', '# Acme\n\nbody\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /^# PRODUCT\.md/); - assert.match(res.stdout, /# Acme/); - assert.equal(res.stdout.includes('# DESIGN.md'), false); - // Directives are appended after `---`; missing visual authority now - // routes to new-work rather than back through product init. - assert.match(res.stdout, /\n---\n\n/); - assert.match(res.stdout, /WORLD_DISCOVERY_REQUIRED: PRODUCT\.md exists but no DESIGN\.md/); - assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); - assert.match(res.stdout, /detect\.mjs --json /); - }); - - it('drains stdout before exit when the parent pipe is paused', async () => { - const MARKER = 'END_MARKER_573'; - write('PRODUCT.md', `# Acme\n\n${'x'.repeat(256 * 1024)}\n\n${MARKER}\n`); - const child = spawn(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - let stdout = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stdout.pause(); - const resume = setTimeout(() => child.stdout.resume(), 100); - const status = await new Promise((resolve, reject) => { - child.on('error', reject); - child.on('close', resolve); - }); - clearTimeout(resume); - assert.equal(status, 0); - assert.match(stdout, /END_MARKER_573/); - assert.match(stdout, /RESOLVED_CONTEXT:/); - }); - - // The build-path preference rides the unified config beside hook and - // detector settings. The local file wins because whether a machine can - // generate images is a property of that machine, not of the committed - // default the rest of the team shares. - describe('BUILD_PATH_DEFAULT', () => { - const run = () => spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - - it('reports a recorded preference from the shared config', () => { - write('PRODUCT.md', '# Acme\n'); - write('.impeccable/config.json', JSON.stringify({ buildPath: 'code' })); - assert.match(run().stdout, /BUILD_PATH_DEFAULT: code \(from \.impeccable\/config\.json\)/); - }); - - it('lets the gitignored local config win over the committed one', () => { - write('PRODUCT.md', '# Acme\n'); - write('.impeccable/config.json', JSON.stringify({ buildPath: 'comp' })); - write('.impeccable/config.local.json', JSON.stringify({ buildPath: 'code' })); - assert.match(run().stdout, /BUILD_PATH_DEFAULT: code \(from \.impeccable\/config\.local\.json\)/); - }); - - it('stays silent when nothing is recorded, leaving new-work its own default', () => { - write('PRODUCT.md', '# Acme\n'); - assert.equal(run().stdout.includes('BUILD_PATH_DEFAULT'), false); - }); - - it('stays silent on a value nothing reads rather than guessing at it', () => { - write('PRODUCT.md', '# Acme\n'); - write('.impeccable/config.json', JSON.stringify({ buildPath: 'code-first' })); - assert.equal(run().stdout.includes('BUILD_PATH_DEFAULT'), false); - }); - - // A monorepo commits the preference once at the repo root for every app in - // it. Reading only projectRoot left the staleness finding (which does read - // both) silent while the directive never named the value. - describe('in a monorepo', () => { - const writeWorkspace = () => { - write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] })); - write('turbo.json', JSON.stringify({ tasks: {} })); - write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/src/App.jsx', 'export default function App() { return "d"; }\n'); - }; - const runFromWorkspace = () => spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/src/App.jsx'], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - - it('falls back to the repo-root value for a workspace that sets none', () => { - writeWorkspace(); - write('.impeccable/config.json', JSON.stringify({ buildPath: 'code' })); - assert.match(runFromWorkspace().stdout, /BUILD_PATH_DEFAULT: code/); - }); - - it('lets the workspace override the repo root, nearest root first', () => { - writeWorkspace(); - write('.impeccable/config.json', JSON.stringify({ buildPath: 'code' })); - write('apps/dashboard/.impeccable/config.json', JSON.stringify({ buildPath: 'comp' })); - assert.match(runFromWorkspace().stdout, /BUILD_PATH_DEFAULT: comp/); - }); - - // Running from one workspace while targeting another must not hand the - // target the caller's preference. The invoking directory is not evidence - // about the project being worked on. - it('does not let the invoking workspace lend its value to the target', () => { - writeWorkspace(); - write('apps/marketing/src/App.jsx', 'export default function App() { return "m"; }\n'); - write('.impeccable/config.json', JSON.stringify({ buildPath: 'code' })); - write('apps/marketing/.impeccable/config.json', JSON.stringify({ buildPath: 'comp' })); - // Absolute, so the target actually resolves onto dashboard. A relative - // path would be read against the caller's cwd, which resolves the - // project back to marketing and tests nothing. - const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', path.join(scratch, 'apps', 'dashboard', 'src', 'App.jsx')], { - cwd: path.join(scratch, 'apps', 'marketing'), - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - // The repo-root default, not marketing's comp. - assert.match(res.stdout, /BUILD_PATH_DEFAULT: code/); - }); - }); - }); - - it('keeps the manual-detector directive out of early context when the current provider hook is active', () => { - const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); - stageContextBundle(scripts, { providerId: 'codex' }); - - const project = path.join(scratch, 'project'); - fs.mkdirSync(path.join(project, '.codex'), { recursive: true }); - fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Acme\n'); - fs.writeFileSync(path.join(project, '.codex', 'hooks.json'), JSON.stringify({ - hooks: { Stop: [{ hooks: [{ command: 'node .agents/skills/impeccable/scripts/hook.mjs' }] }] }, - })); - - const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], { - cwd: project, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0, res.stderr); - assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); - - fs.mkdirSync(path.join(project, '.impeccable'), { recursive: true }); - fs.writeFileSync(path.join(project, '.impeccable', 'config.json'), JSON.stringify({ hook: { enabled: false } })); - const disabled = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], { - cwd: project, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(disabled.status, 0, disabled.stderr); - assert.match(disabled.stdout, /MANUAL_DETECTOR_REQUIRED:/); - assert.match(disabled.stdout, /detect\.mjs --json /); - }); - - it('adds no detector directive when a per-edit-only hook is active', () => { - const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); - stageContextBundle(scripts, { providerId: 'cursor' }); - - const project = path.join(scratch, 'project'); - fs.mkdirSync(path.join(project, '.cursor'), { recursive: true }); - fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Acme\n'); - fs.writeFileSync(path.join(project, '.cursor', 'hooks.json'), JSON.stringify({ - hooks: { preToolUse: [{ command: 'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs' }] }, - })); - - const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], { - cwd: project, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0, res.stderr); - assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); - assert.doesNotMatch(res.stdout, /detect\.mjs --json /); - }); - - it('treats tokenized code as incumbent design authority when DESIGN.md is missing', () => { - write('PRODUCT.md', '# Acme\n'); - write('src/app.css', ':root { --color-brand: red; --color-surface: white; --color-text: black; }\nbody { font-family: system-ui; background: var(--color-surface); color: var(--color-text); }\n'); - assert.equal(hasVisualImplementation(scratch), true); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /INCUMBENT_WORLD_UNDOCUMENTED:/); - assert.match(res.stdout, /For shape or a new-surface\/redesign request, load reference\/new-work\.md/); - assert.doesNotMatch(res.stdout, /WORLD_DISCOVERY_REQUIRED:/); - assert.match(res.stdout, /"hasVisualImplementation": true/); - }); - - it('does not mistake the empty Astro eval scaffold for an incumbent identity', () => { - write('src/styles/global.css', '@import "tailwindcss";\n'); - write('src/pages/index.astro', `---\nimport "../styles/global.css";\n---\nEval Workspace\n`); - assert.equal(hasVisualImplementation(scratch), false); - }); - - it('recognizes one substantive authored Astro surface', () => { - write('src/pages/index.astro', `

Field notes for the night shift

Specific authored content.

\n\n`); - assert.equal(hasVisualImplementation(scratch), true); - }); - - it('does not let irrelevant or vendored files exhaust or satisfy the visual scan', () => { - for (let i = 0; i < 300; i++) write(`src/data/item-${String(i).padStart(3, '0')}.txt`, 'not visual\n'); - write('public/vendor/framework.min.css', ':root { --a: 1; --b: 2; --c: 3; } body { color: red; background: blue; border-color: green; font-family: sans-serif; }\n'); - write('styles/z-theme.css', ':root { --brand: #124; --surface: #fff; --text: #111; } main { color: var(--text); background-color: var(--surface); border-color: var(--brand); }\n'); - assert.equal(hasVisualImplementation(scratch), true); - }); - - it('routes new surfaces through init but keeps narrow refinements non-blocking when visual code exists without PRODUCT.md', () => { - write('styles/theme.css', ':root { --brand: #124; --surface: #fff; --text: #111; }\nmain { color: var(--text); background-color: var(--surface); border-color: var(--brand); }\n'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /^NO_PRODUCT_MD:/); - assert.match(res.stdout, /EXISTING_VISUAL_SYSTEM:/); - assert.match(res.stdout, /BUILD_INIT_REQUIRED:/); - assert.match(res.stdout, /SCOPED_EXISTING_ALLOWED:/); - assert.match(res.stdout, /proceed without blocking/); - assert.match(res.stdout, /For `init`, `teach`, `shape`, or any request to create a new surface/); - assert.match(res.stdout, /For a redesign\/rebrand.*old look only as evidence and anti-reference/s); - assert.doesNotMatch(res.stdout, /WORLD_DISCOVERY_REQUIRED:/); - }); - - it('prints DESIGN.md even when PRODUCT.md is missing', () => { - // The skill resumes after init writes PRODUCT.md without rerunning this - // script, so a DESIGN.md withheld from the no-PRODUCT.md branch is never - // seen at all. Emit incumbent visual authority on both branches. - write('DESIGN.md', '# Acme design\n\nUNIQUE_DESIGN_MARKER\n'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /^NO_PRODUCT_MD:/); - assert.match(res.stdout, /# DESIGN\.md\n\n# Acme design/); - assert.match(res.stdout, /UNIQUE_DESIGN_MARKER/); - }); - - it('concatenates PRODUCT.md and DESIGN.md with a --- separator', async () => { - write('PRODUCT.md', '# Acme product\n'); - write('DESIGN.md', '# Acme design\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /^# PRODUCT\.md/); - assert.match(res.stdout, /\n---\n/); - assert.match(res.stdout, /# DESIGN\.md\n\n# Acme design/); - assert.equal(res.stdout.includes('WORLD_DISCOVERY_REQUIRED:'), false); - }); - - it('loads the only persisted surface brief as current task context', () => { - write('PRODUCT.md', '# Acme product\n'); - write('DESIGN.md', '# Acme design\n'); - write('.impeccable/surfaces/src-pages-pricing-astro.md', `--- -version: 1 -slug: "src-pages-pricing-astro" -primary_target: "src/pages/pricing.astro" -related_targets: [] ---- - -# Surface brief: Pricing - -## Product strategy -Make plan tradeoffs legible before asking for a trial. -`); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /# SURFACE BRIEF \(\.impeccable\/surfaces\/src-pages-pricing-astro\.md\)/); - assert.match(res.stdout, /Make plan tradeoffs legible/); - assert.match(res.stdout, /"surfaceBriefReason": "only-brief"/); - }); - - it('selects a surface brief by an exact primary or related target', () => { - write('PRODUCT.md', '# Acme product\n'); - write('DESIGN.md', '# Acme design\n'); - write('src/pages/pricing.astro', '
Pricing
\n'); - write('.impeccable/surfaces/src-pages-pricing-astro.md', `--- -version: 1 -slug: "src-pages-pricing-astro" -primary_target: "src/pages/pricing.astro" -related_targets: ["src/components/PricingTable.astro"] ---- - -# Surface brief: Pricing - -PRICING_STRATEGY_SENTINEL -`); - write('.impeccable/surfaces/src-pages-home-astro.md', `--- -version: 1 -slug: "src-pages-home-astro" -primary_target: "src/pages/home.astro" -related_targets: [] ---- - -# Surface brief: Home - -HOME_STRATEGY_SENTINEL -`); - const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'src/pages/pricing.astro'], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /PRICING_STRATEGY_SENTINEL/); - assert.doesNotMatch(res.stdout, /HOME_STRATEGY_SENTINEL/); - }); - - it('lists candidates instead of guessing when several surface briefs exist', () => { - write('PRODUCT.md', '# Acme product\n'); - write('DESIGN.md', '# Acme design\n'); - for (const [slug, target] of [ - ['src-pages-pricing-astro', 'src/pages/pricing.astro'], - ['src-pages-home-astro', 'src/pages/home.astro'], - ]) { - write(`.impeccable/surfaces/${slug}.md`, `--- -version: 1 -slug: "${slug}" -primary_target: "${target}" -related_targets: [] ---- - -# Surface brief: ${slug} -`); - } - const res = spawnSync(process.execPath, [SCRIPT_PATH], { - cwd: scratch, - encoding: 'utf8', - env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, - }); - assert.equal(res.status, 0); - assert.match(res.stdout, /SURFACE_CONTEXT_AVAILABLE:/); - assert.match(res.stdout, /src\/pages\/pricing\.astro/); - assert.match(res.stdout, /src\/pages\/home\.astro/); - assert.doesNotMatch(res.stdout, /# SURFACE BRIEF \(/); - }); - - it('reads from a fallback dir when cwd is clean', async () => { - write('.agents/context/PRODUCT.md', '# fallback product\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /^# PRODUCT\.md/); - assert.match(res.stdout, /# fallback product/); - }); - - it('ignores a legacy Register field because visitor mode is task-scoped', async () => { - write('PRODUCT.md', '# Acme\n\n## Register\n\nbrand\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.doesNotMatch(res.stdout, /REGISTER:/); - assert.match(res.stdout, /WORLD_DISCOVERY_REQUIRED: PRODUCT\.md exists but no DESIGN\.md/); - }); - - it('loads the native platform reference for an ios project', async () => { - write('PRODUCT.md', '# Acme\n\n## Platform\n\nios\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /# NATIVE PLATFORM REFERENCE: IOS \(reference\/ios\.md\)/); - assert.match(res.stdout, /Apple Human Interface Guidelines|iOS/i); - assert.doesNotMatch(res.stdout, /NEXT STEP:.*reference\/ios\.md/); - }); - - it('loads both native platform references for an adaptive project', async () => { - write('PRODUCT.md', '# Acme\n\n## Platform\n\nadaptive\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /# NATIVE PLATFORM REFERENCE: IOS \(reference\/ios\.md\)/); - assert.match(res.stdout, /# NATIVE PLATFORM REFERENCE: ANDROID \(reference\/android\.md\)/); - }); - - it('appends no native platform directive for a web project', async () => { - write('PRODUCT.md', '# Acme\n\n## Platform\n\nweb\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('This project targets'), false); - assert.equal(res.stdout.includes('reference/ios.md'), false); - }); - - it('loads the native platform reference for an android project', async () => { - write('PRODUCT.md', '# Acme\n\n## Platform\n\nandroid\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /# NATIVE PLATFORM REFERENCE: ANDROID \(reference\/android\.md\)/); - assert.match(res.stdout, /Material Design|Android/i); - }); - - it('warns on an unrecognized platform value instead of silently defaulting to web', async () => { - // The likeliest misconfiguration is a toolchain name where the target - // belongs. Silent fallback to web would give web guidance to the exact - // projects that tried to declare themselves native. - write('PRODUCT.md', '# Acme\n\n## Platform\n\nflutter\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.match(res.stdout, /WARNING: PRODUCT\.md's `## Platform` value `flutter` is not recognized/); - assert.match(res.stdout, /treating the project as `web`/); - assert.equal(res.stdout.includes('This project targets'), false); - }); - - it('emits no warning for an empty Platform section', async () => { - write('PRODUCT.md', '# Acme\n\n## Platform\n\n## Users\n\nAnglers.\n'); - const { spawnSync } = await import('node:child_process'); - const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('WARNING: PRODUCT.md'), false); - assert.equal(res.stdout.includes('This project targets'), false); - }); -}); - -describe('context.mjs update check', () => { - // The script reads its own version from a sibling SKILL.md (resolved via - // import.meta.url, not cwd). The source tree has no SKILL.md, so we copy the - // script into a scratch skill dir with a controlled version and run that. - // Local version is pinned to 1.0.0; "newer" = 2.0.0, "older" = 0.0.1. - const LOCAL_VERSION = '1.0.0'; - - const cachePath = () => path.join(scratch, 'update-check.json'); - - function setup(cacheObj, { disable = false, host } = {}) { - const skillScript = stageContextBundle(path.join(scratch, 'skill', 'scripts')); - fs.writeFileSync( - path.join(scratch, 'skill', 'SKILL.md'), - `---\nname: impeccable\nversion: ${LOCAL_VERSION}\n---\n\nbody\n`, - ); - fs.writeFileSync(cachePath(), JSON.stringify(cacheObj)); - const project = path.join(scratch, 'project'); - fs.mkdirSync(project, { recursive: true }); - fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Acme\n'); - const env = { - ...process.env, - IMPECCABLE_UPDATE_CACHE: cachePath(), - IMPECCABLE_NO_UPDATE_CHECK: disable ? '1' : '', - // This suite asserts on the update directive alone. Staleness findings - // are a separate directive with their own tests, and leaving the check on - // would also write notice state into the developer's home dir. - IMPECCABLE_NO_STALENESS_CHECK: '1', - ...(host ? { IMPECCABLE_UPDATE_HOST: host } : {}), - }; - return { skillScript, project, env }; - } - - // A fresh cache (lastCheck = now) skips the network poll, so cache-driven - // tests stay synchronous and hermetic. - function run(cacheObj, opts) { - const { skillScript, project, env } = setup(cacheObj, opts); - return spawnSync(process.execPath, [skillScript], { cwd: project, encoding: 'utf8', env }); - } - - // Async variant for the live-fetch tests: the stub server runs in THIS - // process, so the runner must not block the event loop (spawnSync would - // deadlock the loopback connection). spawn keeps the loop serving. - function runAsync(cacheObj, opts) { - const { skillScript, project, env } = setup(cacheObj, opts); - return new Promise((resolve) => { - const proc = spawn(process.execPath, [skillScript], { cwd: project, env }); - let stdout = ''; - proc.stdout.on('data', (d) => (stdout += d.toString())); - proc.on('exit', (status) => resolve({ status, stdout })); - }); - } - - function readCache() { - return JSON.parse(fs.readFileSync(cachePath(), 'utf8')); - } - - it('appends UPDATE_AVAILABLE when the cached latest version is newer', () => { - const res = run({ lastCheck: Date.now(), latestVersion: '2.0.0' }); - assert.equal(res.status, 0); - assert.match(res.stdout, /UPDATE_AVAILABLE: A newer Impeccable skill is available/); - assert.match(res.stdout, /installed v1\.0\.0, latest v2\.0\.0/); - assert.match(res.stdout, /npx impeccable update/); - // It must come after the real context, never replace it. - assert.match(res.stdout, /^# PRODUCT\.md/); - }); - - // The directive used to say "ask once" and "if they agree, run it" while also - // saying to continue without waiting. Nothing gated the run on an answer that - // could not arrive, so the command read as the next step. It now forbids - // running in this turn outright, whatever the answer. - it('forbids running the update in the same turn, on any answer', () => { - const { stdout } = run({ lastCheck: Date.now(), latestVersion: '2.0.0' }); - assert.match(stdout, /Do not run `npx impeccable update` in this turn, whatever the user answers/); - assert.match(stdout, /only after the user has asked for it in their own words/); - // The conditional that made the command look reachable must be gone. - assert.equal(/If they agree, run/.test(stdout), false); - }); - - it('stays silent when the cached latest version is not newer', () => { - const res = run({ lastCheck: Date.now(), latestVersion: '0.0.1' }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('UPDATE_AVAILABLE'), false); - }); - - it('does not re-surface a version notified within the last week', () => { - const res = run({ - lastCheck: Date.now(), - latestVersion: '2.0.0', - notifiedVersion: '2.0.0', - notifiedAt: Date.now(), - }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('UPDATE_AVAILABLE'), false); - }); - - it('respects IMPECCABLE_NO_UPDATE_CHECK', () => { - const res = run({ lastCheck: Date.now(), latestVersion: '2.0.0' }, { disable: true }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('UPDATE_AVAILABLE'), false); - }); - - // ─── live fetch path (against a localhost stub, never the real site) ────── - function startStub(body, { status = 200 } = {}) { - return new Promise((resolve) => { - const srv = http.createServer((req, res) => { - res.statusCode = status; - res.setHeader('content-type', 'application/json'); - res.end(typeof body === 'string' ? body : JSON.stringify(body)); - }); - srv.listen(0, '127.0.0.1', () => resolve({ srv, host: `http://127.0.0.1:${srv.address().port}` })); - }); - } - - it('polls /api/version over the network and caches a newer version', async () => { - const { srv, host } = await startStub({ skills: '2.0.0' }); - try { - const res = await runAsync({}, { host }); // empty cache forces the poll - assert.equal(res.status, 0); - assert.match(res.stdout, /UPDATE_AVAILABLE/); - assert.match(res.stdout, /installed v1\.0\.0, latest v2\.0\.0/); - const cache = readCache(); - assert.equal(cache.latestVersion, '2.0.0'); - assert.equal(typeof cache.lastCheck, 'number'); - } finally { - srv.close(); - } - }); - - it('stays silent when the network reports a same-or-older version', async () => { - const { srv, host } = await startStub({ skills: '1.0.0' }); - try { - const res = await runAsync({}, { host }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('UPDATE_AVAILABLE'), false); - // The poll still happened, so lastCheck is stamped to throttle the next. - assert.equal(typeof readCache().lastCheck, 'number'); - } finally { - srv.close(); - } - }); - - it('fails silent and stamps lastCheck when the endpoint is unreachable', async () => { - // Bind then immediately close to obtain a port nothing is listening on. - const { srv, host } = await startStub({ skills: '2.0.0' }); - await new Promise((r) => srv.close(r)); - const res = run({}, { host }); - assert.equal(res.status, 0); - assert.equal(res.stdout.includes('UPDATE_AVAILABLE'), false); - assert.match(res.stdout, /^# PRODUCT\.md/); // core output is unaffected - const cache = readCache(); - assert.equal(typeof cache.lastCheck, 'number'); // stamped so we don't re-poll every boot - assert.equal(cache.latestVersion, undefined); // nothing learned - }); - - // Targeted live-fetch boot: the Windows abort in issue #573 fired after - // stdout was already complete, so the contract is exit 0 with the full - // context still on stdout. - it('exits 0 after a targeted live-fetch boot writes full context', async () => { - const { srv, host } = await startStub({ skills: '2.0.0' }); - try { - const { skillScript, project, env } = setup({}, { host }); - fs.writeFileSync( - path.join(project, 'package.json'), - JSON.stringify({ private: true, workspaces: ['packages/*'] }), - ); - const jervPi = path.join(project, 'packages', 'jerv-pi'); - fs.mkdirSync(jervPi, { recursive: true }); - fs.writeFileSync(path.join(jervPi, 'PRODUCT.md'), '# Jerv Pi product\n'); - - const result = await new Promise((resolveRun, rejectRun) => { - const child = spawn(process.execPath, [skillScript, '--target', 'packages/jerv-pi'], { - cwd: project, - env, - }); - let stdout = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.on('error', rejectRun); - child.on('close', (status) => resolveRun({ status, stdout })); - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /RESOLVED_CONTEXT:/); - assert.match(result.stdout, /# Jerv Pi product/); - assert.match(result.stdout, /UPDATE_AVAILABLE/); - } finally { - srv.close(); - } - }); -}); diff --git a/tests/critique-storage.test.mjs b/tests/critique-storage.test.mjs deleted file mode 100644 index 38c49b6f4..000000000 --- a/tests/critique-storage.test.mjs +++ /dev/null @@ -1,881 +0,0 @@ -/** - * Tests for critique snapshot persistence. - * Run with: node --test tests/critique-storage.test.mjs - */ - -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; -import { basename, join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const SCRIPT = fileURLToPath(new URL('../skill/scripts/critique-storage.mjs', import.meta.url)); - -import { - fingerprintTarget, - slugFromTarget, - writeSnapshot, - readLatestSnapshot, - readLatestSnapshotAcrossTargets, - readTrend, - closeSnapshot, - nowFilenameStamp, -} from '../skill/scripts/critique-storage.mjs'; - -let cwd; -beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'imp-critique-')); }); -afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); - -describe('slugFromTarget', () => { - it('kebabs a relative file path', () => { - assert.equal(slugFromTarget('site/pages/index.astro', { cwd }), 'site-pages-index-astro'); - }); - - it('kebabs an absolute path inside cwd by relativizing', () => { - const abs = join(cwd, 'site/pages/index.astro'); - assert.equal(slugFromTarget(abs, { cwd }), 'site-pages-index-astro'); - }); - - it('uses basename for absolute paths outside cwd', () => { - // Sibling path, not under cwd - const abs = join(tmpdir(), 'somewhere', 'else', 'page.html'); - assert.equal(slugFromTarget(abs, { cwd }), 'page-html'); - }); - - it('drops port from URL', () => { - assert.equal(slugFromTarget('http://localhost:3000/pricing', { cwd }), 'localhost-pricing'); - }); - - it('normalizes URL casing and trailing slash', () => { - assert.equal( - slugFromTarget('https://Impeccable.Style/docs/audit/', { cwd }), - 'impeccable-style-docs-audit', - ); - }); - - it('strips query strings', () => { - assert.equal( - slugFromTarget('https://example.com/x?utm=1&foo=bar', { cwd }), - 'example-com-x', - ); - }); - - it('returns null for empty / project-root inputs', () => { - assert.equal(slugFromTarget('', { cwd }), null); - assert.equal(slugFromTarget('.', { cwd }), null); - assert.equal(slugFromTarget(null, { cwd }), null); - }); - - it('caps overly long slugs from the tail', () => { - const longPath = 'a/'.repeat(60) + 'file.tsx'; // way over 50 - const slug = slugFromTarget(longPath, { cwd }); - assert.ok(slug.length <= 50); - assert.ok(slug.endsWith('file-tsx')); - }); - - it('is stable: same input → same slug', () => { - const a = slugFromTarget('site/pages/index.astro', { cwd }); - const b = slugFromTarget('site/pages/index.astro', { cwd }); - assert.equal(a, b); - }); -}); - -describe('nowFilenameStamp', () => { - it('is windows-safe (no colons or dots in the time fragment)', () => { - const stamp = nowFilenameStamp(new Date('2026-05-12T18:30:00.123Z')); - assert.equal(stamp, '2026-05-12T18-30-00Z'); - }); -}); - -describe('fingerprintTarget', () => { - it('fingerprints exact local file bytes independent of Git state', () => { - const target = join(cwd, 'index.html'); - writeFileSync(target, '
hello
'); - const first = fingerprintTarget(target, { cwd }); - assert.match(first, /^sha256:[a-f0-9]{64}$/); - assert.equal(fingerprintTarget('index.html', { cwd }), first); - - writeFileSync(target, '
changed
'); - assert.notEqual(fingerprintTarget(target, { cwd }), first); - }); - - it('returns null for URLs, directories, and missing files', () => { - assert.equal(fingerprintTarget('https://example.com/page', { cwd }), null); - assert.equal(fingerprintTarget('.', { cwd }), null); - assert.equal(fingerprintTarget('missing.html', { cwd }), null); - }); -}); - -describe('writeSnapshot + readLatestSnapshot', () => { - it('round-trips body and frontmatter', () => { - const out = writeSnapshot({ - slug: 'index-astro', - meta: { target: 'the homepage', total_score: 28, p0_count: 1, p1_count: 3 }, - body: '# Critique\n\nP0: nested cards', - cwd, - }); - assert.ok(out.endsWith('__index-astro.md')); - const latest = readLatestSnapshot('index-astro', { cwd }); - assert.equal(latest.meta.slug, 'index-astro'); - assert.equal(latest.meta.target, 'the homepage'); - assert.equal(latest.meta.total_score, 28); - assert.match(latest.body, /P0: nested cards/); - }); - - it('returns null when no snapshot for slug', () => { - assert.equal(readLatestSnapshot('nope', { cwd }), null); - }); - - it('picks the newest by filename when multiple exist', () => { - writeSnapshot({ slug: 'index-astro', meta: { total_score: 22 }, body: 'old', cwd, now: new Date('2026-05-01T00:00:00Z') }); - writeSnapshot({ slug: 'index-astro', meta: { total_score: 30 }, body: 'new', cwd, now: new Date('2026-05-12T00:00:00Z') }); - const latest = readLatestSnapshot('index-astro', { cwd }); - assert.equal(latest.meta.total_score, 30); - assert.match(latest.body, /new/); - }); - - it('preserves same-second snapshots with a sortable collision suffix', () => { - const now = new Date('2026-05-12T18:30:00Z'); - const first = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 20 }, - body: 'first', - cwd, - now, - }); - const second = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 30 }, - body: 'second', - cwd, - now, - }); - - assert.notEqual(second, first); - assert.ok(first.endsWith('2026-05-12T18-30-00Z__index-astro.md')); - assert.ok(second.endsWith('2026-05-12T18-30-00Z~0001__index-astro.md')); - assert.match(readLatestSnapshot('index-astro', { cwd }).body, /second/); - assert.deepEqual( - readTrend('index-astro', { cwd }).map((entry) => entry.total_score), - [20, 30], - ); - }); - - it('picks the newest snapshot across target slugs', () => { - writeSnapshot({ slug: 'home', meta: {}, body: 'old', cwd, now: new Date('2026-05-01T00:00:00Z') }); - writeSnapshot({ slug: 'pricing', meta: {}, body: 'new', cwd, now: new Date('2026-05-12T00:00:00Z') }); - writeFileSync(join(cwd, '.impeccable', 'critique', 'ignore.md'), '# Critique ignores\n'); - writeFileSync(join(cwd, '.impeccable', 'critique', '9999-not-a-snapshot.md'), '# Draft\n'); - const latest = readLatestSnapshotAcrossTargets({ cwd }); - assert.equal(latest.meta.slug, 'pricing'); - assert.match(latest.body, /new/); - }); - - it('does not see snapshots for a different slug', () => { - writeSnapshot({ slug: 'pricing-astro', meta: { total_score: 10 }, body: 'b', cwd }); - assert.equal(readLatestSnapshot('index-astro', { cwd }), null); - }); - - it('caller-supplied meta cannot override computed timestamp or slug', () => { - // Defends against a corrupt IMPECCABLE_CRITIQUE_META blob (parsed from - // an env var) silently rewriting fields that must agree with the - // filename. Otherwise readTrend would attribute scores to the wrong - // timestamps with no error. - const out = writeSnapshot({ - slug: 'index-astro', - meta: { timestamp: 'NOT_A_REAL_STAMP', slug: 'somewhere-else', total_score: 50 }, - body: 'b', - cwd, - now: new Date('2026-05-12T18:30:00Z'), - }); - const latest = readLatestSnapshot('index-astro', { cwd }); - assert.equal(latest.meta.slug, 'index-astro'); - assert.equal(latest.meta.timestamp, '2026-05-12T18-30-00Z'); - // The legit meta field still lands. - assert.equal(latest.meta.total_score, 50); - // The filename matches the computed slug. - assert.ok(out.endsWith('2026-05-12T18-30-00Z__index-astro.md')); - }); - - it('quotes values containing : or # to keep parsing simple', () => { - writeSnapshot({ - slug: 'x', - meta: { target: 'docs: critique # main' }, - body: '...', - cwd, - }); - const latest = readLatestSnapshot('x', { cwd }); - assert.equal(latest.meta.target, 'docs: critique # main'); - }); - - it('closeSnapshot returns the path and leaves readLatestSnapshot null', () => { - const out = writeSnapshot({ slug: 'index-astro', meta: { total_score: 20 }, body: 'open', cwd }); - const closed = closeSnapshot(out, { cwd }); - assert.equal(closed, out); - assert.ok(closed.endsWith('__index-astro.md')); - assert.equal(readLatestSnapshot('index-astro', { cwd }), null); - }); - - it('closeSnapshot closes the backlog without deleting its trend history', () => { - writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 21, p0_count: 7 }, - body: 'old leftover', - cwd, - now: new Date('2026-05-01T00:00:00Z'), - }); - const newest = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 30 }, - body: 'newer', - cwd, - now: new Date('2026-05-12T00:00:00Z'), - }); - const closed = closeSnapshot(newest, { cwd }); - assert.equal(closed, newest); - assert.equal(readLatestSnapshot('index-astro', { cwd }), null); - const trend = readTrend('index-astro', { cwd }); - assert.equal(trend.length, 2); - assert.equal(trend[0].total_score, 21); - assert.equal(trend[1].total_score, 30); - assert.equal(trend[1].closed, true); - }); - - it('a new snapshot reopens a previously closed slug', () => { - const resolved = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 20 }, - body: 'resolved', - cwd, - now: new Date('2026-05-01T00:00:00Z'), - }); - closeSnapshot(resolved, { cwd }); - const reopened = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 15 }, - body: 'new findings', - cwd, - now: new Date('2026-05-12T00:00:00Z'), - }); - - assert.equal(readLatestSnapshot('index-astro', { cwd }).path, reopened); - assert.equal(readTrend('index-astro', { cwd }).length, 2); - }); - - it('latest across targets skips a closed slug without hiding other backlogs', () => { - const pricing = writeSnapshot({ - slug: 'pricing', - meta: { total_score: 25 }, - body: 'pricing backlog', - cwd, - now: new Date('2026-05-01T00:00:00Z'), - }); - const home = writeSnapshot({ - slug: 'home', - meta: { total_score: 30 }, - body: 'home backlog', - cwd, - now: new Date('2026-05-12T00:00:00Z'), - }); - closeSnapshot(home, { cwd }); - - assert.equal(readLatestSnapshotAcrossTargets({ cwd }).path, pricing); - }); - - it('latest across targets keeps colliding target identities independent', () => { - const original = writeSnapshot({ - slug: 'foo-bar', - meta: { - target_identity: `file:${join(cwd, 'foo', 'bar')}`, - total_score: 20, - }, - body: 'older original backlog', - cwd, - now: new Date('2026-05-01T00:00:00Z'), - }); - const colliding = writeSnapshot({ - slug: 'foo-bar', - meta: { - target_identity: `file:${join(cwd, 'foo-bar')}`, - total_score: 30, - }, - body: 'newer colliding backlog', - cwd, - now: new Date('2026-05-12T00:00:00Z'), - }); - closeSnapshot(colliding, { cwd }); - - assert.equal(readLatestSnapshotAcrossTargets({ cwd }).path, original); - }); - - it('latest across targets does not resurrect legacy work after identity migration', () => { - writeSnapshot({ - slug: 'index-html', - meta: { total_score: 20 }, - body: 'legacy backlog', - cwd, - now: new Date('2026-05-01T00:00:00Z'), - }); - const modern = writeSnapshot({ - slug: 'index-html', - meta: { - target_identity: `file:${join(cwd, 'index.html')}`, - total_score: 30, - }, - body: 'modern backlog', - cwd, - now: new Date('2026-05-12T00:00:00Z'), - }); - closeSnapshot(modern, { cwd }); - - assert.equal(readLatestSnapshotAcrossTargets({ cwd }), null); - }); -}); - -describe('CLI entry point', () => { - // Why a subprocess test: the CLI guard at the bottom of the script - // previously compared import.meta.url to `file://${process.argv[1]}`, - // which silently broke on Windows (forward vs back slashes) — exit 0, - // no output, save skipped. The exported functions kept passing because - // tests never spawned the script as a process. See issue #155. - it('slug subcommand prints a slug and exits 0', () => { - const r = spawnSync(process.execPath, [SCRIPT, 'slug', 'site/pages/index.astro'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - assert.equal(r.stdout.trim(), 'site-pages-index-astro'); - }); - - it('slug subcommand exits 1 with a message for empty input', () => { - const r = spawnSync(process.execPath, [SCRIPT, 'slug', ''], { cwd, encoding: 'utf-8' }); - assert.equal(r.status, 1); - assert.match(r.stderr, /no stable slug/); - }); - - it('runs when invoked through a symlinked harness path', () => { - const linkedScript = join(cwd, 'linked-critique-storage.mjs'); - symlinkSync(SCRIPT, linkedScript); - - const r = spawnSync(process.execPath, [linkedScript, 'slug', 'index.html'], { - cwd, - encoding: 'utf-8', - }); - - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - assert.equal(r.stdout.trim(), 'index-html'); - }); - - it('latest subcommand exits 2 when no snapshot exists', () => { - const r = spawnSync(process.execPath, [SCRIPT, 'latest', 'never-written'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 2); - }); - - it('inherits an unchanged untracked file snapshot and closes it after any byte change', () => { - const target = join(cwd, 'index.html'); - const bodyFile = join(cwd, 'critique.md'); - writeFileSync(target, '
assessed worktree
'); - writeFileSync(bodyFile, '# Critique\n\nP1: improve hierarchy'); - - const write = spawnSync(process.execPath, [SCRIPT, 'write', target, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(write.status, 0, `stderr: ${write.stderr}`); - const written = readLatestSnapshot('index-html', { cwd }); - assert.equal(written.meta.target_path, target); - assert.equal(written.meta.target_identity, `file:${target}`); - assert.match(written.meta.target_fingerprint, /^sha256:[a-f0-9]{64}$/); - - const unchanged = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(unchanged.status, 0, `stderr: ${unchanged.stderr}`); - assert.match(unchanged.stdout, /improve hierarchy/); - - // The edit can happen in the same clock second as the snapshot; exact - // bytes, rather than timestamp precision, determine freshness. - writeFileSync(target, '
newer worktree
'); - const changed = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(changed.status, 2, `stderr: ${changed.stderr}`); - assert.equal(readLatestSnapshot('index-html', { cwd }), null); - assert.equal(readTrend('index-html', { cwd })[0].closed, true); - }); - - it('fingerprints extensionless local targets instead of mistaking them for slugs', () => { - const target = join(cwd, 'main'); - const bodyFile = join(cwd, 'critique.md'); - writeFileSync(target, '
assessed
'); - writeFileSync(bodyFile, '# Critique\n\nP1: improve hierarchy'); - - const write = spawnSync(process.execPath, [SCRIPT, 'write', target, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(write.status, 0, `stderr: ${write.stderr}`); - - writeFileSync(target, '
changed
'); - const changed = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(changed.status, 2, `stderr: ${changed.stderr}`); - assert.equal(readLatestSnapshot('main', { cwd }), null); - - const deletedTarget = join(cwd, 'shell'); - writeFileSync(deletedTarget, '#!/bin/sh\n'); - const deletedWrite = spawnSync(process.execPath, [SCRIPT, 'write', deletedTarget, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(deletedWrite.status, 0, `stderr: ${deletedWrite.stderr}`); - rmSync(deletedTarget); - const deleted = spawnSync(process.execPath, [SCRIPT, 'latest', deletedTarget], { - cwd, - encoding: 'utf-8', - }); - assert.equal(deleted.status, 2, `stderr: ${deleted.stderr}`); - assert.equal(readLatestSnapshot('shell', { cwd }), null); - }); - - it('rejects a concrete target that collides with another target slug', () => { - const originalDir = join(cwd, 'foo'); - const originalTarget = join('foo', 'bar'); - const originalPath = join(cwd, originalTarget); - const ambiguousTarget = join(cwd, 'foo-bar'); - const bodyFile = join(cwd, 'critique.md'); - mkdirSync(originalDir); - writeFileSync(originalPath, '
assessed original
'); - writeFileSync(bodyFile, '# Critique\n\nP1: preserve this backlog'); - - const write = spawnSync(process.execPath, [SCRIPT, 'write', originalTarget, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(write.status, 0, `stderr: ${write.stderr}`); - - // This distinct extensionless file shares the original target's slug. - // The bare value is ambiguous while that file exists, and an explicit - // local path is a known identity mismatch. Neither may inherit or close - // the original snapshot. - writeFileSync(ambiguousTarget, '
different target
'); - const ambiguous = spawnSync(process.execPath, [SCRIPT, 'latest', 'foo-bar'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(ambiguous.status, 2, `stderr: ${ambiguous.stderr}`); - assert.match(ambiguous.stderr, /ambiguous snapshot slug/); - const explicitOther = spawnSync(process.execPath, [SCRIPT, 'latest', './foo-bar'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(explicitOther.status, 2, `stderr: ${explicitOther.stderr}`); - assert.notEqual(readLatestSnapshot('foo-bar', { cwd }), null); - - // Once the local name collision is gone, the same bare value is an - // intentional slug lookup and can return the original backlog. - rmSync(ambiguousTarget); - const bySlug = spawnSync(process.execPath, [SCRIPT, 'latest', 'foo-bar'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(bySlug.status, 0, `stderr: ${bySlug.stderr}`); - assert.match(bySlug.stdout, /preserve this backlog/); - - // The recorded original path still owns freshness invalidation. - writeFileSync(originalPath, '
changed original
'); - const changedOriginal = spawnSync(process.execPath, [SCRIPT, 'latest', originalTarget], { - cwd, - encoding: 'utf-8', - }); - assert.equal(changedOriginal.status, 2, `stderr: ${changedOriginal.stderr}`); - assert.equal(readLatestSnapshot('foo-bar', { cwd }), null); - }); - - it('finds the exact target backlog when two live snapshots share a slug', () => { - const originalDir = join(cwd, 'foo'); - const originalTarget = join('foo', 'bar'); - const otherTarget = join(cwd, 'foo-bar'); - const bodyFile = join(cwd, 'critique.md'); - mkdirSync(originalDir); - writeFileSync(join(cwd, originalTarget), '
original
'); - writeFileSync(otherTarget, '
other
'); - - writeFileSync(bodyFile, '# Critique\n\nP1: original backlog'); - const originalWrite = spawnSync( - process.execPath, - [SCRIPT, 'write', originalTarget, bodyFile], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(originalWrite.status, 0, `stderr: ${originalWrite.stderr}`); - - writeFileSync(bodyFile, '# Critique\n\nP1: newer other backlog'); - const otherWrite = spawnSync( - process.execPath, - [SCRIPT, 'write', './foo-bar', bodyFile], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(otherWrite.status, 0, `stderr: ${otherWrite.stderr}`); - - const originalLatest = spawnSync( - process.execPath, - [SCRIPT, 'latest', originalTarget, '--json'], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(originalLatest.status, 0, `stderr: ${originalLatest.stderr}`); - const originalResult = JSON.parse(originalLatest.stdout); - assert.match(originalResult.body, /original backlog/); - assert.doesNotMatch(originalResult.body, /newer other backlog/); - - const otherLatest = spawnSync( - process.execPath, - [SCRIPT, 'latest', './foo-bar', '--json'], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(otherLatest.status, 0, `stderr: ${otherLatest.stderr}`); - const otherResult = JSON.parse(otherLatest.stdout); - assert.match(otherResult.body, /newer other backlog/); - assert.notEqual(otherResult.snapshot_file, originalResult.snapshot_file); - - const closeOriginal = spawnSync(process.execPath, [ - SCRIPT, - 'close', - originalTarget, - originalResult.snapshot_file, - ], { cwd, encoding: 'utf-8' }); - assert.equal(closeOriginal.status, 0, `stderr: ${closeOriginal.stderr}`); - - const closedOriginal = spawnSync( - process.execPath, - [SCRIPT, 'latest', originalTarget], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(closedOriginal.status, 2, `stderr: ${closedOriginal.stderr}`); - const stillOpenOther = spawnSync( - process.execPath, - [SCRIPT, 'latest', './foo-bar'], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(stillOpenOther.status, 0, `stderr: ${stillOpenOther.stderr}`); - assert.match(stillOpenOther.stdout, /newer other backlog/); - }); - - it('closes a local snapshot when its target is deleted or replaced by a directory', () => { - const bodyFile = join(cwd, 'critique.md'); - writeFileSync(bodyFile, '# Critique\n\nP1: improve hierarchy'); - - for (const replacement of ['missing', 'directory']) { - const target = join(cwd, `${replacement}.html`); - writeFileSync(target, '
assessed
'); - const write = spawnSync(process.execPath, [SCRIPT, 'write', target, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(write.status, 0, `stderr: ${write.stderr}`); - - rmSync(target); - if (replacement === 'directory') mkdirSync(target); - - const latest = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(latest.status, 2, `stderr: ${latest.stderr}`); - const slug = `${replacement}-html`; - assert.equal(readLatestSnapshot(slug, { cwd }), null); - assert.equal(readTrend(slug, { cwd })[0].closed, true); - } - }); - - it('treats a legacy local-file snapshot without a fingerprint as stale', () => { - const target = join(cwd, 'index.html'); - writeFileSync(target, '
current
'); - writeSnapshot({ slug: 'index-html', meta: { total_score: 20 }, body: 'legacy', cwd }); - - const latest = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(latest.status, 2, `stderr: ${latest.stderr}`); - assert.equal(readTrend('index-html', { cwd })[0].closed, true); - }); - - it('rejects an ambiguous legacy extensionless lookup until the path is explicit', () => { - const target = join(cwd, 'main'); - writeFileSync(target, '
changed since legacy critique
'); - writeSnapshot({ slug: 'main', meta: { total_score: 20 }, body: 'legacy stale', cwd }); - - const ambiguous = spawnSync(process.execPath, [SCRIPT, 'latest', 'main'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(ambiguous.status, 2, `stderr: ${ambiguous.stderr}`); - assert.match(ambiguous.stderr, /ambiguous legacy snapshot target/); - assert.notEqual(readLatestSnapshot('main', { cwd }), null); - - const explicit = spawnSync(process.execPath, [SCRIPT, 'latest', './main'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(explicit.status, 2, `stderr: ${explicit.stderr}`); - assert.equal(readLatestSnapshot('main', { cwd }), null); - assert.equal(readTrend('main', { cwd })[0].closed, true); - }); - - it('keeps URL snapshots current without a local fingerprint', () => { - const bodyFile = join(cwd, 'critique.md'); - writeFileSync(bodyFile, '# Critique\n\nP1: improve hierarchy'); - const target = 'https://example.com/page'; - - const write = spawnSync(process.execPath, [SCRIPT, 'write', target, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(write.status, 0, `stderr: ${write.stderr}`); - assert.equal( - readLatestSnapshot('example-com-page', { cwd }).meta.target_identity, - 'url:https://example.com/page', - ); - - const latest = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(latest.status, 0, `stderr: ${latest.stderr}`); - assert.match(latest.stdout, /improve hierarchy/); - - const bySlug = spawnSync(process.execPath, [SCRIPT, 'latest', 'example-com-page'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(bySlug.status, 0, `stderr: ${bySlug.stderr}`); - assert.match(bySlug.stdout, /improve hierarchy/); - }); - - it('keeps URL schemes and non-default ports in separate identity streams', () => { - const bodyFile = join(cwd, 'critique.md'); - const targets = [ - ['http://example.test/review', 'http backlog'], - ['https://example.test/review', 'https backlog'], - ['https://example.test:8443/review', 'port backlog'], - ]; - - for (const [target, backlog] of targets) { - writeFileSync(bodyFile, `# Critique\n\nP1: ${backlog}`); - const write = spawnSync(process.execPath, [SCRIPT, 'write', target, bodyFile], { - cwd, - encoding: 'utf-8', - }); - assert.equal(write.status, 0, `stderr: ${write.stderr}`); - } - - const results = targets.map(([target, backlog]) => { - const latest = spawnSync( - process.execPath, - [SCRIPT, 'latest', target, '--json'], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(latest.status, 0, `stderr: ${latest.stderr}`); - const result = JSON.parse(latest.stdout); - assert.match(result.body, new RegExp(backlog)); - return result; - }); - assert.equal(new Set(results.map((result) => result.snapshot_file)).size, 3); - - const wrongClose = spawnSync(process.execPath, [ - SCRIPT, - 'close', - targets[1][0], - results[0].snapshot_file, - ], { cwd, encoding: 'utf-8' }); - assert.equal(wrongClose.status, 2, `stderr: ${wrongClose.stderr}`); - const httpStillOpen = spawnSync( - process.execPath, - [SCRIPT, 'latest', targets[0][0]], - { cwd, encoding: 'utf-8' }, - ); - assert.equal(httpStillOpen.status, 0, `stderr: ${httpStillOpen.stderr}`); - assert.match(httpStillOpen.stdout, /http backlog/); - - const closeHttp = spawnSync(process.execPath, [ - SCRIPT, - 'close', - targets[0][0], - results[0].snapshot_file, - ], { cwd, encoding: 'utf-8' }); - assert.equal(closeHttp.status, 0, `stderr: ${closeHttp.stderr}`); - - for (const [target, backlog] of targets.slice(1)) { - const latest = spawnSync(process.execPath, [SCRIPT, 'latest', target], { - cwd, - encoding: 'utf-8', - }); - assert.equal(latest.status, 0, `stderr: ${latest.stderr}`); - assert.match(latest.stdout, new RegExp(backlog)); - } - }); - - it('latest --json returns the exact snapshot identity and body', () => { - const target = 'https://example.com/exact'; - const snapshot = writeSnapshot({ - slug: 'example-com-exact', - meta: { total_score: 20 }, - body: 'exact backlog', - cwd, - }); - const r = spawnSync(process.execPath, [SCRIPT, 'latest', target, '--json'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - const result = JSON.parse(r.stdout); - assert.equal(result.snapshot_file, basename(snapshot)); - assert.match(result.body, /exact backlog/); - }); - - it('close subcommand closes the identified snapshot and preserves its trend', () => { - const snapshot = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 20 }, - body: 'open', - cwd, - }); - const r = spawnSync(process.execPath, [ - SCRIPT, - 'close', - 'index-astro', - basename(snapshot), - ], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - assert.equal(readLatestSnapshot('index-astro', { cwd }), null); - assert.equal(readTrend('index-astro', { cwd }).length, 1); - assert.equal(readTrend('index-astro', { cwd })[0].closed, true); - }); - - it('close subcommand leaves a newer critique backlog active', () => { - const target = 'https://example.com/index'; - const first = writeSnapshot({ - slug: 'example-com-index', - meta: { total_score: 20 }, - body: 'first backlog', - cwd, - now: new Date('2026-05-12T00:00:00Z'), - }); - const read = spawnSync(process.execPath, [SCRIPT, 'latest', target, '--json'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(read.status, 0, `stderr: ${read.stderr}`); - assert.equal(JSON.parse(read.stdout).snapshot_file, basename(first)); - - const newer = writeSnapshot({ - slug: 'example-com-index', - meta: { total_score: 30 }, - body: 'newer unprocessed backlog', - cwd, - now: new Date('2026-05-12T00:00:01Z'), - }); - const close = spawnSync(process.execPath, [ - SCRIPT, - 'close', - target, - basename(first), - ], { - cwd, - encoding: 'utf-8', - }); - assert.equal(close.status, 0, `stderr: ${close.stderr}`); - assert.equal(readLatestSnapshot('example-com-index', { cwd }).path, newer); - assert.equal(readLatestSnapshotAcrossTargets({ cwd }).path, newer); - const trend = readTrend('example-com-index', { cwd }); - assert.equal(trend[0].closed, true); - assert.equal(trend[1].closed, undefined); - }); - - it('close subcommand exits 2 when the identified snapshot is already closed', () => { - const snapshot = writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 20 }, - body: 'open', - cwd, - }); - closeSnapshot(snapshot, { cwd }); - const r = spawnSync(process.execPath, [ - SCRIPT, - 'close', - 'index-astro', - basename(snapshot), - ], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 2); - }); - - it('close subcommand exits 2 when no snapshot exists', () => { - const r = spawnSync(process.execPath, [ - SCRIPT, - 'close', - 'never-written', - '2026-05-12T00-00-00Z__never-written.md', - ], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 2); - }); - - it('close subcommand requires the identity returned by latest --json', () => { - const r = spawnSync(process.execPath, [SCRIPT, 'close', 'index-astro'], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 1); - assert.match(r.stderr, /snapshot-file/); - }); - - it('close subcommand rejects a snapshot identity from another slug', () => { - const home = writeSnapshot({ slug: 'home', meta: { total_score: 20 }, body: 'home', cwd }); - const r = spawnSync(process.execPath, [SCRIPT, 'close', 'pricing', basename(home)], { - cwd, - encoding: 'utf-8', - }); - assert.equal(r.status, 2); - assert.notEqual(readLatestSnapshot('home', { cwd }), null); - }); -}); - -describe('readTrend', () => { - it('returns last N entries oldest → newest, filtered by slug', () => { - for (let i = 0; i < 6; i++) { - writeSnapshot({ - slug: 'index-astro', - meta: { total_score: 20 + i }, - body: `run ${i}`, - cwd, - now: new Date(2026, 4, i + 1), - }); - } - writeSnapshot({ slug: 'pricing-astro', meta: { total_score: 99 }, body: 'unrelated', cwd }); - const trend = readTrend('index-astro', { limit: 5, cwd }); - assert.equal(trend.length, 5); - assert.equal(trend[0].total_score, 21); // dropped the oldest - assert.equal(trend[4].total_score, 25); - }); - - it('returns empty when no snapshots', () => { - assert.deepEqual(readTrend('nope', { cwd }), []); - }); -}); diff --git a/tests/design-parser.test.mjs b/tests/design-parser.test.mjs deleted file mode 100644 index a2cee5962..000000000 --- a/tests/design-parser.test.mjs +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Tests for design-parser.mjs — frontmatter + body extraction. - * Run with: node --test tests/design-parser.test.mjs - */ - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { assessCoverage, parseDesignMd } from '../skill/scripts/lib/design-parser.mjs'; - -describe('parseDesignMd frontmatter branch', () => { - it('returns null frontmatter when the file has no YAML header', () => { - const md = `# Design System: Demo - -## 1. Overview - -Some prose. -`; - const model = parseDesignMd(md); - assert.equal(model.schemaVersion, 2); - assert.equal(model.frontmatter, null); - assert.equal(model.title, 'Design System: Demo'); - }); - - it('parses a Stitch-shaped frontmatter and strips it from the body', () => { - const md = `--- -name: Demo System -description: A quiet editorial look. -colors: - primary: "#b8422e" - neutral-bg: "#faf7f2" -typography: - display: - fontFamily: "Cormorant Garamond, Georgia, serif" - fontWeight: 300 - lineHeight: 1 - body: - fontFamily: "Inter, sans-serif" -rounded: - sm: "4px" - md: "8px" -components: - button-primary: - backgroundColor: "{colors.primary}" - textColor: "{colors.neutral-bg}" - rounded: "{rounded.sm}" ---- - -# Design System: Demo - -## 1. Overview - -Opening prose. -`; - const model = parseDesignMd(md); - assert.equal(model.schemaVersion, 2); - assert.equal(model.title, 'Design System: Demo'); - assert.ok(model.frontmatter); - assert.equal(model.frontmatter.name, 'Demo System'); - assert.equal(model.frontmatter.description, 'A quiet editorial look.'); - assert.equal(model.frontmatter.colors.primary, '#b8422e'); - assert.equal(model.frontmatter.colors['neutral-bg'], '#faf7f2'); - assert.equal(model.frontmatter.typography.display.fontFamily, 'Cormorant Garamond, Georgia, serif'); - assert.equal(model.frontmatter.typography.display.fontWeight, 300); - assert.equal(model.frontmatter.typography.display.lineHeight, 1); - assert.equal(model.frontmatter.rounded.md, '8px'); - assert.equal(model.frontmatter.components['button-primary'].backgroundColor, '{colors.primary}'); - }); - - it('recovers gracefully when frontmatter has no closing marker', () => { - // No `---` terminator: the whole file is treated as body, not partial - // frontmatter. The H1 title still resolves from the body. - const md = `--- -this is not valid yaml : : : -no closing marker -# Design System: Broken - -## 1. Overview - -Prose. -`; - const model = parseDesignMd(md); - assert.equal(model.frontmatter, null); - assert.equal(model.title, 'Design System: Broken'); - }); - - it('ignores line-only comments but preserves unquoted hex values', () => { - const md = `--- -# Top-level comment -colors: - primary: #b8422e - # mid-block comment - accent: "#ec4899" ---- - -# Design System: Commented - -## 1. Overview - -Prose. -`; - const model = parseDesignMd(md); - assert.equal(model.frontmatter.colors.primary, '#b8422e'); - assert.equal(model.frontmatter.colors.accent, '#ec4899'); - }); - - it('strips inline comments after quoted OKLCH values', () => { - const md = `--- -colors: - kinpaku-gold: "oklch(84% 0.19 80.46)" # primary accent - gold-hairline: "oklch(58% 0.065 82 / 0.32)" # default rule ---- - -# Design System: Kinpaku - -## 1. Overview - -Prose. -`; - const model = parseDesignMd(md); - assert.equal(model.frontmatter.colors['kinpaku-gold'], 'oklch(84% 0.19 80.46)'); - assert.equal(model.frontmatter.colors['gold-hairline'], 'oklch(58% 0.065 82 / 0.32)'); - }); - - it('normalizes quoted YAML keys in token maps', () => { - const md = `--- -rounded: - "2xl": "80px" - '3xl': "96px" -colors: - "brand-gold": "#d9a531" ---- - -# Design System: Quoted Keys - -## 1. Overview - -Prose. -`; - const model = parseDesignMd(md); - assert.equal(model.frontmatter.rounded['2xl'], '80px'); - assert.equal(model.frontmatter.rounded['3xl'], '96px'); - assert.equal(model.frontmatter.colors['brand-gold'], '#d9a531'); - assert.equal(model.frontmatter.rounded['"2xl"'], undefined); - }); - - it('unescapes quote escapes inside quoted scalars (issue #428)', () => { - const md = `--- -typography: - body: - fontFamily: "\\"IBM Plex Sans\\", system-ui, sans-serif" -name: 'It''s quiet' -empty: " ---- - -# Design System: Escaped - -## 1. Overview - -Prose. -`; - const model = parseDesignMd(md); - // YAML double-quoted scalars process backslash escapes. - assert.equal(model.frontmatter.typography.body.fontFamily, '"IBM Plex Sans", system-ui, sans-serif'); - // Single-quoted scalars escape the quote by doubling it. - assert.equal(model.frontmatter.name, "It's quiet"); - // A lone quote satisfies startsWith and endsWith at once; keep it literal - // instead of slicing it into an empty string. - assert.equal(model.frontmatter.empty, '"'); - }); - - it('decodes hex, Unicode, and whitespace escapes in double-quoted scalars', () => { - const md = `--- -colors: - accent: "\\x23b8422e" -typography: - accent: - fontFamily: "S\\u00f6hne, sans-serif" - label: - fontFamily: "IBM\\ Plex\\ Serif, serif" - mono: - fontFamily: "Space\\_Grotesk, sans-serif" -emoji: "\\U0001F44D" -bad-hex: "\\xZZ nope" -bad-range: "\\UFFFFFFFF nope" ---- - -# Design System: Hex Escapes - -## 1. Overview - -Prose. -`; - const model = parseDesignMd(md); - assert.equal(model.frontmatter.colors.accent, '#b8422e'); - assert.equal(model.frontmatter.typography.accent.fontFamily, 'Söhne, sans-serif'); - // \ (escaped space) and \_ (non-breaking space) are valid YAML escapes. - assert.equal(model.frontmatter.typography.label.fontFamily, 'IBM Plex Serif, serif'); - assert.equal(model.frontmatter.typography.mono.fontFamily, 'Space\u00a0Grotesk, sans-serif'); - assert.equal(model.frontmatter.emoji, '\u{1F44D}'); - // Malformed or out-of-range sequences stay literal. - assert.equal(model.frontmatter['bad-hex'], '\\xZZ nope'); - assert.equal(model.frontmatter['bad-range'], '\\UFFFFFFFF nope'); - }); -}); - -describe('parseDesignMd overview branch', () => { - it('joins wrapped Key Characteristics bullets without leaking continuations into philosophy', () => { - const md = `# Design System: Example - -## Overview - -**Creative North Star: "Structured clarity"** - -**Key Characteristics:** - -- Status remains understandable without relying on color - alone. -- Navigation controls remain visible when the viewport becomes - narrow. -`; - const overview = parseDesignMd(md).overview; - - assert.deepEqual(overview.keyCharacteristics, [ - 'Status remains understandable without relying on color alone.', - 'Navigation controls remain visible when the viewport becomes narrow.', - ]); - assert.deepEqual(overview.philosophy, []); - }); -}); - -describe('parseDesignMd named rules', () => { - it('preserves format precedence while deduplicating later definitions', () => { - const md = `# Design System: Rules - -## Layout - -### The "Rhythm" Rule -Ignore this duplicate heading definition. - -### The Fallback Principle -Use the heading definition. - -### Named Rules -- **The Fallback Principle:** Ignore this duplicate bullet definition. -- **The Layering Principle:** Use the bullet definition. - -**The Rhythm Rule.** Keep the first definition. -`; - - assert.deepEqual(parseDesignMd(md).layout.rules, [ - { name: 'The Rhythm Rule', body: 'Keep the first definition.' }, - { name: 'The Fallback Principle', body: 'Use the heading definition.' }, - { name: 'The Layering Principle', body: 'Use the bullet definition.' }, - ]); - }); -}); - -describe('parseDesignMd canonical sections', () => { - it('preserves content and named rules from all eight canonical sections', () => { - const md = `# Design System: Complete - -## Overview - -**Creative North Star: "Structured clarity"** - -## Colors - -### Primary -- **Ink** (#111111): Primary text. - -## Typography - -**Body Font:** Inter (with sans-serif) - -## Layout: Responsive rhythm - -Primary regions use a twelve-column grid that collapses to one column on narrow screens. - -### Named Rules -**The Spatial Hierarchy Rule.** Primary content must remain visually dominant. - -## Elevation & Depth - -Surfaces use tonal layering instead of shadows. - -## Shapes - -Selected objects use a double outline and clipped corners. - -### The "Recognizable Silhouette" Rule -Repeated geometry must remain recognizable without color. - -## Components - -### Button -- **Primary:** Uses the accent color. - -## Do's and Don'ts - -### Do -- Preserve the spatial hierarchy. - -### Don't -- Flatten every surface. -`; - const model = parseDesignMd(md); - - assert.equal(model.layout.subtitle, 'Responsive rhythm'); - assert.equal( - model.layout.description, - 'Primary regions use a twelve-column grid that collapses to one column on narrow screens.', - ); - assert.deepEqual(model.layout.rules, [{ - name: 'The Spatial Hierarchy Rule', - body: 'Primary content must remain visually dominant.', - }]); - assert.equal(model.shapes.description, 'Selected objects use a double outline and clipped corners.'); - assert.deepEqual(model.shapes.rules, [{ - name: 'The Recognizable Silhouette Rule', - body: 'Repeated geometry must remain recognizable without color.', - }]); - - const coverage = assessCoverage(model); - assert.deepEqual( - Object.entries(coverage).filter(([, v]) => v === 'missing').map(([k]) => k), - [], - 'a section present in the markdown must not be reported as missing', - ); - assert.deepEqual(Object.keys(coverage), [ - 'overview', - 'colors', - 'typography', - 'layout', - 'elevation', - 'shapes', - 'components', - 'dosDonts', - ]); - assert.deepEqual(coverage.layout, { description: true, rules: 1 }); - assert.deepEqual(coverage.shapes, { description: true, rules: 1 }); - }); -}); diff --git a/tests/design-system.test.mjs b/tests/design-system.test.mjs deleted file mode 100644 index ebd83243f..000000000 --- a/tests/design-system.test.mjs +++ /dev/null @@ -1,688 +0,0 @@ -/** - * Design-system normalization and source-rule tests. - * Run with: node --test tests/design-system.test.mjs - */ - -import { describe, it, afterEach } 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 { - checkSourceDesignSystem, - collectStaticDesignSystemFindings, - isAllowedColorRaw, - isAllowedShadowColorRaw, - isAllowedFont, - isAllowedRadiusRaw, - isAllowedFontSizeRaw, - loadDesignSystemForCwd, - normalizeDesignSystem, -} from '../cli/engine/design-system.mjs'; - -const tempDirs = []; - -function mkTmp() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-design-system-')); - tempDirs.push(dir); - return dir; -} - -function sampleDesignSystem() { - return normalizeDesignSystem({ - frontmatter: { - typography: { - display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' }, - body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '16px' }, - label: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '0.875rem' }, - }, - colors: { - ink: '#241f1a', - paper: '#f7f4ee', - accent: '#b8422e', - gold: 'oklch(84% 0.19 80.46)', - }, - rounded: { - sm: '4px', - md: '8px', - '"2xl"': '80px', - full: '999px', - }, - }, - sidecar: { - extensions: { - colorMeta: { - gold: { - canonical: 'oklch(84% 0.19 80.46)', - tonalRamp: ['#d9a531', '#b98518'], - }, - }, - roundedMeta: { - soft: { - canonical: '12px', - values: ['24px'], - }, - }, - }, - }, - }); -} - -afterEach(() => { - while (tempDirs.length) { - fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); - } -}); - -describe('normalizeDesignSystem()', () => { - it('normalizes typography, colors, sidecar ramps, and quoted rounded keys', () => { - const designSystem = sampleDesignSystem(); - - assert.equal(isAllowedFont('avenir next', designSystem), true); - assert.equal(isAllowedFont('ibm plex sans', designSystem), true); - assert.equal(isAllowedFont('system-ui', designSystem), true); - assert.equal(isAllowedFont('poppins', designSystem), false); - - assert.equal(isAllowedColorRaw('#241f1a', designSystem), true); - assert.equal(isAllowedColorRaw('oklch(84% 0.19 80.46 / 0.5)', designSystem), true); - assert.equal(isAllowedColorRaw('#d9a531', designSystem), true); - assert.equal(isAllowedColorRaw('#ff00aa', designSystem), false); - assert.equal(isAllowedColorRaw('var(--brand-accent)', designSystem), true); - assert.equal(isAllowedColorRaw('currentColor', designSystem), true); - - assert.equal(isAllowedRadiusRaw('0', designSystem), true); - assert.equal(isAllowedRadiusRaw('50%', designSystem), true); - assert.equal(isAllowedRadiusRaw('80px', designSystem), true); - assert.equal(isAllowedRadiusRaw('12px', designSystem), true); - assert.equal(isAllowedRadiusRaw('24px', designSystem), true); - assert.equal(isAllowedRadiusRaw('100px', designSystem), true); - assert.equal(isAllowedRadiusRaw('9999px', designSystem), true); - assert.equal(isAllowedRadiusRaw('18px', designSystem), false); - - assert.equal(isAllowedFontSizeRaw('16px', designSystem), true); - assert.equal(isAllowedFontSizeRaw('1rem', designSystem), true); - assert.equal(isAllowedFontSizeRaw('0.875rem', designSystem), true); - assert.equal(isAllowedFontSizeRaw('14px', designSystem), true); - assert.equal(isAllowedFontSizeRaw('12.5px', designSystem), false); - assert.equal(isAllowedFontSizeRaw('1.2em', designSystem), true); - assert.equal(isAllowedFontSizeRaw('var(--text-body)', designSystem), true); - - // Fluid values are judged on their endpoints. This fixture documents 14px, - // 16px, and the display role's 40px/64px endpoints, so a 2rem (32px) max is - // off the ramp even though the 1rem min is on it. - assert.equal(isAllowedFontSizeRaw('clamp(1rem, 2vw, 2rem)', designSystem), false); - assert.equal(isAllowedFontSizeRaw('clamp(2.5rem, 6vw, 4rem)', designSystem), true); - }); - - it('reads a typography.scale map as literal ramp steps', () => { - const designSystem = normalizeDesignSystem({ - frontmatter: { - typography: { - scale: { - micro: '0.5625rem', // 9px - body: '1rem', // 16px - title: '1.5rem', // 24px - }, - body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '1rem' }, - }, - }, - }); - - assert.equal(designSystem.hasFontSizes, true); - assert.equal(isAllowedFontSizeRaw('9px', designSystem), true); - assert.equal(isAllowedFontSizeRaw('0.5625rem', designSystem), true); - assert.equal(isAllowedFontSizeRaw('24px', designSystem), true); - // Off every step by more than the 0.5px tolerance. - assert.equal(isAllowedFontSizeRaw('0.82rem', designSystem), false); - assert.equal(isAllowedFontSizeRaw('20px', designSystem), false); - }); - - it('accepts both clamp() endpoints as ramp steps', () => { - const designSystem = normalizeDesignSystem({ - frontmatter: { - typography: { - scale: { body: '1rem' }, - display: { fontFamily: 'Alumni Sans, sans-serif', fontSize: 'clamp(3.4rem, 6.5vw, 5.6rem)' }, - }, - }, - }); - - // 3.4rem = 54.4px (min) and 5.6rem = 89.6px (max) are both documented. - assert.equal(isAllowedFontSizeRaw('3.4rem', designSystem), true); - assert.equal(isAllowedFontSizeRaw('5.6rem', designSystem), true); - assert.equal(isAllowedFontSizeRaw('54.4px', designSystem), true); - assert.equal(isAllowedFontSizeRaw('89.6px', designSystem), true); - // The vw middle term is viewport-relative, never a fixed step. - assert.equal(isAllowedFontSizeRaw('6.5px', designSystem), false); - // An arbitrary size between the endpoints is still off the ramp. - assert.equal(isAllowedFontSizeRaw('4.2rem', designSystem), false); - }); - - it('validates clamp() endpoints in usage, not just in DESIGN.md', () => { - const designSystem = normalizeDesignSystem({ - frontmatter: { - typography: { - scale: { body: '1rem', title: '1.5rem' }, // 16px, 24px - }, - }, - }); - - // Both endpoints documented. - assert.equal(isAllowedFontSizeRaw('clamp(1rem, 2vw, 1.5rem)', designSystem), true); - // Neither endpoint is a step: 23.2px and 28.8px. - assert.equal(isAllowedFontSizeRaw('clamp(1.45rem, 1.8vw, 1.8rem)', designSystem), false); - // One bad endpoint is enough. - assert.equal(isAllowedFontSizeRaw('clamp(1rem, 2vw, 1.8rem)', designSystem), false); - // The viewport term interpolates and is never judged as a step. - assert.equal(isAllowedFontSizeRaw('clamp(1rem, 6.5vw, 1.5rem)', designSystem), true); - // Unjudgeable endpoints abstain rather than guess. - assert.equal(isAllowedFontSizeRaw('clamp(var(--a), 2vw, 1.5rem)', designSystem), true); - assert.equal(isAllowedFontSizeRaw('clamp(var(--a), 2vw, var(--b))', designSystem), true); - // Malformed or unparseable fluid values abstain. - assert.equal(isAllowedFontSizeRaw('clamp(1.45rem, 1.8vw)', designSystem), true); - // Non-clamp functional values keep abstaining. - assert.equal(isAllowedFontSizeRaw('calc(1rem + 3px)', designSystem), true); - assert.equal(isAllowedFontSizeRaw('var(--text-body)', designSystem), true); - }); - - it("accepts DESIGN.md's own fluid roles when used verbatim in source", () => { - // The endpoints a fluid role declares are documented sizes, so authoring - // that exact clamp must not flag. Regression guard for the asymmetry where - // the extractor read clamp endpoints but the checker never validated them. - const designSystem = normalizeDesignSystem({ - frontmatter: { - typography: { - scale: { body: '1rem' }, - display: { fontFamily: 'Alumni Sans, sans-serif', fontSize: 'clamp(3.4rem, 6.5vw, 5.6rem)' }, - }, - }, - }); - - assert.equal(isAllowedFontSizeRaw('clamp(3.4rem, 6.5vw, 5.6rem)', designSystem), true); - assert.equal(isAllowedFontSizeRaw('clamp(3.4rem, 6.5vw, 6rem)', designSystem), false); - }); - - it('strips CSS priority markers from the font-size ignore value', () => { - // The ignoreValue is what a `hooks ignore-value` waiver has to match, so a - // size must not need two different waivers depending on whether the - // declaration carries !important. font-family already behaves this way. - const designSystem = normalizeDesignSystem({ - frontmatter: { typography: { scale: { body: '1rem' } } }, - }); - const findings = checkSourceDesignSystem( - '.a { font-size: 1.4rem !important; }\n.b { font-size: 1.4rem; }', - '/tmp/important.css', - { designSystem }, - ); - const sizes = findings.filter((f) => f.antipattern === 'design-system-font-size'); - assert.equal(sizes.length, 2); - assert.deepEqual(sizes.map((f) => f.ignoreValue), ['1.4rem', '1.4rem']); - }); - - it('reports which fluid endpoint is off the ramp', () => { - const designSystem = normalizeDesignSystem({ - frontmatter: { typography: { scale: { body: '1rem' } } }, - }); - const findings = checkSourceDesignSystem( - '.a { font-size: clamp(1.45rem, 1.8vw, 1.8rem) !important; }', - '/tmp/fluid.css', - { designSystem }, - ); - const sizes = findings.filter((f) => f.antipattern === 'design-system-font-size'); - assert.equal(sizes.length, 1); - assert.match(sizes[0].snippet, /1\.45rem/); - assert.match(sizes[0].snippet, /1\.8rem/); - assert.equal(sizes[0].ignoreValue, '1.45rem'); - }); - - it('does not let clamp() endpoints alone switch the font-size rule on', () => { - // A fully fluid system enumerates no discrete ramp, so inferring one from - // clamp endpoints would flag every intermediate size. Keep abstaining. - const designSystem = normalizeDesignSystem({ - frontmatter: { - typography: { - display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' }, - body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: 'clamp(1rem, 2vw, 1.125rem)' }, - }, - }, - }); - - assert.equal(designSystem.hasFontSizes, false); - assert.equal(isAllowedFontSizeRaw('12.5px', designSystem), true); - }); -}); - -describe('loadDesignSystemForCwd()', () => { - it('loads DESIGN.md plus .impeccable/design.json and marks stale sidecars', () => { - const cwd = mkTmp(); - fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); - const designMd = path.join(cwd, 'DESIGN.md'); - const sidecarJson = path.join(cwd, '.impeccable', 'design.json'); - - fs.writeFileSync(designMd, `--- -typography: - body: - fontFamily: "IBM Plex Sans, Arial, sans-serif" -colors: - ink: "#241f1a" -rounded: - "2xl": "80px" ---- - -# Design System -`); - fs.writeFileSync(sidecarJson, JSON.stringify({ - extensions: { - colorMeta: { - accent: { - canonical: '#b8422e', - tonalRamp: ['#d55a42'], - }, - }, - roundedMeta: { - lg: { canonical: '24px' }, - }, - shadows: [ - { name: 'ambient-low', value: '0 4px 24px rgba(0,0,0,0.12)', purpose: 'Diffuse hover glow.' }, - ], - }, - })); - - fs.utimesSync(sidecarJson, new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')); - fs.utimesSync(designMd, new Date('2026-01-02T00:00:00Z'), new Date('2026-01-02T00:00:00Z')); - - const loaded = loadDesignSystemForCwd(cwd); - assert.equal(loaded.present, true); - assert.equal(loaded.sourcePath, designMd); - assert.equal(loaded.sidecarPath, sidecarJson); - assert.equal(loaded.mdNewerThanJson, true); - assert.equal(isAllowedColorRaw('#d55a42', loaded), true); - assert.equal(isAllowedRadiusRaw('80px', loaded), true); - assert.equal(isAllowedRadiusRaw('24px', loaded), true); - assert.equal(isAllowedShadowColorRaw('rgba(0, 0, 0, 0.12)', loaded), true); - assert.equal(isAllowedShadowColorRaw('rgba(0, 0, 0, 0.5)', loaded), false); - }); - - it('unescapes YAML-escaped quotes around multi-word font families (issue #428)', () => { - // A YAML double-quoted scalar processes backslash escapes, so a stack that - // quotes a multi-word family the CSS way arrives as - // fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif" - // Before the fix the family reached allowedFonts as '\"ibm plex sans' and - // the rule flagged fonts DESIGN.md declares. - const cwd = mkTmp(); - fs.writeFileSync(path.join(cwd, 'DESIGN.md'), `--- -typography: - display: - fontFamily: "Archivo, system-ui, sans-serif" - body: - fontFamily: "\\"IBM Plex Sans\\", system-ui, sans-serif" - data: - fontFamily: '"IBM Plex Mono", ui-monospace, monospace' - accent: - fontFamily: "S\\u00f6hne, sans-serif" - label: - fontFamily: "IBM\\ Plex\\ Serif, serif" - mono: - fontFamily: "Space\\_Grotesk, sans-serif" -colors: - accent: "\\x23b8422e" ---- - -# Design System -`); - - const loaded = loadDesignSystemForCwd(cwd); - assert.deepEqual( - [...loaded.allowedFonts].sort(), - ['archivo', 'ibm plex mono', 'ibm plex sans', 'ibm plex serif', 'space grotesk', 'söhne'], - ); - assert.equal(isAllowedFont('ibm plex sans', loaded), true); - assert.equal(isAllowedFont('ibm plex mono', loaded), true); - // Escaped space (\ ) and non-breaking space (\_) forms; NBSP collapses to - // a plain space in normalizeFontName, so the CSS declaration matches. - assert.equal(isAllowedFont('ibm plex serif', loaded), true); - assert.equal(isAllowedFont('space grotesk', loaded), true); - assert.equal(isAllowedFont('comic sans ms', loaded), false); - // \x escapes decode too: "\x23b8422e" is #b8422e. - assert.equal(isAllowedColorRaw('#b8422e', loaded), true); - assert.equal(isAllowedColorRaw('#ff00aa', loaded), false); - - const findings = checkSourceDesignSystem(` -body { font-family: "IBM Plex Sans", system-ui, sans-serif; } -code { font-family: "IBM Plex Mono", ui-monospace, monospace; } -h1 { font-family: Archivo, system-ui, sans-serif; } -em { font-family: "Söhne", sans-serif; color: #b8422e; } -small { font-family: "IBM Plex Serif", serif; } -pre { font-family: "Space Grotesk", sans-serif; } -`, '/tmp/escaped-fonts.css', { designSystem: loaded }); - assert.deepEqual(findings, []); - }); -}); - -describe('checkSourceDesignSystem()', () => { - it('reports source fonts, literal colors, and radii outside DESIGN.md', () => { - const designSystem = sampleDesignSystem(); - const findings = checkSourceDesignSystem(` -.good { - font-family: "IBM Plex Sans", Arial, sans-serif; - color: #241f1a; - background: rgba(184, 66, 46, 0.45); - border-radius: 8px; -} - -.bad { - font-family: "Poppins", sans-serif; - color: #ff00aa; - background: rgba(255, 0, 170, 1); - border-radius: 18px; -} -`, '/tmp/source.css', { designSystem }); - - assert.deepEqual( - findings.map((item) => item.antipattern), - ['design-system-font', 'design-system-color', 'design-system-color', 'design-system-radius'], - ); - assert.deepEqual( - findings.map((item) => item.ignoreValue), - ['Poppins', '#ff00aa', 'rgba(255, 0, 170, 1)', '18px'], - ); - }); - - it('strips CSS priority markers before checking font-family declarations', () => { - const designSystem = sampleDesignSystem(); - const findings = checkSourceDesignSystem(` -.good { - font-family: "IBM Plex Sans", Arial, sans-serif !important; -} - -.also-good { - font-family: "Avenir Next" !important; -} - -.bad { - font-family: "Poppins" !important; -} -`, '/tmp/important.css', { designSystem }); - - assert.deepEqual( - findings.map((item) => item.ignoreValue), - ['Poppins'], - ); - }); - - it('does not treat issue labels, HTML entities, or font variables as literal design values', () => { - const designSystem = sampleDesignSystem(); - const findings = checkSourceDesignSystem(` -#155 - -const MONO = 'SFMono-Regular, Roboto Mono, Consolas, monospace'; -const FONT = 'IBM Plex Sans, Arial, sans-serif'; -const COLOR_SAMPLE = 'rgba(255, 0, 170, 1)'; -const COLOR_NOTE = 'oklch(60% 0.2 20)'; -button.innerHTML = \`Pick\`; -scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px;'; -.demo [style*="background: #fef3c7"] { - border-color: #ff00aa; -} - -.bad { - font-family: "Poppins", sans-serif; - color: #cc00ff; -} -`, '/tmp/source.jsx', { designSystem }); - - assert.deepEqual( - findings.map((item) => item.ignoreValue), - ['10px', '#ff00aa', 'Poppins', '#cc00ff'], - ); - }); - - it('reports literal font sizes outside the DESIGN.md type ramp', () => { - const designSystem = sampleDesignSystem(); - const source = `.off-ramp { - font-size: 12.5px; -} -const label = { fontSize: "11px" }; -const badge = { className: "text-[10px]" }; -/* font-size: 9px; */ -.on-ramp { - font-size: 1rem; -} -`; - const findings = checkSourceDesignSystem(source, '/tmp/sizes.css', { designSystem }); - const fontSizeFindings = findings.filter((item) => item.antipattern === 'design-system-font-size'); - - assert.equal(fontSizeFindings.length, 3); - assert.deepEqual( - fontSizeFindings.map((item) => item.ignoreValue), - ['12.5px', '11px', '10px'], - ); - assert.deepEqual( - fontSizeFindings.map((item) => item.line), - [2, 4, 5], - ); - }); - - it('abstains on font-size checks when DESIGN.md has no literal ramp steps', () => { - const designSystem = normalizeDesignSystem({ - frontmatter: { - typography: { - display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' }, - body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: 'clamp(1rem, 2vw, 1.125rem)' }, - }, - }, - }); - assert.equal(designSystem.hasFontSizes, false); - - const findings = checkSourceDesignSystem('.bad { font-size: 12.5px; }', '/tmp/clamp-only.css', { designSystem }); - assert.equal(findings.some((item) => item.antipattern === 'design-system-font-size'), false); - }); -}); - -describe('sidecar shadow tokens (issue #547)', () => { - // Mirrors the sidecar `extensions.shadows` schema from document.md Step 4b. - function shadowDesignSystem() { - return normalizeDesignSystem({ - frontmatter: { - colors: { ink: '#241f1a', paper: '#f7f4ee' }, - }, - sidecar: { - extensions: { - shadows: [ - { - name: 'outset', - value: 'inset 0 1px 0 oklch(1 0 0 / 0.07), 0 1px 2px oklch(0 0 0 / 0.28), 0 4px 12px oklch(0 0 0 / 0.22)', - purpose: 'Default card shadow.', - }, - ], - }, - }, - }); - } - - it('matches documented shadow colors on alpha, not just r/g/b', () => { - const designSystem = shadowDesignSystem(); - assert.equal(isAllowedShadowColorRaw('oklch(0 0 0 / 0.28)', designSystem), true); - assert.equal(isAllowedShadowColorRaw('rgba(0, 0, 0, 0.28)', designSystem), true); - assert.equal(isAllowedShadowColorRaw('oklch(1 0 0 / 0.07)', designSystem), true); - // Same black, undocumented alpha: the r/g/b channels alone must not match. - assert.equal(isAllowedShadowColorRaw('oklch(0 0 0 / 55%)', designSystem), false); - assert.equal(isAllowedShadowColorRaw('#000', designSystem), false); - // Shadow tokens must not switch the general color rule's allowlist on. - assert.equal(isAllowedColorRaw('oklch(0 0 0 / 0.28)', designSystem), false); - }); - - it('allows documented shadow colors in shadow contexts only', () => { - const designSystem = shadowDesignSystem(); - const findings = checkSourceDesignSystem(` -.a { box-shadow: 0 1px 2px oklch(0 0 0 / 0.28); } -.b { box-shadow: 0 20px 50px oklch(0 0 0 / 55%); } -.c { background: #000; } -.d { background: oklch(0 0 0 / 0.28); } -.e { text-shadow: 0 1px 2px oklch(0 0 0 / 0.28); } -.f { box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.07), 0 4px 12px oklch(0 0 0 / 0.22); } -const card = { boxShadow: "0 1px 2px rgba(0, 0, 0, 0.28)" }; -const layered = { boxShadow: "0 1px 2px rgba(0, 0, 0, 0.28), 0 4px 12px rgba(0, 0, 0, 0.22)" }; -const bad = { color: "rgba(0, 0, 0, 0.28)" }; -const leak = { boxShadow: "0 1px 2px rgba(0, 0, 0, 0.28)", color: "rgba(0, 0, 0, 0.28)" }; -`, '/tmp/shadows.css', { designSystem }); - const colors = findings.filter((item) => item.antipattern === 'design-system-color'); - - // .a, .e, .f, and both JS boxShadow strings (including the second layer - // past the comma) pass; .b (undocumented alpha), .c (forbidden ground), - // .d (documented alpha outside a shadow), and both JS color keys still - // fire — the `leak` line proves the closing quote stops the shadow - // context from reaching a later property. A fix that silences .d has - // stopped discriminating between shadow usage and page grounds. - assert.deepEqual( - colors.map((item) => [item.line, item.ignoreValue]), - [ - [3, 'oklch(0 0 0 / 55%)'], - [4, '#000'], - [5, 'oklch(0 0 0 / 0.28)'], - [10, 'rgba(0, 0, 0, 0.28)'], - [11, 'rgba(0, 0, 0, 0.28)'], - ], - ); - }); - - it('abstains from the color rule entirely when only shadows are documented', () => { - // A shadows-only sidecar must not switch hasColors on: with no palette to - // measure against, the engine abstains rather than guesses, same as every - // other design-system rule. - const designSystem = normalizeDesignSystem({ - sidecar: { - extensions: { - shadows: [{ name: 'outset', value: '0 1px 2px oklch(0 0 0 / 0.28)' }], - }, - }, - }); - assert.equal(designSystem.hasColors, false); - const findings = checkSourceDesignSystem( - '.c { background: #000; }', - '/tmp/shadows-only.css', - { designSystem }, - ); - assert.equal(findings.some((item) => item.antipattern === 'design-system-color'), false); - }); - - it('keeps shadow context across template interpolations', () => { - const designSystem = shadowDesignSystem(); - const findings = checkSourceDesignSystem(` -const card = { boxShadow: \`0 \${offset}px 2px rgba(0, 0, 0, 0.28)\` }; - box-shadow: 0 1px \${blur}px rgba(0, 0, 0, 0.28); -const fn = { boxShadow: \`0 \${getShadow('lg')} 2px rgba(0, 0, 0, 0.28)\` }; -const tern = { boxShadow: \`0 1px \${dark ? "4px" : "2px"} rgba(0, 0, 0, 0.28)\` }; - box-shadow: 0 \${theme('blur')} rgba(0, 0, 0, 0.28); -const nested = { boxShadow: \`0 \${getOffset({ size: 2 })}px 2px rgba(0, 0, 0, 0.28)\` }; -const nestedQ = { boxShadow: \`0 \${getOffset({ size: 'lg' })}px rgba(0, 0, 0, 0.28)\` }; -const leak = { boxShadow: \`0 \${offset}px rgba(0, 0, 0, 0.28)\`, color: "rgba(0, 0, 0, 0.28)" }; -`, '/tmp/interpolated.js', { designSystem }); - const colors = findings.filter((item) => item.antipattern === 'design-system-color'); - - // The documented shadow color passes after a \${...} interpolation in - // the JS template literal and the CSS-in-JS line, including - // interpolations carrying quoted function arguments, ternary branches, - // and one level of object-literal braces; the color key on the leak line - // still fires because it sits past the template's closing backtick. - assert.deepEqual( - colors.map((item) => [item.line, item.ignoreValue]), - [[9, 'rgba(0, 0, 0, 0.28)']], - ); - }); - - it('does not allow a later declaration to inherit shadow context from earlier on the line', () => { - const designSystem = shadowDesignSystem(); - const findings = checkSourceDesignSystem( - '.x { box-shadow: 0 1px 2px oklch(0 0 0 / 0.28); background: oklch(0 0 0 / 0.28); }', - '/tmp/one-line.css', - { designSystem }, - ); - const colors = findings.filter((item) => item.antipattern === 'design-system-color'); - assert.equal(colors.length, 1); - assert.equal(colors[0].ignoreValue, 'oklch(0 0 0 / 0.28)'); - }); -}); - -describe('collectStaticDesignSystemFindings()', () => { - function makeElement(tagName, { text = '', attrs = {}, style = {}, parentElement = null } = {}) { - return { - tagName: tagName.toUpperCase(), - textContent: text, - parentElement, - _style: style, - childNodes: text ? [{ nodeType: 3, textContent: text }] : [], - getAttribute(name) { - return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; - }, - }; - } - - function makeWindow() { - const defaults = { - color: 'rgb(36, 31, 26)', - backgroundColor: 'rgba(0, 0, 0, 0)', - borderTopWidth: '0px', - borderRightWidth: '0px', - borderBottomWidth: '0px', - borderLeftWidth: '0px', - borderTopColor: 'rgb(36, 31, 26)', - borderRightColor: 'rgb(36, 31, 26)', - borderBottomColor: 'rgb(36, 31, 26)', - borderLeftColor: 'rgb(36, 31, 26)', - outlineWidth: '0px', - outlineColor: 'rgb(36, 31, 26)', - borderRadius: '0px', - display: '', - visibility: 'visible', - fontFamily: 'IBM Plex Sans, Arial, sans-serif', - }; - return { - getComputedStyle(el) { - return { ...defaults, ...(el?._style || {}) }; - }, - }; - } - - it('skips non-rendered tags and hidden elements in the static DOM pass', () => { - const designSystem = sampleDesignSystem(); - const hiddenParent = makeElement('section', { attrs: { hidden: '' } }); - const elements = [ - makeElement('style', { - text: '.hidden { color: #ff00aa; font-family: Poppins; }', - style: { color: 'rgb(0, 0, 0)', fontFamily: 'Poppins, sans-serif' }, - }), - makeElement('script', { - text: 'const color = "#ff00aa";', - style: { color: 'rgb(0, 0, 0)', fontFamily: 'Poppins, sans-serif' }, - }), - makeElement('div', { - text: 'Hidden Drift', - parentElement: hiddenParent, - style: { color: 'rgb(255, 0, 170)', fontFamily: 'Poppins, sans-serif', borderRadius: '18px' }, - }), - makeElement('div', { - text: 'Display None Drift', - style: { display: 'none', color: 'rgb(255, 0, 170)', fontFamily: 'Poppins, sans-serif', borderRadius: '18px' }, - }), - makeElement('div', { - text: 'Visible Drift', - style: { color: 'rgb(255, 0, 170)', fontFamily: 'Poppins, sans-serif', borderRadius: '18px' }, - }), - ]; - const findings = collectStaticDesignSystemFindings( - { querySelectorAll: () => elements }, - makeWindow(), - '/tmp/page.html', - designSystem, - ); - const snippets = findings.map(item => item.snippet).join('\n'); - - assert.match(snippets, /Visible Drift/); - assert.doesNotMatch(snippets, /Hidden Drift/); - assert.doesNotMatch(snippets, /Display None Drift/); - assert.doesNotMatch(snippets, /\.hidden/); - assert.doesNotMatch(snippets, /const color/); - }); -}); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs deleted file mode 100644 index 0a264bf28..000000000 --- a/tests/detect-antipatterns-browser.test.mjs +++ /dev/null @@ -1,1373 +0,0 @@ -/** - * Puppeteer-backed fixture tests for browser-only detection rules. - * - * Some detection rules (cramped-padding, line-length, body-text-viewport-edge) - * need real browser layout — they read getBoundingClientRect and real - * getComputedStyle results that the static HTML/CSS engine intentionally - * does not invent. - * - * This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome - * via a temporary static HTTP server, so the fixtures can use absolute - * ', - '', - '', - ].join('\n'); - const cssDelimiters = [ - '', - '', - '', - ].join('\n'); - - for (const filePath of ['hero.astro', 'hero.vue', 'hero.svelte']) { - expect(detectText(htmlDelimiters, filePath).filter(r => r.antipattern === 'broken-image')).toHaveLength(1); - expect(detectText(cssDelimiters, filePath).filter(r => r.antipattern === 'broken-image')).toHaveLength(1); - } - }); - - test('ignores preprocessor line comments in stylesheets', () => { - const source = '// font-family: Inter\n.hero { color: red; }'; - - for (const filePath of ['hero.scss', 'hero.sass', 'hero.less']) { - expect(detectText(source, filePath).filter(r => r.antipattern === 'overused-font')).toHaveLength(0); - } - }); - - test('still detects live font-family after a preprocessor line comment', () => { - const source = '// skip this\n.hero { font-family: Inter; }'; - - const findings = detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font'); - expect(findings).toHaveLength(1); - expect(findings[0].line).toBe(2); - }); - - test('does not blank https URLs in SCSS', () => { - const source = '.hero { background: url(https://example.com/i.png); }\n.hero { font-family: Inter; }'; - - const findings = detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font'); - expect(findings).toHaveLength(1); - expect(findings[0].line).toBe(2); - }); - - test('ignores frontmatter comments after a --- line inside a template literal', () => { - const source = [ - '---', - 'const md = `', - '---', - '`;', - '// Comment-only image', - '---', - '
ok
', - ].join('\n'); - - expect(detectText(source, 'hero.astro').filter(r => r.antipattern === 'broken-image')).toHaveLength(0); - }); - - test('ignores preprocessor line comments in component style blocks', () => { - const source = [ - '', - ].join('\n'); - - for (const filePath of ['hero.astro', 'hero.vue', 'hero.svelte']) { - expect(detectText(source, filePath).filter(r => r.antipattern === 'overused-font')).toHaveLength(0); - } - }); - - test('still detects live font-family after a style-block line comment', () => { - const source = [ - '', - ].join('\n'); - - const findings = detectText(source, 'hero.vue').filter(r => r.antipattern === 'overused-font'); - expect(findings).toHaveLength(1); - expect(findings[0].line).toBe(3); - }); - - test('ignores frontmatter comments after a regex literal that contains quotes', () => { - const source = [ - '---', - 'const re = /["\']/;', - '// Comment-only image', - '---', - '
ok
', - ].join('\n'); - - expect(detectText(source, 'hero.astro').filter(r => r.antipattern === 'broken-image')).toHaveLength(0); - }); - - test('keeps live font-family after a protocol-relative URL in SCSS', () => { - const sources = [ - '.hero { background: url( //cdn.example.com/i.png); font-family: Inter; }', - '.hero { background: url(#{$prefix}//cdn.example.com/i.png); font-family: Inter; }', - ]; - - for (const source of sources) { - expect(detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font')).toHaveLength(1); - } - expect(detectText( - '.hero { background: url(@{prefix}//cdn.example.com/i.png); font-family: Inter; }', - 'hero.less', - ).filter(r => r.antipattern === 'overused-font')).toHaveLength(1); - }); -}); - -describe('detectText — CSS borders', () => { - test('detects border-left shorthand', () => { - const f = detectText('.card { border-left: 4px solid #3b82f6; }', 'test.css'); - expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); - }); - - test('detects border-left shorthand in Sass', () => { - const f = detectText(".card\n border-left: 4px solid #3b82f6", 'test.sass'); - expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); - }); - - test('ignores neutral border', () => { - const f = detectText('.card { border-left: 4px solid #e5e7eb; }', 'test.css'); - expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); - }); - - test('skips blockquote', () => { - const f = detectText('
', 'test.html'); - expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); - }); -}); - -describe('detectText — overused fonts', () => { - test('detects Inter', () => { - const f = detectText("body { font-family: 'Inter', sans-serif; }", 'test.css'); - expect(f.some(r => r.antipattern === 'overused-font')).toBe(true); - }); - - test('detects Fraunces (current AI-default monoculture)', () => { - const f = detectText("h1 { font-family: 'Fraunces', Georgia, serif; }", 'test.css'); - expect(f.some(r => r.antipattern === 'overused-font')).toBe(true); - }); - - test('detects Geist (Vercel-default monoculture)', () => { - const f = detectText("body { font-family: 'Geist', sans-serif; }", 'test.css'); - expect(f.some(r => r.antipattern === 'overused-font')).toBe(true); - }); - - test('does not flag distinctive fonts', () => { - const f = detectText("body { font-family: 'Karla', sans-serif; }", 'test.css'); - expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0); - }); - - test('detects overused Google Fonts css2 family after first family param', () => { - const page = pageWithGoogleFonts('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300&family=Inter:wght@400;500;600&display=swap'); - const f = detectText(page, 'index.html'); - expect(f.some(r => r.antipattern === 'overused-font' && /Inter/i.test(r.snippet))).toBe(true); - }); - - test('does not flag single-font for combined Google Fonts css2 families', () => { - const page = pageWithGoogleFonts('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300&family=Jost:wght@300;400;500&display=swap'); - const f = detectText(page, 'index.html'); - expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0); - }); - - test('keeps legacy Google Fonts css pipe-separated families multi-font', () => { - const page = pageWithGoogleFonts('https://fonts.googleapis.com/css?family=Cormorant+Garamond|Jost&display=swap'); - const f = detectText(page, 'index.html'); - expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0); - }); - - test('page typography parses repeated Google Fonts css2 family params', () => { - const f = pageTypographyForGoogleFonts('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300&family=Inter:wght@400;500;600&display=swap'); - expect(f.some(r => r.id === 'overused-font' && /inter/i.test(r.snippet))).toBe(true); - expect(f.filter(r => r.id === 'single-font')).toHaveLength(0); - }); -}); - -describe('detectHtml — overused fonts system stack', () => { - test('Inter before a system stack still flags overused-font', async () => { - const page = `

Hello

world

`; - await withStaticFixture({ 'index.html': page }, async ({ file }) => { - const f = await detectHtml(file); - expect(f.some(r => r.antipattern === 'overused-font' && /inter/i.test(r.snippet))).toBe(true); - }); - }); - - test('checkPageTypography regex path skips Roboto in system stack', () => { - const html = `

Hello

world

`; - const doc = { - styleSheets: [], - documentElement: { outerHTML: html }, - querySelectorAll() { return []; }, - }; - const win = { getComputedStyle() { return { fontSize: '16px' }; } }; - const f = checkPageTypography(doc, win); - expect(f.filter(r => r.id === 'overused-font')).toHaveLength(0); - }); -}); - -describe('detectText — flat type hierarchy', () => { - test('flags sizes too close together', () => { - const page = ''; - const f = detectText(page, 'test.html'); - expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true); - }); - - test('passes good hierarchy', () => { - const page = ''; - const f = detectText(page, 'test.html'); - expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0); - }); -}); - -// Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test) - -// --------------------------------------------------------------------------- -// Full page vs partial detection -// --------------------------------------------------------------------------- - -describe('isFullPage', () => { - test('detects DOCTYPE', () => expect(isFullPage('')).toBe(true)); - test('detects ', () => expect(isFullPage('')).toBe(true)); - test('detects ', () => expect(isFullPage('')).toBe(true)); - test('rejects component/partial', () => expect(isFullPage('
content
')).toBe(false)); - test('rejects JSX', () => expect(isFullPage('export default function Card() { return
hi
}')).toBe(false)); -}); - -describe('partials skip page-level checks', () => { - test('regex: partial with flat hierarchy is not flagged', () => { - const partial = '
text
\n
text
\n
text
'; - const f = detectText(partial, 'card.tsx'); - expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0); - }); - - test('regex: partial with single overused font is not flagged for single-font', () => { - const partial = `
text
\n`.repeat(25); - const f = detectText(partial, 'card.tsx'); - expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0); - }); - - test('regex: partial still flags border anti-patterns', () => { - const partial = '
card
'; - const f = detectText(partial, 'card.tsx'); - expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); - }); - - test('regex: full page with flat hierarchy IS flagged', () => { - const page = '\n' + - '

h1

\n

h2

\n' + - '

p

\ns\n' + - 'sm\n'; - const f = detectText(page, 'index.html'); - expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true); - }); -}); - -describe('detectText — numeric content', () => { - test('does not infer section scaffolding from raw numeric sequences', () => { - const page = '' + - '
  1. 01 — Glassline — 03:11
  2. ' + - '
  3. 02 — Tidal Memory — 04:08
  4. ' + - '
  5. 03 — Pale Signal — 05:02
' + - ''; - const f = detectText(page, 'test.html'); - expect(f.some(r => r.antipattern === 'numbered-section-markers')).toBe(false); - }); - - test('does not infer section scaffolding from JS source with embedded numbers', () => { - const source = ` - const shell = 'Preview'; - const palette = 'oklch(86% 0.07 84 / 0.08)'; - const shadow = '0 0 0 1px oklch(0% 0 0 / 0.04), 0 4px 16px oklch(0% 0 0 / 0.05), 0 1px 3px oklch(0% 0 0 / 0.06)'; - const size = '11.5px'; - const eye = ''; - const shader = 'float band = bandAt(uv.y - y, 0.05, 0.32);'; - const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; - `; - const f = detectText(source, 'live-browser.js'); - expect(f.filter(r => r.antipattern === 'numbered-section-markers')).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Layout anti-patterns -// --------------------------------------------------------------------------- - -describe('detectHtml — layout', () => { - test('detects monotonous spacing via regex', () => { - // A page where every padding/margin is 16px - const html = '' + - '

a

'.repeat(5) + - ''; - const f = detectText(html, 'test.html'); - expect(f.some(r => r.antipattern === 'monotonous-spacing')).toBe(true); - }); - -}); - -// --------------------------------------------------------------------------- -// Text overflow screen-reader-only handling -// --------------------------------------------------------------------------- - -describe('checkElementTextOverflowDOM', () => { - function baseTextStyle(overrides = {}) { - return { - position: 'static', - width: '160px', - height: '20px', - overflow: 'visible', - overflowX: 'visible', - overflowY: 'visible', - clipPath: 'none', - clip: 'auto', - ...overrides, - }; - } - - function mockTextElement({ - className = 'flag-overflow', - style = baseTextStyle(), - clientWidth = 24, - clientHeight = 20, - scrollWidth = 80, - rectWidth = clientWidth, - rectHeight = clientHeight, - } = {}) { - return { - tagName: 'DIV', - className, - childNodes: [{ nodeType: 3, textContent: 'A long accessible label that overflows its box' }], - parentElement: null, - clientWidth, - clientHeight, - scrollWidth, - __style: style, - getAttribute(name) { - return name === 'class' ? className : null; - }, - getBoundingClientRect() { - return { width: rectWidth, height: rectHeight }; - }, - }; - } - - function withMockComputedStyle(callback) { - const original = globalThis.getComputedStyle; - globalThis.getComputedStyle = (el) => el.__style; - try { - return callback(); - } finally { - if (original === undefined) delete globalThis.getComputedStyle; - else globalThis.getComputedStyle = original; - } - } - - test('classifies clip-path sr-only text as visually hidden', () => { - expect(isScreenReaderOnlyTextStyle(baseTextStyle({ - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - overflowX: 'hidden', - overflowY: 'hidden', - clipPath: 'inset(50%)', - }), { width: 1, height: 1 })).toBe(true); - }); - - test('classifies legacy clip rect sr-only text as visually hidden', () => { - expect(isScreenReaderOnlyTextStyle(baseTextStyle({ - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - overflowX: 'hidden', - overflowY: 'hidden', - clip: 'rect(0, 0, 0, 0)', - }), { width: 1, height: 1 })).toBe(true); - }); - - test('classifies tiny absolute overflow-hidden text as visually hidden without clip', () => { - expect(isScreenReaderOnlyTextStyle(baseTextStyle({ - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - overflowX: 'hidden', - overflowY: 'hidden', - }), { width: 1, height: 1 })).toBe(true); - }); - - test('classifies fully clipped text as visually hidden without tiny sizing', () => { - expect(isScreenReaderOnlyTextStyle(baseTextStyle({ - position: 'absolute', - width: '160px', - height: '20px', - overflow: 'visible', - clipPath: 'inset(50%)', - }), { width: 160, height: 20 })).toBe(true); - }); - - test('flags visible overflowing text', () => { - const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(mockTextElement())); - - expect(findings).toHaveLength(1); - expect(findings[0].id).toBe('text-overflow'); - expect(findings[0].snippet).toContain('.flag-overflow'); - }); - - test('skips overflowing sr-only text', () => { - const srOnly = mockTextElement({ - className: 'pass-sr-only-clip-path', - style: baseTextStyle({ - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - overflowX: 'hidden', - overflowY: 'hidden', - clipPath: 'inset(50%)', - }), - clientWidth: 1, - clientHeight: 1, - scrollWidth: 240, - rectWidth: 1, - rectHeight: 1, - }); - - const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(srOnly)); - - expect(findings).toHaveLength(0); - }); - - test('does not classify tiny visible text as sr-only', () => { - const style = baseTextStyle({ - position: 'absolute', - width: '1px', - height: '1px', - }); - - expect(isScreenReaderOnlyTextStyle(style, { width: 1, height: 1 })).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// Motion anti-patterns -// --------------------------------------------------------------------------- - -describe('checkElementMotion', () => { - function mockStyle(overrides) { - return { transitionProperty: '', animationName: 'none', animationTimingFunction: '', transitionTimingFunction: '', ...overrides }; - } - - test('detects bounce animation name', () => { - const f = checkElementMotion('div', mockStyle({ animationName: 'bounce' })); - expect(f.some(r => r.id === 'bounce-easing')).toBe(true); - }); - - test('detects elastic animation name', () => { - const f = checkElementMotion('div', mockStyle({ animationName: 'elastic-in' })); - expect(f.some(r => r.id === 'bounce-easing')).toBe(true); - }); - - test('detects overshoot cubic-bezier in animation timing', () => { - const f = checkElementMotion('div', mockStyle({ - animationTimingFunction: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', - })); - expect(f.some(r => r.id === 'bounce-easing')).toBe(true); - }); - - test('detects overshoot cubic-bezier in transition timing', () => { - const f = checkElementMotion('div', mockStyle({ - transitionTimingFunction: 'cubic-bezier(0.34, 1.56, 0.64, 1)', - })); - expect(f.some(r => r.id === 'bounce-easing')).toBe(true); - }); - - test('passes standard ease-out-quart', () => { - const f = checkElementMotion('div', mockStyle({ - transitionTimingFunction: 'cubic-bezier(0.25, 1, 0.5, 1)', - })); - expect(f.filter(r => r.id === 'bounce-easing')).toHaveLength(0); - }); - - test('passes standard ease', () => { - const f = checkElementMotion('div', mockStyle({ - transitionTimingFunction: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', - })); - expect(f.filter(r => r.id === 'bounce-easing')).toHaveLength(0); - }); - - test('detects width transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'width' })); - expect(f.some(r => r.id === 'layout-transition')).toBe(true); - }); - - test('detects height transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'height' })); - expect(f.some(r => r.id === 'layout-transition')).toBe(true); - }); - - test('detects padding transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'padding' })); - expect(f.some(r => r.id === 'layout-transition')).toBe(true); - }); - - test('detects margin transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'margin' })); - expect(f.some(r => r.id === 'layout-transition')).toBe(true); - }); - - test('detects max-height transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'max-height' })); - expect(f.some(r => r.id === 'layout-transition')).toBe(true); - }); - - test('detects layout prop among mixed transitions', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'opacity, width, color' })); - expect(f.some(r => r.id === 'layout-transition')).toBe(true); - }); - - test('passes transform transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'transform' })); - expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0); - }); - - test('passes opacity transition', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'opacity' })); - expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0); - }); - - test('skips transition: all', () => { - const f = checkElementMotion('div', mockStyle({ transitionProperty: 'all' })); - expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0); - }); - - test('skips safe tags', () => { - const f = checkElementMotion('button', mockStyle({ - animationName: 'bounce', transitionProperty: 'width', - })); - expect(f).toHaveLength(0); - }); -}); - -describe('detectText — motion', () => { - test('detects animate-bounce Tailwind class', () => { - const f = detectText('
loading
', 'test.html'); - expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true); - }); - - test('detects animation: bounce CSS', () => { - const f = detectText('.icon { animation: bounce-ball 1s infinite; }', 'test.css'); - const finding = f.find(r => r.antipattern === 'bounce-easing'); - expect(finding).toBeTruthy(); - expect(finding.snippet).toBe('animation: bounce-ball'); - }); - - test('detects animation-name: elastic', () => { - const f = detectText('.card { animation-name: elastic; }', 'test.css'); - expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true); - }); - - test('detects overshoot cubic-bezier', () => { - const f = detectText('.btn { transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); }', 'test.css'); - expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true); - }); - - test('passes standard cubic-bezier', () => { - const f = detectText('.btn { transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1); }', 'test.css'); - expect(f.filter(r => r.antipattern === 'bounce-easing')).toHaveLength(0); - }); - - test('detects transition: width', () => { - const f = detectText('.sidebar { transition: width 0.3s ease; }', 'test.css'); - expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true); - }); - - test('detects transition: height', () => { - const f = detectText('.panel { transition: height 0.4s ease-out; }', 'test.css'); - expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true); - }); - - test('detects transition: max-height', () => { - const f = detectText('.accordion { transition: max-height 0.5s ease; }', 'test.css'); - expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true); - }); - - test('detects transition-property: width', () => { - const f = detectText('.box { transition-property: width; transition-duration: 0.3s; }', 'test.css'); - expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true); - }); - - test('skips transition: all', () => { - const f = detectText('.card { transition: all 0.3s ease; }', 'test.css'); - expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0); - }); - - test('skips transition: transform', () => { - const f = detectText('.card { transition: transform 0.3s ease; }', 'test.css'); - expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0); - }); - - test('skips transition: opacity', () => { - const f = detectText('.btn { transition: opacity 0.2s ease; }', 'test.css'); - expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0); - }); - - test('passes JSX quoted paint-only transition with later layout prop', () => { - const f = detectText("
", 'test.jsx'); - expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0); - }); - - test('passes JSX grid-template-rows transition with later padding and width', () => { - const f = detectText("
", 'test.jsx'); - expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0); - }); - - test('detects JSX quoted width transition', () => { - const f = detectText("
", 'test.jsx'); - expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true); - }); - - test('skips JSX quoted transition: all', () => { - const f = detectText("
", 'test.jsx'); - expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Dark glow anti-pattern -// --------------------------------------------------------------------------- - -describe('checkElementGlow', () => { - function mockStyle(overrides) { - return { boxShadow: 'none', backgroundColor: '', ...overrides }; - } - - // Dark bg = luminance < 0.1 (e.g. #111827 = gray-900) - const darkBg = { r: 17, g: 24, b: 39 }; // #111827 - const lightBg = { r: 249, g: 250, b: 251 }; // #f9fafb - const mediumBg = { r: 107, g: 114, b: 128 }; // #6b7280 - - test('detects blue glow on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('detects purple glow on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(139, 92, 246, 0.35) 0px 0px 25px 0px', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('detects glow in multi-shadow', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(0, 0, 0, 0.3) 0px 4px 6px 0px, rgba(168, 85, 247, 0.3) 0px 0px 30px 0px', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('passes gray shadow on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(0, 0, 0, 0.4) 0px 4px 12px 0px', - }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('detects zero-offset colored halo on light background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px', - }), lightBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('detects zero-offset colored halo on medium gray background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.5) 0px 0px 20px 0px', - }), mediumBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('passes offset colored drop shadow on light background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.4) 0px 8px 20px 0px', - }), lightBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('passes achromatic zero-offset shadow on light background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(0, 0, 0, 0.15) 0px 0px 24px 0px', - }), lightBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('detects oklch glow on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: '0 0 12px oklch(0.85 0.12 200 / 0.5)', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('detects oklch zero-offset glow on light background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'oklch(0.65 0.2 300 / 0.45) 0px 0px 20px 0px', - }), lightBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('passes achromatic oklch glow (white halo) on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: '0 0 12px oklch(1 0 0 / .7)', - }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('detects hex glow on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: '0 0 16px #3b82f6', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('detects hsl glow on dark background', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: '0 0 22px hsl(280, 80%, 60%)', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('skips unresolvable var() shadow color instead of guessing', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: '0 0 10px var(--ok)', - }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('detects chromatic text-shadow glow on any background', () => { - const f = checkElementGlow('h1', mockStyle({ - textShadow: 'rgb(34, 211, 238) 0px 0px 12px', - }), lightBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); - - test('passes offset neutral text-shadow', () => { - const f = checkElementGlow('h1', mockStyle({ - textShadow: 'rgba(0, 0, 0, 0.6) 0px 1px 2px', - }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('passes focus ring (spread only, no blur)', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.5) 0px 0px 0px 3px', - }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('passes subtle shadow (blur < 5px)', () => { - const f = checkElementGlow('div', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.2) 0px 1px 3px 0px', - }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('passes no shadow', () => { - const f = checkElementGlow('div', mockStyle({ boxShadow: 'none' }), darkBg); - expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0); - }); - - test('detects glow on buttons (not skipped by safe tags)', () => { - const f = checkElementGlow('button', mockStyle({ - boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px', - }), darkBg); - expect(f.some(r => r.id === 'dark-glow')).toBe(true); - }); -}); - -describe('detectText — dark glow', () => { - test('detects colored box-shadow glow on dark background', () => { - const html = '
glow
'; - const f = detectText(html, 'test.html'); - expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true); - }); - - test('skips gray shadow on dark background', () => { - const html = '
shadow
'; - const f = detectText(html, 'test.html'); - expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0); - }); - - test('detects zero-offset colored halo on light page', () => { - const html = '
glow
'; - const f = detectText(html, 'test.html'); - expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true); - }); - - test('skips offset colored drop shadow on light page', () => { - const html = '
shadow
'; - const f = detectText(html, 'test.html'); - expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0); - }); - - test('detects oklch glow on dark oklch page', () => { - const html = '
glow
'; - const f = detectText(html, 'test.html'); - expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true); - }); - - test('resolves single-level var() shadow colors', () => { - const html = '
lamp
'; - const f = detectText(html, 'test.html'); - expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true); - }); - - test('skips unresolvable var() shadow colors', () => { - const html = '
lamp
'; - const f = detectText(html, 'test.html'); - expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0); - }); - - test('detects chromatic text-shadow glow', () => { - const html = '

glow

'; - const f = detectText(html, 'test.html'); - expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true); - }); - - test('skips achromatic zero-offset halo (soft elevation)', () => { - const html = '
card
'; - const f = detectText(html, 'test.html'); - expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Static HTML/CSS engine -// --------------------------------------------------------------------------- - -describe('detectHtml — static HTML/CSS engine', () => { - test('inlines local linked stylesheets', async () => { - const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html')); - expect(findingIds(f)).toContain('side-tab'); - }); - - test('resolves root-relative linked stylesheets with cache-busting query', async () => { - await withStaticFixture({ - 'index.html': ` - -
Card
`, - 'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', - }, async ({ file }) => { - const f = await detectHtml(file); - expect(findingIds(f)).toContain('side-tab'); - }); - }); - - test('resolves root-relative linked stylesheets from nested pages via ancestor walk', async () => { - await withStaticFixture({ - 'pages/about.html': ` - -
Card
`, - 'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', - }, async ({ dir }) => { - const f = await detectHtml(path.join(dir, 'pages', 'about.html')); - expect(findingIds(f)).toContain('side-tab'); - }); - }); - - test('does not resolve root-relative sheets above the project root', async () => { - await withStaticFixture({ - 'project/package.json': '{}', - 'project/index.html': ` - -
Card
`, - 'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', - }, async ({ dir }) => { - const f = await detectHtml(path.join(dir, 'project', 'index.html')); - expect(findingIds(f)).not.toContain('side-tab'); - }); - }); - - test('does not follow root-relative .. segments out of the page directory', async () => { - await withStaticFixture({ - 'project/package.json': '{}', - 'project/index.html': ` - -
Card
`, - 'outside.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', - }, async ({ dir }) => { - const f = await detectHtml(path.join(dir, 'project', 'index.html')); - expect(findingIds(f)).not.toContain('side-tab'); - }); - }); - - test('warns when a linked stylesheet cannot be read', async () => { - const writes = []; - const origWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = (chunk, ...args) => { - writes.push(String(chunk)); - return origWrite(chunk, ...args); - }; - try { - await withStaticFixture({ - 'index.html': ` - -
Page
`, - }, async ({ file, dir }) => { - await detectHtml(file); - await detectHtml(file); - const msg = writes.join(''); - const hits = msg.split('could not read linked stylesheet /missing/app.css').length - 1; - expect(hits).toBe(2); - expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`); - }); - } finally { - process.stderr.write = origWrite; - } - }); - - test('gradient-text: a style="" attribute alone carries the page-level flag', async () => { - await withStaticFixture({ - 'index.html': `t -

Inline gradient heading

-

Body copy long enough to make this a real page for the scanners.

- `, - }, async ({ file }) => { - const f = await detectHtml(file); - expect(f.some(r => r.antipattern === 'gradient-text' && /background-clip/.test(r.snippet))).toBe(true); - }); - }); - - test('gradient-text: a -

Styled gradient heading

-

Body copy long enough to make this a real page for the scanners.

- `, - }, async ({ file }) => { - const f = await detectHtml(file); - expect(f.some(r => r.antipattern === 'gradient-text' && /background-clip/.test(r.snippet))).toBe(true); - }); - }); - - test('gradient-text: prose and code samples about the pattern do not flag', async () => { - await withStaticFixture({ - 'index.html': `changelog -

Changelog

-

background-clip: text gradients stop tripping the contrast rules.

-
.hero { background: linear-gradient(135deg, #667eea, #764ba2); background-clip: text; }
-

Pair bg-clip-text with bg-gradient-to-r and both utilities together are the tell.

- `, - }, async ({ file }) => { - const f = await detectHtml(file); - expect(f.filter(r => r.antipattern === 'gradient-text')).toHaveLength(0); - expect(f.filter(r => r.antipattern === 'ai-color-palette')).toHaveLength(0); - }); - }); - - test('flattens @layer, resolves CSS variables and fallbacks, and skips unsupported selectors', async () => { - await withStaticFixture({ - 'index.html': ` - - - - - -
Layer variable side tab
-
Fallback variable top accent
- - `, - }, async ({ file }) => { - const profile = []; - const f = await detectHtml(file, { profile }); - const ids = findingIds(f); - expect(ids).toContain('side-tab'); - expect(ids).toContain('border-accent-on-rounded'); - expect(profile.some(e => e.engine === 'static-html' && e.ruleId === 'unsupported-selector')).toBe(true); - }); - }); - - test('honors specificity, source order, !important, and inline style precedence', async () => { - await withStaticFixture({ - 'index.html': ` - - - - - -
Specificity neutral pass
-
Source order chromatic flag
-
Important neutral pass
-
Inline chromatic flag
- - `, - }, async ({ file }) => { - const f = await detectHtml(file); - expect(findingIds(f).filter(id => id === 'side-tab')).toHaveLength(2); - }); - }); - - test('expands background, border, font, transition, and animation shorthands', async () => { - await withStaticFixture({ - 'index.html': ` - - - - - -

This tiny paragraph is long enough to trigger both the static font shorthand size and line-height checks.

- -
Border shorthand side tab
-
Motion shorthand easing
- - `, - }, async ({ file }) => { - const ids = findingIds(await detectHtml(file)); - expect(ids).toContain('tiny-text'); - expect(ids).toContain('tight-leading'); - expect(ids).toContain('low-contrast'); - expect(ids).toContain('side-tab'); - expect(ids).toContain('bounce-easing'); - expect(ids).toContain('layout-transition'); - }); - }); -}); - -describe('StaticDocument.closest — compiled selector cache', () => { - test('compiles each selector once per document', () => { - let compileCount = 0; - const compile = (sel) => { - compileCount++; - return cssSelect.compile(sel); - }; - const root = htmlparser2.parseDocument( - '
' + - '
deep
' + - '
deep2
', - ); - const doc = new StaticDocument(root, { - selectAll: cssSelect.selectAll, - selectOne: cssSelect.selectOne, - compile, - domutils, - }); - const deep = doc.querySelectorAll('span')[0]; - const deep2 = doc.querySelectorAll('span')[1]; - expect(deep.closest('.target-ancestor').node.attribs.class).toBe('target-ancestor'); - deep.closest('.target-ancestor'); - deep2.closest('.target-ancestor'); - expect(compileCount).toBe(1); - }); - - test('invalid selector returns null on repeat calls', () => { - const root = htmlparser2.parseDocument('

x

'); - const doc = new StaticDocument(root, { - selectAll: cssSelect.selectAll, - selectOne: cssSelect.selectOne, - compile: cssSelect.compile, - domutils, - }); - const p = doc.querySelector('p'); - expect(p.closest('p:has-invalid(')).toBeNull(); - expect(p.closest('p:has-invalid(')).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Side-tab as absolutely-positioned pseudo-element stripe -// --------------------------------------------------------------------------- - -describe('side-tab — pseudo-element stripe variant', () => { - test('fixture flags both stripe variants and nothing else', async () => { - const f = await detectHtml(path.join(FIXTURES, 'pseudo-stripe.html')); - const stripes = f.filter(r => r.antipattern === 'side-tab'); - const snippets = stripes.map(r => r.snippet).join(' | '); - expect(stripes).toHaveLength(2); - expect(snippets).toContain('.card-stripe::before'); - expect(snippets).toContain('.row-stripe::after'); - }); - - test('detects ::before stripe with var() background resolved to chromatic', () => { - const css = ` - :root { --accent: oklch(0.78 0.145 155); } - .hero::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--accent); } - `; - const f = scanCssTextForPseudoStripe(css); - expect(f).toHaveLength(1); - expect(f[0].id).toBe('side-tab'); - expect(f[0].snippet).toContain('.hero::before'); - }); - - test('detects height:100% + right:0 variant', () => { - const css = '.card::after { position: absolute; right: 0; top: 0; height: 100%; width: 4px; background: #3b82f6; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(1); - }); - - test('detects floating stripe inset a few px from each end', () => { - // The evasion shape from human review: same left-edge accent bar, but - // backed off the card's corners by a small top/bottom inset so it never - // touches an edge (and needs no corner rounding to read as a side tab). - const css = '.row::before { content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px; border-radius: 3px; background: oklch(0.65 0.19 15); }'; - const f = scanCssTextForPseudoStripe(css); - expect(f).toHaveLength(1); - expect(f[0].id).toBe('side-tab'); - expect(f[0].snippet).toContain('(left: 0)'); - }); - - test('skips deeply-inset partial rail (not an edge-spanning stripe)', () => { - const css = '.rail::before { position: absolute; left: 0; top: 40px; bottom: 40px; width: 4px; background: #3b82f6; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('unresolvable custom-property color errs toward detection', () => { - const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--from-external-sheet); }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(1); - }); - - test('skips neutral hairline divider', () => { - const css = '.col::before { position: absolute; left: 0; top: 0; bottom: 0; width: 1px; background: rgba(0,0,0,0.08); }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('skips neutral 4px rail (chromatic gate)', () => { - const css = '.timeline::before { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: rgb(209, 213, 219); }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('skips 2px stripe below width threshold', () => { - const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: #3b82f6; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('skips blockquote pseudo decoration', () => { - const css = 'blockquote::before { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #d97706; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('skips non-edge-anchored pseudo (toggle knob)', () => { - const css = '.switch::before { position: absolute; left: 2px; top: 2px; width: 10px; height: 10px; background: #3b82f6; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('skips full-overlay pseudo (inset: 0, no narrow width)', () => { - const css = '.hero::after { position: absolute; inset: 0; background: #3b82f6; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - // Horizontal (top/bottom) stripe variant - test('detects top-anchored full-width pseudo stripe', () => { - const css = '.stat-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: #e04a3a; }'; - const f = scanCssTextForPseudoStripe(css); - expect(f).toHaveLength(1); - expect(f[0].snippet).toContain('(top: 0)'); - }); - - test('detects bottom-anchored width:100% pseudo stripe', () => { - const css = '.promo::after { content: ""; position: absolute; bottom: 0; left: 0; width: 100%; height: 5px; background: oklch(0.62 0.2 30); }'; - const f = scanCssTextForPseudoStripe(css); - expect(f).toHaveLength(1); - expect(f[0].snippet).toContain('(bottom: 0)'); - }); - - test('skips link/button underline affordances (horizontal variant)', () => { - const link = '.nav-link::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #e04a3a; }'; - const anchor = 'a.cta::after { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: #e04a3a; }'; - const btn = '.cta-btn::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #e04a3a; }'; - expect(scanCssTextForPseudoStripe(link)).toHaveLength(0); - expect(scanCssTextForPseudoStripe(anchor)).toHaveLength(0); - expect(scanCssTextForPseudoStripe(btn)).toHaveLength(0); - }); - - test('skips selected-state underlines, flags all-tabs underlines (horizontal variant)', () => { - const selectedTab = '[role="tab"][aria-selected="true"]::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }'; - const activeItem = '.tabs .item.active::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }'; - expect(scanCssTextForPseudoStripe(selectedTab)).toHaveLength(0); - expect(scanCssTextForPseudoStripe(activeItem)).toHaveLength(0); - // The same stripe on EVERY tab in the group is decoration, not state. - const allTabs = '.tabs .item::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }'; - const roleTab = '[role="tab"]::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }'; - expect(scanCssTextForPseudoStripe(allTabs)).toHaveLength(1); - expect(scanCssTextForPseudoStripe(roleTab)).toHaveLength(1); - }); - - test('skips hover-state underline affordance (horizontal variant)', () => { - const css = '.item:hover::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #e04a3a; }'; - expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); - }); - - test('skips 2px and 16px horizontal bars (thickness gates)', () => { - const thin = '.card::before { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: #e04a3a; }'; - const band = '.card::before { position: absolute; top: 0; left: 0; right: 0; height: 16px; background: #e04a3a; }'; - expect(scanCssTextForPseudoStripe(thin)).toHaveLength(0); - expect(scanCssTextForPseudoStripe(band)).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Low contrast — modern computed-color serializations (browser adapter path) -// --------------------------------------------------------------------------- - -describe('checkColors — oklch computed colors', () => { - test('flat dark-on-dark oklch CTA pair parses and fails contrast', () => { - // Real browsers hand back oklch() strings from getComputedStyle for - // colors authored in modern spaces; the adapters must not lose them. - const textColor = parseAnyColor('oklch(0.34 0.01 70)'); - const bgColor = parseAnyColor('oklch(0.22 0.01 70)'); - expect(textColor).toBeTruthy(); - expect(bgColor).toBeTruthy(); - const f = checkColors({ - tag: 'a', - textColor, - bgColor, - effectiveBg: bgColor, - effectiveBgStops: null, - fontSize: 14.4, - fontWeight: 500, - hasDirectText: true, - isEmojiOnly: false, - bgClip: '', - bgImage: '', - classList: 'btn btn-primary', - }); - expect(f.some(r => r.id === 'low-contrast')).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Numbered section labels — pure helpers -// --------------------------------------------------------------------------- - -describe('numbered-section-labels — pure helpers', () => { - test('parseNumberedLabelText accepts zero-padded and separator forms only', () => { - expect(parseNumberedLabelText('01')).toEqual({ index: 1, text: '01' }); - expect(parseNumberedLabelText('12')).toEqual({ index: 12, text: '12' }); - expect(parseNumberedLabelText('04 / rollout')).toMatchObject({ index: 4 }); - expect(parseNumberedLabelText('6 · getting started')).toMatchObject({ index: 6 }); - expect(parseNumberedLabelText('7')).toBeNull(); - expect(parseNumberedLabelText('Step 3')).toBeNull(); - expect(parseNumberedLabelText('12 minute read')).toBeNull(); - expect(parseNumberedLabelText('50% off everything')).toBeNull(); - expect(parseNumberedLabelText('')).toBeNull(); - }); - - test('checkNumberedSectionLabels needs 2+ candidates with 2+ distinct indices', () => { - const candidate = (index) => ({ index, labelText: String(index).padStart(2, '0'), headingTag: 'h2', headingText: 'Heading' }); - expect(checkNumberedSectionLabels({ candidates: [candidate(1)] })).toHaveLength(0); - expect(checkNumberedSectionLabels({ candidates: [candidate(1), candidate(1)] })).toHaveLength(0); - const flagged = checkNumberedSectionLabels({ candidates: [candidate(1), candidate(2)] }); - expect(flagged).toHaveLength(2); - expect(flagged[0].id).toBe('numbered-section-labels'); - }); -}); - -// --------------------------------------------------------------------------- -// Radial-gradient background halo -// --------------------------------------------------------------------------- - -describe('radial-halo', () => { - const darkRoot = 'body { background: oklch(0.085 0.020 262); }'; - - test('flags chromatic halo fading to transparent on a dark page', () => { - const css = `${darkRoot} body { background: radial-gradient(120% 80% at 50% -10%, oklch(0.240 0.045 268) 0%, transparent 55%), oklch(0.085 0.020 262); }`; - const f = scanCssTextForRadialHalo(css); - expect(f).toHaveLength(1); - expect(f[0].snippet).toContain('radial-gradient halo'); - }); - - test('skips achromatic vignette with no transparent stop', () => { - const css = `${darkRoot} body { background: radial-gradient(120% 90% at 50% -10%, oklch(0.19 0.02 264) 0%, oklch(0.075 0.01 262) 100%); }`; - expect(scanCssTextForRadialHalo(css)).toHaveLength(0); - }); - - test('skips panel sheen fading to an opaque surface color', () => { - const css = `${darkRoot} .hero { background: radial-gradient(120% 90% at 85% 0%, oklch(0.255 0.034 262), oklch(0.205 0.032 262) 60%); }`; - expect(scanCssTextForRadialHalo(css)).toHaveLength(0); - }); - - test('skips px-sized dot texture patterns', () => { - const css = `${darkRoot} .device::before { background-image: radial-gradient(oklch(1 0 0 / 0.018) 1px, transparent 1.4px); }`; - expect(scanCssTextForRadialHalo(css)).toHaveLength(0); - }); - - test('skips translucent light-scene washes (inner alpha below 0.7)', () => { - const css = `${darkRoot} .hero .light { background: radial-gradient(closest-side, oklch(0.62 0.10 255 / 0.55), oklch(0.42 0.09 258 / 0.22) 45%, transparent 72%); }`; - expect(scanCssTextForRadialHalo(css)).toHaveLength(0); - }); - - test('skips halos on light pages', () => { - const css = 'body { background: #faf7f2; } .hero { background: radial-gradient(60% 40% at 50% 0%, #7c3aed 0%, transparent 70%); }'; - expect(scanCssTextForRadialHalo(css)).toHaveLength(0); - }); - - test('skips declarations that include photographic url() layers', () => { - const css = `${darkRoot} .hero { background: url(cover.jpg), radial-gradient(60% 40% at 50% 0%, #7c3aed 0%, transparent 70%); }`; - expect(scanCssTextForRadialHalo(css)).toHaveLength(0); - }); - - test('resolves var() color stops', () => { - const css = `:root { --glow: oklch(0.5 0.18 300); } ${darkRoot} .bg { background: radial-gradient(80% 60% at 50% 0%, var(--glow) 0%, transparent 60%); }`; - expect(scanCssTextForRadialHalo(css)).toHaveLength(1); - }); -}); - -// --------------------------------------------------------------------------- -// Hover-state contrast + color-mix parsing -// --------------------------------------------------------------------------- - -describe('hover contrast + color-mix', () => { - test('parseColorMix: mix with transparent keeps color, scales alpha', () => { - const c = parseColorMix('color-mix(in oklab, rgb(230, 68, 37) 16%, transparent)'); - expect(c.r).toBe(230); - expect(c.g).toBe(68); - expect(c.b).toBe(37); - expect(c.a).toBeCloseTo(0.16, 2); - }); - - test('parseColorMix: 50/50 opaque mix averages channels', () => { - const c = parseColorMix('color-mix(in srgb, rgb(0, 0, 0), rgb(255, 255, 255))'); - expect(c.a).toBe(1); - expect(Math.abs(c.r - 128)).toBeLessThanOrEqual(1); - }); - - test('parseAnyColor routes color-mix expressions', () => { - const c = parseAnyColor('color-mix(in oklab, oklch(0.625 0.205 33) 16%, transparent)'); - expect(c).not.toBeNull(); - expect(c.a).toBeCloseTo(0.16, 2); - }); - - // Every expected value below is what Chrome itself paints for that string - // (read back from a 1x1 canvas), so the parser is pinned to the browser it - // has to agree with rather than to my arithmetic. - describe('parseAnyColor — the color syntaxes a browser reports verbatim', () => { - const cases = [ - ['oklch(0.84 0.19 80.46)', [255, 186, 0]], - ['oklch(84% 0.19 80.46)', [255, 186, 0]], - ['oklch(1 0 0)', [255, 255, 255]], - ['oklch(0 0 0)', [0, 0, 0]], - // Chroma far outside the sRGB gamut must clamp, never produce NaN. - ['oklch(0.62 0.4 30)', [255, 0, 0]], - ['color(srgb 0.1 0.11 0.12)', [26, 28, 31]], - // Chrome's serialization of a color-mix in srgb routinely lands outside - // 0..1 on one or more channels. - ['color(srgb 1.04084 0.728032 -0.213551)', [255, 186, 0]], - ['color(srgb-linear 0.5 0.5 0.5)', [188, 188, 188]], - ['color(display-p3 0.9 0.8 0.2)', [235, 203, 0]], - ['color(display-p3 1 0 0)', [255, 0, 0]], - ['lch(20 5 60)', [54, 47, 42]], - ['lab(50 40 -30)', [165, 91, 171]], - ['lab(100 0 0)', [255, 255, 255]], - ['lab(0 0 0)', [0, 0, 0]], - ]; - for (const [input, [r, g, b]] of cases) { - test(`${input} -> rgb(${r}, ${g}, ${b})`, () => { - const c = parseAnyColor(input); - expect(c).not.toBeNull(); - expect(Math.abs(c.r - r)).toBeLessThanOrEqual(1); - expect(Math.abs(c.g - g)).toBeLessThanOrEqual(1); - expect(Math.abs(c.b - b)).toBeLessThanOrEqual(1); - expect(c.a).toBe(1); - }); - } - - test('carries the alpha channel through color() and lch()', () => { - expect(parseAnyColor('color(srgb 0.1 0.11 0.12 / 0.4)').a).toBeCloseTo(0.4, 3); - expect(parseAnyColor('lch(20 5 60 / 25%)').a).toBeCloseTo(0.25, 3); - }); - - test('returns null for color spaces it does not model, so callers abstain', () => { - expect(parseAnyColor('color(rec2020 0.5 0.2 0.1)')).toBeNull(); - expect(parseAnyColor('color(--custom-profile 0.2 0.3 0.4)')).toBeNull(); - }); - }); - - test('parseGradientColors reads stops written in modern color syntax', () => { - const stops = parseGradientColors('linear-gradient(oklch(0.07 0.006 95), oklch(0.11 0.008 95))'); - expect(stops).toHaveLength(2); - expect(stops[0].r).toBeLessThan(10); - expect(stops[1].r).toBeLessThan(20); - }); - - test('parseGradientColors ignores the interpolation-space hint', () => { - const stops = parseGradientColors('linear-gradient(in oklab, rgb(0, 0, 0), rgb(255, 255, 255))'); - expect(stops).toHaveLength(2); - }); - - test('parseGradientColors resolves color-mix stops without leaking nested hex', () => { - const stops = parseGradientColors('linear-gradient(135deg, color-mix(in srgb, #2d5a4a 92%, #000), color-mix(in srgb, #1a3d32 90%, #000))'); - expect(stops).toHaveLength(2); - expect(stops[0]).toEqual({ r: 41, g: 83, b: 68, a: 1 }); - expect(stops[1]).toEqual({ r: 23, g: 55, b: 45, a: 1 }); - }); - - test('parseGradientColors does not leak nested hex when color-mix has var()', () => { - const stops = parseGradientColors('linear-gradient(135deg, color-mix(in srgb, var(--brand) 92%, #000), color-mix(in srgb, var(--brand-deep) 90%, #000))'); - expect(stops).toEqual([]); - }); - - test('parseGradientColors still collects sibling bare hex stops beside color-mix', () => { - const stops = parseGradientColors('linear-gradient(color-mix(in srgb, #2d5a4a 92%, #000), #ffffff)'); - expect(stops).toHaveLength(2); - expect(stops[0]).toEqual({ r: 41, g: 83, b: 68, a: 1 }); - expect(stops[1]).toEqual({ r: 255, g: 255, b: 255, a: 1 }); - }); - - test('parseGradientColors still reads bare hex gradient stops', () => { - const stops = parseGradientColors('linear-gradient(#2d5a4a, #000)'); - expect(stops).toHaveLength(2); - expect(stops[0]).toEqual({ r: 45, g: 90, b: 74, a: 1 }); - expect(stops[1]).toEqual({ r: 0, g: 0, b: 0, a: 1 }); - }); - - test('checkHoverContrast flags a failing hover pair on a styled control', () => { - const f = checkHoverContrast({ - tag: 'a', - textColor: { r: 239, g: 236, b: 233, a: 1 }, - bg: { r: 215, g: 56, b: 23, a: 1 }, - ownBgAlpha: 1, - fontSize: 13.6, - fontWeight: 500, - hasDirectText: true, - isEmojiOnly: false, - }); - expect(f).toHaveLength(1); - expect(f[0].id).toBe('low-contrast'); - expect(f[0].snippet).toContain(':hover'); - }); - - test('checkHoverContrast skips plain links without their own background', () => { - const f = checkHoverContrast({ - tag: 'a', - textColor: { r: 120, g: 120, b: 120, a: 1 }, - bg: { r: 128, g: 128, b: 128, a: 1 }, - ownBgAlpha: null, - fontSize: 16, - fontWeight: 400, - hasDirectText: true, - isEmojiOnly: false, - }); - expect(f).toHaveLength(0); - }); - - test('checkHoverContrast passes a compliant hover pair', () => { - const f = checkHoverContrast({ - tag: 'a', - textColor: { r: 255, g: 255, b: 255, a: 1 }, - bg: { r: 20, g: 20, b: 20, a: 1 }, - ownBgAlpha: 1, - fontSize: 14, - fontWeight: 500, - hasDirectText: true, - isEmojiOnly: false, - }); - expect(f).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Auto-scrolling marquee -// --------------------------------------------------------------------------- - -describe('marquee', () => { - test('flags infinite percent-travel X loop (implicit start)', () => { - const css = ` - .ticker-track { display: flex; width: max-content; animation: ticker 25s linear infinite; } - @keyframes ticker { to { transform: translateX(-50%); } } - `; - const f = scanCssTextForMarquee(css); - expect(f).toHaveLength(1); - expect(f[0].id).toBe('marquee'); - expect(f[0].snippet).toContain('.ticker-track'); - }); - - test('flags elements', () => { - const f = scanCssTextForMarquee('
sale sale sale
'); - expect(f).toHaveLength(1); - expect(f[0].snippet).toContain(''); - }); - - test('skips centered elements animating other properties', () => { - const css = ` - .toast { animation: rise 3s ease infinite; } - @keyframes rise { from { transform: translate(-50%, 8px); } to { transform: translate(-50%, 0); } } - `; - expect(scanCssTextForMarquee(css)).toHaveLength(0); - }); - - test('skips non-infinite slide-in animations', () => { - const css = ` - .panel { animation: enter 0.4s ease; } - @keyframes enter { from { transform: translateX(-100%); } to { transform: translateX(0); } } - `; - expect(scanCssTextForMarquee(css)).toHaveLength(0); - }); - - test('skips px-travel sweeps (playheads, progress indicators)', () => { - const css = ` - .wave-anim .playhead { animation: sweep 6s linear infinite; } - @keyframes sweep { from { transform: translateX(0); } to { transform: translateX(760px); } } - `; - expect(scanCssTextForMarquee(css)).toHaveLength(0); - }); - - test('skips rotation and pulse animations', () => { - const css = ` - .spinner { animation: spin 1s linear infinite; } - @keyframes spin { to { transform: rotate(360deg); } } - .dot { animation: breathe 2s ease infinite; } - @keyframes breathe { 50% { transform: translateX(-50%) scale(1.1); opacity: 0.6; } } - `; - expect(scanCssTextForMarquee(css)).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Inset box-shadow stripes (side-tab variant) -// --------------------------------------------------------------------------- - -describe('inset box-shadow stripe', () => { - test('flags single-edge chromatic inset stripes on repeated items', () => { - const css = ` - :root { --good: #16a34a; } - .flag-good { box-shadow: inset 0 3px 0 var(--good); } - .callout { box-shadow: inset 4px 0 0 #dc2626; } - `; - const f = scanCssTextForInsetStripe(css); - expect(f).toHaveLength(2); - expect(f[0].id).toBe('side-tab'); - }); - - test('exempts current/selected-state indicators', () => { - const css = ` - .section-link[aria-current="location"] { box-shadow: inset 3px 0 0 #ea580c; } - .item.active { box-shadow: inset 3px 0 0 #ea580c; } - [role="tab"][aria-selected="true"] { box-shadow: inset 0 -3px 0 #ea580c; } - .link:hover { box-shadow: inset 3px 0 0 #ea580c; } - `; - expect(scanCssTextForInsetStripe(css)).toHaveLength(0); - }); - - test('skips narrow glyphs, blurred/spread shadows, neutrals, and thick fills', () => { - const css = ` - .brand-mark { width: 13px; box-shadow: inset 0 -7px 0 #2563eb; } - .card { box-shadow: inset 0 3px 6px rgba(0,0,0,.2); } - .row { box-shadow: inset 0 1px 0 #e5e7eb; } - .well { box-shadow: inset 0 20px 0 #dc2626; } - `; - expect(scanCssTextForInsetStripe(css)).toHaveLength(0); - }); - - test('flags all-tabs inset underlines, keeps the selected tab exempt', () => { - const allTabs = '.tab { box-shadow: inset 0 -3px 0 #f59e0b; }'; - const roleTab = '[role="tab"] { box-shadow: inset 0 -3px 0 #f59e0b; }'; - const selectedOnly = 'nav button[aria-selected="true"] { box-shadow: inset 0 -3px 0 #f59e0b; }'; - expect(scanCssTextForInsetStripe(allTabs)).toHaveLength(1); - expect(scanCssTextForInsetStripe(roleTab)).toHaveLength(1); - expect(scanCssTextForInsetStripe(selectedOnly)).toHaveLength(0); - }); - - test('static tab strip: chromatic border on every tab flags, selected-only underline stays silent', async () => { - // All-tabs variant: every tab in the group carries the stripe. - await withStaticFixture({ - 'index.html': ` -
- - - -
-

Departures board

-

Enough body copy for the page-level scanners to treat this as a page.

- `, - }, async ({ file }) => { - const findings = await detectHtml(file); - const stripes = findings.filter(f => f.antipattern === 'side-tab'); - // The two unselected tabs flag; the selected tab stays exempt. - expect(stripes).toHaveLength(2); - }); - - // Reserved-space variant: transparent on all, chromatic on selected only. - await withStaticFixture({ - 'index.html': ` -
- - -
-

Departures board

-

Enough body copy for the page-level scanners to treat this as a page.

- `, - }, async ({ file }) => { - const findings = await detectHtml(file); - expect(findings.filter(f => f.antipattern === 'side-tab')).toHaveLength(0); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Grid-line background variants (checkHtmlPatterns block scan) -// --------------------------------------------------------------------------- - -describe('codex-grid-background variants', () => { - const grids = (html) => checkHtmlPatterns(html).filter(f => f.id === 'codex-grid-background'); - - test('flags two-axis inverted-calc hairlines with shorthand tile size', () => { - const css = `body { background: - linear-gradient(90deg, transparent calc(100% - 1px), oklch(0.84 0.015 255 / 0.4) 1px) 0 0 / 48px 48px, - linear-gradient(transparent calc(100% - 1px), oklch(0.84 0.015 255 / 0.4) 1px) 0 0 / 48px 48px, - #eef1f7; }`; - expect(grids(css)).toHaveLength(1); - }); - - test('keeps single-axis hairline tiled by a px pair cell legal', () => { - const css = `body { background: linear-gradient(90deg, rgba(23,25,24,.035) 1px, transparent 1px) 0 0 / 40px 40px, #f4f1ea; }`; - expect(grids(css)).toHaveLength(0); - }); - - test('keeps percent-tiled single hairlines (data-viz track rules) legal', () => { - const css = `.span-track { background-image: linear-gradient(90deg, #303532 1px, transparent 1px); background-size: 25% 100%; }`; - expect(grids(css)).toHaveLength(0); - }); - - test('keeps 1D dashed dot rules legal', () => { - const css = `.dot-rule { - height: 5px; - background-image: linear-gradient(90deg, rgba(255,255,255,.75) 5px, transparent 5px); - background-size: 10px 5px; - background-repeat: repeat-x; - }`; - expect(grids(css)).toHaveLength(0); - }); - - test('keeps 1D progress rails with dash-period px pair tiles legal', () => { - const css = `.progress-rail { - background-image: linear-gradient(90deg, #eee 1px, transparent 1px); - background-size: 8px 4px; - background-repeat: repeat-x; - }`; - expect(grids(css)).toHaveLength(0); - }); - - test('regex source engine keeps 1D dot rules legal', () => { - const css = `.dot-rule { - height: 5px; - background-image: linear-gradient(90deg, rgba(255,255,255,.75) 5px, transparent 5px); - background-size: 10px 5px; - background-repeat: repeat-x; - }`; - const findings = detectText(css, 'dot-rule.css'); - expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); - }); - - test('classic two-axis background-size form still flags', () => { - const css = `.hero { background-image: - linear-gradient(#eee 1px, transparent 1px), - linear-gradient(90deg, #eee 1px, transparent 1px); - background-size: 24px 24px; }`; - expect(grids(css)).toHaveLength(1); - }); - - test('regex source engine catches a grid in a standalone CSS file', () => { - const css = `.worlds-shell { - background-image: - linear-gradient(rgba(23, 25, 24, 0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(23, 25, 24, 0.04) 1px, transparent 1px); - background-size: 80px 80px; - }`; - const findings = detectText(css, 'worlds.css'); - expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(1); - }); - - test('regex source engine keeps structural percentage rules legal', () => { - const css = `.timeline-track { - background-image: linear-gradient(90deg, #303532 1px, transparent 1px); - background-size: 25% 100%; - }`; - const findings = detectText(css, 'timeline.css'); - expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); - }); - - test('regex source engine ignores grids in JavaScript comments', () => { - const source = `/* .demo { - background-image: - linear-gradient(#eee 1px, transparent 1px), - linear-gradient(90deg, #eee 1px, transparent 1px); - background-size: 24px 24px; - } */`; - const findings = detectText(source, 'demo.ts'); - expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); - }); - - test('regex source engine ignores grids in CSS-in-JS comments', () => { - const source = `const Demo = styled.div\` - /* .demo { background-image: - linear-gradient(#eee 1px, transparent 1px), - linear-gradient(90deg, #eee 1px, transparent 1px); - background-size: 24px 24px; } */ - \`;`; - const findings = detectText(source, 'demo.tsx'); - expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); - }); - - test('regex source engine still detects live CSS-in-JS grids', () => { - const source = `const Demo = styled.div\` - .demo { background-image: - linear-gradient(#eee 1px, transparent 1px), - linear-gradient(90deg, #eee 1px, transparent 1px); - background-size: 24px 24px; } - \`;`; - const findings = detectText(source, 'demo.tsx'); - expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(1); - }); -}); - -// --------------------------------------------------------------------------- -// Hero eyebrow: dash-prefix branch -// --------------------------------------------------------------------------- - -describe('hero-eyebrow dash-prefix branch', () => { - const base = { - headingTag: 'h1', - headingText: 'Find the service that started it.', - headingFontSize: 72, - siblingTag: 'p', - siblingText: 'Distributed tracing for microservices', - siblingTextTransform: 'none', - siblingFontSize: 13, - siblingLetterSpacing: 0.26, - siblingFontWeight: '400', - siblingColor: 'rgb(120, 120, 110)', - }; - - test('flags sentence-case label with accent dash pseudo', () => { - const f = checkHeroEyebrow({ ...base, siblingHasAccentDashPseudo: true }); - expect(f).toHaveLength(1); - expect(f[0].snippet).toContain('dash-prefix'); - }); - - test('same label without the dash stays legal', () => { - expect(checkHeroEyebrow({ ...base, siblingHasAccentDashPseudo: false })).toHaveLength(0); - }); - - test('compact application headings stay legal', () => { - expect(checkHeroEyebrow({ ...base, headingFontSize: 36, siblingHasAccentDashPseudo: true })).toHaveLength(0); - expect(checkHeroEyebrow({ ...base, headingInApplicationContext: true, siblingHasAccentDashPseudo: true })).toHaveLength(0); - }); - - test('static engine resolves the dash through the cascade', async () => { - await withStaticFixture({ - 'index.html': `
-

Distributed tracing for microservices

-

Find the service that started it.

-

Body copy long enough to make this a real page for the scanners.

-
`, - }, async ({ file }) => { - const findings = await detectHtml(file); - const hits = findings.filter(f => f.antipattern === 'hero-eyebrow-chip'); - expect(hits).toHaveLength(1); - expect(hits[0].snippet).toContain('dash-prefix'); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Pulsing status dots -// --------------------------------------------------------------------------- - -describe('pulsing-dot', () => { - test('fixture flags the five pulsing dots and none of the passes', async () => { - const f = await detectHtml(path.join(FIXTURES, 'pulsing-dot.html')); - const dots = f.filter(r => r.antipattern === 'pulsing-dot'); - const snippets = dots.map(r => r.snippet).join(' | '); - expect(dots).toHaveLength(5); - expect(snippets).toContain('.live-dot'); - expect(snippets).toContain('.status .dot'); - expect(snippets).toContain('.beacon'); - expect(snippets).toContain('.rec-mark'); - expect(snippets).toContain('animate-ping'); - expect(snippets).not.toContain('spinner'); - expect(snippets).not.toContain('fake-pulse'); - expect(snippets).not.toContain('breathing-card'); - expect(snippets).not.toContain('square-badge'); - }); - - test('header dot is promoted to error severity; body dots keep the default', async () => { - const f = await detectHtml(path.join(FIXTURES, 'pulsing-dot.html')); - const dots = f.filter(r => r.antipattern === 'pulsing-dot'); - const headerDot = dots.find(r => r.snippet.includes('.rec-mark')); - expect(headerDot.severity).toBe('error'); - expect(headerDot.snippet).toContain('in header/nav'); - const bodyDot = dots.find(r => r.snippet.includes('.live-dot')); - expect(bodyDot.severity).toBe('warning'); - expect(bodyDot.snippet).not.toContain('in header/nav'); - }); - - test('merges size and animation declared in separate rule blocks for one selector', () => { - const css = ` - .dot { width: 8px; height: 8px; border-radius: 50%; } - .dot { animation: pulse 2s infinite; } - @keyframes pulse { 50% { opacity: 0.3; } } - `; - const f = scanCssTextForPulsingDot(css); - expect(f).toHaveLength(1); - expect(f[0].selector).toBe('.dot'); - }); - - test('descends into matching media queries for the animation half', () => { - const css = ` - .dot { width: 7px; height: 7px; border-radius: 50%; } - @media (prefers-reduced-motion: no-preference) { - .dot { animation: pulseDot 2.4s ease-in-out infinite; } - } - @keyframes pulseDot { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(1); - }); - - test('prefers-reduced-motion: reduce resets never mask the default animation', () => { - const css = ` - .dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; } - @media (prefers-reduced-motion: reduce) { .dot { animation: none; } } - @keyframes pulse { 50% { opacity: 0.3; } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(1); - }); - - test('a later animation: none outside reduced-motion disables the dot', () => { - const css = ` - .dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; } - .dot { animation: none; } - @keyframes pulse { 50% { opacity: 0.3; } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(0); - }); - - test('detects tiny circle with infinite opacity-pulse keyframes', () => { - const css = ` - .dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; } - @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } - `; - const f = scanCssTextForPulsingDot(css); - expect(f).toHaveLength(1); - expect(f[0].id).toBe('pulsing-dot'); - }); - - test('detects box-shadow ripple keyframes', () => { - const css = ` - .dot { width: 7px; height: 7px; border-radius: 999px; animation: ripple 1.8s linear infinite; } - @keyframes ripple { 0% { box-shadow: 0 0 0 0 rgba(0,255,0,0.4); } 100% { box-shadow: 0 0 0 6px rgba(0,255,0,0); } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(1); - }); - - test('accepts pulse-family names when keyframes are not in the scanned text', () => { - const css = '.dot { width: 8px; height: 8px; border-radius: 50%; animation: blink 1.4s infinite; }'; - expect(scanCssTextForPulsingDot(css)).toHaveLength(1); - }); - - test('rotation-only animations never flag (spinners)', () => { - const css = ` - .spinner { width: 14px; height: 14px; border-radius: 50%; animation: spin 0.8s linear infinite; } - @keyframes spin { to { transform: rotate(360deg); } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(0); - }); - - test('rotation-only keyframes win over a pulse-like name', () => { - const css = ` - .dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse-ring 1s linear infinite; } - @keyframes pulse-ring { to { transform: rotate(180deg); } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(0); - }); - - test('skips large pulsing surfaces (not a dot)', () => { - const css = ` - .card { width: 240px; height: 120px; border-radius: 16px; animation: pulse 3s infinite; } - @keyframes pulse { 50% { opacity: 0.5; } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(0); - }); - - test('skips finite pulse animations', () => { - const css = ` - .dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 0.6s ease-out 3; } - @keyframes pulse { 50% { opacity: 0.5; } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(0); - }); - - test('skips non-circular pulsing elements', () => { - const css = ` - .badge { width: 12px; height: 12px; border-radius: 2px; animation: pulse 2s infinite; } - @keyframes pulse { 50% { opacity: 0.5; } } - `; - expect(scanCssTextForPulsingDot(css)).toHaveLength(0); - }); - - test('Tailwind animate-ping on tiny rounded-full element flags; large skeleton does not', () => { - const html = ` - -
- `; - const f = scanCssTextForPulsingDot(html); - expect(f).toHaveLength(1); - expect(f[0].snippet).toContain('animate-ping'); - }); -}); - -// --------------------------------------------------------------------------- -// Shape-assembled illustrations -// --------------------------------------------------------------------------- - -describe('shape-assembled-illustration', () => { - test('fixture flags only the hero mascot scene', async () => { - const f = await detectHtml(path.join(FIXTURES, 'shape-assembled-illustration.html')); - const hits = f.filter(r => r.antipattern === 'shape-assembled-illustration'); - expect(hits).toHaveLength(1); - expect(hits[0].severity).toBe('advisory'); - expect(hits[0].snippet).toContain('primitive shapes'); - }); - - test('flags a large multi-fill primitive scene', () => { - const shapes = Array.from({ length: 10 }, (_, i) => - ``).join(''); - const html = `${shapes}`; - expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(1); - }); - - test('small explicit size wins over a large viewBox (icons, logos)', () => { - const shapes = Array.from({ length: 10 }, (_, i) => - ``).join(''); - const html = `${shapes}`; - expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0); - }); - - test('axis-labeled charts never flag', () => { - const bars = Array.from({ length: 9 }, (_, i) => - ``).join(''); - const labels = 'Q1Q2Q3'; - const html = `${bars}${labels}`; - expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0); - }); - - test('stroke-only line drawings (no fills) never flag', () => { - const shapes = Array.from({ length: 10 }, (_, i) => - ``).join(''); - const html = `${shapes}`; - expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0); - }); - - test('tiling pattern backgrounds never flag', () => { - const shapes = Array.from({ length: 10 }, (_, i) => - ``).join(''); - const html = `${shapes}`; - expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0); - }); - - test('fewer than eight primitives never flags (path-heavy real illustration)', () => { - const html = ` - - - - - `; - expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Nav CTA contrast construction sweep -// --------------------------------------------------------------------------- - -describe('nav CTA contrast constructions', () => { - // One fixture per construction family that has shipped (or could ship) a - // low-contrast header CTA past the detector: solid own bg, specificity - // cascade, var() indirection, own gradient bg, pseudo-element surface, - // inherited color, alpha-composited bg, oklch serialization. The - // background-image: url(...) variant stays unflaggable by design (no - // computable surface color). - test('every computable construction produces a low-contrast finding', async () => { - const f = await detectHtml(path.join(FIXTURES, 'nav-cta-constructions.html')); - const lows = f.filter(r => r.antipattern === 'low-contrast').map(r => r.snippet).join(' | '); - expect(lows).toContain('#e8b84b'); // v1 solid bg + own color - expect(lows).toContain('#e5a93f'); // v2 specificity cascade - expect(lows).toContain('#f0b64e'); // v3 var() indirection - expect(lows).toContain('#f2b854'); // v4 own gradient bg (worst stop) - expect(lows).toContain('#efb352'); // v5 pseudo-element surface - expect(lows).toContain('#eab04a'); // v6 inherited color - expect(lows).toContain('#cf9c48'); // v7 alpha bg composited over header - expect(lows).toContain('#fcb442'); // v8 oklch on both sides - }); -}); - - -// --------------------------------------------------------------------------- -// ANTIPATTERNS registry -// --------------------------------------------------------------------------- - -describe('ANTIPATTERNS registry', () => { - test('has at least 5 entries', () => { - expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(5); - }); - - test('each entry has required fields', () => { - for (const ap of ANTIPATTERNS) { - expect(ap.id).toBeTypeOf('string'); - expect(ap.name).toBeTypeOf('string'); - expect(ap.description).toBeTypeOf('string'); - } - }); -}); - -// --------------------------------------------------------------------------- -// walkDir -// --------------------------------------------------------------------------- - -describe('walkDir', () => { - test('includes Sass files in scannable extensions', () => { - expect(SCANNABLE_EXTENSIONS.has('.sass')).toBe(true); - }); - - test('finds Blade templates during directory scans', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-')); - try { - const blade = path.join(tmp, 'resources', 'views', 'card.blade.php'); - const upperBlade = path.join(tmp, 'resources', 'views', 'hero.BLADE.PHP'); - fs.mkdirSync(path.dirname(blade), { recursive: true }); - fs.writeFileSync(blade, '
Card
'); - fs.writeFileSync(upperBlade, '
Hero
'); - expect(walkDir(tmp)).toEqual(expect.arrayContaining([blade, upperBlade])); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('compound suffix matching does not broaden scans to module extensions', () => { - expect(hasScannableExtension('card.blade.php')).toBe(true); - expect(hasScannableExtension('card.BLADE.PHP')).toBe(true); - for (const file of ['next.config.mjs', 'vite.config.cjs', 'route.mts', 'route.cts']) { - expect(hasScannableExtension(file)).toBe(false); - } - }); - - test('finds scannable files', () => { - const files = walkDir(FIXTURES); - expect(files.length).toBeGreaterThanOrEqual(3); - expect(files.every(hasScannableExtension)).toBe(true); - }); - - test('returns empty for nonexistent dir', () => { - expect(walkDir('/nonexistent/path/12345')).toHaveLength(0); - }); - - // Issue #303: when impeccable (or any agent tool) is installed into a - // project's .claude/.cursor/etc. tree, a root scan descended into the - // vendored skill code and reported the detector's own example strings as - // findings. Hidden directories are never app source — skip them all. - test('skips hidden dirs (AI-harness installs) during recursion', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-')); - try { - const write = (rel) => { - const abs = path.join(tmp, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, '/* fixture */'); - }; - write('src/app.css'); - write('.claude/skills/impeccable/scripts/detector.js'); - write('.cursor/skills/impeccable/example.css'); - write('.impeccable/live/preview.html'); - write('node_modules/pkg/index.js'); - // Hidden dirs that conventionally hold real UI source are the - // exception: VitePress themes and Storybook preview files must keep - // being scanned (they were before the hidden-dir rule existed). - write('.vitepress/theme/Layout.vue'); - write('.vuepress/theme/Layout.vue'); - write('.storybook/preview.css'); - const files = walkDir(tmp).sort(); - expect(files).toEqual([ - path.join(tmp, '.storybook', 'preview.css'), - path.join(tmp, '.vitepress', 'theme', 'Layout.vue'), - path.join(tmp, '.vuepress', 'theme', 'Layout.vue'), - path.join(tmp, 'src', 'app.css'), - ]); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('still scans a hidden dir passed as the explicit target', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-')); - try { - const abs = path.join(tmp, '.claude', 'page.html'); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, ''); - // Only child entries are name-checked; naming the hidden dir directly - // is an explicit user intent and must keep working. - expect(walkDir(path.join(tmp, '.claude'))).toEqual([abs]); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - -// --------------------------------------------------------------------------- -// CLI integration -// --------------------------------------------------------------------------- - -describe('CLI', () => { - function run(...args) { - const result = spawnSync('node', [SCRIPT, ...withoutDesignSystemArgs(args)], { encoding: 'utf-8', timeout: 15000 }); - return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }; - } - function runIn(cwd, ...args) { - const result = spawnSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf-8', timeout: 15000 }); - return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }; - } - - test('--help exits 0', () => { - const { stdout, code } = run('--help'); - expect(code).toBe(0); - expect(stdout).toContain('Usage:'); - expect(stdout).toContain('--quiet'); - expect(stdout).not.toContain('--gpt'); - expect(stdout).not.toContain('--gemini'); - }); - - test('generated-UI tells run by default in the CLI', () => { - const { stdout, code } = run('--json', path.join(FIXTURES, 'gpt-tells.html')); - expect(code).toBe(2); - const ids = JSON.parse(stdout).map(f => f.antipattern); - expect(ids).toContain('gpt-thin-border-wide-shadow'); - expect(ids).toContain('repeating-stripes-gradient'); - expect(ids).toContain('codex-grid-background'); - expect(ids).toContain('theater-slop-phrase'); - }); - - test('legacy provider flags are accepted as deprecated no-ops', () => { - const { stdout, stderr, code } = run('--gpt', '--json', path.join(FIXTURES, 'gpt-tells.html')); - expect(code).toBe(2); - expect(stderr).toContain('--gpt and --gemini are deprecated and ignored'); - expect(JSON.parse(stdout).some(f => f.antipattern === 'codex-grid-background')).toBe(true); - }); - - test('detect subcommand is not treated as a scan target', () => { - const { stderr, code } = run('detect', '--json', path.join(FIXTURES, 'should-pass.html')); - expect(code).toBe(0); - expect(stderr).not.toContain('cannot access detect'); - }); - - test('should-pass exits 0', () => { - const { code } = run(path.join(FIXTURES, 'should-pass.html')); - expect(code).toBe(0); - }); - - test('should-flag exits 2 with findings', () => { - const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html')); - expect(code).toBe(2); - expect(stderr).toContain('side-tab'); - }); - - test('--json outputs valid JSON', () => { - const { stdout, code } = run('--json', path.join(FIXTURES, 'should-flag.html')); - expect(code).toBe(2); - const parsed = JSON.parse(stdout.trim()); - expect(parsed).toBeArray(); - expect(parsed.length).toBeGreaterThan(0); - }); - - test('--quiet suppresses text details and keeps the summary exit signal', () => { - const { stdout, stderr, code } = run('--quiet', path.join(FIXTURES, 'should-flag.html')); - expect(code).toBe(2); - expect(stdout).toBe(''); - expect(stderr.trim()).toMatch(/^[1-9]\d* anti-patterns? found\.$/); - expect(stderr).not.toContain('side-tab'); - expect(stderr).not.toContain('line '); - }); - - test('--quiet stays silent on clean files', () => { - const { stdout, stderr, code } = run('--quiet', path.join(FIXTURES, 'should-pass.html')); - expect(code).toBe(0); - expect(stdout).toBe(''); - expect(stderr).toBe(''); - }); - - test('--quiet does not affect JSON output', () => { - const { stdout, stderr, code } = run('--json', '--quiet', path.join(FIXTURES, 'should-flag.html')); - expect(code).toBe(2); - expect(stderr).toBe(''); - const parsed = JSON.parse(stdout.trim()); - expect(parsed).toBeArray(); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed.some(f => f.antipattern === 'side-tab')).toBe(true); - }); - - test('-json alias outputs valid JSON', () => { - const { stdout, stderr, code } = run('-json', path.join(FIXTURES, 'should-flag.html')); - expect(code).toBe(2); - expect(stderr).not.toContain('cannot access -json'); - const parsed = JSON.parse(stdout.trim()); - expect(parsed).toBeArray(); - expect(parsed.length).toBeGreaterThan(0); - }); - - test('--json on clean file outputs empty array', () => { - const { stdout, code } = run('--json', path.join(FIXTURES, 'should-pass.html')); - expect(code).toBe(0); - expect(JSON.parse(stdout.trim())).toEqual([]); - }); - - test('--fast is accepted but deprecated (no-op, full scan still runs)', () => { - const { code, stderr } = run('--fast', path.join(FIXTURES, 'should-flag.html')); - expect(code).toBe(2); // still flags the planted anti-patterns via the full scan - expect(stderr).toContain('--fast is deprecated'); - }); - - test('linked stylesheet detected (static HTML/CSS default)', () => { - const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html')); - expect(code).toBe(2); - expect(stderr).toContain('side-tab'); - }); - - test('local DESIGN.md enables design-system rules by default and --no-design-system disables them', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-design-system-')); - try { - fs.writeFileSync(path.join(dir, 'DESIGN.md'), `--- -typography: - body: - fontFamily: "IBM Plex Sans, Arial, sans-serif" -colors: - ink: "#241f1a" - paper: "#f7f4ee" -rounded: - md: "8px" ---- - -# Design System -`); - fs.writeFileSync(path.join(dir, 'index.html'), ` -
- Design drift -
- `); - - const active = runIn(dir, '--json', 'index.html'); - expect(active.code).toBe(2); - const activeIds = JSON.parse(active.stdout).map((finding) => finding.antipattern); - expect(activeIds).toContain('design-system-font'); - expect(activeIds).toContain('design-system-color'); - expect(activeIds).toContain('design-system-radius'); - - const disabled = runIn(dir, '--json', '--no-design-system', 'index.html'); - const disabledIds = JSON.parse(disabled.stdout).map((finding) => finding.antipattern); - expect(disabledIds.some((id) => id.startsWith('design-system-'))).toBe(false); - - const raw = runIn(dir, '--json', '--no-config', 'index.html'); - const rawIds = JSON.parse(raw.stdout).map((finding) => finding.antipattern); - expect(rawIds.some((id) => id.startsWith('design-system-'))).toBe(false); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('filterByScopes keeps only findings for the requested design domain', () => { - const findings = [ - { antipattern: 'flat-type-hierarchy' }, - { antipattern: 'nested-cards' }, - { antipattern: 'line-length' }, - ]; - - expect(filterByScopes(findings, ['type']).map((f) => f.antipattern)).toEqual([ - 'flat-type-hierarchy', - 'line-length', - ]); - expect(filterByScopes(findings, ['layout']).map((f) => f.antipattern)).toEqual([ - 'nested-cards', - 'line-length', - ]); - expect(filterByScopes(findings, [])).toEqual(findings); - }); - - test('--scope filters CLI output to a design domain', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-scope-')); - try { - fs.writeFileSync(path.join(dir, 'DESIGN.md'), `--- -typography: - body: - fontFamily: "IBM Plex Sans, Arial, sans-serif" - fontSize: "16px" -colors: - ink: "#241f1a" - paper: "#f7f4ee" ---- - -# Design System -`); - fs.writeFileSync(path.join(dir, 'index.css'), ` -.bad { - font-family: "IBM Plex Sans", Arial, sans-serif; - font-size: 12.5px; - color: #ff00aa; -} -`); - - const full = runIn(dir, '--json', 'index.css'); - expect(full.code).toBe(2); - const fullIds = JSON.parse(full.stdout).map((finding) => finding.antipattern); - expect(fullIds).toContain('design-system-font-size'); - expect(fullIds).toContain('design-system-color'); - - const typeOnly = runIn(dir, '--json', '--scope', 'type', 'index.css'); - const typeIds = JSON.parse(typeOnly.stdout).map((finding) => finding.antipattern); - expect(typeIds).toContain('design-system-font-size'); - expect(typeIds.some((id) => id === 'design-system-color')).toBe(false); - - const badScope = runIn(dir, '--scope', 'bogus', 'index.css'); - expect(badScope.code).toBe(1); - expect(badScope.stderr).toContain('Valid scopes:'); - - // A bare --scope must fail instead of silently scanning unscoped. - const missingTrailing = runIn(dir, 'index.css', '--scope'); - expect(missingTrailing.code).toBe(1); - expect(missingTrailing.stderr).toContain('--scope requires a value'); - - const missingBeforeFlag = runIn(dir, '--scope', '--json', 'index.css'); - expect(missingBeforeFlag.code).toBe(1); - expect(missingBeforeFlag.stderr).toContain('--scope requires a value'); - - const emptyInline = runIn(dir, '--scope=', 'index.css'); - expect(emptyInline.code).toBe(1); - expect(emptyInline.stderr).toContain('--scope requires a value'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('detector designSystem.enabled=false disables CLI design-system rules', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cli-design-disabled-')); - try { - fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true }); - fs.writeFileSync(path.join(dir, '.impeccable', 'config.json'), JSON.stringify({ - detector: { designSystem: { enabled: false } }, - })); - fs.writeFileSync(path.join(dir, 'DESIGN.md'), `--- -typography: - body: - fontFamily: "IBM Plex Sans, Arial, sans-serif" -colors: - ink: "#241f1a" - paper: "#f7f4ee" -rounded: - md: "8px" ---- - -# Design System -`); - fs.writeFileSync(path.join(dir, 'index.html'), ` -
- Design drift -
- `); - - const result = runIn(dir, '--json', 'index.html'); - const ids = JSON.parse(result.stdout).map((finding) => finding.antipattern); - expect(ids.some((id) => id.startsWith('design-system-'))).toBe(false); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('respects .impeccable config ignoreFiles like the hook', async () => { - await withStaticFixture({ - '.impeccable/config.json': JSON.stringify({ - detector: { ignoreFiles: ['src/noisy.css'] }, - }), - 'src/noisy.css': "body { font-family: 'Inter', sans-serif; }", - }, ({ dir }) => { - const { stdout, code } = runIn(dir, '--json', 'src'); - expect(code).toBe(0); - expect(JSON.parse(stdout.trim())).toEqual([]); - }); - }); - - test('respects .impeccable config ignoreRules like the hook', async () => { - await withStaticFixture({ - '.impeccable/config.json': JSON.stringify({ - detector: { ignoreRules: ['side-tab'] }, - }), - 'src/card.css': '.card { border-left: 4px solid #3b82f6; border-radius: 12px; }', - }, ({ dir }) => { - const { stdout, code } = runIn(dir, '--json', 'src/card.css'); - expect(code).toBe(0); - expect(JSON.parse(stdout.trim())).toEqual([]); - }); - }); - - test('respects .impeccable config ignoreValues like the hook', async () => { - await withStaticFixture({ - '.impeccable/config.json': JSON.stringify({ - detector: { - ignoreValues: [ - { rule: 'overused-font', value: 'Inter' }, - ], - }, - }), - 'src/fonts.css': [ - "body { font-family: 'Inter', sans-serif; }", - "h1 { font-family: 'Roboto', sans-serif; }", - ].join('\n'), - }, ({ dir }) => { - const { stdout, code } = runIn(dir, '--json', 'src/fonts.css'); - expect(code).toBe(2); - const snippets = JSON.parse(stdout.trim()).map(f => f.snippet).join('\n'); - expect(snippets).not.toContain('Inter'); - expect(snippets).toContain('Roboto'); - }); - }); - - test('respects scoped wildcard ignoreValues like the hook', async () => { - await withStaticFixture({ - '.impeccable/config.json': JSON.stringify({ - detector: { - ignoreValues: [ - { rule: 'overused-font', value: '*', files: ['src/main.css'] }, - ], - }, - }), - 'src/main.css': "body { font-family: 'Inter', sans-serif; }", - 'src/other.css': "body { font-family: 'Inter', sans-serif; }", - }, ({ dir }) => { - const { stdout, code } = runIn(dir, '--json', 'src'); - expect(code).toBe(2); - const findings = JSON.parse(stdout.trim()); - expect(findings.some(f => f.file.endsWith('src/main.css'))).toBe(false); - expect(findings.some(f => f.file.endsWith('src/other.css'))).toBe(true); - }); - }); - - test('warns on nonexistent path', () => { - const { stderr } = run('/nonexistent/file/xyz.html'); - expect(stderr).toContain('Warning'); - }); -}); - -// --------------------------------------------------------------------------- -// Detector benchmark smoke test -// --------------------------------------------------------------------------- - -describe('benchmark-detector', () => { - test('--quick --json emits timing schema', () => { - const result = spawnSync('node', [BENCH_SCRIPT, '--quick', '--json'], { - encoding: 'utf-8', - timeout: 30000, - }); - expect(result.status).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed.version).toBe(1); - expect(parsed.quick).toBe(true); - expect(parsed.browser).toBe(false); - expect(parsed.cases).toBeArray(); - expect(parsed.cases.length).toBeGreaterThan(0); - expect(parsed.summary).toBeArray(); - expect(parsed.summary.length).toBeGreaterThan(0); - - const okCase = parsed.cases.find(c => c.status === 'ok'); - expect(okCase).toBeTruthy(); - expect(okCase).toHaveProperty('totalMs'); - expect(okCase).toHaveProperty('findings'); - expect(okCase.profile).toBeArray(); - - const row = parsed.summary[0]; - for (const key of ['engine', 'phase', 'ruleId', 'target', 'calls', 'totalMs', 'avgMs', 'p50', 'p95', 'findings']) { - expect(row).toHaveProperty(key); - } - }); -}); - -// --------------------------------------------------------------------------- -// Tier 1: Vue/Svelte `; - const blocks = extractStyleBlocks(vue, '.vue'); - expect(blocks.length).toBe(1); - expect(blocks[0].content).toContain('border-left: 4px solid blue'); - expect(blocks[0].startLine).toBeGreaterThan(1); - }); - - test('extracts multiple -`; - const blocks = extractStyleBlocks(vue, '.vue'); - expect(blocks.length).toBe(2); - }); - - test('extracts `; - const blocks = extractStyleBlocks(svelte, '.svelte'); - expect(blocks.length).toBe(1); - expect(blocks[0].content).toContain('border-right: 4px solid'); - }); - - test('returns empty for non-Vue/Svelte files', () => { - const jsx = 'export function Card() { return
hi
; }'; - expect(extractStyleBlocks(jsx, '.jsx')).toHaveLength(0); - expect(extractStyleBlocks(jsx, '.tsx')).toHaveLength(0); - }); - - test('returns empty when no `; - const f = detectText(vue, 'Card.vue'); - expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); - }); - - test('detects overused font in `; - const f = detectText(vue, 'App.vue'); - expect(f.some(r => r.antipattern === 'overused-font')).toBe(true); - }); - - test('detects bounce animation in `; - const f = detectText(vue, 'Card.vue'); - expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true); - }); - - test('detects gradient-text in `; - const f = detectText(vue, 'Hero.vue'); - expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true); - }); - - test('detects Tailwind anti-patterns in