Revised checkbox-tree functions and related

This commit is contained in:
2026-07-12 21:45:55 -05:00
parent af325782a8
commit 9e7fbe0865
15 changed files with 1063 additions and 721 deletions
+32 -25
View File
@@ -1,11 +1,11 @@
/**
* Generates expected-parsed.json for each fixture directory that contains expected.md.
* Run manually: node test/generate-rendered-fixtures.mjs
* Run manually: node scripts/generate-rendered-fixtures.mjs
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('<!doctype html><html><body></body></html>');
@@ -20,31 +20,38 @@ globalThis.fetch = () => Promise.resolve({
const { parseMarkdownIssues, markdownSummaryToHtml } = await import('../web/js/issues.js');
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const fixturesDir = join(__dirname, '../test/fixtures');
const defaultFixturesDir = join(__dirname, '../test/fixtures');
const fixtures = readdirSync(fixturesDir).filter(name => {
const contents = readdirSync(join(fixturesDir, name)).map(d => d);
return contents.includes('expected.md');
});
export function generateRenderedFixtures(fixturesDir = defaultFixturesDir) {
const fixtures = readdirSync(fixturesDir).filter(name => {
const contents = readdirSync(join(fixturesDir, name)).map(d => d);
return contents.includes('expected.md');
});
for (const fixtureId of fixtures) {
const fixturePath = join(fixturesDir, fixtureId);
const markdownText = readFileSync(join(fixturePath, 'expected.md'), 'utf-8');
const parsed = parseMarkdownIssues(markdownText);
for (const fixtureId of fixtures) {
const fixturePath = join(fixturesDir, fixtureId);
const markdownText = readFileSync(join(fixturePath, 'expected.md'), 'utf-8');
const parsed = parseMarkdownIssues(markdownText);
const result = parsed.map(issue => ({
id: issue.id,
summary: issue.summary,
resolved: issue.resolved,
caveat: issue.caveat,
renderedHtml: markdownSummaryToHtml(issue.summary, issue.resolved, issue.caveat)
}));
const result = parsed.map(issue => ({
id: issue.id,
summary: issue.summary,
resolved: issue.resolved,
caveat: issue.caveat,
renderedHtml: markdownSummaryToHtml(issue.summary, issue.resolved, issue.caveat)
}));
writeFileSync(
join(fixturePath, 'expected-parsed.json'),
JSON.stringify(result, null, 2) + '\n',
'utf-8'
);
writeFileSync(
join(fixturePath, 'expected-parsed.json'),
JSON.stringify(result, null, 2) + '\n',
'utf-8'
);
console.log(`Written ${fixtureId}/expected-parsed.json (${result.length} issues)`);
console.log(`Written ${fixtureId}/expected-parsed.json (${result.length} issues)`);
}
return { fixtures: fixtures.length };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
generateRenderedFixtures();
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
import { runUpdateProductsCli } from './update-products-from-issues.mjs';
import { generateRenderedFixtures } from './generate-rendered-fixtures.mjs';
await runUpdateProductsCli();
const fixtureResult = generateRenderedFixtures();
console.log(`Regenerated ${fixtureResult.fixtures} rendered fixture set(s).`);
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { buildProductTree, enumerateTreePaths } from '../web/js/product-tree-model.js';
const ISSUE_TYPES = new Set(['addressed', 'known']);
const VERSION_FILENAME_RE = /^(?<version>[^.]+(?:\.[^.]+)*)\.md$/;
const HOTFIX_RE = /^(?<base>\d+(?:\.\d+)*)(?:-h(?<hotfix>\d+))?$/;
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
async function walkFiles(root) {
const files = [];
async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) await visit(path);
else if (entry.isFile()) files.push(path);
}
}
await visit(root);
return files.sort();
}
export async function collectIssueFiles(issuesDir) {
const records = [];
for (const filePath of await walkFiles(issuesDir)) {
const parts = relative(issuesDir, filePath).split(/[\\/]/);
if (parts.length !== 3) continue;
const [product, issueType, filename] = parts;
if (!ISSUE_TYPES.has(issueType)) continue;
const match = VERSION_FILENAME_RE.exec(filename);
if (!match) continue;
records.push({ product, issueType, version: match.groups.version, filename });
}
return records;
}
function clearLeafIssueArrays(node) {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
if ('addressed' in node || 'known' in node) {
node.addressed = [];
node.known = [];
return;
}
Object.values(node).forEach(clearLeafIssueArrays);
}
function getPath(data, path) {
let current = data;
for (const key of path) {
if (!current || typeof current !== 'object' || !(key in current)) return null;
current = current[key];
}
return current;
}
function isIssueLeaf(node) {
return Boolean(node) && typeof node === 'object' && ('addressed' in node || 'known' in node);
}
function pickTargetLeafPath(products, product, version) {
const parts = version.split('.');
const major = parts[0];
const minor = parts.length > 1 ? `${parts[0]}.${parts[1]}` : major;
for (const path of [[product, major, minor], [product, major], [product]]) {
if (isIssueLeaf(getPath(products, path))) return path;
}
return null;
}
function parseReleasePrefix(prefix) {
const match = HOTFIX_RE.exec(prefix);
if (!match) return { base: [], hotfix: -1 };
return {
base: match.groups.base.split('.').map(Number),
hotfix: match.groups.hotfix === undefined ? -1 : Number(match.groups.hotfix)
};
}
function compareNumberArrays(left, right) {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index++) {
const difference = (left[index] ?? -1) - (right[index] ?? -1);
if (difference) return difference;
}
return 0;
}
function compareIssueFilenames(left, right) {
const leftPrefix = left.replace(/\.[^.]+$/, '');
const rightPrefix = right.replace(/\.[^.]+$/, '');
const leftVersion = parseReleasePrefix(leftPrefix);
const rightVersion = parseReleasePrefix(rightPrefix);
return compareNumberArrays(leftVersion.base, rightVersion.base) ||
Number(leftVersion.hotfix >= 0) - Number(rightVersion.hotfix >= 0) ||
leftVersion.hotfix - rightVersion.hotfix ||
left.localeCompare(right);
}
export function rebuildProducts(products, records) {
clearLeafIssueArrays(products);
const unmatched = [];
records.sort((left, right) =>
left.product.localeCompare(right.product) ||
left.issueType.localeCompare(right.issueType) ||
left.version.localeCompare(right.version)
);
for (const record of records) {
const path = pickTargetLeafPath(products, record.product, record.version);
if (!path) {
unmatched.push(record);
continue;
}
const leaf = getPath(products, path);
const files = Array.isArray(leaf[record.issueType]) ? leaf[record.issueType] : [];
leaf[record.issueType] = Array.from(new Set([...files, record.filename])).sort(compareIssueFilenames);
}
return { products, unmatched };
}
export function updateManifest(manifest, currentPaths) {
if (!manifest || manifest.schema !== 1 || !Array.isArray(manifest.nodes)) {
throw new Error('Product-tree manifest must use schema 1 with a nodes array.');
}
const active = new Set();
manifest.nodes.forEach((path, slot) => {
if (path === null) return;
if (typeof path !== 'string' || !path) throw new Error(`Invalid manifest entry at slot ${slot}.`);
if (active.has(path)) throw new Error(`Duplicate manifest path: ${path}`);
active.add(path);
});
const current = new Set(currentPaths);
const appended = currentPaths.filter(path => !active.has(path));
manifest.nodes.push(...appended);
const missing = Array.from(active).filter(path => !current.has(path));
return { manifest, appended, missing };
}
export async function updateProductsFromIssues({ issuesDir, productsPath, manifestPath }) {
const [productsText, manifestText, records] = await Promise.all([
readFile(productsPath, 'utf8'),
readFile(manifestPath, 'utf8'),
collectIssueFiles(issuesDir)
]);
if (records.length === 0) {
throw new Error("No '<version>.md' issue files found; refusing to rewrite products.json");
}
const products = JSON.parse(productsText);
if (!products || typeof products !== 'object' || Array.isArray(products)) {
throw new Error('products.json must contain a top-level object.');
}
const rebuilt = rebuildProducts(products, records);
const paths = enumerateTreePaths(buildProductTree(rebuilt.products));
const manifestUpdate = updateManifest(JSON.parse(manifestText), paths);
await writeFile(productsPath, `${JSON.stringify(rebuilt.products, null, 2)}\n`, 'utf8');
if (manifestUpdate.appended.length > 0) {
await writeFile(manifestPath, `${JSON.stringify(manifestUpdate.manifest, null, 2)}\n`, 'utf8');
}
return {
records: records.length,
unmatched: rebuilt.unmatched,
appended: manifestUpdate.appended,
missing: manifestUpdate.missing
};
}
function parseArgs(argv) {
const options = {
issuesDir: join(REPO_ROOT, 'web/data/issues'),
productsPath: join(REPO_ROOT, 'web/data/products.json'),
manifestPath: join(REPO_ROOT, 'web/data/product-tree-manifest.json')
};
const flags = new Map([
['--issues-dir', 'issuesDir'],
['--products-json', 'productsPath'],
['--manifest-json', 'manifestPath']
]);
for (let index = 0; index < argv.length; index += 2) {
const property = flags.get(argv[index]);
if (!property || argv[index + 1] === undefined) throw new Error(`Invalid argument: ${argv[index] ?? ''}`);
options[property] = resolve(argv[index + 1]);
}
return options;
}
export async function runUpdateProductsCli(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const result = await updateProductsFromIssues(options);
console.log(`Updated ${options.productsPath} from ${result.records} issue files.`);
console.log(`Appended ${result.appended.length} manifest path(s).`);
if (result.missing.length) console.warn(`${result.missing.length} manifest path(s) are no longer rendered; slots were preserved.`);
if (result.unmatched.length) console.warn(`${result.unmatched.length} issue file(s) did not match a products.json leaf.`);
return result;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await runUpdateProductsCli();
}
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
"""Update products.json entries from discovered issue data files."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
ISSUE_TYPES = {"addressed", "known"}
VERSION_FILENAME_RE = re.compile(r"^(?P<version>[^.]+(?:\.[^.]+)*)\.md$")
HOTFIX_RE = re.compile(r"^(?P<base>\d+(?:\.\d+)*)(?:-h(?P<hotfix>\d+))?$")
def parse_args() -> argparse.Namespace:
"""Parse command line arguments for input and output paths."""
parser = argparse.ArgumentParser(
description="Update products.json from issue files."
)
parser.add_argument(
"--issues-dir",
default="web/data/issues",
help="Directory containing product issue files.",
)
parser.add_argument(
"--products-json",
default="web/data/products.json",
help="Path to products.json.",
)
return parser.parse_args()
def collect_issue_files(issues_dir: Path) -> list[tuple[str, str, str, str]]:
"""Collect all issue files with '<version>.md' naming."""
records: list[tuple[str, str, str, str]] = []
for file_path in sorted(p for p in issues_dir.rglob("*") if p.is_file()):
relative = file_path.relative_to(issues_dir)
if len(relative.parts) != 3:
continue
product, issue_type, filename = relative.parts
if issue_type not in ISSUE_TYPES:
continue
version = parse_issue_filename(filename)
if version is None:
continue
records.append((product, issue_type, version, filename))
return records
def parse_issue_filename(filename: str) -> str | None:
"""Parse '<version>.md' issue filenames and return version."""
match = VERSION_FILENAME_RE.match(filename)
if not match:
return None
return match.group("version")
def clear_leaf_issue_arrays(node: Any) -> None:
"""Reset addressed/known arrays on every issue leaf in products.json."""
if isinstance(node, dict):
if "addressed" in node or "known" in node:
node["addressed"] = []
node["known"] = []
return
for value in node.values():
clear_leaf_issue_arrays(value)
def is_issue_leaf(node: Any) -> bool:
"""Return whether a node is a leaf containing issue arrays."""
return isinstance(node, dict) and ("addressed" in node or "known" in node)
def pick_target_leaf_path(products: dict[str, Any], product: str, version: str) -> list[str] | None:
"""Choose the best matching product tree path for a version."""
parts = version.split(".")
if not parts:
return None
major = parts[0]
minor = f"{parts[0]}.{parts[1]}" if len(parts) > 1 else parts[0]
candidates = [
[product, major, minor],
[product, major],
[product],
]
for path in candidates:
node = get_path(products, path)
if is_issue_leaf(node):
return path
return None
def get_path(data: Any, path: list[str]) -> Any:
"""Traverse nested dict keys and return the value at path, if present."""
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
return current
def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]:
"""Extract numeric base version parts and optional hotfix number."""
match = HOTFIX_RE.match(prefix)
if not match:
return tuple(), -1
base_parts = tuple(int(part) for part in match.group("base").split("."))
hotfix_group = match.group("hotfix")
hotfix = int(hotfix_group) if hotfix_group is not None else -1
return base_parts, hotfix
def filename_sort_key(filename: str) -> tuple[tuple[int, ...], int, int, str]:
"""Build a sort key that orders base versions before hotfixes."""
prefix = filename.rsplit(".", 1)[0]
base_parts, hotfix = parse_release_prefix(prefix)
is_hotfix = 1 if hotfix >= 0 else 0
return base_parts, is_hotfix, hotfix, filename
def add_file_to_leaf(
products: dict[str, Any],
path: list[str],
issue_type: str,
filename: str,
) -> None:
"""Insert an issue filename into the target leaf with stable sorting."""
leaf = get_path(products, path)
if not isinstance(leaf, dict):
return
files = leaf.get(issue_type)
if not isinstance(files, list):
files = []
files.append(filename)
leaf[issue_type] = sorted(set(files), key=filename_sort_key)
def update_products(issues_dir: Path, products_path: Path) -> None:
"""Rebuild product issue file lists from crawled issue files."""
if not issues_dir.is_dir():
raise FileNotFoundError(f"Issues directory not found: {issues_dir}")
if not products_path.is_file():
raise FileNotFoundError(f"products.json not found: {products_path}")
with products_path.open("r", encoding="utf-8") as f:
products = json.load(f)
if not isinstance(products, dict):
raise ValueError("products.json must contain a top-level object")
records = collect_issue_files(issues_dir)
if not records:
raise ValueError(
"No '<version>.md' issue files found; refusing to rewrite products.json"
)
clear_leaf_issue_arrays(products)
for product, issue_type, version, filename in sorted(
records,
key=lambda r: (r[0], r[1], r[2]),
):
path = pick_target_leaf_path(products, product, version)
if path is None:
continue
add_file_to_leaf(products, path, issue_type, filename)
with products_path.open("w", encoding="utf-8") as f:
json.dump(products, f, indent=2)
f.write("\n")
def main() -> int:
"""Entrypoint for command-line usage."""
args = parse_args()
script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent
issues_dir = (repo_root / args.issues_dir).resolve()
products_path = (repo_root / args.products_json).resolve()
update_products(issues_dir=issues_dir, products_path=products_path)
print(f"Updated {products_path} from {issues_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())