@pareto4/pp-core 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * verify-rename-guard.cjs — REBR-04 rename-integrity ratchet guard.
6
+ *
7
+ * The gsd → pp rename is functionally finished (Phase 15/16/17); this guard
8
+ * is a CI ratchet that stops it from regressing — it is explicitly NOT a
9
+ * cosmetic sweep of the ~1,186 files that still legitimately contain `gsd`
10
+ * (runtime contracts, historical corpora, migration provenance, etc.).
11
+ *
12
+ * Design (D-01..D-11, .planning/phases/18-rename-integrity-guard/18-CONTEXT.md):
13
+ * D-01 Baseline ratchet: a committed baseline captures existing gsd hits;
14
+ * CI fails only on a NEW (novel) hit, never on the existing debt.
15
+ * D-02 Line-fingerprint precision: baseline keys on (file, normalized line
16
+ * content), not (file, line NUMBER) — see Pitfall 1 below.
17
+ * D-03 Case-insensitive substring match: RENAME_RE = /gsd/i, deliberately
18
+ * broad; carve-outs and keep-tokens do the narrowing.
19
+ * D-04 Growth-only enforcement: shrinking/removing a baselined gsd
20
+ * reference NEVER fails the guard — it is a harmless upper bound.
21
+ * D-07 Whole tracked tree: scans every git-tracked text file (skip
22
+ * binaries), not a curated subset.
23
+ * D-09 Single-source allowlist authority: tests/fixtures/gsd-keep-tokens.cjs
24
+ * (isFrozen/isAttribution for whole-file carve-outs, isKeepTokenLine
25
+ * for per-line exemptions). Never fork a parallel token list here.
26
+ * D-10 Whole-file/dir carve-outs (installer-migrations, .changeset/archived,
27
+ * the fixture itself) live in gsd-keep-tokens.cjs's FROZEN_PATHS.
28
+ *
29
+ * This plan (18-03) does NOT generate/commit the real-tree baseline — that is
30
+ * plan 04 (wave 2), once the tree reflects REBR-05's functional fixes. A
31
+ * missing baseline file here is treated as an EMPTY baseline, which lets the
32
+ * subprocess test wrapper fully control the baseline via env-var redirection.
33
+ *
34
+ * Exit codes:
35
+ * 0 — no novel gsd reference found (or --update-baseline succeeded)
36
+ * 1 — one or more novel gsd references found outside the baseline
37
+ *
38
+ * Usage:
39
+ * node scripts/verify-rename-guard.cjs
40
+ * node scripts/verify-rename-guard.cjs --update-baseline
41
+ *
42
+ * Env overrides (test-only, mirrors GSD_LINT_LEGACY_REPO_ROOT in
43
+ * scripts/lint-legacy-dir-name.cjs):
44
+ * RENAME_GUARD_REPO_ROOT — redirect the whole-tree scan to a temp git repo
45
+ * RENAME_GUARD_BASELINE_PATH — redirect the baseline file to a temp path
46
+ *
47
+ * Exports:
48
+ * runScan(opts?) → { byFile, hitList, baseline, novel, fileCount, repoRoot, baselinePath }
49
+ */
50
+
51
+ const { execFileSync } = require('node:child_process');
52
+ const fs = require('node:fs');
53
+ const path = require('node:path');
54
+
55
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
56
+ const keepTokens = require('../tests/fixtures/gsd-keep-tokens.cjs');
57
+
58
+ // D-03: deliberately broad case-insensitive substring match — carve-outs and
59
+ // keep-tokens (not a narrower pattern) do the exemption work.
60
+ const RENAME_RE = /gsd/i;
61
+
62
+ // RENAME_GUARD_REPO_ROOT lets tests redirect the guard to a temporary git repo
63
+ // without touching the real working tree (mirrors GSD_LINT_LEGACY_REPO_ROOT).
64
+ const REPO_ROOT = process.env.RENAME_GUARD_REPO_ROOT
65
+ ? path.resolve(process.env.RENAME_GUARD_REPO_ROOT)
66
+ : path.resolve(__dirname, '..');
67
+
68
+ // RENAME_GUARD_BASELINE_PATH lets tests redirect the baseline file to a
69
+ // temporary path. The real default lives under tests/ but is NOT committed
70
+ // by this plan (18-03) — plan 04 generates and commits it against the final
71
+ // post-REBR-05 tree. A missing file is treated as an empty baseline.
72
+ const DEFAULT_BASELINE_PATH = path.join(__dirname, '..', 'tests', 'rename-guard-baseline.json');
73
+ const BASELINE_PATH = process.env.RENAME_GUARD_BASELINE_PATH
74
+ ? path.resolve(process.env.RENAME_GUARD_BASELINE_PATH)
75
+ : DEFAULT_BASELINE_PATH;
76
+
77
+ // A NUL byte between the relative path and the normalized line text — cannot
78
+ // occur in either half of a text-file scan, so it is a safe composite-key
79
+ // separator for the Set-diff below.
80
+ const KEY_SEP = '\u0000';
81
+
82
+ /**
83
+ * isBinary(fullPath) — NUL-byte probe on the first 512 bytes.
84
+ * Copied verbatim from scripts/lint-legacy-dir-name.cjs's isBinary().
85
+ *
86
+ * @param {string} fullPath absolute path to probe
87
+ * @returns {boolean}
88
+ */
89
+ function isBinary(fullPath) {
90
+ let fd;
91
+ try {
92
+ fd = fs.openSync(fullPath, 'r');
93
+ const buf = Buffer.allocUnsafe(512);
94
+ const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
95
+ return buf.subarray(0, bytesRead).includes(0);
96
+ } catch {
97
+ return false;
98
+ } finally {
99
+ if (fd != null) {
100
+ try { fs.closeSync(fd); } catch { /* best-effort */ }
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * normalizeLine(line) — content-key normalization (D-02, Pitfall 4).
107
+ *
108
+ * `String.prototype.trimEnd()` strips trailing whitespace AND trailing line
109
+ * terminators (including a lone `\r` left behind when a CRLF file is split
110
+ * on `\n`), so a single trimEnd() call both trims trailing whitespace and
111
+ * neutralizes the CRLF/LF cross-platform baseline-desync pitfall documented
112
+ * in tests/workflow-size-budget.test.cjs's #683 regression.
113
+ *
114
+ * @param {string} line
115
+ * @returns {string}
116
+ */
117
+ function normalizeLine(line) {
118
+ return line.trimEnd();
119
+ }
120
+
121
+ /**
122
+ * collectHits(repoRoot) — enumerate the whole tracked tree and collect every
123
+ * RENAME_RE hit not exempted by a whole-file carve-out (isFrozen/isAttribution)
124
+ * or a per-line keep-token (isKeepTokenLine).
125
+ *
126
+ * @param {string} repoRoot absolute path to scan (git ls-files cwd)
127
+ * @returns {{
128
+ * byFile: Object<string, Set<string>>,
129
+ * hitList: Array<{file: string, line: number, text: string}>,
130
+ * fileCount: number,
131
+ * }}
132
+ */
133
+ function collectHits(repoRoot) {
134
+ let trackedFiles;
135
+ try {
136
+ trackedFiles = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' })
137
+ .split('\n')
138
+ .map((f) => f.trim())
139
+ .filter(Boolean);
140
+ } catch (err) {
141
+ throw new ExitError(1, 'ERROR verify-rename-guard: git ls-files failed: ' + err.message);
142
+ }
143
+
144
+ /** @type {Object<string, Set<string>>} */
145
+ const byFile = {};
146
+ /** @type {Array<{file: string, line: number, text: string}>} */
147
+ const hitList = [];
148
+
149
+ for (const relPath of trackedFiles) {
150
+ const fullPath = path.join(repoRoot, relPath);
151
+
152
+ // D-09/D-10 whole-file/dir carve-out layer — single-source authority.
153
+ if (keepTokens.isFrozen(fullPath) || keepTokens.isAttribution(fullPath)) continue;
154
+
155
+ if (isBinary(fullPath)) continue;
156
+
157
+ let content;
158
+ try {
159
+ content = fs.readFileSync(fullPath, 'utf8');
160
+ } catch {
161
+ // Unreadable file (permissions, race with deletion, etc.) — skip silently.
162
+ continue;
163
+ }
164
+
165
+ const lines = content.split('\n');
166
+ for (let i = 0; i < lines.length; i++) {
167
+ const raw = lines[i];
168
+ if (!RENAME_RE.test(raw)) continue;
169
+
170
+ // D-09 per-line exemption — single-source authority.
171
+ if (keepTokens.isKeepTokenLine(raw)) continue;
172
+
173
+ const normalized = normalizeLine(raw);
174
+ if (!byFile[relPath]) byFile[relPath] = new Set();
175
+ // Pitfall 3 (accepted limitation): two identical-content gsd lines in the
176
+ // same file collapse to one Set entry — occurrence count is not tracked.
177
+ // This is an intentional D-02 trade-off, not a bug — do not "fix" it into
178
+ // a fragile per-occurrence counter.
179
+ byFile[relPath].add(normalized);
180
+ hitList.push({ file: relPath, line: i + 1, text: normalized });
181
+ }
182
+ }
183
+
184
+ return { byFile, hitList, fileCount: trackedFiles.length };
185
+ }
186
+
187
+ /**
188
+ * loadBaseline(baselinePath) — read the committed baseline JSON.
189
+ * A missing or unreadable/invalid file is treated as an EMPTY baseline (this
190
+ * plan does not commit a real-tree baseline; plan 04 does).
191
+ *
192
+ * @param {string} baselinePath
193
+ * @returns {Object<string, string[]>}
194
+ */
195
+ function loadBaseline(baselinePath) {
196
+ try {
197
+ const raw = fs.readFileSync(baselinePath, 'utf8');
198
+ return JSON.parse(raw);
199
+ } catch {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ /**
205
+ * serializeBaseline(byFile) — stable sorted serializer for --update-baseline.
206
+ * Mirrors scripts/update-size-baseline.cjs's serializeBaseline() shape:
207
+ * sorted file keys, sorted per-file line arrays, 2-space indent, trailing \n.
208
+ *
209
+ * @param {Object<string, Set<string>|string[]>} byFile
210
+ * @returns {string}
211
+ */
212
+ function serializeBaseline(byFile) {
213
+ const sorted = {};
214
+ for (const key of Object.keys(byFile).sort()) {
215
+ sorted[key] = [...byFile[key]].sort();
216
+ }
217
+ return JSON.stringify(sorted, null, 2) + '\n';
218
+ }
219
+
220
+ /**
221
+ * computeNovel(byFile, baseline) — D-04 growth-only Set-diff.
222
+ *
223
+ * Returns ONLY current-minus-known (novel) composite keys — NEVER
224
+ * known-minus-current (stale/shrink), because shrinking or removing a
225
+ * baselined gsd reference is always safe (D-04). This deliberately does NOT
226
+ * reuse scripts/lib/allowlist-ratchet.cjs's assertWithinAllowlist() unmodified,
227
+ * since that helper also fails on stale entries (see Pitfall 2 in RESEARCH.md).
228
+ *
229
+ * @param {Object<string, Set<string>>} byFile current scan result
230
+ * @param {Object<string, string[]>} baseline loaded baseline
231
+ * @returns {string[]} sorted array of "relPath normalizedLine" keys
232
+ */
233
+ function computeNovel(byFile, baseline) {
234
+ const currentKeys = new Set();
235
+ for (const [file, lines] of Object.entries(byFile)) {
236
+ for (const line of lines) currentKeys.add(file + KEY_SEP + line);
237
+ }
238
+
239
+ const knownKeys = new Set();
240
+ for (const [file, lines] of Object.entries(baseline)) {
241
+ for (const line of lines) knownKeys.add(file + KEY_SEP + line);
242
+ }
243
+
244
+ return [...currentKeys].filter((k) => !knownKeys.has(k)).sort();
245
+ }
246
+
247
+ /**
248
+ * runScan(opts?) — pure scan entry point (no process.exit, no stdout/stderr
249
+ * writes). Consumed by the CLI wrapper (cliMain) and directly by tests.
250
+ *
251
+ * @param {object} [opts]
252
+ * @param {string} [opts.repoRoot] defaults to the module-level REPO_ROOT
253
+ * @param {string} [opts.baselinePath] defaults to the module-level BASELINE_PATH
254
+ * @returns {{
255
+ * byFile: Object<string, Set<string>>,
256
+ * hitList: Array<{file: string, line: number, text: string}>,
257
+ * baseline: Object<string, string[]>,
258
+ * novel: string[],
259
+ * fileCount: number,
260
+ * repoRoot: string,
261
+ * baselinePath: string,
262
+ * }}
263
+ */
264
+ function runScan(opts) {
265
+ const repoRoot = (opts && opts.repoRoot) || REPO_ROOT;
266
+ const baselinePath = (opts && opts.baselinePath) || BASELINE_PATH;
267
+
268
+ const { byFile, hitList, fileCount } = collectHits(repoRoot);
269
+ const baseline = loadBaseline(baselinePath);
270
+ const novel = computeNovel(byFile, baseline);
271
+
272
+ return { byFile, hitList, baseline, novel, fileCount, repoRoot, baselinePath };
273
+ }
274
+
275
+ /**
276
+ * splitKey(key) — inverse of the KEY_SEP composite key used by computeNovel().
277
+ * @param {string} key
278
+ * @returns {{file: string, text: string}}
279
+ */
280
+ function splitKey(key) {
281
+ const idx = key.indexOf(KEY_SEP);
282
+ return { file: key.slice(0, idx), text: key.slice(idx + 1) };
283
+ }
284
+
285
+ // ── CLI entry ────────────────────────────────────────────────────────────────
286
+
287
+ function cliMain() {
288
+ if (process.argv.includes('--update-baseline')) {
289
+ const { byFile, fileCount } = collectHits(REPO_ROOT);
290
+ fs.writeFileSync(BASELINE_PATH, serializeBaseline(byFile));
291
+ process.stdout.write(
292
+ 'ok verify-rename-guard: baseline updated at ' +
293
+ path.relative(REPO_ROOT, BASELINE_PATH) +
294
+ ' (' + fileCount + ' tracked file(s) scanned, ' +
295
+ Object.keys(byFile).length + ' file(s) with gsd reference(s))\n'
296
+ );
297
+ return 0;
298
+ }
299
+
300
+ const { novel, hitList, fileCount } = runScan();
301
+
302
+ if (novel.length === 0) {
303
+ process.stdout.write(
304
+ 'ok verify-rename-guard: ' + fileCount + ' tracked file(s) checked, 0 novel gsd reference(s)\n'
305
+ );
306
+ return 0;
307
+ }
308
+
309
+ process.stderr.write(
310
+ '\nERROR verify-rename-guard: ' + novel.length +
311
+ ' novel gsd reference(s) found (not in the committed baseline)\n\n'
312
+ );
313
+ for (const key of novel) {
314
+ const { file, text } = splitKey(key);
315
+ const hit = hitList.find((h) => h.file === file && h.text === text);
316
+ const lineNo = hit ? hit.line : '?';
317
+ process.stderr.write(' ' + file + ':' + lineNo + ' — ' + JSON.stringify(text) + '\n');
318
+ }
319
+ process.stderr.write('\n');
320
+ process.stderr.write(
321
+ 'Fix at the source, add the `gsd-allow-legacy-name` marker on the line if the\n' +
322
+ 'use is intentional, or run `node scripts/verify-rename-guard.cjs --update-baseline`\n' +
323
+ 'if the reference is legitimately grandfathered.\n\n'
324
+ );
325
+
326
+ throw new ExitError(1);
327
+ }
328
+
329
+ module.exports = { runScan, RENAME_RE, serializeBaseline, collectHits, loadBaseline, computeNovel };
330
+
331
+ if (require.main === module) {
332
+ runMain(cliMain);
333
+ }