@forwardimpact/libutil 0.1.82 → 0.1.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libutil",
3
- "version": "0.1.82",
3
+ "version": "0.1.83",
4
4
  "description": "Cross-cutting utilities: retry, hashing, token counting, and project discovery.",
5
5
  "keywords": [
6
6
  "util",
@@ -0,0 +1,110 @@
1
+ import path from "node:path";
2
+
3
+ // Shared ESLint-style emitter for structured Finding objects. A Finding is
4
+ // `{ id, level, path?, lineNo?, message, hint? }` where `level` is "fail" or
5
+ // "warn"; everything else is optional. Both libwiki's audit and libcoaligned's
6
+ // instruction/JTBD checks emit Finding objects and render through these
7
+ // functions for consistency.
8
+
9
+ function partition(findings) {
10
+ const failures = [];
11
+ const warnings = [];
12
+ for (const f of findings) {
13
+ if (f.level === "warn") warnings.push(f);
14
+ else failures.push(f);
15
+ }
16
+ return { failures, warnings };
17
+ }
18
+
19
+ function relPath(p, cwd) {
20
+ if (!p) return "(no path)";
21
+ if (!cwd) return p;
22
+ const rel = path.relative(cwd, p);
23
+ return rel.startsWith("..") ? p : rel;
24
+ }
25
+
26
+ function groupByPath(findings) {
27
+ const groups = new Map();
28
+ for (const f of findings) {
29
+ const key = f.path ?? "(no path)";
30
+ if (!groups.has(key)) groups.set(key, []);
31
+ groups.get(key).push(f);
32
+ }
33
+ return groups;
34
+ }
35
+
36
+ function levelLabel(level) {
37
+ return level === "warn" ? "warning" : "error";
38
+ }
39
+
40
+ function widths(group) {
41
+ return {
42
+ loc: Math.max(
43
+ 0,
44
+ ...group.map((f) => (f.lineNo != null ? String(f.lineNo).length : 0)),
45
+ ),
46
+ level: Math.max(...group.map((f) => levelLabel(f.level).length)),
47
+ msg: Math.max(...group.map((f) => f.message.length)),
48
+ };
49
+ }
50
+
51
+ function plural(n, word) {
52
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
53
+ }
54
+
55
+ function renderFinding(f, w) {
56
+ const loc =
57
+ f.lineNo != null ? String(f.lineNo).padStart(w.loc) : " ".repeat(w.loc);
58
+ const level = levelLabel(f.level).padEnd(w.level);
59
+ const msg = f.message.padEnd(w.msg);
60
+ const lines = [` ${loc} ${level} ${msg} ${f.id}`];
61
+ if (f.hint) {
62
+ const pad = 2 + w.loc + 2 + w.level + 2;
63
+ lines.push(`${" ".repeat(pad)}→ ${f.hint}`);
64
+ }
65
+ return lines;
66
+ }
67
+
68
+ function renderGroup(filePath, group, cwd) {
69
+ const w = widths(group);
70
+ const lines = [relPath(filePath, cwd)];
71
+ for (const f of group) lines.push(...renderFinding(f, w));
72
+ return lines;
73
+ }
74
+
75
+ function renderTrailer(findings) {
76
+ const { failures, warnings } = partition(findings);
77
+ const symbol = failures.length === 0 ? "⚠" : "✖";
78
+ return `${symbol} ${plural(findings.length, "problem")} (${plural(failures.length, "error")}, ${plural(warnings.length, "warning")})`;
79
+ }
80
+
81
+ /** Render findings as ESLint-style grouped output with rule IDs and hints. */
82
+ export function emitFindingsText(findings, options = {}) {
83
+ if (findings.length === 0) {
84
+ const label = options.passMessage ?? "all checks passed";
85
+ return `✓ ${label}\n`;
86
+ }
87
+ const cwd = options.cwd ?? null;
88
+ const blocks = [];
89
+ for (const [filePath, group] of groupByPath(findings)) {
90
+ blocks.push(renderGroup(filePath, group, cwd).join("\n"));
91
+ }
92
+ blocks.push(renderTrailer(findings));
93
+ return `${blocks.join("\n\n")}\n`;
94
+ }
95
+
96
+ /** Render findings as a JSON document. */
97
+ export function emitFindingsJson(findings) {
98
+ const { failures, warnings } = partition(findings);
99
+ return (
100
+ JSON.stringify(
101
+ {
102
+ result: failures.length === 0 ? "pass" : "fail",
103
+ failures,
104
+ warnings,
105
+ },
106
+ null,
107
+ 2,
108
+ ) + "\n"
109
+ );
110
+ }
package/src/index.js CHANGED
@@ -163,3 +163,5 @@ export { ProcessorBase } from "./processor.js";
163
163
  export { Retry, createRetry } from "./retry.js";
164
164
  export { parseJsonBody } from "./http.js";
165
165
  export { waitFor } from "./wait.js";
166
+ export { emitFindingsText, emitFindingsJson } from "./findings.js";
167
+ export { runRules } from "./rules.js";
package/src/rules.js ADDED
@@ -0,0 +1,56 @@
1
+ // Generic rule-execution engine, paired with `libutil/findings.js` for output.
2
+ //
3
+ // A rule is `{ id, scope, severity, when?, check, message, hint? }`:
4
+ //
5
+ // - `scope` is an opaque string. The caller supplies a `resolveScope(scopeKey,
6
+ // ctx)` function that returns the list of subjects for that scope. Subjects
7
+ // carry whatever fields the rule's `check` and `message` functions read
8
+ // (commonly `path`, `lineNo`, `text`, parsed-row fields, etc.).
9
+ // - `when(subject, ctx)` is an optional predicate — falsy skips the rule.
10
+ // - `check(subject, ctx)` returns `null` (clean), a single finding item, or
11
+ // an array of finding items. Each item is a plain object whose fields the
12
+ // rule's `message` function reads (e.g., `{ value: 572 }`).
13
+ // - `message(subject, item, ctx)` builds the human-readable message string.
14
+ // - `hint` is an optional static string rendered as an action prompt by the
15
+ // text emitter.
16
+ //
17
+ // `ctx` is passed unchanged to every rule. Cross-subject state (e.g., a
18
+ // duplicate-detection map) lives on `ctx` and is mutated by the rule during
19
+ // iteration — the engine iterates rules grouped by scope in stable order.
20
+
21
+ function groupByScope(rules) {
22
+ const groups = new Map();
23
+ for (const rule of rules) {
24
+ if (!groups.has(rule.scope)) groups.set(rule.scope, []);
25
+ groups.get(rule.scope).push(rule);
26
+ }
27
+ return groups;
28
+ }
29
+
30
+ function applyRule(rule, subject, ctx) {
31
+ if (rule.when && !rule.when(subject, ctx)) return [];
32
+ const result = rule.check(subject, ctx);
33
+ if (result == null) return [];
34
+ const items = Array.isArray(result) ? result : [result];
35
+ return items.map((item) => ({
36
+ id: rule.id,
37
+ level: rule.severity,
38
+ path: subject.path ?? null,
39
+ lineNo: item.lineNo ?? subject.lineNo ?? null,
40
+ message: rule.message(subject, item, ctx),
41
+ hint: rule.hint ?? null,
42
+ }));
43
+ }
44
+
45
+ /** Apply a declarative rule catalogue against a context with an injected scope resolver. Returns a flat Finding[]. */
46
+ export function runRules(rules, ctx, { resolveScope }) {
47
+ const findings = [];
48
+ for (const [scopeKey, scopeRules] of groupByScope(rules)) {
49
+ for (const subject of resolveScope(scopeKey, ctx)) {
50
+ for (const rule of scopeRules) {
51
+ findings.push(...applyRule(rule, subject, ctx));
52
+ }
53
+ }
54
+ }
55
+ return findings;
56
+ }