Skill Tracer
Skills › Archify › archify › diff.mjs

diff.mjs

124 lines · archify/explainer/diff.mjs at a406594

1import { containsExplainerSecret } from './sources.mjs';
2
3const EXAMPLE = '@@ -1,2 +1,2 @@\n unchanged\n-old value\n+new value';
4
5function sourceLines(text) {
6 const result=[];
7 for (const match of text.matchAll(/([^\n]*)(\n|$)/gu)) {
8 if (!match[0]) continue;
9 const crlf=match[2] && match[1].endsWith('\r');
10 result.push({raw:crlf?match[1].slice(0,-1):match[1],ending:crlf?'\r\n':match[2]});
11 }
12 return result;
15function fileName(value) {
16 let name=value.split('\t')[0];
17 if (name.startsWith('"') && name.endsWith('"')) {
18 try { name=JSON.parse(name); } catch { name=name.slice(1,-1); }
19 }
20 if (name==='/dev/null') return null;
21 return name.replace(/^[ab]\//u,'');
24/** Parse unified diff without rewriting its literal bytes. Fragment line numbers stay null.
25 * File paths identify credential syntax only; this parser performs no filesystem reads.
26 */
27export function parseExplainerDiff(text, {line=1,component='diff',input,panelId,path} = {}) {
28 if (typeof text!=='string') throw new TypeError('Diff input must be a string');
29 const rows=[],diagnostics=[],counts={added:0,removed:0,context:0,hunks:0,files:0};
30 let hunk=null,oldPath=null,newPath=null,fileIndex=-1,oldHeader=false,newHeader=false,oldHeaderLine=line,lastBody=null,markerFor=null;
31 let format='fragment';
32 const groups=new Map();
33 function fail(code,sourceLine,reason,example=EXAMPLE) {
34 diagnostics.push({code:`explainer/diff-${code}`,severity:'error',message:reason,
35 subject:{line:sourceLine,column:1,component,...(input?{input}:{}),...(panelId?{panelId}:{}),...(path?{path}:{})},
36 evidence:{reason,example},supportedFixes:[`Use a valid unified diff, for example:\n${example}`]});
37 }
38 function finishHunk() {
39 if (!hunk) return;
40 if (hunk.usedOld!==hunk.oldCount || hunk.usedNew!==hunk.newCount) fail('hunk-count',hunk.sourceLine,
41 `Hunk declares ${hunk.oldCount} old and ${hunk.newCount} new lines, but contains ${hunk.usedOld} old and ${hunk.usedNew} new lines.`);
42 hunk=null;
43 }
44 function startFile() {
45 if(oldHeader&&!newHeader)fail('header-order',oldHeaderLine,'An old-file header needs its matching new-file header.','--- a/file.txt\n+++ b/file.txt\n'+EXAMPLE);
46 finishHunk();fileIndex++;counts.files++;oldPath=null;newPath=null;oldHeader=false;newHeader=false;lastBody=null;markerFor=null;format='unified';
47 }
48 function row(kind,value,index,extra={}) {
49 const entry={kind,raw:value.raw,text:value.raw,ending:value.ending,sourceLine:line+index,oldLine:null,newLine:null,...extra};
50 const knownPath=kind==='removed' ? oldPath||newPath : newPath||oldPath;
51 if (knownPath) entry.filePath=knownPath;
52 if (kind==='header'||kind==='hunk') {lastBody=null;markerFor=null;}
53 rows.push(entry);return entry;
54 }
55 const values=sourceLines(text);
56 for (const [index,value] of values.entries()) {
57 const raw=value.raw,sourceLine=line+index;
58 if (/^(?:diff --(?:cc|combined)\b|@@@)/u.test(raw)) {finishHunk();fail('combined',sourceLine,'Combined merge diffs are unsupported. Provide a two-way unified diff.');row('header',value,index);continue;}
59 if (/^(?:GIT binary patch|Binary files .+ differ|literal \d+$|delta \d+$)/u.test(raw)) {finishHunk();fail('binary',sourceLine,'Binary patches are unsupported. Describe the binary change separately.');row('header',value,index);continue;}
60 if (raw.startsWith('diff --git ')) {
61 if(!/^diff --git .+ .+$/u.test(raw))fail('header',sourceLine,'A git diff header must identify old and new paths.','diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n'+EXAMPLE);
62 startFile();row('header',value,index);continue;
63 }
64 if (raw.startsWith('@@')) {
65 finishHunk();format='unified';
66 const match=raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:[ \t].*)?$/u);
67 if (!match) {fail('hunk-header',sourceLine,'A hunk header requires old and new start/count ranges.');row('hunk',value,index);continue;}
68 const [oldStart,oldCount,newStart,newCount]=[match[1],match[2]??'1',match[3],match[4]??'1'].map(Number);
69 const valid=[oldStart,oldCount,newStart,newCount].every(Number.isSafeInteger)
70 && Number.isSafeInteger(oldStart+oldCount)&&Number.isSafeInteger(newStart+newCount)
71 && (oldCount===0||oldStart>=1)&&(newCount===0||newStart>=1);
72 if(!valid){fail('hunk-range',sourceLine,'Hunk ranges must use safe nonnegative integers; a nonempty range starts at line 1 or later.');row('hunk',value,index);continue;}
73 counts.hunks++;hunk={oldStart,oldCount,newStart,newCount,usedOld:0,usedNew:0,sourceLine};lastBody=null;markerFor=null;
74 row('hunk',value,index,{oldStart,oldCount,newStart,newCount});continue;
75 }
76 // Once the declared hunk is complete, a file header belongs to the next file.
77 // Within an unfinished hunk, +++/--- remain ordinary additions/removals.
78 const within=hunk&&(hunk.usedOld<hunk.oldCount||hunk.usedNew<hunk.newCount);
79 if(!within && /^--- /u.test(raw)) {
80 finishHunk();if(oldHeader&&newHeader)startFile();else if(fileIndex<0)startFile();
81 oldPath=fileName(raw.slice(4));oldHeader=true;oldHeaderLine=sourceLine;newHeader=false;row('header',value,index);continue;
82 }
83 if(!within && /^\+\+\+ /u.test(raw)) {
84 finishHunk();format='unified';if(!oldHeader)fail('header-order',sourceLine,'A new-file header needs its preceding old-file header.','--- a/file.txt\n+++ b/file.txt\n'+EXAMPLE);
85 newPath=fileName(raw.slice(4));newHeader=true;row('header',value,index);continue;
86 }
87 if (/^(?:index |(?:new file|deleted file|old|new) mode |(?:similarity|dissimilarity) index |(?:rename|copy) (?:from|to) )/u.test(raw)) {
88 finishHunk();format='unified';
89 const valid=/^index [a-fA-F0-9]{1,64}\.\.[a-fA-F0-9]{1,64}(?: [0-7]{6})?$/u.test(raw)
90 ||/^(?:new file|deleted file|old|new) mode [0-7]{6}$/u.test(raw)
91 ||/^(?:rename|copy) (?:from|to) .+$/u.test(raw)
92 ||(/^(?:similarity|dissimilarity) index (\d+)%$/u.test(raw)&&Number(raw.match(/(\d+)%$/u)[1])<=100);
93 if(!valid)fail('header',sourceLine,'This git metadata header is malformed.','diff --git a/file.txt b/file.txt\nindex 1234567..abcdef0 100644\n--- a/file.txt\n+++ b/file.txt\n'+EXAMPLE);
94 row('header',value,index);lastBody=null;markerFor=null;continue;
95 }
96 if(raw==='\\ No newline at end of file') {
97 if(!lastBody||markerFor===lastBody)fail('newline-marker',sourceLine,'A no-newline marker must immediately follow one content row and cannot repeat for that row.','@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file');
98 row('no-newline',value,index,{...(lastBody?{forLine:lastBody.sourceLine}:{})});markerFor=lastBody;continue;
99 }
100 const kind=raw.startsWith('+')?'added':raw.startsWith('-')?'removed':raw.startsWith(' ')?'context':null;
101 if(!kind) {fail('row',sourceLine,'Diff content rows must start with a space, + or -.');row('header',value,index);lastBody=null;markerFor=null;continue;}
102 const entry=row(kind,value,index,{text:raw.slice(1)});counts[kind]++;
103 if(hunk) {
104 if(kind!=='added')entry.oldLine=hunk.oldStart+hunk.usedOld++;
105 if(kind!=='removed')entry.newLine=hunk.newStart+hunk.usedNew++;
106 }
107 lastBody=entry;markerFor=null;
108 if(!groups.has(fileIndex))groups.set(fileIndex,[]);groups.get(fileIndex).push(entry);
109 }
110 finishHunk();
111 if(oldHeader&&!newHeader)fail('header-order',rows.at(-1)?.sourceLine||line,'An old-file header needs its matching new-file header.','--- a/file.txt\n+++ b/file.txt\n'+EXAMPLE);
112 if(!rows.length)fail('empty',line,'A diff must contain content or a standard git metadata change.');
113 // Scan the whole authored content and pinpoint a matching row when possible.
114 // The shared scanner owns credential rules; diagnostics never quote rejected bytes.
115 for(const group of groups.values()) {
116 const knownPaths=[...new Set(group.map(entry=>entry.filePath).filter(Boolean))];
117 const combined=group.map(entry=>entry.text).join('\n');
118 if((knownPaths.length?knownPaths:[undefined]).some(knownPath=>containsExplainerSecret(combined,knownPath))) {
119 const matched=group.find(entry=>containsExplainerSecret(entry.text,entry.filePath));
120 fail('secret',matched?.sourceLine||group[0].sourceLine,'The diff contains a credential or sensitive secret value. Remove or redact it before rendering.','-const timeout = 100;\n+const timeout = 200;');
121 }
122 }
123 return {rows,counts,diagnostics,format};