@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -0
- package/bin/install.js +9 -1
- package/gemini-extension.json +1 -1
- package/package.json +6 -3
- package/pareto-skills/pp-project-setup/SKILL.md +32 -440
- package/pp-core/bin/lib/capability-state.cjs +13 -7
- package/pp-core/bin/lib/config.cjs +7 -22
- package/pp-core/bin/lib/loop-resolver.cjs +6 -0
- package/pp-core/bin/lib/profile-output.cjs +4 -2
- package/pp-core/bin/shared/config-schema.manifest.json +4 -0
- package/pp-core/workflows/discovery-sprint.md +409 -16
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +250 -0
- package/pp-core/workflows/discovery-to-build.md +218 -48
- package/pp-core/workflows/discuss-phase.md +1 -1
- package/pp-core/workflows/execute-phase.md +1 -1
- package/pp-core/workflows/plan-phase.md +1 -1
- package/scripts/changeset/lint.cjs +20 -5
- package/scripts/lint-docs-required.cjs +10 -3
- package/scripts/release-channel.cjs +58 -0
- package/scripts/release-tarball-smoke.cjs +4 -0
- package/scripts/verify-opengsd-sweep.cjs +12 -0
- package/scripts/verify-rename-guard.cjs +333 -0
|
@@ -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
|
+
}
|