forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaudit.ts
More file actions
1869 lines (1776 loc) · 72.4 KB
/
Copy pathaudit.ts
File metadata and controls
1869 lines (1776 loc) · 72.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Showcase Audit CLI
*
* Walks showcase/integrations/* and emits a human-readable coverage report
* comparing declared demos vs. e2e spec files vs. QA markdown, plus
* deployment status and examples/integrations provenance.
*
* Usage:
* npx tsx showcase/scripts/audit.ts
* npx tsx showcase/scripts/audit.ts --json # machine-readable output
* npx tsx showcase/scripts/audit.ts --slug <slug> # single package
* npx tsx showcase/scripts/audit.ts --json --slug <slug> # single package, JSON
*
* Output sections (printed in this order):
* 1. Per-package summary table. Columns render as:
* slug | demos | specs | qa | deployed | examples src
* The last column is addressable via the filter key `examples-src`
* (hyphenated) but its rendered header label is `examples src`
* (space) to keep the table visually consistent.
* 2. Coverage anomalies (count mismatches, undeployed, missing examples source)
* 3. Overall health (pass/fail counts + suggestions)
*
* Exit codes:
* 0 — no anomalies found (warnings, if any, are informational by default)
* 1 — one or more anomalies (deployed=false, count mismatches,
* empty packages dir, etc.)
* 2 — invalid content / user input (bad args, unknown slug)
* 3 — unreadable (packages dir missing, not-a-directory, or fs failure)
* 4 — unexpected internal error (uncaught exception)
* 5 — --strict and warnings present (default run treats warnings
* as informational)
*
* YAML parsing is delegated to lib/manifest.ts.
*
* Testability:
* All I/O is parameterised by an `AuditConfig` object so tests can point
* at fixture trees. When running as a CLI, the config is derived from
* env var `SHOWCASE_AUDIT_ROOT` (for tests) or, by default, the
* ancestor `showcase/` directory of this script.
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import {
parseManifest,
type Manifest,
type ParsedManifest,
} from "./lib/manifest.js";
import { BORN_IN_SHOWCASE, SLUG_TO_EXAMPLES } from "./lib/slug-map.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Slug map + born-in-showcase set live in ./lib/slug-map.ts.
// Manifest types + parseManifest live in ./lib/manifest.ts.
// Both are re-exported at the bottom of this file so callers can import
// them from audit.ts.
/**
* Thrown when the packages dir cannot be read (EACCES, ENOTDIR, etc.).
* Distinct from generic Error so main()'s top-level catch can map it to
* EXIT_UNREADABLE (3) rather than EXIT_INTERNAL (4).
*
* Uses the ES2022 `Error({ cause })` pattern so callers can still reach
* the original ErrnoException (with `.code`, `.errno`, `.syscall` etc.)
* via `err.cause`. Forwarding just `cause.message` would drop those
* fields.
*/
class UnreadableDirError extends Error {
constructor(
public readonly dir: string,
cause: unknown,
) {
const baseMsg = cause instanceof Error ? cause.message : String(cause);
const code =
cause instanceof Error
? (cause as NodeJS.ErrnoException).code
: undefined;
// Prepend errno code when present and not already embedded in the
// underlying message (Node's fs errors typically already include it,
// but custom Errors thrown by stubs/tests may not).
const msg =
code && !baseMsg.includes(code) ? `${code}: ${baseMsg}` : baseMsg;
super(`could not read ${dir}: ${msg}`, { cause });
this.name = "UnreadableDirError";
}
}
/**
* Dependency-injected paths. In CLI mode these are derived from the
* script's location (or SHOWCASE_AUDIT_ROOT env var for tests). In unit
* tests, callers pass explicit paths pointing at a fixture tree.
*/
interface AuditConfig {
packagesDir: string;
examplesIntegrationsDir: string;
repoRoot: string;
}
// Exit-code constants — see the module header JSDoc for the full
// contract. We keep them in one place so the internals stay in sync with
// the CLI HELP_TEXT and the module docstring. Declared here (above the
// type definitions) so AuditReport.exitCode can derive its literal union
// from `typeof EXIT_*` rather than hard-coding the numbers, preventing
// drift between the runtime constants and the type.
const EXIT_OK = 0 as const;
const EXIT_ANOMALIES = 1 as const;
const EXIT_INVALID_CONTENT = 2 as const;
const EXIT_UNREADABLE = 3 as const;
const EXIT_INTERNAL = 4 as const;
const EXIT_WARNINGS = 5 as const;
/**
* Tagged union describing a package-level anomaly. `buildReport`
* switches on `kind` to classify packages into anomaly buckets.
*
* `not-deployed.state` uses a string union (`"unset" | "explicit-false"`)
* rather than raw `null | false` runtime values — the string encoding
* is self-documenting at consumption sites (`state === "unset"` vs the
* easy-to-misread `state === null`) and decouples the anomaly shape from
* the underlying manifest field encoding. Callers read the boolean
* directly through `p.manifest.manifest.deployed` when they need the
* raw value.
*/
type Anomaly =
| { kind: "missing-manifest" }
| { kind: "malformed-manifest"; subkind: "syntax" | "shape"; error: string }
| { kind: "unreadable-manifest"; error: string }
| { kind: "unreadable-dir"; dir: string; error: string }
| {
kind: "count-mismatch";
dimension: "spec" | "qa";
expected: number;
actual: number;
}
| { kind: "not-deployed"; state: "unset" | "explicit-false" }
| { kind: "missing-examples" }
| {
kind: "unreadable-examples";
slug: string;
candidates: readonly string[];
}
| {
// Mapped slug whose candidate path(s) exist on disk but are NOT
// directories (regular file / symlink-to-file / socket / FIFO).
// This is a misconfiguration — the integrations dir has a stray
// entry masquerading as the provenance target. Surfaced as its
// own Anomaly variant so downstream consumers can route it
// distinctly from `missing-examples` (content absent) and
// `unreadable-examples` (I/O failure).
kind: "mapped-candidate-not-directory";
slug: string;
candidates: readonly string[];
};
/**
* Per-dimension count state. Distinguishes "count=0 because empty" from
* "count=0 because unreadable" so table rendering and parity checks
* don't collapse the two into phantom mismatches.
*
* This is the sole discriminated union for count outcomes: countFiles
* returns it directly. Anything storing a count state uses this shape.
*/
type CountState =
| { state: "ok"; count: number }
| { state: "missing" } // no count field; countValue() returns 0, countLabel() returns "0"
| { state: "unreadable"; error: string };
interface PackageAudit {
slug: string;
/**
* Full tagged-union ParsedManifest variant. Keeping the whole
* variant (not just `.kind`) preserves the correlation between the
* manifest outcome and the derived fields (`demosDeclared`): downstream
* consumers that need to, e.g., echo the underlying malformed error or
* assert on the parsed manifest can reach through `audit.manifest.error`
* or `audit.manifest.manifest` without needing a second lookup table.
*
* Note: the `deployed` boolean is NOT duplicated on PackageAudit —
* consumers read it via `p.manifest.kind === "ok" ? p.manifest.manifest.deployed : undefined`.
* Two sources of truth invite drift.
*/
manifest: ParsedManifest;
demosDeclared: number;
spec: CountState;
qa: CountState;
examplesSource: string | null; // relative path from repo root, or null
anomalies: readonly Anomaly[];
/**
* Runtime diagnostics that don't rise to the level of an anomaly but
* callers (JSON consumers, CI dashboards) may want to surface. Each
* entry is a human-readable string written to stderr as well.
*/
warnings: readonly string[];
}
/**
* Literal union of the exit codes `main()` can assign. Derived from the
* EXIT_* constants so adding a new exit code (or retiring one) only
* requires changes in one place.
*/
type AuditExitCode =
| typeof EXIT_OK
| typeof EXIT_ANOMALIES
| typeof EXIT_INVALID_CONTENT
| typeof EXIT_UNREADABLE
| typeof EXIT_INTERNAL
| typeof EXIT_WARNINGS;
interface AuditReport {
/**
* Top-level scalars for programmatic consumers. `hasAnomalies` mirrors
* `totals.withAnomalies > 0`; `hasWarnings` mirrors
* `packages.some(p => p.warnings.length > 0)` so consumers can
* ratchet on stale-mapping / statSync-race diagnostics without
* re-walking every package. `exitCode` is the exit code `main()` will
* actually use (see EXIT_ANOMALIES / EXIT_WARNINGS).
*
* These are explicitly derived values — exposed as getters on the live
* report object so they can't fall out of sync with the underlying
* packages / anomalies arrays. JSON serialization walks own-enumerable
* properties by default, so buildReport materializes these to a plain
* object shape via a per-field Object.defineProperty call that's both
* enumerable and computed-on-read; see buildReport for the wiring.
*/
readonly hasAnomalies: boolean;
readonly hasWarnings: boolean;
readonly exitCode: AuditExitCode;
readonly packages: readonly PackageAudit[];
/**
* Per-bucket lists. Buckets deliberately overlap: a single package
* with both a count-mismatch and a not-deployed state appears in
* BOTH `countMismatches` AND `notDeployed`. `totals.withAnomalies` is
* the unique-package count (not the sum of bucket lengths).
*
* Entries are slug strings (not live PackageAudit references) to
* prevent downstream consumers from mutating the audit state by
* accident. Each field is `readonly string[]` so a consumer holding
* the report reference cannot mutate the audit state.
*/
readonly anomalies: {
readonly countMismatches: readonly string[];
readonly notDeployed: readonly string[];
readonly missingExamples: readonly string[];
readonly missingManifest: readonly string[];
readonly malformedManifest: readonly string[];
readonly unreadable: readonly string[];
};
readonly totals: {
readonly total: number;
readonly clean: number;
readonly withAnomalies: number;
};
}
// ---------------------------------------------------------------------------
// Config construction
// ---------------------------------------------------------------------------
/**
* Build an AuditConfig for real CLI execution. Honors `SHOWCASE_AUDIT_ROOT`
* to allow test subprocesses to point at a fixture tree. When unset,
* derives paths by walking up from this script's location:
* __dirname → showcase/scripts/
* showcaseRoot = __dirname/.. → showcase/
* repoRoot = showcaseRoot/.. → repo root
* Each step is a single `..` applied to the previous resolved path.
*
* Note: `path.resolve` normalizes path segments (resolving `..` and
* collapsing `.`) but does NOT canonicalize symlinks. If any segment of
* the input path is a symlink, the returned path still contains that
* symlink. Use `fs.realpathSync` to fully canonicalize. For our
* purposes this is fine — readdir/statSync transparently follow
* symlinks on access.
*/
function buildCliConfig(): AuditConfig {
const envRoot = process.env.SHOWCASE_AUDIT_ROOT;
if (envRoot && envRoot.length > 0) {
// Validate the env-var path up front. Without this, a bogus value
// (typo, stale fixture, file-typed path) flows through and surfaces
// as a confusing downstream error about the derived `<root>/packages`
// path — the operator has no hint that SHOWCASE_AUDIT_ROOT itself is
// the problem. We stat() here and throw UnreadableDirError, which
// main()'s top-level catch maps to EXIT_UNREADABLE (3).
try {
const st = fs.statSync(envRoot);
if (!st.isDirectory()) {
throw new UnreadableDirError(
envRoot,
new Error(`SHOWCASE_AUDIT_ROOT=${envRoot} is not a directory`),
);
}
} catch (e) {
if (e instanceof UnreadableDirError) {
throw e;
}
const code =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
const msg =
code === "ENOENT"
? `SHOWCASE_AUDIT_ROOT=${envRoot} does not exist`
: `SHOWCASE_AUDIT_ROOT=${envRoot} is unreadable`;
throw new UnreadableDirError(envRoot, new Error(msg, { cause: e }));
}
// Tests: SHOWCASE_AUDIT_ROOT=/tmp/fixture → /tmp/fixture/packages,
// /tmp/fixture/examples/integrations, repoRoot = /tmp/fixture.
return {
packagesDir: path.join(envRoot, "integrations"),
examplesIntegrationsDir: path.join(envRoot, "examples", "integrations"),
repoRoot: envRoot,
};
}
const showcaseRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(showcaseRoot, "..");
return {
packagesDir: path.join(showcaseRoot, "integrations"),
examplesIntegrationsDir: path.join(repoRoot, "examples", "integrations"),
repoRoot,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* List showcase package slugs. Throws UnreadableDirError on fs failures
* so main() can map them to exit code 3 rather than silently collapsing
* to "empty packages dir" (exit 1). Missing dir also throws — callers
* upstream check existence before invoking this.
*/
function listShowcasePackageSlugs(cfg: AuditConfig): string[] {
try {
return fs
.readdirSync(cfg.packagesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
} catch (e) {
throw new UnreadableDirError(cfg.packagesDir, e);
}
}
/**
* Distinguishes four outcomes for a package's manifest.yaml by
* returning ParsedManifest from lib/manifest.ts directly:
* - missing → file does not exist
* - malformed → file exists but YAML parse or shape validation failed
* (subkind: "syntax" | "shape")
* - unreadable → file exists but readFileSync threw (EACCES, I/O race)
* - ok → file parsed and validated successfully
*
* Downstream buildReport switches on ALL four variants rather than
* collapsing `unreadable` into `malformed` with a prefix, so the cause
* is preserved for structured consumers and CI bucket routing.
*
* Delegates to lib/manifest.ts :: parseManifest so audit.ts, validate-parity.ts,
* and capture-previews.ts all apply identical YAML-shape validation rules.
*/
function readManifest(slug: string, cfg: AuditConfig): ParsedManifest {
const p = path.join(cfg.packagesDir, slug, "manifest.yaml");
// Pass slug so parseManifest can enforce the slug-mismatch guard:
// a manifest whose declared `slug:` disagrees with the directory that
// holds it is flagged as malformed rather than silently keying a
// copy-paste/rename mistake into the wrong package downstream.
return parseManifest(p, slug);
}
/**
* Count files in a directory matching a predicate. Distinguishes three
* outcomes so callers can surface genuine errors:
* - ok → read succeeded; count is accurate
* - missing → directory doesn't exist (legitimate zero)
* - unreadable → readdir threw (permission, I/O); callers should emit
* an anomaly to avoid silent drops.
*
* Returns the public `CountState` shape directly so callers don't have
* to bridge through an intermediate representation.
*/
function countFiles(
dir: string,
extFilter: (name: string) => boolean,
): CountState {
// Use statSync + errno branching instead of `fs.existsSync`. existsSync
// returns false for every statSync failure (ENOENT, EACCES, EPERM,
// ENOTDIR, EIO, ELOOP, …), so an unreadable dir would silently
// classify as `missing` (legitimate zero) and trigger phantom
// count-mismatch anomalies downstream. Branching on err.code lets
// ENOENT keep the "missing" classification while non-ENOENT errno
// conditions propagate as "unreadable" — the caller (auditPackage)
// turns that into an `unreadable-dir` anomaly. Mirrors probeDir in
// validate-parity.ts.
try {
fs.statSync(dir);
} catch (e) {
const code =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
if (code === "ENOENT") return { state: "missing" };
const msg = e instanceof Error ? e.message : String(e);
return { state: "unreadable", error: msg };
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const count = entries.filter((d) => d.isFile() && extFilter(d.name)).length;
return { state: "ok", count };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Do NOT write to stderr here — the caller (auditPackage) pushes an
// `unreadable-dir` anomaly which is rendered by renderAnomalySection
// (single source of truth). Writing here would double-emit.
return { state: "unreadable", error: msg };
}
}
/**
* Numeric view of a CountState for programmatic consumers. Returns
* `null` for the "unreadable" state so callers cannot mistake an
* unknowable count for a real zero; "missing" maps to 0 because an
* absent directory is a legitimate zero. Display callers should prefer
* `countLabel` which emits "?" for unreadable.
*/
function countValue(s: CountState): number | null {
switch (s.state) {
case "ok":
return s.count;
case "missing":
return 0;
case "unreadable":
return null;
}
}
/** Rendered view of a CountState for the summary table. */
function countLabel(s: CountState): string {
switch (s.state) {
case "ok":
return String(s.count);
case "missing":
return "0";
case "unreadable":
return "?";
}
}
/**
* Structured return of {@link resolveExamplesSource}. The `source` field
* carries the resolved path (or null when nothing matched); the tagged
* booleans are classification signals consumed by {@link auditPackage}
* to route each "no resolved path" case to the correct anomaly variant:
*
* - `unreadableForSlug: true` — for a mapped slug, every candidate
* existed on disk but every stat call failed with a non-ENOENT error
* (EACCES/EIO/ELOOP/EPERM/...). Infrastructure failure; routes to
* `unreadable-examples`.
* - `nonDirectoryForSlug: true` — for a mapped slug, at least one
* candidate path exists but is not a directory. Misconfiguration;
* routes to `mapped-candidate-not-directory`.
* - Both false with `source: null` — benign stale mapping, ENOENT
* TOCTOU race, or unmapped miss. Routes to `missing-examples`.
*
* Invariant: classification is driven exclusively by these structured
* booleans. Callers must NEVER substring-match the human-readable
* warning text to decide between anomaly variants — the sink wording
* is free to change (typo fix, i18n, docstring edit) without altering
* routing.
*/
interface ExamplesSourceResult {
readonly source: string | null;
readonly unreadableForSlug: boolean;
/**
* True when at least one mapped candidate exists on disk but is not
* a directory (regular file / symlink-to-file / socket / FIFO). Drives
* the `mapped-candidate-not-directory` anomaly in auditPackage. Only
* set for mapped slugs; unmapped slugs are tracked on the warnings
* sink alone since they aren't routed through a dedicated anomaly.
*/
readonly nonDirectoryForSlug: boolean;
}
/**
* Resolve a showcase slug to its examples/integrations counterpart.
* Returns a structured {@link ExamplesSourceResult} — the `source` is
* null if no candidate exists (which is OK for born-in-showcase
* packages) and `unreadableForSlug` is the classification signal used
* by {@link auditPackage} to distinguish infrastructure failures from
* stale mappings.
*
* statSync is wrapped in try/catch — between existsSync and statSync
* there's a real (if rare) race window on network filesystems, and we
* don't want a TOCTOU race to crash the whole audit. Diagnostic strings
* for statSync failures and stale SLUG_TO_EXAMPLES entries are appended
* to the caller-supplied `warnings` sink. The caller is responsible for
* forwarding them to stderr and/or recording them on the PackageAudit —
* findExamplesSource does NOT touch global state (stdout/stderr).
*
* The `warnings` sink is optional — consumers (tests, ad-hoc scripts)
* that only care about the "found or not found" outcome can omit it,
* in which case warnings are discarded.
*/
function findExamplesSource(
slug: string,
cfg: AuditConfig,
warnings?: string[],
): ExamplesSourceResult {
return resolveExamplesSource(slug, SLUG_TO_EXAMPLES[slug], cfg, warnings);
}
/**
* Pure inner of findExamplesSource — the `mapped` argument is injected
* explicitly so tests can exercise multi-candidate fallback paths
* without relying on a specific SLUG_TO_EXAMPLES shape. Production
* callers should use findExamplesSource; tests that need deterministic
* multi-candidate behavior reach for this helper.
*
* Returns a structured {@link ExamplesSourceResult}: the `source` path
* on a hit, `null` + `unreadableForSlug: true` when all mapped
* candidates existed but every stat failed, and `null` +
* `unreadableForSlug: false` for a benign stale mapping or unmapped
* miss.
*/
function resolveExamplesSource(
slug: string,
mapped: readonly string[] | undefined,
cfg: AuditConfig,
warnings?: string[],
): ExamplesSourceResult {
const sink = warnings ?? [];
const candidates = mapped ?? [slug];
// Track outcomes per-candidate so we can distinguish "the mapped dirs
// don't exist" (stale mapping) from "they all exist but we couldn't
// read ANY of them" (permissions / I/O) — the latter is a CRITICAL
// warning because we literally cannot tell whether the provenance
// link is satisfied.
let unreadableCount = 0;
let existedCount = 0;
// Count of mapped candidates that exist on disk but are not
// directories (regular file / symlink-to-file / socket / FIFO). A
// mapped slug with nonDirCount > 0 and no successful directory hit
// routes to a distinct `mapped-candidate-not-directory` anomaly
// rather than silently degrading to `missing-examples`.
let nonDirCount = 0;
for (const candidate of candidates) {
const full = path.join(cfg.examplesIntegrationsDir, candidate);
// Do NOT gate on `fs.existsSync(full)`. existsSync returns false for
// every statSync failure — including EACCES/EPERM/EIO on a parent
// dir — not just ENOENT. With the old existsSync pre-check, an
// EACCES'd candidate was silently skipped (not counted in
// existedCount, not counted in unreadableCount), and when ALL
// mapped candidates were EACCES'd, the resolver returned
// `unreadableForSlug: false` and the package silently degraded to
// `missing-examples`. The fix: let statSync inside the try block
// be the sole source of truth. ENOENT → `continue` (absent);
// EACCES/other errno → increment unreadableCount AND push sink
// diagnostic so the infrastructure failure is visible.
let st: fs.Stats;
try {
st = fs.statSync(full);
} catch (e) {
const errCode =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
const msg = e instanceof Error ? e.message : String(e);
if (errCode === "ENOENT") {
// Absent candidate — stale mapping or never-existed. Do NOT
// bump existedCount or unreadableCount; classification will
// route to missing-examples (benign).
continue;
}
// Real I/O / permission failure (EACCES / EIO / ELOOP / EPERM /
// EMFILE / ENOTDIR / ...) — record on the warnings sink so it
// doesn't disappear silently, treat as "existed but unreadable"
// so `unreadableForSlug` fires when all mapped candidates land
// here.
existedCount++;
unreadableCount++;
sink.push(`audit: warning: statSync(${full}) failed: ${msg}`);
continue;
}
// stat succeeded — the path resolves to something (dir or not).
existedCount++;
if (st.isDirectory()) {
return {
source: path.relative(cfg.repoRoot, full),
unreadableForSlug: false,
nonDirectoryForSlug: false,
};
}
// Candidate exists but is not a directory (regular file /
// symlink-to-file / socket / FIFO). For BOTH mapped and unmapped
// slugs this is a misconfiguration — a stray entry in the
// integrations dir masquerading as the provenance target.
// Surface a per-candidate "exists but is not a directory"
// warning so operators see exactly which path is wrong, and for
// mapped slugs bump nonDirCount so auditPackage can route the
// `mapped-candidate-not-directory` anomaly instead of silently
// degrading to `missing-examples`.
sink.push(
`audit: warning: candidate path ${full} exists but is not a directory`,
);
if (mapped) nonDirCount++;
}
// Critical: mapped slug with multiple candidates that ALL exist but
// ALL failed with fs errors. We can't tell whether the provenance is
// satisfied — elevate to an ERROR warning so CI / JSON consumers can
// route this differently from a benign "no matching dir". The
// structured `unreadableForSlug: true` return is the classification
// signal consumed by auditPackage — no string-substring scanning.
if (mapped && existedCount > 0 && unreadableCount === existedCount) {
sink.push(
`audit: ERROR: all candidates unreadable for slug "${slug}" (category: unreadable-candidates) → [${mapped.join(", ")}]`,
);
return {
source: null,
unreadableForSlug: true,
nonDirectoryForSlug: false,
};
}
// Mapped slug whose candidate(s) existed-but-weren't-a-directory
// (and none of them was a successful dir hit). Route to the
// `mapped-candidate-not-directory` anomaly so this misconfiguration
// is visible downstream rather than silently degrading to
// `missing-examples`. The per-candidate "exists but is not a
// directory" warnings pushed above already carry the specific paths.
if (mapped && nonDirCount > 0) {
return {
source: null,
unreadableForSlug: false,
nonDirectoryForSlug: true,
};
}
// If the slug was *explicitly* mapped but none of its mapped
// candidates exist, the map is out of sync with the filesystem. Warn
// (via the sink) rather than error: missing examples counterparts are
// reported as audit anomalies downstream, not blocking failures.
// Fallback (unmapped slug → [slug]) is intentionally NOT warned —
// that's the normal "no mapping needed" path.
if (mapped) {
sink.push(
`audit: warning: SLUG_TO_EXAMPLES entry "${slug}" → [${mapped.join(", ")}] has no matching directory under ${cfg.examplesIntegrationsDir}`,
);
}
return {
source: null,
unreadableForSlug: false,
nonDirectoryForSlug: false,
};
}
function auditPackage(slug: string, cfg: AuditConfig): PackageAudit {
const manifestRes = readManifest(slug, cfg);
const pkgDir = path.join(cfg.packagesDir, slug);
const e2eDir = path.join(pkgDir, "tests", "e2e");
const qaDir = path.join(pkgDir, "qa");
const specRes = countFiles(e2eDir, (n) => n.endsWith(".spec.ts"));
const qaRes = countFiles(qaDir, (n) => n.endsWith(".md"));
// findExamplesSource records stale SLUG_TO_EXAMPLES / statSync-race
// warnings on this explicit sink. Callers (main, CI) forward it to
// stderr; JSON consumers read it off `audit.warnings`. The tuple
// result carries structured `unreadableForSlug` and
// `nonDirectoryForSlug` booleans consumed below for anomaly
// classification. Invariant: auditPackage NEVER reads the
// human-readable warning text to decide between anomaly variants —
// classification is driven exclusively by those structured signals.
const warnings: string[] = [];
const examplesResult = findExamplesSource(slug, cfg, warnings);
const examplesSource = examplesResult.source;
// Pull demosDeclared directly from the validated manifest
// (parseManifest guarantees demos is an array of objects and deployed,
// if present, is a real boolean — so the string "yes"/"no" footgun and
// the `.length === 4` footgun on a string demos are both ruled out).
// `deployed` is intentionally NOT duplicated on PackageAudit; consumers
// read it through `p.manifest.kind === "ok" ? p.manifest.manifest.deployed : undefined`
// so the manifest variant is the single source of truth.
const demosDeclared =
manifestRes.kind === "ok" ? manifestRes.manifest.demos.length : 0;
// Accumulate anomalies in a local array, then hand the frozen snapshot
// to the PackageAudit below. Deriving the final shape in one place
// keeps invariant checks (freeze, read-only array type, no downstream
// push) local and explicit — rather than mutating the record
// incrementally as the function walked.
const anomalies: Anomaly[] = [];
// Read-error anomalies propagate regardless of manifest state —
// unreadable dirs are infrastructure failures, not content failures.
if (specRes.state === "unreadable") {
anomalies.push({
kind: "unreadable-dir",
dir: e2eDir,
error: specRes.error,
});
}
if (qaRes.state === "unreadable") {
anomalies.push({
kind: "unreadable-dir",
dir: qaDir,
error: qaRes.error,
});
}
switch (manifestRes.kind) {
case "missing":
anomalies.push({ kind: "missing-manifest" });
break;
case "malformed":
anomalies.push({
kind: "malformed-manifest",
subkind: manifestRes.subkind,
error: manifestRes.error,
});
break;
case "unreadable":
anomalies.push({
kind: "unreadable-manifest",
error: manifestRes.error,
});
break;
case "ok": {
const manifest = manifestRes.manifest;
// Only report count-parity anomalies when we actually managed to
// read the directories — otherwise we'd double-report (unreadable
// + phantom mismatch). When the state is "ok" the count is a real
// number; "missing" implies count=0 which IS a legitimate data
// point for parity comparison.
//
// Informational-only demos (e.g. cli-start entries with a
// `command:` field) live in the registry but have no on-disk
// folder + no spec/qa file to audit. Exclude them from the
// count-mismatch denominator so audit.ts agrees with
// validate-parity.ts on which packages are "clean" — otherwise
// a package that's clean per parity would spuriously flag a
// count mismatch here. Mirrors the `!d.command` filter in
// validate-parity.ts :: auditPackage (~line 723, the
// `auditableDemos` derivation). `demosDeclared` on the
// PackageAudit still carries the RAW manifest count (summary
// table + JSON consumers depend on that); only the parity
// comparison uses the filtered count.
const auditableDemosDeclared = manifest.demos.filter(
(d) => !(d as { command?: string }).command,
).length;
const specCount = countValue(specRes);
if (specCount !== null && specCount !== auditableDemosDeclared) {
anomalies.push({
kind: "count-mismatch",
dimension: "spec",
expected: auditableDemosDeclared,
actual: specCount,
});
}
const qaCount = countValue(qaRes);
if (qaCount !== null && qaCount !== auditableDemosDeclared) {
anomalies.push({
kind: "count-mismatch",
dimension: "qa",
expected: auditableDemosDeclared,
actual: qaCount,
});
}
if (manifest.deployed !== true) {
anomalies.push({
kind: "not-deployed",
// String encoding is self-documenting at consumption sites —
// callers read the raw boolean off the manifest variant when
// they need it.
state: manifest.deployed === false ? "explicit-false" : "unset",
});
}
// Born-in-showcase packages have no Dojo counterpart by design;
// skip the "missing examples source" check for them.
if (examplesSource === null && !BORN_IN_SHOWCASE.has(slug)) {
// Three distinct failure modes for a mapped slug with no
// resolved directory, ordered by specificity:
//
// 1. `unreadable-examples` — all mapped candidates existed
// but every stat failed with a non-ENOENT error
// (EACCES/EIO/ELOOP/EPERM/...). Infrastructure failure;
// we cannot tell whether provenance is satisfied.
// 2. `mapped-candidate-not-directory` — at least one mapped
// candidate exists but is not a directory (stray file /
// symlink-to-file / socket / FIFO). Misconfiguration; the
// integrations dir has an entry masquerading as the
// provenance target.
// 3. `missing-examples` — the catch-all: stale mapping,
// benign TOCTOU race (ENOENT), or plain absence.
//
// Invariant: classification is driven exclusively by structured
// booleans on ExamplesSourceResult. Never substring-match
// warning text — sink wording is free to change without
// altering anomaly routing.
if (examplesResult.unreadableForSlug) {
anomalies.push({
kind: "unreadable-examples",
slug,
candidates: Object.freeze(
(SLUG_TO_EXAMPLES[slug] ?? [slug]).slice(),
) as readonly string[],
});
} else if (examplesResult.nonDirectoryForSlug) {
anomalies.push({
kind: "mapped-candidate-not-directory",
slug,
candidates: Object.freeze(
(SLUG_TO_EXAMPLES[slug] ?? [slug]).slice(),
) as readonly string[],
});
} else {
anomalies.push({ kind: "missing-examples" });
}
}
break;
}
}
// Freeze the mutable containers BEFORE handing them out — direct
// callers of auditPackage must not be able to push to a "readonly"
// array that isn't actually frozen at runtime (which would let
// downstream consumers silently corrupt audit state). `spec`, `qa`,
// and `manifest` are frozen here too so the readonly semantics
// advertised by PackageAudit hold for direct callers of auditPackage
// (which is exported from the bottom of this file). The nested
// demos array + its entries on the "ok" manifest variant are frozen
// by buildReport — auditPackage does not re-traverse them here so
// the freeze stays O(1) per package. buildReport's subsequent freeze
// loop is idempotent (Object.freeze on an already-frozen object is
// a no-op) and is kept as defense-in-depth for consumers that only
// go through buildReport.
Object.freeze(anomalies);
Object.freeze(warnings);
Object.freeze(specRes);
Object.freeze(qaRes);
Object.freeze(manifestRes);
return {
slug,
manifest: manifestRes,
demosDeclared,
spec: specRes,
qa: qaRes,
examplesSource,
anomalies,
warnings,
};
}
// ---------------------------------------------------------------------------
// Anomaly rendering (human-readable strings for the text report)
// ---------------------------------------------------------------------------
function anomalyMessage(a: Anomaly): string {
switch (a.kind) {
case "missing-manifest":
return "missing manifest.yaml";
case "malformed-manifest":
return `malformed manifest.yaml (${a.subkind}): ${a.error}`;
case "unreadable-manifest":
return `could not read manifest.yaml: ${a.error}`;
case "unreadable-dir":
return `could not read ${a.dir}: ${a.error}`;
case "count-mismatch":
return `${a.dimension} count (${a.actual}) != demos (${a.expected})`;
case "not-deployed":
// Render the string-union state as a familiar label so
// human-readable output doesn't change. `"explicit-false"` → "false"
// preserves the historical display; the anomaly itself carries the
// more explicit string for structured consumers.
return `deployed=${a.state === "explicit-false" ? "false" : "unset"}`;
case "missing-examples":
return "no examples/integrations counterpart";
case "unreadable-examples":
return `examples/integrations candidates unreadable for "${a.slug}" → [${a.candidates.join(", ")}]`;
case "mapped-candidate-not-directory":
return `examples/integrations candidate(s) for "${a.slug}" exist but are not directories → [${a.candidates.join(", ")}]`;
}
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
function padRight(s: string, w: number): string {
if (s.length >= w) return s;
return s + " ".repeat(w - s.length);
}
function padLeft(s: string, w: number): string {
if (s.length >= w) return s;
return " ".repeat(w - s.length) + s;
}
// Keyed schema for the package summary table. Defining the per-column
// key, label, value projection, and alignment once — instead of relying
// on positional-index coupling between the header array, the row array,
// and the fmtRow alignment callback — eliminates a class of "edit one
// list, forget the other two" bugs (e.g., adding a column that silently
// grows the divider but wraps values under the wrong header).
// Each column carries a stable `key` (machine-readable identifier used
// by `--columns=<csv>` to filter) alongside its display `label`.
// `as const` pins the tuple shape so `ColumnKey` below is a literal
// union of the declared keys — not `string`. parseArgs validates user
// input against that union at runtime, and ParsedArgs.columns carries
// the narrower type.
const TABLE_COLUMNS = [
{
key: "slug",
label: "slug",
align: "left",
value: (a: PackageAudit) => a.slug,
},
{
key: "demos",
label: "demos",
align: "right",
value: (a: PackageAudit) => String(a.demosDeclared),
},
{
key: "specs",
label: "specs",
align: "right",
value: (a: PackageAudit) => countLabel(a.spec),
},
{
key: "qa",
label: "qa",
align: "right",
value: (a: PackageAudit) => countLabel(a.qa),
},
{
key: "deployed",
label: "deployed",
align: "right",
value: (a: PackageAudit) => {
// Read deployed state through the manifest variant — single
// source of truth. No duplicate `deployed` field on PackageAudit.
if (a.manifest.kind !== "ok") return "?";
const d = a.manifest.manifest.deployed;
if (d === undefined) return "?";
return d ? "yes" : "no";
},
},
{
key: "examples-src",
label: "examples src",
align: "left",
value: (a: PackageAudit) => a.examplesSource ?? "—",
},
] as const satisfies ReadonlyArray<{
key: string;
label: string;
align: "left" | "right";
value: (a: PackageAudit) => string;
}>;
type ColumnKey = (typeof TABLE_COLUMNS)[number]["key"];
/**
* Resolve a user-supplied list of column keys to the subset of
* TABLE_COLUMNS to render, preserving declared column order. Returns
* `null` (untyped sentinel) if `keys` is undefined — i.e. "use all
* columns". parseArgs validates keys up-front so this helper can assume
* every entry is recognised.
*/
function selectColumns(
keys: readonly ColumnKey[] | null,
): ReadonlyArray<(typeof TABLE_COLUMNS)[number]> {
if (keys === null) return TABLE_COLUMNS;
const wanted = new Set<ColumnKey>(keys);
return TABLE_COLUMNS.filter((c) => wanted.has(c.key));
}
function renderTable(
audits: readonly PackageAudit[],
columns: ReadonlyArray<(typeof TABLE_COLUMNS)[number]> = TABLE_COLUMNS,
): string {
// Empty-list guard: no rows means nothing to align to but the header
// widths. Without this, `Math.max(h.length, ...[])` still works (the
// spread of an empty array disappears) but the table would consist of
// header + divider only, which the caller almost never actually wants.
// Short-circuit with a dedicated "(no packages)" note instead.
if (audits.length === 0) {
return " (no packages)";
}
const rows = audits.map((a) => columns.map((col) => col.value(a)));
const widths = columns.map((col, i) =>
Math.max(col.label.length, ...rows.map((r) => r[i].length)),
);
const fmtRow = (cells: readonly string[]) =>
" " +
cells
.map((c, i) =>
columns[i].align === "left"
? padRight(c, widths[i])
: padLeft(c, widths[i]),