@cosyte/script-utils 0.1.2 → 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/CHANGELOG.md +24 -12
- package/LICENSE +1 -1
- package/README.md +192 -12
- package/agent-notes.d.ts +92 -0
- package/agent-notes.js +512 -0
- package/attw.d.ts +18 -0
- package/attw.js +188 -8
- package/catalog.d.ts +110 -0
- package/catalog.js +251 -0
- package/catalog.json +210 -0
- package/claude-card.d.ts +64 -0
- package/claude-card.js +296 -0
- package/internal-refs.d.ts +12 -2
- package/internal-refs.js +131 -6
- package/package.json +27 -1
- package/phi-scan.d.ts +22 -0
- package/phi-scan.js +152 -4
- package/readme.d.ts +81 -0
- package/readme.js +489 -0
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,
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
|
1036
|
-
// root describes several
|
|
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 = (
|
|
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
|
+
}
|