diff --git a/lib/consistency-audit.mjs b/lib/consistency-audit.mjs index 34ed9fa..750cda6 100644 --- a/lib/consistency-audit.mjs +++ b/lib/consistency-audit.mjs @@ -16,9 +16,10 @@ // that exists, a lockfile, a lint gate. Each absence is reported with the reason it // matters, once, and never as a score. // -// Metadata only: no build output, no install, no bytes, no network, and no npm -// dependency. The one value from the network, the major on nextcloud/server master, -// is read by make and handed in. It runs on any Nextcloud app, one that never heard +// Metadata only: no build output, no install, no bytes, and no npm dependency. +// From the network it reads only whether the links of appinfo/info.xml answer, +// which --no-network leaves out; the major on nextcloud/server master is read by +// make and handed in. It runs on any Nextcloud app, one that never heard // of ncmake included. What the checkout does not state is reported as undetermined // rather than guessed. Node 18 or newer. @@ -45,10 +46,12 @@ const defaults = { details: false, // every table; the default is the verdict alone json: false, jsonFile: null, + // The links of info.xml are the one thing the analyser reads from the network + // itself; make hands NO_NETWORK=1 in as CONSISTENCY_AUDIT_NO_NETWORK. + network: process.env.CONSISTENCY_AUDIT_NO_NETWORK !== '1', }; -// The server releases this file knows about. An audit that reads no network cannot -// look them up, so the state is written down with the month it was taken in, and +// The server releases this file knows about. The audit does not look them up, so the state is written down with the month it was taken in, and // the report names that month wherever it judges by it. The analyser is refreshed // from the ncmake cache like every module, which is what keeps this current. const NC = { newest: 35, oldestMaintained: 32, asOf: '2026-09' }; @@ -196,8 +199,7 @@ const READ_MAX = 4 * 1024 * 1024; // The SPDX licence list, release 3.29.0 of spdx/license-list-data (2026-09-16): every // licence id and every exception id that is not deprecated. package.json and composer.json -// may state any of them. An audit that reads no network cannot look the list up, so it is -// written down here with the release it is taken from. +// may state any of them. The audit does not look the list up, so it is written down here with the release it is taken from. const spdxList = (ids) => new Map(ids.trim().split(/\s+/).map((id) => [id.toLowerCase(), id])); const SPDX_LICENCES = spdxList(` 0BSD 3D-Slicer-1.0 AAL Abstyles AdaCore-doc Adobe-2006 Adobe-Display-PostScript Adobe-Glyph @@ -360,9 +362,12 @@ Options: --details print every table behind the findings --json emit the full result as JSON and nothing else --json-file= write that JSON to as well, beside the report + --no-network leave the links of appinfo/info.xml unchecked -h, --help this text -The audit reads the checkout only: no build, no install, no network. The major +The audit reads the checkout: no build, no install. From the network it reads +only whether the links of appinfo/info.xml answer, one HEAD each, which +--no-network or CONSISTENCY_AUDIT_NO_NETWORK=1 leaves out. The major on nextcloud/server master is handed in by make as CONSISTENCY_AUDIT_NC_MASTER, the newest released major as CONSISTENCY_AUDIT_NC_NEWEST; without them, the table of majors in the analyser stands in. The Node.js release schedule is @@ -389,6 +394,7 @@ function parseArgs(argv) { case '--json': opt.json = true; break; case '--json-file': opt.jsonFile = val; break; case '--details': opt.details = true; break; + case '--no-network': opt.network = false; break; case '--top': opt.top = Number(val); break; default: if (key.startsWith('-')) die(`unknown option: ${key}`); @@ -439,12 +445,32 @@ function readInfo(root) { repository: tagText(xml, 'repository'), bugs: tagText(xml, 'bugs'), website: tagText(xml, 'website'), + links: infoLinks(xml), nextcloud: tagAttrs(dep, 'nextcloud'), php: tagAttrs(dep, 'php'), libs: tagTexts(dep, 'lib').map((l) => l.toLowerCase()), }; } +// The links of the App Store listing, each with the element it comes from: +// , , , the three of , every +// with its small-thumbnail, and every . +function infoLinks(xml) { + const out = []; + const add = (element, url) => { if (url && url.trim()) out.push({ element, url: decodeEntities(url.trim()) }); }; + for (const name of ['website', 'bugs', 'repository']) for (const u of tagTexts(xml, name)) add(`<${name}>`, u); + const doc = xml.match(/[\s\S]*?<\/documentation>/)?.[0] ?? ''; + for (const name of ['user', 'admin', 'developer']) for (const u of tagTexts(doc, name)) add(`<${name}>`, u); + for (const m of xml.matchAll(/]*)?>([\s\S]*?)<\/screenshot>/g)) { + add('', m[2]); + const thumb = /\bsmall-thumbnail\s*=\s*"([^"]*)"/.exec(m[1] ?? '')?.[1]; + if (thumb) add('', thumb); + } + for (const u of tagTexts(xml, 'donation')) add('', u); + return out; +} +const decodeEntities = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); + // A file that exists but does not parse is a finding of its own, so a broken JSON // is carried as broken instead of counting as absent. function readJson(root, name) { @@ -1135,6 +1161,7 @@ function audit(opt) { repository: info.repository, bugs: info.bugs, website: info.website, + links: info.links, nextcloud: info.nextcloud ? { min: info.nextcloud['min-version'] ?? null, max: info.nextcloud['max-version'] ?? null } : null, php: info.php ? { min: info.php['min-version'] ?? null, max: info.php['max-version'] ?? null } : null, libs: info.libs, @@ -1177,6 +1204,106 @@ function audit(opt) { }; } +// == The links of info.xml == +// Whether each link of the App Store listing leads anywhere: one HEAD per link, +// a GET where a server refuses HEAD, redirects followed one hop at a time so a +// permanent one can be named, and each URL asked once per run. The one network +// read of the audit; --no-network leaves it out. +const LINK_TIMEOUT = 10000; +const LINK_HOPS = 5; +const LINK_UA = 'ncmake consistency-audit (+https://github.com/ernolf/ncmake)'; + +async function request(url, method) { + const res = await fetch(url, { method, redirect: 'manual', signal: AbortSignal.timeout(LINK_TIMEOUT), headers: { 'user-agent': LINK_UA } }); + res.body?.cancel().catch(() => {}); + return res; +} + +// What a failed request says about the link: a host that does not resolve is +// dead, a timeout or a refused connection may be temporary. +function failure(e) { + if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return { error: 'timeout' }; + const code = e?.cause?.code ?? e?.code ?? null; + if (code === 'ENOTFOUND') return { error: 'unresolved' }; + return { error: 'connection', code }; +} + +async function probe(url) { + let current = url; + let permanent = true; + for (let hop = 0; hop <= LINK_HOPS; hop++) { + let res; + try { + res = await request(current, 'HEAD'); + if ([400, 403, 405, 501].includes(res.status)) res = await request(current, 'GET'); + } catch (e) { + return { ...failure(e), final: current, hops: hop }; + } + const location = res.headers.get('location'); + if (res.status >= 300 && res.status < 400 && location) { + permanent &&= res.status === 301 || res.status === 308; + try { + current = new URL(location, current).href; + } catch { + return { status: res.status, final: current, hops: hop }; + } + continue; + } + return { status: res.status, type: res.headers.get('content-type'), final: current, hops: hop, permanent: hop > 0 && permanent }; + } + return { error: 'redirects', final: current, hops: LINK_HOPS }; +} + +// The repository of origin, owner/repo, for the screenshots pinned to a release +// tag of it, which make version moves along with the version (#304). +function originSlug(root) { + const r = spawnSync('git', ['-c', 'safe.directory=*', '-C', root, 'remote', 'get-url', 'origin'], { encoding: 'utf8' }); + if (r.status !== 0) return null; + return r.stdout.trim().replace(/^git@github\.com:/, '').replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, '') || null; +} +const hasTag = (root, tag) => spawnSync('git', ['-c', 'safe.directory=*', '-C', root, 'rev-parse', '-q', '--verify', `refs/tags/${tag}`]).status === 0; + +// A screenshot pinned to a release tag of the app's own repository that does not +// answer, while this checkout has no such tag: make version moved it to the next +// release, which is not tagged yet. The URL answers once the tag is pushed; until +// then the file has to be in the working tree the tag will be cut from. A tag the +// checkout lacks but origin has, as in a shallow clone, answers and is not asked. +function pendingTag(root, slug, link) { + if (!slug || !link.element.startsWith(' ({ ...l })) }; + const slug = originSlug(r.root); + const asked = new Map(); + const ask = (url) => { + if (!asked.has(url)) asked.set(url, probe(url)); + return asked.get(url); + }; + const list = await Promise.all(links.map(async (l) => { + if (!/^https?:\/\//i.test(l.url)) return { ...l, error: 'scheme' }; + const out = { ...l, ...await ask(l.url) }; + if (out.status === 404) { + const pending = pendingTag(r.root, slug, l); + if (pending) return { ...l, pending }; + } + // A plain http link the server does not move to https: whether the same + // address answers over https. + if (/^http:/i.test(l.url) && !out.error && out.status < 400 && !/^https:/i.test(out.final)) { + const secure = await ask(l.url.replace(/^http:/i, 'https:')); + out.https = !secure.error && secure.status < 400; + } + return out; + })); + return { checked: true, network: true, list }; +} + // == Findings == // A finding names the files it read and what they say. Where the checkout is // silent, the finding says what is missing and why it would be worth stating, @@ -1276,14 +1403,107 @@ function findings(r) { 'A link in appinfo/info.xml is not an http(s) URL:' + `${shape(app.repository) ? '' : ` ${app.repository}`}` + `${shape(app.bugs) ? '' : ` ${app.bugs}`}.`, - ' The audit reads no network, so it checks the form only; whether the target' - + ' answers is not determined here.', + ' Neither a browser nor the App Store can follow it; whether an http(s) link' + + ' answers is checked under link-dead.', ]); } else { add('ok', 'repository-link', ['appinfo/info.xml links its repository and its bug tracker.']); } } + // -- whether the links lead anywhere -- + if (app?.links?.length) { + const L = r.links; + const n = app.links.length; + const many = n === 1 ? 'link' : `${n} links`; + const where = (l) => ` ${l.element} ${l.url}`; + const after = (l) => (l.final && l.final !== l.url ? `, after a redirect to ${l.final}` : ''); + const answer = (l) => ({ + unresolved: 'its host does not resolve', + timeout: `no answer within ${LINK_TIMEOUT / 1000} s`, + connection: `the connection failed${l.code ? ` (${l.code})` : ''}`, + redirects: `more than ${LINK_HOPS} redirects`, + scheme: 'not an http(s) URL', + }[l.error] ?? `HTTP ${l.status}`) + after(l); + const answered = L.list.filter((l) => l.status); + if (!L.checked) { + add('info', 'link-unchecked', [ + `The ${many} of appinfo/info.xml ${n === 1 ? 'is' : 'are'} not checked: the run reads no network (NO_NETWORK=1 or --no-network).`, + ]); + } else if (!answered.length && L.list.some((l) => l.error && l.error !== 'scheme' && l.error !== 'unresolved')) { + add('info', 'link-unchecked', [ + `None of the ${many} of appinfo/info.xml answered, so they are left unchecked.`, + ' Most likely this run has no network; NO_NETWORK=1 says so up front.', + ]); + } else { + const isShot = (l) => l.element.startsWith(' l.element === '' || l.element === ''; + const dead = L.list.filter((l) => (l.pending && !l.pending.present) + || [404, 410].includes(l.status) || l.error === 'unresolved' || (l.error === 'scheme' && !ownField(l))); + const failing = L.list.filter((l) => !dead.includes(l) && !l.pending + && (l.status >= 400 || ['timeout', 'connection', 'redirects'].includes(l.error))); + const notImage = L.list.filter((l) => isShot(l) && l.status >= 200 && l.status < 300 + && !/^image\//i.test(l.type ?? '')); + // A move that only drops .git or a trailing slash is the same address: + // names the git URL, which the forge sends to its page. + const bare = (u) => u.toLowerCase().replace(/(\.git)?\/?$/, ''); + const moved = L.list.filter((l) => l.permanent && l.status >= 200 && l.status < 300 && bare(l.final) !== bare(l.url)); + const insecure = L.list.filter((l) => l.https); + const pending = L.list.filter((l) => l.pending?.present); + if (dead.length) { + add('warn', 'link-dead', [ + `${dead.length === 1 ? 'A link' : `${dead.length} links`} of appinfo/info.xml ${dead.length === 1 ? 'leads' : 'lead'} nowhere:`, + ...dead.map((l) => `${where(l)}: ${l.pending + ? `tag ${l.pending.tag} does not exist yet and ${l.pending.path} is not in the working tree` + : answer(l)}`), + ' The App Store page shows these links as they are; a reader who follows one ends' + + ' on an error page.', + ]); + } + if (notImage.length) { + add('warn', 'screenshot-not-image', [ + `${notImage.length === 1 ? 'A screenshot' : `${notImage.length} screenshots`} of appinfo/info.xml ${notImage.length === 1 ? 'is' : 'are'} not served as an image:`, + ...notImage.map((l) => `${where(l)}: ${l.type ?? 'no content type'}${after(l)}`), + ' The App Store shows the bytes behind the URL, so it needs the image itself;' + + ' a github.com/.../blob/... URL is the page around it, raw.githubusercontent.com' + + ' serves the file.', + ]); + } + if (failing.length) { + add('info', 'link-error', [ + `${failing.length === 1 ? 'A link' : `${failing.length} links`} of appinfo/info.xml did not answer as expected:`, + ...failing.map((l) => `${where(l)}: ${answer(l)}`), + ' This may be temporary, or a server that refuses requests without a browser;' + + ' worth opening once by hand.', + ]); + } + if (moved.length) { + add('info', 'link-redirect', [ + `${moved.length === 1 ? 'A link' : `${moved.length} links`} of appinfo/info.xml ${moved.length === 1 ? 'has' : 'have'} moved permanently:`, + ...moved.flatMap((l) => [where(l), ` now ${l.final}`]), + ' The old address works only as long as the redirect is kept; the new one can' + + ' be stated directly.', + ]); + } + if (insecure.length) { + add('info', 'link-insecure', [ + `${insecure.length === 1 ? 'A link' : `${insecure.length} links`} of appinfo/info.xml ${insecure.length === 1 ? 'uses' : 'use'} http, while the same address answers over https:`, + ...insecure.map(where), + ]); + } + if (pending.length) { + add('info', 'link-pending', [ + `${pending.length === 1 ? 'A screenshot points' : `${pending.length} screenshots point`} to a release tag that does not exist yet:`, + ...pending.map((l) => `${where(l)}: ${l.pending.path} is in the working tree`), + ' make version moved them to the next release; they answer once that tag is pushed.', + ]); + } + if (!dead.length && !notImage.length && !failing.length && !moved.length && !insecure.length) { + add('ok', 'link-dead', [`The ${many} of appinfo/info.xml ${n === 1 ? 'answers' : 'answer'}${pending.length ? ', or will once the next tag is pushed' : ''}.`]); + } + } + } + // -- which servers the release is for -- if (app) { const nc = app.nextcloud; @@ -1823,7 +2043,8 @@ function renderSummary(r) { p(LEGEND); p('The audit compares what the checkout states about itself in more than one place,'); p('and names what a release needs that the checkout does not state. It reads'); - p('metadata only: no build, no install, no network. The oldest maintained server'); + p('metadata only: no build, no install; from the network only whether the links'); + p('of appinfo/info.xml answer. The oldest maintained server'); p(`major is as of ${r.server.asOf}, written into the analyser and refreshed with it;`); p(`the newest released, ${r.server.newest}, is from ${r.server.newestFrom};`); p(`the major on master, ${r.server.master.major}, is from ${r.server.master.from}.`); @@ -1959,6 +2180,7 @@ function renderDetails(r, opt) { // == Main == const opt = parseArgs(process.argv.slice(2)); const report = audit(opt); +report.links = await checkLinks(report, opt); report.findings = findings(report); const json = opt.json || opt.jsonFile ? `${JSON.stringify(report, null, 2)}\n` : null; if (opt.json) { diff --git a/mk/audit.mk b/mk/audit.mk index 0addf4b..732564b 100644 --- a/mk/audit.mk +++ b/mk/audit.mk @@ -10,8 +10,9 @@ # adds what a release needs and a checkout can be missing - the link from a # published version back to its source, a server range that names a server that # exists, a lockfile, a linter that a script calls but no configuration backs. -# Metadata only: it builds nothing and installs nothing. The one thing it reads from -# the network is the major nextcloud/server develops on master, through make. +# Metadata only: it builds nothing and installs nothing. From the network make reads +# the server majors and the Node schedule, and the analyser asks whether the links +# of info.xml answer, which NO_NETWORK=1 leaves out. # # build-verify asks the other question about the same checkout: does the build # output in it come from the source beside it. It copies the tracked files into a @@ -370,7 +371,7 @@ consistency-audit: @$(audit_core) @$(call ncmake_lib_need,$(consistency_libs)) @echo "==> consistency-audit$(if $(strip $(ARGS)), $(ARGS)) (RUNTIME=$(RUNTIME))" >&2 - @$(ncmake_lib_read_run) '$(call ncmake_lib_guard,consistency-audit) CONSISTENCY_AUDIT_CMDLINE="$(consistency_cmdline)" CONSISTENCY_AUDIT_APPDIR="$(notdir $(CURDIR))" CONSISTENCY_AUDIT_NC_MASTER="$(consistency_nc_master)" CONSISTENCY_AUDIT_NC_NEWEST="$(consistency_nc_newest)" CONSISTENCY_AUDIT_NODE_SCHEDULE="$(consistency_node_schedule)" CONSISTENCY_AUDIT_KEEP="$(consistency_keep)" node $(ncmake_lib_path)/consistency-audit.mjs $(ARGS) $(ncmake_json_arg)' + @$(ncmake_lib_read_run) '$(call ncmake_lib_guard,consistency-audit) CONSISTENCY_AUDIT_CMDLINE="$(consistency_cmdline)" CONSISTENCY_AUDIT_APPDIR="$(notdir $(CURDIR))" CONSISTENCY_AUDIT_NC_MASTER="$(consistency_nc_master)" CONSISTENCY_AUDIT_NC_NEWEST="$(consistency_nc_newest)" CONSISTENCY_AUDIT_NODE_SCHEDULE="$(consistency_node_schedule)" CONSISTENCY_AUDIT_KEEP="$(consistency_keep)" CONSISTENCY_AUDIT_NO_NETWORK="$(NO_NETWORK)" node $(ncmake_lib_path)/consistency-audit.mjs $(ARGS) $(ncmake_json_arg)' build-verify: @$(audit_core) @@ -466,7 +467,7 @@ secrets-ignore: check-app fi define help_consistency-audit -make consistency-audit [ARGS=""] [JSON=] +make consistency-audit [ARGS=""] [JSON=] [NO_NETWORK=1] Checks whether the checkout agrees with itself. An app states the same fact in several files, and each file is valid on its own: appinfo/info.xml and @@ -486,6 +487,19 @@ directory the code reads at run time, through __DIR__ in PHP or linkTo in the frontend, that the shipped file set leaves out. Each of those is reported once, with the reason it matters, and never as a score. +The links of the App Store listing in info.xml, , , +, the links, and , are asked +whether they answer: one HEAD each, a GET where a server refuses HEAD, each URL +once per run, 10 seconds at most. A link that answers 404 or 410 or whose host +does not resolve is dead [!], and so is a screenshot that is not served as an +image, such as the page of a github.com blob URL; an error, a timeout or a +refusal may be temporary and is named [i], as are a permanent redirect, with the +address to write instead, and an http link that answers over https as well. A +screenshot pinned to a release tag of the app's own repository (make version +moves those along) whose tag is not in the checkout yet is named [i] while its +file is in the working tree. NO_NETWORK=1 leaves the links unchecked, and the +report says so. + Findings are marked [!] worth changing, [i] worth knowing, [ok] nothing found, and each says which files it was read from. What the checkout does not state is named as not stated instead of being guessed at - with one exception, the oldest @@ -500,7 +514,7 @@ line the release is built on and the oldest line still maintained; without the network a snapshot in the analyser stands in for the schedule. It reads metadata only: no build, no install, no installed dependency tree, no -bytes. That makes it useful on a fresh clone, and on an app that never heard of +bytes, and from the network only the links. That makes it useful on a fresh clone, and on an app that never heard of ncmake. The analyser runs in the throwaway Node container, so the host needs no Node, and it is mounted in from the ncmake cache, so nothing is written into the app. @@ -889,7 +903,7 @@ help:: @echo "" @echo "$(ch)Checkout audit (developer module):$(c0)" @echo " $(ct)consistency-audit$(c0) Whether the checkout agrees with itself, and what a release still needs" - @echo " $(cd)optional $(cv)ARGS=\"--details\"$(cd), see $(cv)make help-consistency-audit$(c0)" + @echo " $(cd)optional $(cv)ARGS=\"--details\"$(cd), $(cv)NO_NETWORK=1$(cd), see $(cv)make help-consistency-audit$(c0)" @echo " $(ct)build-verify$(c0) Whether the committed build output comes from the source beside it" @echo " $(cd)optional $(cv)ARGS=\"--details\"$(cd), see $(cv)make help-build-verify$(c0)" @echo " $(ct)changelog-lint$(c0) Whether the App Store finds this release in the changelog" diff --git a/skills/ncmake/SKILL.md b/skills/ncmake/SKILL.md index d05bf15..73a6b25 100644 --- a/skills/ncmake/SKILL.md +++ b/skills/ncmake/SKILL.md @@ -254,7 +254,7 @@ Six more analysers, in `mk/audit.mk`, about the checkout rather than the bundle. | `make deps-audit` | `composer.lock` and the npm lock file, through the package managers' own audits, and the licence in `appinfo/info.xml` | whether a dependency that ships has a published vulnerability, whether an update can reach the fix, and whether its licence goes with the app's | | `make secrets-audit` | what ships and every commit of the checkout, through gitleaks | whether a key or a token is in the release or in the history | -`consistency-audit` holds the statements a checkout makes against each other: the version in `appinfo/info.xml` against the one in `package.json`, the PHP floor in `info.xml` against `composer.json`, every PHP extension that `composer.json` or a package in `composer.lock` requires against the `` entries in `info.xml`, unless every Nextcloud server has it, the Node version in `package.json` against `.nvmrc` and against the installed workflows. A linter checks one file against a rule and cannot see any of that. The second half is what a release needs and a checkout can be missing: the `` and `` links, a `` range that names servers that exist, one lockfile rather than two, a licence stated the same way everywhere, a linter a script calls while no configuration for it is in the tree. Metadata only, no build, no install, which makes it useful on a fresh clone and on an app that never heard of ncmake. The list of released Nextcloud majors is written into the analyser, and the report names the month the list is from. The major on nextcloud/server master is read from its `version.php` instead, and a `` beyond it is `[!]`; offline the list stands in for it. +`consistency-audit` holds the statements a checkout makes against each other: the version in `appinfo/info.xml` against the one in `package.json`, the PHP floor in `info.xml` against `composer.json`, every PHP extension that `composer.json` or a package in `composer.lock` requires against the `` entries in `info.xml`, unless every Nextcloud server has it, the Node version in `package.json` against `.nvmrc` and against the installed workflows. A linter checks one file against a rule and cannot see any of that. The second half is what a release needs and a checkout can be missing: the `` and `` links, a `` range that names servers that exist, one lockfile rather than two, a licence stated the same way everywhere, a linter a script calls while no configuration for it is in the tree. Metadata only, no build, no install, which makes it useful on a fresh clone and on an app that never heard of ncmake. The list of released Nextcloud majors is written into the analyser, and the report names the month the list is from. The major on nextcloud/server master is read from its `version.php` instead, and a `` beyond it is `[!]`; offline the list stands in for it. It also asks whether the links of the App Store listing in `info.xml` answer, one `HEAD` each: a dead link (404, 410, a host that does not resolve) and a `` not served as an image are `[!]` (`link-dead`, `screenshot-not-image`); an error or timeout, a permanent redirect with its target, and an `http://` link that answers over `https://` are `[i]` (`link-error`, `link-redirect`, `link-insecure`). A screenshot pinned to a release tag of the app that is not tagged yet is `[i]` `link-pending` while its file is in the working tree. `NO_NETWORK=1` leaves the links unchecked (`link-unchecked`). `build-verify` answers the other question: it copies the tracked files into a scratch tree outside the app, installs the locked dependencies there, runs the build - twice, the second time in another directory - and compares the result against what the checkout carries. Twice, because one build cannot separate a difference of the source from output that is not reproducible: what both builds wrote byte for byte the same is what this source produces, so a difference against the checkout is the checkout's, while a file the two builds disagree on is named and gets no verdict. With `ARGS=--fresh`, after a `make build` on this working tree, an output the checkout does not commit and that matches the first build on every file name and every byte but build metadata stands in for the second build, which then does not run. Hashed file names are paired by content, so `app-3f9c1a2b.mjs` and `app-8b20de41.mjs` are one file under two names and a reference carrying the hash counts as build metadata rather than a difference; a source map is compared as the map it is, by what it was compiled from. The checkout is only read: nothing is installed into it and the scratch tree is removed again.