@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/phi-scan.js CHANGED
@@ -39,10 +39,11 @@
39
39
  // repository reads its own `node_modules` copy of this file only if it is not gitignored, and a
40
40
  // diagnostic ABOUT a PHI leak is itself a PHI surface. Describe the shape; never write one down.
41
41
 
42
- import { readFileSync, statSync, lstatSync, existsSync, readdirSync } from "node:fs";
42
+ import { readFileSync, statSync, lstatSync, existsSync, readdirSync, realpathSync } from "node:fs";
43
43
  import { execFileSync } from "node:child_process";
44
44
  import { createHash } from "node:crypto";
45
- import { join, resolve, relative, sep, isAbsolute } from "node:path";
45
+ import { basename, join, resolve, relative, sep, isAbsolute } from "node:path";
46
+ import { fileURLToPath } from "node:url";
46
47
 
47
48
  /**
48
49
  * Every state in which the scan cannot account for something. Raised throughout, caught in `run`,
@@ -1135,10 +1136,15 @@ class PhiScan {
1135
1136
  buildTargetsForStaged() {
1136
1137
  let listBuf;
1137
1138
  try {
1138
- // SECURITY: array-form execFileSync, no shell.
1139
+ // SECURITY: array-form execFileSync, no shell. `--ignore-submodules=none` because
1140
+ // `diff.ignoreSubmodules` in a user's own git config would otherwise hide a staged gitlink
1141
+ // from this list, and the route would never refuse it (x12, terminology and astm pass it;
1142
+ // measured in x12's own suite).
1139
1143
  listBuf = execFileSync(
1140
1144
  "git",
1141
- ["diff", "--cached", "--raw", "-z", "--no-renames", "--diff-filter=d"],
1145
+ ["diff", "--cached", "--raw", "-z", "--no-renames", "--diff-filter=d"].concat(
1146
+ "--ignore-submodules=none",
1147
+ ),
1142
1148
  {
1143
1149
  cwd: this.cfg.repoRoot,
1144
1150
  encoding: "buffer",
@@ -1942,3 +1948,145 @@ function gitModeKind(mode) {
1942
1948
  if (mode === "160000") return "a gitlink (a nested repository)";
1943
1949
  return `a git mode-${mode} entry`;
1944
1950
  }
1951
+
1952
+ // ---- The command-line form (ST34) ---------------------------------------------------------------
1953
+ //
1954
+ // `node node_modules/@cosyte/script-utils/phi-scan.js [--staged | <path>...]` runs this engine over
1955
+ // the repository in the working directory with the house settings and no caller script: exit codes
1956
+ // 0 clean, 1 hits, 2 refuse; the whole repository as the one scan root; the `--staged` route reading
1957
+ // everything but Markdown (`exemptsMarkdown`); the allow-list at `scripts/phi-allow-list.txt` and the
1958
+ // override log at `phi-scan-overrides.md`, the engine's defaults; and what the repository leaves
1959
+ // out, in `scripts/phi-scan.exclude`.
1960
+ //
1961
+ // AN EXCLUSION IS ONE LITERAL TRACKED PATH, as `excludedPaths` requires: no pattern, no directory,
1962
+ // because a class quietly grows new members. One per line; a line whose first character is `#` is a
1963
+ // comment. An entry that names no tracked path is refused rather than ignored: an exclusion that has
1964
+ // outlived its file is a hole nobody is looking at.
1965
+ //
1966
+ // IT CARRIES THE CROSS-CUTTING FLOOR ONLY. A repository whose standard needs field-level detectors
1967
+ // keeps a caller passing `detect` until the per-standard detectors ship in this package.
1968
+
1969
+ /** Where the command-line form reads what a repository leaves out. */
1970
+ export const PHI_EXCLUDE_PATH = "scripts/phi-scan.exclude";
1971
+
1972
+ /** The exit codes of the command-line form. */
1973
+ const CLI_EXIT_CODES = Object.freeze({ clean: 0, hits: 1, refuse: 2 });
1974
+
1975
+ /**
1976
+ * Read an exclusions file against the repository's tracked paths.
1977
+ *
1978
+ * @param {string} text The file's contents.
1979
+ * @param {ReadonlySet<string>} tracked Every tracked path, repo-relative and forward-slashed.
1980
+ * @returns {{ paths: Set<string>, problems: string[] }}
1981
+ */
1982
+ export function readPhiExclusions(text, tracked) {
1983
+ const paths = new Set();
1984
+ const problems = [];
1985
+ text.split("\n").forEach((raw, index) => {
1986
+ const entry = raw.trim();
1987
+ if (entry === "" || entry.startsWith("#")) return;
1988
+ const where = `line ${index + 1}: ${JSON.stringify(entry)}`;
1989
+ if (entry.endsWith("/") || /[*?[\]]/.test(entry)) {
1990
+ problems.push(`${where} is a directory or a pattern; an exclusion is one literal path.`);
1991
+ } else if (paths.has(entry)) {
1992
+ problems.push(`${where} is listed twice.`);
1993
+ } else if (!tracked.has(entry)) {
1994
+ problems.push(`${where} is not a tracked path, so it excludes nothing.`);
1995
+ } else {
1996
+ paths.add(entry);
1997
+ }
1998
+ });
1999
+ return { paths, problems };
2000
+ }
2001
+
2002
+ /**
2003
+ * Run the engine from the command line over the repository in the working directory.
2004
+ *
2005
+ * @param {string[]} argv The engine's own arguments: `--staged`, paths, `--allow-fixture <path>`.
2006
+ * @returns {number} 0 clean, 1 hits, 2 the scan could not account for something.
2007
+ */
2008
+ export function runPhiScanCli(argv) {
2009
+ const repoRoot = process.cwd();
2010
+ let excludedPaths = new Set();
2011
+ const file = join(repoRoot, ...PHI_EXCLUDE_PATH.split("/"));
2012
+ if (existsSync(file)) {
2013
+ let read;
2014
+ try {
2015
+ const tracked = new Set(
2016
+ execFileSync("git", ["ls-files", "-z"], {
2017
+ cwd: repoRoot,
2018
+ encoding: "utf8",
2019
+ stdio: ["ignore", "pipe", "pipe"],
2020
+ maxBuffer: 256 * 1024 * 1024,
2021
+ })
2022
+ .split("\0")
2023
+ .filter((path) => path !== ""),
2024
+ );
2025
+ read = readPhiExclusions(readFileSync(file, "utf8"), tracked);
2026
+ } catch (err) {
2027
+ process.stderr.write(
2028
+ `[phi-scan] could not read ${PHI_EXCLUDE_PATH}: ${err instanceof Error ? err.message : String(err)}\n`,
2029
+ );
2030
+ return CLI_EXIT_CODES.refuse;
2031
+ }
2032
+ if (read.problems.length > 0) {
2033
+ process.stderr.write(
2034
+ `[phi-scan] ${PHI_EXCLUDE_PATH} is not usable, so nothing was scanned:\n` +
2035
+ read.problems.map((problem) => ` ${problem}\n`).join(""),
2036
+ );
2037
+ return CLI_EXIT_CODES.refuse;
2038
+ }
2039
+ excludedPaths = read.paths;
2040
+ }
2041
+ try {
2042
+ return runPhiScan({
2043
+ exitCodes: CLI_EXIT_CODES,
2044
+ scanRoots: ["."],
2045
+ isStagedReadable: exemptsMarkdown,
2046
+ excludedPaths,
2047
+ repoRoot,
2048
+ argv,
2049
+ });
2050
+ } catch (err) {
2051
+ // The backstop: an error the engine did not raise as a refusal must still not take node's own
2052
+ // exit 1, which this contract reserves for hits.
2053
+ process.stderr.write(
2054
+ `[phi-scan] the scan failed and did not complete: ${err instanceof Error ? err.message : String(err)}\n`,
2055
+ );
2056
+ return CLI_EXIT_CODES.refuse;
2057
+ }
2058
+ }
2059
+
2060
+ /**
2061
+ * Whether node was pointed at this file, which is the command-line form.
2062
+ *
2063
+ * SELF-CONTAINED ON PURPOSE: this module is also vendored and copied as a single file, so it may not
2064
+ * import a sibling at load. A file whose name differs from the one node was pointed at cannot be the
2065
+ * entry point, and that is settled without loading anything; only a matching name asks `index.js`'s
2066
+ * `isCliEntrypoint`, and a copy with no `index.js` beside it compares real paths instead. Unsure is
2067
+ * yes, so a gate that cannot tell runs loudly rather than skipping.
2068
+ *
2069
+ * @param {string} moduleUrl This module's `import.meta.url`.
2070
+ * @returns {Promise<boolean>}
2071
+ */
2072
+ async function invokedAsCommand(moduleUrl) {
2073
+ const entry = process.argv[1];
2074
+ if (typeof entry !== "string" || entry === "") return false;
2075
+ const own = fileURLToPath(moduleUrl);
2076
+ const stem = (path) => basename(path).replace(/\.[cm]?[jt]s$/, "");
2077
+ if (stem(entry) !== stem(own)) return false;
2078
+ try {
2079
+ const { isCliEntrypoint } = await import("./index.js");
2080
+ return isCliEntrypoint(moduleUrl);
2081
+ } catch {
2082
+ try {
2083
+ return realpathSync(entry) === realpathSync(own);
2084
+ } catch {
2085
+ return true;
2086
+ }
2087
+ }
2088
+ }
2089
+
2090
+ void invokedAsCommand(import.meta.url).then((command) => {
2091
+ if (command) process.exitCode = runPhiScanCli(process.argv.slice(2));
2092
+ });
package/readme.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Types for `@cosyte/script-utils/readme`: the README gate for every `@cosyte/*` library, which
3
+ * checks the house skeleton and a size limit under npm's.
4
+ */
5
+
6
+ /** The tile's alt text, as assets `alt-text.json` declares it for the light tile. */
7
+ export declare const TILE_ALT: string;
8
+
9
+ /** The shared lockup tile every library README opens with, byte for byte. */
10
+ export declare const TILE: string;
11
+
12
+ /** npm's own cut: the registry keeps this many characters of a README. */
13
+ export declare const NPM_README_LIMIT: 65536;
14
+
15
+ /** This gate's limit, in characters (UTF-16 code units). */
16
+ export declare const README_CHARACTER_LIMIT: 60000;
17
+
18
+ /** The longest tagline the gate accepts, in characters. */
19
+ export declare const TAGLINE_LIMIT: 120;
20
+
21
+ /** The `##` sections every library README carries, in order, with License last. */
22
+ export declare const REQUIRED_SECTIONS: readonly string[];
23
+
24
+ /** A rule id a finding can carry. */
25
+ export type ReadmeRule =
26
+ | "tile"
27
+ | "tile-alt"
28
+ | "title"
29
+ | "tagline"
30
+ | "badges"
31
+ | "section-missing"
32
+ | "section-order"
33
+ | "section-empty"
34
+ | "docs-link"
35
+ | "license-last"
36
+ | "license-link"
37
+ | "size";
38
+
39
+ /** Every rule id, with what it requires. */
40
+ export declare const README_RULES: Readonly<Record<ReadmeRule, string>>;
41
+
42
+ /** One place a README departs from the skeleton. */
43
+ export interface ReadmeFinding {
44
+ rule: ReadmeRule;
45
+ /** 1-based line, or 0 when the finding is about the whole file. */
46
+ line: number;
47
+ message: string;
48
+ }
49
+
50
+ /**
51
+ * The badge row a library README carries (npm version, CI, license, Node), one badge per line,
52
+ * derived from its package.json.
53
+ *
54
+ * @throws {TypeError} When the manifest has no `@cosyte/*` name, no github.com/cosyte repository
55
+ * URL, or no `engines.node`.
56
+ */
57
+ export declare function badgeRow(manifest: Record<string, unknown>): string[];
58
+
59
+ /**
60
+ * Check a library README against the house skeleton. Returns no findings when it follows it.
61
+ *
62
+ * @throws {TypeError} When the manifest cannot supply what the skeleton is derived from.
63
+ */
64
+ export declare function checkReadme(
65
+ text: string,
66
+ manifest: Record<string, unknown>,
67
+ ): ReadmeFinding[];
68
+
69
+ export interface ReadmeGateOptions {
70
+ /** The package directory holding README.md and package.json. Default: the working directory. */
71
+ dir?: string;
72
+ /** CLI arguments; only `--dir <path>` is accepted. */
73
+ argv?: string[];
74
+ }
75
+
76
+ /**
77
+ * Run the gate over one package directory and return the exit code: 0 the README follows the
78
+ * skeleton, 1 at least one finding, 2 the input could not be read. It reports on stdout or stderr
79
+ * and never exits the process.
80
+ */
81
+ export declare function runReadmeGate(options?: ReadmeGateOptions): 0 | 1 | 2;