@erclx/aitk 2.0.0 → 2.2.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.
@@ -1,4 +1,6 @@
1
+ import type { AuditRefusal } from '@/deps/audit'
1
2
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
3
+ import type { ScanRefusal } from '@/secrets/scan'
2
4
  import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
3
5
 
4
6
  /**
@@ -16,6 +18,17 @@ export type Corpus =
16
18
  | 'tracked'
17
19
  /** Gitignored session scratch, so the numbers are one machine's alone. */
18
20
  | 'per-machine'
21
+ /**
22
+ * Read from an index off this machine, so the count moves when someone
23
+ * publishes rather than when someone edits here.
24
+ *
25
+ * Its own member rather than either of the two above. A baseline would
26
+ * record growth nobody caused, which is what keeps it out of the retained
27
+ * set alongside per-machine scratch, and an index this run could not reach
28
+ * is an ordinary absence rather than the broken checkout a missing tracked
29
+ * tree would be.
30
+ */
31
+ | 'upstream'
19
32
 
20
33
  export type AuditStatus =
21
34
  /** The audit reported and every count it produced is zero. */
@@ -47,6 +60,17 @@ export interface AuditSpec {
47
60
  */
48
61
  readonly gatingExits: readonly number[]
49
62
  readonly corpus: Corpus
63
+ /**
64
+ * Refusal reasons that mean this audit has no corpus here rather than that
65
+ * it broke, overriding what the corpus alone would allow.
66
+ *
67
+ * Present only where the corpus answers wrongly. A tracked corpus normally
68
+ * allows nothing, since a tree that ships to targets and cannot be found is
69
+ * a broken checkout, and the secret scan is the exception: a project that
70
+ * publishes nothing has no shipped tree to read, which is an ordinary state
71
+ * rather than a defect.
72
+ */
73
+ readonly absentReasons?: readonly string[]
50
74
  /**
51
75
  * Pulls the counts worth retaining out of this verb's record.
52
76
  *
@@ -62,7 +86,10 @@ export interface AuditResult {
62
86
  readonly id: string
63
87
  readonly label: string
64
88
  readonly status: AuditStatus
89
+ /** Retained by the baseline, which is `tracked` alone. */
65
90
  readonly tracked: boolean
91
+ /** Why it is or is not retained, which `tracked` alone cannot say. */
92
+ readonly corpus: Corpus
66
93
  readonly exitCode: number
67
94
  readonly counts?: Record<string, number>
68
95
  /** Why the audit did not report, present only on `unmeasured`. */
@@ -271,6 +298,26 @@ function boardCounts(record: unknown): Record<string, number> | undefined {
271
298
  })
272
299
  }
273
300
 
301
+ /**
302
+ * Reads the advisory verb's per-severity object rather than a total.
303
+ *
304
+ * A single count folds a critical advisory into a low one, and the response to
305
+ * the two differs. The keys are read off the record rather than listed here,
306
+ * so a severity the index adds is carried instead of silently dropped.
307
+ */
308
+ function advisoryCounts(record: unknown): Record<string, number> | undefined {
309
+ const severities = asObject(asObject(record)?.severities)
310
+ if (severities === undefined) return undefined
311
+
312
+ const counts: Record<string, number> = {}
313
+ for (const [severity, value] of Object.entries(severities)) {
314
+ if (typeof value !== 'number') return undefined
315
+ counts[`advisories-${severity}`] = value
316
+ }
317
+
318
+ return counts
319
+ }
320
+
274
321
  function findingsOnly(record: unknown): Record<string, number> | undefined {
275
322
  const root = asObject(record)
276
323
  if (root === undefined) return undefined
@@ -318,10 +365,12 @@ const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
318
365
  /**
319
366
  * Every audit the aggregate runs.
320
367
  *
321
- * `context`, `markdown`, and `skills` are the three that gate, which is exactly
322
- * the set `scripts/core/verify.sh` already fails a push on. Adding a fourth
323
- * here widens what fails a push without anyone deciding to, and the split this
324
- * repository records gates a fact and reports a judgment.
368
+ * `context`, `markdown`, and `skills` gate because `scripts/core/verify.sh`
369
+ * already fails a push on each. `secrets` is the one entry that gates without
370
+ * a stage behind it, added deliberately rather than as a side effect, since a
371
+ * credential in the published tree is a fact and the split this repository
372
+ * records gates a fact and reports a judgment. Weigh any further addition
373
+ * against that test rather than against the count.
325
374
  *
326
375
  * Each verb runs once in its fullest form. Running the gating half separately
327
376
  * would walk the same tree twice for a number the full record already carries.
@@ -386,6 +435,46 @@ export const AUDITS: readonly AuditSpec[] = [
386
435
  corpus: 'tracked',
387
436
  counts: testOrderCounts,
388
437
  },
438
+ {
439
+ id: 'secrets',
440
+ label: 'Shipped tree secrets',
441
+ argv: ['secrets', 'scan', '--json'],
442
+ // The fourth gate, and the first added since the note above was written.
443
+ // A credential-shaped value sitting in the tree this repository publishes
444
+ // is a fact rather than a judgment, which is the test that note asks any
445
+ // addition to pass. It is decided here and stated in the context entry
446
+ // rather than arriving as a side effect of registering a measure.
447
+ gatingExits: [EXIT_FINDINGS],
448
+ corpus: 'tracked',
449
+ // The three reasons that mean this tree publishes nothing, which is where
450
+ // most targets installing this CLI sit. Without the allowance the
451
+ // aggregate reports `incomplete` on every run in every such project and
452
+ // never changes, which is the permanent signal the per-machine allowance
453
+ // exists against.
454
+ //
455
+ // Two reasons are deliberately left out, and both are a corpus that exists
456
+ // and went unread. `no-git` is a broken checkout, and `no-files-field` is a
457
+ // publish that would pack the whole tree, so calling either an absence
458
+ // would report a pass over a shipped tree nobody measured.
459
+ absentReasons: [
460
+ 'no-manifest',
461
+ 'no-publish',
462
+ 'no-shipped-files',
463
+ ] satisfies ScanRefusal[],
464
+ counts: findingsOnly,
465
+ },
466
+ {
467
+ id: 'deps',
468
+ label: 'Dependency advisories',
469
+ argv: ['deps', 'audit', '--json'],
470
+ // Reports rather than gates, on the same split. A published advisory is a
471
+ // fact about the index and a judgment about this tree, since the upgrade
472
+ // may not exist yet, and a push failing on one teaches a contributor to
473
+ // route around the stage while nothing about the dependency has changed.
474
+ gatingExits: [],
475
+ corpus: 'upstream',
476
+ counts: advisoryCounts,
477
+ },
389
478
  ]
390
479
 
391
480
  export function auditFor(id: string): AuditSpec | undefined {
@@ -432,6 +521,25 @@ const ABSENT_REASONS: readonly (RecordRefusal | BoardRefusal)[] = [
432
521
  'no-board',
433
522
  ]
434
523
 
524
+ /**
525
+ * Every reason the advisory verb refuses for, all of which are an absence.
526
+ *
527
+ * An index it could not reach, a project that is not JavaScript, and one whose
528
+ * dependencies were never resolved are three states in which there is nothing
529
+ * to measure rather than something broken. The verb has no fourth reason, so
530
+ * this is its whole union rather than a chosen subset, and typing it that way
531
+ * fails the build if a later reason arrives without this decision being made.
532
+ *
533
+ * Typed against that module's own union for the reason the record reasons are:
534
+ * a literal here would go on matching nothing after a rename and turn every
535
+ * offline run back into an unmeasured audit.
536
+ */
537
+ const ADVISORY_ABSENT_REASONS: readonly AuditRefusal[] = [
538
+ 'no-record',
539
+ 'no-lockfile',
540
+ 'no-manifest',
541
+ ]
542
+
435
543
  /**
436
544
  * Whether a refusal is a folder this machine never created rather than a break.
437
545
  *
@@ -443,15 +551,25 @@ const ABSENT_REASONS: readonly (RecordRefusal | BoardRefusal)[] = [
443
551
  * A tracked corpus gets no such allowance. That tree ships to targets, so a
444
552
  * checkout that cannot find it is broken, and reading the absence as ordinary
445
553
  * would report a pass over a corpus nobody measured.
554
+ *
555
+ * An upstream corpus takes the allowance for a different reason. Its index is
556
+ * off this machine, so an offline run reaches nothing through no fault of the
557
+ * tree, and pinning the verdict at incomplete every time the network is down
558
+ * is the same signal-nobody-reads failure the per-machine case already names.
446
559
  */
560
+ function absentReasonsFor(spec: AuditSpec): readonly string[] {
561
+ if (spec.absentReasons !== undefined) return spec.absentReasons
562
+ if (spec.corpus === 'per-machine') return ABSENT_REASONS
563
+ if (spec.corpus === 'upstream') return ADVISORY_ABSENT_REASONS
564
+ return []
565
+ }
566
+
447
567
  function isExpectedAbsence(spec: AuditSpec, record: unknown): boolean {
448
- if (spec.corpus !== 'per-machine') return false
568
+ const allowed = absentReasonsFor(spec)
569
+ if (allowed.length === 0) return false
449
570
 
450
571
  const reason = asObject(record)?.reason
451
- return (
452
- typeof reason === 'string' &&
453
- (ABSENT_REASONS as readonly string[]).includes(reason)
454
- )
572
+ return typeof reason === 'string' && allowed.includes(reason)
455
573
  }
456
574
 
457
575
  /**
@@ -471,6 +589,7 @@ export function classify(
471
589
  id: spec.id,
472
590
  label: spec.label,
473
591
  tracked: isTracked(spec),
592
+ corpus: spec.corpus,
474
593
  exitCode,
475
594
  }
476
595
 
package/src/audits/run.ts CHANGED
@@ -85,6 +85,7 @@ export async function runAudits(
85
85
  label: spec.label,
86
86
  status: 'unmeasured' as const,
87
87
  tracked: spec.corpus === 'tracked',
88
+ corpus: spec.corpus,
88
89
  exitCode: 1,
89
90
  reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
90
91
  }
package/src/cli.ts CHANGED
@@ -26,6 +26,8 @@ import { register as markdown } from '@/commands/markdown'
26
26
  import { register as records } from '@/commands/records'
27
27
  import { register as sessions } from '@/commands/sessions'
28
28
  import { register as audits } from '@/commands/audits'
29
+ import { register as secrets } from '@/commands/secrets'
30
+ import { register as deps } from '@/commands/deps'
29
31
  import { register as upgrade } from '@/commands/upgrade'
30
32
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
31
33
  import { palette } from '@/ui'
@@ -63,6 +65,8 @@ function showHelp(): void {
63
65
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
64
66
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
65
67
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
68
+ `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
69
+ `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
66
70
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
67
71
  `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
68
72
  `${GREY}│${NC}`,
@@ -104,6 +108,8 @@ function showHelp(): void {
104
108
  `${GREY}│${NC} aitk records size --json`,
105
109
  `${GREY}│${NC} aitk records push --json`,
106
110
  `${GREY}│${NC} aitk sessions list --json`,
111
+ `${GREY}│${NC} aitk secrets scan --json`,
112
+ `${GREY}│${NC} aitk deps audit --json`,
107
113
  `${GREY}│${NC} aitk audits run --json`,
108
114
  `${GREY}│${NC} aitk upgrade --json`,
109
115
  `${GREY}└${NC}`,
@@ -148,6 +154,8 @@ context(program)
148
154
  markdown(program)
149
155
  records(program)
150
156
  sessions(program)
157
+ secrets(program)
158
+ deps(program)
151
159
  audits(program)
152
160
  upgrade(program)
153
161
 
@@ -244,7 +244,11 @@ function countLine(counts: Record<string, number>): string {
244
244
  * against an absent baseline says the same as a corpus that did not move.
245
245
  */
246
246
  function deltaLine(delta: Delta): string | undefined {
247
- if (delta.kind === 'per-machine' || delta.kind === 'unmeasured') {
247
+ if (
248
+ delta.kind === 'per-machine' ||
249
+ delta.kind === 'upstream' ||
250
+ delta.kind === 'unmeasured'
251
+ ) {
248
252
  return undefined
249
253
  }
250
254
  if (delta.kind === 'unrecorded') return 'No recorded baseline to compare'
@@ -301,7 +305,11 @@ function report(
301
305
  }
302
306
 
303
307
  if (!result.tracked) {
304
- logInfo('Per-machine corpus, so no baseline is kept')
308
+ logInfo(
309
+ result.corpus === 'upstream'
310
+ ? 'Upstream index, so no baseline is kept and growth is not this tree'
311
+ : 'Per-machine corpus, so no baseline is kept',
312
+ )
305
313
  continue
306
314
  }
307
315
 
@@ -335,7 +343,7 @@ function report(
335
343
  // Stated on every run, including a clean one. A count of what passed reads as
336
344
  // a verdict on the whole set unless the run also says what it never reached.
337
345
  logInfo(
338
- `${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent on this machine`,
346
+ `${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent or unreachable from this machine`,
339
347
  )
340
348
 
341
349
  outro()
@@ -0,0 +1,173 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ type Advisory,
5
+ auditDependencies,
6
+ type AuditRefusal,
7
+ countBySeverity,
8
+ SEVERITIES,
9
+ } from '@/deps/audit'
10
+ import {
11
+ intro,
12
+ logInfo,
13
+ logStep,
14
+ logWarn,
15
+ outro,
16
+ pipeOutput,
17
+ plural,
18
+ } from '@/ui'
19
+
20
+ interface AuditCommandOptions {
21
+ readonly json?: boolean
22
+ }
23
+
24
+ /** What a reader does about each way the advisory list fails to arrive. */
25
+ const REFUSALS: Record<AuditRefusal, string> = {
26
+ 'no-manifest': 'No package.json here, so there is no dependency set to read.',
27
+ 'no-lockfile':
28
+ 'No lockfile beside the manifest, so no dependency set is resolved yet. Install first.',
29
+ 'no-record':
30
+ 'The advisory lookup returned no record. Check the network, then re-run.',
31
+ }
32
+
33
+ export function register(program: Command): void {
34
+ const deps = program
35
+ .command('deps')
36
+ .description('Read the installed dependency set for published advisories')
37
+ .helpOption('-h, --help', 'Show this help message')
38
+
39
+ deps
40
+ .command('audit')
41
+ .description('Report advisories against the dependencies already resolved')
42
+ .argument('[path]', 'Project to audit, defaulting to the current directory')
43
+ .helpOption('-h, --help', 'Show this help message')
44
+ .option('--json', 'Add a machine-readable record on stdout')
45
+ .addHelpText(
46
+ 'after',
47
+ [
48
+ '',
49
+ 'Scope:',
50
+ " The resolved dependency set, read through the runtime's own",
51
+ ' advisory command. This reaches a network, so a lookup that fails',
52
+ ' refuses rather than reporting a clean tree.',
53
+ '',
54
+ 'Exit codes:',
55
+ ' 0 no advisory against the resolved set',
56
+ ' 1 refused, with the reason on stderr',
57
+ ' 2 at least one advisory was published',
58
+ '',
59
+ 'Examples:',
60
+ ' aitk deps audit',
61
+ ' aitk deps audit --json',
62
+ '',
63
+ ].join('\n'),
64
+ )
65
+ .action(async (path: string | undefined, opts: AuditCommandOptions) => {
66
+ process.exitCode = await runAudit(path, opts)
67
+ })
68
+ }
69
+
70
+ function severityLine(counts: Record<string, number>): string {
71
+ return SEVERITIES.filter((severity) => counts[severity] !== 0)
72
+ .map((severity) => `${counts[severity]} ${severity}`)
73
+ .join(', ')
74
+ }
75
+
76
+ /**
77
+ * Groups by package, since one dependency commonly carries several advisories
78
+ * and a flat list reads as more distinct upgrades than the tree actually owes.
79
+ */
80
+ function byPackage(advisories: readonly Advisory[]): Map<string, Advisory[]> {
81
+ const grouped = new Map<string, Advisory[]>()
82
+
83
+ for (const advisory of advisories) {
84
+ const held = grouped.get(advisory.package) ?? []
85
+ held.push(advisory)
86
+ grouped.set(advisory.package, held)
87
+ }
88
+
89
+ return grouped
90
+ }
91
+
92
+ async function runAudit(
93
+ path: string | undefined,
94
+ opts: AuditCommandOptions,
95
+ ): Promise<number> {
96
+ const root = resolve(path ?? process.cwd())
97
+ const emitJson = opts.json ?? false
98
+
99
+ intro('aitk deps audit')
100
+
101
+ const audit = await auditDependencies(root)
102
+
103
+ if (audit.kind === 'refused') {
104
+ logStep('Refused')
105
+ logWarn(REFUSALS[audit.reason])
106
+ if (audit.message !== undefined) logWarn(audit.message)
107
+ outro()
108
+
109
+ if (emitJson) {
110
+ process.stdout.write(
111
+ `${JSON.stringify({
112
+ root,
113
+ reason: audit.reason,
114
+ message: audit.message ?? REFUSALS[audit.reason],
115
+ })}\n`,
116
+ )
117
+ }
118
+ return 1
119
+ }
120
+
121
+ const counts = countBySeverity(audit.advisories)
122
+
123
+ logStep('Advisories')
124
+ if (audit.advisories.length === 0) {
125
+ logInfo('No advisory against the resolved dependency set.')
126
+ } else {
127
+ const grouped = byPackage(audit.advisories)
128
+ const total = audit.advisories.length
129
+
130
+ logWarn(
131
+ `${total} ${total === 1 ? 'advisory' : 'advisories'} across ${plural(grouped.size, 'package')}: ${severityLine(counts)}`,
132
+ )
133
+
134
+ // Piped rather than logged line by line, since every one of these is a
135
+ // finding and the timeline's own tick would mark each as something that
136
+ // passed. The frame stays, and the list inside it stays unmarked.
137
+ pipeOutput(
138
+ [...grouped]
139
+ .map(([name, held]) =>
140
+ [
141
+ `${name}: ${severityLine(countBySeverity(held))}`,
142
+ ...held.flatMap((advisory) => [
143
+ ` ${advisory.severity.padEnd(8)} ${advisory.title}`,
144
+ ` ${' '.repeat(8)} ${advisory.url}`,
145
+ ]),
146
+ ].join('\n'),
147
+ )
148
+ .join('\n'),
149
+ )
150
+ }
151
+
152
+ // Stated whichever way the count went. An advisory arrives when someone
153
+ // publishes one, so this number moves with no edit here, and a report that
154
+ // does not date itself reads as a fact about the tree rather than about a day.
155
+ logStep('Reading')
156
+ logInfo(
157
+ 'Measured against the advisory index at run time, not at commit time.',
158
+ )
159
+
160
+ outro()
161
+
162
+ if (emitJson) {
163
+ process.stdout.write(
164
+ `${JSON.stringify({
165
+ root,
166
+ severities: counts,
167
+ advisories: audit.advisories,
168
+ })}\n`,
169
+ )
170
+ }
171
+
172
+ return audit.advisories.length === 0 ? 0 : 2
173
+ }
@@ -0,0 +1,132 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { type ScanRefusal, scanShippedTree } from '@/secrets/scan'
4
+ import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
5
+
6
+ interface ScanCommandOptions {
7
+ readonly json?: boolean
8
+ }
9
+
10
+ /** What a reader does about each way the corpus fails to build. */
11
+ const REFUSALS: Record<ScanRefusal, string> = {
12
+ 'no-manifest':
13
+ 'No package.json here, so nothing is published from this tree.',
14
+ 'no-publish':
15
+ 'The manifest declares private, so this project publishes nothing.',
16
+ // Stated as an unread corpus rather than an absent one. A publish with no
17
+ // files field packs the whole tree, so this is the package that ships the
18
+ // most, and calling it nothing to read is the denial the reasoning in
19
+ // src/secrets/shipped.ts warns against.
20
+ 'no-files-field':
21
+ 'package.json declares no files field, so a publish would pack the whole tree. This check reads a declared corpus and left that one unread.',
22
+ 'no-git': 'git could not list this tree, so the corpus is unknown.',
23
+ 'no-shipped-files':
24
+ 'The files field matched nothing git lists, so nothing would be scanned.',
25
+ }
26
+
27
+ export function register(program: Command): void {
28
+ const secrets = program
29
+ .command('secrets')
30
+ .description('Read committed state for credentials that ship to a target')
31
+ .helpOption('-h, --help', 'Show this help message')
32
+
33
+ secrets
34
+ .command('scan')
35
+ .description('Report credential-shaped values in the tree this repo ships')
36
+ .argument(
37
+ '[path]',
38
+ 'Repository to scan, defaulting to the current directory',
39
+ )
40
+ .helpOption('-h, --help', 'Show this help message')
41
+ .option('--json', 'Add a machine-readable record on stdout')
42
+ .addHelpText(
43
+ 'after',
44
+ [
45
+ '',
46
+ 'Scope:',
47
+ " The package's own files field, so the corpus is what npm packs",
48
+ ' and what the plugin ships. Nothing outside it is read.',
49
+ '',
50
+ 'Exit codes:',
51
+ ' 0 the shipped tree carries no credential-shaped value',
52
+ ' 1 refused, with the reason on stderr',
53
+ ' 2 at least one value was found',
54
+ '',
55
+ 'Examples:',
56
+ ' aitk secrets scan',
57
+ ' aitk secrets scan --json',
58
+ '',
59
+ ].join('\n'),
60
+ )
61
+ .action(async (path: string | undefined, opts: ScanCommandOptions) => {
62
+ process.exitCode = await runScan(path, opts)
63
+ })
64
+ }
65
+
66
+ async function runScan(
67
+ path: string | undefined,
68
+ opts: ScanCommandOptions,
69
+ ): Promise<number> {
70
+ const root = resolve(path ?? process.cwd())
71
+ const emitJson = opts.json ?? false
72
+
73
+ intro('aitk secrets scan')
74
+
75
+ const scan = await scanShippedTree(root)
76
+
77
+ if (scan.kind === 'refused') {
78
+ logStep('Refused')
79
+ logWarn(REFUSALS[scan.reason])
80
+ outro()
81
+
82
+ if (emitJson) {
83
+ process.stdout.write(
84
+ `${JSON.stringify({
85
+ root,
86
+ reason: scan.reason,
87
+ message: REFUSALS[scan.reason],
88
+ })}\n`,
89
+ )
90
+ }
91
+ return 1
92
+ }
93
+
94
+ logStep('Corpus')
95
+ logInfo(
96
+ `${plural(scan.files, 'file')} read, ${scan.skipped} skipped as binary or unreadable`,
97
+ )
98
+ // Stated on every run, including a clean one. The corpus answers what the
99
+ // package publishes, and a reader who sees only the passing count reads the
100
+ // verdict as covering the repository.
101
+ logInfo(
102
+ `${scan.listed - scan.files - scan.skipped} of ${scan.listed} listed files sit outside the published corpus and were not read`,
103
+ )
104
+
105
+ logStep('Findings')
106
+ if (scan.findings.length === 0) {
107
+ logInfo('No credential-shaped value in the shipped tree.')
108
+ } else {
109
+ logError(plural(scan.findings.length, 'value'))
110
+ for (const finding of scan.findings) {
111
+ logWarn(
112
+ `${finding.file}:${finding.line}:${finding.column} ${finding.label} ${finding.preview}`,
113
+ )
114
+ }
115
+ }
116
+
117
+ outro()
118
+
119
+ if (emitJson) {
120
+ process.stdout.write(
121
+ `${JSON.stringify({
122
+ root,
123
+ files: scan.files,
124
+ skipped: scan.skipped,
125
+ listed: scan.listed,
126
+ findings: scan.findings,
127
+ })}\n`,
128
+ )
129
+ }
130
+
131
+ return scan.findings.length === 0 ? 0 : 2
132
+ }