Follow

node-diffraction

Diff two folders, and try to be smart about it

Exports of a pattern library are hundreds of files, and a plain diff is useless: everything looks changed. node-diffraction compares two folders and tries to be smart about it — hashing files, beautifying the markup, clustering the changes and rendering them so a reviewer can see what actually moved.

It was built for exactly that: comparing pattern_exports dumps between two dates before a release.


FactDetail
InputTwo folders of pattern-library exports
TechniqueHashing, beautify, clustering, word-level diffs
StackNode (diff, difflib, set-clustering, rainbow-code)
StartedNovember 2018

What's in it

Clustered, not listed

Changes are grouped so the signal is visible before the details.

Made for markup

HTML-aware beautification before diffing — otherwise whitespace swamps the result.

In detail

What is actually inside it

Clustering before diffing

Grouping similar files first is what turns "412 files changed" into "three components changed, and here is how".

Paths as configuration

diffFromBasePath = 'diffs/20181128-pattern_exports' / '…20181203-pattern_exports': the tool is pointed at two dated export folders, which is exactly how a release review worked.

Markup-aware

HTML is beautified before comparison — without that, whitespace and attribute order swamp the signal and nobody reads the diff.

DetailValue
Built fordiffing pattern-library exports
Techniquehashing → beautify → clustering → word-level diff
StartedNovember 2018

Technical notes

The decisions behind it

Why not use a normal diff?
Because reviewers do not read CSS/HTML diffs. The tool's whole value was the ordering of information: which components changed, how big the change was, and only then the lines.
What are "pattern exports"?
Rendered output of the pattern library, exported per release as *-pattern_exports folders. Comparing two of those was the pre-release check that nothing regressed visually or structurally.

Inside the run

Seven steps, and the constants that decide what you see

diff.js is one file, and npm start is the whole entry point: node diff.js. Everything it knows about the world is two constants above the first function — in the last commit diffFromBasePath = 'diffs/20181128-pattern_exports' and diffToBasePath = 'diffs/20181203-pattern_exports'. The first commit compared 20181119 against 20181128; the second commit's real work was the report, and its configuration change was bumping those two strings to the next pair of exports.

Why a plain recursive diff was not an option: an export contains each pattern's rendered markup, including the markup of whatever it embeds, so one change to a shared element turns up verbatim in every export that includes it — the checked-in patch.html carries a single change record whose file list holds 32 different *.markup-only.html paths. A normal diff reports that as 32 separate changes, and the interesting question ("what actually changed, and how often?") never surfaces.

  1. Glob, not walk. glob-fs, created with { gitignore: false }, reads **/*.markup-only.html from both folders. Only the markup-only variant of a pattern is compared; the rest of the export never enters the run.
  2. Classify by path membership. Files present on both sides become unchangedFilePaths; everything else lands in addedFilePaths / removedFilePaths.
  3. Read everything up front. The union of both folders is read into one Map of path → string. No streaming, no caching — an export has to fit in memory.
  4. Diff with five lines of context. jsdiff.structuredPatch(from, to, a, b, '', '', { context: 5 }). The two empty strings suppress the file headers, because paths are rendered separately.
  5. Hash the hunk. sha256(hunk.lines.join()) is the change identity: jsdiff's prefixed lines (' ', '-', '+') joined by an argument-less join(), which joins with commas. Byte-identical hunks in different files collapse into one record with N file paths. Because the hash covers the context lines too, the same edit nested at a different depth counts as a different change — re-grouping those is what the next step is for.
  6. Cluster at 0.6. Similarity is new difflib.SequenceMatcher(null, a.hunk.lines.join(''), b.hunk.lines.join('')).ratio() — the concatenated hunk bodies scored by a 2012 port of Python's difflib. similarGroups(0.6) keeps groups whose members are at least 60 % alike.
  7. Render two kinds of page. diffractor/src/pages/index.html gets the group table (group id, similar changes, affected files, root-change breadcrumb, link); diffractor/src/pages/patches/patch-N.html gets the hunks, passed through rainbow.colorSync(lines.join('\n'), 'html') into <pre class="change__diff">.

One thing worth knowing before reading the output: js-beautify (indent 2, indent_inner_html) runs on the generated dashboard markup, not on the files being compared. The markup handed to the patcher is exactly what the export wrote, so the export's own formatting sets the noise floor for the entire report.

What a run actually produced

Numbers from the two report snapshots committed in the repo

4
Change groups
One release diff, as recorded in the checked-in report snapshot diffractor/src/pages/index.html (9.7 kB): 06-organisms/04-cards/_09-cards/corporate (2 changes, 3 files), 08-templates/01-artikel/03-pressemitteilung (1, 2), 05-molecules/quote/_quote (3, 11), 04-atoms/text/_blockquote (1, 1).
7
Changes after dedup
2 + 1 + 3 + 1 across those four groups. Each entry is one SHA-256 of one hunk — identical hunks had already collapsed into a single change carrying a list of affected files.
32
Files behind one change
Every one of the three changes in the checked-in patch.html snapshot (71.7 kB) lists the same 32 *.markup-only.html paths, spanning 08-templates-01-artikel-* and 09-pages-*. That repetition is the thing the tool exists to collapse. The snapshot's first title says 23 files while its own list holds 32 — it is a design mock, not regenerated output.
231
Lines, in total
diff.js is 231 lines and there is nothing else: no config file, no CLI flags, no test script. Pointing the tool at another pair of exports means editing two string constants and re-running it.

Two projects, one report

What diffractor/ does that diff.js does not

The report is a static site, and that site is not home-grown: diffractor/ is the ZURB Foundation for Sites template — its package.json still says foundation-zurb-template, "Official ZURB Template for Foundation for Sites", MIT, author ZURB. It brings gulp 4, Panini for Handlebars, webpack, Sass and a BrowserSync server on port 8000, with its own dependency tree and its own 345 kB yarn.lock sitting next to the runner's 68 kB package-lock.json.

diff.js never builds anything. It writes finished HTML into diffractor/src/pages/ and diffractor/src/pages/patches/, and Panini's src/pages/**/*.{html,hbs,handlebars} glob wraps those files in src/layouts/default.html on the next gulp run. The layout is titled "Changes in component library export", sets the page background to #F7F7F7, and pairs the Rainbow theme monokai.css with a rainbow.min.js in src/assets/lib/js/ — even though the hunks were already highlighted server-side by rainbow.colorSync; the emitted <code> has none of the data-language or lang-* hooks the client script looks for.

The dashboard look is assembled from Foundation building blocks (dashboard-table, stats-list, accordion, breadcrumbs), with the report-specific additions in app.scss (.change__diff { margin-bottom: 0 }, the absolutely positioned .logo, breadcrumbs inside table cells, zebra rows). One Foundation default had to be switched off for the diffs to be readable at all: _settings.scss sets $code-color: inherit, $code-background: transparent and $code-border: 0, so the highlighted lines are not framed by the grey code box every line would otherwise sit in.

Two leftovers from the first commit are still visible in the tree. The report rows used to be written to src/partials/inference/change-table-rows.html, and each patch page ended in <script src="js/rainbow.min.js"> — a relative path that could only resolve for pages at the site root. The second commit dropped both, moved diffractor/src/pages/index.html and tmp/ into .gitignore, and left the two snapshots index.html and patch.html committed alongside the generated files. The Dockerfile (node:carbon, .nvmrc at lts/carbon, CMD [ "npm", "start" ]) containerises only the runner; the gulp build stays on the machine.

Rough edges

One TODO file, and what else was still guesswork

Added and removed files go nowhere

addedFilePaths and removedFilePaths are filled while classifying the files and then never read again: only the intersection is diffed and rendered, so a pattern that appears or disappears between two exports is invisible in the report. That also hides a matching bug in the classification itself — the unchanged check is toFilePaths.indexOf(...) > 0, so a match in the first array position is counted as removed. It costs nothing today, but only because removed files are never displayed.

Breadcrumbs derived by three regexes

getFilePathBreadcrumb takes the path out of the filename and applies -(\d+) → /$1, (\d+-\w+)- → $1/ and -_ → /_, which is what turns 06-organisms-04-cards-_09-cards-corporate… into the four-step breadcrumb in the report. TODO.md opens with this function and three worked examples of what it should produce instead — level names upper-cased, pattern names kept whole: 08-templates-03-corporate-01-cw-start-page → 08-TEMPLATES / 03-CORPORATE / 01-CW / START-PAGE, 05-molecules-modal-modal → 05-MOLECULES / MODAL-MODAL. It was the last piece of the pipeline that was still guesswork.

No tests, no lint run, floating dependency

package.json has exactly one script (start) and one devDependency (eslint, never invoked); the config it would use declares ecmaVersion: 5 with airbnb-base/legacy, which cannot parse the async and arrow code in diff.js. The same file assigns filePathSegments without a declaration (an implicit global), and the de-duplication inside getUniqueFilepaths is a no-op because the .filter() result is never assigned — so an "Affected files" figure counts file slots, not distinct files. set-clustering is pulled from github:deestan/set-clustering#master rather than from the published npm releases (latest 1.1.0), so a fresh install depends on what master happens to be that day.

Threshold 0.6 is applied transitively

similarGroups returns connected components, not an average distance: if A≈B and B≈C both clear 60 %, A and C end up in the same group however little they share. Lowering the threshold does not simply merge more changes, it merges them by chaining. The cost is a full N×N table with the ratio function called once per pair and no memoisation — fine for a few hundred hunks, quadratic from there. TODO.md's second item is the same problem from the other side: an insert plus a delete that reorders code produces two nearly identical root-change entries that the threshold never combines.