mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
M1: the AC7 no-import test scans the whole src tree for the CURRENT dump name (the old assertion checked the pre-rename string; proven by execution — a planted fetch now turns it red). M2: deduplicate the #33 paragraphs in both changelogs and ARCHITECTURE. M3: the auditor's exit-3 statuses match the spec contract exactly (migrate-*/deprecated-read); decision-required is live behaviour awaiting #44, drop-on-validation is the backend's own job. User-Visible: no Issue: #33
117 lines
4.7 KiB
JavaScript
117 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Offline inventory of compatibility fields in exported House Plan JSON.
|
|
* The command is read-only and performs no network requests.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { CONFIG_FIELD_REGISTRY } from './config-field-registry.mjs';
|
|
|
|
const args = process.argv.slice(2);
|
|
const jsonOutput = args.includes('--json');
|
|
const help = args.includes('--help') || args.includes('-h');
|
|
const paths = args.filter((arg) => !arg.startsWith('-'));
|
|
const unknownFlags = args.filter((arg) => arg.startsWith('-') && !['--json', '--help', '-h'].includes(arg));
|
|
|
|
const usage = () => {
|
|
console.log('Usage: npm run audit:config -- [--json] <exported-config.json> [...]');
|
|
console.log('Without a file, prints the registered compatibility decisions.');
|
|
};
|
|
|
|
const printable = (value) => {
|
|
const text = JSON.stringify(value);
|
|
return text && text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
};
|
|
|
|
const visit = (node, segments, displayPath, matches, selector, parent = null) => {
|
|
if (!segments.length) {
|
|
if (Object.hasOwn(selector, 'equals') && node !== selector.equals) return;
|
|
if (selector.parent && (!parent || parent[selector.parent.key] !== selector.parent.equals)) return;
|
|
matches.push({ path: displayPath || '$', value: node });
|
|
return;
|
|
}
|
|
const [segment, ...rest] = segments;
|
|
if (segment === '*') {
|
|
if (!Array.isArray(node)) return;
|
|
node.forEach((value, index) => visit(value, rest, `${displayPath}[${index}]`, matches, selector, value));
|
|
return;
|
|
}
|
|
if (!node || typeof node !== 'object' || !Object.hasOwn(node, segment)) return;
|
|
visit(node[segment], rest, `${displayPath}.${segment}`, matches, selector, node);
|
|
};
|
|
|
|
export const auditConfig = (input) => {
|
|
const config = input?.config && typeof input.config === 'object' ? input.config : input;
|
|
return CONFIG_FIELD_REGISTRY.map((field) => {
|
|
const matches = [];
|
|
visit(config, field.selector.path, '$', matches, field.selector);
|
|
return { id: field.id, status: field.status, count: matches.length, matches };
|
|
}).filter((row) => row.count > 0);
|
|
};
|
|
|
|
const printRegistry = () => {
|
|
usage();
|
|
console.log('');
|
|
console.table(CONFIG_FIELD_REGISTRY.map(({ id, status, level, runtime }) => ({ id, status, level, runtime })));
|
|
};
|
|
|
|
const isMain = resolve(process.argv[1] || '') === fileURLToPath(import.meta.url);
|
|
if (isMain) {
|
|
if (help) {
|
|
usage();
|
|
} else if (unknownFlags.length) {
|
|
console.error(`config-audit: unknown option ${unknownFlags[0]}`);
|
|
usage();
|
|
process.exitCode = 2;
|
|
} else if (!paths.length && jsonOutput) {
|
|
console.error('config-audit: --json requires at least one exported config file');
|
|
usage();
|
|
process.exitCode = 2;
|
|
} else if (!paths.length) {
|
|
printRegistry();
|
|
} else {
|
|
try {
|
|
const reports = paths.map((file) => {
|
|
const absolute = resolve(file);
|
|
const parsed = JSON.parse(readFileSync(absolute, 'utf8'));
|
|
return { file: absolute, findings: auditConfig(parsed) };
|
|
});
|
|
// #33: exit-code contract — 0 = clean, 3 = migration available
|
|
// (any finding whose status names a pending mechanism), 2 = invalid
|
|
// input (unchanged). 3 is chosen to avoid colliding with 2.
|
|
// Exactly the statuses that NAME a pending migration (#33 spec rev3):
|
|
// decision-required fields (group_lights, exclude_integrations) are
|
|
// live supported behaviour awaiting a #44 decision, not legacy, and
|
|
// drop-on-validation is handled by the backend itself.
|
|
const MIGRATION_STATUSES = new Set([
|
|
'migrate-on-write', 'migrate-on-settings-save', 'deprecated-read',
|
|
]);
|
|
const migrationFindings = reports.some((report) => report.findings
|
|
.some((finding) => MIGRATION_STATUSES.has(finding.status)));
|
|
if (migrationFindings) process.exitCode = 3;
|
|
if (jsonOutput) {
|
|
console.log(JSON.stringify(reports, null, 2));
|
|
} else {
|
|
for (const report of reports) {
|
|
console.log(`\n${report.file}`);
|
|
if (!report.findings.length) {
|
|
console.log(' No registered compatibility fields found.');
|
|
continue;
|
|
}
|
|
for (const finding of report.findings) {
|
|
console.log(` ${finding.status.padEnd(24)} ${String(finding.count).padStart(4)} ${finding.id}`);
|
|
for (const match of finding.matches.slice(0, 3))
|
|
console.log(` ${match.path} = ${printable(match.value)}`);
|
|
if (finding.matches.length > 3)
|
|
console.log(` ... ${finding.matches.length - 3} more`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`config-audit: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
}
|