@pareto4/pp-core 1.11.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +31 -0
- package/agents/pp-code-fixer.md +1 -1
- package/agents/pp-debug-session-manager.md +1 -1
- package/agents/pp-debugger.md +1 -1
- package/agents/pp-executor.md +20 -10
- package/agents/pp-intel-updater.md +1 -1
- package/agents/pp-phase-researcher.md +1 -1
- package/agents/pp-plan-checker.md +5 -5
- package/agents/pp-planner.md +10 -24
- package/agents/pp-project-researcher.md +1 -1
- package/agents/pp-research-synthesizer.md +1 -1
- package/agents/pp-ui-researcher.md +1 -1
- package/agents/pp-verifier.md +8 -4
- package/bin/install.js +429 -82
- package/commands/pp-autonomous.md +8 -6
- package/commands/pp-discovery-to-build.md +1 -1
- package/commands/pp-discovery.md +3 -2
- package/commands/pp-discuss-phase.md +2 -2
- package/commands/pp-execute-phase.md +3 -1
- package/commands/pp-graphify.md +5 -5
- package/commands/pp-import.md +1 -1
- package/commands/pp-map-codebase.md +1 -1
- package/commands/pp-ns-project.md +2 -3
- package/commands/pp-ns-workflow.md +1 -1
- package/commands/pp-plan-phase.md +2 -1
- package/commands/pp-verify-work.md +2 -1
- package/gemini-extension.json +1 -1
- package/hooks/dist/pp-cursor-session-start.js +2 -2
- package/hooks/dist/pp-session-state.sh +1 -1
- package/hooks/dist/pp-statusline.js +21 -2
- package/hooks/pp-cursor-session-start.js +2 -2
- package/hooks/pp-session-state.sh +1 -1
- package/hooks/pp-statusline.js +21 -2
- package/package.json +18 -5
- package/pareto-skills/pp-project-setup/SKILL.md +5 -63
- package/pp-core/bin/detect-build-commands.sh +182 -0
- package/pp-core/bin/lib/capability-registry.cjs +65 -7
- package/pp-core/bin/lib/check-command-router.cjs +79 -38
- package/pp-core/bin/lib/clusters.cjs +2 -3
- package/pp-core/bin/lib/command-aliases.cjs +36 -0
- package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
- package/pp-core/bin/lib/commands.cjs +21 -6
- package/pp-core/bin/lib/compose-bindings.cjs +128 -15
- package/pp-core/bin/lib/config-schema.cjs +16 -0
- package/pp-core/bin/lib/config.cjs +142 -27
- package/pp-core/bin/lib/core-utils.cjs +43 -7
- package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
- package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
- package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
- package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
- package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
- package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
- package/pp-core/bin/lib/discovery-table.cjs +98 -0
- package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
- package/pp-core/bin/lib/frontmatter.cjs +17 -2
- package/pp-core/bin/lib/init-command-router.cjs +8 -5
- package/pp-core/bin/lib/init-contract.cjs +213 -33
- package/pp-core/bin/lib/init-interactive.cjs +20 -1
- package/pp-core/bin/lib/init.cjs +124 -134
- package/pp-core/bin/lib/install-profiles.cjs +5 -0
- package/pp-core/bin/lib/install-run.cjs +536 -0
- package/pp-core/bin/lib/io.cjs +15 -0
- package/pp-core/bin/lib/loop-resolver.cjs +24 -0
- package/pp-core/bin/lib/milestone.cjs +345 -48
- package/pp-core/bin/lib/phase-command-router.cjs +23 -1
- package/pp-core/bin/lib/phase-locator.cjs +15 -6
- package/pp-core/bin/lib/phase-scope.cjs +91 -0
- package/pp-core/bin/lib/phase-tier.cjs +467 -0
- package/pp-core/bin/lib/phase.cjs +247 -21
- package/pp-core/bin/lib/phases-command-router.cjs +2 -0
- package/pp-core/bin/lib/plan-graph.cjs +368 -0
- package/pp-core/bin/lib/planning-workspace.cjs +53 -0
- package/pp-core/bin/lib/platform-bindings.cjs +32 -2
- package/pp-core/bin/lib/profile-output.cjs +11 -3
- package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
- package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
- package/pp-core/bin/lib/roadmap.cjs +141 -39
- package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
- package/pp-core/bin/lib/state-command-router.cjs +82 -13
- package/pp-core/bin/lib/state-document.cjs +124 -7
- package/pp-core/bin/lib/state.cjs +784 -257
- package/pp-core/bin/lib/template.cjs +22 -6
- package/pp-core/bin/lib/vault-command-router.cjs +57 -6
- package/pp-core/bin/lib/vault-practices.cjs +6 -36
- package/pp-core/bin/lib/vault-write-back.cjs +20 -4
- package/pp-core/bin/lib/vault.cjs +555 -23
- package/pp-core/bin/lib/verify.cjs +53 -31
- package/pp-core/bin/lib/workstream.cjs +1 -1
- package/pp-core/bin/lib/worktree-safety.cjs +26 -15
- package/pp-core/bin/portable-timeout.sh +89 -0
- package/pp-core/bin/pp-tools.cjs +158 -8
- package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
- package/pp-core/bin/shared/config-schema.manifest.json +3 -0
- package/pp-core/references/autonomous-best-effort.md +461 -0
- package/pp-core/references/autonomous-lifecycle.md +146 -0
- package/pp-core/references/autonomous-smart-discuss.md +16 -12
- package/pp-core/references/checkpoints.md +1 -1
- package/pp-core/references/discovery-id-conventions.md +202 -0
- package/pp-core/references/discovery-rebaseline.md +254 -0
- package/pp-core/references/discovery-research-track.md +283 -0
- package/pp-core/references/discovery-traceability.md +400 -0
- package/pp-core/references/loop-hook-dispatch.md +7 -0
- package/pp-core/references/mandatory-initial-read.md +1 -1
- package/pp-core/references/planner-gap-closure.md +1 -0
- package/pp-core/references/planner-human-verify-mode.md +1 -1
- package/pp-core/references/planner-load-graph-context.md +1 -1
- package/pp-core/references/planner-plan-graph.md +66 -0
- package/pp-core/references/planner-tdd.md +1 -0
- package/pp-core/references/planning-config.md +8 -0
- package/pp-core/references/sketch-tooling.md +4 -0
- package/pp-core/references/tdd.md +1 -0
- package/pp-core/templates/claude-md.md +1 -1
- package/pp-core/templates/phase-prompt.md +6 -2
- package/pp-core/templates/prototype-review/review.js +303 -0
- package/pp-core/templates/state.md +6 -0
- package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/pp-core/workflows/add-backlog.md +1 -1
- package/pp-core/workflows/add-phase.md +1 -1
- package/pp-core/workflows/add-tests.md +1 -1
- package/pp-core/workflows/add-todo.md +1 -1
- package/pp-core/workflows/ai-integration-phase.md +2 -2
- package/pp-core/workflows/audit-fix.md +12 -16
- package/pp-core/workflows/audit-milestone.md +1 -1
- package/pp-core/workflows/audit-uat.md +1 -1
- package/pp-core/workflows/autonomous.md +68 -306
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/cleanup.md +1 -1
- package/pp-core/workflows/code-review-fix.md +1 -1
- package/pp-core/workflows/code-review.md +19 -7
- package/pp-core/workflows/complete-milestone.md +41 -60
- package/pp-core/workflows/debug.md +1 -1
- package/pp-core/workflows/diagnose-issues.md +1 -1
- package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
- package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
- package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
- package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
- package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
- package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
- package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
- package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
- package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
- package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
- package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
- package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
- package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
- package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
- package/pp-core/workflows/discovery-sprint.md +79 -1714
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
- package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
- package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
- package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
- package/pp-core/workflows/discovery-to-build.md +21 -238
- package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
- package/pp-core/workflows/discuss-phase.md +9 -2
- package/pp-core/workflows/do.md +4 -4
- package/pp-core/workflows/docs-update.md +1 -1
- package/pp-core/workflows/edit-phase.md +1 -1
- package/pp-core/workflows/eval-review.md +1 -1
- package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
- package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
- package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
- package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
- package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
- package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
- package/pp-core/workflows/execute-phase.md +102 -216
- package/pp-core/workflows/execute-plan.md +6 -2
- package/pp-core/workflows/explore.md +1 -1
- package/pp-core/workflows/extract-learnings.md +1 -1
- package/pp-core/workflows/forensics.md +4 -4
- package/pp-core/workflows/graduation.md +1 -1
- package/pp-core/workflows/health.md +1 -1
- package/pp-core/workflows/help/modes/brief.md +1 -0
- package/pp-core/workflows/help/modes/default.md +2 -1
- package/pp-core/workflows/help/modes/full.md +12 -5
- package/pp-core/workflows/import.md +1 -1
- package/pp-core/workflows/ingest-docs.md +1 -1
- package/pp-core/workflows/init.md +18 -2
- package/pp-core/workflows/insert-phase.md +1 -1
- package/pp-core/workflows/list-workspaces.md +1 -1
- package/pp-core/workflows/manager.md +3 -3
- package/pp-core/workflows/map-codebase.md +2 -2
- package/pp-core/workflows/milestone-summary.md +4 -4
- package/pp-core/workflows/mvp-phase.md +1 -1
- package/pp-core/workflows/new-milestone.md +7 -8
- package/pp-core/workflows/new-project.md +6 -1
- package/pp-core/workflows/new-workspace.md +1 -1
- package/pp-core/workflows/next.md +2 -2
- package/pp-core/workflows/note.md +2 -2
- package/pp-core/workflows/pause-work.md +1 -1
- package/pp-core/workflows/plan-milestone-gaps.md +1 -1
- package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
- package/pp-core/workflows/plan-phase.md +43 -167
- package/pp-core/workflows/plan-review-convergence.md +2 -2
- package/pp-core/workflows/plant-seed.md +1 -1
- package/pp-core/workflows/pr-branch.md +1 -1
- package/pp-core/workflows/profile-user.md +1 -1
- package/pp-core/workflows/progress.md +3 -3
- package/pp-core/workflows/quick.md +3 -1
- package/pp-core/workflows/remove-phase.md +1 -1
- package/pp-core/workflows/remove-workspace.md +1 -1
- package/pp-core/workflows/resume-project.md +6 -12
- package/pp-core/workflows/review.md +1 -1
- package/pp-core/workflows/scan.md +1 -1
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/settings-advanced.md +1 -1
- package/pp-core/workflows/settings-integrations.md +1 -1
- package/pp-core/workflows/settings.md +1 -1
- package/pp-core/workflows/ship.md +25 -8
- package/pp-core/workflows/sketch-wrap-up.md +1 -1
- package/pp-core/workflows/sketch.md +2 -1
- package/pp-core/workflows/spec-phase.md +1 -1
- package/pp-core/workflows/spike-wrap-up.md +1 -1
- package/pp-core/workflows/spike.md +1 -1
- package/pp-core/workflows/stats.md +2 -2
- package/pp-core/workflows/thread.md +1 -1
- package/pp-core/workflows/transition.md +5 -9
- package/pp-core/workflows/ui-phase.md +23 -2
- package/pp-core/workflows/ui-review.md +1 -1
- package/pp-core/workflows/ultraplan-phase.md +2 -2
- package/pp-core/workflows/validate-phase.md +1 -1
- package/pp-core/workflows/vault-write-back.md +1 -1
- package/pp-core/workflows/verify-phase.md +29 -26
- package/pp-core/workflows/verify-work.md +14 -10
- package/scripts/base64-scan.sh +7 -41
- package/scripts/changeset/cli.cjs +25 -5
- package/scripts/dev-env.cjs +462 -0
- package/scripts/lib/allowlist-ratchet.cjs +103 -1
- package/scripts/lib/size-budget.cjs +311 -0
- package/scripts/lint-neutrality-names.cjs +694 -0
- package/scripts/lint-org-coordinates.allowlist.json +35 -0
- package/scripts/lint-org-coordinates.cjs +426 -0
- package/scripts/lint-skill-deps.cjs +48 -9
- package/scripts/lint-test-file-count.cjs +17 -2
- package/scripts/prompt-injection-scan.sh +41 -20
- package/scripts/sync-manifest-versions.cjs +41 -7
- package/scripts/sync-runtime-launcher.cjs +106 -1
- package/scripts/update-size-baseline.cjs +274 -30
- package/scripts/workflow-size.cjs +61 -9
- package/commands/pp-build-handoff.md +0 -46
- 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
|
+
}
|