@forwardimpact/libutil
Advanced tools
+110
| import path from "node:path"; | ||
| // Shared ESLint-style emitter for structured Finding objects. A Finding is | ||
| // `{ id, level, path?, lineNo?, message, hint? }` where `level` is "fail" or | ||
| // "warn"; everything else is optional. Both libwiki's audit and libcoaligned's | ||
| // instruction/JTBD checks emit Finding objects and render through these | ||
| // functions for consistency. | ||
| function partition(findings) { | ||
| const failures = []; | ||
| const warnings = []; | ||
| for (const f of findings) { | ||
| if (f.level === "warn") warnings.push(f); | ||
| else failures.push(f); | ||
| } | ||
| return { failures, warnings }; | ||
| } | ||
| function relPath(p, cwd) { | ||
| if (!p) return "(no path)"; | ||
| if (!cwd) return p; | ||
| const rel = path.relative(cwd, p); | ||
| return rel.startsWith("..") ? p : rel; | ||
| } | ||
| function groupByPath(findings) { | ||
| const groups = new Map(); | ||
| for (const f of findings) { | ||
| const key = f.path ?? "(no path)"; | ||
| if (!groups.has(key)) groups.set(key, []); | ||
| groups.get(key).push(f); | ||
| } | ||
| return groups; | ||
| } | ||
| function levelLabel(level) { | ||
| return level === "warn" ? "warning" : "error"; | ||
| } | ||
| function widths(group) { | ||
| return { | ||
| loc: Math.max( | ||
| 0, | ||
| ...group.map((f) => (f.lineNo != null ? String(f.lineNo).length : 0)), | ||
| ), | ||
| level: Math.max(...group.map((f) => levelLabel(f.level).length)), | ||
| msg: Math.max(...group.map((f) => f.message.length)), | ||
| }; | ||
| } | ||
| function plural(n, word) { | ||
| return `${n} ${word}${n === 1 ? "" : "s"}`; | ||
| } | ||
| function renderFinding(f, w) { | ||
| const loc = | ||
| f.lineNo != null ? String(f.lineNo).padStart(w.loc) : " ".repeat(w.loc); | ||
| const level = levelLabel(f.level).padEnd(w.level); | ||
| const msg = f.message.padEnd(w.msg); | ||
| const lines = [` ${loc} ${level} ${msg} ${f.id}`]; | ||
| if (f.hint) { | ||
| const pad = 2 + w.loc + 2 + w.level + 2; | ||
| lines.push(`${" ".repeat(pad)}→ ${f.hint}`); | ||
| } | ||
| return lines; | ||
| } | ||
| function renderGroup(filePath, group, cwd) { | ||
| const w = widths(group); | ||
| const lines = [relPath(filePath, cwd)]; | ||
| for (const f of group) lines.push(...renderFinding(f, w)); | ||
| return lines; | ||
| } | ||
| function renderTrailer(findings) { | ||
| const { failures, warnings } = partition(findings); | ||
| const symbol = failures.length === 0 ? "⚠" : "✖"; | ||
| return `${symbol} ${plural(findings.length, "problem")} (${plural(failures.length, "error")}, ${plural(warnings.length, "warning")})`; | ||
| } | ||
| /** Render findings as ESLint-style grouped output with rule IDs and hints. */ | ||
| export function emitFindingsText(findings, options = {}) { | ||
| if (findings.length === 0) { | ||
| const label = options.passMessage ?? "all checks passed"; | ||
| return `✓ ${label}\n`; | ||
| } | ||
| const cwd = options.cwd ?? null; | ||
| const blocks = []; | ||
| for (const [filePath, group] of groupByPath(findings)) { | ||
| blocks.push(renderGroup(filePath, group, cwd).join("\n")); | ||
| } | ||
| blocks.push(renderTrailer(findings)); | ||
| return `${blocks.join("\n\n")}\n`; | ||
| } | ||
| /** Render findings as a JSON document. */ | ||
| export function emitFindingsJson(findings) { | ||
| const { failures, warnings } = partition(findings); | ||
| return ( | ||
| JSON.stringify( | ||
| { | ||
| result: failures.length === 0 ? "pass" : "fail", | ||
| failures, | ||
| warnings, | ||
| }, | ||
| null, | ||
| 2, | ||
| ) + "\n" | ||
| ); | ||
| } |
+56
| // Generic rule-execution engine, paired with `libutil/findings.js` for output. | ||
| // | ||
| // A rule is `{ id, scope, severity, when?, check, message, hint? }`: | ||
| // | ||
| // - `scope` is an opaque string. The caller supplies a `resolveScope(scopeKey, | ||
| // ctx)` function that returns the list of subjects for that scope. Subjects | ||
| // carry whatever fields the rule's `check` and `message` functions read | ||
| // (commonly `path`, `lineNo`, `text`, parsed-row fields, etc.). | ||
| // - `when(subject, ctx)` is an optional predicate — falsy skips the rule. | ||
| // - `check(subject, ctx)` returns `null` (clean), a single finding item, or | ||
| // an array of finding items. Each item is a plain object whose fields the | ||
| // rule's `message` function reads (e.g., `{ value: 572 }`). | ||
| // - `message(subject, item, ctx)` builds the human-readable message string. | ||
| // - `hint` is an optional static string rendered as an action prompt by the | ||
| // text emitter. | ||
| // | ||
| // `ctx` is passed unchanged to every rule. Cross-subject state (e.g., a | ||
| // duplicate-detection map) lives on `ctx` and is mutated by the rule during | ||
| // iteration — the engine iterates rules grouped by scope in stable order. | ||
| function groupByScope(rules) { | ||
| const groups = new Map(); | ||
| for (const rule of rules) { | ||
| if (!groups.has(rule.scope)) groups.set(rule.scope, []); | ||
| groups.get(rule.scope).push(rule); | ||
| } | ||
| return groups; | ||
| } | ||
| function applyRule(rule, subject, ctx) { | ||
| if (rule.when && !rule.when(subject, ctx)) return []; | ||
| const result = rule.check(subject, ctx); | ||
| if (result == null) return []; | ||
| const items = Array.isArray(result) ? result : [result]; | ||
| return items.map((item) => ({ | ||
| id: rule.id, | ||
| level: rule.severity, | ||
| path: subject.path ?? null, | ||
| lineNo: item.lineNo ?? subject.lineNo ?? null, | ||
| message: rule.message(subject, item, ctx), | ||
| hint: rule.hint ?? null, | ||
| })); | ||
| } | ||
| /** Apply a declarative rule catalogue against a context with an injected scope resolver. Returns a flat Finding[]. */ | ||
| export function runRules(rules, ctx, { resolveScope }) { | ||
| const findings = []; | ||
| for (const [scopeKey, scopeRules] of groupByScope(rules)) { | ||
| for (const subject of resolveScope(scopeKey, ctx)) { | ||
| for (const rule of scopeRules) { | ||
| findings.push(...applyRule(rule, subject, ctx)); | ||
| } | ||
| } | ||
| } | ||
| return findings; | ||
| } |
+1
-1
| { | ||
| "name": "@forwardimpact/libutil", | ||
| "version": "0.1.82", | ||
| "version": "0.1.83", | ||
| "description": "Cross-cutting utilities: retry, hashing, token counting, and project discovery.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+2
-0
@@ -166,1 +166,3 @@ import crypto from "crypto"; | ||
| export { waitFor } from "./wait.js"; | ||
| export { emitFindingsText, emitFindingsJson } from "./findings.js"; | ||
| export { runRules } from "./rules.js"; |
58837
10.43%16
14.29%1273
13.66%