@cosyte/script-utils 0.1.1 → 0.1.3

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/attw.js CHANGED
@@ -160,9 +160,16 @@
160
160
  * one, and `prepare` and `postpack` double the same way. `prepublishOnly` does
161
161
  * not run under `npm pack` AT ALL (measured: 0 through either gate), which is
162
162
  * why this cannot recurse into the gate that runs it. Of the three that do
163
- * double, every repo here defines only `prepare`, and defines it as the same
164
- * `command -v simple-git-hooks … || true` one-liner, so
165
- * nothing reds today. IT IS NOT FIXED WITH `--ignore-scripts`, AND THAT IS
163
+ * double, the repos here define only `prepare`. AN EARLIER VERSION OF THIS
164
+ * PARAGRAPH SAID EVERY ONE DEFINES IT AS THE SAME ONE-LINER, SO NOTHING REDS,
165
+ * AND THAT WAS FALSE. The `prepare` lines differ, and the one the parser
166
+ * template shipped printed simple-git-hooks' `[INFO]` lines on STDOUT, inside
167
+ * the JSON this net parses: every repo scaffolded from it was born with a gate
168
+ * that refused ("did not print JSON"). The template now sends that output to
169
+ * stderr (`simple-git-hooks 1>&2`, cosyte/config#131), and twelve sibling
170
+ * repos still carry the older line, so a `prepare` that writes to stdout is
171
+ * a refusal here rather than a pass, by design: what it printed is in the
172
+ * message. IT IS NOT FIXED WITH `--ignore-scripts`, AND THAT IS
166
173
  * DELIBERATE: a `prepack` may GENERATE files that belong in the tarball, so
167
174
  * suppressing it would make this net read a listing the real publish would not
168
175
  * produce, which is a correctness bug wearing a performance fix.
@@ -734,15 +741,17 @@ import { spawnSync } from "node:child_process";
734
741
  import {
735
742
  accessSync,
736
743
  constants,
744
+ existsSync,
737
745
  mkdtempSync,
738
746
  readdirSync,
739
747
  readFileSync,
748
+ realpathSync,
740
749
  rmSync,
741
750
  statSync,
742
751
  } from "node:fs";
743
752
  import { tmpdir } from "node:os";
744
- import { join } from "node:path";
745
- import { fileURLToPath } from "node:url";
753
+ import { basename, join } from "node:path";
754
+ import { fileURLToPath, pathToFileURL } from "node:url";
746
755
  import { gunzipSync } from "node:zlib";
747
756
 
748
757
  /**
@@ -990,6 +999,27 @@ function summarizeProblems(out) {
990
999
  return [`attw reported ${lines.length} problem kind(s):`, ...lines].join("\n");
991
1000
  }
992
1001
 
1002
+ /**
1003
+ * The entries of an `npm pack --json` report that describe the package `name`.
1004
+ *
1005
+ * npm 10 and 11 print an ARRAY of entries; npm 12 prints an OBJECT keyed by
1006
+ * package name. An entry counts only when its own `name` is `name`, and in the
1007
+ * object shape only under the key `name` as well, so a key and an entry that
1008
+ * disagree count for nothing. Any other document (a string, a number, `null`)
1009
+ * yields no entries, which the caller refuses rather than grades.
1010
+ *
1011
+ * @param {unknown} doc The parsed report.
1012
+ * @param {string | undefined} name The package name from package.json.
1013
+ * @returns {Array<{ name?: unknown, files?: unknown }>}
1014
+ */
1015
+ function packReportEntries(doc, name) {
1016
+ if (Array.isArray(doc)) return doc.filter((entry) => entry?.name === name);
1017
+ if (doc === null || typeof doc !== "object") return [];
1018
+ return Object.entries(doc)
1019
+ .filter(([key, entry]) => key === name && entry?.name === name)
1020
+ .map(([, entry]) => entry);
1021
+ }
1022
+
993
1023
  /**
994
1024
  * The paths a published tarball of this directory would carry, read from npm
995
1025
  * rather than recomputed from `files`/`.npmignore` here.
@@ -1006,6 +1036,15 @@ function summarizeProblems(out) {
1006
1036
  * file list, because a partial answer graded as a whole one is the false green
1007
1037
  * this whole file exists to refuse.
1008
1038
  *
1039
+ * TWO SHAPES OF REPORT ARE READ, AND ONLY TWO. npm 10 and 11 print an ARRAY of
1040
+ * tarball entries; npm 12 prints an OBJECT keyed by package name whose values
1041
+ * are the same entries. Measured on npm 12.2.0: `npm pack --dry-run --json` on a
1042
+ * package named `@scope/probe` printed `{"@scope/probe": {"name": "@scope/probe",
1043
+ * "files": [...], ...}}`. Before this read both, the object fell through to an
1044
+ * empty list and the net refused every package ("npm described 0 tarball(s)"),
1045
+ * which is a red for the right reason (it read nothing) and the wrong outcome
1046
+ * (CI moving to npm 12 would have refused every publish). See `packReportEntries`.
1047
+ *
1009
1048
  * @param {{ name?: string }} pkg The parsed manifest.
1010
1049
  * @param {NodeJS.ProcessEnv} childEnv The environment to hand npm.
1011
1050
  * @returns {{ files: Set<string>, error?: undefined } | { error: string, files?: undefined }}
@@ -1032,11 +1071,12 @@ function packedFiles(pkg, childEnv) {
1032
1071
  error: `\`npm pack --dry-run --json\` did not print JSON. What it printed:\n\n${res.stdout ?? ""}`,
1033
1072
  };
1034
1073
  }
1035
- // npm reports an ARRAY of tarballs, because the same command run at a workspace
1036
- // root describes several. Only this package's own entry is gradeable here, and
1074
+ // npm describes one entry per tarball, because the same command run at a
1075
+ // workspace root describes several, in one of the two shapes read by
1076
+ // `packReportEntries`. Only this package's own entry is gradeable here, and
1037
1077
  // anything other than exactly one of them means npm was asked a different
1038
1078
  // question than this net thinks it asked.
1039
- const mine = (Array.isArray(doc) ? doc : []).filter((entry) => entry?.name === pkg.name);
1079
+ const mine = packReportEntries(doc, pkg.name);
1040
1080
  if (mine.length !== 1) {
1041
1081
  return {
1042
1082
  error:
@@ -1677,3 +1717,143 @@ function gate({ callerUrl, argv = process.argv.slice(2) }) {
1677
1717
  );
1678
1718
  return 0;
1679
1719
  }
1720
+
1721
+ // ---- The command-line form (ST34) --------------------------------------------
1722
+ //
1723
+ // `node node_modules/@cosyte/script-utils/attw.js` runs this gate for the
1724
+ // package in the working directory with no caller file of its own: it is what
1725
+ // the one-line `scripts/attw.mjs` caller does, resolving the package's own
1726
+ // `node_modules/.bin/attw`. What the package leaves out of attw's analysis is
1727
+ // declared in `scripts/attw.exclude`, one resolution per line, `#` for comments:
1728
+ //
1729
+ // node10 left out by attw's `node16` profile
1730
+ // node16-cjs left out, with node10, by its `esm-only` profile
1731
+ //
1732
+ // The file SELECTS A PROFILE and nothing else, so it reaches only what the
1733
+ // argument allow-list already forwards. A name it does not know, a repeat,
1734
+ // node16-cjs without node10 (no profile leaves out that one alone) and a
1735
+ // `--profile` on the command line beside the file are each refused: one source
1736
+ // for the profile, and nothing an exclusion could use to blind net 2.
1737
+
1738
+ /** The resolutions an exclusions file may name. */
1739
+ const EXCLUDABLE_RESOLUTIONS = new Set(["node10", "node16-cjs"]);
1740
+
1741
+ /** Where the command-line form reads what a package leaves out. */
1742
+ export const ATTW_EXCLUDE_PATH = "scripts/attw.exclude";
1743
+
1744
+ /**
1745
+ * Read an attw exclusions file into the one profile it selects.
1746
+ *
1747
+ * @param {string} text The file's contents.
1748
+ * @returns {{ profile: "node16" | "esm-only" | null, problems: string[] }}
1749
+ */
1750
+ export function readAttwExclusions(text) {
1751
+ const problems = [];
1752
+ const seen = new Set();
1753
+ text.split("\n").forEach((raw, index) => {
1754
+ const hash = raw.indexOf("#");
1755
+ const entry = (hash < 0 ? raw : raw.slice(0, hash)).trim();
1756
+ if (entry === "") return;
1757
+ if (!EXCLUDABLE_RESOLUTIONS.has(entry)) {
1758
+ problems.push(
1759
+ `line ${index + 1}: ${JSON.stringify(entry)} is not a resolution this gate can ` +
1760
+ `leave out (node10, node16-cjs).`,
1761
+ );
1762
+ } else if (seen.has(entry)) {
1763
+ problems.push(`line ${index + 1}: ${entry} is listed twice.`);
1764
+ } else {
1765
+ seen.add(entry);
1766
+ }
1767
+ });
1768
+ let profile = null;
1769
+ if (seen.has("node16-cjs") && !seen.has("node10")) {
1770
+ problems.push(
1771
+ "node16-cjs can be left out only with node10: attw's esm-only profile leaves out both " +
1772
+ "CommonJS resolutions, and no profile leaves out node16-cjs alone.",
1773
+ );
1774
+ } else if (seen.has("node16-cjs")) {
1775
+ profile = "esm-only";
1776
+ } else if (seen.has("node10")) {
1777
+ profile = "node16";
1778
+ }
1779
+ return { profile, problems };
1780
+ }
1781
+
1782
+ /**
1783
+ * Run the gate from the command line for the package in the working directory.
1784
+ *
1785
+ * @param {string[]} argv The command-line arguments, graded by the same allow-list.
1786
+ * @returns {number} The gate's exit code: 0 pass, attw's own status, or 1 for a refusal.
1787
+ */
1788
+ export function runAttwCli(argv) {
1789
+ const args = argv.filter((arg) => arg !== "--");
1790
+ const file = join(process.cwd(), ...ATTW_EXCLUDE_PATH.split("/"));
1791
+ const profile = [];
1792
+ if (existsSync(file)) {
1793
+ let text;
1794
+ try {
1795
+ text = readFileSync(file, "utf8");
1796
+ } catch (err) {
1797
+ process.stderr.write(`\n✗ attw gate: could not read ${ATTW_EXCLUDE_PATH}: ${err.message}\n`);
1798
+ return 1;
1799
+ }
1800
+ const read = readAttwExclusions(text);
1801
+ if (read.problems.length > 0) {
1802
+ process.stderr.write(
1803
+ `\n✗ attw gate: ${ATTW_EXCLUDE_PATH} is not usable, so nothing was checked:\n` +
1804
+ read.problems.map((problem) => ` ${problem}\n`).join(""),
1805
+ );
1806
+ return 1;
1807
+ }
1808
+ if (read.profile !== null) {
1809
+ if (args.some((arg) => arg.split("=")[0] === "--profile")) {
1810
+ process.stderr.write(
1811
+ `\n✗ attw gate: --profile was given and ${ATTW_EXCLUDE_PATH} selects a profile ` +
1812
+ `too. Declare it in the file alone.\n`,
1813
+ );
1814
+ return 1;
1815
+ }
1816
+ profile.push("--profile", read.profile);
1817
+ }
1818
+ }
1819
+ // The caller this form stands in for lives at `scripts/attw.mjs`, so the gate
1820
+ // resolves `../node_modules/.bin/attw` from there: this package's own binary.
1821
+ return runAttwGate({
1822
+ callerUrl: pathToFileURL(join(process.cwd(), "scripts", "attw.mjs")).href,
1823
+ argv: [...args, ...profile],
1824
+ });
1825
+ }
1826
+
1827
+ /**
1828
+ * Whether node was pointed at this file, which is the command-line form.
1829
+ *
1830
+ * SELF-CONTAINED ON PURPOSE: this module is also vendored and copied as a single file, so it may not
1831
+ * import a sibling at load. A file whose name differs from the one node was pointed at cannot be the
1832
+ * entry point, and that is settled without loading anything; only a matching name asks `index.js`'s
1833
+ * `isCliEntrypoint`, and a copy with no `index.js` beside it compares real paths instead. Unsure is
1834
+ * yes, so a gate that cannot tell runs loudly rather than skipping.
1835
+ *
1836
+ * @param {string} moduleUrl This module's `import.meta.url`.
1837
+ * @returns {Promise<boolean>}
1838
+ */
1839
+ async function invokedAsCommand(moduleUrl) {
1840
+ const entry = process.argv[1];
1841
+ if (typeof entry !== "string" || entry === "") return false;
1842
+ const own = fileURLToPath(moduleUrl);
1843
+ const stem = (path) => basename(path).replace(/\.[cm]?[jt]s$/, "");
1844
+ if (stem(entry) !== stem(own)) return false;
1845
+ try {
1846
+ const { isCliEntrypoint } = await import("./index.js");
1847
+ return isCliEntrypoint(moduleUrl);
1848
+ } catch {
1849
+ try {
1850
+ return realpathSync(entry) === realpathSync(own);
1851
+ } catch {
1852
+ return true;
1853
+ }
1854
+ }
1855
+ }
1856
+
1857
+ void invokedAsCommand(import.meta.url).then((command) => {
1858
+ if (command) process.exitCode = runAttwCli(process.argv.slice(2));
1859
+ });
package/catalog.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Types for `@cosyte/script-utils/catalog`: the catalog of Cosyte's libraries and Pathways
3
+ * (ST13, ST14), generated in cosyte/config from each package's package.json, and the check every
4
+ * library runs that its description is the catalog's.
5
+ */
6
+
7
+ /** The three tiers, in the order ST13 names them. */
8
+ export declare const TIERS: readonly ["standard", "built-on", "engine"];
9
+
10
+ /** What every record carries, generated from the package's package.json. */
11
+ export interface CatalogRecord {
12
+ /** The npm package name, for example `@cosyte/hl7`. */
13
+ readonly name: string;
14
+ /** The name without its scope, for example `hl7`. */
15
+ readonly slug: string;
16
+ /** The package.json description: the package's one canonical line. */
17
+ readonly description: string;
18
+ /** The release line, for example `0.1`. */
19
+ readonly line: string;
20
+ readonly engines: { readonly node: string };
21
+ /** `https://github.com/cosyte/<repo>`. */
22
+ readonly repository: string;
23
+ /** `https://docs.cosyte.com/<slug>`. */
24
+ readonly docs: string;
25
+ }
26
+
27
+ /** One of the 14 libraries. */
28
+ export interface CatalogLibrary extends CatalogRecord {
29
+ /** `https://www.npmjs.com/package/<name>`. */
30
+ readonly npm: string;
31
+ readonly tier: "standard" | "built-on";
32
+ /** The standard, as readers see it (`HL7 v2`, `C-CDA`), for a library of the standard tier. */
33
+ readonly standard?: string;
34
+ }
35
+
36
+ /** Pathways, our integration engine. */
37
+ export interface CatalogEngine extends CatalogRecord {
38
+ readonly tier: "engine";
39
+ /** The product name, `Pathways`. */
40
+ readonly displayName: string;
41
+ /** For example `in development`. */
42
+ readonly status: string;
43
+ /** Whether it is published to npm. */
44
+ readonly onNpm: boolean;
45
+ }
46
+
47
+ export interface Catalog {
48
+ /** The 8 standards' libraries, then the 6 built on them, in the order ST13 names them. */
49
+ readonly libraries: readonly CatalogLibrary[];
50
+ readonly engine: CatalogEngine;
51
+ }
52
+
53
+ /** The hand-kept tiers file, `catalog/tiers.json` in cosyte/config. */
54
+ export interface CatalogTiers {
55
+ standards: { package: string; standard: string }[];
56
+ builtOn: string[];
57
+ engine: { package: string; name: string; status: string; onNpm: boolean };
58
+ }
59
+
60
+ /** The catalog, as the `catalog.json` beside this module carries it. Deeply frozen. */
61
+ export declare const CATALOG: Catalog;
62
+
63
+ /** The release line a version is on: `0.<minor>` below 1.0.0, the major from 1.0.0 on. */
64
+ export declare function releaseLine(version: string): string;
65
+
66
+ /**
67
+ * Build the catalog from the tiers file and each package's parsed package.json, keyed by name.
68
+ *
69
+ * @throws {TypeError} When a tier names a package with no usable manifest, or names one twice.
70
+ */
71
+ export declare function buildCatalog(
72
+ tiers: CatalogTiers,
73
+ manifests: Map<string, Record<string, unknown>> | Record<string, Record<string, unknown>>,
74
+ ): Catalog;
75
+
76
+ /** The record for a package name: a library, the engine, or `undefined`. */
77
+ export declare function catalogEntry(
78
+ name: string,
79
+ catalog?: Catalog,
80
+ ): CatalogLibrary | CatalogEngine | undefined;
81
+
82
+ /** A field where a package.json and its catalog record disagree. */
83
+ export interface CatalogDifference {
84
+ field: "description";
85
+ catalog: string;
86
+ packageJson: string;
87
+ }
88
+
89
+ /**
90
+ * Compare a package.json with its catalog record. Only the description is compared.
91
+ *
92
+ * @throws {TypeError} When the catalog does not list the package.
93
+ */
94
+ export declare function compareWithCatalog(
95
+ manifest: Record<string, unknown>,
96
+ catalog?: Catalog,
97
+ ): CatalogDifference[];
98
+
99
+ export interface CatalogCheckOptions {
100
+ /** The package directory holding package.json. Default: the working directory. */
101
+ dir?: string;
102
+ /** CLI arguments; only `--dir <path>` is accepted. */
103
+ argv?: string[];
104
+ }
105
+
106
+ /**
107
+ * Run the check over one package directory and return the exit code: 0 the description is the
108
+ * catalog's, 1 it is not, 2 the check could not run. It never exits the process.
109
+ */
110
+ export declare function runCatalogCheck(options?: CatalogCheckOptions): 0 | 1 | 2;
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
+ }