@cosyte/script-utils 0.0.2 → 0.1.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.
@@ -0,0 +1,874 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/check-no-emdash.sh
3
+ # Brand rule (founder directive, 2026-07-24): cosyte never uses the em dash.
4
+ # The em dash (U+2014) reads as an AI tell, so it is banned outright across every
5
+ # cosyte surface. Source of truth: `knowledgebase/06-brand/voice-and-tone.md`
6
+ # ("No em dashes. Ever."), which names COMMIT MESSAGES explicitly.
7
+ #
8
+ # THIS FILE IS THE ONE IMPLEMENTATION OF THE EM-DASH GATE FOR THIS ESTATE, and config
9
+ # owns it. Every other repo EXECUTES THESE EXACT BYTES and tracks no implementation of
10
+ # its own: the copy at `packages/script-utils/check-no-emdash.sh` is untracked build
11
+ # output, generated from this file by `scripts/pack-emdash-gate.mjs` and published in
12
+ # the `@cosyte/script-utils` tarball, so a consumer runs
13
+ # `bash node_modules/@cosyte/script-utils/check-no-emdash.sh`. `--self-id` below is how
14
+ # a consumer proves it is running these bytes rather than a drifted copy.
15
+ #
16
+ # Ported into config on 2026-07-30 (EMDASH-CONFORMANCE part 2). Composed from the
17
+ # sibling copies rather than taken from any one of them:
18
+ #
19
+ # * BASE: the text-only shape hl7, x12, ncpdp, fhir, pathways and knowledgebase run.
20
+ # No binary partition at all. See BINARY POSTURE below for why that is the RIGHT
21
+ # shape here and not merely the simpler one.
22
+ # * PLUS cli's route fixes ([cli#41](https://github.com/cosyte/cli/pull/41)): every
23
+ # scanned path is `./`-prefixed so a tracked file named exactly `-` cannot be read
24
+ # as standard input; `-d skip` is NOT used, so a tracked symlink to a directory
25
+ # cannot pass silently; `-H` so every hit carries its filename; a tracked entry
26
+ # that is not a regular file is refused BY NAME rather than skipped.
27
+ # * PLUS dicom's binary-match diagnostic branch, so a red caused by a match inside
28
+ # input grep cannot read as text says so instead of blaming an I/O failure.
29
+ # * PLUS the fix for cross-repo residual (iv), which every other copy of this gate
30
+ # documents as a KNOWN HOLE and declines to close. astm's
31
+ # `scripts/check-no-internal-refs.sh` has the reference implementation and this is
32
+ # the em-dash gate's first copy of it: `unset -f grep xargs sed awk`, and the
33
+ # SCANNER VISIBILITY PROBE. See THE INTERPOSED-TOOL HOLE below. The probe is the
34
+ # important half.
35
+ #
36
+ # MEASURED BEFORE THE PORT, byte level, over ALL 126 tracked files (not a markdown
37
+ # sample: a markdown-only count is what wrongly cleared dicom, and this repo would
38
+ # have been cleared the same way):
39
+ # * 529 occurrences of U+2014 as the LITERAL character, across 73 of the 126 files.
40
+ # 42 of those 73 are not markdown (31 are).
41
+ # * ONE occurrence in an ENCODED form, and a literal-character sweep would have
42
+ # missed it: `scripts/parser-template/package.json` carried the JS escape in its
43
+ # npm `description`. That file is the scaffold `scripts/scaffold-parser.mjs`
44
+ # generates every new parser repo from, so the escape would have been copied into
45
+ # the published `description` of every future `@cosyte/*` parser. claude-containers
46
+ # found the same class of miss as an HTML entity; this repo found it as an escape.
47
+ # Sweep for the encodings, never only for the character.
48
+ # * ZERO in `%E2%80%94`, `—`, `—` or `—` form.
49
+ # * ZERO tracked files hold a NUL byte, ZERO are gitlinks (no mode 160000 entry),
50
+ # ZERO are symlinks (no mode 120000 entry), and `git ls-files --eol` reports ZERO
51
+ # as binary. Every tracked file decodes as UTF-8.
52
+ # * THREE occurrences were SEMANTIC VALUES rather than punctuation, plus one glyph
53
+ # used as a drawing character, and all three were converted by hand BEFORE any
54
+ # bulk transform ran. They are listed in the commit that added this gate.
55
+ #
56
+ # All 530 were rewritten in the SAME commit that added this gate, so it arrives green
57
+ # over a tree that was cleared rather than green over a tree nobody looked at. A gate
58
+ # without the sweep reds CI on arrival; a sweep without the gate lets it grow back.
59
+ #
60
+ # The fix is never to re-encode the character: rewrite the sentence with a period, a
61
+ # colon, a comma, or parentheses.
62
+ #
63
+ # ---------------------------------------------------------------------------
64
+ # THE SURFACE. Four modes, and the exit vocabulary is CLOSED AT TWO CODES.
65
+ #
66
+ # (no argument) scan every tracked file
67
+ # --stdin LABEL scan the text arriving on stdin (CI feeds it the PR title, body
68
+ # and commit messages)
69
+ # --list-scanned write to stdout the NUL-separated, repo-root-relative path of
70
+ # every tracked file the default scan would read, and nothing else
71
+ # --self-id write to stdout one line, the lowercase 64-character sha256 of
72
+ # the implementation file that is executing, and nothing else
73
+ #
74
+ # Exit 0 means the mode completed and found nothing banned. Exit 1 means anything else:
75
+ # a banned form was found, an input could not be read, an input was not scanned, a
76
+ # declaration was unusable, or an argument was not recognised. THERE IS NO THIRD CODE,
77
+ # and adding one is a change to this surface that is specced as one.
78
+ #
79
+ # THAT CLAIM IS ENFORCED RATHER THAN ASSERTED, because the tools this gate calls have
80
+ # codes of their own: `git` alone answers 128 for a repository it cannot read, and a
81
+ # status left to propagate is a third code arriving from a tool nobody specced. Every git
82
+ # call is tested and refused BY NAME, and the EXIT trap reduces any status that is neither
83
+ # 0 nor 1 to 1 and says which one it reduced.
84
+ #
85
+ # Hits and diagnostics go to STDERR in every mode. STDOUT carries the human OK line in
86
+ # the two SCANNING modes and the machine output in the two REPORTING modes, never both
87
+ # in one mode: `--list-scanned` is piped into a comparison and `--self-id` into a digest
88
+ # equality, and a progress line on either would corrupt the thing reading it.
89
+ #
90
+ # WHY THE TWO REPORTING MODES EXIST. "One implementation, many consumers" is otherwise
91
+ # an intention rather than a fact. `--self-id` makes "these are the same bytes"
92
+ # checkable by a command instead of by reading two files, and `--list-scanned` makes
93
+ # "the canonical reads everything the retired variant read" checkable by comparing two
94
+ # sets instead of by comparing two filter expressions. Both REPORT what this gate
95
+ # already does; neither grades anything that was not graded before.
96
+ #
97
+ # THE REPORTING MODES DO NOT RUN THE SCANNER SELF-TEST OR THE VISIBILITY PROBE, and
98
+ # that is deliberate rather than an omission. Both assertions are preconditions for
99
+ # BELIEVING A CLEAN SCAN, and neither reporting mode produces one: `--list-scanned`
100
+ # matches nothing and `--self-id` opens nothing but this file. A grep that cannot see
101
+ # does not make either answer wrong, and refusing them for it would red a consumer's
102
+ # coverage comparison for a condition that has no bearing on coverage. The two scanning
103
+ # modes, which DO produce a clean result, run both.
104
+ # ---------------------------------------------------------------------------
105
+ # THE EXCLUSION DECLARATION. A consuming repo declares what its scan leaves out in a
106
+ # tracked file at its top level, `scripts/check-no-emdash.exclude`. THIS REPO SHIPS NO
107
+ # SUCH FILE and is not expected to: config's own scan covers every tracked path.
108
+ #
109
+ # * One entry per line. Blank lines, and lines whose first non-space character is
110
+ # `#`, are ignored.
111
+ # * An entry ending in `/` is a PATH PREFIX and covers every tracked path beneath it.
112
+ # Any other entry is ONE EXACT TRACKED PATH.
113
+ # * Entries are repo-root-relative and ANCHORED. An entry that is absolute, begins
114
+ # with `./`, or contains `..` is unusable and the run exits 1.
115
+ # * An entry matching NO tracked path is unusable and the run exits 1, because an
116
+ # exclusion that has outlived its subject is a hole nobody is looking at.
117
+ # * A repo with no such file declares no exclusions.
118
+ # * The declaration file is itself tracked and is SCANNED LIKE ANY OTHER TRACKED
119
+ # FILE. It is not self-excluding.
120
+ #
121
+ # AN ENTRY IS THE LINE VERBATIM: nothing is trimmed off either end, because a path is
122
+ # bytes and trimming would invent a second spelling for one entry. A line carrying
123
+ # stray whitespace or a trailing carriage return therefore matches no tracked path and
124
+ # is refused as stale, which is loud; the refusal names that possibility so the reader
125
+ # is not left hunting a path that looks right.
126
+ #
127
+ # THE DECLARATION IS READ ONLY WHEN IT IS TRACKED, which is the Contract's own wording
128
+ # ("a tracked file at its top level") taken literally. An untracked file at that path
129
+ # is not a reviewed, committed declaration, so it narrows nothing; the run says on
130
+ # stderr that it is being ignored rather than honouring an exclusion nobody approved.
131
+ #
132
+ # EVERY MODE APPLIES IT, from one shared code path so no two can disagree. `--stdin` and
133
+ # `--self-id` subtract no path with it, so for them this is validation rather than
134
+ # filtering: an entry that names nothing tracked, or one that is not anchored, is a hole
135
+ # nobody is looking at, and which mode happens to be running is no reason to leave it
136
+ # standing. A directory that is not a git work tree tracks nothing, so it HAS no
137
+ # declaration: those two modes answer there as they always did, and the two that
138
+ # enumerate tracked paths refuse, having nothing to enumerate.
139
+ # ---------------------------------------------------------------------------
140
+ # THE SELF-EXCLUSION IS RESOLVED AT RUNTIME, NOT HARDCODED. The gate works out the
141
+ # implementation file it is executing and excludes that path from its own scan when,
142
+ # and ONLY when, that path is tracked in the repo under scan. In config that resolves
143
+ # to `scripts/check-no-emdash.sh`, which has to name the encodings it bans. In a
144
+ # consuming repo the implementation lives under `node_modules/`, which is tracked
145
+ # nowhere, so nothing is excluded and the consumer's own tree is scanned whole. A
146
+ # hardcoded name would have excluded a consuming repo's `scripts/check-no-emdash.sh`
147
+ # whether or not one existed, which is an exclusion granted to a file nobody reviewed.
148
+ #
149
+ # Nothing checks the checker, so keep this file free of the literal character: it
150
+ # matches by codepoint and by encoding and never spells one out.
151
+ # ---------------------------------------------------------------------------
152
+ # BINARY POSTURE. This repo scans EVERY tracked file with NO binary partition and
153
+ # NO `-I`, which is fail-CLOSED, and it is a measured choice rather than a default.
154
+ #
155
+ # cli and website partition NUL-bearing files out as binary because they must: cli
156
+ # vendors ten `pnpm pack` tarballs and one of them already contains the byte sequence
157
+ # E2 80 94 by DEFLATE coincidence, so a text-only port goes red there TODAY on a
158
+ # compressed stream nobody wrote. That partition costs them a real hole: seed one of
159
+ # those tarballs with a live em dash and their gate prints OK.
160
+ #
161
+ # config pays no such cost. It tracks 126 files and not one holds a NUL byte, so the
162
+ # partition would exempt nothing while opening the hole anyway. Scanning everything
163
+ # means a NUL-bearing file that arrives LATER cannot be skipped in silence: grep
164
+ # reports "binary file matches" on stderr, refuse_if_incomplete escalates it to a red,
165
+ # and a human looks. A loud false positive on a future binary beats a silent miss.
166
+ #
167
+ # Bare `-I` is NOT the alternative and must not be re-added. `-I` asks grep's
168
+ # heuristic "does this look like text", and that heuristic also skips a genuine TEXT
169
+ # file whose encoding is broken, so an em dash inside one would be missed in silence.
170
+ # That is the exact failure this whole script exists to refuse.
171
+ #
172
+ # If this repo ever tracks a real binary, revisit this posture deliberately (cli's
173
+ # header has the full argument and the arithmetic). Never revisit the ban.
174
+ # ---------------------------------------------------------------------------
175
+ # THE INTERPOSED-TOOL HOLE, cross-repo residual (iv), CLOSED HERE.
176
+ #
177
+ # Every other copy of this gate TRUSTS that `grep` resolves to a binary, records that
178
+ # trust as a known hole, and declines to close it on the argument that a divergent
179
+ # variant is worse than a shared documented limit. That argument has a shelf life, and
180
+ # astm's `check-no-internal-refs.sh` already ended it: the fix is small, self-testing,
181
+ # and strictly safer than the hole. This is the em-dash gate's first copy of it.
182
+ #
183
+ # The mechanism is real and was found live, not imagined for symmetry. The development
184
+ # container this repo is worked in defines `grep` as a shell FUNCTION forwarding to
185
+ # `ugrep` with `-G --ignore-files -I` FORCED. Under that shim:
186
+ #
187
+ # * `-I` SKIPS ANY FILE THE TOOL CALLS BINARY, SILENTLY, AT EXIT 0. Every argument
188
+ # this header makes against `-I` then applies to the script itself, and the gate
189
+ # prints OK over a file it never opened.
190
+ # * `-G` FORCES BASIC REGULAR EXPRESSIONS, under which `|` in PATTERN is a LITERAL.
191
+ # The pattern would match nothing, which is indistinguishable from a clean tree.
192
+ # * `--ignore-files` honours `.gitignore`. It is immaterial to THIS script, which
193
+ # only ever passes explicit operands produced by `git ls-files` (tracked, so never
194
+ # ignored), and it is stated so it is not re-litigated. It matters enormously to a
195
+ # HAND sweep, where it silently reports zero over a surface it never opened.
196
+ #
197
+ # A shell function is not exported to a child process, so `bash scripts/check-no-emdash.sh`
198
+ # gets the real binary and CI is unaffected. But `export -f grep` in a caller's
199
+ # environment WOULD reach here, so the functions are unset rather than assumed absent.
200
+ # `unset -f` on a name that is not a function is a no-op, so this costs nothing.
201
+ #
202
+ # The `-G` half needs no separate guard: the SELF-TEST below uses alternation, so a
203
+ # BRE-forced grep fails it and the script refuses. The `-I` half DOES need one, because
204
+ # it changes what is READ rather than what is MATCHED, and no pattern assertion can see
205
+ # that: a tool that skips a file produces the same empty output as a tool that read the
206
+ # file and found it clean, and both exit 0. That is the SCANNER VISIBILITY PROBE.
207
+ # ---------------------------------------------------------------------------
208
+ set -euo pipefail
209
+
210
+ unset -f grep xargs sed awk 2>/dev/null || true
211
+
212
+ # LOCALE PIN, load-bearing. `grep -P` compiles `\x{NNNN}` as a Unicode codepoint only
213
+ # in PCRE's UTF-8 mode, which GNU grep enables from the locale. Under LC_CTYPE=POSIX
214
+ # (a bare container, cron, `sh -c`, any shell that inherits no locale) GNU grep 3.8
215
+ # instead ABORTS with "character code point value in \x{} or \o{} is too large".
216
+ # That is not a hypothetical here: it fired during this repo's own measurement pass,
217
+ # where an unpinned `/usr/bin/grep -P '\x{2014}'` aborted on every file and the empty
218
+ # output read exactly like a clean tree. An earlier version of this gate in a sibling
219
+ # repo discarded that on stderr and `|| true`d the pipeline, so it printed OK having
220
+ # scanned nothing. Do not remove the pin, and do not restore the stderr redirect.
221
+ #
222
+ # The pin cannot be traded for a raw-byte pattern: `\xe2\x80\x94` matches the em dash
223
+ # under POSIX but NOT under a UTF-8 locale, where PCRE reads it as three characters.
224
+ # One pattern cannot cover both, so the locale is fixed and the pattern follows it.
225
+ #
226
+ # It also pins grep's DIAGNOSTIC MESSAGES to English, which the binary-match branch in
227
+ # refuse_if_incomplete reads. That branch only refines wording, never the red.
228
+ export LC_ALL=C.UTF-8
229
+
230
+ # Matches U+2014 as the literal character and as its encodings: %E2%80%94 (URL), the
231
+ # JS backslash-u escape, and the — / — / — HTML entities. The
232
+ # escape is not theoretical in this repo: it is what the parser-template manifest
233
+ # carried, and a literal-only sweep passed straight over it.
234
+ #
235
+ # THE MATCH VOCABULARY IS FROZEN at these six forms. A consumer may narrow WHAT IS
236
+ # SCANNED through the declaration above; nothing may narrow WHAT IS MATCHED, and
237
+ # widening it is a change every consuming repo inherits at once and is specced as one.
238
+ PATTERN='\x{2014}|%E2%80%94|\\u2014|—|—|—'
239
+
240
+ # The declaration's path, relative to the top level of the repo under scan. Deliberately
241
+ # NOT relative to this file: in a consuming repo this file lives under `node_modules/`
242
+ # and the declaration is the consumer's own tracked file.
243
+ DECLARATION='scripts/check-no-emdash.exclude'
244
+
245
+ # ---- resolve the implementation file that is executing --------------------------
246
+ #
247
+ # Resolved HERE, before any `cd`, because `${BASH_SOURCE[0]}` may be relative to the
248
+ # working directory the caller had. `readlink -f` is not used: it is GNU-only, and this
249
+ # gate runs on developer machines as well as on ubuntu runners. The loop below is the
250
+ # portable equivalent and follows a chain of symlinks, which is how a `node_modules`
251
+ # copy reached through a pnpm store link still resolves to the file that is executing.
252
+ resolve_self() {
253
+ local src="${BASH_SOURCE[0]}" dir
254
+ while [ -L "$src" ]; do
255
+ dir=$(cd -P "$(dirname "$src")" > /dev/null && pwd)
256
+ src=$(readlink "$src")
257
+ case $src in
258
+ /*) ;;
259
+ *) src="$dir/$src" ;;
260
+ esac
261
+ done
262
+ dir=$(cd -P "$(dirname "$src")" > /dev/null && pwd)
263
+ printf '%s/%s\n' "$dir" "$(basename "$src")"
264
+ }
265
+ SELF_PATH=$(resolve_self)
266
+
267
+ # ---- argument dispatch ----------------------------------------------------------
268
+ #
269
+ # Every mode is named here and anything else is REFUSED rather than guessed at. An
270
+ # unrecognised flag silently taken as "the default scan" is how a caller ends up
271
+ # believing it ran `--list-scanned` while reading an OK line, and the closed exit
272
+ # vocabulary means the mistake carries no distinguishing code of its own.
273
+ MODE=files
274
+ LABEL=stdin
275
+ SCAN_MODE=files
276
+
277
+ refuse_arguments() {
278
+ echo "ERROR: check-no-emdash - $1" >&2
279
+ echo " The modes are: (no argument) to scan every tracked file; --stdin LABEL" >&2
280
+ echo " to scan the text arriving on stdin; --list-scanned to write the paths the" >&2
281
+ echo " default scan reads; --self-id to write the sha256 of the implementation" >&2
282
+ echo " that is executing. Refusing to run rather than guess which was meant." >&2
283
+ exit 1
284
+ }
285
+
286
+ # ONE LEADING `--` IS CONSUMED, and it is what makes the estate's own invocation work. Every
287
+ # consuming repo keeps a `check:no-emdash` package script and reaches this gate through
288
+ # `pnpm run check:no-emdash -- <flag>`; pnpm 10 forwards that separator VERBATIM, so without this
289
+ # the gate is handed `-- --stdin LABEL`. Measured, not assumed, and the old behaviour was the bad
290
+ # kind of wrong: `$1` was neither `--stdin` nor anything else the script tested, so the run fell
291
+ # through to the DEFAULT FILE SCAN and printed OK while the PR text it was asked about went unread.
292
+ # `--` is the POSIX end-of-options marker and consuming exactly one of them is what every utility
293
+ # that accepts it does; a SECOND one is an argument, and falls through to the refusal below.
294
+ if [ "$#" -gt 0 ] && [ "$1" = '--' ]; then
295
+ shift
296
+ fi
297
+
298
+ case "${1:-}" in
299
+ '')
300
+ MODE=files
301
+ ;;
302
+ --stdin)
303
+ MODE=stdin
304
+ SCAN_MODE=stdin
305
+ LABEL="${2:-stdin}"
306
+ if [ "$#" -gt 2 ]; then
307
+ refuse_arguments "--stdin takes one LABEL, and this was given after it: ${3}"
308
+ fi
309
+ ;;
310
+ --list-scanned)
311
+ MODE=list
312
+ if [ "$#" -gt 1 ]; then
313
+ refuse_arguments "--list-scanned takes no further argument, and this was given: ${2}"
314
+ fi
315
+ ;;
316
+ --self-id)
317
+ MODE=self-id
318
+ if [ "$#" -gt 1 ]; then
319
+ refuse_arguments "--self-id takes no further argument, and this was given: ${2}"
320
+ fi
321
+ ;;
322
+ *)
323
+ refuse_arguments "unrecognised argument: ${1}"
324
+ ;;
325
+ esac
326
+
327
+ # Anything the scanner writes to stderr means either that it did not read everything it
328
+ # was given, or that it matched inside input it classifies as binary. Neither may print
329
+ # OK, and exit status cannot carry either signal: grep exits 1 on "no match", which
330
+ # xargs in turn reports as 123, so "clean" and "died part way through the batch" are
331
+ # indistinguishable by code, while a binary match exits 0 with empty stdout.
332
+ # They are created before any mode does its work, because every mode now asks git a
333
+ # question and a refusal quotes what git said.
334
+ ERRLOG=$(mktemp)
335
+ GITERR=$(mktemp)
336
+ FILELIST=$(mktemp)
337
+ SCANLIST=$(mktemp)
338
+ PLAINLIST=$(mktemp)
339
+ STDINBUF=$(mktemp)
340
+ BINPROBE=$(mktemp)
341
+ PROBEERR=$(mktemp)
342
+
343
+ # THE BACKSTOP ON THE EXIT VOCABULARY. Every refusal in this file chooses 1 deliberately,
344
+ # but a status this script never chose can still arrive from a tool it calls or from a
345
+ # signal. Each git call below is checked so the known case never reaches here; this is
346
+ # what makes "there is no third code" a property of the file rather than a list of the
347
+ # codes someone thought to look for, and it reports the status it reduced so the cause is
348
+ # not lost with it.
349
+ close_exit_vocabulary() {
350
+ local code=$?
351
+ rm -f "$ERRLOG" "$GITERR" "$FILELIST" "$SCANLIST" "$PLAINLIST" "$STDINBUF" "$BINPROBE" \
352
+ "$PROBEERR"
353
+ if [ "$code" -ne 0 ] && [ "$code" -ne 1 ]; then
354
+ echo "ERROR: check-no-emdash - the run ended with status ${code}. This gate reports 0" >&2
355
+ echo " for a mode that completed clean and 1 for everything else, so it is" >&2
356
+ echo " reporting 1. Whatever is above this line is what actually failed." >&2
357
+ exit 1
358
+ fi
359
+ }
360
+ trap close_exit_vocabulary EXIT
361
+
362
+ # ---- the repository under scan ---------------------------------------------------
363
+ #
364
+ # EVERY MODE COMES THROUGH HERE, because the declaration below is validated in every mode
365
+ # and an entry can only be judged against the tracked list. `--stdin` and `--self-id`
366
+ # subtract no path with it; what they owe is the refusal, not the filtering.
367
+ #
368
+ # NO WORK TREE IS AN ANSWER. A GIT THAT FAILED IS NOT. Where there is no work tree nothing
369
+ # is tracked, so there is no declaration to honour and no tracked file to read: the two
370
+ # modes that need neither carry on, and the two that enumerate tracked paths refuse,
371
+ # having nothing to enumerate. A git call that fails with a work tree in hand (an
372
+ # unreadable or corrupt index) is an input this gate could not read, and every mode
373
+ # refuses on that. git's status is never passed out: 128 is not on this gate's surface.
374
+ #
375
+ # `git ls-files` is relative to the working directory, so from a subdirectory it lists
376
+ # a subtree and the scan would report OK having skipped the rest of the repo. That
377
+ # matters more here than in a flat repo: config is a pnpm workspace and `pnpm -r` runs
378
+ # scripts from each package directory. Anchor at the top level, which also keeps the
379
+ # declaration's path and the self-exclusion's comparison below correct.
380
+ refuse_git() {
381
+ echo "ERROR: check-no-emdash - ${1} failed, so this gate could not read the repository" >&2
382
+ echo " it was asked about. What git reported follows." >&2
383
+ cat "$GITERR" >&2
384
+ echo " Refusing to report on a repository this gate could not read. Run it from a" >&2
385
+ echo " readable checkout of the repository it is meant to scan." >&2
386
+ exit 1
387
+ }
388
+
389
+ # The choices below each close a route by which the scan could report green without
390
+ # having actually read its input, because a gate that prints OK when it did not read
391
+ # its input is worse than no gate at all.
392
+ #
393
+ # This list is NOT a claim of exhaustiveness. The bare `-` operand route was found by a
394
+ # refuter against a copy whose own comment implied it was already closed. Treat this as
395
+ # the routes that are known and closed, not as proof that no other exists.
396
+ #
397
+ # the file list is built as its own command, not as the head of the pipeline, so a
398
+ # `git ls-files` that fails (an unreadable or corrupt index) stops the run, and is
399
+ # REFUSED BY NAME rather than left to carry git's own 128 out as this gate's exit
400
+ # code. Piped, its status is erased by the `|| true` the no-match case needs, and the
401
+ # scan would report OK over an empty list. An empty list is refused for the same
402
+ # reason.
403
+ #
404
+ # -z, and -0 on the xargs below: `git ls-files` C-quotes any path holding a space, a
405
+ # quote, or a non-ASCII byte, and unseparated, grep is handed a name no file has.
406
+ # -r on xargs drops the grep invocation entirely when the list is empty; without it
407
+ # grep falls back to reading stdin and prints OK.
408
+ #
409
+ # -e before the pattern and -- after it, so neither the pattern nor a tracked
410
+ # filename that starts with a dash is read as a grep option.
411
+ #
412
+ # EVERY PATH IS `./`-PREFIXED as the list is built, which is what actually closes the
413
+ # dash family. `--` alone does NOT: it stops `-` being parsed as an OPTION, but grep
414
+ # then reads the bare operand `-` as STANDARD INPUT, and xargs points its child's
415
+ # stdin at /dev/null. A tracked file literally named `-` (a `cmd > -` typo, which
416
+ # `git add -A` stages without complaint) is therefore never opened, and the gate
417
+ # prints OK over a live em dash. Prefixing in the LOOP rather than through `sed -z`
418
+ # also keeps the scan a single command, so the stderr capture binds to all of it, and
419
+ # drops a GNU-only dependency that has no self-test.
420
+ #
421
+ # THE `./` PREFIX IS FOR GREP AND NOT FOR THE REPORT. `--list-scanned` writes the
422
+ # repo-root-relative path, so the loop builds BOTH spellings in one pass rather than
423
+ # transforming one into the other afterwards: a second pass is a second place for the
424
+ # reported set and the scanned set to disagree, and the whole value of that mode is
425
+ # that they cannot.
426
+ #
427
+ # -H so every hit carries its filename. grep omits the name when handed exactly one
428
+ # file, which an xargs batch boundary can produce, and an unattributable hit in a red
429
+ # build is a worse report for no saving.
430
+ #
431
+ # NO -d skip. It is the one fail-OPEN flag in this pipeline's ancestry: with it, a
432
+ # tracked symlink to a directory is skipped silently (no stderr, so
433
+ # refuse_if_incomplete never fires and the gate goes green). It is not needed, because
434
+ # the loop below refuses a tracked entry that is not a regular file BY NAME, which is
435
+ # louder still. The one entry that loop skips is a gitlink, and it reads that off the
436
+ # INDEX MODE rather than off the type ON DISK: every test of the type on disk reopens
437
+ # the same hole from the other side, a symlink to a directory and a tracked blob
438
+ # shadowed by a directory both testing as directories.
439
+ #
440
+ # no -I, and no binary partition: see BINARY POSTURE in the header.
441
+ #
442
+ # stderr is captured and any of it fails the run (see refuse_if_incomplete above).
443
+ enumerate_tracked() {
444
+ local record tab
445
+ git ls-files -s -z > "$FILELIST" 2> "$GITERR" || refuse_git "git ls-files -s -z"
446
+
447
+ # `-s` carries the INDEX MODE, which is the only thing that can say an entry is a
448
+ # gitlink. Each record is `<mode> <sha> <stage>`, a TAB, then the path; the metadata
449
+ # holds no tab, so the first one separates the two however the path is spelled.
450
+ tab=$'\t'
451
+ while IFS= read -r -d '' record; do
452
+ TRACKED_MODE+=("${record%% *}")
453
+ TRACKED+=("${record#*"$tab"}")
454
+ done < "$FILELIST"
455
+
456
+ # Scoped to the two modes that read tracked files. `--stdin` scans text that is not a
457
+ # file and `--self-id` opens only this one, so neither would be reporting green from a
458
+ # scan that read nothing, and refusing them here would red a consumer's pull-request
459
+ # text step over a property of a tree that step never looked at.
460
+ if [ "${#TRACKED[@]}" -eq 0 ] && { [ "$MODE" = files ] || [ "$MODE" = list ]; }; then
461
+ echo "ERROR: check-no-emdash - no tracked files to scan. Refusing to report green" >&2
462
+ echo " from a scan that read nothing." >&2
463
+ exit 1
464
+ fi
465
+ }
466
+
467
+ # git answers 128 for a missing repository and for most other fatals alike, so its status
468
+ # cannot tell those apart. What it can say is whether a work tree is in hand, which is the
469
+ # question every mode below needs answered.
470
+ INSIDE_WORK_TREE=$(git rev-parse --is-inside-work-tree 2> "$GITERR" || true)
471
+
472
+ TRACKED=()
473
+ TRACKED_MODE=()
474
+ ROOT=''
475
+ if [ "$INSIDE_WORK_TREE" = true ]; then
476
+ ROOT=$(git rev-parse --show-toplevel 2> "$GITERR") || refuse_git "git rev-parse --show-toplevel"
477
+ cd "$ROOT"
478
+ ROOT=$(pwd -P)
479
+ enumerate_tracked
480
+ elif [ "$MODE" = files ] || [ "$MODE" = list ]; then
481
+ echo "ERROR: check-no-emdash - this is not a git work tree, so there is no tracked file" >&2
482
+ echo " list to read: $(pwd)" >&2
483
+ cat "$GITERR" >&2
484
+ echo " Refusing to report on a repository this gate could not read. Run it from a" >&2
485
+ echo " readable checkout of the repository it is meant to scan." >&2
486
+ exit 1
487
+ fi
488
+
489
+ is_tracked() {
490
+ local needle="$1" t
491
+ if [ "${#TRACKED[@]}" -eq 0 ]; then return 1; fi
492
+ for t in "${TRACKED[@]}"; do
493
+ if [ "$t" = "$needle" ]; then return 0; fi
494
+ done
495
+ return 1
496
+ }
497
+
498
+ # Does any tracked path answer to this entry? A prefix entry is answered by anything
499
+ # beneath it; any other entry only by itself. Both are LITERAL comparisons: `$entry` is
500
+ # quoted inside the pattern, so a glob character in a declared path is a character
501
+ # rather than a wildcard.
502
+ entry_matches_a_tracked_path() {
503
+ local entry="$1" t
504
+ if [ "${#TRACKED[@]}" -eq 0 ]; then return 1; fi
505
+ case $entry in
506
+ */)
507
+ for t in "${TRACKED[@]}"; do
508
+ case $t in
509
+ "$entry"*) return 0 ;;
510
+ esac
511
+ done
512
+ ;;
513
+ *)
514
+ for t in "${TRACKED[@]}"; do
515
+ if [ "$t" = "$entry" ]; then return 0; fi
516
+ done
517
+ ;;
518
+ esac
519
+ return 1
520
+ }
521
+
522
+ # ---- the exclusion declaration ---------------------------------------------------
523
+ EXCLUDES=()
524
+ refuse_declaration() {
525
+ local line="$1" entry="$2" what="$3"
526
+ echo "ERROR: check-no-emdash - unusable entry in ${DECLARATION}, line ${line}: ${entry}" >&2
527
+ echo " ${what}" >&2
528
+ echo " Refusing to scan under a declaration this gate cannot honour." >&2
529
+ exit 1
530
+ }
531
+
532
+ if is_tracked "$DECLARATION"; then
533
+ if [ ! -r "$DECLARATION" ]; then
534
+ echo "ERROR: check-no-emdash - the exclusion declaration is tracked but could not be" >&2
535
+ echo " read: ${DECLARATION}" >&2
536
+ echo " Refusing to scan without knowing what the declaration leaves out." >&2
537
+ exit 1
538
+ fi
539
+ lineno=0
540
+ line=''
541
+ # `|| [ -n "$line" ]` so a final entry with no trailing newline is still read.
542
+ while IFS= read -r line || [ -n "$line" ]; do
543
+ lineno=$((lineno + 1))
544
+ # Blank lines and comments are ignored, and the comment test is on the first
545
+ # NON-SPACE character, so an indented comment is still a comment. `stripped` exists
546
+ # only to answer those two questions; the ENTRY below is the line verbatim.
547
+ stripped=${line#"${line%%[![:space:]]*}"}
548
+ if [ -z "$stripped" ]; then continue; fi
549
+ case $stripped in
550
+ '#'*) continue ;;
551
+ esac
552
+
553
+ # The entry is the line VERBATIM. See the header: nothing is trimmed, because a path
554
+ # is bytes. The anchor rules are the whole of what makes an entry usable on its face.
555
+ entry=$line
556
+ case $entry in
557
+ /*)
558
+ refuse_declaration "$lineno" "$entry" \
559
+ "An entry is repo-root-relative, and this one is absolute. Drop the leading /."
560
+ ;;
561
+ ./*)
562
+ refuse_declaration "$lineno" "$entry" \
563
+ "An entry is anchored at the repository root, so it does not begin with ./."
564
+ ;;
565
+ *..*)
566
+ refuse_declaration "$lineno" "$entry" \
567
+ "An entry may not contain .., which would reach outside the repository."
568
+ ;;
569
+ esac
570
+
571
+ if ! entry_matches_a_tracked_path "$entry"; then
572
+ echo "ERROR: check-no-emdash - stale entry in ${DECLARATION}, line ${lineno}: ${entry}" >&2
573
+ if [ "$entry" != "$stripped" ] || [ "$entry" != "${entry%[[:space:]]}" ]; then
574
+ echo " The entry carries leading or trailing whitespace (a stray space, or a" >&2
575
+ echo " trailing carriage return from a CRLF file), and that whitespace is part" >&2
576
+ echo " of the path this gate looked for. Remove it." >&2
577
+ elif [ "${entry%/}" != "$entry" ]; then
578
+ echo " No tracked path lies beneath it. An exclusion that has outlived its" >&2
579
+ echo " subject is a hole nobody is looking at: delete the entry, or correct" >&2
580
+ echo " it to the directory it was meant to name." >&2
581
+ else
582
+ echo " No tracked path is spelled that way. An exclusion that has outlived its" >&2
583
+ echo " subject is a hole nobody is looking at: delete the entry, or correct it" >&2
584
+ echo " to the path it was meant to name. A directory entry ends in /." >&2
585
+ fi
586
+ exit 1
587
+ fi
588
+
589
+ EXCLUDES+=("$entry")
590
+ done < "$DECLARATION"
591
+ elif [ -e "$DECLARATION" ]; then
592
+ echo "NOTE: check-no-emdash - ${DECLARATION} exists but is not tracked, so it is being" >&2
593
+ echo " IGNORED and nothing is excluded by it. What this gate scans is decided only" >&2
594
+ echo " by files that have been reviewed and committed. Track it, or delete it." >&2
595
+ fi
596
+
597
+ is_excluded() {
598
+ local p="$1" e
599
+ if [ "${#EXCLUDES[@]}" -eq 0 ]; then return 1; fi
600
+ for e in "${EXCLUDES[@]}"; do
601
+ case $e in
602
+ */)
603
+ case $p in
604
+ "$e"*) return 0 ;;
605
+ esac
606
+ ;;
607
+ *)
608
+ if [ "$p" = "$e" ]; then return 0; fi
609
+ ;;
610
+ esac
611
+ done
612
+ return 1
613
+ }
614
+
615
+ # ---- --self-id: which bytes am I ------------------------------------------------
616
+ #
617
+ # Answered before the scanner assertions, because this mode reads no scanned content and
618
+ # reports nothing about a tree: a grep that cannot see leaves the answer no less true. It
619
+ # is what a consuming repo runs to prove it is executing the canonical rather than a
620
+ # drifted copy, and that question is worth answering precisely when something else about
621
+ # the environment is wrong, which is why it needs no work tree either.
622
+ #
623
+ # It comes AFTER the declaration for the one reason the criterion gives: an unusable
624
+ # declaration is refused in every mode, and a digest handed back over one would be an
625
+ # answer nobody asked for standing beside a hole nobody is looking at.
626
+ #
627
+ # The digest tool is whichever of the three is present, and the answer is CHECKED to be
628
+ # 64 lowercase hex characters before it is printed: a tool that emitted a different
629
+ # shape would otherwise hand a consumer a string that compares unequal for a reason
630
+ # nobody could see.
631
+ if [ "$MODE" = self-id ]; then
632
+ if [ ! -r "$SELF_PATH" ]; then
633
+ echo "ERROR: check-no-emdash - cannot read the implementation file to identify it:" >&2
634
+ echo " ${SELF_PATH}" >&2
635
+ echo " Refusing to report an identity for bytes that could not be read." >&2
636
+ exit 1
637
+ fi
638
+ DIGEST=''
639
+ if command -v sha256sum > /dev/null 2>&1; then
640
+ DIGEST=$(sha256sum -- "$SELF_PATH")
641
+ DIGEST=${DIGEST%% *}
642
+ elif command -v shasum > /dev/null 2>&1; then
643
+ DIGEST=$(shasum -a 256 -- "$SELF_PATH")
644
+ DIGEST=${DIGEST%% *}
645
+ elif command -v openssl > /dev/null 2>&1; then
646
+ DIGEST=$(openssl dgst -sha256 -r "$SELF_PATH")
647
+ DIGEST=${DIGEST%% *}
648
+ else
649
+ echo "ERROR: check-no-emdash - no sha256 tool is available (looked for sha256sum," >&2
650
+ echo " shasum and openssl), so this gate cannot say which bytes it is." >&2
651
+ echo " Install coreutils or openssl, then re-run --self-id." >&2
652
+ exit 1
653
+ fi
654
+ case $DIGEST in
655
+ *[!0-9a-f]* | '')
656
+ echo "ERROR: check-no-emdash - the sha256 tool did not produce a digest:" >&2
657
+ echo " ${DIGEST}" >&2
658
+ echo " Refusing to report an identity this gate cannot vouch for." >&2
659
+ exit 1
660
+ ;;
661
+ esac
662
+ if [ "${#DIGEST}" -ne 64 ]; then
663
+ echo "ERROR: check-no-emdash - the sha256 tool produced a ${#DIGEST}-character digest," >&2
664
+ echo " and a sha256 is 64. Refusing to report an identity this gate cannot" >&2
665
+ echo " vouch for." >&2
666
+ exit 1
667
+ fi
668
+ printf '%s\n' "$DIGEST"
669
+ exit 0
670
+ fi
671
+
672
+ if [ "$MODE" = files ] || [ "$MODE" = stdin ]; then
673
+ # SELF-TEST: prove the scanner can still MATCH what it is meant to catch before any
674
+ # clean result is believed. `printf` emits U+2014 as its UTF-8 bytes, so this file
675
+ # still never contains the literal character. Scope the claim honestly: this proves
676
+ # the PATTERN compiles and matches, not that the scan reached every tracked file. The
677
+ # probe below and the refusals further down cover the second half.
678
+ if ! printf 'a\xe2\x80\x94b\n' | grep -qP "$PATTERN"; then
679
+ echo "ERROR: check-no-emdash - the scanner cannot match a known em dash." >&2
680
+ echo " grep -P is unavailable, not in UTF-8 mode (LC_ALL=${LC_ALL}), or has" >&2
681
+ echo " been forced to basic regular expressions, under which the alternations" >&2
682
+ echo " in PATTERN are literals and match nothing." >&2
683
+ echo " Refusing to report a clean tree on a scanner that cannot see." >&2
684
+ exit 1
685
+ fi
686
+
687
+ # SCANNER VISIBILITY PROBE: prove the scanner can still READ a file before believing a
688
+ # word it says about one. The self-test above asserts what the tool MATCHES; this
689
+ # asserts what it OPENS, and nothing above can substitute for it. A tool that silently
690
+ # skips a file produces the same empty output as a tool that read it and found it
691
+ # clean, and exit status cannot tell them apart because both are 0.
692
+ #
693
+ # The probe file holds a NUL byte and a seeded violation, which is precisely the input
694
+ # an `-I`-forced tool drops without a diagnostic. GNU grep does the opposite: it
695
+ # reports "binary file matches" on stderr, which refuse_if_incomplete escalates to a
696
+ # red. So the property pinned here is not "grep is GNU" (a version string is easy to
697
+ # satisfy and proves nothing about behaviour) but "a violation inside input this tool
698
+ # may classify as binary reaches me SOMEHOW": as a hit on stdout, or as a diagnostic on
699
+ # stderr. Either is fine. SILENCE IS NOT.
700
+ #
701
+ # Do NOT "fix" a red here by deleting the probe. A green report from a scanner that
702
+ # skips files is the exact defect this gate exists to prevent.
703
+ printf 'clean line\n\000 seeded \xe2\x80\x94 violation\n' > "$BINPROBE"
704
+ PROBE_OUT=$(grep -H -nP -e "$PATTERN" -- "$BINPROBE" 2> "$PROBEERR" || true)
705
+ PROBE_DIAG=$(cat "$PROBEERR" 2> /dev/null || true)
706
+ if [ -z "$PROBE_OUT" ] && [ -z "$PROBE_DIAG" ]; then
707
+ echo "ERROR: check-no-emdash - the grep in use SILENTLY SKIPPED a probe file holding a" >&2
708
+ echo " NUL byte and a seeded em dash: no hit on stdout, no diagnostic on stderr," >&2
709
+ echo " exit 0. That is a scanner that cannot see its subject, and it is" >&2
710
+ echo " indistinguishable from a clean tree." >&2
711
+ echo " The known cause is a \`grep\` interposed with -I forced (the cosyte dev" >&2
712
+ echo " container ships one as a shell function; \`export -f grep\` reaches a child" >&2
713
+ echo " script). Run this gate with a real GNU grep." >&2
714
+ exit 1
715
+ fi
716
+ fi
717
+
718
+ fail_with_hits() {
719
+ local what="$1" hits="$2"
720
+ echo "$hits" >&2
721
+ echo "" >&2
722
+ echo "ERROR: check-no-emdash - em dash (U+2014, or an encoded form) found in ${what}." >&2
723
+ echo " cosyte never uses em dashes (founder directive; 06-brand/voice-and-tone.md)." >&2
724
+ echo " Rewrite with a period, colon, comma, or parentheses. Never re-encode it." >&2
725
+ exit 1
726
+ }
727
+
728
+ refuse_if_incomplete() {
729
+ [ -s "$ERRLOG" ] || return 0
730
+ cat "$ERRLOG" >&2
731
+ echo "" >&2
732
+ # GNU grep >= 3.5 prints "grep: FILE: binary file matches" on STDERR and nothing on
733
+ # stdout, so a match in input grep cannot read as text arrives here rather than in
734
+ # HITS. Name that case explicitly: without this branch the run reds saying the scan
735
+ # did not read all of its input, which sends a reader hunting an I/O failure that
736
+ # never happened. This branch only chooses the wording. Every path below exits 1, so
737
+ # if grep's wording ever changes the run still reds through the generic message.
738
+ if grep -qi 'binary file' "$ERRLOG"; then
739
+ echo "ERROR: check-no-emdash - the input named above MATCHED the banned pattern, but" >&2
740
+ echo " grep classifies it as binary, so the hit is reported without a line" >&2
741
+ echo " number. Treat it as a real em dash." >&2
742
+ if [ "$SCAN_MODE" = stdin ]; then
743
+ echo " The text piped in is not valid UTF-8. Fix the encoding of whatever" >&2
744
+ echo " produced it, then rewrite the sentence." >&2
745
+ else
746
+ echo " This repo tracks NO binaries and applies no binary partition, so this" >&2
747
+ echo " is either a TEXT file with a broken encoding (repair it; it should be" >&2
748
+ echo " UTF-8, then rewrite the sentence) or the first real binary this repo" >&2
749
+ echo " has ever tracked. If it is the latter, read the BINARY POSTURE note in" >&2
750
+ echo " this script's header before changing anything." >&2
751
+ fi
752
+ echo " cosyte never uses em dashes (founder directive; 06-brand/voice-and-tone.md)." >&2
753
+ fi
754
+ if grep -qiv 'binary file' "$ERRLOG"; then
755
+ echo "ERROR: check-no-emdash - the scan reported errors, so it did not read all of" >&2
756
+ echo " its input. Refusing to report green from an incomplete scan." >&2
757
+ fi
758
+ exit 1
759
+ }
760
+
761
+ # ---- stdin mode: text that is not a file (commit messages, PR title and body) ----
762
+ if [ "$MODE" = stdin ]; then
763
+ # Buffer stdin to a file first, for the same reason the file mode builds its list as
764
+ # its own command: empty input must be REFUSED, not reported green. A caller whose
765
+ # redirect silently produced nothing (a missing file, a `git log` that wrote to a
766
+ # different stream, a mis-typed heredoc) would otherwise get OK from a scan that read
767
+ # nothing, which is the exact blind-gate shape this script exists to refuse. The
768
+ # shipped workflow always emits at least the title line, so this only fires on a
769
+ # genuinely broken caller.
770
+ cat > "$STDINBUF"
771
+ if [ ! -s "$STDINBUF" ]; then
772
+ echo "ERROR: check-no-emdash - nothing arrived on stdin for ${LABEL}. Refusing to" >&2
773
+ echo " report green from a scan that read nothing." >&2
774
+ exit 1
775
+ fi
776
+ HITS=$(grep -nP -e "$PATTERN" -- "$STDINBUF" 2>> "$ERRLOG" || true)
777
+ refuse_if_incomplete
778
+ [ -n "$HITS" ] && fail_with_hits "$LABEL" "$HITS"
779
+ echo "check-no-emdash: OK (no em dashes in ${LABEL})"
780
+ exit 0
781
+ fi
782
+
783
+ # ---- the self-exclusion, resolved rather than named ------------------------------
784
+ #
785
+ # The implementation's path AS THIS REPO WOULD SPELL IT, which exists only when the
786
+ # file is inside this repo's tree at all. In a consuming repo it is under node_modules
787
+ # and SELF_REL is either empty or an untracked path, so nothing is excluded: the
788
+ # membership test below is against the tracked list, never against the filesystem.
789
+ SELF_REL=''
790
+ case $SELF_PATH in
791
+ "$ROOT"/*) SELF_REL=${SELF_PATH#"$ROOT"/} ;;
792
+ esac
793
+
794
+ : > "$SCANLIST"
795
+ : > "$PLAINLIST"
796
+ gitlinks=0
797
+ scanned=0
798
+ self_excluded=0
799
+ declared_excluded=0
800
+ for i in "${!TRACKED[@]}"; do
801
+ f=${TRACKED[$i]}
802
+ # The implementation under scan, excluded only because it is tracked HERE. It has to
803
+ # name the encodings it bans, and nothing checks the checker.
804
+ if [ -n "$SELF_REL" ] && [ "$f" = "$SELF_REL" ]; then
805
+ self_excluded=$((self_excluded + 1))
806
+ continue
807
+ fi
808
+
809
+ # What the repo declared it leaves out. Checked before the entry is opened, because an
810
+ # excluded path is not read and its type on disk is therefore not this gate's business.
811
+ if is_excluded "$f"; then
812
+ declared_excluded=$((declared_excluded + 1))
813
+ continue
814
+ fi
815
+
816
+ # `git ls-files` lists a submodule as a gitlink, which on disk is a REAL directory.
817
+ # config has none today (checked: `git ls-files -s` lists no mode 160000 entry). The
818
+ # skip is keyed on the INDEX MODE and never on the type on disk, because the two
819
+ # disagree in the case that costs the most: a tracked regular file shadowed on disk by
820
+ # a directory tests as a directory, so a type-keyed skip reads none of its indexed
821
+ # content, counts it as a gitlink it is not, and prints OK over it. Keyed on 160000 it
822
+ # falls through to the not-a-regular-file refusal below and is named.
823
+ if [ "${TRACKED_MODE[$i]}" = 160000 ]; then
824
+ gitlinks=$((gitlinks + 1))
825
+ continue
826
+ fi
827
+
828
+ if [ ! -r "$f" ]; then
829
+ echo "ERROR: check-no-emdash - tracked file is not readable: $f" >&2
830
+ echo " Refusing to report green from a scan that could not open its input." >&2
831
+ exit 1
832
+ fi
833
+
834
+ # Anything tracked that is not a regular file after symlink resolution (a symlink to
835
+ # a directory, a symlink to nothing, a device) is refused by name rather than
836
+ # skipped. Skipping is how `-d skip` let an UNREAD entry pass green, which is a
837
+ # missed read rather than a missed match and is the harder one to notice.
838
+ if [ ! -f "$f" ]; then
839
+ echo "ERROR: check-no-emdash - tracked entry is not a regular file: $f" >&2
840
+ echo " Refusing to report green from a scan that skipped one of its inputs." >&2
841
+ exit 1
842
+ fi
843
+
844
+ printf './%s\0' "$f" >> "$SCANLIST"
845
+ printf '%s\0' "$f" >> "$PLAINLIST"
846
+ scanned=$((scanned + 1))
847
+ done
848
+
849
+ if [ ! -s "$SCANLIST" ]; then
850
+ echo "ERROR: check-no-emdash - no tracked files left to scan. Refusing to report" >&2
851
+ echo " green from a scan that read nothing." >&2
852
+ exit 1
853
+ fi
854
+
855
+ # ---- --list-scanned: what the default scan reads ---------------------------------
856
+ #
857
+ # The same set the scan is about to read, off the same pass, so a consumer comparing
858
+ # coverage against a retired variant is comparing what this gate ACTUALLY opens rather
859
+ # than a description of it. NUL-separated and nothing else on stdout: every diagnostic
860
+ # above went to stderr, so this stream is safe to pipe into `sort`, `comm` or `xargs -0`.
861
+ if [ "$MODE" = list ]; then
862
+ cat "$PLAINLIST"
863
+ exit 0
864
+ fi
865
+
866
+ HITS=$(xargs -0 -r grep -H -nP -e "$PATTERN" -- < "$SCANLIST" 2>> "$ERRLOG" || true)
867
+
868
+ refuse_if_incomplete
869
+
870
+ [ -n "$HITS" ] && fail_with_hits "the tracked files listed above" "$HITS"
871
+
872
+ echo "check-no-emdash: OK (no em dashes in ${scanned} of ${#TRACKED[@]} tracked file(s);" \
873
+ "${gitlinks} gitlink(s) skipped, ${self_excluded} excluded as the implementation under" \
874
+ "scan, ${declared_excluded} excluded by ${DECLARATION})"