@cosyte/script-utils 0.0.1 → 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.
- package/CHANGELOG.md +174 -2
- package/README.md +423 -17
- package/attw.d.ts +36 -0
- package/attw.js +1679 -0
- package/check-no-emdash.sh +874 -0
- package/internal-refs.d.ts +223 -0
- package/internal-refs.js +1363 -0
- package/package.json +28 -1
- package/phi-scan.d.ts +271 -0
- package/phi-scan.js +1936 -0
|
@@ -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})"
|