@pareto4/pp-core 1.11.1 → 1.12.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.
Files changed (255) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -0
  3. package/agents/pp-code-fixer.md +1 -1
  4. package/agents/pp-debug-session-manager.md +1 -1
  5. package/agents/pp-debugger.md +1 -1
  6. package/agents/pp-executor.md +20 -10
  7. package/agents/pp-intel-updater.md +1 -1
  8. package/agents/pp-phase-researcher.md +1 -1
  9. package/agents/pp-plan-checker.md +5 -5
  10. package/agents/pp-planner.md +10 -24
  11. package/agents/pp-project-researcher.md +1 -1
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-researcher.md +1 -1
  14. package/agents/pp-verifier.md +8 -4
  15. package/bin/install.js +429 -82
  16. package/commands/pp-autonomous.md +8 -6
  17. package/commands/pp-discovery-to-build.md +1 -1
  18. package/commands/pp-discovery.md +3 -2
  19. package/commands/pp-discuss-phase.md +2 -2
  20. package/commands/pp-execute-phase.md +3 -1
  21. package/commands/pp-graphify.md +5 -5
  22. package/commands/pp-import.md +1 -1
  23. package/commands/pp-map-codebase.md +1 -1
  24. package/commands/pp-ns-project.md +2 -3
  25. package/commands/pp-ns-workflow.md +1 -1
  26. package/commands/pp-plan-phase.md +2 -1
  27. package/commands/pp-verify-work.md +2 -1
  28. package/gemini-extension.json +1 -1
  29. package/hooks/dist/pp-cursor-session-start.js +2 -2
  30. package/hooks/dist/pp-session-state.sh +1 -1
  31. package/hooks/dist/pp-statusline.js +21 -2
  32. package/hooks/pp-cursor-session-start.js +2 -2
  33. package/hooks/pp-session-state.sh +1 -1
  34. package/hooks/pp-statusline.js +21 -2
  35. package/package.json +18 -5
  36. package/pareto-skills/pp-project-setup/SKILL.md +5 -63
  37. package/pp-core/bin/detect-build-commands.sh +182 -0
  38. package/pp-core/bin/lib/capability-registry.cjs +65 -7
  39. package/pp-core/bin/lib/check-command-router.cjs +79 -38
  40. package/pp-core/bin/lib/clusters.cjs +2 -3
  41. package/pp-core/bin/lib/command-aliases.cjs +36 -0
  42. package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
  43. package/pp-core/bin/lib/commands.cjs +21 -6
  44. package/pp-core/bin/lib/compose-bindings.cjs +70 -6
  45. package/pp-core/bin/lib/config-schema.cjs +16 -0
  46. package/pp-core/bin/lib/config.cjs +142 -27
  47. package/pp-core/bin/lib/core-utils.cjs +43 -7
  48. package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
  49. package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
  50. package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
  51. package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
  52. package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
  53. package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
  54. package/pp-core/bin/lib/discovery-table.cjs +98 -0
  55. package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
  56. package/pp-core/bin/lib/frontmatter.cjs +17 -2
  57. package/pp-core/bin/lib/init-command-router.cjs +8 -5
  58. package/pp-core/bin/lib/init-contract.cjs +93 -4
  59. package/pp-core/bin/lib/init.cjs +124 -134
  60. package/pp-core/bin/lib/install-profiles.cjs +5 -0
  61. package/pp-core/bin/lib/install-run.cjs +536 -0
  62. package/pp-core/bin/lib/io.cjs +15 -0
  63. package/pp-core/bin/lib/loop-resolver.cjs +24 -0
  64. package/pp-core/bin/lib/milestone.cjs +345 -48
  65. package/pp-core/bin/lib/phase-command-router.cjs +23 -1
  66. package/pp-core/bin/lib/phase-locator.cjs +15 -6
  67. package/pp-core/bin/lib/phase-scope.cjs +91 -0
  68. package/pp-core/bin/lib/phase-tier.cjs +467 -0
  69. package/pp-core/bin/lib/phase.cjs +247 -21
  70. package/pp-core/bin/lib/phases-command-router.cjs +2 -0
  71. package/pp-core/bin/lib/plan-graph.cjs +368 -0
  72. package/pp-core/bin/lib/planning-workspace.cjs +53 -0
  73. package/pp-core/bin/lib/platform-bindings.cjs +32 -2
  74. package/pp-core/bin/lib/profile-output.cjs +11 -3
  75. package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
  76. package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
  77. package/pp-core/bin/lib/roadmap.cjs +141 -39
  78. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  79. package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
  80. package/pp-core/bin/lib/state-command-router.cjs +82 -13
  81. package/pp-core/bin/lib/state-document.cjs +124 -7
  82. package/pp-core/bin/lib/state.cjs +784 -257
  83. package/pp-core/bin/lib/template.cjs +22 -6
  84. package/pp-core/bin/lib/vault-command-router.cjs +57 -6
  85. package/pp-core/bin/lib/vault-practices.cjs +6 -36
  86. package/pp-core/bin/lib/vault-write-back.cjs +20 -4
  87. package/pp-core/bin/lib/vault.cjs +555 -23
  88. package/pp-core/bin/lib/verify.cjs +53 -31
  89. package/pp-core/bin/lib/workstream.cjs +1 -1
  90. package/pp-core/bin/lib/worktree-safety.cjs +26 -15
  91. package/pp-core/bin/portable-timeout.sh +89 -0
  92. package/pp-core/bin/pp-tools.cjs +149 -9
  93. package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
  94. package/pp-core/bin/shared/config-schema.manifest.json +3 -0
  95. package/pp-core/references/autonomous-best-effort.md +461 -0
  96. package/pp-core/references/autonomous-lifecycle.md +146 -0
  97. package/pp-core/references/autonomous-smart-discuss.md +16 -12
  98. package/pp-core/references/checkpoints.md +1 -1
  99. package/pp-core/references/discovery-id-conventions.md +202 -0
  100. package/pp-core/references/discovery-rebaseline.md +254 -0
  101. package/pp-core/references/discovery-research-track.md +283 -0
  102. package/pp-core/references/discovery-traceability.md +400 -0
  103. package/pp-core/references/loop-hook-dispatch.md +7 -0
  104. package/pp-core/references/mandatory-initial-read.md +1 -1
  105. package/pp-core/references/planner-gap-closure.md +1 -0
  106. package/pp-core/references/planner-human-verify-mode.md +1 -1
  107. package/pp-core/references/planner-load-graph-context.md +1 -1
  108. package/pp-core/references/planner-plan-graph.md +66 -0
  109. package/pp-core/references/planner-tdd.md +1 -0
  110. package/pp-core/references/planning-config.md +8 -0
  111. package/pp-core/references/sketch-tooling.md +4 -0
  112. package/pp-core/references/tdd.md +1 -0
  113. package/pp-core/templates/claude-md.md +1 -1
  114. package/pp-core/templates/phase-prompt.md +6 -2
  115. package/pp-core/templates/prototype-review/review.js +303 -0
  116. package/pp-core/templates/state.md +6 -0
  117. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
  118. package/pp-core/workflows/add-backlog.md +1 -1
  119. package/pp-core/workflows/add-phase.md +1 -1
  120. package/pp-core/workflows/add-tests.md +1 -1
  121. package/pp-core/workflows/add-todo.md +1 -1
  122. package/pp-core/workflows/ai-integration-phase.md +2 -2
  123. package/pp-core/workflows/audit-fix.md +12 -16
  124. package/pp-core/workflows/audit-milestone.md +1 -1
  125. package/pp-core/workflows/audit-uat.md +1 -1
  126. package/pp-core/workflows/autonomous.md +68 -306
  127. package/pp-core/workflows/check-todos.md +1 -1
  128. package/pp-core/workflows/cleanup.md +1 -1
  129. package/pp-core/workflows/code-review-fix.md +1 -1
  130. package/pp-core/workflows/code-review.md +19 -7
  131. package/pp-core/workflows/complete-milestone.md +41 -60
  132. package/pp-core/workflows/debug.md +1 -1
  133. package/pp-core/workflows/diagnose-issues.md +1 -1
  134. package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
  135. package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
  136. package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
  137. package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
  138. package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
  139. package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
  140. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
  141. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
  142. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
  143. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
  144. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
  145. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
  146. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
  147. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
  148. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
  149. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
  150. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
  151. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
  152. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
  153. package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
  154. package/pp-core/workflows/discovery-sprint.md +79 -1714
  155. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
  156. package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
  157. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
  158. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
  159. package/pp-core/workflows/discovery-to-build.md +21 -238
  160. package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
  161. package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
  162. package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
  163. package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
  164. package/pp-core/workflows/discuss-phase.md +9 -2
  165. package/pp-core/workflows/do.md +4 -4
  166. package/pp-core/workflows/docs-update.md +1 -1
  167. package/pp-core/workflows/edit-phase.md +1 -1
  168. package/pp-core/workflows/eval-review.md +1 -1
  169. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
  170. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
  171. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  172. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
  173. package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
  174. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
  175. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
  176. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
  177. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
  178. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
  179. package/pp-core/workflows/execute-phase.md +102 -216
  180. package/pp-core/workflows/execute-plan.md +6 -2
  181. package/pp-core/workflows/explore.md +1 -1
  182. package/pp-core/workflows/extract-learnings.md +1 -1
  183. package/pp-core/workflows/forensics.md +4 -4
  184. package/pp-core/workflows/graduation.md +1 -1
  185. package/pp-core/workflows/health.md +1 -1
  186. package/pp-core/workflows/help/modes/brief.md +1 -0
  187. package/pp-core/workflows/help/modes/default.md +2 -1
  188. package/pp-core/workflows/help/modes/full.md +12 -5
  189. package/pp-core/workflows/import.md +1 -1
  190. package/pp-core/workflows/ingest-docs.md +1 -1
  191. package/pp-core/workflows/init.md +18 -2
  192. package/pp-core/workflows/insert-phase.md +1 -1
  193. package/pp-core/workflows/list-workspaces.md +1 -1
  194. package/pp-core/workflows/manager.md +3 -3
  195. package/pp-core/workflows/map-codebase.md +2 -2
  196. package/pp-core/workflows/milestone-summary.md +4 -4
  197. package/pp-core/workflows/mvp-phase.md +1 -1
  198. package/pp-core/workflows/new-milestone.md +7 -8
  199. package/pp-core/workflows/new-project.md +6 -1
  200. package/pp-core/workflows/new-workspace.md +1 -1
  201. package/pp-core/workflows/next.md +2 -2
  202. package/pp-core/workflows/note.md +2 -2
  203. package/pp-core/workflows/pause-work.md +1 -1
  204. package/pp-core/workflows/plan-milestone-gaps.md +1 -1
  205. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
  206. package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
  207. package/pp-core/workflows/plan-phase.md +43 -167
  208. package/pp-core/workflows/plan-review-convergence.md +2 -2
  209. package/pp-core/workflows/plant-seed.md +1 -1
  210. package/pp-core/workflows/pr-branch.md +1 -1
  211. package/pp-core/workflows/profile-user.md +1 -1
  212. package/pp-core/workflows/progress.md +3 -3
  213. package/pp-core/workflows/quick.md +3 -1
  214. package/pp-core/workflows/remove-phase.md +1 -1
  215. package/pp-core/workflows/remove-workspace.md +1 -1
  216. package/pp-core/workflows/resume-project.md +6 -12
  217. package/pp-core/workflows/review.md +1 -1
  218. package/pp-core/workflows/scan.md +1 -1
  219. package/pp-core/workflows/secure-phase.md +1 -1
  220. package/pp-core/workflows/settings-advanced.md +1 -1
  221. package/pp-core/workflows/settings-integrations.md +1 -1
  222. package/pp-core/workflows/settings.md +1 -1
  223. package/pp-core/workflows/ship.md +25 -8
  224. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  225. package/pp-core/workflows/sketch.md +2 -1
  226. package/pp-core/workflows/spec-phase.md +1 -1
  227. package/pp-core/workflows/spike-wrap-up.md +1 -1
  228. package/pp-core/workflows/spike.md +1 -1
  229. package/pp-core/workflows/stats.md +2 -2
  230. package/pp-core/workflows/thread.md +1 -1
  231. package/pp-core/workflows/transition.md +5 -9
  232. package/pp-core/workflows/ui-phase.md +23 -2
  233. package/pp-core/workflows/ui-review.md +1 -1
  234. package/pp-core/workflows/ultraplan-phase.md +2 -2
  235. package/pp-core/workflows/validate-phase.md +1 -1
  236. package/pp-core/workflows/vault-write-back.md +1 -1
  237. package/pp-core/workflows/verify-phase.md +29 -26
  238. package/pp-core/workflows/verify-work.md +14 -10
  239. package/scripts/base64-scan.sh +7 -41
  240. package/scripts/changeset/cli.cjs +25 -5
  241. package/scripts/dev-env.cjs +462 -0
  242. package/scripts/lib/allowlist-ratchet.cjs +103 -1
  243. package/scripts/lib/size-budget.cjs +311 -0
  244. package/scripts/lint-neutrality-names.cjs +694 -0
  245. package/scripts/lint-org-coordinates.allowlist.json +35 -0
  246. package/scripts/lint-org-coordinates.cjs +426 -0
  247. package/scripts/lint-skill-deps.cjs +48 -9
  248. package/scripts/lint-test-file-count.cjs +17 -2
  249. package/scripts/prompt-injection-scan.sh +41 -20
  250. package/scripts/sync-manifest-versions.cjs +41 -7
  251. package/scripts/sync-runtime-launcher.cjs +106 -1
  252. package/scripts/update-size-baseline.cjs +274 -30
  253. package/scripts/workflow-size.cjs +61 -9
  254. package/commands/pp-build-handoff.md +0 -46
  255. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
@@ -0,0 +1,694 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * lint-neutrality-names.cjs — TEN-09 CI name scan.
6
+ *
7
+ * Design (D-11..D-13, .planning/phases/38-tenant-neutrality-planning-records-guards/38-CONTEXT.md):
8
+ *
9
+ * D-12 The list lives ONLY in the `PP_NEUTRALITY_NAMES_B64` CI variable — base64 of a
10
+ * newline-separated `role:value` list — and is deliberately MASKED, not PROTECTED.
11
+ * A protected variable is only exposed to pipelines on protected branches/tags, so an
12
+ * ordinary-branch merge-request pipeline would never see it and D-13's fail-closed rule
13
+ * would redden every normal MR. The list needs to stay out of the repository, not secret
14
+ * from project members; masking only keeps it out of job logs.
15
+ *
16
+ * D-13 Fail-closed and local-skip semantics: in CI (`process.env.CI === 'true'`) a missing,
17
+ * empty or invalid list fails closed (exit 1) — covering merge-request, protected-branch
18
+ * and every other pipeline context, because the variable is deliberately unprotected
19
+ * (D-12). Locally, with no list supplied, the scan skips with a loud stderr notice and
20
+ * exits 0; with a list supplied locally it still scans (the D-15 local proof leg). Output
21
+ * discipline: every report line shows only a masked path, a line number (when the hit is
22
+ * in content) and a category — never a list value, never the decoded list, never a
23
+ * matched literal. Even the masked, base64-encoded variable is not secret from project
24
+ * members — masking only keeps it out of CI job logs. A v* tag pipeline (the publish
25
+ * path) reports `context=tag` (IN-11); it is a CI run like any other and fails closed the
26
+ * same way.
27
+ *
28
+ * List format (CR-03, T-38-20): the variable is canonical base64 (no truncation or
29
+ * corruption) of UTF-8 text. The first non-blank line must be exactly
30
+ * `# pp-neutrality-list entries=<N>`, with N from 1 and no leading zero, and exactly N entries
31
+ * must then parse; a missing or malformed header, or a count that differs, is a config error.
32
+ * A leading BOM and CRLF line endings are accepted. Every later line is blank, a `#` comment
33
+ * on its own line, or one `role:value` entry, where `role` is one of ROLES (`client`,
34
+ * `partner`, `person`, `canary`, `other`), matched case-insensitively, immediately followed by
35
+ * a colon with no whitespace before it; whitespace after the colon is allowed. `canary` marks
36
+ * a planted proof-of-life value (D-15's throwaway pipeline leg) so a real hit can be told
37
+ * apart from an intentional drill in the report summary's category counts.
38
+ *
39
+ * Accepted values (the claim, round 3): an unknown or malformed role prefix (including
40
+ * whitespace before the colon), an empty value and a value under 4 characters are config
41
+ * errors (exit 1, local or CI), never taken whole as an `other` value. Beyond that a value is
42
+ * accepted only if (1) every code point is in alphabet A: the ASCII digits, U+0020, the five
43
+ * joiners (hyphen, dot, ampersand, ASCII apostrophe, U+2019) and the letters that are both
44
+ * \p{L} and \p{Script=Latin} and not \p{Default_Ignorable_Code_Point}; (2) it equals its own
45
+ * NFKC normalisation; and (3) grammar G generates it: words of letters and digits, a joiner
46
+ * only between two letters or digits, an optional leading apostrophe and one optional trailing
47
+ * dot or apostrophe on a word, words separated by exactly one plain space or by space,
48
+ * ampersand, space, and not a value that starts and ends with an apostrophe (that is a quoted
49
+ * value). The claim is that no accepted value can be a dead entry because of an invisible,
50
+ * non-standard-space, other-script, compatibility-form, combining-mark or symbol character,
51
+ * punctuation other than the five joiners, or malformed spacing. It is proven in
52
+ * tests/lint-neutrality-names.test.cjs by an exhaustive sweep of every single code point and by
53
+ * fast-check properties; it rests only on strict decode, the header count, the role prefix, the
54
+ * minimum length and checks (1)-(3). The operator-UX pre-checks (a second role prefix, an
55
+ * inline ` #` comment, a quoted value, a value that is itself canonical base64 of printable
56
+ * text) only name the likelier cause of a bad line and no guarantee rests on them; the
57
+ * encoded-value check requires a canonical round-trip AND a decode to printable UTF-8, so the
58
+ * canary shape (`ppcanary` plus hex) is never flagged. A control, format or separator character
59
+ * in a comment line is rejected as well (a hidden entry break). Errors cite fixed reason words,
60
+ * a 1-based line number (blank and comment lines count, so it matches the raw decoded text) and
61
+ * counts only, never decoded content, never the offending character. Matching is a literal,
62
+ * case-insensitive substring match, so a name whose distinctive part needs a character outside
63
+ * A is listed by an in-alphabet part and the full name in the tree still matches.
64
+ *
65
+ * Scope (IN-07): the scan covers the tracked tree at HEAD only. Git history, commit messages,
66
+ * branch and tag names, and merge-request titles are outside it.
67
+ *
68
+ * Known residuals (named, not claimed away; each is pinned by a `residual (x)` characterization
69
+ * test under the delete-don't-weaken convention, and tracked in
70
+ * `.planning/todos/pending/neutrality-guard-review-deferred-findings.md`):
71
+ * (a) an in-alphabet substitution, deletion or look-alike letter inside a value (a typo, or
72
+ * in-transit corruption that stays inside A): the count still matches; the per-entry
73
+ * checksum is the deferred fix and would be an outward, approval-gated variable update;
74
+ * (b) a well-formed but wrong value: the operator's semantic check is the per-category counts
75
+ * on the `ok` line;
76
+ * (c) tree-side spelling variants: tree text using a different apostrophe, space or hyphen
77
+ * variant than the list is not a hit (matching is literal; the deferred WR-02);
78
+ * (d) names whose distinctive part needs characters outside A: list an in-alphabet part;
79
+ * (e) encoded values that stay inside A and escape the canonical-base64 heuristic (base64url
80
+ * with a hyphen, or under 8 characters).
81
+ *
82
+ * Exit codes:
83
+ * 0 — local run with nothing to check, or a scan with 0 hits
84
+ * 1 — CI run with a missing/empty/invalid list, or a scan with 1+ hits
85
+ *
86
+ * Usage:
87
+ * node scripts/lint-neutrality-names.cjs
88
+ *
89
+ * Env:
90
+ * PP_NEUTRALITY_NAMES_B64 — base64 of the headered, newline-separated role:value list (D-12)
91
+ * NEUTRALITY_SCAN_REPO_ROOT — redirect the whole-tree scan to a temp git repo (tests only)
92
+ *
93
+ * Exports:
94
+ * VARIABLE_NAME, EXEMPT_PATHS, ROLES, parseList, resolveContext, runScan, cliMain
95
+ */
96
+
97
+ const { execFileSync } = require('node:child_process');
98
+ const fs = require('node:fs');
99
+ const path = require('node:path');
100
+
101
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
102
+
103
+ // D-12 working name, kept.
104
+ const VARIABLE_NAME = 'PP_NEUTRALITY_NAMES_B64';
105
+
106
+ // D-11: the single, explicitly listed exemption — a prefix match on tracked paths.
107
+ const EXEMPT_PATHS = Object.freeze(['.planning/milestones/']);
108
+
109
+ const ROLES = ['client', 'partner', 'person', 'canary', 'other'];
110
+
111
+ // NEUTRALITY_SCAN_REPO_ROOT lets tests redirect the guard to a temporary git repo without
112
+ // touching the real working tree (mirrors RENAME_GUARD_REPO_ROOT in verify-rename-guard.cjs).
113
+ const REPO_ROOT = process.env.NEUTRALITY_SCAN_REPO_ROOT
114
+ ? path.resolve(process.env.NEUTRALITY_SCAN_REPO_ROOT)
115
+ : path.resolve(__dirname, '..');
116
+
117
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
118
+
119
+ /**
120
+ * ListError — thrown by parseList() on an unusable list. The message cites only an entry
121
+ * index or a count, never any decoded content (D-13 output discipline).
122
+ */
123
+ class ListError extends Error {}
124
+
125
+ /**
126
+ * escapeRegExp(str) — literal-match escape for building a combined RegExp from list values.
127
+ * Copied verbatim from scripts/sync-runtime-launcher.cjs's escapeRegExp().
128
+ */
129
+ function escapeRegExp(str) {
130
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ }
132
+
133
+ const ROLE_PREFIX_RE = new RegExp(`^(${ROLES.join('|')}):`, 'i');
134
+
135
+ // CR-03: the required first non-blank list line, declaring the exact entry count.
136
+ const HEADER_RE = /^# pp-neutrality-list entries=([1-9][0-9]{0,5})$/;
137
+ // Any control, format, line-separator or paragraph-separator character (covers CR, NUL, tab,
138
+ // NEL, U+2028/2029 and zero-width characters — every way to hide an entry break). Applied to
139
+ // comment lines, and used to pick the reason word for an entry-value character outside A.
140
+ const CONTROL_OR_FORMAT_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
141
+ // Reason classes only (they choose the reason word for a character already outside alphabet A).
142
+ const NONSTANDARD_SPACE_RE = /\p{Zs}/u;
143
+ const INVISIBLE_RE = /\p{Default_Ignorable_Code_Point}/u;
144
+
145
+ // -- Operator-UX pre-checks: they only name a likelier cause; no guarantee rests on them. --
146
+ // A second `role:` inside a value: the role at the start or after a non-alphanumeric character.
147
+ const EMBEDDED_ROLE_RE = new RegExp(`(?:^|[^\\p{L}\\p{N}])(?:${ROLES.join('|')})\\s*:`, 'iu');
148
+ const INLINE_COMMENT_RE = /\s#/;
149
+ // A value with a quote or backtick at an edge: ASCII double quote, backtick, U+201C, U+201D,
150
+ // guillemets U+00AB, U+00BB, U+2039, U+203A at either edge, and U+201E at the start (WR-12).
151
+ const QUOTE_EDGE_RE =
152
+ /^["`\u{201C}\u{201D}\u{00AB}\u{00BB}\u{2039}\u{203A}\u{201E}]|["`\u{201C}\u{201D}\u{00AB}\u{00BB}\u{2039}\u{203A}]$/u;
153
+ // A value wrapped in single quotes: starts with an apostrophe-like quote AND ends with one. A
154
+ // single apostrophe at one edge is a name (WR-13: 't, 's-, possessives), not a quote.
155
+ const QUOTE_PAIR_RE = /^['\u{2018}\u{2019}\u{201A}][\s\S]*['\u{2018}\u{2019}]$/u;
156
+ const PRINTABLE_TEXT_RE = /^[\p{L}\p{N}\p{Zs}\p{P}\p{S}]{4,}$/u;
157
+
158
+ // -- The security checks (CR-04, T-38-74): closed alphabet A, NFKC stability, grammar G. --
159
+ // Every non-ASCII character is a \u{...} escape; in a v-mode class the hyphen and the ampersand
160
+ // must be escaped, and outside a class the ampersand is written bare.
161
+ const NAME_LETTER_OR_DIGIT =
162
+ '[0-9[[\\p{L}&&\\p{Script=Latin}]--\\p{Default_Ignorable_Code_Point}]]';
163
+ const NAME_APOSTROPHE = "['\\u{2019}]";
164
+ const NAME_JOINER = "[\\-.\\&'\\u{2019}]";
165
+ // Alphabet A = letters/digits, U+0020 and the five joiners; OUTSIDE_ALPHABET_RE is its negation.
166
+ const OUTSIDE_ALPHABET_RE = new RegExp(
167
+ "[^0-9[[\\p{L}&&\\p{Script=Latin}]--\\p{Default_Ignorable_Code_Point}]\\u{20}\\-.\\&'\\u{2019}]",
168
+ 'v'
169
+ );
170
+ // Grammar G: a word is an optional leading apostrophe, letters/digits, then (one joiner, more
171
+ // letters/digits) repeated, then one optional trailing dot or apostrophe. A value is words
172
+ // separated by one space, optionally with a bare `&` and one more space; a value starting and
173
+ // ending with an apostrophe is a quoted value, not a name.
174
+ const NAME_WORD =
175
+ `${NAME_APOSTROPHE}?${NAME_LETTER_OR_DIGIT}+(?:${NAME_JOINER}${NAME_LETTER_OR_DIGIT}+)*(?:\\.|${NAME_APOSTROPHE})?`;
176
+ const NAME_GRAMMAR_RE = new RegExp(
177
+ `^(?!${NAME_APOSTROPHE}.*${NAME_APOSTROPHE}$)${NAME_WORD}(?:\\u{20}(?:& )?${NAME_WORD})*$`,
178
+ 'v'
179
+ );
180
+
181
+ /**
182
+ * looksLikeEncodedValue(v) — true when a value is itself canonical base64 of printable UTF-8
183
+ * text (an operator encoded each value instead of the whole list). Requires a canonical
184
+ * round-trip AND a decode to printable text, so the live canary shape (`ppcanary` + hex, whose
185
+ * first decoded byte 0xA6 is never a valid UTF-8 lead byte) is never flagged.
186
+ *
187
+ * @param {string} v
188
+ * @returns {boolean}
189
+ */
190
+ function looksLikeEncodedValue(v) {
191
+ if (v.length < 8 || v.length % 4 !== 0 || !BASE64_RE.test(v)) return false;
192
+ const bytes = Buffer.from(v, 'base64');
193
+ if (bytes.toString('base64') !== v) return false;
194
+ let text;
195
+ try {
196
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
197
+ } catch {
198
+ return false;
199
+ }
200
+ return PRINTABLE_TEXT_RE.test(text);
201
+ }
202
+
203
+ /**
204
+ * decodeStrict(trimmed) — canonical-base64 and valid-UTF-8 decode (parser contract steps 1-3).
205
+ * Never a lenient decode: a truncated or corrupted value must fail here, not decode to garbage.
206
+ *
207
+ * @param {string} trimmed
208
+ * @returns {string} the decoded text
209
+ * @throws {ListError} `not valid base64` | `not canonical base64 (truncated or corrupted value)`
210
+ * | `decoded list is not valid UTF-8`
211
+ */
212
+ function decodeStrict(trimmed) {
213
+ if (!BASE64_RE.test(trimmed)) {
214
+ throw new ListError('not valid base64');
215
+ }
216
+
217
+ if (trimmed.length % 4 !== 0 || Buffer.from(trimmed, 'base64').toString('base64') !== trimmed) {
218
+ throw new ListError('not canonical base64 (truncated or corrupted value)');
219
+ }
220
+
221
+ const bytes = Buffer.from(trimmed, 'base64');
222
+ try {
223
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
224
+ } catch {
225
+ throw new ListError('decoded list is not valid UTF-8');
226
+ }
227
+ }
228
+
229
+ /**
230
+ * parseEntryLine(line, lineNo) — validate and categorise one non-blank, non-comment decoded
231
+ * line (parser contract steps 5-6). The role prefix must match exactly (case-insensitively),
232
+ * immediately followed by a colon — no trimming of the prefix, so whitespace before the colon is
233
+ * rejected. The unrecognised prefix text is never included in the thrown message: it may be part
234
+ * of a name (D-13 output discipline).
235
+ *
236
+ * @param {string} line
237
+ * @param {number} lineNo 1-based source line number (counts blank/comment lines, IN-03)
238
+ * @returns {{category: string, value: string}}
239
+ * @throws {ListError} `line <n> has no recognised role prefix (...)` |
240
+ * `line <n> has an empty value after its role prefix` | `line <n> is too short (min 4 characters)` |
241
+ * operator-UX pre-checks: `line <n> contains a second role prefix (...)` |
242
+ * `line <n> has an inline comment (...)` | `line <n> is quoted (...)` |
243
+ * `line <n> looks base64-encoded (...)` |
244
+ * security checks, in order: `line <n> contains a control or format character` |
245
+ * `line <n> contains a space other than a plain space (...)` |
246
+ * `line <n> contains an invisible character (...)` |
247
+ * `line <n> contains a character outside the allowed alphabet (...)` (alphabet A; the reason
248
+ * word is chosen by the class of the first offending character, which is never interpolated) |
249
+ * `line <n> is not NFKC-normalised (...)` | `line <n> is not a well-formed name (...)` (grammar G)
250
+ */
251
+ function parseEntryLine(line, lineNo) {
252
+ const colonIdx = line.indexOf(':');
253
+ const prefix = colonIdx >= 0 ? line.slice(0, colonIdx).toLowerCase() : null;
254
+ if (colonIdx < 0 || !ROLE_PREFIX_RE.test(line)) {
255
+ throw new ListError(
256
+ `line ${lineNo} has no recognised role prefix (expected one of ${ROLES.join(', ')}, followed directly by a colon)`
257
+ );
258
+ }
259
+
260
+ const value = line.slice(colonIdx + 1).trim();
261
+ if (value.length === 0) {
262
+ throw new ListError(`line ${lineNo} has an empty value after its role prefix`);
263
+ }
264
+ if (value.length < 4) {
265
+ throw new ListError(`line ${lineNo} is too short (min 4 characters)`);
266
+ }
267
+
268
+ // Operator-UX pre-checks (reason words only; the security checks below do not depend on them).
269
+ if (EMBEDDED_ROLE_RE.test(value)) {
270
+ throw new ListError(
271
+ `line ${lineNo} contains a second role prefix (entries must be newline-separated)`
272
+ );
273
+ }
274
+ if (INLINE_COMMENT_RE.test(value)) {
275
+ throw new ListError(`line ${lineNo} has an inline comment (put comments on their own line)`);
276
+ }
277
+ if (QUOTE_EDGE_RE.test(value) || QUOTE_PAIR_RE.test(value)) {
278
+ throw new ListError(`line ${lineNo} is quoted (write the value bare)`);
279
+ }
280
+ if (looksLikeEncodedValue(value)) {
281
+ throw new ListError(
282
+ `line ${lineNo} looks base64-encoded (encode the whole list once, not each value)`
283
+ );
284
+ }
285
+
286
+ // Security checks: alphabet A, then NFKC stability, then grammar G. The reason word for an
287
+ // alphabet failure comes from the class of the first offending character; the character
288
+ // itself, its code point and any fragment of the value are never put in the message.
289
+ const outside = OUTSIDE_ALPHABET_RE.exec(value);
290
+ if (outside) {
291
+ const ch = outside[0];
292
+ if (CONTROL_OR_FORMAT_RE.test(ch)) {
293
+ throw new ListError(`line ${lineNo} contains a control or format character`);
294
+ }
295
+ if (NONSTANDARD_SPACE_RE.test(ch)) {
296
+ throw new ListError(
297
+ `line ${lineNo} contains a space other than a plain space (retype it as a plain space)`
298
+ );
299
+ }
300
+ if (INVISIBLE_RE.test(ch)) {
301
+ throw new ListError(`line ${lineNo} contains an invisible character (retype the value)`);
302
+ }
303
+ throw new ListError(
304
+ `line ${lineNo} contains a character outside the allowed alphabet (Latin letters, digits, plain spaces, hyphens, dots, ampersands and apostrophes; list an in-alphabet part of the name)`
305
+ );
306
+ }
307
+ if (value !== value.normalize('NFKC')) {
308
+ throw new ListError(
309
+ `line ${lineNo} is not NFKC-normalised (retype compatibility or look-alike letter forms as plain letters)`
310
+ );
311
+ }
312
+ if (!NAME_GRAMMAR_RE.test(value)) {
313
+ throw new ListError(
314
+ `line ${lineNo} is not a well-formed name (single plain spaces between words; hyphens, dots, ampersands and apostrophes only between letters or digits)`
315
+ );
316
+ }
317
+
318
+ return { category: prefix, value };
319
+ }
320
+
321
+ /**
322
+ * parseList(b64) — decode and strictly validate the headered base64 role:value list (CR-03).
323
+ * Order: strict decode; required `# pp-neutrality-list entries=<N>` header on the first
324
+ * non-blank line; comment lines (control/format check, then skip); entry validation by
325
+ * parseEntryLine (prefix, length, operator-UX pre-checks, then alphabet / NFKC / grammar);
326
+ * zero-entry check; exact entry-count check.
327
+ *
328
+ * @param {string} b64
329
+ * @returns {Array<{category: string, value: string}>}
330
+ * @throws {ListError} on invalid or non-canonical base64, non-UTF-8 decoded bytes, a missing or
331
+ * malformed header, a control or format character in a comment line, an unrecognised or
332
+ * malformed role prefix, an empty or too-short value, a value rejected by the operator-UX
333
+ * pre-checks (second role prefix, inline comment, quoted, base64-encoded) or by the alphabet,
334
+ * NFKC or grammar checks (see parseEntryLine), zero usable entries, or an entry count that
335
+ * differs from the header.
336
+ */
337
+ function parseList(b64) {
338
+ const trimmed = typeof b64 === 'string' ? b64.trim() : '';
339
+ const decoded = decodeStrict(trimmed);
340
+
341
+ const rawLines = decoded.split('\n');
342
+ const entries = [];
343
+ let declared = null;
344
+ for (let i = 0; i < rawLines.length; i++) {
345
+ const line = rawLines[i].trim();
346
+ if (line.length === 0) continue;
347
+ const lineNo = i + 1;
348
+
349
+ if (declared === null) {
350
+ const m = HEADER_RE.exec(line);
351
+ if (m) {
352
+ declared = Number(m[1]);
353
+ continue;
354
+ }
355
+ if (line.startsWith('#') && /pp-neutrality-list/i.test(line)) {
356
+ throw new ListError(
357
+ `line ${lineNo} has a malformed list header (expected "# pp-neutrality-list entries=<N>")`
358
+ );
359
+ }
360
+ throw new ListError(
361
+ 'missing list header (the first non-blank line must be "# pp-neutrality-list entries=<N>")'
362
+ );
363
+ }
364
+
365
+ // IN-15: the control/format check exists once per line kind. A comment line gets it here
366
+ // (hidden entry-break protection); an entry value gets it only as one reason class of the
367
+ // alphabet check inside parseEntryLine.
368
+ if (line.startsWith('#')) {
369
+ if (CONTROL_OR_FORMAT_RE.test(line)) {
370
+ throw new ListError(`line ${lineNo} contains a control or format character`);
371
+ }
372
+ continue;
373
+ }
374
+ entries.push(parseEntryLine(line, lineNo));
375
+ }
376
+
377
+ if (declared === null) {
378
+ throw new ListError(
379
+ 'missing list header (the first non-blank line must be "# pp-neutrality-list entries=<N>")'
380
+ );
381
+ }
382
+ if (entries.length === 0) {
383
+ throw new ListError('0 usable entries');
384
+ }
385
+ if (entries.length !== declared) {
386
+ throw new ListError(
387
+ `entry count mismatch (header declares ${declared}, parsed ${entries.length})`
388
+ );
389
+ }
390
+
391
+ return entries;
392
+ }
393
+
394
+ /**
395
+ * resolveContext(env) — classify the run context from CI predefined variables.
396
+ *
397
+ * @param {NodeJS.ProcessEnv} env
398
+ * @returns {'local'|'merge_request_event'|'tag'|'protected'|'branch'}
399
+ */
400
+ function resolveContext(env) {
401
+ if (env.CI !== 'true') return 'local';
402
+ if (env.CI_PIPELINE_SOURCE === 'merge_request_event') return 'merge_request_event';
403
+ if (env.CI_COMMIT_TAG) return 'tag';
404
+ if (env.CI_COMMIT_REF_PROTECTED === 'true') return 'protected';
405
+ return 'branch';
406
+ }
407
+
408
+ /**
409
+ * maskText(text, entries) — replace every list value, case-insensitively, with `<category>`.
410
+ * Used for every path printed, so a value that happens to appear in a tracked path never
411
+ * reaches a report line verbatim.
412
+ *
413
+ * CR-05: masking is one pass over the ORIGINAL text. Every occurrence of every value is found
414
+ * first (overlapping occurrences included, each entry searched independently), the covered
415
+ * character ranges are merged, and each merged run is replaced once. So no character that
416
+ * belongs to any occurrence of any value survives — not the tail of a longer value that
417
+ * contains a shorter one, not a value that overlaps another at a different offset — and a
418
+ * value that happens to spell part of a category word cannot split a marker, because markers
419
+ * are never re-scanned. A merged run covering several categories prints them all, sorted and
420
+ * `|`-joined.
421
+ *
422
+ * @param {string} text
423
+ * @param {Array<{category: string, value: string}>} entries
424
+ * @returns {string}
425
+ */
426
+ function maskText(text, entries) {
427
+ const spans = [];
428
+ for (const { category, value } of entries) {
429
+ const re = new RegExp(escapeRegExp(value), 'gi');
430
+ let m = re.exec(text);
431
+ while (m !== null) {
432
+ spans.push({ start: m.index, end: m.index + m[0].length, category });
433
+ re.lastIndex = m.index + 1; // overlapping occurrences of the same value
434
+ m = re.exec(text);
435
+ }
436
+ }
437
+ if (spans.length === 0) return text;
438
+
439
+ spans.sort((x, y) => x.start - y.start || y.end - x.end);
440
+ const merged = [];
441
+ for (const span of spans) {
442
+ const last = merged[merged.length - 1];
443
+ if (last && span.start < last.end) {
444
+ last.end = Math.max(last.end, span.end);
445
+ last.categories.add(span.category);
446
+ } else {
447
+ merged.push({ start: span.start, end: span.end, categories: new Set([span.category]) });
448
+ }
449
+ }
450
+
451
+ let out = '';
452
+ let cursor = 0;
453
+ for (const run of merged) {
454
+ out += text.slice(cursor, run.start) + `<${[...run.categories].sort().join('|')}>`;
455
+ cursor = run.end;
456
+ }
457
+ return out + text.slice(cursor);
458
+ }
459
+
460
+ /**
461
+ * isBinary(fullPath) — NUL-byte probe on the first 8 KiB.
462
+ *
463
+ * @param {string} fullPath
464
+ * @returns {boolean}
465
+ */
466
+ function isBinary(fullPath) {
467
+ let fd;
468
+ try {
469
+ fd = fs.openSync(fullPath, 'r');
470
+ const buf = Buffer.allocUnsafe(8192);
471
+ const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
472
+ return buf.subarray(0, bytesRead).includes(0);
473
+ } catch {
474
+ return false;
475
+ } finally {
476
+ if (fd != null) {
477
+ try { fs.closeSync(fd); } catch { /* best-effort */ }
478
+ }
479
+ }
480
+ }
481
+
482
+ /**
483
+ * listTrackedFiles(repoRoot) — `git ls-files -z`, split on NUL (handles unusual filenames).
484
+ *
485
+ * @param {string} repoRoot
486
+ * @returns {string[]}
487
+ */
488
+ function listTrackedFiles(repoRoot) {
489
+ let raw;
490
+ try {
491
+ raw = execFileSync('git', ['ls-files', '-z'], {
492
+ cwd: repoRoot,
493
+ encoding: 'utf8',
494
+ maxBuffer: 64 * 1024 * 1024,
495
+ });
496
+ } catch (err) {
497
+ throw new ExitError(1, 'ERROR lint-neutrality-names: git ls-files failed: ' + err.message);
498
+ }
499
+ return raw.split('\u0000').filter(Boolean);
500
+ }
501
+
502
+ /**
503
+ * matchCategories(text, combinedSource, lookup) — run one combined case-insensitive RegExp
504
+ * (built from every escaped list value) against `text` and map each distinct match back to
505
+ * its category via `lookup` (value.toLowerCase() -> category).
506
+ *
507
+ * @param {string} text
508
+ * @param {string} combinedSource RegExp source, values already escaped and `|`-joined,
509
+ * longest-value-first so an entry never shadows a longer one that contains it.
510
+ * @param {Map<string, string>} lookup
511
+ * @returns {Set<string>} distinct categories matched in `text`
512
+ */
513
+ function matchCategories(text, combinedSource, lookup) {
514
+ const found = new Set();
515
+ const re = new RegExp(combinedSource, 'gi');
516
+ let m = re.exec(text);
517
+ while (m !== null) {
518
+ const category = lookup.get(m[0].toLowerCase());
519
+ if (category) found.add(category);
520
+ if (m.index === re.lastIndex) re.lastIndex += 1; // guard against a zero-length match loop
521
+ m = re.exec(text);
522
+ }
523
+ return found;
524
+ }
525
+
526
+ /**
527
+ * scanTree(repoRoot, entries) — whole-tracked-tree scan with the single named exemption
528
+ * (D-11), reusing the git ls-files walk / binary skip / per-line test shape from
529
+ * scripts/verify-rename-guard.cjs's collectHits().
530
+ *
531
+ * @param {string} repoRoot
532
+ * @param {Array<{category: string, value: string}>} entries
533
+ * @returns {{ hits: Array<{file: string, line: number|null, category: string, where: 'content'|'path'}>, fileCount: number }}
534
+ */
535
+ function scanTree(repoRoot, entries) {
536
+ const files = listTrackedFiles(repoRoot).filter(
537
+ (relPath) => !EXEMPT_PATHS.some((exempt) => relPath.startsWith(exempt))
538
+ );
539
+
540
+ const lookup = new Map();
541
+ for (const { category, value } of entries) lookup.set(value.toLowerCase(), category);
542
+
543
+ // Longest value first so, at a shared match position, a longer entry that contains a
544
+ // shorter one wins the alternation.
545
+ const combinedSource = [...entries]
546
+ .sort((a, b) => b.value.length - a.value.length)
547
+ .map((entry) => escapeRegExp(entry.value))
548
+ .join('|');
549
+
550
+ const hits = [];
551
+ for (const relPath of files) {
552
+ const fullPath = path.join(repoRoot, relPath);
553
+ const maskedPath = maskText(relPath, entries);
554
+
555
+ for (const category of matchCategories(relPath, combinedSource, lookup)) {
556
+ hits.push({ file: maskedPath, line: null, category, where: 'path' });
557
+ }
558
+
559
+ if (isBinary(fullPath)) continue;
560
+
561
+ let content;
562
+ try {
563
+ content = fs.readFileSync(fullPath, 'utf8');
564
+ } catch {
565
+ continue; // unreadable file (permissions, race with deletion) — skip silently
566
+ }
567
+
568
+ const lines = content.split('\n');
569
+ for (let i = 0; i < lines.length; i++) {
570
+ for (const category of matchCategories(lines[i], combinedSource, lookup)) {
571
+ hits.push({ file: maskedPath, line: i + 1, category, where: 'content' });
572
+ }
573
+ }
574
+ }
575
+
576
+ return { hits, fileCount: files.length };
577
+ }
578
+
579
+ /**
580
+ * runScan(opts?) — pure scan entry point (no process.exit, no stdout/stderr writes).
581
+ *
582
+ * @param {object} [opts]
583
+ * @param {string} [opts.repoRoot] defaults to the module-level REPO_ROOT
584
+ * @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
585
+ * @returns {{
586
+ * status: 'skipped'|'failed-closed'|'invalid'|'clean'|'violations',
587
+ * context: 'local'|'merge_request_event'|'tag'|'protected'|'branch',
588
+ * hits: Array<{file: string, line: number|null, category: string, where: 'content'|'path'}>,
589
+ * fileCount: number,
590
+ * entryCounts: Object<string, number>,
591
+ * message?: string,
592
+ * }}
593
+ */
594
+ function runScan(opts) {
595
+ const env = (opts && opts.env) || process.env;
596
+ const repoRoot = (opts && opts.repoRoot) || REPO_ROOT;
597
+ const context = resolveContext(env);
598
+ const rawList = env[VARIABLE_NAME];
599
+
600
+ if (!rawList) {
601
+ return {
602
+ status: context === 'local' ? 'skipped' : 'failed-closed',
603
+ context,
604
+ hits: [],
605
+ fileCount: 0,
606
+ entryCounts: {},
607
+ };
608
+ }
609
+
610
+ let entries;
611
+ try {
612
+ entries = parseList(rawList);
613
+ } catch (err) {
614
+ if (err instanceof ListError) {
615
+ return {
616
+ status: 'invalid',
617
+ context,
618
+ hits: [],
619
+ fileCount: 0,
620
+ entryCounts: {},
621
+ message: err.message,
622
+ };
623
+ }
624
+ throw err;
625
+ }
626
+
627
+ const entryCounts = {};
628
+ for (const { category } of entries) entryCounts[category] = (entryCounts[category] || 0) + 1;
629
+
630
+ const { hits, fileCount } = scanTree(repoRoot, entries);
631
+
632
+ return {
633
+ status: hits.length === 0 ? 'clean' : 'violations',
634
+ context,
635
+ hits,
636
+ fileCount,
637
+ entryCounts,
638
+ };
639
+ }
640
+
641
+ // ── CLI entry ────────────────────────────────────────────────────────────────
642
+
643
+ function cliMain() {
644
+ const result = runScan();
645
+ const { status, context, hits, fileCount, entryCounts, message } = result;
646
+
647
+ if (status === 'skipped') {
648
+ process.stderr.write(
649
+ `NOTICE lint-neutrality-names: SKIPPED — ${VARIABLE_NAME} is not set and this is not a CI run; the name scan did not run.\n`
650
+ );
651
+ return 0;
652
+ }
653
+
654
+ if (status === 'failed-closed') {
655
+ throw new ExitError(
656
+ 1,
657
+ `FAIL lint-neutrality-names: ${VARIABLE_NAME} is not set in CI (context=${context}); failing closed.`
658
+ );
659
+ }
660
+
661
+ if (status === 'invalid') {
662
+ throw new ExitError(1, `FAIL lint-neutrality-names: list invalid — ${message} (context=${context})`);
663
+ }
664
+
665
+ if (status === 'clean') {
666
+ const entryTotal = Object.values(entryCounts).reduce((sum, n) => sum + n, 0);
667
+ const catSummary = Object.keys(entryCounts).sort()
668
+ .map((category) => `${category}=${entryCounts[category]}`)
669
+ .join(', ');
670
+ process.stdout.write(
671
+ `ok lint-neutrality-names: ${fileCount} tracked files scanned against ${entryTotal} list entries (${catSummary}), 0 hits, context=${context}\n`
672
+ );
673
+ return 0;
674
+ }
675
+
676
+ // violations
677
+ process.stderr.write(`FAIL lint-neutrality-names: ${hits.length} hit(s) (context=${context})\n`);
678
+ for (const hit of hits) {
679
+ if (hit.where === 'content') {
680
+ process.stderr.write(` ${hit.file}:${hit.line} — category=${hit.category}\n`);
681
+ } else {
682
+ process.stderr.write(` ${hit.file} — category=${hit.category} (in path)\n`);
683
+ }
684
+ }
685
+ process.stderr.write('reword by role; the list lives only in the masked CI variable\n');
686
+
687
+ throw new ExitError(1);
688
+ }
689
+
690
+ module.exports = { VARIABLE_NAME, EXEMPT_PATHS, ROLES, parseList, resolveContext, runScan, cliMain };
691
+
692
+ if (require.main === module) {
693
+ runMain(cliMain);
694
+ }