← Files Zuora Coding AgentARCHIVED FILE
scripts/lint-meter-json.js
16.4 KB · Oct 2, 2026 · 00:30 UTC
#!/usr/bin/env node
/**
* Structural and semantic linter for Zuora Mediation meter JSON.
*
* Catches the class of errors that would break import on the Zuora Mediation
* server. Because the plugin has no access to the live mediation validator,
* this lint script is the only pre-import safety net.
*
* Usage:
* node scripts/lint-meter-json.js <path> [<path> ...]
* node scripts/lint-meter-json.js --json <path> # JSON-formatted report
* node scripts/lint-meter-json.js --quiet <path> # summary only
* node scripts/lint-meter-json.js --no-warn <path> # exit nonzero only on errors
* node scripts/lint-meter-json.js --assign-uuids <path> # lint then rewrite non-UUID ids
*
* Also exports { lintMeter, loadRules } for use from tests.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const PLUGIN_REFS = path.join(__dirname, "..", "references");
const OPERATORS_DIR = path.join(PLUGIN_REFS, "meter-operators");
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// ajv is optional — E143 is skipped if not installed
let Ajv;
try { Ajv = require("ajv"); } catch (_) { /* not installed */ }
/**
* Create Ajv validator instance for JSON schema validation.
* Returns { ajv, ajvAvailable: boolean }.
* Returns empty result if Ajv is not installed.
*/
function initAjv() {
if (!Ajv) return { validators: {}, ajvAvailable: false };
const ajv = new Ajv({ allErrors: true, strict: false });
const validators = {};
// We need operator skeletons loaded first, but this runs inside loadRules
// so we pass already-loaded operators
return { ajvAvailable: true, ajv };
}
function loadRules() {
const manifest = JSON.parse(fs.readFileSync(path.join(OPERATORS_DIR, "_manifest.json"), "utf8"));
const operators = {};
const operatorsByCanonicalType = {};
for (const fname of fs.readdirSync(OPERATORS_DIR)) {
if (fname === "_manifest.json" || !fname.endsWith(".json")) continue;
const skeleton = JSON.parse(fs.readFileSync(path.join(OPERATORS_DIR, fname), "utf8"));
operators[fname.replace(/\.json$/, "")] = skeleton;
if (skeleton.operatorType) {
operatorsByCanonicalType[skeleton.operatorType] = skeleton;
}
}
// Initialize Ajv validator for schema validation
const { ajvAvailable, ajv } = initAjv();
const validators = {};
if (ajvAvailable) {
for (const [fname, skeleton] of Object.entries(operators)) {
if (skeleton.schema && typeof skeleton.schema === "object") {
try {
// Key by operatorType so E143 lookup by task.operatorType works
const opType = skeleton.operatorType || fname;
validators[opType] = ajv.compile(skeleton.schema);
} catch (e) {
console.error(`Warning: failed to compile schema for ${fname}: ${e.message}`);
}
}
}
}
return { manifest, operators, operatorsByCanonicalType, validators, ajvAvailable };
}
// Intentionally deferred rules (documented in the spec self-review):
// E141 — per-operator required-field-null check. Operator skeletons encode
// "required" via a `blockers[]` array rather than a flat field list;
// the design skill already honors blockers and the build skill fails
// loudly if the user skipped them, so a redundant lint check would
// duplicate that semantic.
// W171 — JSON-boolean-where-string-expected. No failing case in the gold
// corpus; adding without a concrete repro violates TDD.
// W172 — optional-field-outside-documented-set. Same rationale as W171.
function lintMeter(meter, rules) {
const issues = [];
// E110 — top-level required keys
for (const key of ["name", "type", "version"]) {
if (meter[key] === undefined || meter[key] === null || meter[key] === "") {
issues.push({ severity: "error", rule: "E110", message: `missing or empty top-level "${key}"` });
}
}
// E111 — type is a valid string enum
const VALID_TYPES = new Set(["CUSTOM", "DIRECT", "DELTA", "CUMULATIVE", "SUM", "MAX", "MIN", "COUNT", "AVG"]);
if (meter.type !== undefined && meter.type !== null && meter.type !== "") {
if (typeof meter.type !== "string" || !VALID_TYPES.has(meter.type)) {
issues.push({
severity: "error",
rule: "E111",
message: `invalid type ${JSON.stringify(meter.type)} — must be one of ${[...VALID_TYPES].join(", ")}`,
});
}
}
// E120 / E121 — type shape consistency
if (meter.type === "CUSTOM") {
if (!Array.isArray(meter.tasks)) {
issues.push({ severity: "error", rule: "E120", message: "CUSTOM meter must have tasks[]" });
}
if (meter.typeDefinition !== undefined) {
issues.push({ severity: "error", rule: "E120", message: "CUSTOM meter must not have typeDefinition" });
}
} else if (meter.type && VALID_TYPES.has(meter.type)) {
if (!meter.typeDefinition || typeof meter.typeDefinition !== "object") {
issues.push({ severity: "error", rule: "E121", message: `${meter.type} meter must have typeDefinition` });
}
if (meter.tasks !== undefined) {
issues.push({ severity: "error", rule: "E121", message: `${meter.type} meter must not have tasks[]` });
}
}
// E150 — predefined meter required fieldMappings
if (meter.type && VALID_TYPES.has(meter.type) && meter.type !== "CUSTOM" && meter.typeDefinition) {
const required = new Set(["accountNumber", "uom", "startDateTime"]);
if (meter.type !== "COUNT") required.add("quantity");
const mappings = Array.isArray(meter.typeDefinition.fieldMappings) ? meter.typeDefinition.fieldMappings : [];
const present = new Set(mappings.map((m) => m && m.name).filter(Boolean));
for (const name of required) {
if (!present.has(name)) {
issues.push({
severity: "error",
rule: "E150",
message: `${meter.type} meter missing required fieldMapping "${name}"`,
});
}
}
}
// E151 — type-specific required configs
if (meter.type && meter.typeDefinition) {
const configs = meter.typeDefinition.configs || {};
if (meter.type === "CUMULATIVE" && !configs.cumulativeMethod) {
issues.push({
severity: "error",
rule: "E151",
message: "CUMULATIVE meter requires typeDefinition.configs.cumulativeMethod",
});
}
if (["SUM", "MAX", "MIN", "COUNT", "AVG"].includes(meter.type) && !configs.cumulativePeriod) {
issues.push({
severity: "error",
rule: "E151",
message: `${meter.type} meter requires typeDefinition.configs.cumulativePeriod`,
});
}
}
// W170 — predefined meter's typeDefinition.schemaId looks like an unresolved name
if (meter.typeDefinition && typeof meter.typeDefinition === "object") {
const sid = meter.typeDefinition.schemaId;
if (typeof sid === "string" && sid.length > 0 && !/^\d+$/.test(sid) && !UUID_RE.test(sid)) {
issues.push({
severity: "warn",
rule: "W170",
message: `typeDefinition.schemaId: value ${JSON.stringify(sid)} looks like an unresolved name, not an integer ID`,
});
}
}
// E130–E136 — task-level and graph-level checks
if (Array.isArray(meter.tasks)) {
const VALID_NODE_TYPES = new Set(["SOURCE", "PROCESSOR", "SINK"]);
const idCounts = new Map();
for (const t of meter.tasks) {
if (t && t.id !== undefined && t.id !== null) {
const k = String(t.id);
idCounts.set(k, (idCounts.get(k) || 0) + 1);
}
}
for (const [id, count] of idCounts) {
if (count > 1) {
issues.push({ severity: "error", rule: "E135", message: `duplicate task id ${JSON.stringify(id)} (${count}×)` });
}
}
const idSet = new Set(idCounts.keys());
for (const [idx, task] of meter.tasks.entries()) {
const loc = `tasks[${idx}]`;
if (task == null || typeof task !== "object") {
issues.push({ severity: "error", rule: "E130", message: `${loc}: not an object` });
continue;
}
for (const key of ["id", "name", "nodeType"]) {
if (task[key] === undefined || task[key] === null || task[key] === "") {
issues.push({ severity: "error", rule: "E130", message: `${loc}: missing "${key}"` });
}
}
if (task.nodeType !== undefined && !VALID_NODE_TYPES.has(task.nodeType)) {
issues.push({
severity: "error",
rule: "E131",
message: `${loc}: invalid nodeType ${JSON.stringify(task.nodeType)}`,
});
}
const preds = Array.isArray(task.predecessors) ? task.predecessors : [];
if (task.nodeType === "SOURCE" && preds.length > 0) {
issues.push({ severity: "error", rule: "E132", message: `${loc}: SOURCE must have empty predecessors` });
}
if ((task.nodeType === "PROCESSOR" || task.nodeType === "SINK") && preds.length === 0) {
issues.push({
severity: "error",
rule: "E133",
message: `${loc}: ${task.nodeType} must have at least one predecessor`,
});
}
for (const p of preds) {
if (p && p.id !== undefined && !idSet.has(String(p.id))) {
issues.push({
severity: "error",
rule: "E134",
message: `${loc}: predecessor ${JSON.stringify(p.id)} not found among task ids`,
});
}
}
}
// E140 — operatorType must be a known canonical operatorType value from the operator skeletons
for (const [idx, task] of meter.tasks.entries()) {
const loc = `tasks[${idx}]`;
if (!task || typeof task !== "object") continue;
const opKey = task.operatorType || task.operator_key;
if (opKey && !rules.operatorsByCanonicalType[opKey]) {
issues.push({
severity: "error",
rule: "E140",
message: `${loc}: operatorType ${JSON.stringify(opKey)} not found in manifest`,
});
}
if (task.metadata && typeof task.metadata === "object") {
for (const [k, v] of Object.entries(task.metadata)) {
if (/Id$/.test(k) && typeof v === "string" && v.length > 0) {
const isDigits = /^\d+$/.test(v);
const isUuid = UUID_RE.test(v);
if (!isDigits && !isUuid) {
issues.push({
severity: "warn",
rule: "W170",
message: `${loc}.metadata.${k}: value ${JSON.stringify(v)} looks like an unresolved name, not an integer ID`,
});
}
}
}
}
}
// E143 — per-operator metadata field validation against operator schema (via ajv)
if (rules.ajvAvailable) {
const SCHEMA_RULE = "E143";
for (const [idx, task] of meter.tasks.entries()) {
if (!task || typeof task !== "object") continue;
const opKey = task.operatorType || task.operator_key;
if (!opKey) continue;
const validateFn = rules.validators[opKey];
if (!validateFn) continue; // no schema for this operator
if (!task.metadata || typeof task.metadata !== "object") {
issues.push({
severity: "error",
rule: SCHEMA_RULE,
message: `tasks[${idx}].metadata: metadata required for operator ${opKey}`,
});
continue;
}
const valid = validateFn(task.metadata);
if (!valid) {
for (const err of validateFn.errors) {
// Map ajv instancePath (e.g. "/appendFields/0") to ".appendFields[0]"
const parts = err.instancePath ? err.instancePath.split("/").filter(Boolean) : [];
const propPath = parts.length > 0
? "." + parts.join(".").replace(/\.(\d+)(\.|$)/g, (_, n, after) => `[${n}]${after === "." ? "." : ""}`)
: "";
issues.push({
severity: "error",
rule: SCHEMA_RULE,
message: `tasks[${idx}].metadata${propPath}: ${err.message}`,
});
}
}
}
}
// E136 — cycle detection via iterative DFS
const graph = new Map();
for (const t of meter.tasks) {
if (!t || t.id === undefined) continue;
graph.set(String(t.id), (Array.isArray(t.predecessors) ? t.predecessors : []).map((p) => String(p && p.id)));
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const id of graph.keys()) color.set(id, WHITE);
let cycleFound = false;
for (const start of graph.keys()) {
if (cycleFound) break;
if (color.get(start) !== WHITE) continue;
const stack = [[start, 0]];
color.set(start, GRAY);
while (stack.length > 0) {
const [node, i] = stack[stack.length - 1];
const preds = graph.get(node) || [];
if (i < preds.length) {
const next = preds[i];
stack[stack.length - 1][1] = i + 1;
if (!graph.has(next)) continue;
const c = color.get(next);
if (c === GRAY) { cycleFound = true; break; }
if (c === WHITE) {
color.set(next, GRAY);
stack.push([next, 0]);
}
} else {
color.set(node, BLACK);
stack.pop();
}
}
}
if (cycleFound) {
issues.push({ severity: "error", rule: "E136", message: "cycle detected in task graph" });
}
}
// E142 — unreplaced <<REQUIRED: ...>> sentinel anywhere in the JSON
const SENTINEL_RE = /<<REQUIRED[^>]*>>/;
(function walk(node, pathParts) {
if (typeof node === "string") {
if (SENTINEL_RE.test(node)) {
issues.push({
severity: "error",
rule: "E142",
message: `unreplaced sentinel at ${pathParts.join(".") || "(root)"}: ${node}`,
});
}
} else if (Array.isArray(node)) {
node.forEach((v, i) => walk(v, [...pathParts, `[${i}]`]));
} else if (node && typeof node === "object") {
for (const k of Object.keys(node)) walk(node[k], [...pathParts, k]);
}
})(meter, []);
return issues;
}
function assignUuids(meter) {
const tasks = Array.isArray(meter.tasks) ? meter.tasks : [];
const idMap = {};
for (const t of tasks) {
if (!t || t.id == null) continue;
const cur = String(t.id);
idMap[cur] = UUID_RE.test(cur) ? cur : crypto.randomUUID();
}
const remap = (x) => (x == null ? x : (idMap[String(x)] || String(x)));
for (const t of tasks) {
if (t && t.id != null) t.id = remap(t.id);
if (Array.isArray(t && t.predecessors)) {
for (const p of t.predecessors) {
if (p && p.id != null) p.id = remap(p.id);
}
}
}
return meter;
}
// ---- CLI ----
function main(argv) {
const args = argv.slice(2);
if (args.length === 0) {
console.error("usage: lint-meter-json.js [--json|--quiet|--no-warn|--assign-uuids] <path> [<path>...]");
process.exit(2);
}
const flags = {
json: args.includes("--json"),
quiet: args.includes("--quiet"),
noWarn: args.includes("--no-warn"),
assignUuids: args.includes("--assign-uuids"),
};
const files = args.filter((a) => !a.startsWith("--"));
const rules = loadRules();
let worstExit = 0;
const report = [];
for (const file of files) {
let meter;
try {
meter = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (e) {
report.push({ file, issues: [{ severity: "error", rule: "E100", message: e.message }] });
worstExit = 1;
continue;
}
const issues = lintMeter(meter, rules);
const hasError = issues.some((i) => i.severity === "error");
if (flags.assignUuids && !hasError) {
assignUuids(meter);
fs.writeFileSync(file, JSON.stringify(meter, null, 2) + "\n");
}
if (hasError) worstExit = 1;
else if (!flags.noWarn && issues.length > 0) worstExit = Math.max(worstExit, 0);
report.push({ file, issues });
}
if (flags.json) {
console.log(JSON.stringify(report, null, 2));
} else if (!flags.quiet) {
for (const r of report) {
console.log(`\n${r.file}:`);
if (r.issues.length === 0) {
console.log(" ✓ clean");
} else {
for (const i of r.issues) {
console.log(` ${i.severity === "error" ? "✗" : "!"} ${i.rule}: ${i.message}`);
}
}
}
} else {
const errs = report.reduce((n, r) => n + r.issues.filter((i) => i.severity === "error").length, 0);
const warns = report.reduce((n, r) => n + r.issues.filter((i) => i.severity === "warn").length, 0);
console.log(`${files.length} file(s): ${errs} error(s), ${warns} warning(s)`);
}
process.exit(worstExit);
}
if (require.main === module) main(process.argv);
module.exports = { lintMeter, loadRules, assignUuids, UUID_RE };
SHA-256: 16d4179060ad0f9c61e85682b074105568faefcc0486c92e004783b9fbc5e89a