@@ -20,8 +20,7 @@ export interface Segment {
2020}
2121interface Evidence { owners : ( string | null ) [ ] ; outOfScope : string [ ] }
2222interface TrackedLine { text : string ; evidence : Evidence ; origins : string [ ] ; moved : Evidence }
23- interface TrackedFile { lines : TrackedLine [ ] ; metadata : Evidence }
24- const lines = ( text : string | null | undefined ) : string [ ] => text ?. match ( / [ ^ \n ] * \n | [ ^ \n ] + $ / g) ?? [ ] ;
23+ interface TrackedFile { lines : TrackedLine [ ] ; metadata : Evidence ; metadataPaths : string [ ] }
2524const unique = < T > ( values : T [ ] ) : T [ ] => [ ...new Set ( values ) ] ;
2625const origin = ( path : string , i : number ) => `${ path } \0${ i } ` ;
2726function textFile ( file : FileVersion | null ) : boolean {
@@ -32,10 +31,6 @@ function metadataChange(delta: FileDelta): boolean {
3231 ! textFile ( delta . before ) || ! textFile ( delta . after ) ;
3332}
3433const empty = ( ) : Evidence => ( { owners : [ ] , outOfScope : [ ] } ) ;
35- const combine = ( ...evidence : Evidence [ ] ) : Evidence => ( {
36- owners : unique ( evidence . flatMap ( e => e . owners ) ) ,
37- outOfScope : unique ( evidence . flatMap ( e => e . outOfScope ) ) ,
38- } ) ;
3934function classify ( evidence : Evidence ) : Pick < Segment , 'row' | 'scope' > {
4035 const { owners, outOfScope } = evidence ;
4136 if ( ! owners . length || owners . includes ( null ) ) return { row : 'Unplanned' , scope : 'unplanned' } ;
@@ -44,72 +39,139 @@ function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
4439 return { row : owner , scope : outOfScope . includes ( owner ) ? 'out-of-scope' : 'in-scope' } ;
4540}
4641
42+ export interface LinkingLimits { maxLines ?: number ; maxSegments ?: number ; maxReferences ?: number ; maxDurationMs ?: number }
43+
4744/** Replays a linear history. Commit messages and Plan-Item trailers are never trusted. */
48- export function linkHistory ( plan : Plan , history : History , ledger : ReadonlyMap < string , string | null > , pathKey : ( path : string ) => string ) : Segment [ ] {
45+ export function linkHistory ( plan : Plan , history : History , ledger : ReadonlyMap < string , string | null > , pathKey : ( path : string ) => string , limits : LinkingLimits = { } ) : Segment [ ] {
4946 if ( typeof pathKey !== 'function' ) throw new Error ( 'Known checkout path identity is required.' ) ;
47+ const budget = ( value : number | undefined , ceiling : number , name : string ) => {
48+ const limit = value ?? ceiling ;
49+ if ( ! Number . isSafeInteger ( limit ) || limit < 1 || limit > ceiling ) throw new Error ( `Invalid ${ name } budget.` ) ;
50+ return limit ;
51+ } ;
52+ const maxLines = budget ( limits . maxLines , 100_000 , 'line' ) ;
53+ const maxSegments = budget ( limits . maxSegments , 100_000 , 'segment' ) ;
54+ const maxReferences = budget ( limits . maxReferences , 1_000_000 , 'reference' ) ;
55+ const deadline = performance . now ( ) + budget ( limits . maxDurationMs , 30_000 , 'duration' ) ;
56+ const remaining = ( ) => {
57+ const ms = deadline - performance . now ( ) ;
58+ if ( ms <= 0 ) throw new Error ( 'Linking exceeded its overall deadline.' ) ;
59+ return Math . max ( 1 , Math . min ( 2000 , Math . ceil ( ms ) ) ) ;
60+ } ;
61+ let lineCount = 0 , segmentCount = 0 , references = 0 , characters = 0 ;
62+ const charge = ( count : number ) => {
63+ remaining ( ) ; references += count ;
64+ if ( references > maxReferences ) throw new Error ( 'Linking exceeds its cumulative reference/work budget.' ) ;
65+ } ;
66+ const chargeText = ( count : number ) => {
67+ characters += count ;
68+ if ( characters > 32 * 1024 * 1024 ) throw new Error ( 'Linking exceeds its cumulative text/origin character budget.' ) ;
69+ } ;
70+ const lines = ( text : string | null | undefined ) : string [ ] => {
71+ remaining ( ) ;
72+ if ( ! text ) return [ ] ;
73+ chargeText ( text . length ) ;
74+ const result : string [ ] = [ ] ;
75+ for ( let start = 0 ; start < text . length ; ) {
76+ if ( ++ lineCount > maxLines ) throw new Error ( 'Linking exceeds its cumulative line budget.' ) ;
77+ remaining ( ) ;
78+ const newline = text . indexOf ( '\n' , start ) , end = newline < 0 ? text . length : newline + 1 ;
79+ result . push ( text . slice ( start , end ) ) ; start = end ;
80+ }
81+ return result ;
82+ } ;
83+ const combine = ( evidence : readonly Evidence [ ] ) : Evidence => {
84+ const owners = new Set < string | null > ( ) , outOfScope = new Set < string > ( ) ;
85+ for ( const entry of evidence ) {
86+ charge ( entry . owners . length + entry . outOfScope . length ) ;
87+ for ( const owner of entry . owners ) owners . add ( owner ) ;
88+ for ( const owner of entry . outOfScope ) outOfScope . add ( owner ) ;
89+ }
90+ return { owners : [ ...owners ] , outOfScope : [ ...outOfScope ] } ;
91+ } ;
92+ const mergeOrigins = ( tracked : readonly TrackedLine [ ] ) : string [ ] => {
93+ const origins = new Set < string > ( ) ;
94+ for ( const line of tracked ) { charge ( line . origins . length ) ; for ( const id of line . origins ) origins . add ( id ) ; }
95+ return [ ...origins ] ;
96+ } ;
5097 const files = new Map < string , TrackedFile > ( ) ;
5198 const removed = new Map < string , Evidence > ( ) ;
5299 // Deletions retain metadata even after the file leaves the tree.
53100 const metadata = new Map < string , Evidence > ( ) ;
54101 let parent = history . base ;
55102 for ( const commit of history . commits ) {
103+ remaining ( ) ;
56104 if ( commit . parent !== parent ) throw new Error ( 'Linking requires a contiguous linear history.' ) ;
57105 parent = commit . sha ;
58106 const owner = ledger . get ( commit . sha ) ?? null ;
59107 if ( owner !== null && ! plan . items . some ( item => item . id === owner ) ) throw new Error ( `Unknown ledger item: ${ owner } ` ) ;
60108 for ( const delta of commit . files ) {
109+ remaining ( ) ;
61110 const oldPath = delta . oldPath ;
62111 const item = plan . items . find ( item => item . id === owner ) ;
63112 const declared = new Set ( item ?. files . flatMap ( file => [ file . path , ...( file . renamed_from ? [ file . renamed_from ] : [ ] ) ] ) . map ( pathKey ) ) ;
64113 const touched = [ delta . oldPath , delta . newPath ] . filter ( ( path ) : path is string => path !== null ) ;
65114 const current : Evidence = { owners : [ owner ] , outOfScope : owner !== null && touched . some ( path => ! declared . has ( pathKey ( path ) ) ) ? [ owner ] : [ ] } ;
66115 let previous = oldPath ? files . get ( oldPath ) : undefined ;
67116 if ( ! previous ) previous = {
68- lines : lines ( textFile ( delta . before ) ? delta . before ! . text : '' ) . map ( ( text , i ) => ( { text, evidence : empty ( ) , origins : [ origin ( oldPath ! , i ) ] , moved : empty ( ) } ) ) ,
69- metadata : empty ( ) ,
117+ lines : lines ( textFile ( delta . before ) ? delta . before ! . text : '' ) . map ( ( text , i ) => {
118+ charge ( 1 ) ; chargeText ( ( oldPath ?. length ?? 0 ) + 12 ) ;
119+ return { text, evidence : empty ( ) , origins : [ origin ( oldPath ! , i ) ] , moved : empty ( ) } ;
120+ } ) ,
121+ metadata : empty ( ) , metadataPaths : oldPath ? [ oldPath ] : [ ] ,
70122 } ;
71123 const next : TrackedLine [ ] = [ ] ;
72- const changes = diffArrays ( previous . lines . map ( line => line . text ) , lines ( textFile ( delta . after ) ? delta . after ! . text : '' ) , { timeout : 2000 } ) ;
124+ const changes = diffArrays ( previous . lines . map ( line => line . text ) , lines ( textFile ( delta . after ) ? delta . after ! . text : '' ) , { timeout : remaining ( ) } ) ;
73125 if ( ! changes ) throw new Error ( 'Line attribution exceeded the diff time budget.' ) ;
74126 let cursor = 0 ;
75127 for ( let n = 0 ; n < changes . length ; n ++ ) {
76128 const change = changes [ n ] ! ;
77- if ( ! change . added && ! change . removed ) { next . push ( ... previous . lines . slice ( cursor , cursor + change . value . length ) ) ; cursor += change . value . length ; continue ; }
129+ if ( ! change . added && ! change . removed ) { for ( const line of previous . lines . slice ( cursor , cursor + change . value . length ) ) next . push ( line ) ; cursor += change . value . length ; continue ; }
78130 const deleted = change . removed ? previous . lines . slice ( cursor , cursor + change . value . length ) : [ ] ;
79131 if ( change . removed ) cursor += change . value . length ;
80- const evidence = combine ( ...deleted . map ( line => line . evidence ) , current ) ;
81- const origins = unique ( deleted . flatMap ( line => line . origins ) ) ;
132+ const evidence = combine ( [ ...deleted . map ( line => line . evidence ) , current ] ) ;
133+ const origins = mergeOrigins ( deleted ) ;
82134 for ( const id of origins ) removed . set ( id , evidence ) ;
83135 const added = change . added ? change : changes [ n + 1 ] ?. added ? changes [ ++ n ] ! : undefined ;
84- if ( added ) for ( const text of added . value ) next . push ( { text, evidence, origins, moved : combine ( ...deleted . map ( line => line . moved ) ) } ) ;
136+ if ( added ) {
137+ const moved = combine ( deleted . map ( line => line . moved ) ) ;
138+ for ( const text of added . value ) { charge ( origins . length + 1 ) ; next . push ( { text, evidence, origins, moved } ) ; }
139+ }
85140 }
86141 if ( oldPath && delta . newPath && oldPath !== delta . newPath ) {
87142 for ( let i = 0 ; i < next . length ; i ++ ) {
88143 const line = next [ i ] ! ;
89- next [ i ] = { ...line , moved : combine ( line . moved , current ) } ;
90- for ( const id of line . origins ) if ( ! removed . has ( id ) ) removed . set ( id , combine ( line . evidence , current ) ) ;
144+ next [ i ] = { ...line , moved : combine ( [ line . moved , current ] ) } ;
145+ charge ( line . origins . length ) ;
146+ for ( const id of line . origins ) if ( ! removed . has ( id ) ) removed . set ( id , combine ( [ line . evidence , current ] ) ) ;
91147 }
92148 }
93- const metadataEvidence = metadataChange ( delta ) ? combine ( previous . metadata , current ) : previous . metadata ;
94- if ( oldPath ) { files . delete ( oldPath ) ; metadata . set ( oldPath , metadataEvidence ) ; }
95- if ( delta . newPath ) { files . set ( delta . newPath , { lines : next , metadata : metadataEvidence } ) ; metadata . set ( delta . newPath , metadataEvidence ) ; }
149+ const metadataEvidence = metadataChange ( delta ) ? combine ( [ previous . metadata , current ] ) : previous . metadata ;
150+ const metadataPaths = unique ( [ ...previous . metadataPaths , ...touched ] ) ;
151+ charge ( metadataPaths . length ) ;
152+ for ( const path of metadataPaths ) metadata . set ( path , metadataEvidence ) ;
153+ if ( oldPath ) files . delete ( oldPath ) ;
154+ if ( delta . newPath ) files . set ( delta . newPath , { lines : next , metadata : metadataEvidence , metadataPaths } ) ;
96155 }
97156 }
98157 if ( parent !== history . head ) throw new Error ( 'History does not end at the requested head.' ) ;
99158 const segments : Segment [ ] = [ ] ;
100159 for ( const delta of history . final ) {
160+ remaining ( ) ;
101161 const path = delta . newPath ?? delta . oldPath ! ;
102162 const tracked = delta . newPath ? files . get ( delta . newPath ) : undefined ;
103163 const oldLines = lines ( textFile ( delta . before ) ? delta . before ! . text : '' ) ;
104164 const newLines = lines ( textFile ( delta . after ) ? delta . after ! . text : '' ) ;
105165 let oldIndex = 0 , newIndex = 0 , hunk = 0 ;
106166 const fileSegments : Segment [ ] = [ ] ;
107167 const affectedPaths = unique ( [ delta . oldPath , delta . newPath ] . filter ( ( p ) : p is string => p !== null ) ) ;
108- const push = ( part : Omit < Segment , 'row' | 'scope' | 'sharesHunkWith' | 'owners' > , evidence : Evidence ) => fileSegments . push ( {
109- ...part , owners : evidence . owners , ...classify ( evidence ) , sharesHunkWith : [ ] ,
110- } ) ;
168+ const push = ( part : Omit < Segment , 'row' | 'scope' | 'sharesHunkWith' | 'owners' > , evidence : Evidence ) => {
169+ remaining ( ) ;
170+ if ( ++ segmentCount > maxSegments ) throw new Error ( 'Linking exceeds its cumulative segment budget.' ) ;
171+ fileSegments . push ( { ...part , owners : evidence . owners , ...classify ( evidence ) , sharesHunkWith : [ ] } ) ;
172+ } ;
111173 if ( metadataChange ( delta ) ) {
112- const evidence = combine ( ... affectedPaths . map ( path => metadata . get ( path ) ?? empty ( ) ) ) ;
174+ const evidence = combine ( affectedPaths . map ( path => metadata . get ( path ) ?? empty ( ) ) ) ;
113175 push ( { path, oldPath : delta . oldPath , kind : 'file' , oldLine : null , newLine : null ,
114176 operation : null , context : '' , hunk : - 1 ,
115177 content : JSON . stringify ( { oldPath : delta . oldPath , newPath : delta . newPath ,
@@ -118,7 +180,7 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
118180 newObject : delta . after ? { kind : delta . after . mode === '160000' ? 'commit' : 'blob' , oid : delta . after . oid } : null } ) ,
119181 } , evidence ) ;
120182 }
121- const finalChanges = diffArrays ( oldLines , newLines , { timeout : 2000 } ) ;
183+ const finalChanges = diffArrays ( oldLines , newLines , { timeout : remaining ( ) } ) ;
122184 if ( ! finalChanges ) throw new Error ( 'Final diff exceeded the time budget.' ) ;
123185 for ( const change of finalChanges ) {
124186 if ( ! change . added && ! change . removed ) {
@@ -143,18 +205,46 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
143205 }
144206 // Adjacent lines with identical ownership/context form one segment.
145207 const grouped : Segment [ ] = [ ] ;
208+ let groupedLineCount = 0 ;
146209 for ( const part of fileSegments ) {
210+ remaining ( ) ;
147211 const last = grouped . at ( - 1 ) ;
148212 if ( last && part . kind === 'text' && last . kind === 'text' && last . hunk === part . hunk &&
149213 last . operation === part . operation && last . context === part . context && last . scope === part . scope &&
150- ( part . operation === '+' ? last . newLine ! + lines ( last . content ) . length === part . newLine : last . oldLine ! + lines ( last . content ) . length === part . oldLine ) &&
151- JSON . stringify ( last . owners ) === JSON . stringify ( part . owners ) ) last . content += part . content ;
152- else grouped . push ( { ...part } ) ;
214+ ( part . operation === '+' ? last . newLine ! + groupedLineCount === part . newLine : last . oldLine ! + groupedLineCount === part . oldLine ) &&
215+ JSON . stringify ( last . owners ) === JSON . stringify ( part . owners ) ) { last . content += part . content ; groupedLineCount ++ ; }
216+ else { grouped . push ( { ...part } ) ; groupedLineCount = part . kind === 'text' ? 1 : 0 ; }
217+ }
218+ const hunkRows = new Map < number , Map < string , Set < string > > > ( ) ;
219+ for ( const part of grouped ) {
220+ charge ( part . owners . length + 1 ) ;
221+ let rows = hunkRows . get ( part . hunk ) ;
222+ if ( ! rows ) { rows = new Map ( ) ; hunkRows . set ( part . hunk , rows ) ; }
223+ let owners = rows . get ( part . row ) ;
224+ if ( ! owners ) { owners = new Set ( ) ; rows . set ( part . row , owners ) ; }
225+ for ( const owner of part . owners ) if ( owner !== null ) owners . add ( owner ) ;
226+ }
227+ const sharing = new Map < number , Map < string , string [ ] > > ( ) ;
228+ for ( const [ hunk , rows ] of hunkRows ) {
229+ const byRow = new Map < string , string [ ] > ( ) ; sharing . set ( hunk , byRow ) ;
230+ for ( const row of rows . keys ( ) ) {
231+ const owners = new Set < string > ( ) ;
232+ for ( const [ otherRow , otherOwners ] of rows ) {
233+ charge ( 1 ) ;
234+ if ( row === otherRow ) continue ;
235+ charge ( otherOwners . size ) ;
236+ for ( const owner of otherOwners ) owners . add ( owner ) ;
237+ }
238+ byRow . set ( row , [ ...owners ] ) ;
239+ }
240+ }
241+ for ( const part of grouped ) {
242+ const owners = sharing . get ( part . hunk ) ! . get ( part . row ) ! ;
243+ charge ( owners . length ) ;
244+ part . sharesHunkWith = [ ...owners ] ;
153245 }
154- for ( const part of grouped ) part . sharesHunkWith = unique ( grouped
155- . filter ( other => other !== part && other . hunk === part . hunk && other . row !== part . row )
156- . flatMap ( other => other . owners . filter ( ( owner ) : owner is string => owner !== null ) ) ) ;
157- segments . push ( ...grouped ) ;
246+ for ( const part of grouped ) segments . push ( part ) ;
158247 }
248+ remaining ( ) ;
159249 return segments ;
160250}
0 commit comments