/**
 * design/lint.mjs — static grep-based anti-pattern lint for generated HTML/CSS (C11).
 *
 * Given a DESIGN.md (the project-root system contract) and a list of HTML/CSS
 * files (mockups, component outputs, or plain stylesheets), compile each
 * antiPatterns rule's `pattern` as a regular expression and scan each file
 * for matches.  A match = a violation, carrying the rule id + category +
 * the rewrite guidance authored alongside the pattern.
 *
 * Pure function: no I/O for rule compilation; callers read files and pass
 * the text in.  This keeps it trivially testable from vitest without tmp
 * dirs and lets the CLI layer do selective reads (e.g. only *.html under
 * a task_dir).
 *
 * Regex compile:
 *   - Patterns are stored as plain strings in the YAML frontmatter so
 *     humans can read them.  We compile with `gm` flags — global for
 *     line-spanning matches, multiline so `^` / `$` anchor per line.
 *   - Rules that fail to compile are reported as lint warnings (skipped,
 *     not fatal) so the catalog doesn't bring the pipeline down when a
 *     typo slips through.
 *
 * Line numbers: we precompute line-start offsets once per file, then map
 * match.index → line via binary search.  Matches are truncated at 120
 * chars so JSON payloads stay small even on huge generated files.
 */
/**
 * @typedef {{
 *   id: string,
 *   category: string,
 *   pattern: string,
 *   why: string,
 *   rewrite: string,
 * }} AntiPattern
 *
 * @typedef {{
 *   file: string,
 *   line: number,
 *   column: number,
 *   rule_id: string,
 *   category: string,
 *   match: string,
 *   why: string,
 *   rewrite: string,
 * }} Violation
 *
 * @typedef {{
 *   violations: Violation[],
 *   warnings: string[],
 *   files_scanned: number,
 *   rules_compiled: number,
 *   rules_skipped: number,
 * }} LintResult
 */
const MATCH_TRUNCATE = 120;
function buildLineOffsets(text) {
    const offsets = [0];
    for (let i = 0; i < text.length; i++) {
        if (text.charCodeAt(i) === 10) {
            offsets.push(i + 1);
        }
    }
    return offsets;
}
function offsetToLineCol(offsets, offset) {
    // binary search for the greatest offset <= our match index
    let lo = 0;
    let hi = offsets.length - 1;
    while (lo < hi) {
        const mid = (lo + hi + 1) >>> 1;
        if (offsets[mid] <= offset)
            lo = mid;
        else
            hi = mid - 1;
    }
    return { line: lo + 1, column: offset - offsets[lo] + 1 };
}
function truncate(s) {
    if (!s)
        return '';
    return s.length > MATCH_TRUNCATE ? `${s.slice(0, MATCH_TRUNCATE - 1)}…` : s;
}
/**
 * Compile a single antiPattern rule into a RegExp.  Returns null when the
 * pattern doesn't compile — the caller should surface a warning.
 *
 * @param {AntiPattern} rule
 * @returns {RegExp | null}
 */
function compileRule(rule) {
    try {
        return new RegExp(rule.pattern, 'gm');
    }
    catch {
        return null;
    }
}
/**
 * Run the full lint.
 *
 * @param {object} input
 * @param {AntiPattern[]} input.rules — antiPatterns from DESIGN.md frontmatter
 * @param {{ file: string, text: string }[]} input.files — files to scan
 * @returns {LintResult}
 */
export function lintAntiPatterns({ rules, files }) {
    if (!Array.isArray(rules)) {
        return {
            violations: [],
            warnings: ['no antiPatterns array supplied'],
            files_scanned: 0,
            rules_compiled: 0,
            rules_skipped: 0,
        };
    }
    if (!Array.isArray(files)) {
        throw new Error('lintAntiPatterns: files must be an array');
    }
    const warnings = [];
    /** @type {Array<{ rule: AntiPattern, regex: RegExp }>} */
    const compiled = [];
    let skipped = 0;
    for (const rule of rules) {
        if (!rule || typeof rule.pattern !== 'string' || typeof rule.id !== 'string') {
            warnings.push(`skipped rule with missing id or pattern: ${JSON.stringify(rule)}`);
            skipped++;
            continue;
        }
        const regex = compileRule(rule);
        if (!regex) {
            warnings.push(`rule "${rule.id}" failed to compile: ${rule.pattern}`);
            skipped++;
            continue;
        }
        compiled.push({ rule, regex });
    }
    /** @type {Violation[]} */
    const violations = [];
    for (const { file, text } of files) {
        if (typeof text !== 'string' || text.length === 0)
            continue;
        const offsets = buildLineOffsets(text);
        for (const { rule, regex } of compiled) {
            regex.lastIndex = 0;
            let m = regex.exec(text);
            while (m !== null) {
                const { line, column } = offsetToLineCol(offsets, m.index);
                violations.push({
                    file,
                    line,
                    column,
                    rule_id: rule.id,
                    category: rule.category,
                    match: truncate(m[0]),
                    why: rule.why,
                    rewrite: rule.rewrite,
                });
                // Guard against zero-width regex infinite loop: advance by 1 when
                // the match is empty.
                if (regex.lastIndex === m.index)
                    regex.lastIndex++;
                m = regex.exec(text);
            }
        }
    }
    return {
        violations,
        warnings,
        files_scanned: files.length,
        rules_compiled: compiled.length,
        rules_skipped: skipped,
    };
}
/**
 * Group a flat violations array by file.  Convenience for report rendering.
 *
 * @param {Violation[]} violations
 * @returns {Record<string, Violation[]>}
 */
export function groupByFile(violations) {
    const out = {};
    for (const v of violations) {
        if (!out[v.file])
            out[v.file] = [];
        out[v.file].push(v);
    }
    return out;
}
/**
 * Build a short human-readable summary of the lint result.
 *
 * @param {LintResult} result
 * @returns {string}
 */
export function summarize(result) {
    const byCat = new Map();
    for (const v of result.violations) {
        byCat.set(v.category, (byCat.get(v.category) ?? 0) + 1);
    }
    const catSummary = byCat.size === 0 ? '' : ` (${[...byCat.entries()].map(([c, n]) => `${c}=${n}`).join(', ')})`;
    return `${result.violations.length} violation(s) across ${result.files_scanned} file(s)${catSummary}; rules compiled=${result.rules_compiled} skipped=${result.rules_skipped}`;
}
//# sourceMappingURL=lint.mjs.map