facult 2.5.2 → 2.7.0

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.
@@ -2,8 +2,8 @@ import { homedir } from "node:os";
2
2
  import { ensureAiIndexPath } from "../ai-state";
3
3
  import type { FacultIndex } from "../index-builder";
4
4
  import { facultAiIndexPath, facultRootDir } from "../paths";
5
- import type { AuditItemResult, Severity } from "./types";
6
- import { SEVERITY_ORDER } from "./types";
5
+ import { computeStoredAuditStatus } from "./status";
6
+ import type { AuditItemResult } from "./types";
7
7
 
8
8
  function isPlainObject(v: unknown): v is Record<string, unknown> {
9
9
  return !!v && typeof v === "object" && !Array.isArray(v);
@@ -21,19 +21,6 @@ function ensureIndexStructure(index: FacultIndex): FacultIndex {
21
21
  };
22
22
  }
23
23
 
24
- function computeAuditStatus(
25
- findings: { severity: Severity; ruleId: string }[]
26
- ): "pending" | "passed" | "flagged" {
27
- if (findings.some((f) => f.ruleId === "agent-error")) {
28
- return "pending";
29
- }
30
- const worst = findings.reduce(
31
- (m, f) => Math.max(m, SEVERITY_ORDER[f.severity]),
32
- -1
33
- );
34
- return worst >= SEVERITY_ORDER.high ? "flagged" : "passed";
35
- }
36
-
37
24
  async function loadIndex(homeDir: string): Promise<FacultIndex | null> {
38
25
  const { path: indexPath } = await ensureAiIndexPath({
39
26
  homeDir,
@@ -83,7 +70,7 @@ export async function updateIndexFromAuditReport(opts: {
83
70
  // Only update the canonical instance tracked in the index.
84
71
  continue;
85
72
  }
86
- const status = computeAuditStatus(
73
+ const status = computeStoredAuditStatus(
87
74
  r.findings.map((f) => ({ severity: f.severity, ruleId: f.ruleId }))
88
75
  );
89
76
  entry.auditStatus = status;
@@ -100,7 +87,7 @@ export async function updateIndexFromAuditReport(opts: {
100
87
  if (typeof entry.path === "string" && entry.path !== r.path) {
101
88
  continue;
102
89
  }
103
- const status = computeAuditStatus(
90
+ const status = computeStoredAuditStatus(
104
91
  r.findings.map((f) => ({ severity: f.severity, ruleId: f.ruleId }))
105
92
  );
106
93
  entry.auditStatus = status;
package/src/cli-ui.ts ADDED
@@ -0,0 +1,375 @@
1
+ const ANSI_ESCAPE = String.fromCharCode(27);
2
+ const ANSI_RE = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g");
3
+ const NEWLINE_SPLIT_RE = /\n+/;
4
+ const WHITESPACE_SPLIT_RE = /\s+/;
5
+ const DEFAULT_CONTENT_WIDTH = 92;
6
+ const MIN_CONTENT_WIDTH = 64;
7
+ const MAX_CONTENT_WIDTH = 108;
8
+ const TABLE_MIN_COLUMN_WIDTH = 12;
9
+ const TABLE_GAP = 2;
10
+
11
+ type Tone = "accent" | "muted" | "success" | "warn" | "danger";
12
+
13
+ export interface PageSection {
14
+ title: string;
15
+ lines: string[];
16
+ }
17
+
18
+ export interface RenderPageOptions {
19
+ title: string;
20
+ subtitle?: string;
21
+ sections: PageSection[];
22
+ footer?: string[];
23
+ }
24
+
25
+ export interface RenderTableOptions {
26
+ headers: string[];
27
+ rows: string[][];
28
+ }
29
+
30
+ export interface RenderCatalogItem {
31
+ title: string;
32
+ meta?: string;
33
+ badges?: string[];
34
+ description?: string;
35
+ details?: string[];
36
+ }
37
+
38
+ function supportsColor(): boolean {
39
+ return (
40
+ process.stdout.isTTY === true &&
41
+ process.env.NO_COLOR === undefined &&
42
+ process.env.CLICOLOR !== "0"
43
+ );
44
+ }
45
+
46
+ function colorize(text: string, tone: Tone, bold = false): string {
47
+ if (!supportsColor()) {
48
+ return text;
49
+ }
50
+
51
+ const code =
52
+ tone === "accent"
53
+ ? "36"
54
+ : tone === "muted"
55
+ ? "2"
56
+ : tone === "success"
57
+ ? "32"
58
+ : tone === "warn"
59
+ ? "33"
60
+ : "31";
61
+
62
+ const prefix = bold ? `1;${code}` : code;
63
+ return `\u001B[${prefix}m${text}\u001B[0m`;
64
+ }
65
+
66
+ function visibleWidth(value: string): number {
67
+ return value.replace(ANSI_RE, "").length;
68
+ }
69
+
70
+ function containsAnsi(value: string): boolean {
71
+ return value.replace(ANSI_RE, "") !== value;
72
+ }
73
+
74
+ function contentWidth(): number {
75
+ const terminalWidth = process.stdout.columns ?? DEFAULT_CONTENT_WIDTH;
76
+ return Math.max(
77
+ MIN_CONTENT_WIDTH,
78
+ Math.min(MAX_CONTENT_WIDTH, terminalWidth - 2)
79
+ );
80
+ }
81
+
82
+ function padVisible(value: string, width: number): string {
83
+ return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
84
+ }
85
+
86
+ function clampColumnWidths(headers: string[], rows: string[][]): number[] {
87
+ const maxWidth = contentWidth();
88
+ const widths = headers.map((header, index) =>
89
+ Math.max(
90
+ visibleWidth(header),
91
+ ...rows.map((row) => visibleWidth(row[index] ?? ""))
92
+ )
93
+ );
94
+ const totalWidth =
95
+ widths.reduce((sum, width) => sum + width, 0) +
96
+ Math.max(0, headers.length - 1) * TABLE_GAP;
97
+
98
+ if (totalWidth <= maxWidth || headers.length <= 1) {
99
+ return widths;
100
+ }
101
+
102
+ const next = [...widths];
103
+ const shrinkable = new Set(next.keys());
104
+
105
+ while (
106
+ next.reduce((sum, width) => sum + width, 0) +
107
+ Math.max(0, headers.length - 1) * TABLE_GAP >
108
+ maxWidth
109
+ ) {
110
+ let widestIndex = -1;
111
+ let widestWidth = -1;
112
+
113
+ for (const index of shrinkable) {
114
+ if ((next[index] ?? 0) > widestWidth) {
115
+ widestWidth = next[index] ?? 0;
116
+ widestIndex = index;
117
+ }
118
+ }
119
+
120
+ if (widestIndex < 0) {
121
+ break;
122
+ }
123
+
124
+ const minWidth =
125
+ widestIndex === 0 && headers.length === 2 ? 10 : TABLE_MIN_COLUMN_WIDTH;
126
+ if ((next[widestIndex] ?? 0) <= minWidth) {
127
+ shrinkable.delete(widestIndex);
128
+ if (shrinkable.size === 0) {
129
+ break;
130
+ }
131
+ continue;
132
+ }
133
+
134
+ next[widestIndex] = (next[widestIndex] ?? 0) - 1;
135
+ }
136
+
137
+ return next;
138
+ }
139
+
140
+ function wrapWord(word: string, width: number): string[] {
141
+ if (width <= 0 || visibleWidth(word) <= width) {
142
+ return [word];
143
+ }
144
+
145
+ const chunks: string[] = [];
146
+ let chunk = "";
147
+ for (const char of word) {
148
+ if (visibleWidth(chunk + char) > width && chunk) {
149
+ chunks.push(chunk);
150
+ chunk = char;
151
+ continue;
152
+ }
153
+ chunk += char;
154
+ }
155
+ if (chunk) {
156
+ chunks.push(chunk);
157
+ }
158
+ return chunks;
159
+ }
160
+
161
+ function wrapPlainText(text: string, width: number): string[] {
162
+ const normalized = text.trim();
163
+ if (!normalized) {
164
+ return [""];
165
+ }
166
+
167
+ if (width <= 0) {
168
+ return [normalized];
169
+ }
170
+
171
+ const paragraphs = normalized.split(NEWLINE_SPLIT_RE);
172
+ const lines: string[] = [];
173
+
174
+ for (const paragraph of paragraphs) {
175
+ const words = paragraph.split(WHITESPACE_SPLIT_RE).filter(Boolean);
176
+ if (words.length === 0) {
177
+ lines.push("");
178
+ continue;
179
+ }
180
+
181
+ let line = "";
182
+ for (const word of words) {
183
+ const segments = wrapWord(word, width);
184
+ for (const segment of segments) {
185
+ const candidate = line ? `${line} ${segment}` : segment;
186
+ if (visibleWidth(candidate) > width && line) {
187
+ lines.push(line);
188
+ line = segment;
189
+ continue;
190
+ }
191
+ line = candidate;
192
+ }
193
+ }
194
+
195
+ if (line) {
196
+ lines.push(line);
197
+ }
198
+ }
199
+
200
+ return lines.length > 0 ? lines : [normalized];
201
+ }
202
+
203
+ function wrapPrefixedLine(
204
+ prefix: string,
205
+ text: string,
206
+ width = contentWidth()
207
+ ): string[] {
208
+ if (containsAnsi(text)) {
209
+ return [`${prefix}${text}`];
210
+ }
211
+ const bodyWidth = Math.max(12, width - visibleWidth(prefix));
212
+ const wrapped = wrapPlainText(text, bodyWidth);
213
+ return wrapped.map(
214
+ (line, index) =>
215
+ `${index === 0 ? prefix : " ".repeat(visibleWidth(prefix))}${line}`
216
+ );
217
+ }
218
+
219
+ function renderSectionTitle(title: string): string[] {
220
+ const dividerWidth = Math.max(8, Math.min(contentWidth(), title.length + 12));
221
+ return [
222
+ colorize(title, "accent", true),
223
+ renderMuted("─".repeat(dividerWidth)),
224
+ ];
225
+ }
226
+
227
+ export function renderCode(value: string): string {
228
+ return colorize(value, "accent", true);
229
+ }
230
+
231
+ export function renderMuted(value: string): string {
232
+ return colorize(value, "muted");
233
+ }
234
+
235
+ export function renderBadge(value: string, tone: Tone): string {
236
+ return colorize(`[${value}]`, tone, true);
237
+ }
238
+
239
+ export function renderBullets(items: string[]): string[] {
240
+ return items.flatMap((item) => wrapPrefixedLine("• ", item));
241
+ }
242
+
243
+ export function renderCatalog(items: RenderCatalogItem[]): string[] {
244
+ const width = contentWidth();
245
+ const lines: string[] = [];
246
+
247
+ for (const [index, item] of items.entries()) {
248
+ if (index > 0) {
249
+ lines.push(renderMuted("─".repeat(Math.min(width, 32))));
250
+ }
251
+
252
+ const titleLine = colorize(item.title, "accent", true);
253
+ if (item.meta) {
254
+ const metaLine = renderMuted(item.meta);
255
+ const combined = `${titleLine} ${metaLine}`;
256
+ if (visibleWidth(combined) <= width) {
257
+ lines.push(combined);
258
+ } else {
259
+ lines.push(titleLine);
260
+ lines.push(metaLine);
261
+ }
262
+ } else {
263
+ lines.push(titleLine);
264
+ }
265
+
266
+ if (item.badges && item.badges.length > 0) {
267
+ lines.push(item.badges.join(" "));
268
+ }
269
+
270
+ if (item.description) {
271
+ lines.push(...wrapPlainText(item.description, width));
272
+ }
273
+
274
+ if (item.details) {
275
+ for (const detail of item.details) {
276
+ lines.push(...wrapPrefixedLine(" ", detail, width));
277
+ }
278
+ }
279
+ }
280
+
281
+ return lines;
282
+ }
283
+
284
+ export function renderKeyValue(
285
+ rows: [label: string, value: string][]
286
+ ): string[] {
287
+ if (rows.length === 0) {
288
+ return [];
289
+ }
290
+
291
+ const width = contentWidth();
292
+ const labelWidth = Math.min(
293
+ 16,
294
+ Math.max(...rows.map(([label]) => visibleWidth(label)), 0)
295
+ );
296
+ const valueWidth = Math.max(20, width - labelWidth - 3);
297
+ const lines: string[] = [];
298
+
299
+ for (const [label, value] of rows) {
300
+ const wrapped = wrapPlainText(value, valueWidth);
301
+ lines.push(
302
+ `${padVisible(renderMuted(label), labelWidth)} ${wrapped[0] ?? ""}`
303
+ );
304
+ for (const line of wrapped.slice(1)) {
305
+ lines.push(`${" ".repeat(labelWidth)} ${line}`);
306
+ }
307
+ }
308
+
309
+ return lines;
310
+ }
311
+
312
+ export function renderTable(options: RenderTableOptions): string[] {
313
+ if (options.headers.length === 0) {
314
+ return [];
315
+ }
316
+
317
+ const widths = clampColumnWidths(options.headers, options.rows);
318
+ const gap = " ".repeat(TABLE_GAP);
319
+ const header = options.headers
320
+ .map((value, index) => padVisible(value, widths[index] ?? 0))
321
+ .join(gap);
322
+ const divider = widths.map((width) => "─".repeat(width)).join(gap);
323
+ const rows = options.rows.flatMap((row) => {
324
+ const wrappedCells = row.map((value, index) =>
325
+ wrapPlainText(value ?? "", widths[index] ?? TABLE_MIN_COLUMN_WIDTH)
326
+ );
327
+ const rowHeight = Math.max(...wrappedCells.map((cell) => cell.length), 1);
328
+
329
+ return Array.from({ length: rowHeight }, (_, rowIndex) =>
330
+ wrappedCells
331
+ .map((cell, columnIndex) =>
332
+ padVisible(cell[rowIndex] ?? "", widths[columnIndex] ?? 0)
333
+ )
334
+ .join(gap)
335
+ );
336
+ });
337
+
338
+ return [colorize(header, "accent", true), renderMuted(divider), ...rows];
339
+ }
340
+
341
+ export function renderJsonBlock(value: unknown): string[] {
342
+ return JSON.stringify(value, null, 2).split("\n");
343
+ }
344
+
345
+ export function renderPage(options: RenderPageOptions): string {
346
+ const lines: string[] = [colorize(options.title, "accent", true)];
347
+
348
+ if (options.subtitle) {
349
+ lines.push(
350
+ ...wrapPlainText(options.subtitle, contentWidth()).map((line) =>
351
+ renderMuted(line)
352
+ )
353
+ );
354
+ }
355
+
356
+ for (const section of options.sections) {
357
+ if (section.lines.length === 0) {
358
+ continue;
359
+ }
360
+ lines.push("");
361
+ lines.push(...renderSectionTitle(section.title));
362
+ lines.push(...section.lines);
363
+ }
364
+
365
+ if (options.footer && options.footer.length > 0) {
366
+ lines.push("");
367
+ lines.push(
368
+ ...options.footer.flatMap((line) =>
369
+ wrapPrefixedLine("› ", line).map((wrapped) => renderMuted(wrapped))
370
+ )
371
+ );
372
+ }
373
+
374
+ return lines.join("\n");
375
+ }