@cosyte/script-utils 0.1.2 → 0.1.4

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/catalog.js ADDED
@@ -0,0 +1,251 @@
1
+ // @cosyte/script-utils/catalog
2
+ //
3
+ // THE CATALOG OF COSYTE'S LIBRARIES (ST13, ST14). One record per library, generated from that
4
+ // library's own package.json, with the tier it belongs to; and one for Pathways, our integration
5
+ // engine, which is in development and not on npm. The tiers and Pathways' status come from a small
6
+ // hand-kept file in cosyte/config (`catalog/tiers.json`); everything else is read out of each
7
+ // package.json by `scripts/catalog.mjs` there, and written beside this module as `catalog.json`.
8
+ // A site, the org profile, a deck or a knowledgebase page reads that one list instead of keeping
9
+ // its own roster.
10
+ //
11
+ // IT ALSO CARRIES THE CHECK EVERY LIBRARY RUNS. Each package.json description is that library's one
12
+ // canonical line (ST14), so the description in a library's package.json must be the catalog's.
13
+ // When a library changes its description, its check fails until the catalog is regenerated and a
14
+ // script-utils release carries it: the catalog and the package cannot quietly disagree.
15
+ //
16
+ // node node_modules/@cosyte/script-utils/catalog.js # the package in this directory
17
+ // node node_modules/@cosyte/script-utils/catalog.js --dir <path> # another package directory
18
+ //
19
+ // EXIT CODES, the contract every gate here keeps: 0 the description agrees, 1 it does not, 2 the
20
+ // check could not run (no package.json, or a package the catalog does not list).
21
+ //
22
+ // ZERO DEPENDENCIES AND NO BUILD STEP, like the rest of this package.
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { join, resolve } from "node:path";
26
+
27
+ import { isCliEntrypoint } from "./index.js";
28
+
29
+ /** The three tiers, in the order ST13 names them. */
30
+ export const TIERS = Object.freeze(["standard", "built-on", "engine"]);
31
+
32
+ /** Deep-freeze a parsed JSON value, so no consumer can edit the shared catalog in place. */
33
+ function freeze(value) {
34
+ if (value !== null && typeof value === "object") {
35
+ for (const child of Object.values(value)) freeze(child);
36
+ Object.freeze(value);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ /** The catalog, as `catalog.json` beside this module carries it. */
42
+ export const CATALOG = freeze(
43
+ JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url), "utf8")),
44
+ );
45
+
46
+ /**
47
+ * The release line a version is on: `0.<minor>` below 1.0.0, where a minor may break, and the
48
+ * major from 1.0.0 on. `0.1.2` is the 0.1 line (ST16).
49
+ *
50
+ * @param {string} version A `major.minor.patch` version.
51
+ * @returns {string} The line, for example `0.1`.
52
+ */
53
+ export function releaseLine(version) {
54
+ const match = /^(\d+)\.(\d+)\.\d+(?:[-+].*)?$/.exec(String(version));
55
+ if (match === null) throw new TypeError(`${JSON.stringify(version)} is not a semver version.`);
56
+ return match[1] === "0" ? `0.${match[2]}` : match[1];
57
+ }
58
+
59
+ /** The `https://github.com/cosyte/<repo>` a package.json's repository names, or `null`. */
60
+ function repositoryUrl(manifest) {
61
+ const raw =
62
+ typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
63
+ if (typeof raw !== "string") return null;
64
+ const match = /github\.com[/:]cosyte\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(raw);
65
+ return match === null ? null : `https://github.com/cosyte/${match[1]}`;
66
+ }
67
+
68
+ /** The slug a `@cosyte/<slug>` name carries. */
69
+ function slugOf(name) {
70
+ const match = /^@cosyte\/([a-z0-9][a-z0-9.-]*)$/.exec(String(name));
71
+ if (match === null)
72
+ throw new TypeError(`${JSON.stringify(name)} is not a @cosyte/<name> package.`);
73
+ return match[1];
74
+ }
75
+
76
+ /** The fields every record takes from its package.json, refusing a manifest that lacks one. */
77
+ function fromManifest(name, manifest) {
78
+ if (manifest === undefined || manifest === null || typeof manifest !== "object") {
79
+ throw new TypeError(`no package.json was read for ${name}.`);
80
+ }
81
+ if (manifest.name !== name) {
82
+ throw new TypeError(
83
+ `the package.json read for ${name} names ${JSON.stringify(manifest.name)}.`,
84
+ );
85
+ }
86
+ const slug = slugOf(name);
87
+ const problems = [];
88
+ if (typeof manifest.description !== "string" || manifest.description.trim() === "") {
89
+ problems.push("no description");
90
+ }
91
+ if (typeof manifest.version !== "string") problems.push("no version");
92
+ if (typeof manifest.engines?.node !== "string") problems.push("no engines.node");
93
+ const repository = repositoryUrl(manifest);
94
+ if (repository === null) problems.push("no github.com/cosyte repository");
95
+ if (problems.length > 0)
96
+ throw new TypeError(`${name}'s package.json has ${problems.join(", ")}.`);
97
+ return {
98
+ name,
99
+ slug,
100
+ description: manifest.description,
101
+ line: releaseLine(manifest.version),
102
+ engines: { node: manifest.engines.node },
103
+ repository,
104
+ docs: `https://docs.cosyte.com/${slug}`,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Build the catalog from the hand-kept tiers file and each package's package.json. Pure: it reads
110
+ * nothing, so the generator decides where the manifests come from.
111
+ *
112
+ * @param {{ standards: { package: string, standard: string }[], builtOn: string[],
113
+ * engine: { package: string, name: string, status: string, onNpm: boolean } }} tiers
114
+ * @param {Map<string, Record<string, any>> | Record<string, Record<string, any>>} manifests
115
+ * Each package's parsed package.json, keyed by package name.
116
+ * @returns {{ libraries: object[], engine: object }} The catalog.
117
+ * @throws {TypeError} When a tier names a package with no usable manifest, or names one twice.
118
+ */
119
+ export function buildCatalog(tiers, manifests) {
120
+ const get = (name) => (manifests instanceof Map ? manifests.get(name) : manifests[name]);
121
+ const seen = new Set();
122
+ const claim = (name) => {
123
+ if (seen.has(name)) throw new TypeError(`${name} is listed twice in the tiers.`);
124
+ seen.add(name);
125
+ };
126
+ const libraries = [];
127
+ for (const { package: name, standard } of tiers.standards) {
128
+ claim(name);
129
+ libraries.push({
130
+ ...fromManifest(name, get(name)),
131
+ npm: `https://www.npmjs.com/package/${name}`,
132
+ tier: "standard",
133
+ standard,
134
+ });
135
+ }
136
+ for (const name of tiers.builtOn) {
137
+ claim(name);
138
+ libraries.push({
139
+ ...fromManifest(name, get(name)),
140
+ npm: `https://www.npmjs.com/package/${name}`,
141
+ tier: "built-on",
142
+ });
143
+ }
144
+ const { package: enginePackage, name: engineName, status, onNpm } = tiers.engine;
145
+ claim(enginePackage);
146
+ const engine = {
147
+ ...fromManifest(enginePackage, get(enginePackage)),
148
+ tier: "engine",
149
+ displayName: engineName,
150
+ status,
151
+ onNpm,
152
+ };
153
+ return { libraries, engine };
154
+ }
155
+
156
+ /**
157
+ * The catalog record for a package name: a library or the engine, or `undefined`.
158
+ *
159
+ * @param {string} name A package name, for example `@cosyte/hl7`.
160
+ * @param {{ libraries: object[], engine: object }} [catalog] Defaults to `CATALOG`.
161
+ */
162
+ export function catalogEntry(name, catalog = CATALOG) {
163
+ if (catalog.engine?.name === name) return catalog.engine;
164
+ return catalog.libraries.find((entry) => entry.name === name);
165
+ }
166
+
167
+ /**
168
+ * Compare a package.json with its catalog record. Only the description is compared: it is the line
169
+ * ST14 makes canonical, and the other generated fields move with a release (the version line) or
170
+ * cannot drift without the package moving (the name and repository).
171
+ *
172
+ * @param {Record<string, any>} manifest The package's parsed package.json.
173
+ * @param {{ libraries: object[], engine: object }} [catalog] Defaults to `CATALOG`.
174
+ * @returns {{ field: string, catalog: string, packageJson: string }[]} The disagreements.
175
+ * @throws {TypeError} When the catalog does not list the package.
176
+ */
177
+ export function compareWithCatalog(manifest, catalog = CATALOG) {
178
+ const entry = catalogEntry(manifest?.name, catalog);
179
+ if (entry === undefined) {
180
+ throw new TypeError(`the catalog does not list ${JSON.stringify(manifest?.name ?? null)}.`);
181
+ }
182
+ if (manifest.description === entry.description) return [];
183
+ return [
184
+ {
185
+ field: "description",
186
+ catalog: entry.description,
187
+ packageJson: String(manifest.description ?? ""),
188
+ },
189
+ ];
190
+ }
191
+
192
+ /**
193
+ * Run the check over one package directory and return the exit code. It never exits the process.
194
+ *
195
+ * @param {{ dir?: string, argv?: string[] }} [options] `dir` is the package directory (default:
196
+ * the working directory). `argv` is the CLI arguments, read only for `--dir <path>`.
197
+ * @returns {0 | 1 | 2} 0 agree, 1 disagree, 2 the check could not run.
198
+ */
199
+ export function runCatalogCheck(options = {}) {
200
+ let dir = options.dir ?? process.cwd();
201
+ const argv = options.argv ?? [];
202
+ for (let i = 0; i < argv.length; i += 1) {
203
+ if (argv[i] === "--dir" && typeof argv[i + 1] === "string") {
204
+ dir = argv[i + 1];
205
+ i += 1;
206
+ } else if (argv[i] !== "--") {
207
+ process.stderr.write(
208
+ `catalog check: ${JSON.stringify(argv[i])} is not an argument this check accepts (only ` +
209
+ `--dir <package directory>). Nothing was checked.\n`,
210
+ );
211
+ return 2;
212
+ }
213
+ }
214
+ dir = resolve(dir);
215
+ let manifest;
216
+ try {
217
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
218
+ } catch (err) {
219
+ process.stderr.write(`catalog check: could not read package.json in ${dir}: ${err.message}\n`);
220
+ return 2;
221
+ }
222
+ let differences;
223
+ try {
224
+ differences = compareWithCatalog(manifest);
225
+ } catch (err) {
226
+ process.stderr.write(`catalog check: ${err.message} Nothing was checked.\n`);
227
+ return 2;
228
+ }
229
+ if (differences.length === 0) {
230
+ process.stdout.write(`catalog check: ${manifest.name}'s description is the catalog's.\n`);
231
+ return 0;
232
+ }
233
+ const report = differences
234
+ .map(
235
+ (d) =>
236
+ ` ${d.field}\n package.json: ${JSON.stringify(d.packageJson)}\n` +
237
+ ` catalog: ${JSON.stringify(d.catalog)}`,
238
+ )
239
+ .join("\n");
240
+ process.stderr.write(
241
+ `catalog check: ${manifest.name}'s package.json and the catalog disagree.\n${report}\n` +
242
+ `The catalog is generated from package.json in cosyte/config (scripts/catalog.mjs) and ` +
243
+ `ships in @cosyte/script-utils. Change both together: regenerate the catalog, release ` +
244
+ `script-utils, then move this package to that release.\n`,
245
+ );
246
+ return 1;
247
+ }
248
+
249
+ if (isCliEntrypoint(import.meta.url)) {
250
+ process.exitCode = runCatalogCheck({ argv: process.argv.slice(2) });
251
+ }
package/catalog.json ADDED
@@ -0,0 +1,210 @@
1
+ {
2
+ "$comment": "GENERATED by cosyte/config scripts/catalog.mjs from each package's package.json and catalog/tiers.json. Do not edit by hand: change the package.json or the tiers file, then run `node scripts/catalog.mjs write` from the umbrella.",
3
+ "libraries": [
4
+ {
5
+ "name": "@cosyte/hl7",
6
+ "slug": "hl7",
7
+ "description": "Developer-focused HL7 v2 toolkit for Node.js and TypeScript, message parsing and building, mutation and serialization, and one-line helpers.",
8
+ "line": "0.1",
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "repository": "https://github.com/cosyte/hl7",
13
+ "docs": "https://docs.cosyte.com/hl7",
14
+ "npm": "https://www.npmjs.com/package/@cosyte/hl7",
15
+ "tier": "standard",
16
+ "standard": "HL7 v2"
17
+ },
18
+ {
19
+ "name": "@cosyte/mllp",
20
+ "slug": "mllp",
21
+ "description": "MLLP transport for HL7 v2 in Node.js and TypeScript: client and server, framing, ACK correlation, auto-reconnect, TLS, and an in-memory transport for tests.",
22
+ "line": "0.1",
23
+ "engines": {
24
+ "node": ">=22.0.0"
25
+ },
26
+ "repository": "https://github.com/cosyte/mllp",
27
+ "docs": "https://docs.cosyte.com/mllp",
28
+ "npm": "https://www.npmjs.com/package/@cosyte/mllp",
29
+ "tier": "standard",
30
+ "standard": "MLLP"
31
+ },
32
+ {
33
+ "name": "@cosyte/fhir",
34
+ "slug": "fhir",
35
+ "description": "FHIR R4 for Node.js and TypeScript: a resource model that keeps every value exactly as written, JSON and XML codecs, layered validation, and a safety readout for status and negation.",
36
+ "line": "0.1",
37
+ "engines": {
38
+ "node": ">=22.0.0"
39
+ },
40
+ "repository": "https://github.com/cosyte/fhir",
41
+ "docs": "https://docs.cosyte.com/fhir",
42
+ "npm": "https://www.npmjs.com/package/@cosyte/fhir",
43
+ "tier": "standard",
44
+ "standard": "FHIR"
45
+ },
46
+ {
47
+ "name": "@cosyte/ccda",
48
+ "slug": "ccda",
49
+ "description": "C-CDA parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.",
50
+ "line": "0.1",
51
+ "engines": {
52
+ "node": ">=22.0.0"
53
+ },
54
+ "repository": "https://github.com/cosyte/ccda",
55
+ "docs": "https://docs.cosyte.com/ccda",
56
+ "npm": "https://www.npmjs.com/package/@cosyte/ccda",
57
+ "tier": "standard",
58
+ "standard": "C-CDA"
59
+ },
60
+ {
61
+ "name": "@cosyte/x12",
62
+ "slug": "x12",
63
+ "description": "Developer-focused ASC X12 healthcare EDI parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.",
64
+ "line": "0.1",
65
+ "engines": {
66
+ "node": ">=22.0.0"
67
+ },
68
+ "repository": "https://github.com/cosyte/x12",
69
+ "docs": "https://docs.cosyte.com/x12",
70
+ "npm": "https://www.npmjs.com/package/@cosyte/x12",
71
+ "tier": "standard",
72
+ "standard": "X12"
73
+ },
74
+ {
75
+ "name": "@cosyte/ncpdp",
76
+ "slug": "ncpdp",
77
+ "description": "NCPDP parser, serializer, and builder for Node.js and TypeScript: SCRIPT ePrescribing and Telecom pharmacy claims, lenient on parse, spec-clean on emit.",
78
+ "line": "0.1",
79
+ "engines": {
80
+ "node": ">=22.0.0"
81
+ },
82
+ "repository": "https://github.com/cosyte/ncpdp",
83
+ "docs": "https://docs.cosyte.com/ncpdp",
84
+ "npm": "https://www.npmjs.com/package/@cosyte/ncpdp",
85
+ "tier": "standard",
86
+ "standard": "NCPDP"
87
+ },
88
+ {
89
+ "name": "@cosyte/astm",
90
+ "slug": "astm",
91
+ "description": "ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.",
92
+ "line": "0.1",
93
+ "engines": {
94
+ "node": ">=22.0.0"
95
+ },
96
+ "repository": "https://github.com/cosyte/astm",
97
+ "docs": "https://docs.cosyte.com/astm",
98
+ "npm": "https://www.npmjs.com/package/@cosyte/astm",
99
+ "tier": "standard",
100
+ "standard": "ASTM"
101
+ },
102
+ {
103
+ "name": "@cosyte/dicom",
104
+ "slug": "dicom",
105
+ "description": "DICOM Part 10 parser + utility library for Node.js and TypeScript: metadata-first, vendor-quirky-tolerant, dual ESM/CJS.",
106
+ "line": "0.1",
107
+ "engines": {
108
+ "node": ">=22.0.0"
109
+ },
110
+ "repository": "https://github.com/cosyte/dicom",
111
+ "docs": "https://docs.cosyte.com/dicom",
112
+ "npm": "https://www.npmjs.com/package/@cosyte/dicom",
113
+ "tier": "standard",
114
+ "standard": "DICOM"
115
+ },
116
+ {
117
+ "name": "@cosyte/terminology",
118
+ "slug": "terminology",
119
+ "description": "Terminology engine for US healthcare code systems in Node.js and TypeScript: FHIR $translate, $lookup, $validate-code and $expand over code systems, value sets and maps you supply.",
120
+ "line": "0.1",
121
+ "engines": {
122
+ "node": ">=22.0.0"
123
+ },
124
+ "repository": "https://github.com/cosyte/terminology",
125
+ "docs": "https://docs.cosyte.com/terminology",
126
+ "npm": "https://www.npmjs.com/package/@cosyte/terminology",
127
+ "tier": "built-on"
128
+ },
129
+ {
130
+ "name": "@cosyte/transform",
131
+ "slug": "transform",
132
+ "description": "HL7 v2 → FHIR R4 transformation library for Node.js and TypeScript: IG-grounded, fail-safe, value-free diagnostics; never a confident wrong FHIR value.",
133
+ "line": "0.1",
134
+ "engines": {
135
+ "node": ">=22.0.0"
136
+ },
137
+ "repository": "https://github.com/cosyte/transform",
138
+ "docs": "https://docs.cosyte.com/transform",
139
+ "npm": "https://www.npmjs.com/package/@cosyte/transform",
140
+ "tier": "built-on"
141
+ },
142
+ {
143
+ "name": "@cosyte/deid",
144
+ "slug": "deid",
145
+ "description": "Healthcare de-identification for Node.js and TypeScript: applies a HIPAA Safe Harbor policy to HL7 v2, C-CDA, FHIR R4, X12, NCPDP Telecom and DICOM, fails closed, and emits a value-free manifest.",
146
+ "line": "0.1",
147
+ "engines": {
148
+ "node": ">=22.0.0"
149
+ },
150
+ "repository": "https://github.com/cosyte/deid",
151
+ "docs": "https://docs.cosyte.com/deid",
152
+ "npm": "https://www.npmjs.com/package/@cosyte/deid",
153
+ "tier": "built-on"
154
+ },
155
+ {
156
+ "name": "@cosyte/synth",
157
+ "slug": "synth",
158
+ "description": "Deterministic, seedable synthetic healthcare test fixtures for Node.js and TypeScript: HL7 v2, FHIR R4 and US Core, C-CDA, X12, NCPDP and ASTM, spec-clean by construction and never real PHI.",
159
+ "line": "0.1",
160
+ "engines": {
161
+ "node": ">=22.0.0"
162
+ },
163
+ "repository": "https://github.com/cosyte/synth",
164
+ "docs": "https://docs.cosyte.com/synth",
165
+ "npm": "https://www.npmjs.com/package/@cosyte/synth",
166
+ "tier": "built-on"
167
+ },
168
+ {
169
+ "name": "@cosyte/dates",
170
+ "slug": "dates",
171
+ "description": "Date and time utilities for the @cosyte/* healthcare parsers: precision-preserving conversion and validation that never guesses a timezone.",
172
+ "line": "0.1",
173
+ "engines": {
174
+ "node": ">=22.0.0"
175
+ },
176
+ "repository": "https://github.com/cosyte/dates",
177
+ "docs": "https://docs.cosyte.com/dates",
178
+ "npm": "https://www.npmjs.com/package/@cosyte/dates",
179
+ "tier": "built-on"
180
+ },
181
+ {
182
+ "name": "@cosyte/cli",
183
+ "slug": "cli",
184
+ "description": "The cosyte CLI: a PHI-safe developer front door over the @cosyte/* healthcare parsers. Parse HL7 v2 / FHIR from a file or stdin to typed JSON, with a documented exit-code contract.",
185
+ "line": "0.1",
186
+ "engines": {
187
+ "node": ">=22.0.0 <26.0.0"
188
+ },
189
+ "repository": "https://github.com/cosyte/cli",
190
+ "docs": "https://docs.cosyte.com/cli",
191
+ "npm": "https://www.npmjs.com/package/@cosyte/cli",
192
+ "tier": "built-on"
193
+ }
194
+ ],
195
+ "engine": {
196
+ "name": "@cosyte/pathways",
197
+ "slug": "pathways",
198
+ "description": "A code-first healthcare integration engine for Node.js: channels in YAML, transforms in TypeScript. Early and not yet released.",
199
+ "line": "0.0",
200
+ "engines": {
201
+ "node": ">=24.0.0"
202
+ },
203
+ "repository": "https://github.com/cosyte/pathways",
204
+ "docs": "https://docs.cosyte.com/pathways",
205
+ "tier": "engine",
206
+ "displayName": "Pathways",
207
+ "status": "in development",
208
+ "onNpm": false
209
+ }
210
+ }
@@ -228,19 +228,32 @@ unset -f grep xargs sed awk 2>/dev/null || true
228
228
  # refuse_if_incomplete reads. That branch only refines wording, never the red.
229
229
  export LC_ALL=C.UTF-8
230
230
 
231
- # Matches U+2014 as the literal character and as its encodings: %E2%80%94 (URL), the
232
- # JS backslash-u escape, and the &mdash; / &#8212; / &#x2014; HTML entities. The
233
- # escape is not theoretical in this repo: it is what the parser-template manifest
234
- # carried, and a literal-only sweep passed straight over it. Matches U+2013 the same
235
- # way: %E2%80%93, the backslash-u escape, and the &ndash; / &#8211; / &#x2013; HTML
236
- # entities.
237
- #
238
- # THE MATCH VOCABULARY IS FROZEN at these twelve forms, six for each dash. A consumer
239
- # may narrow WHAT IS SCANNED through the declaration above; nothing may narrow WHAT IS
240
- # MATCHED, and widening it is a change every consuming repo inherits at once and is
241
- # specced as one. The en dash's six were that change (operator decision, 2026-11-07).
242
- PATTERN='\x{2014}|%E2%80%94|\\u2014|&mdash;|&#8212;|&#x2014;'
243
- PATTERN+='|\x{2013}|%E2%80%93|\\u2013|&ndash;|&#8211;|&#x2013;'
231
+ # What counts as a dash, for U+2014 and U+2013 alike. Case never matters, so `&MDASH;`
232
+ # and `%e2%80%94` count too (D56: the forms the marketing and consulting scanner caught,
233
+ # which this gate now matches so those repos lose nothing by moving to it):
234
+ #
235
+ # the characters U+2014 U+2013
236
+ # HTML named entities, ; optional &mdash; &ndash;
237
+ # HTML decimal references &#8212; &#8211; leading zeros and a missing ; too
238
+ # HTML hex references &#x2014; &#x2013; leading zeros and a missing ; too
239
+ # URL encoding of the UTF-8 bytes %E2%80%94 %E2%80%93
240
+ # backslash-u escapes (JS, JSON, Java, \u2014 \u{2014} \U00002014
241
+ # C#, Python, Rust, Swift)
242
+ # backslash-x escapes (PCRE, Perl) \x{2014}
243
+ # CSS escapes \2014 \002014
244
+ # Unicode names (Python, Perl) \N{EM DASH} \N{EN DASH}
245
+ #
246
+ # The lookaheads keep the near misses out: &#82120; and the CSS escape \20140 name other
247
+ # characters, and &mdashboard is not an entity. The escape forms are not theoretical: the
248
+ # parser-template manifest once carried a backslash-u em dash, and a literal-only sweep
249
+ # passed straight over it.
250
+ #
251
+ # A consumer may narrow WHAT IS SCANNED through the declaration above; nothing may narrow
252
+ # WHAT IS MATCHED, and widening it is a change every consuming repo inherits at once. The
253
+ # en dash's forms (operator decision, 2026-11-07) and these (D56) were such changes.
254
+ PATTERN='(?i)\x{2014}|\x{2013}|&[mn]dash(?![a-z])|&#0*821[12](?![0-9])|&#x0*201[34](?![0-9a-f])'
255
+ PATTERN+='|%E2%80%9[34]|\\u(?:0000)?201[34]|\\u\{0*201[34]\}|\\x\{0*201[34]\}'
256
+ PATTERN+='|\\0{0,2}201[34](?![0-9a-f])|\\N\{E[MN] DASH\}'
244
257
 
245
258
  # The declaration's path, relative to the top level of the repo under scan. Deliberately
246
259
  # NOT relative to this file: in a consuming repo this file lives under `node_modules/`
@@ -281,10 +294,11 @@ SCAN_MODE=files
281
294
 
282
295
  refuse_arguments() {
283
296
  echo "ERROR: check-no-emdash - $1" >&2
284
- echo " The modes are: (no argument) to scan every tracked file; --stdin LABEL" >&2
285
- echo " to scan the text arriving on stdin; --list-scanned to write the paths the" >&2
286
- echo " default scan reads; --self-id to write the sha256 of the implementation" >&2
287
- echo " that is executing. Refusing to run rather than guess which was meant." >&2
297
+ echo " The modes are: (no argument) to scan every tracked file and its name;" >&2
298
+ echo " --stdin LABEL to scan the text arriving on stdin; --list-scanned to write the" >&2
299
+ echo " paths the default scan reads; --self-id to write the sha256 of the" >&2
300
+ echo " implementation that is executing; --help for this. Refusing to run rather" >&2
301
+ echo " than guess which was meant." >&2
288
302
  exit 1
289
303
  }
290
304
 
@@ -324,6 +338,22 @@ case "${1:-}" in
324
338
  refuse_arguments "--self-id takes no further argument, and this was given: ${2}"
325
339
  fi
326
340
  ;;
341
+ --help | -h)
342
+ cat << 'USAGE'
343
+ check-no-emdash: refuse the em dash (U+2014) and the en dash (U+2013), as the characters and
344
+ in every encoded form, in the tracked files of the repository it runs in and in their names.
345
+
346
+ check-no-emdash.sh scan every tracked file and every tracked file name
347
+ check-no-emdash.sh --stdin LABEL scan the text on stdin (a PR title, body, commit message)
348
+ check-no-emdash.sh --list-scanned write the NUL-separated paths the default scan reads
349
+ check-no-emdash.sh --self-id write the sha256 of the implementation that is executing
350
+
351
+ A repository leaves paths out in scripts/check-no-emdash.exclude: one per line, an entry ending
352
+ in / covers everything beneath it, # starts a comment. Exit 0 means the mode completed and found
353
+ nothing banned; exit 1 means anything else.
354
+ USAGE
355
+ exit 0
356
+ ;;
327
357
  *)
328
358
  refuse_arguments "unrecognised argument: ${1}"
329
359
  ;;
@@ -341,6 +371,7 @@ GITERR=$(mktemp)
341
371
  FILELIST=$(mktemp)
342
372
  SCANLIST=$(mktemp)
343
373
  PLAINLIST=$(mktemp)
374
+ NAMELIST=$(mktemp)
344
375
  STDINBUF=$(mktemp)
345
376
  BINPROBE=$(mktemp)
346
377
  PROBEERR=$(mktemp)
@@ -353,8 +384,8 @@ PROBEERR=$(mktemp)
353
384
  # not lost with it.
354
385
  close_exit_vocabulary() {
355
386
  local code=$?
356
- rm -f "$ERRLOG" "$GITERR" "$FILELIST" "$SCANLIST" "$PLAINLIST" "$STDINBUF" "$BINPROBE" \
357
- "$PROBEERR"
387
+ rm -f "$ERRLOG" "$GITERR" "$FILELIST" "$SCANLIST" "$PLAINLIST" "$NAMELIST" "$STDINBUF" \
388
+ "$BINPROBE" "$PROBEERR"
358
389
  if [ "$code" -ne 0 ] && [ "$code" -ne 1 ]; then
359
390
  echo "ERROR: check-no-emdash - the run ended with status ${code}. This gate reports 0" >&2
360
391
  echo " for a mode that completed clean and 1 for everything else, so it is" >&2
@@ -816,6 +847,7 @@ esac
816
847
 
817
848
  : > "$SCANLIST"
818
849
  : > "$PLAINLIST"
850
+ : > "$NAMELIST"
819
851
  gitlinks=0
820
852
  scanned=0
821
853
  self_excluded=0
@@ -831,11 +863,16 @@ for i in "${!TRACKED[@]}"; do
831
863
 
832
864
  # What the repo declared it leaves out. Checked before the entry is opened, because an
833
865
  # excluded path is not read and its type on disk is therefore not this gate's business.
866
+ # It is not scanned at all, so its NAME is left out with its content.
834
867
  if is_excluded "$f"; then
835
868
  declared_excluded=$((declared_excluded + 1))
836
869
  continue
837
870
  fi
838
871
 
872
+ # A NAME IS COPY TOO (D56): every tracked path the declaration does not leave out has its
873
+ # name scanned, a gitlink's included, before any test of what is on disk.
874
+ printf '%s\0' "$f" >> "$NAMELIST"
875
+
839
876
  # `git ls-files` lists a submodule as a gitlink, which on disk is a REAL directory.
840
877
  # config has none today (checked: `git ls-files -s` lists no mode 160000 entry). The
841
878
  # skip is keyed on the INDEX MODE and never on the type on disk, because the two
@@ -886,12 +923,22 @@ if [ "$MODE" = list ]; then
886
923
  exit 0
887
924
  fi
888
925
 
926
+ # File names first: NUL-separated in, one per line out. `-a` because a name is text whatever
927
+ # bytes it holds, and the stderr capture refuses a name scan that could not complete.
928
+ NAME_HITS=$(grep -z -a -P -e "$PATTERN" < "$NAMELIST" 2>> "$ERRLOG" | tr '\0' '\n' || true)
929
+ refuse_if_incomplete
930
+
889
931
  HITS=$(xargs -0 -r grep -H -nP -e "$PATTERN" -- < "$SCANLIST" 2>> "$ERRLOG" || true)
890
932
 
891
933
  refuse_if_incomplete
892
934
 
935
+ if [ -n "$NAME_HITS" ] && [ -n "$HITS" ]; then
936
+ fail_with_hits "the tracked file names and the tracked files listed above" \
937
+ "$NAME_HITS"$'\n'"$HITS"
938
+ fi
939
+ [ -n "$NAME_HITS" ] && fail_with_hits "the tracked file names listed above" "$NAME_HITS"
893
940
  [ -n "$HITS" ] && fail_with_hits "the tracked files listed above" "$HITS"
894
941
 
895
- echo "check-no-emdash: OK (no em or en dashes in ${scanned} of ${#TRACKED[@]} tracked file(s);" \
896
- "${gitlinks} gitlink(s) skipped, ${self_excluded} excluded as the implementation under" \
897
- "scan, ${declared_excluded} excluded by ${DECLARATION})"
942
+ echo "check-no-emdash: OK (no em or en dashes in ${scanned} of ${#TRACKED[@]} tracked file(s)" \
943
+ "or their names; ${gitlinks} gitlink(s) skipped, ${self_excluded} excluded as the" \
944
+ "implementation under scan, ${declared_excluded} excluded by ${DECLARATION})"