Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/live-manual-edit-evidence.mjs
1#!/usr/bin/env node2/**3* Collect evidence for pending live copy edits.4*5* This module intentionally does not edit source files and does not choose a6* winner. It gathers staged browser edits, rendered context, framework source7* hints, and likely source candidates so the AI copy-edit batch runner can make8* source changes with full repo context.9*/1011import fs from 'node:fs';12import path from 'node:path';13import { isGeneratedFile } from './lib/is-generated.mjs';14import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';1516const EVIDENCE_VERSION = 1;17const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);18const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data'];19const STRONG_LITERAL_MATCH_LIMIT = 8;20const WEAK_LITERAL_MATCH_LIMIT = 4;21const OBJECT_KEY_MATCH_LIMIT = 8;22const LOCATOR_MATCH_LIMIT = 4;23const CONTEXT_MATCH_LIMIT = 8;24const CONTEXT_MATCH_PER_HINT = 2;25const SKIP_DIRS = new Set([26'node_modules',27'.git',28'.impeccable',29'.astro',30'.next',31'.nuxt',32'.svelte-kit',33'dist',34'build',35'out',36'coverage',37]);3839export function buildManualEditEvidence({ cwd = process.cwd(), pageUrl = null } = {}) {40const buffer = readBuffer(cwd);41const entries = pageUrl42? buffer.entries.filter((entry) => entry.pageUrl === pageUrl)43: buffer.entries;44const opCount = countOps(entries);4546if (opCount === 0) {47return {48pageUrl,49count: 0,50entries: [],51ops: [],52candidates: [],53};54}5556const searchFiles = collectSearchFiles(cwd);57const ops = flattenOps(entries);58const candidates = ops.map((op) => buildCandidatesForOp(op, cwd, searchFiles));59return {60version: EVIDENCE_VERSION,61pageUrl: pageUrl || null,62count: opCount,63entries,64ops,65context: {66cwd,67bufferPath: path.relative(cwd, getBufferPath(cwd)),68totalEntries: entries.length,69totalOps: opCount,70},71candidates,72};73}7475function countOps(entries) {76let count = 0;77for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;78return count;79}8081function flattenOps(entries) {82const out = [];83for (const entry of entries) {84const contextHintsByRef = buildContextHintsByRef(entry);85for (const op of entry.ops || []) {86out.push({87entryId: entry.id,88pageUrl: entry.pageUrl,89ref: op.ref,90contextRef: op.contextRef || null,91tag: op.tag,92elementId: op.elementId || null,93classes: Array.isArray(op.classes) ? op.classes : [],94originalText: op.originalText,95newText: op.newText,96deleted: op.deleted === true,97sourceHint: op.sourceHint || null,98leaf: op.leaf || null,99nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts : [],100container: op.container || null,101contextHints: contextHintsByRef.get(op.ref) || [],102});103}104}105return out;106}107108function buildContextHintsByRef(entry) {109const map = new Map();110for (const op of entry.ops || []) {111const hints = new Set();112const add = (value) => {113const text = normalizeText(decodeBasicHtml(String(value || '')));114if (text.length < 3 || text.length > 160) return;115if (text === normalizeText(op.originalText) || text === normalizeText(op.newText)) return;116hints.add(text);117};118119for (const item of op.nearbyEditableTexts || []) {120add(typeof item === 'string' ? item : item?.text);121}122const outer = typeof entry.element?.outerHTML === 'string' ? entry.element.outerHTML : '';123for (const match of outer.matchAll(/data-impeccable-original-text="([^"]*)"/g)) add(match[1]);124if (typeof entry.element?.textContent === 'string') {125for (const chunk of entry.element.textContent.split(/\s{2,}|\n|\t/)) add(chunk);126}127map.set(op.ref, [...hints].slice(0, 16));128}129return map;130}131132function buildCandidatesForOp(op, cwd, searchFiles) {133const originalText = String(op.originalText || '');134const contextNeedles = op.contextHints || [];135return {136entryId: op.entryId,137ref: op.ref,138originalText,139sourceHint: analyzeSourceHint(op, cwd),140textMatches: originalText ? findLiteralMatches(searchFiles, originalText, { max: literalMatchLimit(originalText) }) : [],141objectKeyMatches: originalText ? findObjectKeyMatches(searchFiles, originalText, { max: OBJECT_KEY_MATCH_LIMIT }) : [],142locatorMatches: findLocatorMatches(searchFiles, op, { max: LOCATOR_MATCH_LIMIT }),143contextTextMatches: findContextMatches(searchFiles, contextNeedles, { maxPerHint: CONTEXT_MATCH_PER_HINT, max: CONTEXT_MATCH_LIMIT }),144};145}146147function literalMatchLimit(text) {148return isWeakSourceNeedle(text) ? WEAK_LITERAL_MATCH_LIMIT : STRONG_LITERAL_MATCH_LIMIT;149}150151function isWeakSourceNeedle(text) {152const normalized = normalizeText(text);153return normalized.length < 4 || /^[\d.,+\-%\s]+$/.test(normalized);154}155156function analyzeSourceHint(op, cwd) {157const hint = normalizeSourceHint(op.sourceHint);158if (!hint.file) return null;159const file = path.resolve(cwd, hint.file);160const relativeFile = path.relative(cwd, file);161if (!isPathInsideOrEqual(cwd, file)) {162return { ...hint, status: 'outside_cwd', relativeFile: hint.file };163}164if (!fs.existsSync(file)) {165return { ...hint, status: 'file_missing', relativeFile };166}167if (isGeneratedFile(file, { cwd })) {168return { ...hint, status: 'generated', relativeFile };169}170171const content = fs.readFileSync(file, 'utf-8');172const lines = content.split('\n');173const line = hint.line || 1;174const start = Math.max(0, line - 4);175const end = Math.min(lines.length, line + 3);176const windowText = lines.slice(start, end).join('\n');177const containsOriginalText = typeof op.originalText === 'string' && windowText.includes(op.originalText);178return {179...hint,180status: containsOriginalText ? 'ok' : 'text_not_found_near_hint',181relativeFile,182excerpt: lines.slice(start, end).map((text, index) => ({183line: start + index + 1,184text: text.slice(0, 240),185})),186};187}188189function normalizeSourceHint(hint) {190if (!hint || typeof hint !== 'object') return {};191let line = Number.isFinite(Number(hint.line)) ? Number(hint.line) : null;192let column = Number.isFinite(Number(hint.column)) ? Number(hint.column) : null;193if ((!line || !column) && typeof hint.loc === 'string') {194const match = hint.loc.match(/^(\d+)(?::(\d+))?/);195if (match) {196line = Number(match[1]);197if (match[2]) column = Number(match[2]);198}199}200return {201file: typeof hint.file === 'string' ? hint.file : '',202loc: typeof hint.loc === 'string' ? hint.loc : '',203line,204column,205};206}207208function collectSearchFiles(cwd) {209const out = [];210const seenDirs = new Set();211const seenFiles = new Set();212for (const dir of SEARCH_DIRS) {213scanDir(path.join(cwd, dir), cwd, seenDirs, seenFiles, out, 0);214}215scanRootFiles(cwd, seenFiles, out);216return out;217}218219function scanDir(dir, cwd, seenDirs, seenFiles, out, depth) {220if (depth > 7 || !fs.existsSync(dir)) return;221let realDir;222try { realDir = fs.realpathSync(dir); } catch { return; }223if (seenDirs.has(realDir)) return;224seenDirs.add(realDir);225226let entries;227try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }228for (const entry of entries) {229const fullPath = path.join(dir, entry.name);230if (entry.isDirectory()) {231if (SKIP_DIRS.has(entry.name)) continue;232scanDir(fullPath, cwd, seenDirs, seenFiles, out, depth + 1);233continue;234}235if (!entry.isFile() || !TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;236maybeAddSearchFile(fullPath, cwd, seenFiles, out);237}238}239240function scanRootFiles(cwd, seenFiles, out) {241let entries;242try { entries = fs.readdirSync(cwd, { withFileTypes: true }); } catch { return; }243for (const entry of entries) {244if (!entry.isFile() || !TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;245maybeAddSearchFile(path.join(cwd, entry.name), cwd, seenFiles, out);246}247}248249function maybeAddSearchFile(file, cwd, seenFiles, out) {250let realFile;251try { realFile = fs.realpathSync(file); } catch { return; }252if (seenFiles.has(realFile)) return;253seenFiles.add(realFile);254if (isGeneratedFile(file, { cwd })) return;255let content;256try { content = fs.readFileSync(file, 'utf-8'); } catch { return; }257out.push({ file, relativeFile: path.relative(cwd, file), content, lines: content.split('\n') });258}259260function findLiteralMatches(searchFiles, needle, { max }) {261return findMatches(searchFiles, needle, { kind: 'text', max });262}263264function findObjectKeyMatches(searchFiles, text, { max }) {265const re = new RegExp('(["\\\'`])' + escapeRegExp(text) + '\\1(?=\\s*:)', 'g');266const out = [];267for (const file of searchFiles) {268for (const match of file.content.matchAll(re)) {269out.push(matchForIndex(file, match.index, 'object_key', text));270if (out.length >= max) return out;271}272}273return out;274}275276function findLocatorMatches(searchFiles, op, { max }) {277const needles = [];278if (op.elementId) needles.push({ kind: 'id', needle: op.elementId });279for (const cls of op.classes || []) {280if (cls) needles.push({ kind: 'class', needle: cls });281}282if (op.tag) needles.push({ kind: 'tag', needle: '<' + op.tag });283284const out = [];285const seen = new Set();286for (const { kind, needle } of needles) {287for (const match of findMatches(searchFiles, needle, { kind, max })) {288const key = match.file + ':' + match.line + ':' + kind + ':' + needle;289if (seen.has(key)) continue;290seen.add(key);291out.push({ ...match, needle });292if (out.length >= max) return out;293}294}295return out;296}297298function findContextMatches(searchFiles, hints, { maxPerHint, max }) {299const out = [];300const seen = new Set();301for (const hint of hints || []) {302for (const match of findMatches(searchFiles, hint, { kind: 'context', max: maxPerHint })) {303const key = match.file + ':' + match.line + ':' + hint;304if (seen.has(key)) continue;305seen.add(key);306out.push({ ...match, needle: hint });307if (out.length >= max) return out;308}309}310return out;311}312313function findMatches(searchFiles, needle, { kind, max }) {314const text = String(needle || '');315if (!text) return [];316const out = [];317for (const file of searchFiles) {318let index = 0;319while (out.length < max) {320index = file.content.indexOf(text, index);321if (index === -1) break;322out.push(matchForIndex(file, index, kind, text));323index += Math.max(1, text.length);324}325if (out.length >= max) break;326}327return out;328}329330function matchForIndex(file, index, kind, needle) {331const line = file.content.slice(0, index).split('\n').length;332const lineText = file.lines[line - 1] || '';333return {334kind,335file: file.relativeFile,336line,337needle,338excerpt: lineText.trim().slice(0, 240),339};340}341342function isPathInsideOrEqual(cwd, file) {343const rel = path.relative(path.resolve(cwd), path.resolve(file));344return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));345}346347function normalizeText(value) {348return String(value || '').replace(/\s+/g, ' ').trim();349}350351function decodeBasicHtml(value) {352return value353.replace(/"/g, '"')354.replace(/'/g, "'")355.replace(/'/g, "'")356.replace(/&/g, '&')357.replace(/</g, '<')358.replace(/>/g, '>');359}360361function escapeRegExp(value) {362return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');363}364