← Files ShapeUIARCHIVED FILE

skills/impeccable/scripts/live-browser-ignores.js

10.1 KB · Oct 5, 2026 · 18:34 UTC

↓ Download file

/**
 * Browser-side resolution of project detector waivers for Impeccable live mode.
 *
 * The live server serializes `.impeccable/config.json` + `config.local.json`
 * detector ignores (plus the served-root prefixes from the inject config's
 * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
 * resolves that config against the current page's URL path when a detect scan
 * starts, so the overlay suppresses the same findings the CLI and the edit
 * hook do (issue #639).
 *
 * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
 *   1. `ignoreRules` suppress a rule project-wide.
 *   2. `ignoreValues` entries with `value: "*"` suppress their rule in the
 *      files their globs name. The CLI never applies an unscoped wildcard
 *      (isIgnoredFindingValue returns false for it), so neither does this.
 *   3. Remaining `ignoreValues` entries match on the finding's own value;
 *      those are forwarded as `disabledValues` for the detector bundle to
 *      apply where the findings are assembled.
 *   4. `ignoreFiles` globs that name the page waive it wholesale: the
 *      resolver reports `skipScan: true` and the detector answers the scan
 *      with zero findings, mirroring shouldIgnoreDetectionFile in the CLI
 *      and the edit hook's own ignoreFiles gate.
 *
 * `pageFiles`, when the server could resolve it, lists the real project
 * files the inject config serves. A URL that suffix-matches exactly one of
 * them takes that file as its only project identity; an ambiguous or absent
 * match falls back to the served-root common ancestor below.
 *
 * Known gap, unchanged from PR #645: framework apps inject into source files
 * (src/routes/about/+page.svelte) while scans see route URLs (/about), so
 * entries scoped to source or asset paths never match a page candidate and
 * are dropped. That shows the finding, which is the conservative direction.
 *
 * Kept separate from live-browser.js so the glob and page-scope logic can be
 * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
 * overlay UI bundle.
 */
(function (root) {
  'use strict';
  if (!root) return;

  // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
  // cli/lib/impeccable-config.mjs.
  function normalizeIgnoreRule(rule) {
    return String(rule || '').trim().toLowerCase();
  }

  function normalizeIgnoreValue(value) {
    return String(value || '')
      .trim()
      .replace(/^["']|["']$/g, '')
      .replace(/\+/g, ' ')
      .replace(/\s+/g, ' ')
      .toLowerCase();
  }

  // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
  // Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
  function globToRegex(glob) {
    let re = '^';
    let i = 0;
    while (i < glob.length) {
      const c = glob[i];
      if (c === '*') {
        if (glob[i + 1] === '*') {
          re += '.*';
          i += 2;
          if (glob[i] === '/') i += 1;
        } else {
          re += '[^/]*';
          i += 1;
        }
      } else if (c === '?') {
        re += '[^/]';
        i += 1;
      } else if (c === '{') {
        const end = glob.indexOf('}', i);
        if (end === -1) { re += '\\{'; i += 1; continue; }
        const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
        re += `(?:${parts.join('|')})`;
        i = end + 1;
      } else if (/[.+^$()|[\]\\]/.test(c)) {
        re += `\\${c}`;
        i += 1;
      } else {
        re += c;
        i += 1;
      }
    }
    re += '$';
    return new RegExp(re);
  }

  // The project-relative paths this page could be known as. Ignore globs are
  // project-relative (prototype/foo.html) and the URL is site-relative
  // (/foo.html), because a static server's root usually sits inside the
  // project; `roots` carries that prefix. The server reads it from the inject
  // config's own `files` globs, which already state where the served pages
  // are. Do not derive it from the ignore globs: a single entry scoped to
  // prototype/library/** would then lend prototype/library/ as a candidate
  // prefix to every page, and that rule would suppress site-wide.
  //
  // Each prefixed path also contributes its slash suffixes, mirroring
  // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
  // matches globs against every path suffix of the finding's file).
  //
  // One live session is served by one server, so a single document root must
  // sit at or above every configured page. The only prefix that can safely
  // be asserted is therefore the deepest common ancestor of the glob roots.
  // Treating each glob's own prefix as an identity goes wrong in both
  // directions: disjoint roots (src/ and public/) invent simultaneous
  // identities for one URL, so a waiver scoped to src/foo.html hides a
  // finding on a page served from public/foo.html; nested roots (prototype/
  // and prototype/library/, from globs at two depths in one tree) are not
  // alternatives at all, and demanding a waiver match under both stops
  // prototype/index.html from applying anywhere. When the globs share no
  // common root, no prefix is asserted and only the URL path itself matches.
  function pageCandidates(pathname, roots, pageFiles) {
    let pagePath = String(pathname || '');
    try {
      pagePath = decodeURIComponent(pagePath);
    } catch {
      // Malformed percent-escape: match on the raw path rather than throwing.
    }
    pagePath = pagePath.replace(/^\/+/, '');
    // A directory URL serves that directory's index, and the ignore globs
    // name files. Without this, /news/ never matches prototype/news/index.html.
    if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';

    const candidates = new Set();
    const addSuffixes = (fullPath) => {
      const parts = fullPath.split('/').filter(Boolean);
      for (let i = 0; i < parts.length; i++) {
        candidates.add(parts.slice(i).join('/'));
      }
    };
    addSuffixes(pagePath);

    // The served page list names the real files the inject config serves.
    // A URL that suffix-matches exactly one of them has an unambiguous
    // project identity; assert that identity and stop guessing from roots
    // (PR #645 review: with src/ and public/ both served, /foo.html must not
    // borrow src/foo.html's waivers while actually serving public/foo.html).
    // Zero matches or several fall through to the common-ancestor fallback:
    // ambiguity resolves toward showing the finding.
    const knownPages = [];
    for (const entry of Array.isArray(pageFiles) ? pageFiles : []) {
      if (typeof entry !== 'string' || !entry) continue;
      if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry);
    }
    if (knownPages.length === 1) {
      addSuffixes(knownPages[0]);
      return [...candidates];
    }

    const prefixes = [];
    for (const entry of Array.isArray(roots) ? roots : []) {
      if (typeof entry !== 'string') continue;
      prefixes.push(entry.split('/').filter(Boolean));
    }
    let common = prefixes.length > 0 ? prefixes[0] : [];
    for (const segments of prefixes.slice(1)) {
      let i = 0;
      while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1;
      common = common.slice(0, i);
    }

    if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath);
    return [...candidates];
  }

  function matchesScope(globs, candidates) {
    return globs.some((glob) => {
      let re;
      try {
        re = globToRegex(String(glob));
      } catch {
        // Malformed glob: skip it, as matchesAnyGlob does in the CLI.
        return false;
      }
      return candidates.some((candidate) => re.test(candidate));
    });
  }

  /**
   * Resolve the serialized project ignores for one page.
   *
   * @param {object} options
   * @param {object} options.ignores  window.__IMPECCABLE_PROJECT_IGNORES__,
   *   in whatever state it arrived: absent, null, or hand-edited into the
   *   wrong shape. Every read tolerates that and degrades to no filtering.
   * @param {string} options.pathname  location.pathname of the scanned page.
   * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}>, skipScan: boolean }}
   */
  function resolveDetectIgnores({ ignores, pathname } = {}) {
    const config = ignores && typeof ignores === 'object' ? ignores : {};
    const asArray = (value) => (Array.isArray(value) ? value : []);
    const candidates = pageCandidates(pathname, config.roots, config.pageFiles);

    // detector.ignoreFiles waives whole files. When any glob names this
    // page, the scan itself is skipped; rule and value lists are returned
    // empty because nothing will run.
    const ignoreFileGlobs = asArray(config.ignoreFiles)
      .filter((glob) => typeof glob === 'string' && glob.trim());
    if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) {
      return { disabledRules: [], disabledValues: [], skipScan: true };
    }

    const disabledRules = new Set(
      asArray(config.ignoreRules)
        .filter((rule) => typeof rule === 'string')
        .map(normalizeIgnoreRule)
        .filter(Boolean),
    );
    const disabledValues = [];

    for (const entry of asArray(config.ignoreValues)) {
      if (!entry || typeof entry !== 'object') continue;
      const rule = normalizeIgnoreRule(entry.rule);
      const value = normalizeIgnoreValue(entry.value);
      if (!rule || !value) continue;
      const files = [
        ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
        ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
      ];
      if (value === '*') {
        // Wildcards suppress their rule only inside the files they name.
        if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
        continue;
      }
      if (files.length > 0 && !matchesScope(files, candidates)) continue;
      disabledValues.push({ rule, value });
    }

    return { disabledRules: [...disabledRules], disabledValues, skipScan: false };
  }

  root.__IMPECCABLE_LIVE_IGNORES__ = {
    version: 1,
    resolveDetectIgnores,
  };
})(typeof window !== 'undefined' ? window : globalThis);

SHA-256: c1cb6877ca99a0c1b38ba0c7d59f32702d3022f6821d8045f7133f7c40b6362f