@erclx/canon 4.113.0 → 4.115.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.
Files changed (63) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/codebase-layout/SKILL.md +1 -1
  3. package/claude/skills/document-health/references/axes.md +1 -1
  4. package/claude/skills/target-setup/SKILL.md +3 -1
  5. package/claude/skills/target-setup/references/detect.md +43 -9
  6. package/claude/skills/target-setup/references/verify.md +26 -10
  7. package/claude/skills/teach-workspace/SKILL.md +5 -3
  8. package/claude/skills/test-craft/SKILL.md +1 -1
  9. package/docs/agents/audits.md +4 -2
  10. package/docs/agents/commands.md +2 -2
  11. package/docs/agents/context-audit-checks.md +6 -6
  12. package/docs/agents/context-audit.md +1 -1
  13. package/docs/agents/context-classify.md +1 -1
  14. package/docs/agents/counts.md +3 -3
  15. package/docs/agents/install-and-sync.md +2 -0
  16. package/docs/agents/records.md +22 -15
  17. package/docs/agents/teach.md +12 -4
  18. package/docs/target-projects.md +11 -2
  19. package/docs/workflow/ai-workflow.md +1 -1
  20. package/governance/rules/lang/130-go.md +40 -0
  21. package/governance/rules/lang/140-php.md +31 -0
  22. package/governance/rules/lib/335-testing-go.md +35 -0
  23. package/governance/rules/lib/336-testing-php.md +30 -0
  24. package/governance/rules/lib/360-security-server.md +2 -0
  25. package/governance/rules/lib/370-database.md +2 -0
  26. package/governance/rules/ui/410-a11y.md +1 -0
  27. package/governance/rules/ui/440-surface-capture.md +2 -0
  28. package/governance/stacks/nextjs.toml +2 -0
  29. package/governance/stacks/react.toml +1 -1
  30. package/package.json +1 -1
  31. package/src/audits/catalog.ts +52 -0
  32. package/src/commands/context.ts +30 -10
  33. package/src/commands/records.ts +18 -1
  34. package/src/commands/teach.ts +4 -1
  35. package/src/commands/tooling.ts +25 -2
  36. package/src/context/audit.ts +54 -18
  37. package/src/design/components.ts +17 -0
  38. package/src/design/tokens.ts +1 -1
  39. package/src/gate/measures.ts +13 -2
  40. package/src/records/backup.ts +197 -27
  41. package/src/records/push-guard.ts +71 -0
  42. package/src/teach/nav.ts +278 -34
  43. package/src/tooling/inject.ts +43 -4
  44. package/src/tooling/scan.ts +35 -0
  45. package/src/tooling/subfolder.ts +110 -0
  46. package/standards/teach.md +13 -12
  47. package/tooling/base/configs/.husky/post-merge +5 -1
  48. package/tooling/go/configs/.golangci.yml +25 -0
  49. package/tooling/go/configs/scripts/verify.sh +95 -0
  50. package/tooling/go/manifest.toml +13 -0
  51. package/tooling/go/reference.md +60 -0
  52. package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
  53. package/tooling/go/seeds/smoke_test.go +9 -0
  54. package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
  55. package/tooling/php/configs/phpstan.neon +5 -0
  56. package/tooling/php/configs/phpunit.xml +18 -0
  57. package/tooling/php/configs/scripts/verify.sh +100 -0
  58. package/tooling/php/manifest.toml +15 -0
  59. package/tooling/php/reference.md +74 -0
  60. package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
  61. package/tooling/php/seeds/tests/SmokeTest.php +13 -0
  62. package/tooling/python/configs/scripts/verify.sh +32 -15
  63. package/tooling/web/configs/scripts/verify.sh +32 -15
@@ -594,6 +594,23 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
594
594
  .sb-ws > summary:hover, .sb-ws[open] > summary { background: var(--color-chrome); color: var(--color-text); }
595
595
  .sb-ws .car { color: var(--color-muted); display: inline-flex; align-items: center; }
596
596
 
597
+ /* The top band is as tall as the masthead and a mission title can run to a
598
+ sentence, so the name stops at two lines rather than overflowing the rule.
599
+ Nav carries the whole title in the element's \`title\` attribute. The cap
600
+ trim the crumb labels share is dropped here, since it ends the box at the
601
+ last baseline and the clip would cut every descender on the second line. */
602
+ .sb-ws { min-width: 0; }
603
+ .sb-ws .ws-name {
604
+ display: -webkit-box;
605
+ -webkit-box-orient: vertical;
606
+ -webkit-line-clamp: 2;
607
+ line-clamp: 2;
608
+ overflow: hidden;
609
+ min-width: 0;
610
+ line-height: 1.2;
611
+ text-box: normal;
612
+ }
613
+
597
614
  .sb-wl {
598
615
  position: absolute;
599
616
  top: calc(100% + 0.35rem);
@@ -373,7 +373,7 @@ export const TOKENS: DesignTokens = {
373
373
  ],
374
374
 
375
375
  layout:
376
- 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor and the rest are the breakpoint buckets. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
376
+ 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor. Every range the width queries in the generated base and teach stylesheets lay out holds at least one of these widths, so a capture photographs every layout state those stylesheets have, and a design test fails when a query moves or is added so that it opens a range none of them reaches. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
377
377
 
378
378
  motion:
379
379
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
@@ -103,11 +103,22 @@ const output = (text: string): Emission => ({ kind: 'output', text })
103
103
  * `snippets` as a folder-whole entry of its own and the rule reaches every base
104
104
  * consumer through that instead.
105
105
  *
106
- * Both are recorded here rather than in a config file: the list is what a
106
+ * `130-go`, `140-php`, `335-testing-go`, and `336-testing-php` are opt-in by
107
+ * `--add` until a Go or PHP stack ships, which `target-setup` reaches by
108
+ * matching a detected language against each rule's description.
109
+ *
110
+ * All are recorded here rather than in a config file: the list is what a
107
111
  * reader compares a new arrival against, and a config file would absorb the
108
112
  * arrival silently.
109
113
  */
110
- export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
114
+ export const GOV_EXPECTED_UNREFERENCED = [
115
+ '130-go',
116
+ '140-php',
117
+ '260-shadcn',
118
+ '320-tanstack-query',
119
+ '335-testing-go',
120
+ '336-testing-php',
121
+ ]
111
122
 
112
123
  /**
113
124
  * Scenarios declaring no expectation, taken from `canon sandbox coverage`
@@ -1,8 +1,9 @@
1
- import { existsSync, readdirSync } from 'node:fs'
1
+ import { existsSync, lstatSync, readdirSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
- import { RECORD_ROOTS, recordRoot } from '@/record-root'
5
+ import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
6
+ import { type BlockedPath, guardPayload } from '@/records/push-guard'
6
7
 
7
8
  /**
8
9
  * The legacy `.claude`-root allowlist, read only when a project has not moved
@@ -81,6 +82,11 @@ const RECORDS_GIT_NAME = '.records.git'
81
82
  * every entry already keeps a pull's `README.md` out, so this is the one name
82
83
  * that still has to be said: without it a listing would stage the history
83
84
  * into itself.
85
+ *
86
+ * The list bounds the records index as well as the disk. `scopedFolders`
87
+ * filters the names the index tracks through `isExcluded`, so a name an older
88
+ * binary committed is removed from the history once rather than re-staged on
89
+ * every push by the `-f` that carries the payload.
84
90
  */
85
91
  export const EXCLUDED_ENTRIES = [
86
92
  'tmp',
@@ -88,6 +94,20 @@ export const EXCLUDED_ENTRIES = [
88
94
  RECORDS_GIT_NAME,
89
95
  ] as const
90
96
 
97
+ /**
98
+ * Whether a top-level name stays out of every push, at either root.
99
+ *
100
+ * `SCRATCH` is the legacy `.tmp` spelling, which a records index opened before
101
+ * the move can still carry and which `EXCLUDED_ENTRIES` names at its `.canon`
102
+ * spelling only.
103
+ */
104
+ function isExcluded(name: string): boolean {
105
+ return (
106
+ name === SCRATCH ||
107
+ EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number])
108
+ )
109
+ }
110
+
91
111
  /**
92
112
  * The record folders sitting at `dir` right now, at either root spelling.
93
113
  *
@@ -112,10 +132,7 @@ function foldersAt(dir: string): string[] {
112
132
  return entries
113
133
  .filter((entry) => entry.isDirectory())
114
134
  .map((entry) => entry.name)
115
- .filter(
116
- (name) =>
117
- !EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number]),
118
- )
135
+ .filter((name) => !isExcluded(name))
119
136
  }
120
137
 
121
138
  /**
@@ -197,6 +214,7 @@ export const BACKUP_REFUSALS = [
197
214
  'no-remote-records',
198
215
  'local-changes',
199
216
  'local-ahead',
217
+ 'unsafe-payload',
200
218
  'git-failed',
201
219
  ] as const
202
220
 
@@ -206,6 +224,8 @@ export interface BackupRefused {
206
224
  readonly ok: false
207
225
  readonly reason: BackupRefusal
208
226
  readonly message: string
227
+ /** Every pending path the payload guard refused, carried on `unsafe-payload` only. */
228
+ readonly blocked?: readonly BlockedPath[]
209
229
  }
210
230
 
211
231
  export interface PushReport {
@@ -214,6 +234,10 @@ export interface PushReport {
214
234
  readonly folders: readonly string[]
215
235
  /** A folder in scope this push found on disk but the records index had never tracked. */
216
236
  readonly firstSeen: readonly string[]
237
+ /** Every file this push stages that the records history has never carried. */
238
+ readonly added: readonly string[]
239
+ /** An excluded name the records index still tracked, removed from the history going forward. */
240
+ readonly dropped: readonly string[]
217
241
  readonly changed: number
218
242
  readonly commit?: string
219
243
  readonly pushed: boolean
@@ -450,10 +474,14 @@ function refuseSplitRoots(root: string): BackupRefused | undefined {
450
474
  )
451
475
  }
452
476
 
453
- /** A pathspec-safe subset, plus which of it never entered the records index before. */
477
+ /**
478
+ * A pathspec-safe subset, which of it never entered the records index before,
479
+ * and which excluded names the index still tracks.
480
+ */
454
481
  interface FolderScope {
455
482
  readonly scope: readonly string[]
456
483
  readonly firstSeen: readonly string[]
484
+ readonly dropped: readonly string[]
457
485
  }
458
486
 
459
487
  /**
@@ -476,12 +504,19 @@ interface FolderScope {
476
504
  * tracked, which is the read a caller reports rather than acts on: a folder
477
505
  * `.canon` picked up that should have been excluded is visible in the push
478
506
  * report instead of entering the payload silently.
507
+ *
508
+ * The index half passes through `isExcluded` before the union, since the index
509
+ * is the one side an excluded name can still reach the pathspec through. A
510
+ * name it drops is returned as `dropped` rather than silently forgotten, so the
511
+ * push can remove it from the history once and say so.
479
512
  */
480
513
  async function scopedFolders(root: string): Promise<FolderScope> {
481
514
  const tracked = await records(root, ['ls-files'])
482
- const indexed = new Set(
515
+ const everIndexed = new Set(
483
516
  tracked.ok ? tracked.text.split('\n').filter(Boolean).map(topSegment) : [],
484
517
  )
518
+ const indexed = new Set([...everIndexed].filter((name) => !isExcluded(name)))
519
+ const dropped = [...everIndexed].filter(isExcluded).sort()
485
520
 
486
521
  const present = new Set(foldersAt(workTree(root)))
487
522
  const retired = RETIRED_FOLDERS.filter(
@@ -492,7 +527,7 @@ async function scopedFolders(root: string): Promise<FolderScope> {
492
527
  (folder) => present.has(folder) && !indexed.has(folder),
493
528
  )
494
529
 
495
- return { scope, firstSeen }
530
+ return { scope, firstSeen, dropped }
496
531
  }
497
532
 
498
533
  function topSegment(path: string): string {
@@ -531,12 +566,105 @@ function countLines(text: string): number {
531
566
  return text.split('\n').filter(Boolean).length
532
567
  }
533
568
 
569
+ function splitNul(text: string): string[] {
570
+ return text.split('\0').filter(Boolean)
571
+ }
572
+
573
+ /**
574
+ * The files `add -A -f` is about to stage from `scope`, split into those the
575
+ * history has never carried and every pending one.
576
+ *
577
+ * `ls-files -o` without `--exclude-standard` lists ignored files too, which is
578
+ * what `-f` stages. `-m` also lists a deleted file and `-o` lists an embedded
579
+ * repository as a `dir/` entry, so both are filtered down to regular files,
580
+ * which is all a payload check has bytes to read.
581
+ */
582
+ async function pendingFiles(
583
+ root: string,
584
+ scope: readonly string[],
585
+ ): Promise<{ added: string[]; pending: string[] } | BackupRefused> {
586
+ if (scope.length === 0) return { added: [], pending: [] }
587
+
588
+ const [others, modified] = await Promise.all([
589
+ records(root, ['ls-files', '-z', '-o', '--', ...scope]),
590
+ records(root, ['ls-files', '-z', '-m', '--', ...scope]),
591
+ ])
592
+ if (!others.ok) return failed('ls-files', others)
593
+ if (!modified.ok) return failed('ls-files', modified)
594
+
595
+ const tree = workTree(root)
596
+ const isRegularFile = (path: string): boolean => {
597
+ try {
598
+ return lstatSync(join(tree, path)).isFile()
599
+ } catch {
600
+ return false
601
+ }
602
+ }
603
+
604
+ const added = splitNul(others.text).filter(isRegularFile).sort()
605
+ const pending = [
606
+ ...new Set([...added, ...splitNul(modified.text).filter(isRegularFile)]),
607
+ ].sort()
608
+
609
+ return { added, pending }
610
+ }
611
+
612
+ function refuseBlocked(blocked: readonly BlockedPath[]): BackupRefused {
613
+ return {
614
+ ...refuse(
615
+ 'unsafe-payload',
616
+ [
617
+ `${blocked.length} record(s) cannot be backed up, so nothing was staged or pushed:`,
618
+ ...blocked.map(
619
+ (finding) =>
620
+ ` ${finding.path} (${finding.cause}: ${finding.detail})`,
621
+ ),
622
+ 'Move each out of the record folders, such as into .canon/tmp/, or remove the credential, then push again.',
623
+ ].join('\n'),
624
+ ),
625
+ blocked,
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Puts HEAD and the index back where they stood before this run committed.
631
+ *
632
+ * A mixed reset to the recorded HEAD rather than `reset --soft HEAD~1`. A soft
633
+ * reset leaves a dropped name's deletion staged while the next run's index read
634
+ * no longer lists the name, so that run's diff never sees the deletion and it
635
+ * is never committed. The recorded HEAD also covers the root commit, where
636
+ * `HEAD~1` does not exist and only removing the ref restores an unborn branch.
637
+ *
638
+ * Returns an error sentence when the undo itself failed, so the caller appends
639
+ * it rather than reporting a clean history that is not.
640
+ */
641
+ async function undoCommit(
642
+ root: string,
643
+ priorHead: string,
644
+ ): Promise<string | undefined> {
645
+ if (priorHead.length > 0) {
646
+ const reset = await records(root, ['reset', '--quiet', priorHead])
647
+ return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
648
+ }
649
+
650
+ const unref = await records(root, ['update-ref', '-d', 'HEAD'])
651
+ if (!unref.ok) return `Undoing it failed: ${unref.stderr}.`
652
+ const reset = await records(root, ['reset', '--quiet'])
653
+ return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
654
+ }
655
+
534
656
  /**
535
657
  * Stages the backed folders, commits when any of them changed, and pushes.
536
658
  *
537
- * The push runs whether or not this call committed, because a previous run can
538
- * have committed and then failed to reach the network. Skipping it would leave
539
- * that commit on one disk, which is the state the whole verb exists to end.
659
+ * The payload guard reads every pending file ahead of the stage, so a refusal
660
+ * leaves the index and the object store exactly as it found them. It reads what
661
+ * this run would add, never a commit already in the history.
662
+ *
663
+ * The push runs whether or not this call committed, because an older binary
664
+ * can have committed and then failed to reach the network. Skipping it would
665
+ * leave that commit on one disk, which is the state the whole verb exists to
666
+ * end. A commit this call made is undone when the push fails, so a remote that
667
+ * rejects the payload is not handed the same commit again on every later run.
540
668
  */
541
669
  export async function pushRecords(root: string): Promise<PushOutcome> {
542
670
  const split = refuseSplitRoots(root)
@@ -546,27 +674,56 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
546
674
  const remote = await resolveRemote(root, enclosing)
547
675
  if (typeof remote !== 'string') return remote
548
676
 
549
- const { scope, firstSeen } = await scopedFolders(root)
677
+ const { scope, firstSeen, dropped } = await scopedFolders(root)
678
+
679
+ const files = await pendingFiles(root, scope)
680
+ if ('ok' in files) return files
681
+
682
+ const blocked = await guardPayload(workTree(root), files.pending)
683
+ if (blocked.length > 0) return refuseBlocked(blocked)
550
684
 
551
685
  if (scope.length > 0) {
552
686
  // `-f` is what carries the payload: every backed folder is ignored by the
553
687
  // enclosing repository, and the pathspecs are the whole list, so nothing
554
- // outside them can enter the index however the ignore rules read.
688
+ // outside them can enter the index however the ignore rules read. An
689
+ // excluded name never reaches the list, whatever the records index holds.
555
690
  const staged = await records(root, ['add', '-A', '-f', '--', ...scope])
556
691
  if (!staged.ok) return failed('add', staged)
557
692
  }
558
693
 
694
+ if (dropped.length > 0) {
695
+ const removed = await records(root, [
696
+ 'rm',
697
+ '-r',
698
+ '--cached',
699
+ '--quiet',
700
+ '--',
701
+ ...dropped,
702
+ ])
703
+ if (!removed.ok) return failed('rm', removed)
704
+ }
705
+
706
+ // Scope alone would miss the dropped deletions, which sit outside it.
559
707
  const diff = await records(root, [
560
708
  'diff',
561
709
  '--cached',
562
710
  '--name-only',
563
711
  '--',
564
712
  ...scope,
713
+ ...dropped,
565
714
  ])
566
715
  if (!diff.ok) return failed('diff', diff)
567
716
 
568
717
  const changed = countLines(diff.text)
569
718
 
719
+ const prior = await records(root, [
720
+ 'rev-parse',
721
+ '--verify',
722
+ '--quiet',
723
+ 'HEAD',
724
+ ])
725
+ const priorHead = prior.ok ? prior.text : ''
726
+
570
727
  if (changed > 0) {
571
728
  const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16)
572
729
  const commit = await records(root, [
@@ -579,11 +736,17 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
579
736
  if (!commit.ok) return failed('commit', commit)
580
737
  }
581
738
 
582
- const folders = presentFolders(root)
739
+ const report = {
740
+ ok: true,
741
+ root,
742
+ folders: presentFolders(root),
743
+ firstSeen,
744
+ added: files.added,
745
+ dropped,
746
+ changed,
747
+ } as const
583
748
  const head = await records(root, ['rev-parse', '--short', 'HEAD'])
584
- if (!head.ok) {
585
- return { ok: true, root, folders, firstSeen, changed, pushed: false }
586
- }
749
+ if (!head.ok) return { ...report, pushed: false }
587
750
 
588
751
  const branch = await projectBranch(root, enclosing)
589
752
  const pushed = await records(root, [
@@ -591,17 +754,24 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
591
754
  'origin',
592
755
  `HEAD:refs/heads/${branch}`,
593
756
  ])
594
- if (!pushed.ok) return failed('push', pushed)
757
+ if (!pushed.ok) {
758
+ const refused = failed('push', pushed)
759
+ if (changed === 0) return refused
595
760
 
596
- return {
597
- ok: true,
598
- root,
599
- folders,
600
- firstSeen,
601
- changed,
602
- commit: head.text,
603
- pushed: true,
761
+ const undoError = await undoCommit(root, priorHead)
762
+ return refuse(
763
+ 'git-failed',
764
+ [
765
+ refused.message,
766
+ 'The commit this run made was undone, and the records are still on disk.',
767
+ undoError,
768
+ ]
769
+ .filter(Boolean)
770
+ .join(' '),
771
+ )
604
772
  }
773
+
774
+ return { ...report, commit: head.text, pushed: true }
605
775
  }
606
776
 
607
777
  /**
@@ -0,0 +1,71 @@
1
+ import { lstat, readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { isBinary } from '@/binary'
4
+ import { scanText } from '@/secrets/scan'
5
+
6
+ /**
7
+ * The largest single record a push carries, in bytes.
8
+ *
9
+ * A constant rather than a flag. The largest record measured in this
10
+ * repository is a 6.7 MB git pack under a groundwork fixture and GitHub
11
+ * rejects a blob at 100 MB, so this sits well clear of both, and an override
12
+ * is one nobody remembers to pass the first time a backup tarball lands in a
13
+ * record folder.
14
+ */
15
+ export const MAX_RECORD_BYTES = 25 * 1024 * 1024
16
+
17
+ export interface BlockedPath {
18
+ readonly path: string
19
+ readonly cause: 'oversized' | 'credential'
20
+ /** A size or `<label> at line <n>`, never the matched value or a preview of it. */
21
+ readonly detail: string
22
+ }
23
+
24
+ function megabytes(bytes: number): string {
25
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
26
+ }
27
+
28
+ /**
29
+ * Every pending path a push must not carry, in the order given.
30
+ *
31
+ * Reads the size off `lstat` first so an oversized file is never opened, and
32
+ * skips anything that is not a regular file, since `add` stages a symlink as
33
+ * its target path rather than the bytes it points at. The credential half is
34
+ * `scanText` unchanged, so the patterns and the `canon-allow-secret` marker
35
+ * answer here exactly as they do in `canon secrets scan`.
36
+ */
37
+ export async function guardPayload(
38
+ tree: string,
39
+ paths: readonly string[],
40
+ ): Promise<BlockedPath[]> {
41
+ const blocked: BlockedPath[] = []
42
+
43
+ for (const path of paths) {
44
+ const full = join(tree, path)
45
+ const stats = await lstat(full).catch(() => undefined)
46
+ if (!stats?.isFile()) continue
47
+
48
+ if (stats.size > MAX_RECORD_BYTES) {
49
+ blocked.push({
50
+ path,
51
+ cause: 'oversized',
52
+ detail: `${megabytes(stats.size)}, over the ${megabytes(MAX_RECORD_BYTES)} ceiling`,
53
+ })
54
+ continue
55
+ }
56
+
57
+ const text = await readFile(full, 'utf8').catch(() => undefined)
58
+ if (text === undefined || isBinary(text)) continue
59
+
60
+ const [first] = scanText(path, text)
61
+ if (first) {
62
+ blocked.push({
63
+ path,
64
+ cause: 'credential',
65
+ detail: `${first.label} at line ${first.line}`,
66
+ })
67
+ }
68
+ }
69
+
70
+ return blocked
71
+ }