Revised checkbox-tree functions and related
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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).`);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user