pi-harness-runtime 1.1.435 → 1.1.437

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.
Files changed (122) hide show
  1. package/harness/key-file-audit.ts +103 -3
  2. package/harness/startup-report.ts +32 -1
  3. package/index.ts +28 -0
  4. package/package.json +2 -1
  5. package/packages/a2a-adapter/package.json +1 -1
  6. package/packages/agent-policy/package.json +1 -1
  7. package/packages/architecture-generator/package.json +1 -1
  8. package/packages/auth/package.json +1 -1
  9. package/packages/autonomous-refactor/package.json +1 -1
  10. package/packages/autonomous-runtime/package.json +1 -1
  11. package/packages/cache-strategy/package.json +1 -1
  12. package/packages/capability-registry/package.json +1 -1
  13. package/packages/checkpoint/package.json +1 -1
  14. package/packages/cli-plugin-sdk/package.json +1 -1
  15. package/packages/clipboard/package.json +1 -1
  16. package/packages/clipboard-plugin/package.json +1 -1
  17. package/packages/code-generation/package.json +1 -1
  18. package/packages/code-review/package.json +1 -1
  19. package/packages/codex-adapter/package.json +1 -1
  20. package/packages/codex-watcher/package.json +1 -1
  21. package/packages/config-capture/package.json +1 -1
  22. package/packages/connection-runtime/package.json +1 -1
  23. package/packages/context-compiler/package.json +1 -1
  24. package/packages/context-discovery/package.json +1 -1
  25. package/packages/context-manager/package.json +1 -1
  26. package/packages/contracts/package.json +1 -1
  27. package/packages/cookie-sanitizer/package.json +1 -1
  28. package/packages/cost-optimizer/package.json +1 -1
  29. package/packages/dependency-analyzer/package.json +1 -1
  30. package/packages/django-plugin/package.json +1 -1
  31. package/packages/doc-generator/package.json +1 -1
  32. package/packages/durable-review/package.json +1 -1
  33. package/packages/e2e-tester/package.json +1 -1
  34. package/packages/evaluation-engine/package.json +1 -1
  35. package/packages/evaluation-runner/package.json +1 -1
  36. package/packages/event-bus/package.json +1 -1
  37. package/packages/event-sink/package.json +1 -1
  38. package/packages/event-store/package.json +1 -1
  39. package/packages/experience-replay/package.json +1 -1
  40. package/packages/feedback-collector/package.json +1 -1
  41. package/packages/file-copy-helper/package.json +1 -1
  42. package/packages/framework-detector/package.json +1 -1
  43. package/packages/framework-plugin-sdk/package.json +1 -1
  44. package/packages/frappe-plugin/package.json +1 -1
  45. package/packages/generic-web-plugin/package.json +1 -1
  46. package/packages/health-monitor/package.json +1 -1
  47. package/packages/intent-analyzer/package.json +1 -1
  48. package/packages/jev-judge/package.json +1 -1
  49. package/packages/knowledge-graph/package.json +1 -1
  50. package/packages/knowledge-retrieval/package.json +1 -1
  51. package/packages/laravel-plugin/package.json +1 -1
  52. package/packages/learning-engine/package.json +1 -1
  53. package/packages/logging/package.json +1 -1
  54. package/packages/mcp-adapter/package.json +1 -1
  55. package/packages/memory-engine/package.json +1 -1
  56. package/packages/milestone-manager/package.json +1 -1
  57. package/packages/model-registry/package.json +1 -1
  58. package/packages/nextjs-plugin/package.json +1 -1
  59. package/packages/notification/package.json +1 -1
  60. package/packages/observability/package.json +1 -1
  61. package/packages/okf-indexer/package.json +1 -1
  62. package/packages/paperclip-integration/package.json +1 -1
  63. package/packages/performance-optimizer/package.json +1 -1
  64. package/packages/permission-mode/package.json +1 -1
  65. package/packages/privilege-broker/package.json +1 -1
  66. package/packages/project-analyzer/package.json +1 -1
  67. package/packages/project-bootstrap/package.json +1 -1
  68. package/packages/projection-engine/package.json +1 -1
  69. package/packages/prompt-compiler/package.json +1 -1
  70. package/packages/prompt-versioning/package.json +1 -1
  71. package/packages/provider-adapter-sdk/package.json +1 -1
  72. package/packages/provider-router/package.json +1 -1
  73. package/packages/provider-selector/package.json +1 -1
  74. package/packages/providers/package.json +1 -1
  75. package/packages/qdrant-skills/package.json +1 -1
  76. package/packages/quota-manager/package.json +1 -1
  77. package/packages/rate-limiter/package.json +1 -1
  78. package/packages/react-vite-plugin/package.json +1 -1
  79. package/packages/relay/package.json +1 -1
  80. package/packages/release-manager/package.json +1 -1
  81. package/packages/requirement-compiler/package.json +1 -1
  82. package/packages/runtime/package.json +1 -1
  83. package/packages/scheduler/package.json +1 -1
  84. package/packages/scheduler-adapter/package.json +1 -1
  85. package/packages/session/package.json +1 -1
  86. package/packages/session-api/package.json +1 -1
  87. package/packages/session-export/package.json +1 -1
  88. package/packages/shared-context/package.json +1 -1
  89. package/packages/skill-mcp-client/package.json +1 -1
  90. package/packages/skill-registry/package.json +1 -1
  91. package/packages/skills/package.json +1 -1
  92. package/packages/sprint-planner/package.json +1 -1
  93. package/packages/subscription-engine/package.json +1 -1
  94. package/packages/task-compiler/package.json +1 -1
  95. package/packages/tencentdb-memory/package.json +1 -1
  96. package/packages/tencentdb-sync/package.json +1 -1
  97. package/packages/test-data-generator/package.json +1 -1
  98. package/packages/test-generator/package.json +1 -1
  99. package/packages/todo-bd-sync/package.json +1 -1
  100. package/packages/token-estimation/package.json +1 -1
  101. package/packages/token-optimizer/package.json +1 -1
  102. package/packages/tooling-preference/package.json +1 -1
  103. package/packages/trajectory/package.json +1 -1
  104. package/packages/tui/package.json +1 -1
  105. package/packages/two-phase-review/package.json +1 -1
  106. package/packages/types/package.json +1 -1
  107. package/packages/voice-api/package.json +1 -1
  108. package/packages/webmcp-tester/package.json +1 -1
  109. package/packages/wiki/package.json +1 -1
  110. package/packages/workflow-events/package.json +1 -1
  111. package/packages/workspace-scanner/package.json +1 -1
  112. package/packages/worktree/package.json +1 -1
  113. package/packages/write-review/package.json +1 -1
  114. package/scripts/check-sensitive-terms.d.mts +116 -0
  115. package/scripts/check-sensitive-terms.mjs +764 -0
  116. package/scripts/guard-scan.sh +102 -0
  117. package/scripts/release/adapters/node-workspace.ts +1 -3
  118. package/scripts/release/orchestrator.ts +11 -5
  119. package/scripts/release/tsconfig.json +11 -0
  120. package/scripts/typecheck-packages.mjs +15 -2
  121. package/scripts/verify-package-contents.ts +69 -1
  122. package/scripts/tsconfig.json +0 -9
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The check that keeps a client name out of a pull request.
4
+ *
5
+ * The repository is public, so a name that reaches a pull request has
6
+ * reached the world. GitHub's free secret scanning finds credential
7
+ * patterns; a client name is a custom text pattern and is not part of
8
+ * that detection. So the names live in a repository secret, and this
9
+ * script compares them with what a pull request adds.
10
+ *
11
+ * The comparison is a plain, case-insensitive fragment - not a word
12
+ * boundary. A name appears inside identifiers and inside file names, so
13
+ * a word boundary would miss exactly the cases that matter. The same
14
+ * rule is used by the skill sanitizer.
15
+ *
16
+ * Two properties matter more than the matching:
17
+ *
18
+ * 1. The script never prints a term and never prints the line that
19
+ * matched. The log of a workflow run is public, and the log is
20
+ * where a term would leak a second time. A hit is reported as a
21
+ * path and a line number - both are already public in the pull
22
+ * request itself.
23
+ * 2. The script fails closed. An empty term list is not "nothing
24
+ * found", it is "nothing was checked", and the exit code says so.
25
+ *
26
+ * The exit codes:
27
+ *
28
+ * 0 nothing was found
29
+ * 1 a term was found
30
+ * 2 the check could not run - no terms, no pull request, no registry
31
+ *
32
+ * The usage:
33
+ *
34
+ * node scripts/check-sensitive-terms.mjs --pr 42
35
+ * node scripts/check-sensitive-terms.mjs --diff changes.patch
36
+ * node scripts/check-sensitive-terms.mjs --file README.md
37
+ * node scripts/check-sensitive-terms.mjs --text "a name"
38
+ * node scripts/check-sensitive-terms.mjs --tree path/to/the/published/files
39
+ * node scripts/check-sensitive-terms.mjs --commits v1.0.0..HEAD
40
+ *
41
+ * The terms come from, in this order:
42
+ *
43
+ * --terms-file <path> a file with one term per line
44
+ * SENSITIVE_TERMS the environment (the repository secret)
45
+ * PI_SENSITIVE_TERMS_FILE the environment (a local file)
46
+ *
47
+ * The script is written so that it can be tested: the matching, the
48
+ * diff parsing and the reporting are exported, and the command line
49
+ * only wires them together.
50
+ */
51
+
52
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
53
+ import { execFileSync } from "node:child_process";
54
+ import { join, relative } from "node:path";
55
+
56
+ /**
57
+ * The exit codes of the check.
58
+ */
59
+ export const EXIT_CLEAN = 0;
60
+ export const EXIT_FOUND = 1;
61
+ export const EXIT_UNAVAILABLE = 2;
62
+
63
+ /**
64
+ * The terms of a list.
65
+ *
66
+ * A list is one term per line. An empty line is not a term, and a line
67
+ * that starts with a hash is a comment, so the list can be grouped
68
+ * without a second file.
69
+ */
70
+ export function parseTerms(text) {
71
+ const terms = [];
72
+ const seen = new Set();
73
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
74
+ const term = raw.trim();
75
+ if (!term) continue;
76
+ if (term.startsWith("#")) continue;
77
+ const key = term.toLowerCase();
78
+ if (seen.has(key)) continue;
79
+ seen.add(key);
80
+ terms.push(term);
81
+ }
82
+ return terms;
83
+ }
84
+
85
+ /**
86
+ * The terms of a file.
87
+ *
88
+ * A file that cannot be read is no terms - and the caller has to notice
89
+ * that, because a missing list and an empty list mean the same thing
90
+ * here: nothing was checked.
91
+ */
92
+ export function readTermsFile(path) {
93
+ if (!path || !existsSync(path)) return [];
94
+ try {
95
+ return parseTerms(readFileSync(path, "utf8"));
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The repository, when it is not given.
103
+ *
104
+ * The workflow always has GITHUB_REPOSITORY. A local run in a checkout
105
+ * does not, and the origin remote names the same thing.
106
+ */
107
+ export function repositoryFromRemote(cwd = process.cwd()) {
108
+ try {
109
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
110
+ cwd,
111
+ encoding: "utf8",
112
+ stdio: ["ignore", "pipe", "ignore"],
113
+ }).trim();
114
+ const match = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
115
+ if (!match) return null;
116
+ return `${match[1]}/${match[2]}`;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The terms of the environment.
124
+ *
125
+ * The repository secret arrives as SENSITIVE_TERMS. The local file
126
+ * arrives as PI_SENSITIVE_TERMS_FILE.
127
+ */
128
+ export function termsFromEnv(env = process.env) {
129
+ if (env.SENSITIVE_TERMS) return parseTerms(env.SENSITIVE_TERMS);
130
+ if (env.PI_SENSITIVE_TERMS_FILE) return readTermsFile(env.PI_SENSITIVE_TERMS_FILE);
131
+ return [];
132
+ }
133
+
134
+ /**
135
+ * Does a line hold one of the terms?
136
+ *
137
+ * The comparison is a plain fragment, case-insensitive. The first
138
+ * match wins - the caller only needs to know that there is one.
139
+ */
140
+ export function lineHasTerm(line, terms) {
141
+ if (!line) return false;
142
+ const haystack = line.toLowerCase();
143
+ for (const term of terms) {
144
+ if (haystack.includes(term.toLowerCase())) return true;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * The added lines of a unified diff, with the line number they have in
151
+ * the new file.
152
+ *
153
+ * Only the added lines are scanned. A removed line is a line that is
154
+ * on its way out - a pull request that deletes a name must not fail
155
+ * because of it, or the cleanup of a leak can never be merged.
156
+ *
157
+ * The result is a list of { line, text }. The text is never printed by
158
+ * the caller.
159
+ */
160
+ export function addedLines(patch) {
161
+ const result = [];
162
+ if (!patch) return result;
163
+ let newLine = 0;
164
+ for (const raw of String(patch).split("\n")) {
165
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
166
+ if (hunk) {
167
+ newLine = Number.parseInt(hunk[1], 10);
168
+ continue;
169
+ }
170
+ if (raw.startsWith("+++") || raw.startsWith("---")) continue;
171
+ if (raw.startsWith("+")) {
172
+ result.push({ line: newLine, text: raw.slice(1) });
173
+ newLine += 1;
174
+ continue;
175
+ }
176
+ if (raw.startsWith("-")) continue;
177
+ if (raw.startsWith("\\")) continue;
178
+ if (raw.startsWith(" ")) newLine += 1;
179
+ }
180
+ return result;
181
+ }
182
+
183
+ /**
184
+ * The lines of a whole file.
185
+ *
186
+ * This is the fallback for a file whose diff the API does not return -
187
+ * a large file, or a file that is new. The whole content is scanned,
188
+ * because a name that is not in the diff is in the file.
189
+ */
190
+ export function fileLines(content) {
191
+ return String(content ?? "")
192
+ .split("\n")
193
+ .map((text, index) => ({ line: index + 1, text }));
194
+ }
195
+
196
+ /**
197
+ * A hit: a place, and nothing else.
198
+ *
199
+ * The place is a path and a line number. Both are already visible in
200
+ * the pull request, so naming them leaks nothing that the pull request
201
+ * does not already show - and it tells the author where to look.
202
+ */
203
+ function hit(path, line) {
204
+ return { path, line };
205
+ }
206
+
207
+ /**
208
+ * Does a path hold a term?
209
+ *
210
+ * A file name can carry a name, and a path that does is a leak that
211
+ * no diff would show - a file that was only renamed keeps its content
212
+ * and changes its path. The path is reported as a hit on line 0.
213
+ */
214
+ export function pathHasTerm(path, terms) {
215
+ return lineHasTerm(path, terms);
216
+ }
217
+
218
+ /**
219
+ * Hide the terms in a text.
220
+ *
221
+ * A path can hold a term - that is often why it is a hit - so a report
222
+ * that prints the path as it is would print the term with it. Every
223
+ * term becomes a fixed mark, so the report still says which file and
224
+ * where, and still says nothing that is private.
225
+ */
226
+ export function redactTerms(text, terms) {
227
+ let result = String(text ?? "");
228
+ for (const term of terms) {
229
+ if (!term) continue;
230
+ result = result.replace(new RegExp(escapeRegExp(term), "gi"), "***");
231
+ }
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * The regular expression of a term, with the characters of a term that
237
+ * mean something in a pattern escaped.
238
+ */
239
+ function escapeRegExp(value) {
240
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
241
+ }
242
+
243
+ /**
244
+ * Scan a patch for the terms.
245
+ */
246
+ export function scanPatch(patch, path, terms) {
247
+ const hits = [];
248
+ if (pathHasTerm(path, terms)) hits.push(hit(path, 0));
249
+ for (const { line, text } of addedLines(patch)) {
250
+ if (lineHasTerm(text, terms)) hits.push(hit(path, line));
251
+ }
252
+ return hits;
253
+ }
254
+
255
+ /**
256
+ * Scan a whole text for the terms.
257
+ *
258
+ * The path is a label, not a file that is read again.
259
+ */
260
+ export function scanText(text, path, terms) {
261
+ const hits = [];
262
+ if (pathHasTerm(path, terms)) hits.push(hit(path, 0));
263
+ for (const { line, text: content } of fileLines(text)) {
264
+ if (lineHasTerm(content, terms)) hits.push(hit(path, line));
265
+ }
266
+ return hits;
267
+ }
268
+
269
+ /**
270
+ * The report of a scan.
271
+ *
272
+ * The report names the places and nothing else. The term is not in it
273
+ * and the matching line is not in it.
274
+ */
275
+ export function formatReport({ hits, scanned, mode, terms = [] }) {
276
+ const lines = [];
277
+ if (hits.length === 0) {
278
+ lines.push(`scanned ${scanned} ${mode}`);
279
+ lines.push("no sensitive term was found");
280
+ return lines.join("\n");
281
+ }
282
+ lines.push("this change holds a term from the repository's sensitive list.");
283
+ lines.push("the term is not printed here: the list is a repository secret and this log is public.");
284
+ lines.push("the places to look:");
285
+ for (const found of hits) {
286
+ // A path can be the place the term is in, so the path is
287
+ // printed with the terms hidden.
288
+ lines.push(` ${redactTerms(found.path, terms)}:${found.line}`);
289
+ }
290
+ lines.push("remove the term from the change, or keep the discussion out of the public repository.");
291
+ return lines.join("\n");
292
+ }
293
+
294
+ /**
295
+ * The files of a pull request.
296
+ *
297
+ * The API returns the patch of every file it can. A file it cannot
298
+ * return a patch for is a file that is too large or that is binary -
299
+ * for a file that is too large the content is fetched instead, because
300
+ * a name that is not in the diff is in the file.
301
+ */
302
+ async function pullRequestFiles({ owner, repo, number, token, fetchImpl }) {
303
+ const files = [];
304
+ let page = 1;
305
+ for (;;) {
306
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}/files?per_page=100&page=${page}`;
307
+ const response = await fetchImpl(url, {
308
+ headers: {
309
+ accept: "application/vnd.github+json",
310
+ authorization: `Bearer ${token}`,
311
+ "user-agent": "pi-harness-runtime-sensitive-terms",
312
+ "x-github-api-version": "2022-11-28",
313
+ },
314
+ });
315
+ if (!response.ok) {
316
+ throw new Error(`the pull request files could not be read: HTTP ${response.status}`);
317
+ }
318
+ const batch = await response.json();
319
+ if (!Array.isArray(batch) || batch.length === 0) break;
320
+ files.push(...batch);
321
+ if (batch.length < 100) break;
322
+ page += 1;
323
+ }
324
+ return files;
325
+ }
326
+
327
+ /**
328
+ * The title and the body of a pull request.
329
+ *
330
+ * Both are public the moment the pull request exists, so both are
331
+ * scanned.
332
+ */
333
+ async function pullRequestMeta({ owner, repo, number, token, fetchImpl }) {
334
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}`;
335
+ const response = await fetchImpl(url, {
336
+ headers: {
337
+ accept: "application/vnd.github+json",
338
+ authorization: `Bearer ${token}`,
339
+ "user-agent": "pi-harness-runtime-sensitive-terms",
340
+ "x-github-api-version": "2022-11-28",
341
+ },
342
+ });
343
+ if (!response.ok) {
344
+ throw new Error(`the pull request could not be read: HTTP ${response.status}`);
345
+ }
346
+ const pull = await response.json();
347
+ return { title: pull?.title ?? "", body: pull?.body ?? "" };
348
+ }
349
+
350
+ /**
351
+ * The content of a file at a revision.
352
+ *
353
+ * A file that is binary, or that the API refuses, is not scanned - and
354
+ * the caller is told, because a file that was not scanned is a file
355
+ * that was not checked.
356
+ */
357
+ async function fileContentAtRef({ owner, repo, path, ref, token, fetchImpl }) {
358
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(ref)}`;
359
+ const response = await fetchImpl(url, {
360
+ headers: {
361
+ accept: "application/vnd.github+json",
362
+ authorization: `Bearer ${token}`,
363
+ "user-agent": "pi-harness-runtime-sensitive-terms",
364
+ "x-github-api-version": "2022-11-28",
365
+ },
366
+ });
367
+ if (!response.ok) return null;
368
+ const payload = await response.json();
369
+ if (!payload || typeof payload.content !== "string") return null;
370
+ let text;
371
+ try {
372
+ text = Buffer.from(payload.content, "base64").toString("utf8");
373
+ } catch {
374
+ return null;
375
+ }
376
+ // A binary file is not text. It is not a place a name would be.
377
+ if (text.includes("\u0000")) return null;
378
+ return text;
379
+ }
380
+
381
+ /**
382
+ * The scan of a pull request.
383
+ */
384
+ /**
385
+ * The commits of a pull request.
386
+ *
387
+ * A commit message is public the moment the commit is pushed, and it
388
+ * travels further than the change does: GitHub builds the release notes
389
+ * from the commit messages, and the release notes are public too.
390
+ */
391
+ async function pullRequestCommits({ owner, repo, number, token, fetchImpl }) {
392
+ const commits = [];
393
+ let page = 1;
394
+ for (;;) {
395
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}/commits?per_page=100&page=${page}`;
396
+ const response = await fetchImpl(url, {
397
+ headers: {
398
+ accept: "application/vnd.github+json",
399
+ authorization: `Bearer ${token}`,
400
+ "user-agent": "pi-harness-runtime-sensitive-terms",
401
+ "x-github-api-version": "2022-11-28",
402
+ },
403
+ });
404
+ if (!response.ok) {
405
+ throw new Error(`the pull request commits could not be read: HTTP ${response.status}`);
406
+ }
407
+ const batch = await response.json();
408
+ if (!Array.isArray(batch) || batch.length === 0) break;
409
+ for (const commit of batch) {
410
+ commits.push({
411
+ sha: commit?.sha ?? "",
412
+ message: commit?.commit?.message ?? "",
413
+ });
414
+ }
415
+ if (batch.length < 100) break;
416
+ page += 1;
417
+ }
418
+ return commits;
419
+ }
420
+
421
+ export async function scanPullRequest({ owner, repo, number, token, terms, fetchImpl = fetch }) {
422
+ const hits = [];
423
+ let scanned = 0;
424
+
425
+ const meta = await pullRequestMeta({ owner, repo, number, token, fetchImpl });
426
+ if (lineHasTerm(meta.title, terms)) hits.push(hit("pull request title", 1));
427
+ if (lineHasTerm(meta.body, terms)) hits.push(hit("pull request body", 1));
428
+ scanned += 2;
429
+
430
+ const files = await pullRequestFiles({ owner, repo, number, token, fetchImpl });
431
+ for (const file of files) {
432
+ const path = file?.filename ?? "(unknown)";
433
+ if (typeof file?.patch === "string" && file.patch.length > 0) {
434
+ hits.push(...scanPatch(file.patch, path, terms));
435
+ scanned += 1;
436
+ continue;
437
+ }
438
+ // No patch. A binary file has nothing to find; a large file has
439
+ // to be read as a whole.
440
+ if (file?.patch === undefined && file?.changes !== undefined) {
441
+ const content = await fileContentAtRef({
442
+ owner,
443
+ repo,
444
+ path,
445
+ ref: file.sha ?? "HEAD",
446
+ token,
447
+ fetchImpl,
448
+ });
449
+ if (content === null) continue;
450
+ hits.push(...scanText(content, path, terms));
451
+ scanned += 1;
452
+ }
453
+ }
454
+
455
+ // The commit messages of the pull request. They are public, and
456
+ // GitHub builds the release notes from them.
457
+ const commits = await pullRequestCommits({ owner, repo, number, token, fetchImpl });
458
+ for (const commit of commits) {
459
+ for (const { line, text } of fileLines(commit.message)) {
460
+ if (lineHasTerm(text, terms)) hits.push(hit(commit.sha, line));
461
+ }
462
+ scanned += 1;
463
+ }
464
+
465
+ return {
466
+ hits,
467
+ scanned,
468
+ mode: "files, added lines, commit messages, and the pull request title and body",
469
+ };
470
+ }
471
+
472
+ /**
473
+ * The diff of a range of commits.
474
+ */
475
+ function gitRangeDiff(range, cwd) {
476
+ return execFileSync("git", ["diff", "--unified=0", range], {
477
+ cwd,
478
+ encoding: "utf8",
479
+ maxBuffer: 64 * 1024 * 1024,
480
+ });
481
+ }
482
+
483
+ /**
484
+ * The commit messages of a range.
485
+ *
486
+ * A commit message is public the moment the commit is pushed, and it
487
+ * travels further than the change does: GitHub builds the release notes
488
+ * from the commit messages, and the release notes are public too. A
489
+ * name in a message is therefore a name in the release notes.
490
+ *
491
+ * The result is a list of { sha, message }. The sha is the place the
492
+ * term is in, and it says nothing private.
493
+ */
494
+ export function commitMessages(range, cwd = process.cwd()) {
495
+ let output;
496
+ try {
497
+ output = execFileSync(
498
+ "git",
499
+ ["log", "--format=%H%x1f%B%x1e", range],
500
+ { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
501
+ );
502
+ } catch {
503
+ return [];
504
+ }
505
+ const commits = [];
506
+ for (const record of output.split("\x1e")) {
507
+ const trimmed = record.replace(/^\n/, "");
508
+ if (!trimmed.trim()) continue;
509
+ const separator = trimmed.indexOf("\x1f");
510
+ if (separator === -1) continue;
511
+ const sha = trimmed.slice(0, separator).trim();
512
+ const message = trimmed.slice(separator + 1);
513
+ if (!sha) continue;
514
+ commits.push({ sha, message });
515
+ }
516
+ return commits;
517
+ }
518
+
519
+ /**
520
+ * The scan of the commit messages of a range.
521
+ */
522
+ export function scanCommitMessages(range, terms, cwd = process.cwd()) {
523
+ const hits = [];
524
+ for (const commit of commitMessages(range, cwd)) {
525
+ for (const { line, text } of fileLines(commit.message)) {
526
+ if (lineHasTerm(text, terms)) hits.push(hit(commit.sha, line));
527
+ }
528
+ }
529
+ return { hits, scanned: commitMessages(range, cwd).length, mode: "commit messages" };
530
+ }
531
+
532
+ /**
533
+ * The scan of a range of commits.
534
+ */
535
+ export function scanRange(range, terms, cwd = process.cwd()) {
536
+ const diff = gitRangeDiff(range, cwd);
537
+ const files = [];
538
+ let current = null;
539
+ for (const raw of diff.split("\n")) {
540
+ if (raw.startsWith("diff --git ")) {
541
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(raw);
542
+ current = { path: match ? match[2] : "(unknown)", patch: "" };
543
+ files.push(current);
544
+ continue;
545
+ }
546
+ if (!current) continue;
547
+ if (raw.startsWith("@@") || raw.startsWith("+") || raw.startsWith(" ")) {
548
+ current.patch += `${raw}\n`;
549
+ }
550
+ }
551
+ const hits = [];
552
+ for (const file of files) {
553
+ hits.push(...scanPatch(file.patch, file.path, terms));
554
+ }
555
+ // The commit messages of the range are public as well, and GitHub
556
+ // builds the release notes from them.
557
+ const messages = scanCommitMessages(range, terms, cwd);
558
+ hits.push(...messages.hits);
559
+ return {
560
+ hits,
561
+ scanned: files.length + messages.scanned,
562
+ mode: "changed files and commit messages",
563
+ };
564
+ }
565
+
566
+ /**
567
+ * The directories a tree scan does not walk.
568
+ *
569
+ * They are never part of a published package, and their content is not
570
+ * the content of this repository. A name inside a dependency is not a
571
+ * leak of this repository.
572
+ */
573
+ const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
574
+
575
+ /**
576
+ * The files of a directory tree.
577
+ *
578
+ * A published package is a directory tree, and a name can sit in any file
579
+ * of it. The walk follows no symlink: a link is not a published file, and
580
+ * a link that points outside the tree would read a file that npm does not
581
+ * publish.
582
+ */
583
+ export function listTreeFiles(dir, root = dir, out = []) {
584
+ let entries;
585
+ try {
586
+ entries = readdirSync(dir, { withFileTypes: true });
587
+ } catch {
588
+ return out;
589
+ }
590
+ for (const entry of entries) {
591
+ const absolute = join(dir, entry.name);
592
+ if (entry.isDirectory()) {
593
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
594
+ listTreeFiles(absolute, root, out);
595
+ continue;
596
+ }
597
+ // A symlink is not a file: it is not published, and its target
598
+ // may live outside the tree.
599
+ if (!entry.isFile()) continue;
600
+ out.push({
601
+ relative: relative(root, absolute).split("\\").join("/"),
602
+ absolute,
603
+ });
604
+ }
605
+ return out;
606
+ }
607
+
608
+ /**
609
+ * The scan of a directory tree.
610
+ *
611
+ * Every file is scanned as a whole, and the path of the file is scanned
612
+ * with it: a file that carries a name in its name leaks the name just as
613
+ * well as a file that carries it in its content.
614
+ */
615
+ export function scanTree(dir, terms) {
616
+ const files = listTreeFiles(dir);
617
+ const hits = [];
618
+ for (const file of files) {
619
+ let content;
620
+ try {
621
+ content = readFileSync(file.absolute, "utf8");
622
+ } catch {
623
+ continue;
624
+ }
625
+ hits.push(...scanText(content, file.relative, terms));
626
+ }
627
+ return { hits, scanned: files.length, mode: "published files" };
628
+ }
629
+
630
+ /**
631
+ * The arguments of the command line.
632
+ */
633
+ function parseArgs(argv) {
634
+ const args = { mode: null };
635
+ for (let index = 0; index < argv.length; index += 1) {
636
+ const arg = argv[index];
637
+ const next = () => argv[++index];
638
+ if (arg === "--pr") args.mode = "pr", (args.number = next());
639
+ else if (arg === "--diff") args.mode = "diff", (args.path = next());
640
+ else if (arg === "--file") args.mode = "file", (args.path = next());
641
+ else if (arg === "--text") args.mode = "text", (args.text = next());
642
+ else if (arg === "--range") args.mode = "range", (args.range = next());
643
+ else if (arg === "--tree") args.mode = "tree", (args.path = next());
644
+ else if (arg === "--commits") args.mode = "commits", (args.range = next());
645
+ else if (arg === "--terms-file") args.termsFile = next();
646
+ else if (arg === "--repo") args.repo = next();
647
+ }
648
+ return args;
649
+ }
650
+
651
+ /**
652
+ * The main.
653
+ */
654
+ async function main() {
655
+ const args = parseArgs(process.argv.slice(2));
656
+
657
+ let terms = args.termsFile ? readTermsFile(args.termsFile) : termsFromEnv();
658
+ if (terms.length === 0 && args.termsFile) {
659
+ // The list was named and it was empty. That is a broken setup,
660
+ // not a clean run.
661
+ console.error("the term list is empty, so nothing could be checked.");
662
+ return EXIT_UNAVAILABLE;
663
+ }
664
+ if (terms.length === 0) {
665
+ console.error("no term list is configured, so nothing could be checked.");
666
+ console.error("set SENSITIVE_TERMS, or pass --terms-file <path>.");
667
+ return EXIT_UNAVAILABLE;
668
+ }
669
+
670
+ // A mode without its value checked nothing. That is a broken
671
+ // command, not a clean result, so it stops here.
672
+ const required = {
673
+ diff: "path",
674
+ file: "path",
675
+ range: "range",
676
+ tree: "path",
677
+ commits: "range",
678
+ };
679
+ const needed = args.mode ? required[args.mode] : undefined;
680
+ if (needed && !args[needed]) {
681
+ console.error(`the mode --${args.mode} needs a ${needed}.`);
682
+ return EXIT_UNAVAILABLE;
683
+ }
684
+ if (args.mode === "text" && args.text === undefined) {
685
+ console.error("the mode --text needs a text.");
686
+ return EXIT_UNAVAILABLE;
687
+ }
688
+
689
+ let result;
690
+ if (args.mode === "pr") {
691
+ const repo = args.repo ?? process.env.GITHUB_REPOSITORY ?? repositoryFromRemote();
692
+ const token = process.env.GITHUB_TOKEN;
693
+ const number = args.number ?? process.env.PR_NUMBER;
694
+ if (!repo || !number) {
695
+ console.error("the pull request or the repository is unknown.");
696
+ console.error("pass --repo <owner>/<name> and --pr <number>.");
697
+ return EXIT_UNAVAILABLE;
698
+ }
699
+ if (!token) {
700
+ console.error("no token is available for the pull request API.");
701
+ return EXIT_UNAVAILABLE;
702
+ }
703
+ const [owner, name] = repo.split("/");
704
+ result = await scanPullRequest({ owner, repo: name, number, token, terms });
705
+ } else if (args.mode === "diff") {
706
+ const diff = readFileSync(args.path, "utf8");
707
+ const files = [];
708
+ let current = null;
709
+ for (const raw of diff.split("\n")) {
710
+ if (raw.startsWith("diff --git ")) {
711
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(raw);
712
+ current = { path: match ? match[2] : "(unknown)", patch: "" };
713
+ files.push(current);
714
+ continue;
715
+ }
716
+ if (!current) continue;
717
+ if (raw.startsWith("@@") || raw.startsWith("+")) current.patch += `${raw}\n`;
718
+ }
719
+ const hits = [];
720
+ for (const file of files) hits.push(...scanPatch(file.patch, file.path, terms));
721
+ result = { hits, scanned: files.length, mode: "changed files" };
722
+ } else if (args.mode === "file") {
723
+ result = { hits: scanText(readFileSync(args.path, "utf8"), args.path, terms), scanned: 1, mode: "file" };
724
+ } else if (args.mode === "text") {
725
+ result = { hits: scanText(args.text, "(text)", terms), scanned: 1, mode: "text" };
726
+ } else if (args.mode === "range") {
727
+ result = scanRange(args.range, terms);
728
+ } else if (args.mode === "commits") {
729
+ result = scanCommitMessages(args.range, terms);
730
+ // A range that holds no commit is a range that was written
731
+ // wrong. Scanning it would report a clean result for a check
732
+ // that never ran.
733
+ if (result.scanned === 0) {
734
+ console.error(`the range ${args.range} holds no commit, so nothing could be checked.`);
735
+ return EXIT_UNAVAILABLE;
736
+ }
737
+ } else if (args.mode === "tree") {
738
+ if (!existsSync(args.path) || !statSync(args.path).isDirectory()) {
739
+ console.error(`the directory ${args.path} does not exist.`);
740
+ return EXIT_UNAVAILABLE;
741
+ }
742
+ result = scanTree(args.path, terms);
743
+ } else {
744
+ console.error(
745
+ "nothing to check. use --pr, --diff, --file, --text, --range, --commits or --tree.",
746
+ );
747
+ return EXIT_UNAVAILABLE;
748
+ }
749
+
750
+ console.log(formatReport({ ...result, terms }));
751
+ return result.hits.length > 0 ? EXIT_FOUND : EXIT_CLEAN;
752
+ }
753
+
754
+ // Only run when the file is the program, not when it is imported.
755
+ if (import.meta.url === `file://${process.argv[1]}`) {
756
+ main()
757
+ .then((code) => {
758
+ process.exitCode = code;
759
+ })
760
+ .catch((error) => {
761
+ console.error(`the check could not run: ${error?.message ?? error}`);
762
+ process.exitCode = EXIT_UNAVAILABLE;
763
+ });
764
+ }