@preventive/compare 1.2.8 → 1.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/compare.d.ts CHANGED
@@ -120,11 +120,17 @@ export interface LineOptions {
120
120
  // lowercase letters alone), as `index-CyWAfh-p.js` and `index-hRvO4yU2.js`
121
121
  // are, or `main.3f9a2c1d.chunk.js` and `main.8b7e6a5f.chunk.js`. Minified onto
122
122
  // a line, such a chunk scores little as git splits it, so it is scored with
123
- // its spans ending after `,` `;` `=` `{` `}` too, and taken at git's stricter
124
- // score for a basename, halfway from -M's to 100%: the best pairs first, its
125
- // similarity that score, giving way as a pair by path does but only where git
126
- // takes the candidate it gives way to, and none past the rename limit, as they
127
- // are scored. git has no such passes, so false is git's own -M.
123
+ // its spans ending after `,` `;` `=` `{` `}` too, and taken however it scores
124
+ // where each is the other's only such chunk and their names are one but for a
125
+ // hash as a build writes one -- the last 8 to 32 of those characters after a
126
+ // `-` or `.`, with a letter, and a digit or a capital, no date nor a name's
127
+ // part, nor two of one format, a character of a kind in each place -- as a
128
+ // pair by path is (a build's chunk rewritten: playwright's
129
+ // dashboard `index-*.js`, 31% alike from 1.61.1 to 1.62.0), else at git's
130
+ // stricter score for a basename, halfway from -M's to 100%: the best pairs
131
+ // first, its similarity that score, giving way as a pair by path does but only
132
+ // where git takes the candidate it gives way to, and none past the rename
133
+ // limit, as they are scored. git has no such passes, so false is git's own -M.
128
134
  //
129
135
  // `renameLimit` is git's diff.renameLimit: when more deleted files times added
130
136
  // files are left, in a group, after the renames that need no scoring than its
package/compare.js CHANGED
@@ -23,7 +23,9 @@ export { renameParts } from './src/paths.js'
23
23
 
24
24
  // Two trees taken from different roots lined up: the root between them and
25
25
  // each old path, directory and package directory as the new tree lays it out.
26
- export { layoutDir, layoutPackageDirs, rootLabel, treeLayout } from './src/layout.js'
26
+ // The line and path helpers a view reads alone are in view.js too.
27
+ export { layoutPackageDirs, treeLayout } from './src/layout.js'
28
+ export { layoutDir, rootLabel } from './src/root.js'
27
29
 
28
30
  // Whether a TypeScript text holds types alone, so that Node runs nothing of it,
29
31
  // as compareTrees stamps a declaration file's change: proven on Node alone, where
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/compare",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "Git-compatible file tree diffs and minified JavaScript comparison",
5
5
  "type": "module",
6
6
  "main": "./compare.js",
@@ -21,6 +21,10 @@
21
21
  "./pretty.js": {
22
22
  "types": "./pretty.d.ts",
23
23
  "default": "./pretty.js"
24
+ },
25
+ "./view.js": {
26
+ "types": "./view.d.ts",
27
+ "default": "./view.js"
24
28
  }
25
29
  },
26
30
  "imports": {
@@ -46,6 +50,8 @@
46
50
  "names.js",
47
51
  "pretty.d.ts",
48
52
  "pretty.js",
53
+ "view.d.ts",
54
+ "view.js",
49
55
  "src/blocks.js",
50
56
  "src/changes.js",
51
57
  "src/chunks.js",
@@ -71,6 +77,7 @@
71
77
  "src/pretty.js",
72
78
  "src/quote.js",
73
79
  "src/renames.js",
80
+ "src/root.js",
74
81
  "src/same-bytes.js",
75
82
  "src/same-bytes.node.js",
76
83
  "src/script.js",
package/src/chunks.js CHANGED
@@ -10,6 +10,42 @@ const HASH = /^[\w-]{6,}$/u
10
10
  const RANDOM = /[\dA-Z]/u
11
11
  const SEPARATORS = '-._'
12
12
 
13
+ // A name's readings as one a build gave a hash, `[name]-[hash]` or
14
+ // `[name].[contenthash]`, the shortest hash first: `[name, hash, extensions]`,
15
+ // the hash 8 to 32 of base64url's characters after a `-` or `.`, with a
16
+ // letter, and a digit or a capital, and only extensions after it, a `-` in
17
+ // one too (`main.AbCd1234.hot-update.js`). None for a date
18
+ // (`migration-20240101.js`) or a word (`lodash.debounce.js`).
19
+ const EXTENSIONS = /^(?:\.[\w-]+)+$/u, HASHED = /^[\w-]{8,32}$/u
20
+ function readings(name) {
21
+ const found = []
22
+ for (let at = name.length - 1; at > 0; at--) {
23
+ if (name[at] !== '-' && name[at] !== '.') continue
24
+ const end = name.indexOf('.', at + 1), extensions = name.slice(end), hash = name.slice(at + 1, end)
25
+ if (end !== -1 && HASHED.test(hash) && EXTENSIONS.test(extensions) && /[A-Za-z]/u.test(hash) && RANDOM.test(hash)) found.push([name.slice(0, at), hash, extensions])
26
+ }
27
+ return found
28
+ }
29
+
30
+ // Whether two names are one but for a hash: the shortest reading of one whose
31
+ // hash the other's reading with those extensions does not share, its name
32
+ // theirs. A part both share is an extension's (`index-AbCd1234.production2024.js`);
33
+ // a `-` in a hash is the hash's (`index-CyWAfh-p.js`, `codeMirrorModule--QdMvsKi.css`)
34
+ // and one before eight more the name's (`vendor-react-dom-AbCd1234.js`). Two
35
+ // with a character of a kind in each place, a digit for a digit, a capital for
36
+ // a capital, are of one format, a time's or a version's
37
+ // (`migration-20240101T120000.js`, `migration-2024-Oct-14.js`), as two hashes
38
+ // all but never are.
39
+ const shape = (text) => text.replaceAll(/\d/gu, '0').replaceAll(/[a-z]/gu, 'a').replaceAll(/[A-Z]/gu, 'A')
40
+ function oneButHash(a, b) {
41
+ const others = readings(b)
42
+ for (const [name, hash, extensions] of readings(a)) {
43
+ const other = others.find((reading) => reading[2] === extensions)
44
+ if (other && other[1] !== hash) return other[0] === name && shape(other[1]) !== shape(hash)
45
+ }
46
+ return false
47
+ }
48
+
13
49
  function nameOf(path) {
14
50
  const slash = path.lastIndexOf('/')
15
51
  const name = path.slice(slash + 1)
@@ -32,25 +68,30 @@ function oneChunk(a, b) {
32
68
  }
33
69
 
34
70
  // Chunks git's scored pass has yet to claim, by directory, extension and
35
- // name's length, each pair at least `bar`: the best first, as git takes its
36
- // candidates, but for a pair `outdone` by a file's other candidate, set aside
37
- // and returned, best first, for git to pair that candidate with another file:
38
- // it holds its files, so that a later pair of either is set aside behind it.
71
+ // name's length, each pair at least `bar` -- or however it scores, where each
72
+ // is the other's only such chunk and their names are one but for a hash
73
+ // (oneButHash), as a pair by path is: the build's own chunk, rewritten
74
+ // (playwright's dashboard `index-*.js`, 31% alike from 1.61 to 1.62):
75
+ // the best first, as git takes its candidates, but for a pair `outdone` by a
76
+ // file's other candidate, set aside and returned, best first, for git to pair
77
+ // that candidate with another file: it holds its files, so that a later pair
78
+ // of either is set aside behind it.
39
79
  export function chunkRenames({ sources, dests, pairs, used, record, punctuatedScoreOf }, { remaining, open }, bar, outdone) {
40
80
  const names = (files, indices) => new Map(indices.filter((i) => files[i].type === 'file').map((i) => [i, nameOf(files[i].path)]))
41
81
  const destNames = names(dests, open), sourceNames = names(sources, remaining)
42
82
  const byKey = Map.groupBy(sourceNames.keys(), (s) => sourceNames.get(s).key)
43
83
  const opens = Map.groupBy(destNames.keys(), (d) => destNames.get(d).key)
44
- const candidates = []
84
+ const chunks = []
45
85
  for (const [key, ds] of opens) {
46
86
  const ss = byKey.get(key) ?? []
47
- for (const d of ds) {
48
- for (const s of ss) {
49
- if (!oneChunk(sourceNames.get(s).name, destNames.get(d).name)) continue
50
- const score = punctuatedScoreOf(s, d, bar)
51
- if (score >= bar) candidates.push({ s, d, score })
52
- }
53
- }
87
+ for (const d of ds) for (const s of ss) if (oneChunk(sourceNames.get(s).name, destNames.get(d).name)) chunks.push({ s, d })
88
+ }
89
+ const byDest = Map.groupBy(chunks, ({ d }) => d), bySource = Map.groupBy(chunks, ({ s }) => s)
90
+ const candidates = []
91
+ for (const { s, d } of chunks) {
92
+ const only = bySource.get(s).length === 1 && byDest.get(d).length === 1 && oneButHash(sourceNames.get(s).name, destNames.get(d).name)
93
+ const score = punctuatedScoreOf(s, d, only ? 0 : bar)
94
+ if (only || score >= bar) candidates.push({ s, d, score })
54
95
  }
55
96
  const aside = [], heldDests = new Set(), heldSources = new Set()
56
97
  for (const pair of candidates.sort((x, y) => y.score - x.score)) {
package/src/layout.js CHANGED
@@ -1,14 +1,7 @@
1
1
  // Two trees taken from different roots lined up: the directory one's files sit
2
2
  // under in the other (`root`), and each old path as the new tree lays it out.
3
3
 
4
- function throughRoot(root, path) {
5
- if (!root) return path
6
- if (root.base === '') return `${root.other}${path}`
7
- return path.startsWith(root.base) ? path.slice(root.base.length) : null
8
- }
9
-
10
- // A path outside a root as one inside it reaches it.
11
- const outside = (root, path) => `${'../'.repeat(root.base.split('/').length - 1)}${path}`
4
+ import { layoutDir, outside, throughRoot } from './root.js'
12
5
 
13
6
  // Each old path as the new tree lays it out: through the root where the new
14
7
  // tree has it there, else as it is where it has that, else through the root,
@@ -70,25 +63,9 @@ export function treeLayout(basePaths, otherPaths) {
70
63
  return { root, paths: rootPaths(base, other, root).paths }
71
64
  }
72
65
 
73
- export function layoutDir({ root, paths }, dir, files) {
74
- if (!root || dir === '.') return dir
75
- for (const file of files) {
76
- const to = paths.get(`${dir}/${file}`)
77
- if (to?.endsWith(`/${file}`)) return to.slice(0, -file.length - 1)
78
- }
79
- const to = throughRoot(root, `${dir}/`)
80
- return to === null ? outside(root, dir) : to.slice(0, -1) || '.'
81
- }
82
-
83
66
  export function layoutPackageDirs(baseDirs, otherDirs, layout) {
84
67
  if (!baseDirs && !otherDirs) return null
85
68
  const files = Map.groupBy([...baseDirs ?? []], ([, dir]) => dir)
86
69
  const dirs = new Map([...files].map(([dir, entries]) => [dir, layoutDir(layout, dir, entries.map(([path]) => path.slice(dir.length + 1)))]))
87
70
  return new Map([...[...baseDirs ?? []].map(([path, dir]) => [layout.paths.get(path) ?? path, dirs.get(dir)]), ...otherDirs ?? []])
88
71
  }
89
-
90
- export function rootLabel(root, prefix) {
91
- if (!root) return { prefix, from: null, to: null }
92
- const kept = prefix.startsWith(root.other) ? prefix : ''
93
- return { prefix: kept, from: `./${root.base}${kept.slice(root.other.length)}`, to: `./${kept || root.other}` }
94
- }
@@ -53,6 +53,7 @@ const fnOf = (where) => (where.fn ? where : fnOf(where.parent))
53
53
  function join(s, kept, gone) {
54
54
  if (kept === gone || !s.variables.includes(gone)) return
55
55
  for (const path of gone.through ?? []) (kept.through ??= []).push(path)
56
+ kept.varIn ??= gone.varIn
56
57
  for (const at of s.scopes) for (const [name, variable] of at.names) if (variable === gone) at.names.set(name, kept)
57
58
  for (const [node, [variable, where]] of s.declarations) if (variable === gone) s.declarations.set(node, [kept, where])
58
59
  s.variables.splice(s.variables.indexOf(gone), 1)
@@ -64,6 +65,7 @@ function declareVariables(node, where, s) {
64
65
  for (const id of node.declarations.flatMap((declarator) => declared(declarator.id))) {
65
66
  const variable = declare(s, node.kind === 'var' ? fnOf(where) : where, id)
66
67
  if (node.kind !== 'var') continue
68
+ variable.varIn = fnOf(where)
67
69
  const path = []
68
70
  for (let at = where; !at.fn; at = at.parent) path.push(at)
69
71
  if (path.length > 0) (variable.through ??= []).push(path)
@@ -111,6 +113,17 @@ function namingNames(node, s) {
111
113
  }
112
114
  }
113
115
 
116
+ // Names a variable is set by after its declaration: an assignment's, an
117
+ // update's, or a loop's that declares none.
118
+ function writtenNames(node, s) {
119
+ switch (node.type) {
120
+ case 'AssignmentExpression': for (const id of declared(node.left)) s.writing.add(id); break
121
+ case 'UpdateExpression': for (const id of declared(node.argument)) s.writing.add(id); break
122
+ case 'ForInStatement': case 'ForOfStatement': if (node.left.type !== 'VariableDeclaration') for (const id of declared(node.left)) s.writing.add(id); break
123
+ default: break
124
+ }
125
+ }
126
+
114
127
  // Names that are no variable's (keys, labels), names kept, and the properties
115
128
  // names are read through.
116
129
  function otherNames(node, s) {
@@ -166,7 +179,7 @@ function enterFunction(node, where, s) {
166
179
  for (const child of [node.superClass, node.body, ...node.decorators ?? []]) if (child) s.work.push([child, named])
167
180
  return
168
181
  }
169
- const parameters = scope(s, named, true)
182
+ const parameters = Object.assign(scope(s, named, true), { arrow: node.type === 'ArrowFunctionExpression', parameter: true })
170
183
  if (node.body?.type === 'BlockStatement' && saysStrict(node.body.body)) parameters.strict = true
171
184
  for (const parameter of node.params) {
172
185
  for (const id of declared(parameter)) declare(s, parameters, id)
@@ -202,7 +215,7 @@ function enter(node, where, s) {
202
215
 
203
216
  // A work list rather than a recursion: a minified file's expressions nest deeply.
204
217
  function walk(program) {
205
- const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), naming: new Set(), reads: new Set(), references: [], work: [] }
218
+ const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), naming: new Set(), writing: new Set(), reads: new Set(), references: [], work: [] }
206
219
  s.root = Object.assign(scope(s, null, true), { strict: program.sourceType === 'module' || saysStrict(program.body) })
207
220
  s.work.push([program, s.root])
208
221
  while (s.work.length > 0) {
@@ -212,6 +225,7 @@ function walk(program) {
212
225
  continue
213
226
  }
214
227
  namingNames(node, s)
228
+ writtenNames(node, s)
215
229
  const reason = visit(node, where, s)
216
230
  if (reason) return { reason }
217
231
  enter(node, where, s)
@@ -229,9 +243,29 @@ function resolved(program, s) {
229
243
  if (variable) occurrences.push([node, variable, where])
230
244
  else globals.push([node, where])
231
245
  }
246
+ // Set again (`reassigned`): written, or declared more than once, or a
247
+ // parameter of a function that reads `arguments`, which may write it
248
+ // (`arguments[0] = x`, outside strict code) -- an arrow's its function's,
249
+ // and a `var arguments` its function's own `arguments` still, unlike one a
250
+ // `let`, a parameter, a `catch` or an arrow's `var` declares.
251
+ const setting = new Set()
252
+ for (const [node, where] of globals) {
253
+ if (node.name !== 'arguments') continue
254
+ let at = where
255
+ while (at && !(at.parameter && !at.arrow)) at = at.parent
256
+ setting.add(at)
257
+ }
258
+ for (const variable of s.variables) {
259
+ const at = variable.varIn?.parameters ?? variable.varIn
260
+ if (variable.name === 'arguments' && at?.parameter && !at.arrow) setting.add(at)
261
+ }
262
+ for (const at of setting) for (const variable of at?.names.values() ?? []) variable.reassigned = true
263
+ const declaredAlready = new Set()
232
264
  for (const [node, variable] of occurrences) {
233
265
  if (s.keeping.has(node)) variable.kept = true
234
266
  if (s.naming.has(node)) variable.naming = true
267
+ if (s.writing.has(node) || (s.declarations.has(node) && declaredAlready.has(variable))) variable.reassigned = true
268
+ if (s.declarations.has(node)) declaredAlready.add(variable)
235
269
  }
236
270
  const own = program.sourceType === 'module' || program.sourceType === 'commonjs' || globals.some(([node]) => COMMONJS.has(node.name))
237
271
  for (const variable of s.variables) variable.aside = renamable(variable) && (own || variable.scope !== s.root)
@@ -245,7 +279,7 @@ function resolved(program, s) {
245
279
  }
246
280
  const deduplicated = occurrences.filter((entry, i) => i === 0 || entry[0].start !== occurrences[i - 1][0].start)
247
281
  const names = new Set([...s.variables.map(({ name }) => name), ...globals.map(([node]) => node.name)])
248
- return { scopes: s.scopes, variables: s.variables, occurrences: deduplicated, globals, names, reads: s.reads, module: program.sourceType === 'module', commonjs: program.sourceType === 'commonjs', own }
282
+ return { scopes: s.scopes, variables: s.variables, occurrences: deduplicated, declaring: new Set(s.declarations.keys()), globals, names, reads: s.reads, module: program.sourceType === 'module', commonjs: program.sourceType === 'commonjs', own }
249
283
  }
250
284
 
251
285
  const minified = (name) => name.length <= RENAMED_MAX_LENGTH && MINIFIED.test(name)
@@ -1,6 +1,8 @@
1
- // Which old variable became which new one: each pair of lines, as the diff of
2
- // their keys (names set aside) pairs them, counts its variables as standing for
3
- // each other, and two each the other's most counted pair, a tie pairing neither.
1
+ // Which old variable became which new one: a variable declared on a line each
2
+ // side holds once is the one declared there on the other; each pair of lines,
3
+ // as the diff of their keys (names set aside) pairs them, counts its variables
4
+ // as standing for each other, and two each the other's most counted pair, a tie
5
+ // pairing neither.
4
6
 
5
7
  import { shortestChangeSet } from '../text.js'
6
8
  import { RESERVED } from './syntax.js'
@@ -8,17 +10,22 @@ import { RESERVED } from './syntax.js'
8
10
  // Unchanged lines between changes that pair as unchanged ones, not as changed.
9
11
  const ANCHORED_RUN = 3
10
12
 
13
+ // Where a name stood in a key with a slot for each.
14
+ const SLOT = '\0'
15
+
11
16
  // Whitespace alone never tells a name apart.
12
17
  const KEYS = { horizon: 0, whitespace: 'all' }
13
18
 
14
19
  // A text's lines with its renamable variables' names set aside (`key`), but
15
20
  // those `written` names (a pair found), each line's variables set aside in
16
- // order (`lines`), and where in its key each stood (`places`).
17
- function keyed(text, { occurrences }, written = () => null) {
21
+ // order (`lines`), where in its key each stood (`places`), and with `slots`,
22
+ // whether it is declared there (`declares`) and the key with a slot where each
23
+ // stood (`slotted`), so that two keys alike stand for names alike.
24
+ function keyed(text, { occurrences, declaring }, written = () => null, slots = false) {
18
25
  const starts = [0]
19
26
  for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) starts.push(at + 1)
20
- const lines = starts.map(() => []), places = starts.map(() => [])
21
- let at = 0, key = '', line = 0, removed = 0
27
+ const declares = starts.map(() => []), lines = starts.map(() => []), places = starts.map(() => [])
28
+ let at = 0, key = '', line = 0, removed = 0, slotted = ''
22
29
  for (const [node, variable] of occurrences) {
23
30
  if (!variable.aside) continue
24
31
  const name = written(variable)
@@ -26,17 +33,19 @@ function keyed(text, { occurrences }, written = () => null) {
26
33
  if (name === null) {
27
34
  lines[line].push(variable)
28
35
  places[line].push(node.start - starts[line] - removed)
36
+ if (slots) declares[line].push(declaring.has(node))
29
37
  }
30
38
  key += text.slice(at, node.start) + (name ?? '')
39
+ if (slots) slotted += text.slice(at, node.start) + (name ?? SLOT)
31
40
  removed += node.end - node.start - (name?.length ?? 0)
32
41
  at = node.end
33
42
  }
34
43
  key += text.slice(at)
35
- return { key, keys: key.split('\n'), lines, places }
44
+ return { key, keys: key.split('\n'), lines, places, declares, slotted: slots ? slotted + text.slice(at) : null }
36
45
  }
37
46
 
38
47
  // Lines as a diff that sets whitespace aside compares them.
39
- const SPACE = /[ \t\n\v\f\r]/gu
48
+ const BLANKS = /[ \t\v\f\r]/gu, SPACE = /[ \t\n\v\f\r]/gu
40
49
  const plain = (lines) => lines.map((line) => line.replace(SPACE, ''))
41
50
 
42
51
  // The kept lines of each side a run of changes could stand in place of: a run
@@ -230,21 +239,67 @@ function matched({ backward, forward }, { map, paired, support }, eligible = ()
230
239
  return chosen
231
240
  }
232
241
 
242
+ // A variable set once is where it is declared, as its uses may come to read
243
+ // another: `function(e) { ...e.data... }` -> `function(e) { const i = f(e);
244
+ // ...i.data... }`, where more lines read the new `i` as the old `e` did than
245
+ // read `e` still. A line each side holds once, names set aside, is the same
246
+ // line, as patience diff has it, wherever a diff puts it: each variable
247
+ // declared there is the one declared in its place on the other side -- where
248
+ // lines counted say so too, as a short line may stand once on each side for
249
+ // code that is not the same, and where nothing else declared once says
250
+ // otherwise. One set again may be two variables a minifier made one, its
251
+ // declaration the first's alone (`var i = /\d/; ... i = q.prototype`): its
252
+ // lines say.
253
+ function declaredOnce(ka, kb, { changed, tokened, unchanged }) {
254
+ const once = (k) => {
255
+ const where = new Map()
256
+ k.slotted.replace(BLANKS, '').split('\n').forEach((key, line) => where.set(key, where.has(key) ? -1 : line))
257
+ return where
258
+ }
259
+ const from = new Map(), news = once(kb), olds = once(ka), to = new Map()
260
+ const add = (map, x, y) => map.set(x, map.has(x) && map.get(x) !== y ? null : y)
261
+ for (const [key, i] of olds) {
262
+ const j = news.get(key)
263
+ if (i === -1 || j === undefined || j === -1) continue
264
+ ka.lines[i].forEach((variable, n) => {
265
+ if (!ka.declares[i][n] || !kb.declares[j][n] || variable.reassigned || kb.lines[j][n].reassigned) return
266
+ add(to, variable, kb.lines[j][n])
267
+ add(from, kb.lines[j][n], variable)
268
+ })
269
+ }
270
+ // The first tier counting the pair, as were it found there: its count, and whether that tier's pairs are weak.
271
+ const tiers = [unchanged, changed, tokened]
272
+ return [...to].flatMap(([variable, other]) => {
273
+ const tier = tiers.findIndex(({ forward }) => forward.get(variable)?.has(other))
274
+ return other === null || from.get(other) !== variable || tier === -1 ? [] : [[variable, other, tiers[tier].forward.get(variable).get(other), tier > 0]]
275
+ })
276
+ }
277
+
233
278
  // Unchanged lines pair first, and code moved counted with them: a function's
234
279
  // code moved into a helper reads its variables as the helper's, the function's
235
280
  // own lines as its own, and the most lines decide. Changed ones then pair only
236
281
  // what those say nothing of, and a change's tokens only what no line says
237
282
  // anything of: lines reordered, a change's tokens may line up across them. Both
238
- // are `weak` pairs, the first to give way where names read wrongly. A second
239
- // pass reads the pairs found as names, so lines alike with every name set aside
240
- // and diffed against the wrong one line up by those.
283
+ // are `weak` pairs, the first to give way where names read wrongly; a pair
284
+ // declared once on each side, found before them all, gives way as the tier
285
+ // first counting it would have it. A second pass reads the pairs found as
286
+ // names, so lines alike with every name set aside and diffed against the wrong
287
+ // one line up by those.
241
288
  export function pairing(before, after, a, b) {
242
289
  const found = { map: new Map(), paired: new Set(), support: new Map() }, said = new Set(), weak = new Set()
243
290
  const passes = [[() => null, () => null], [(variable) => found.map.get(variable)?.name ?? null, (variable) => (found.paired.has(variable) ? variable.name : null)]]
244
- for (const [old, now] of passes) {
291
+ for (const [pass, [old, now]] of passes.entries()) {
245
292
  // A pass pairs nothing where every variable of a side is paired already.
246
293
  if (!a.variables.some((variable) => variable.aside && !found.map.has(variable)) || !b.variables.some((variable) => variable.aside && !found.paired.has(variable))) break
247
- const { changed, tokened, unchanged } = tallied(keyed(before, a, old), keyed(after, b, now))
294
+ // Lines declaring once, read in the first pass: the second finds few more, at a cost.
295
+ const ka = keyed(before, a, old, pass === 0), kb = keyed(after, b, now, pass === 0), lines = tallied(ka, kb)
296
+ const { changed, tokened, unchanged } = lines
297
+ for (const [variable, other, support, weakly] of pass === 0 ? declaredOnce(ka, kb, lines) : []) {
298
+ found.map.set(variable, other)
299
+ found.paired.add(other)
300
+ found.support.set(variable, support)
301
+ if (weakly) weak.add(variable)
302
+ }
248
303
  for (const [counts, tier] of [[unchanged, false], [changed, true], [tokened, true]]) {
249
304
  for (const variable of matched(counts, found, (from, to) => !said.has(from) && !said.has(to))) if (tier) weak.add(variable)
250
305
  for (const variable of [...counts.forward.keys(), ...counts.backward.keys()]) said.add(variable)
package/src/renames.js CHANGED
@@ -193,7 +193,8 @@ const unpaired = ({ sources, dests, pairs, used }) => ({
193
193
  })
194
194
 
195
195
  // git's stricter score, halfway from -M's to 100%, for a pair that names alone
196
- // put forward: a basename both sides have once, or a chunk's.
196
+ // put forward: a basename both sides have once, or a chunk's of a name either
197
+ // side has more of.
197
198
  const stricter = (minimum) => minimum + Math.trunc(0.5 * (MAX_SCORE - minimum))
198
199
 
199
200
  // Each added file's rename by its index in `dests`: { source, score }. A
package/src/root.js ADDED
@@ -0,0 +1,29 @@
1
+ // A path through the root two trees are lined up by (layout.js treeLayout),
2
+ // and what a view of them shows of it: a directory of the old tree as the new
3
+ // one lays it out, and the root as a heading reads it. Kept apart from what
4
+ // finds the root, so a view that is given its trees lined up loads none of that.
5
+
6
+ export function throughRoot(root, path) {
7
+ if (!root) return path
8
+ if (root.base === '') return `${root.other}${path}`
9
+ return path.startsWith(root.base) ? path.slice(root.base.length) : null
10
+ }
11
+
12
+ // A path outside a root as one inside it reaches it.
13
+ export const outside = (root, path) => `${'../'.repeat(root.base.split('/').length - 1)}${path}`
14
+
15
+ export function layoutDir({ root, paths }, dir, files) {
16
+ if (!root || dir === '.') return dir
17
+ for (const file of files) {
18
+ const to = paths.get(`${dir}/${file}`)
19
+ if (to?.endsWith(`/${file}`)) return to.slice(0, -file.length - 1)
20
+ }
21
+ const to = throughRoot(root, `${dir}/`)
22
+ return to === null ? outside(root, dir) : to.slice(0, -1) || '.'
23
+ }
24
+
25
+ export function rootLabel(root, prefix) {
26
+ if (!root) return { prefix, from: null, to: null }
27
+ const kept = prefix.startsWith(root.other) ? prefix : ''
28
+ return { prefix: kept, from: `./${root.base}${kept.slice(root.other.length)}`, to: `./${kept || root.other}` }
29
+ }
package/view.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ // What view.js gives, as compare.js gives it.
2
+ export { hidesMixedWhitespace, layoutDir, pairLines, renameParts, rootLabel, wordRanges } from './compare.js'
3
+ export type { Layout, Root } from './compare.js'
package/view.js ADDED
@@ -0,0 +1,14 @@
1
+ // What a view of two trees' changes reads them by, given the files they pair:
2
+ // changed lines read word by word, which removed line was edited into which
3
+ // added one, and whether diff -w hides blanks of more than one kind changing;
4
+ // a rename written as `git diff --stat` writes one; and a directory and the
5
+ // root as the new tree lays them out. compare.js gives these too; this loads
6
+ // none of compareTrees, nor what finds the root, so a view whose files are
7
+ // paired elsewhere (a server's) loads none of the pairing. Typed and described
8
+ // in compare.d.ts.
9
+
10
+ export { pairLines } from './src/pairs.js'
11
+ export { wordRanges } from './src/words.js'
12
+ export { hidesMixedWhitespace } from './src/whitespace.js'
13
+ export { renameParts } from './src/paths.js'
14
+ export { layoutDir, rootLabel } from './src/root.js'