docs(site): [SITE-01] inventory sources and reconcile demo coverage

This commit is contained in:
Harvey Zhao committed 2026-09-14 06:21:25 +08:00
1 parent d62ab13a35
commit d74ecc43cc
21 files changed
+22389 -56

No files matched your search

+27 -22
View File
@@ -1,15 +1,17 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { hash, refactorDir } from './releases.mjs'
const root = path.resolve(refactorDir, '..')
const read = name => fs.readFileSync(path.join(root, name), 'utf8').replaceAll('\r\n', '\n')
function htmlFiles(dir = 'docs') {
return fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
return fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap((entry) => {
const name = `${dir}/${entry.name}`
if (entry.isDirectory()) return ['compiled', 'uncompiled', 'node_modules'].includes(entry.name) ? [] : htmlFiles(name)
if (entry.isDirectory())
return ['compiled', 'uncompiled', 'node_modules'].includes(entry.name) ? [] : htmlFiles(name)
return entry.name.endsWith('.html') ? [name] : []
}).sort()
}
@@ -19,38 +21,40 @@ function menuLinks() {
export function captureDemoInventory() {
const links = menuLinks()
const inventory = JSON.parse(read('refactor/package-inventory.json'))
const examples = fs.readdirSync(path.join(root, 'docs/assets/example')).filter(name => name.endsWith('.js')).sort().map(file => {
const examples = fs.readdirSync(path.join(root, 'docs/assets/example')).filter(name => name.endsWith('.js')).sort().map((file) => {
const name = file.slice(0, -3)
const source = `docs/assets/example/${file}`
const linked = links.filter(link => new URL(link, 'http://localhost:8082').searchParams.get('example') === name)
const libs = [...new Set(linked.flatMap(link => (new URL(link, 'http://localhost:8082').searchParams.get('libs') || '').split('\n').filter(Boolean)))]
return { name, source, sha256Lf: hash(read(source)), menuLinks: linked, route: linked[0] || `/?example=${name}`, libs,
status: 'not-run', owner: 'EX-03',
note: name === 'thumbnail' ? 'References artplayer-plugin-thumbnail, which is absent from the 22 current workspace packages; dependency and route need reconciliation.' : linked.length ? 'Existing menu route; SDK and media loading have not been validated.' : 'No menu link; example route follows common.js loader. Required globals are not automatically loaded.' }
return { name, source, sha256Lf: hash(read(source)), menuLinks: linked, route: linked[0] || `/?example=${name}`, libs, status: 'not-run', owner: 'EX-03', note: name === 'thumbnail' ? 'References artplayer-plugin-thumbnail, which is absent from the 22 current workspace packages; dependency and route need reconciliation.' : linked.length ? 'Existing menu route; SDK and media loading have not been validated.' : 'No menu link; example route follows common.js loader. Required globals are not automatically loaded.' }
})
const pages = htmlFiles().map(source => {
const pages = htmlFiles().map((source) => {
const text = read(source)
const uncommented = text.replace(/<!--[\s\S]*?-->/g, '')
return { source, route: `/${source.slice(5)}`.replace(/index\.html$/, ''), sha256Lf: hash(text),
kind: source.startsWith('docs/document/') ? 'generated-docs' : source.includes('googlebc') ? 'site-verification' : source === 'docs/upscaler/index.html' ? 'standalone-tool' : 'demo-or-editor',
scriptSources: [...uncommented.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["']/g)].map(match => match[1]),
status: 'not-run', owner: source.includes('googlebc') ? 'SITE-01' : 'EX-03' }
return { source, route: `/${source.slice(5)}`.replace(/index\.html$/, ''), sha256Lf: hash(text), kind: source.startsWith('docs/document/') ? 'generated-docs' : source.includes('googlebc') ? 'site-verification' : source === 'docs/upscaler/index.html' ? 'standalone-tool' : 'demo-or-editor', scriptSources: [...uncommented.matchAll(/<script\b[^>]+\bsrc=["']([^"']+)["']/g)].map(match => match[1]), status: 'not-run', owner: source.includes('googlebc') ? 'SITE-01' : 'EX-03' }
})
return { schemaVersion: 1, task: 'BASE-04', sourceCommit: '94893929', capturedAt: new Date().toISOString(),
note: 'Historical source inventory, not browser or network evidence. ScriptSources excludes comments but does not resolve inline scripts, import maps or dynamic SDK loading. Hashes use LF and describe this capture; check validates coverage, not current content identity.',
examples, pages,
packages: inventory.packages.map(pkg => ({ name: pkg.name, examples: examples.filter(example => example.libs.some(lib => lib.includes(`/${pkg.name}/`))).map(example => example.name), status: 'not-run', owner: pkg.name === 'artplayer-vitepress' ? 'SITE-01' : 'EX-03' })),
editor: { source: 'docs/assets/js/common.js', sha256Lf: hash(read('docs/assets/js/common.js')), storage: ['prod', 'ts', 'code', 'log'], isolation: 'Use separate test origin; record keys, do not erase user browser data.', core: { prodTrue: './compiled/artplayer.js', otherwise: './uncompiled/artplayer/index.js' }, pluginSources: 'URL libs parameter; independent of prod toggle', execution: 'Run evaluates editor text; TS mode alone is not strict tsc validation.', runtimeStatus: 'not-run' },
}
return { schemaVersion: 1, task: 'BASE-04', sourceCommit: '94893929', capturedAt: new Date().toISOString(), note: 'Historical source inventory, not browser or network evidence. ScriptSources excludes comments but does not resolve inline scripts, import maps or dynamic SDK loading. Hashes use LF and describe this capture; check validates coverage, not current content identity.', examples, pages, packages: inventory.packages.map(pkg => ({ name: pkg.name, examples: examples.filter(example => example.libs.some(lib => lib.includes(`/${pkg.name}/`))).map(example => example.name), status: 'not-run', owner: pkg.name === 'artplayer-vitepress' ? 'SITE-01' : 'EX-03' })), editor: { source: 'docs/assets/js/common.js', sha256Lf: hash(read('docs/assets/js/common.js')), storage: ['prod', 'ts', 'code', 'log'], isolation: 'Use separate test origin; record keys, do not erase user browser data.', core: { prodTrue: './compiled/artplayer.js', otherwise: './uncompiled/artplayer/index.js' }, pluginSources: 'URL libs parameter; independent of prod toggle', execution: 'Run evaluates editor text; TS mode alone is not strict tsc validation.', runtimeStatus: 'not-run' } }
}
export function validateDemoInventory(inventory) {
export function validateDemoInventory(inventory, additions = JSON.parse(read('refactor/baselines/demo-additions.json'))) {
assert.equal(inventory.schemaVersion, 1)
assert.deepEqual(inventory.examples.map(item => item.source).sort(), fs.readdirSync(path.join(root, 'docs/assets/example')).filter(name => name.endsWith('.js')).map(name => `docs/assets/example/${name}`).sort(), 'Demo file coverage drift')
assert.equal(additions.schemaVersion, 1)
const taskIds = new Set(JSON.parse(read('refactor/tasks.json')).tasks.map(task => task.id))
for (const addition of additions.examples) {
assert(/^docs\/assets\/example\/[a-z0-9.]+\.js$/.test(addition.source), 'Invalid added example path')
assert(/^[a-f0-9]{40}$/.test(addition.introducedBy), 'Missing introduction commit')
assert(taskIds.has(addition.owner) && addition.finalDemoOwner === 'EX-03', 'Missing added example owner')
assert.equal(new URL(addition.route, 'http://localhost:8082').searchParams.get('example'), path.basename(addition.source, '.js'), 'Added route does not select its example')
}
const sources = [...inventory.examples, ...additions.examples].map(item => item.source)
assert.equal(new Set(sources).size, sources.length, 'Duplicate baseline/addition paths')
assert.deepEqual(sources.sort(), fs.readdirSync(path.join(root, 'docs/assets/example')).filter(name => name.endsWith('.js')).map(name => `docs/assets/example/${name}`).sort(), 'Demo file coverage drift')
assert.deepEqual(inventory.pages.map(item => item.source).sort(), htmlFiles(), 'HTML route coverage drift')
assert.deepEqual(inventory.examples.flatMap(item => item.menuLinks).sort(), menuLinks().sort(), 'Editor link coverage drift')
assert.deepEqual(inventory.packages.map(item => item.name).sort(), JSON.parse(read('refactor/package-inventory.json')).packages.map(item => item.name).sort(), 'Package demo coverage drift')
const names = new Set(inventory.examples.map(item => item.name))
for (const pkg of inventory.packages) for (const name of pkg.examples) assert(names.has(name), `Unknown example: ${name}`)
for (const pkg of inventory.packages) {
for (const name of pkg.examples) assert(names.has(name), `Unknown example: ${name}`)
}
assert(inventory.examples.find(item => item.name === 'thumbnail').note.includes('absent'), 'Missing historical plugin exception')
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -58,13 +62,14 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
if (process.argv.includes('--capture')) {
assert(!fs.existsSync(filename), 'Do not overwrite the historical inventory; reconcile changes explicitly')
const inventory = captureDemoInventory()
validateDemoInventory(inventory)
validateDemoInventory(inventory, { schemaVersion: 1, examples: [] })
fs.writeFileSync(filename, `${JSON.stringify(inventory, null, 2)}\n`)
}
else {
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --capture or --check')
const inventory = JSON.parse(fs.readFileSync(filename, 'utf8'))
validateDemoInventory(inventory)
console.log(`Demo coverage: ${inventory.examples.length} examples, ${inventory.pages.length} HTML paths, ${inventory.packages.length} packages; no browser pass implied`)
const additions = JSON.parse(read('refactor/baselines/demo-additions.json'))
console.log(`Demo coverage: ${inventory.examples.length} historical + ${additions.examples.length} added examples, ${inventory.pages.length} HTML paths, ${inventory.packages.length} packages; no browser pass implied`)
}
}
+18
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- Runs with the repository baseline node:test suite.
import test from 'node:test'
import { validateDemoInventory } from './demos.mjs'
import { refactorDir } from './releases.mjs'
@@ -19,3 +20,20 @@ test('Demo inventory rejects missing files, routes and ecosystem packages', () =
assert.throws(() => validateDemoInventory(changed))
}
})
test('Demo additions require complete paths, specific routes and task ownership', () => {
const inventory = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/demo-inventory.json'), 'utf8'))
const additions = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/demo-additions.json'), 'utf8'))
for (const mutate of [
value => value.examples.pop(),
value => value.examples.push(value.examples[0]),
value => value.examples[0].route = '/?example=index',
value => value.examples[0].owner = 'UNKNOWN-01',
value => value.examples[0].source = 'docs/assets/example/../example/asr.local.js',
value => value.examples[0].introducedBy = '',
]) {
const changed = structuredClone(additions)
mutate(changed)
assert.throws(() => validateDemoInventory(inventory, changed))
}
})
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import MarkdownIt from 'markdown-it'
import ts from 'typescript'
import { captureDemoInventory } from './demos.mjs'
import { hash, refactorDir } from './releases.mjs'
const root = path.resolve(refactorDir, '..')
const filename = path.join(refactorDir, 'baselines/site-inventory.json')
const read = file => fs.readFileSync(path.join(root, file), 'utf8').replaceAll('\r\n', '\n')
const json = file => JSON.parse(read(file))
const markdown = new MarkdownIt({ html: true })
function files(dir) {
return fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap((entry) => {
const file = `${dir}/${entry.name}`
return entry.isDirectory() ? ['node_modules', '.vitepress', 'dist'].includes(entry.name) ? [] : files(file) : [file]
}).sort()
}
function headings(file) {
const tokens = markdown.parse(read(file), {})
return tokens.flatMap((token, index) => token.type === 'heading_open' ? [{ text: tokens[index + 1].content, line: token.map[0] + 1 }] : [])
}
function fingerprint(file) {
const bytes = fs.readFileSync(path.join(root, file))
return { file, bytes: bytes.length, sha256: hash(bytes) }
}
export function captureSiteInventory() {
const demo = captureDemoInventory()
const historical = json('refactor/baselines/demo-inventory.json')
const docs = files('packages/artplayer-vitepress/docs').filter(file => file.endsWith('.md')).map((file) => {
const relative = file.replace('packages/artplayer-vitepress/docs/', '')
const english = relative.startsWith('en/')
const peer = `packages/artplayer-vitepress/docs/${english ? relative.slice(3) : `en/${relative}`}`
return { file, sha256Lf: hash(read(file)), language: english ? 'en' : 'zh', route: `/document/${relative.replace(/index\.md$/, '').replace(/\.md$/, '.html')}`, headings: headings(file), runCodeMarkers: (read(file).match(/className="run-code"/g) || []).length, translation: fs.existsSync(path.join(root, peer)) ? peer : null, translationOwner: fs.existsSync(path.join(root, peer)) ? null : 'SITE-04' }
})
const api = files('packages/artplayer/public').filter(file => /\.ts$/.test(file)).flatMap((file) => {
const source = ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true)
const rows = []
function visit(node) {
if ((ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && node.name) {
const members = node.members || (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type) ? node.type.members : [])
for (const member of members) {
if (!member.name && !ts.isConstructSignatureDeclaration(member) && !ts.isConstructorDeclaration(member))
continue
const name = member.name?.getText(source).replace(/^['"]|['"]$/g, '') || 'constructor'
const matches = docs.flatMap(doc => doc.headings.filter(heading => heading.text.replaceAll('`', '').split(/[ .()]/).includes(name)).map(heading => ({ file: doc.file, line: heading.line })))
rows.push({ file, declaration: node.name.text, name, line: source.getLineAndCharacterOfPosition(member.getStart(source)).line + 1, candidateHeadings: matches, semanticReviewOwner: 'SITE-04' })
}
}
ts.forEachChild(node, visit)
}
visit(source)
return rows
})
const config = read('packages/artplayer-vitepress/docs/.vitepress/config.js')
const packages = json('refactor/package-inventory.json').packages.map((pkg) => {
const dir = `packages/${pkg.name}`
const readme = `${dir}/README.md`
const architecture = `${dir}/ARCHITECTURE.md`
const examples = demo.examples.filter(example => example.libs.some(lib => lib.includes(`/${pkg.name}/`))).map(example => example.name)
return { name: pkg.name, readme: fs.existsSync(path.join(root, readme)) ? readme : null, architecture: fs.existsSync(path.join(root, architecture)) ? architecture : null, examples, sidebarMentionsPackage: config.includes(pkg.name), dedicatedPages: pkg.name === 'artplayer' || pkg.name === 'artplayer-vitepress' ? docs.map(doc => doc.file) : docs.filter(doc => doc.file.endsWith(`/plugin/${pkg.name.replace('artplayer-plugin-', '')}.md`)).map(doc => doc.file), contentReviewOwner: 'SITE-04', demoExecutionOwner: 'EX-03' }
})
const scriptFiles = ['scripts/build-types.mjs', 'scripts/build-ts.js', 'scripts/editor-types.mjs', 'scripts/plugin-editor-types.mjs', 'scripts/build-test.js', 'scripts/build-i18n.js', 'scripts/build-docs.js', 'scripts/build-llm.js', 'scripts/trans-docs.js', 'scripts/projects.js', 'docs/assets/js/common.js', 'docs/assets/js/mobile.js', 'packages/artplayer-vitepress/docs/public/main.js', 'packages/artplayer-vitepress/docs/vite.config.ts', 'packages/artplayer-vitepress/docs/.vitepress/config.js']
const assets = [...files('docs/assets/sample'), ...files('docs/assets/jassub').filter(file => /\.(?:ttf|otf|woff2?)$/i.test(file)), ...files('docs/assets/js/vs'), 'docs/assets/js/vconsole.min.js', 'docs/assets/js/console.js'].map(fingerprint)
return {
schemaVersion: 1,
task: 'SITE-01',
note: 'Read-only source inventory. Heading matches are candidates, never semantic API coverage. Asset identity is not permission. No runtime, remote Pages or npm acceptance is implied.',
counts: { packages: packages.length, markdown: docs.length, examples: demo.examples.length, html: demo.pages.length, declarationMembers: api.length, assets: assets.length },
docs,
api,
packages,
examples: demo.examples,
pages: demo.pages,
baselinePaths: { examplesAdded: demo.examples.filter(row => !historical.examples.some(old => old.source === row.source)).map(row => row.source), examplesRemoved: historical.examples.filter(row => !demo.examples.some(now => now.source === row.source)).map(row => row.source), htmlAdded: demo.pages.filter(row => !historical.pages.some(old => old.source === row.source)).map(row => row.source), htmlRemoved: historical.pages.filter(row => !demo.pages.some(now => now.source === row.source)).map(row => row.source) },
editorDeclarations: [...read('docs/assets/js/common.js').matchAll(/'\.\/assets\/ts\/([^']+\.d\.ts)'/g)].map(match => ({ file: `docs/assets/ts/${match[1]}`, exists: fs.existsSync(path.join(root, 'docs/assets/ts', match[1])), owner: 'SITE-02' })),
scripts: scriptFiles.map(file => ({ file, sha256Lf: hash(read(file)) })),
generationCommands: Object.fromEntries(Object.entries(json('package.json').scripts).filter(([name]) => /^(?:build:(?:types|ts|test|i18n|docs|llm|all)|ci:build)$/.test(name))),
siteManifest: json('packages/artplayer-vitepress/package.json'),
assets,
}
}
export function validateSiteInventory(value) {
assert.equal(value.schemaVersion, 1)
const tasks = new Map(json('refactor/tasks.json').tasks.map(task => [task.id, task]))
for (const owner of ['SITE-02', 'SITE-03', 'SITE-04', 'SITE-05', 'EX-03', 'REL-01']) assert(tasks.has(owner), `Missing follow-up: ${owner}`)
for (const item of value.editorDeclarations) assert(item.exists, `Missing editor declaration: ${item.file}`)
assert.equal(new Set(value.packages.map(pkg => pkg.name)).size, 22)
for (const file of ['docs/index.html', 'docs/mobile.html', 'docs/esm.html', 'docs/i18n.html', 'docs/iframe.html', 'docs/test/index.html']) assert(value.pages.some(page => page.source === file), `Missing HTML entry: ${file}`)
assert.deepEqual(value, captureSiteInventory(), 'Site inventory drift; inspect changes before --write')
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert.equal(process.argv.length, 3, 'Use --write or --check')
assert(['--write', '--check'].includes(process.argv[2]), 'Use --write or --check')
const value = process.argv[2] === '--write' ? captureSiteInventory() : JSON.parse(fs.readFileSync(filename, 'utf8'))
validateSiteInventory(value)
if (process.argv[2] === '--write')
fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`)
console.log(`Site inventory: ${JSON.stringify(value.counts)}; semantic/browser review remains separate`)
}
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Runs with the repository baseline node:test suite.
import test from 'node:test'
import { captureSiteInventory, validateSiteInventory } from './site-inventory.mjs'
test('Site inventory detects omitted HTML, declarations and fingerprint drift', () => {
const snapshot = captureSiteInventory()
validateSiteInventory(snapshot)
for (const mutate of [
value => value.pages = value.pages.filter(page => page.source !== 'docs/mobile.html'),
value => value.editorDeclarations.pop(),
value => value.assets[0].sha256 = 'changed',
value => value.api.pop(),
]) {
const changed = structuredClone(snapshot)
mutate(changed)
assert.throws(() => validateSiteInventory(changed))
}
})
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { archiveFiles, hash, readMember, refactorDir } from './releases.mjs'
assert.deepEqual(process.argv.slice(2), ['--network'], 'Explicit --network required; this command does not execute downloaded code')
const root = path.resolve(refactorDir, '..')
const cache = fs.mkdtempSync(path.join(refactorDir, '.cache/site-provenance-'))
const report = { task: 'SITE-01', capturedAt: new Date().toISOString(), cache, packages: [], passed: false }
const json = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
try {
for (const [name, version] of [['artplayer-plugin-thumbnail', '1.0.3'], ['vconsole', '3.15.0'], ['monaco-editor', '0.30.1']]) {
const registry = `https://registry.npmjs.org/${name}/${version}`
const metadataResponse = await fetch(registry, { redirect: 'error', signal: AbortSignal.timeout(30000) })
assert(metadataResponse.ok, `${registry}: ${metadataResponse.status}`)
const metadata = await metadataResponse.json()
assert.equal(metadata.name, name)
assert.equal(metadata.version, version)
const url = new URL(metadata.dist.tarball)
assert.equal(url.origin, 'https://registry.npmjs.org')
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(60000) })
assert(response.ok, `${url}: ${response.status}`)
const bytes = Buffer.from(await response.arrayBuffer())
assert.equal(`sha512-${hash(bytes, 'sha512', 'base64')}`, metadata.dist.integrity)
assert.equal(hash(bytes, 'sha1'), metadata.dist.shasum)
const archive = path.join(cache, `${name}-${version}.tgz`)
fs.writeFileSync(archive, bytes)
const members = archiveFiles(archive)
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
const row = { name, version, registry, tarball: url.href, integrity: metadata.dist.integrity, sha256: hash(bytes), repository: metadata.repository, manifest, files: members, matches: [], notices: [] }
report.packages.push(row)
for (const member of members.filter(member => /(?:license|thirdpartynotices|notice)(?:\.\w+)?$/i.test(member))) {
const content = readMember(archive, member)
const target = `${name}-${path.basename(member)}.txt`
fs.writeFileSync(path.join(cache, target), content)
row.notices.push({ member, cached: target, sha256: hash(content) })
}
if (name === 'artplayer-plugin-thumbnail') {
for (const member of members.filter(member => /(?:README\.md|src\/index\.js|types\/.*\.d\.ts)$/i.test(member))) fs.writeFileSync(path.join(cache, `${name}-${member.replaceAll('/', '_')}.txt`), readMember(archive, member))
}
else {
const mappings = name === 'vconsole'
? [['docs/assets/js/vconsole.min.js', 'package/dist/vconsole.min.js']]
: fs.readdirSync(path.join(root, 'docs/assets/js/vs'), { recursive: true, withFileTypes: true }).filter(entry => entry.isFile()).map((entry) => {
const relative = path.relative(path.join(root, 'docs/assets/js/vs'), path.join(entry.parentPath, entry.name)).replaceAll('\\', '/')
return [`docs/assets/js/vs/${relative}`, `package/min/vs/${relative}`]
}).sort(([a], [b]) => a.localeCompare(b))
for (const [file, member] of mappings) {
const local = fs.readFileSync(path.join(root, file))
const upstream = members.includes(member) ? readMember(archive, member) : null
row.matches.push({ file, member, sha256: hash(local), upstreamSha256: upstream && hash(upstream), equal: upstream !== null && local.equals(upstream), equalLf: upstream !== null && local.toString('utf8').replaceAll('\r\n', '\n') === upstream.toString('utf8').replaceAll('\r\n', '\n') })
}
}
console.log(`${name}@${version}: ${row.matches.filter(item => item.equal).length}/${row.matches.length} exact local matches, ${row.notices.length} upstream notice files`)
}
report.passed = true
}
finally {
json(path.join(cache, 'report.json'), report)
console.log(`Provenance evidence: ${cache}`)
}