-
Notifications
You must be signed in to change notification settings - Fork 29
[AIAA] Add deterministic data-collection scripts #382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nate-double-u
wants to merge
2
commits into
cncf:main
Choose a base branch
from
nate-double-u:build/05-data
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| name: Assessment tests | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - scripts/assessment/** | ||
| - .github/workflows/assessment-test.yml | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| assessment-test: | ||
| name: ASSESSMENT tests | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: ./.github/actions/npm-ci-via-cached-nvmrc | ||
| - run: npm run test:assessment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,5 @@ | ||
| /docs/localization/ja | ||
|
|
||
| # Test fixtures are frozen inputs; formatting would change the bytes | ||
| # the assessment collection tests hash. | ||
| /scripts/assessment/test/fixtures/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| #!/usr/bin/env node | ||
| // Deterministic data collection for documentation assessments. | ||
| // Inventories one or more repository checkouts, extracts markdown | ||
| // links, optionally fetches sites and checks external links, and | ||
| // writes a content-hash manifest so every quantitative claim traces | ||
| // to a committed, re-runnable step and post-collection edits are | ||
| // detectable. | ||
| // | ||
| // node scripts/assessment/collect.mjs --repo <path> [--repo <path>] | ||
| // [--site <url>] [--check-links] --out <dir> | ||
| // node scripts/assessment/collect.mjs verify --out <dir> | ||
|
|
||
| import path from 'node:path'; | ||
| import process from 'node:process'; | ||
| import { buildInventory, markdownPaths } from './lib/inventory.mjs'; | ||
| import { buildLinkReport } from './lib/links.mjs'; | ||
| import { resolveHeadSha } from './lib/git.mjs'; | ||
| import { checkLinks, fetchSites } from './lib/sitefetch.mjs'; | ||
| import { verifyManifest, writeCollection } from './lib/manifest.mjs'; | ||
| import { stableStringify } from './lib/util.mjs'; | ||
|
|
||
| const USAGE = | ||
| 'usage: collect.mjs [verify] --repo <path> [--site <url>] [--check-links] --out <dir>'; | ||
|
|
||
| function takeValue(argv, i, flag) { | ||
| const value = argv[i + 1]; | ||
| if (value == null || value.startsWith('--')) { | ||
| throw new Error(`missing value for ${flag}`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function parseArgs(argv) { | ||
| const args = { | ||
| verify: false, | ||
| repos: [], | ||
| sites: [], | ||
| checkLinks: false, | ||
| out: null, | ||
| }; | ||
| let i = 0; | ||
| if (argv[0] === 'verify') { | ||
| args.verify = true; | ||
| i = 1; | ||
| } | ||
| while (i < argv.length) { | ||
| const flag = argv[i]; | ||
| if (flag === '--repo') { | ||
| args.repos.push(takeValue(argv, i, flag)); | ||
| i += 2; | ||
| } else if (flag === '--site') { | ||
| args.sites.push(takeValue(argv, i, flag)); | ||
| i += 2; | ||
| } else if (flag === '--out') { | ||
| args.out = takeValue(argv, i, flag); | ||
| i += 2; | ||
| } else if (flag === '--check-links') { | ||
| args.checkLinks = true; | ||
| i += 1; | ||
| } else throw new Error(`unknown flag: ${flag}`); | ||
| } | ||
| if (!args.out) throw new Error('missing --out'); | ||
| if (!args.verify && args.repos.length === 0 && args.sites.length === 0) { | ||
| throw new Error('nothing to collect: pass --repo or --site'); | ||
| } | ||
| for (const repoPath of args.repos) { | ||
| if (outputRelPath(repoPath, args.out) === '') { | ||
| throw new Error('--out must not be a repository root'); | ||
| } | ||
| } | ||
| return args; | ||
| } | ||
|
|
||
| // Relative posix path of the output directory inside a repository, so | ||
| // a rerun never inventories its own previous outputs; null when the | ||
| // output directory lives outside the repository. | ||
| function outputRelPath(repoPath, out) { | ||
| const rel = path.relative(path.resolve(repoPath), path.resolve(out)); | ||
| if (rel === '') return ''; | ||
| if (rel.startsWith('..') || path.isAbsolute(rel)) return null; | ||
| return rel.split(path.sep).join('/'); | ||
| } | ||
|
|
||
| // The manifest records the command without --out: the output location | ||
| // is wherever the manifest lives, and omitting it keeps two runs into | ||
| // different directories byte-identical. | ||
| function commandLine(args) { | ||
| const parts = ['collect.mjs']; | ||
| for (const repo of args.repos) parts.push('--repo', repo); | ||
| for (const site of args.sites) parts.push('--site', site); | ||
| if (args.checkLinks) parts.push('--check-links'); | ||
| return parts.join(' '); | ||
| } | ||
|
|
||
| async function collect(args) { | ||
| const outputs = {}; | ||
| const repos = []; | ||
| const repoReports = []; | ||
| const linkReports = []; | ||
| for (const repoPath of args.repos) { | ||
| const excludeRel = outputRelPath(repoPath, args.out); | ||
| const inventory = buildInventory(repoPath, { | ||
| exclude: excludeRel ? [excludeRel] : [], | ||
| }); | ||
| const report = buildLinkReport(repoPath, markdownPaths(inventory)); | ||
| repos.push({ path: repoPath, sha: resolveHeadSha(repoPath) }); | ||
| repoReports.push({ path: repoPath, ...inventory }); | ||
| linkReports.push({ path: repoPath, ...report }); | ||
| } | ||
| outputs['inventory.json'] = stableStringify({ repos: repoReports }) + '\n'; | ||
| outputs['links.json'] = stableStringify({ repos: linkReports }) + '\n'; | ||
|
|
||
| const sites = []; | ||
| if (args.sites.length > 0) { | ||
| const fetched = await fetchSites(args.sites); | ||
| fetched.forEach((entry, index) => { | ||
| const { body, ...meta } = entry; | ||
| if (body) { | ||
| // A body implies the fetch succeeded, so the URL parses; on | ||
| // failure the entry (including an unparseable URL) is written | ||
| // through as evidence untouched. | ||
| const host = new URL(entry.url).host; | ||
| const name = `sites/${String(index + 1).padStart(3, '0')}-${host}.body`; | ||
| outputs[name] = body; | ||
| meta.bodyFile = name; | ||
| } | ||
| sites.push(meta); | ||
| }); | ||
| outputs['site-fetches.json'] = stableStringify({ sites }) + '\n'; | ||
| } | ||
|
|
||
| if (args.checkLinks) { | ||
| const external = [ | ||
| ...new Set(linkReports.flatMap((report) => report.external)), | ||
| ].sort(); | ||
| outputs['link-status.json'] = | ||
| stableStringify({ checked: await checkLinks(external) }) + '\n'; | ||
| } | ||
|
|
||
| writeCollection(args.out, { | ||
| commands: [commandLine(args)], | ||
| repos, | ||
| sites, | ||
| outputs, | ||
| }); | ||
| process.stdout.write(`collected into ${args.out}\n`); | ||
| } | ||
|
|
||
| function verify(args) { | ||
| const result = verifyManifest(args.out); | ||
| if (result.ok) { | ||
| process.stdout.write('manifest ok\n'); | ||
| return; | ||
| } | ||
| for (const name of result.mismatched) { | ||
| process.stderr.write(`mismatched: ${name}\n`); | ||
| } | ||
| for (const name of result.missing) { | ||
| process.stderr.write(`missing: ${name}\n`); | ||
| } | ||
| for (const name of result.unexpected) { | ||
| process.stderr.write(`unexpected: ${name}\n`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| async function main() { | ||
| let args; | ||
| try { | ||
| args = parseArgs(process.argv.slice(2)); | ||
| } catch (err) { | ||
| process.stderr.write(`${err.message}\n${USAGE}\n`); | ||
| process.exit(2); | ||
| } | ||
| if (args.verify) verify(args); | ||
| else await collect(args); | ||
| } | ||
|
|
||
| await main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { execFileSync } from 'node:child_process'; | ||
|
|
||
| function defaultExec(cmd, args) { | ||
| return execFileSync(cmd, args, { | ||
| encoding: 'utf8', | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }); | ||
| } | ||
|
|
||
| export function resolveHeadSha(dir, execImpl = defaultExec) { | ||
| try { | ||
| const out = execImpl('git', ['-C', dir, 'rev-parse', 'HEAD']).trim(); | ||
| return /^[0-9a-f]{40}$/i.test(out) ? out : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { sha256Hex } from './util.mjs'; | ||
|
|
||
| const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown']); | ||
| const SKIPPED_DIRECTORIES = new Set(['.git', 'node_modules']); | ||
|
|
||
| function walk(root, rel, { files, symlinks, exclude }) { | ||
| const entries = fs.readdirSync(path.join(root, rel), { | ||
| withFileTypes: true, | ||
| }); | ||
| for (const entry of entries) { | ||
| const relPath = rel ? `${rel}/${entry.name}` : entry.name; | ||
| if (exclude.has(relPath)) continue; | ||
| if (entry.isSymbolicLink()) { | ||
| // Recorded, never followed: the link target string is the | ||
| // content, so links pointing outside the repository cannot pull | ||
| // outside bytes into the inventory. | ||
| symlinks.push(relPath); | ||
| } else if (entry.isDirectory()) { | ||
| if (!SKIPPED_DIRECTORIES.has(entry.name)) { | ||
| walk(root, relPath, { files, symlinks, exclude }); | ||
| } | ||
| } else if (entry.isFile()) { | ||
| files.push(relPath); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export function buildInventory(root, { exclude = [] } = {}) { | ||
| const filePaths = []; | ||
| const symlinkPaths = []; | ||
| walk(root, '', { | ||
| files: filePaths, | ||
| symlinks: symlinkPaths, | ||
| exclude: new Set(exclude), | ||
| }); | ||
| filePaths.sort(); | ||
| symlinkPaths.sort(); | ||
| const files = filePaths.map((relPath) => { | ||
| const content = fs.readFileSync(path.join(root, relPath)); | ||
| return { path: relPath, bytes: content.length, sha256: sha256Hex(content) }; | ||
| }); | ||
| const symlinks = symlinkPaths.map((relPath) => { | ||
| const target = fs.readlinkSync(path.join(root, relPath)); | ||
| return { | ||
| path: relPath, | ||
| target, | ||
| bytes: Buffer.byteLength(target), | ||
| sha256: sha256Hex(target), | ||
| }; | ||
| }); | ||
| const byExtension = {}; | ||
| let markdownCount = 0; | ||
| for (const file of files) { | ||
| const ext = path.posix.extname(file.path).toLowerCase(); | ||
| if (ext) byExtension[ext] = (byExtension[ext] ?? 0) + 1; | ||
| if (MARKDOWN_EXTENSIONS.has(ext)) markdownCount += 1; | ||
| } | ||
| return { | ||
| files, | ||
| symlinks, | ||
| totals: { | ||
| fileCount: files.length, | ||
| symlinkCount: symlinks.length, | ||
| byteCount: files.reduce((n, f) => n + f.bytes, 0), | ||
| markdownCount, | ||
| byExtension, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export function markdownPaths(inventory) { | ||
| return inventory.files | ||
| .map((f) => f.path) | ||
| .filter((p) => | ||
| MARKDOWN_EXTENSIONS.has(path.posix.extname(p).toLowerCase()), | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
|
|
||
| // Deliberate limitations, documented rather than hidden: destinations | ||
| // with unescaped parentheses need the angle-bracket form to be seen, | ||
| // and indented (non-fenced) code blocks are still scanned. A markdown | ||
| // AST would lift both at the cost of a new dependency. | ||
| const INLINE_LINK = /!?\[[^\]]*\]\(([^)\s<][^)\s]*)(?:\s+"[^"]*")?\)/g; | ||
| const ANGLE_LINK = /!?\[[^\]]*\]\(<([^>]+)>(?:\s+"[^"]*")?\)/g; | ||
| const REFERENCE_DEFINITION = /^\s*\[[^\]]+\]:\s*(\S+)/gm; | ||
| const AUTOLINK = /<(https?:\/\/[^>\s]+)>/g; | ||
|
|
||
| const FENCE = /^ {0,3}(`{3,}|~{3,})/; | ||
|
|
||
| // Blanks out fenced code blocks and inline code spans so example | ||
| // links inside them (common in documentation about documentation) do | ||
| // not pollute the inventory. | ||
| function stripCodeRegions(markdown) { | ||
| const out = []; | ||
| let fence = null; | ||
| for (const line of markdown.split('\n')) { | ||
| const opener = line.match(FENCE); | ||
| if (fence) { | ||
| if ( | ||
| opener && | ||
| opener[1][0] === fence[0] && | ||
| opener[1].length >= fence.length | ||
| ) { | ||
| fence = null; | ||
| } | ||
| out.push(''); | ||
| } else if (opener) { | ||
| fence = opener[1]; | ||
| out.push(''); | ||
| } else { | ||
| out.push(line.replace(/(`+)[^`]*\1/g, ' ')); | ||
| } | ||
| } | ||
| return out.join('\n'); | ||
| } | ||
|
|
||
| function classify(url) { | ||
| if (url.startsWith('#')) return 'anchor'; | ||
| if (/^https?:\/\//i.test(url)) return 'external'; | ||
| return 'internal'; | ||
| } | ||
|
|
||
| export function extractLinks(markdown) { | ||
| const source = stripCodeRegions(markdown); | ||
| const urls = new Set(); | ||
| for (const pattern of [ | ||
| INLINE_LINK, | ||
| ANGLE_LINK, | ||
| REFERENCE_DEFINITION, | ||
| AUTOLINK, | ||
| ]) { | ||
| for (const match of source.matchAll(pattern)) { | ||
| urls.add(match[1]); | ||
| } | ||
| } | ||
| return [...urls].sort().map((url) => ({ url, kind: classify(url) })); | ||
| } | ||
|
|
||
| export function buildLinkReport(root, markdownRelPaths) { | ||
| const perFile = {}; | ||
| const byKind = { | ||
| external: new Set(), | ||
| internal: new Set(), | ||
| anchor: new Set(), | ||
| }; | ||
| for (const relPath of markdownRelPaths) { | ||
| const links = extractLinks( | ||
| fs.readFileSync(path.join(root, relPath), 'utf8'), | ||
| ); | ||
| perFile[relPath] = links; | ||
| for (const link of links) byKind[link.kind].add(link.url); | ||
| } | ||
| return { | ||
| perFile, | ||
| external: [...byKind.external].sort(), | ||
| internal: [...byKind.internal].sort(), | ||
| anchors: [...byKind.anchor].sort(), | ||
| }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.