facult 2.6.0 → 2.7.1

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.
@@ -1,4 +1,6 @@
1
1
  import { agentAuditCommand } from "./agent";
2
+ import { auditFixCommand } from "./fix";
3
+ import { auditSafeCommand } from "./safe";
2
4
  import { staticAuditCommand } from "./static";
3
5
  import { auditTuiCommand } from "./tui";
4
6
 
@@ -7,6 +9,8 @@ function printHelp() {
7
9
 
8
10
  Usage:
9
11
  fclt audit [--from <path>] [--no-config-from]
12
+ fclt audit fix <item> [--path <path>] [--source <static|agent|combined>]
13
+ fclt audit safe <item> [--rule <id>] [--location <text>] [--message <text>]
10
14
  fclt audit --non-interactive [name|mcp:<name>] [--severity <level>] [--rules <path>] [--from <path>] [--json]
11
15
  fclt audit --non-interactive [name|mcp:<name>] --with <claude|codex> [--from <path>] [--max-items <n|all>] [--json]
12
16
 
@@ -18,13 +22,36 @@ Legacy (still supported; prefer --non-interactive):
18
22
  }
19
23
 
20
24
  export async function auditCommand(argv: string[]) {
25
+ const firstPositional = argv.find((a) => a && !a.startsWith("-")) ?? null;
26
+
27
+ if (
28
+ (argv.includes("--help") || argv.includes("-h")) &&
29
+ firstPositional === "fix"
30
+ ) {
31
+ await auditFixCommand(argv.slice(1));
32
+ return;
33
+ }
34
+ if (
35
+ (argv.includes("--help") || argv.includes("-h")) &&
36
+ firstPositional === "safe"
37
+ ) {
38
+ await auditSafeCommand(argv.slice(1));
39
+ return;
40
+ }
41
+ if (argv[0] === "help" && (argv[1] === "fix" || argv[1] === "safe")) {
42
+ if (argv[1] === "fix") {
43
+ await auditFixCommand(["--help"]);
44
+ return;
45
+ }
46
+ await auditSafeCommand(["--help"]);
47
+ return;
48
+ }
21
49
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
22
50
  printHelp();
23
51
  return;
24
52
  }
25
53
 
26
54
  const nonInteractive = argv.includes("--non-interactive");
27
- const firstPositional = argv.find((a) => a && !a.startsWith("-")) ?? null;
28
55
 
29
56
  const rest = argv.filter((a) => a !== "--non-interactive");
30
57
 
@@ -32,10 +59,24 @@ export async function auditCommand(argv: string[]) {
32
59
  // Optional: allow `fclt audit --non-interactive static ...` / `... agent ...`
33
60
  const sub = firstPositional;
34
61
  const subArgs =
35
- sub === "static" || sub === "agent" || sub === "tui" || sub === "wizard"
62
+ sub === "static" ||
63
+ sub === "agent" ||
64
+ sub === "tui" ||
65
+ sub === "wizard" ||
66
+ sub === "fix" ||
67
+ sub === "safe"
36
68
  ? rest.slice(1)
37
69
  : rest;
38
70
 
71
+ if (sub === "fix") {
72
+ await auditFixCommand(subArgs);
73
+ return;
74
+ }
75
+ if (sub === "safe") {
76
+ await auditSafeCommand(subArgs);
77
+ return;
78
+ }
79
+
39
80
  const hasWith =
40
81
  subArgs.includes("--with") ||
41
82
  subArgs.some((a) => a.startsWith("--with="));
@@ -68,6 +109,14 @@ export async function auditCommand(argv: string[]) {
68
109
  await auditTuiCommand(argv.slice(1));
69
110
  return;
70
111
  }
112
+ if (firstPositional === "safe") {
113
+ await auditSafeCommand(argv.slice(1));
114
+ return;
115
+ }
116
+ if (firstPositional === "fix") {
117
+ await auditFixCommand(argv.slice(1));
118
+ return;
119
+ }
71
120
 
72
121
  // Default: interactive wizard.
73
122
  await auditTuiCommand(argv);
@@ -0,0 +1,596 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, join } from "node:path";
3
+ import { facultStateDir } from "../paths";
4
+ import type { AgentAuditReport } from "./agent";
5
+ import {
6
+ applyAuditSuppressionsToAgentReport,
7
+ applyAuditSuppressionsToStaticReport,
8
+ loadAuditSuppressions,
9
+ recordAuditSuppressions,
10
+ } from "./suppressions";
11
+ import type {
12
+ AuditFinding,
13
+ AuditItemResult,
14
+ Severity,
15
+ StaticAuditReport,
16
+ } from "./types";
17
+ import { updateIndexFromAuditReport } from "./update-index";
18
+
19
+ type AuditSafeSource = "static" | "agent" | "combined";
20
+ const ARG_VALUE_SPLIT_RE = /=(.*)/s;
21
+
22
+ interface AuditSafeArgs {
23
+ all: boolean;
24
+ dryRun: boolean;
25
+ itemSelectors: string[];
26
+ json: boolean;
27
+ locations: string[];
28
+ messages: string[];
29
+ note?: string;
30
+ paths: string[];
31
+ rules: string[];
32
+ severity?: Severity;
33
+ source?: AuditSafeSource;
34
+ yes: boolean;
35
+ }
36
+
37
+ interface FindingSelection {
38
+ result: AuditItemResult;
39
+ finding: AuditFinding;
40
+ }
41
+
42
+ const RULE_ID_PREFIX_RE = /^(static|agent):/;
43
+
44
+ function normalizeRuleId(ruleId: string): string {
45
+ return ruleId.replace(RULE_ID_PREFIX_RE, "");
46
+ }
47
+
48
+ function parseSource(value: string): AuditSafeSource {
49
+ const normalized = value.trim().toLowerCase();
50
+ if (
51
+ normalized === "static" ||
52
+ normalized === "agent" ||
53
+ normalized === "combined"
54
+ ) {
55
+ return normalized;
56
+ }
57
+ throw new Error(`Unknown audit safe source: ${value}`);
58
+ }
59
+
60
+ function parseSeverity(value: string): Severity {
61
+ const normalized = value.trim().toLowerCase();
62
+ if (
63
+ normalized === "low" ||
64
+ normalized === "medium" ||
65
+ normalized === "high" ||
66
+ normalized === "critical"
67
+ ) {
68
+ return normalized;
69
+ }
70
+ throw new Error(`Unknown severity: ${value}`);
71
+ }
72
+
73
+ function parseAuditSafeArgs(argv: string[]): AuditSafeArgs {
74
+ const args: AuditSafeArgs = {
75
+ all: false,
76
+ dryRun: false,
77
+ itemSelectors: [],
78
+ json: false,
79
+ locations: [],
80
+ messages: [],
81
+ paths: [],
82
+ rules: [],
83
+ yes: false,
84
+ };
85
+
86
+ for (let i = 0; i < argv.length; i += 1) {
87
+ const arg = argv[i];
88
+ if (!arg) {
89
+ continue;
90
+ }
91
+
92
+ if (arg === "--all") {
93
+ args.all = true;
94
+ continue;
95
+ }
96
+ if (arg === "--dry-run") {
97
+ args.dryRun = true;
98
+ continue;
99
+ }
100
+ if (arg === "--json") {
101
+ args.json = true;
102
+ continue;
103
+ }
104
+ if (arg === "--yes" || arg === "-y") {
105
+ args.yes = true;
106
+ continue;
107
+ }
108
+
109
+ if (arg === "--source" || arg === "--item" || arg === "--path") {
110
+ const next = argv[i + 1];
111
+ if (!next) {
112
+ throw new Error(`${arg} requires a value`);
113
+ }
114
+ if (arg === "--source") {
115
+ args.source = parseSource(next);
116
+ } else if (arg === "--item") {
117
+ args.itemSelectors.push(next);
118
+ } else {
119
+ args.paths.push(next);
120
+ }
121
+ i += 1;
122
+ continue;
123
+ }
124
+
125
+ if (
126
+ arg === "--rule" ||
127
+ arg === "--location" ||
128
+ arg === "--message" ||
129
+ arg === "--note" ||
130
+ arg === "--severity"
131
+ ) {
132
+ const next = argv[i + 1];
133
+ if (!next) {
134
+ throw new Error(`${arg} requires a value`);
135
+ }
136
+ if (arg === "--rule") {
137
+ args.rules.push(next);
138
+ } else if (arg === "--location") {
139
+ args.locations.push(next);
140
+ } else if (arg === "--message") {
141
+ args.messages.push(next);
142
+ } else if (arg === "--note") {
143
+ args.note = next;
144
+ } else {
145
+ args.severity = parseSeverity(next);
146
+ }
147
+ i += 1;
148
+ continue;
149
+ }
150
+
151
+ if (
152
+ arg.startsWith("--source=") ||
153
+ arg.startsWith("--item=") ||
154
+ arg.startsWith("--path=") ||
155
+ arg.startsWith("--rule=") ||
156
+ arg.startsWith("--location=") ||
157
+ arg.startsWith("--message=") ||
158
+ arg.startsWith("--note=") ||
159
+ arg.startsWith("--severity=")
160
+ ) {
161
+ const [flag, rawValue] = arg.split(ARG_VALUE_SPLIT_RE, 2);
162
+ const value = rawValue ?? "";
163
+ if (!value) {
164
+ throw new Error(`${flag} requires a value`);
165
+ }
166
+ if (flag === "--source") {
167
+ args.source = parseSource(value);
168
+ } else if (flag === "--item") {
169
+ args.itemSelectors.push(value);
170
+ } else if (flag === "--path") {
171
+ args.paths.push(value);
172
+ } else if (flag === "--rule") {
173
+ args.rules.push(value);
174
+ } else if (flag === "--location") {
175
+ args.locations.push(value);
176
+ } else if (flag === "--message") {
177
+ args.messages.push(value);
178
+ } else if (flag === "--note") {
179
+ args.note = value;
180
+ } else if (flag === "--severity") {
181
+ args.severity = parseSeverity(value);
182
+ }
183
+ continue;
184
+ }
185
+
186
+ if (arg.startsWith("-")) {
187
+ throw new Error(`Unknown argument: ${arg}`);
188
+ }
189
+
190
+ args.itemSelectors.push(arg);
191
+ }
192
+
193
+ if (
194
+ !args.all &&
195
+ args.itemSelectors.length === 0 &&
196
+ args.paths.length === 0 &&
197
+ args.rules.length === 0 &&
198
+ args.locations.length === 0 &&
199
+ args.messages.length === 0 &&
200
+ !args.severity
201
+ ) {
202
+ throw new Error(
203
+ "Specify what to suppress with --item, --rule, --path, --location, --message, --severity, or use --all."
204
+ );
205
+ }
206
+
207
+ return args;
208
+ }
209
+
210
+ function uniqueByKey<T>(items: T[], key: (value: T) => string): T[] {
211
+ const seen = new Set<string>();
212
+ const out: T[] = [];
213
+ for (const item of items) {
214
+ const itemKey = key(item);
215
+ if (seen.has(itemKey)) {
216
+ continue;
217
+ }
218
+ seen.add(itemKey);
219
+ out.push(item);
220
+ }
221
+ return out;
222
+ }
223
+
224
+ function keyForResult(result: AuditItemResult): string {
225
+ return `${result.type}\0${result.item}\0${result.path}`;
226
+ }
227
+
228
+ function prefixRuleId(
229
+ finding: AuditFinding,
230
+ prefix: "static" | "agent"
231
+ ): AuditFinding {
232
+ return finding.ruleId.startsWith(`${prefix}:`)
233
+ ? finding
234
+ : { ...finding, ruleId: `${prefix}:${finding.ruleId}` };
235
+ }
236
+
237
+ function mergeStaticAndAgentResults(args: {
238
+ static: AuditItemResult[];
239
+ agent: AuditItemResult[];
240
+ }): AuditItemResult[] {
241
+ const byKey = new Map<
242
+ string,
243
+ { static?: AuditItemResult; agent?: AuditItemResult }
244
+ >();
245
+
246
+ for (const result of args.static) {
247
+ const key = keyForResult(result);
248
+ const previous = byKey.get(key) ?? {};
249
+ byKey.set(key, { ...previous, static: result });
250
+ }
251
+ for (const result of args.agent) {
252
+ const key = keyForResult(result);
253
+ const previous = byKey.get(key) ?? {};
254
+ byKey.set(key, { ...previous, agent: result });
255
+ }
256
+
257
+ const out: AuditItemResult[] = [];
258
+ for (const key of [...byKey.keys()].sort()) {
259
+ const entry = byKey.get(key);
260
+ if (!entry) {
261
+ continue;
262
+ }
263
+ if (entry.static && entry.agent) {
264
+ out.push({
265
+ ...entry.agent,
266
+ passed: entry.static.passed && entry.agent.passed,
267
+ findings: [
268
+ ...entry.agent.findings.map((finding) =>
269
+ prefixRuleId(finding, "agent")
270
+ ),
271
+ ...entry.static.findings.map((finding) =>
272
+ prefixRuleId(finding, "static")
273
+ ),
274
+ ],
275
+ });
276
+ continue;
277
+ }
278
+ out.push(entry.agent ?? entry.static!);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ async function loadLatestStaticReport(
284
+ homeDir: string
285
+ ): Promise<StaticAuditReport | null> {
286
+ const path = join(facultStateDir(homeDir), "audit", "static-latest.json");
287
+ const file = Bun.file(path);
288
+ if (!(await file.exists())) {
289
+ return null;
290
+ }
291
+ return (await file.json()) as StaticAuditReport;
292
+ }
293
+
294
+ async function loadLatestAgentReport(
295
+ homeDir: string
296
+ ): Promise<AgentAuditReport | null> {
297
+ const path = join(facultStateDir(homeDir), "audit", "agent-latest.json");
298
+ const file = Bun.file(path);
299
+ if (!(await file.exists())) {
300
+ return null;
301
+ }
302
+ return (await file.json()) as AgentAuditReport;
303
+ }
304
+
305
+ function inferSource(args: {
306
+ requested?: AuditSafeSource;
307
+ staticReport: StaticAuditReport | null;
308
+ agentReport: AgentAuditReport | null;
309
+ }): AuditSafeSource {
310
+ if (args.requested) {
311
+ return args.requested;
312
+ }
313
+ if (args.staticReport && args.agentReport) {
314
+ return "combined";
315
+ }
316
+ if (args.agentReport) {
317
+ return "agent";
318
+ }
319
+ return "static";
320
+ }
321
+
322
+ function matchesItemSelector(
323
+ result: AuditItemResult,
324
+ selector: string
325
+ ): boolean {
326
+ const normalized = selector.trim();
327
+ if (!normalized) {
328
+ return false;
329
+ }
330
+ const labels = [
331
+ result.item,
332
+ `${result.type}:${result.item}`,
333
+ result.type === "mcp" ? `mcp:${result.item}` : null,
334
+ result.type === "skill" ? `skill:${result.item}` : null,
335
+ basename(result.path),
336
+ ].filter(Boolean) as string[];
337
+ return labels.some(
338
+ (label) => label.toLowerCase() === normalized.toLowerCase()
339
+ );
340
+ }
341
+
342
+ function matchesPath(result: AuditItemResult, candidate: string): boolean {
343
+ const normalized = candidate.trim().toLowerCase();
344
+ if (!normalized) {
345
+ return false;
346
+ }
347
+ const path = result.path.toLowerCase();
348
+ return path === normalized || path.endsWith(`/${normalized}`);
349
+ }
350
+
351
+ function matchesFinding(args: {
352
+ result: AuditItemResult;
353
+ finding: AuditFinding;
354
+ filters: AuditSafeArgs;
355
+ }): boolean {
356
+ if (
357
+ args.filters.itemSelectors.length > 0 &&
358
+ !args.filters.itemSelectors.some((selector) =>
359
+ matchesItemSelector(args.result, selector)
360
+ )
361
+ ) {
362
+ return false;
363
+ }
364
+
365
+ if (
366
+ args.filters.paths.length > 0 &&
367
+ !args.filters.paths.some((candidate) => matchesPath(args.result, candidate))
368
+ ) {
369
+ return false;
370
+ }
371
+
372
+ if (
373
+ args.filters.rules.length > 0 &&
374
+ !args.filters.rules.some((rule) => {
375
+ const normalizedRule = rule.trim().toLowerCase();
376
+ return (
377
+ args.finding.ruleId.toLowerCase() === normalizedRule ||
378
+ normalizeRuleId(args.finding.ruleId).toLowerCase() === normalizedRule
379
+ );
380
+ })
381
+ ) {
382
+ return false;
383
+ }
384
+
385
+ if (
386
+ args.filters.locations.length > 0 &&
387
+ !args.filters.locations.some((location) =>
388
+ (args.finding.location ?? "")
389
+ .toLowerCase()
390
+ .includes(location.toLowerCase())
391
+ )
392
+ ) {
393
+ return false;
394
+ }
395
+
396
+ if (
397
+ args.filters.messages.length > 0 &&
398
+ !args.filters.messages.some((message) =>
399
+ args.finding.message.toLowerCase().includes(message.toLowerCase())
400
+ )
401
+ ) {
402
+ return false;
403
+ }
404
+
405
+ if (
406
+ args.filters.severity &&
407
+ args.finding.severity.toLowerCase() !== args.filters.severity
408
+ ) {
409
+ return false;
410
+ }
411
+
412
+ return true;
413
+ }
414
+
415
+ async function rewriteLatestReports(args: {
416
+ homeDir: string;
417
+ staticReport: StaticAuditReport | null;
418
+ agentReport: AgentAuditReport | null;
419
+ }) {
420
+ const auditDir = join(facultStateDir(args.homeDir), "audit");
421
+ if (args.staticReport) {
422
+ await Bun.write(
423
+ join(auditDir, "static-latest.json"),
424
+ `${JSON.stringify(args.staticReport, null, 2)}\n`
425
+ );
426
+ }
427
+ if (args.agentReport) {
428
+ await Bun.write(
429
+ join(auditDir, "agent-latest.json"),
430
+ `${JSON.stringify(args.agentReport, null, 2)}\n`
431
+ );
432
+ }
433
+ }
434
+
435
+ export async function runAuditSafe(args: {
436
+ argv: string[];
437
+ homeDir?: string;
438
+ }): Promise<{
439
+ added: number;
440
+ matched: number;
441
+ source: AuditSafeSource;
442
+ totalSuppressions: number;
443
+ }> {
444
+ const parsed = parseAuditSafeArgs(args.argv);
445
+ const homeDir = args.homeDir ?? homedir();
446
+ const staticReport = await loadLatestStaticReport(homeDir);
447
+ const agentReport = await loadLatestAgentReport(homeDir);
448
+
449
+ if (!(staticReport || agentReport)) {
450
+ throw new Error(
451
+ "No latest audit reports found. Run `fclt audit` first, then mark findings safe."
452
+ );
453
+ }
454
+
455
+ const source = inferSource({
456
+ requested: parsed.source,
457
+ staticReport,
458
+ agentReport,
459
+ });
460
+ const reportResults =
461
+ source === "static"
462
+ ? (staticReport?.results ?? [])
463
+ : source === "agent"
464
+ ? (agentReport?.results ?? [])
465
+ : mergeStaticAndAgentResults({
466
+ static: staticReport?.results ?? [],
467
+ agent: agentReport?.results ?? [],
468
+ });
469
+
470
+ const selections = reportResults.flatMap((result) =>
471
+ result.findings
472
+ .filter((finding) =>
473
+ parsed.all
474
+ ? true
475
+ : matchesFinding({
476
+ result,
477
+ finding,
478
+ filters: parsed,
479
+ })
480
+ )
481
+ .map((finding) => ({ result, finding }))
482
+ );
483
+
484
+ const uniqueSelections = uniqueByKey(
485
+ selections,
486
+ ({ result, finding }) =>
487
+ `${result.type}\0${result.item}\0${result.path}\0${finding.severity}\0${normalizeRuleId(finding.ruleId)}\0${finding.message}\0${finding.location ?? ""}`
488
+ );
489
+
490
+ if (uniqueSelections.length === 0) {
491
+ throw new Error("No findings matched the requested filters.");
492
+ }
493
+
494
+ if (parsed.dryRun) {
495
+ const totalSuppressions = (await loadAuditSuppressions(homeDir)).length;
496
+ return {
497
+ added: 0,
498
+ matched: uniqueSelections.length,
499
+ source,
500
+ totalSuppressions,
501
+ };
502
+ }
503
+
504
+ const saved = await recordAuditSuppressions({
505
+ homeDir,
506
+ selected: uniqueSelections,
507
+ note: parsed.note,
508
+ });
509
+ const suppressions = await loadAuditSuppressions(homeDir);
510
+ const nextStaticReport = staticReport
511
+ ? applyAuditSuppressionsToStaticReport(staticReport, suppressions)
512
+ : null;
513
+ const nextAgentReport = agentReport
514
+ ? applyAuditSuppressionsToAgentReport(agentReport, suppressions)
515
+ : null;
516
+
517
+ await rewriteLatestReports({
518
+ homeDir,
519
+ staticReport: nextStaticReport,
520
+ agentReport: nextAgentReport,
521
+ });
522
+
523
+ await updateIndexFromAuditReport({
524
+ homeDir,
525
+ timestamp: new Date().toISOString(),
526
+ results: uniqueByKey(
527
+ mergeStaticAndAgentResults({
528
+ static: nextStaticReport?.results ?? [],
529
+ agent: nextAgentReport?.results ?? [],
530
+ }),
531
+ keyForResult
532
+ ),
533
+ });
534
+
535
+ return {
536
+ added: saved.added,
537
+ matched: uniqueSelections.length,
538
+ source,
539
+ totalSuppressions: saved.total,
540
+ };
541
+ }
542
+
543
+ function printHelp() {
544
+ console.log(`fclt audit safe — suppress reviewed findings for future audits
545
+
546
+ Usage:
547
+ fclt audit safe <item> [--rule <id>] [--location <text>] [--message <text>]
548
+ fclt audit safe --item <item> [--path <path>] [--severity <level>] [--note <text>]
549
+ fclt audit safe --all --source <static|agent|combined> [--note <text>] [--yes]
550
+ fclt audit safe --dry-run ...
551
+
552
+ Notes:
553
+ - Reads the latest saved audit reports from ~/.ai/.facult/audit/.
554
+ - Matching is non-interactive and agent-safe.
555
+ - Combined review suppressions also match future raw static/agent findings.
556
+ `);
557
+ }
558
+
559
+ export async function auditSafeCommand(
560
+ argv: string[],
561
+ opts?: { homeDir?: string }
562
+ ) {
563
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
564
+ printHelp();
565
+ return;
566
+ }
567
+
568
+ try {
569
+ const result = await runAuditSafe({
570
+ argv,
571
+ homeDir: opts?.homeDir,
572
+ });
573
+
574
+ if (argv.includes("--json")) {
575
+ console.log(JSON.stringify(result, null, 2));
576
+ return;
577
+ }
578
+
579
+ if (argv.includes("--dry-run")) {
580
+ console.log(
581
+ `Matched ${result.matched} finding${result.matched === 1 ? "" : "s"} in the ${result.source} audit view.`
582
+ );
583
+ return;
584
+ }
585
+
586
+ console.log(
587
+ `Marked ${result.matched} finding${result.matched === 1 ? "" : "s"} safe in the ${result.source} audit view.`
588
+ );
589
+ console.log(
590
+ `Saved ${result.added} new suppression${result.added === 1 ? "" : "s"} (${result.totalSuppressions} total).`
591
+ );
592
+ } catch (error) {
593
+ console.error(error instanceof Error ? error.message : String(error));
594
+ process.exitCode = 1;
595
+ }
596
+ }