@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.2

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 (112) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +112 -114
  13. package/package.json +5 -2
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -13
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  31. package/pipeline/scripts/log-metric.sh +9 -3
  32. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  33. package/pipeline/scripts/pre-commit-check.sh +119 -27
  34. package/pipeline/scripts/scan-agent-config.sh +9 -9
  35. package/pipeline/scripts/unattended_policy.py +12 -3
  36. package/pipeline/scripts/uninstall.mjs +88 -1
  37. package/pipeline/scripts/usage-identity.mjs +1 -1
  38. package/pipeline/scripts/usage-register.mjs +1 -1
  39. package/pipeline/skills/.skill-manifest.json +30 -30
  40. package/pipeline/skills/shared/README.md +64 -64
  41. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  42. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  43. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  44. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  45. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  46. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  47. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  48. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  49. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  50. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  51. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  53. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  54. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  55. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  56. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  57. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  58. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  59. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  60. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  61. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  62. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
  63. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
  64. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
  65. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
  66. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
  67. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
  68. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
  69. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  70. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
  71. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
  72. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
  73. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  74. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
  75. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
  76. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
  77. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
  78. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
  79. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  80. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  81. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  82. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  83. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  84. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  85. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  86. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  87. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  88. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  89. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  90. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  91. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  92. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  93. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  94. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  95. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  96. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  97. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  98. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  99. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  100. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  101. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  102. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  103. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  104. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  105. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  106. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  107. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  108. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  109. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
  110. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  111. package/pipeline/scripts/make-manifest.mjs +0 -199
  112. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "20.8.0",
3
+ "version": "20.8.2",
4
4
  "description": "6-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "start": "node index.js",
23
- "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/eval-guard-robustness.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
23
+ "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/gen-skills-index.mjs --check && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/eval-guard-robustness.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
24
24
  "test:unit": "node --test test/*.test.mjs",
25
25
  "test:smoke": "node pipeline/scripts/run-smokes.mjs",
26
26
  "lint:skills": "node pipeline/scripts/lint-skills.mjs",
@@ -94,6 +94,9 @@
94
94
  "!pipeline/scripts/run-smokes.mjs",
95
95
  "!pipeline/scripts/eval-*.mjs",
96
96
  "!pipeline/scripts/check-rewrite-overlap.mjs",
97
+ "!pipeline/scripts/gen-ref-toc.mjs",
98
+ "!pipeline/scripts/make-manifest.mjs",
99
+ "!pipeline/scripts/scorecard-snapshot.mjs",
97
100
  "!pipeline/scripts/scorecard.mjs",
98
101
  "!pipeline/scripts/lint-skills.mjs",
99
102
  "!pipeline/scripts/lint-personas.mjs",
@@ -28,10 +28,16 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
28
28
 
29
29
  ## Steps
30
30
 
31
- 1. **Resolve the package and both versions** (run steps 1-3 in ONE shell block so the variables survive):
31
+ Every fenced block below runs in a fresh shell, so no variable survives from one
32
+ block to the next. Step 1 writes the values the later steps need to a state file,
33
+ and every later block that needs them starts by sourcing it.
34
+
35
+ 1. **Resolve the package and both versions**:
32
36
  ```bash
37
+ STATE="${TMPDIR:-/tmp}/multi-agent-update.env"
33
38
  PKG="@{npm-scope}/multi-agent-pipeline"
34
39
  REG="https://registry.npmjs.org"
40
+ UPD_DIR="${TMPDIR:-/tmp}/multi-agent-update"
35
41
  CUR=$(tr -d '[:space:]' < "$HOME/.claude/.pipeline-version" 2>/dev/null)
36
42
  [ -n "$CUR" ] || CUR="unknown"
37
43
  # Read the registry directly - `npm view` can answer from a stale local cache.
@@ -45,23 +51,25 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
45
51
  REQUIRED=$(curl -fsS -H 'Accept: application/vnd.npm.install-v1+json' \
46
52
  "$REG/$(printf '%s' "$PKG" | sed 's|/|%2F|')" \
47
53
  | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8"))["dist-tags"].required || ""' 2>/dev/null)
54
+ printf 'PKG=%q\nREG=%q\nUPD_DIR=%q\nCUR=%q\nLATEST=%q\n' \
55
+ "$PKG" "$REG" "$UPD_DIR" "$CUR" "$LATEST" > "$STATE"
48
56
  echo "Current: v$CUR Latest: v$LATEST${REQUIRED:+ Required floor: v$REQUIRED}"
49
57
  ```
50
58
 
51
59
  This command is never blocked by the floor - it is the remedy for it. The
52
- line above is where the floor is reported, so the user sees why a run halted;
53
- `$REQUIRED` does not survive into the later shell blocks.
60
+ line above is where the floor is reported, so the user sees why a run halted.
54
61
 
55
62
  2. **Stop early when already current.** When the line below prints, skip
56
63
  step 3 (no download, no reinstall) and continue at step 4b, so the stack
57
64
  plugins are still refreshed:
58
65
  ```bash
66
+ . "${TMPDIR:-/tmp}/multi-agent-update.env" || { echo "Run step 1 first."; exit 1; }
59
67
  [ "$CUR" = "$LATEST" ] && echo "✓ Already up to date: v$LATEST"
60
68
  ```
61
69
 
62
70
  3. **Download the release and install**:
63
71
  ```bash
64
- UPD_DIR="${TMPDIR:-/tmp}/multi-agent-update"
72
+ . "${TMPDIR:-/tmp}/multi-agent-update.env" || { echo "Run step 1 first."; exit 1; }
65
73
  rm -rf "$UPD_DIR" && mkdir -p "$UPD_DIR"
66
74
  # `--registry` does NOT override a scope mapping. A user-level .npmrc line
67
75
  # like `@scope:registry=https://npm.pkg.github.com` wins over it, so the
@@ -80,10 +88,15 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
80
88
  TGZ="$UPD_DIR/$(printf '%s' "$PACK_OUT" | tail -1 | tr -d '[:space:]')"
81
89
  [ -f "$TGZ" ] || { echo "npm pack reported no tarball; aborting."; exit 1; }
82
90
  tar -xzf "$TGZ" -C "$UPD_DIR"
83
- node "$UPD_DIR/package/install.js" --all
91
+ node "$UPD_DIR/package/install.js" --installed
84
92
  ```
85
93
 
86
- The installer refreshes every configured CLI target (Claude Code, Copilot CLI, Codex CLI) and writes the new version to `$HOME/.claude/.pipeline-version`.
94
+ `--installed` refreshes exactly the CLI targets that already carry a version
95
+ stamp (`$HOME/.claude/.pipeline-version`, `$HOME/.copilot/.pipeline-version`,
96
+ `$HOME/.codex/.pipeline-version`) and adds none: an update never installs
97
+ Copilot CLI or Codex CLI on a machine that only had Claude Code. With no stamp
98
+ at all it installs Claude Code. Each refreshed target gets the new version
99
+ stamp.
87
100
 
88
101
  4b. **Update the stack plugins** (catalog refresh, then every installed plugin in its own scope):
89
102
  ```bash
@@ -98,10 +111,8 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
98
111
  echo " -> no global adapter files to prune"
99
112
  ```
100
113
 
101
- > **Do NOT delete `$HOME/.codex/prompts/multi-agent.md`.** Releases up to
102
- > v12.11.0 removed it here as a retired v9.7.0 adapter leftover. Codex CLI is a
103
- > supported target again as of v13.0.0 and the installer writes that file, so
104
- > deleting it silently breaks the `/multi-agent` slash command on Codex.
114
+ > **Do NOT delete `$HOME/.codex/prompts/multi-agent.md`.** The Codex installer
115
+ > writes it and it backs the `/multi-agent` slash command on Codex CLI.
105
116
 
106
117
  5. **Migrate preferences** (if there is an old schema):
107
118
  ```bash
@@ -110,11 +121,9 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
110
121
  fi
111
122
  ```
112
123
 
113
- 5b. **Auto-configure operational reporting.** One call, and it is the same call
114
- `/multi-agent:setup` and a first run make - the registration used to live here
115
- as forty lines of shell, so a user who installed, ran setup and never ran
116
- update was never registered and never reported, which reads in the panel
117
- exactly like nobody using the pipeline.
124
+ 5b. **Auto-configure operational reporting.** One call, the same one
125
+ `/multi-agent:setup` and a first run make, so a machine is registered by
126
+ whichever of the three runs first.
118
127
 
119
128
  ```bash
120
129
  node "$HOME/.claude/scripts/usage-register.mjs"
@@ -128,6 +137,7 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
128
137
 
129
138
  6. **Show the new version and its changes** (from the packaged CHANGELOG - there is no git history on this channel):
130
139
  ```bash
140
+ . "${TMPDIR:-/tmp}/multi-agent-update.env" || { echo "Run step 1 first."; exit 1; }
131
141
  NEW=$(tr -d '[:space:]' < "$HOME/.claude/.pipeline-version" 2>/dev/null)
132
142
  echo ""
133
143
  echo "✓ Updated: v$CUR → v$NEW"
@@ -140,11 +150,12 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
140
150
 
141
151
  7. **Smoke test (optional)** - the release tarball ships exactly two smokes for this purpose:
142
152
  ```bash
153
+ . "${TMPDIR:-/tmp}/multi-agent-update.env" || { echo "Run step 1 first."; exit 1; }
143
154
  echo ""
144
155
  echo "Verification:"
145
156
  bash "$UPD_DIR/package/pipeline/scripts/smoke-schema-validation.sh" 2>&1 | tail -1
146
157
  bash "$UPD_DIR/package/pipeline/scripts/smoke-cross-cli-behavior.sh" 2>&1 | tail -1
147
- rm -rf "$UPD_DIR"
158
+ rm -rf "$UPD_DIR" "${TMPDIR:-/tmp}/multi-agent-update.env"
148
159
  ```
149
160
 
150
161
  ## Output
@@ -152,7 +163,7 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
152
163
  ```
153
164
  Current: v15.6.0 Latest: v15.6.1
154
165
  -> npm pack @{npm-scope}/multi-agent-pipeline@15.6.1
155
- -> node install.js --all (56 commands, 263 scripts, 210 skills)
166
+ -> node install.js --installed (Claude Code; no Copilot CLI or Codex CLI stamp, not installed)
156
167
  -> migrate-prefs.mjs (0 changes - already v2.6.0)
157
168
 
158
169
  ✓ Updated: v15.6.0 → v15.6.1
@@ -1,36 +1,23 @@
1
1
  {
2
- "_readme": "Characters a guard folds before matching, applied after NFKC. `strip` lists invisible characters that are removed. `map` sends a character that renders like an ASCII one to that ASCII character: the dash family, and the Cyrillic and Greek letters whose glyphs match Latin ones. Chosen from the Unicode confusables data for the letters and punctuation the guard rules use. Read by lib/normalize-text.mjs and scripts/agent-guard.py.",
3
- "strip": [
4
- "\u00ad",
5
- "\u034f",
6
- "\u061c",
7
- "\u115f",
8
- "\u1160",
9
- "\u17b4",
10
- "\u17b5",
11
- "\u180e",
12
- "\u200b",
13
- "\u200c",
14
- "\u200d",
15
- "\u200e",
16
- "\u200f",
17
- "\u202a",
18
- "\u202b",
19
- "\u202c",
20
- "\u202d",
21
- "\u202e",
22
- "\u2060",
23
- "\u2061",
24
- "\u2062",
25
- "\u2063",
26
- "\u2064",
27
- "\u2066",
28
- "\u2067",
29
- "\u2068",
30
- "\u2069",
31
- "\u3164",
32
- "\ufeff",
33
- "\uffa0"
2
+ "_readme": "Characters a guard folds before matching, applied after NFKC. `stripRanges` lists the Default_Ignorable_Code_Point set as hex code point ranges (a single value or `FIRST-LAST`); every character in them is removed. `map` sends a character that renders like an ASCII one to that ASCII character: the dash family, the Cyrillic and Greek letters whose glyphs match Latin ones, and the compatibility letters and digits added after Unicode 13 whose NFKC form is ASCII, so a Python whose unicodedata predates them folds them the way Node does. Chosen from the Unicode confusables data for the letters and punctuation the guard rules use. Read by lib/normalize-text.mjs and scripts/agent-guard.py.",
3
+ "stripRanges": [
4
+ "00AD",
5
+ "034F",
6
+ "061C",
7
+ "115F-1160",
8
+ "17B4-17B5",
9
+ "180B-180F",
10
+ "200B-200F",
11
+ "202A-202E",
12
+ "2060-206F",
13
+ "3164",
14
+ "FE00-FE0F",
15
+ "FEFF",
16
+ "FFA0",
17
+ "FFF0-FFF8",
18
+ "1BCA0-1BCA3",
19
+ "1D173-1D17A",
20
+ "E0000-E0FFF"
34
21
  ],
35
22
  "map": {
36
23
  "\u2010": "-",
@@ -95,6 +82,65 @@
95
82
  "\u03b1": "a",
96
83
  "\u03bd": "v",
97
84
  "\u03c1": "p",
98
- "\u0131": "i"
85
+ "\u0131": "i",
86
+ "\ua7f1": "S",
87
+ "\ua7f2": "C",
88
+ "\ua7f3": "F",
89
+ "\ua7f4": "Q",
90
+ "\ud801\udf81": ":",
91
+ "\ud801\udfa5": "q",
92
+ "\ud833\udcd6": "A",
93
+ "\ud833\udcd7": "B",
94
+ "\ud833\udcd8": "C",
95
+ "\ud833\udcd9": "D",
96
+ "\ud833\udcda": "E",
97
+ "\ud833\udcdb": "F",
98
+ "\ud833\udcdc": "G",
99
+ "\ud833\udcdd": "H",
100
+ "\ud833\udcde": "I",
101
+ "\ud833\udcdf": "J",
102
+ "\ud833\udce0": "K",
103
+ "\ud833\udce1": "L",
104
+ "\ud833\udce2": "M",
105
+ "\ud833\udce3": "N",
106
+ "\ud833\udce4": "O",
107
+ "\ud833\udce5": "P",
108
+ "\ud833\udce6": "Q",
109
+ "\ud833\udce7": "R",
110
+ "\ud833\udce8": "S",
111
+ "\ud833\udce9": "T",
112
+ "\ud833\udcea": "U",
113
+ "\ud833\udceb": "V",
114
+ "\ud833\udcec": "W",
115
+ "\ud833\udced": "X",
116
+ "\ud833\udcee": "Y",
117
+ "\ud833\udcef": "Z",
118
+ "\ud833\udcf0": "0",
119
+ "\ud833\udcf1": "1",
120
+ "\ud833\udcf2": "2",
121
+ "\ud833\udcf3": "3",
122
+ "\ud833\udcf4": "4",
123
+ "\ud833\udcf5": "5",
124
+ "\ud833\udcf6": "6",
125
+ "\ud833\udcf7": "7",
126
+ "\ud833\udcf8": "8",
127
+ "\ud833\udcf9": "9",
128
+ "\ud838\udc30": "a",
129
+ "\ud838\udc35": "e",
130
+ "\ud838\udc3c": "o",
131
+ "\ud838\udc3e": "p",
132
+ "\ud838\udc3f": "c",
133
+ "\ud838\udc41": "y",
134
+ "\ud838\udc43": "x",
135
+ "\ud838\udc4c": "i",
136
+ "\ud838\udc4d": "j",
137
+ "\ud838\udc51": "a",
138
+ "\ud838\udc56": "e",
139
+ "\ud838\udc5c": "o",
140
+ "\ud838\udc5e": "c",
141
+ "\ud838\udc5f": "y",
142
+ "\ud838\udc61": "x",
143
+ "\ud838\udc68": "i",
144
+ "\ud838\udc69": "s"
99
145
  }
100
146
  }
@@ -927,11 +927,11 @@ bucket_di_registration() {
927
927
  case "$PLATFORM" in
928
928
  ios)
929
929
  local configurators
930
- configurators=$(run_find -type f \( -name '*DependencyConfigurator.swift' -o -name '*Assembly.swift' -o -name '*Module.swift' \) 2>/dev/null | head -20 || true)
930
+ configurators=$(run_find -type f \( -name '*DependencyConfigurator.swift' -o -name '*DependencyRegistrar.swift' -o -name '*Assembly.swift' -o -name '*Module.swift' \) 2>/dev/null | head -20 || true)
931
931
  local resolver_count factory_count manual_count swinject_count
932
932
  manual_count=0; resolver_count=0; factory_count=0; swinject_count=0
933
933
  if [ -n "$configurators" ]; then
934
- manual_count=$(printf '%s\n' "$configurators" | count_matches 'DependencyConfigurator')
934
+ manual_count=$(printf '%s\n' "$configurators" | count_matches -E 'Dependency(Configurator|Registrar)')
935
935
  fi
936
936
  files=$(run_find -type f -name '*.swift' 2>/dev/null || true)
937
937
  if [ -n "$files" ]; then
@@ -943,7 +943,7 @@ bucket_di_registration() {
943
943
 
944
944
  sample=$(printf '%s\n' "$configurators" | head -3 | sed "s|^$REPO_PATH/||")
945
945
  if [ "$manual_count" -ge "$resolver_count" ] && [ "$manual_count" -ge "$factory_count" ] && [ "$manual_count" -ge "$swinject_count" ] && [ "$manual_count" -gt 0 ]; then
946
- emit_bucket "manual *DependencyConfigurator" "FooDependencyConfigurator.swift" "$(confidence_for "$manual_count")" "$(files_to_json "$sample")" '["Resolver","Factory","Swinject"]'
946
+ emit_bucket "manual per-module registrar" "FooDependencyRegistrar.swift" "$(confidence_for "$manual_count")" "$(files_to_json "$sample")" '["Resolver","Factory","Swinject"]'
947
947
  elif [ "$resolver_count" -ge "$factory_count" ] && [ "$resolver_count" -ge "$swinject_count" ] && [ "$resolver_count" -gt 0 ]; then
948
948
  emit_bucket "Resolver" "Resolver.register { FooViewModel() }" "$(confidence_for "$resolver_count")" "[]" '["manual-configurator","Factory","Swinject"]'
949
949
  elif [ "$factory_count" -ge "$swinject_count" ] && [ "$factory_count" -gt 0 ]; then
@@ -19,7 +19,9 @@
19
19
  import {
20
20
  chmodSync,
21
21
  linkSync,
22
+ lstatSync,
22
23
  readFileSync,
24
+ realpathSync,
23
25
  renameSync,
24
26
  rmSync,
25
27
  statSync,
@@ -125,17 +127,31 @@ export function withFileLockSync(
125
127
  /**
126
128
  * Replace `path` with `data` as JSON, atomically, at `mode`.
127
129
  *
130
+ * `followSymlink` is for a file the user owns and may keep in a dotfiles repo
131
+ * (the preferences file): when `path` is a symlink, the rename lands on its
132
+ * target, so the link and the dotfiles copy stay in step. Without it a
133
+ * symlink at `path` is replaced by a regular file, which is the safe default
134
+ * for a path an unattended run could plant a link at.
135
+ *
128
136
  * @param {string} path
129
137
  * @param {unknown} data
130
- * @param {{mode?: number}} [opts]
138
+ * @param {{mode?: number, followSymlink?: boolean}} [opts]
131
139
  */
132
- export function writeJsonAtomicSync(path, data, { mode = 0o600 } = {}) {
133
- const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
140
+ export function writeJsonAtomicSync(path, data, { mode = 0o600, followSymlink = false } = {}) {
141
+ let target = path;
142
+ if (followSymlink) {
143
+ try {
144
+ if (lstatSync(path).isSymbolicLink()) target = realpathSync(path);
145
+ } catch {
146
+ // Missing file: write the path as given.
147
+ }
148
+ }
149
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
134
150
  rmSync(tmp, { force: true });
135
151
  try {
136
152
  writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode, flag: "wx" });
137
153
  chmodSync(tmp, mode);
138
- renameSync(tmp, path);
154
+ renameSync(tmp, target);
139
155
  } catch (err) {
140
156
  rmSync(tmp, { force: true });
141
157
  throw err;
@@ -149,16 +165,20 @@ export function writeJsonAtomicSync(path, data, { mode = 0o600 } = {}) {
149
165
  *
150
166
  * @param {string} path
151
167
  * @param {(current: any) => any} mutate
152
- * @param {{fallback?: any, mode?: number, timeoutMs?: number, staleMs?: number}} [opts]
168
+ * @param {{fallback?: any, mode?: number, followSymlink?: boolean, timeoutMs?: number, staleMs?: number}} [opts]
153
169
  * @returns {any} the document written, or undefined when nothing was
154
170
  */
155
- export function updateJsonFileSync(path, mutate, { fallback = null, mode = 0o600, ...lock } = {}) {
171
+ export function updateJsonFileSync(
172
+ path,
173
+ mutate,
174
+ { fallback = null, mode = 0o600, followSymlink = false, ...lock } = {},
175
+ ) {
156
176
  return withFileLockSync(
157
177
  path,
158
178
  () => {
159
179
  const next = mutate(readRegularJson(path, fallback));
160
180
  if (next === undefined) return undefined;
161
- writeJsonAtomicSync(path, next, { mode });
181
+ writeJsonAtomicSync(path, next, { mode, followSymlink });
162
182
  return next;
163
183
  },
164
184
  lock,
@@ -2,13 +2,20 @@
2
2
  /**
3
3
  * normalize-text.mjs - fold look-alike text to the form a guard matches on.
4
4
  *
5
- * NFKC, then the invisible characters in confusables.json removed and its
6
- * look-alike letters and punctuation mapped to ASCII. A guard matches on the
7
- * raw text and on this folded copy, so a trailer or a token written with a
8
- * zero-width space or a Cyrillic letter is still recognised. The folded copy is
9
- * only ever matched against; nothing written or shown uses it.
5
+ * NFKC, then the Default_Ignorable_Code_Point ranges in confusables.json
6
+ * removed and its look-alike letters and punctuation mapped to ASCII. A guard
7
+ * matches on the raw text and on this folded copy, so a trailer or a token
8
+ * written with a zero-width space or a Cyrillic letter is still recognised. The
9
+ * folded copy is only ever matched against; nothing written or shown uses it.
10
10
  *
11
- * As a CLI it reads stdin and writes the folded text to stdout.
11
+ * The table is loaded fail-soft: when confusables.json cannot be read the fold
12
+ * is NFKC alone, `TABLE_LOADED` is false and one notice goes to stderr. The log
13
+ * redactor keeps working on that reduced fold; the outbound gate checks
14
+ * `TABLE_LOADED` and refuses to scan.
15
+ *
16
+ * As a CLI it reads stdin and writes the folded text to stdout. With `--nul`
17
+ * it reads NUL-separated records and writes each folded record followed by a
18
+ * NUL, so a caller folds many texts in one process.
12
19
  *
13
20
  * @module pipeline/lib/normalize-text
14
21
  */
@@ -17,32 +24,94 @@ import { readFileSync } from "node:fs";
17
24
  import { join } from "node:path";
18
25
  import { invokedDirectly } from "./invoked-directly.mjs";
19
26
 
20
- const TABLE = JSON.parse(readFileSync(join(import.meta.dirname, "confusables.json"), "utf8"));
21
- const STRIP = new Set(TABLE.strip);
22
- const MAP = new Map(Object.entries(TABLE.map));
27
+ function loadTable() {
28
+ try {
29
+ const table = JSON.parse(readFileSync(join(import.meta.dirname, "confusables.json"), "utf8"));
30
+ return { table, loaded: true };
31
+ } catch (err) {
32
+ process.stderr.write(
33
+ `normalize-text: confusables.json unreadable (${err.code || err.name}) - folding with NFKC only\n`,
34
+ );
35
+ return { table: {}, loaded: false };
36
+ }
37
+ }
38
+
39
+ const { table: TABLE, loaded } = loadTable();
40
+
41
+ /** False when confusables.json could not be read and the fold is NFKC only. */
42
+ export const TABLE_LOADED = loaded;
43
+
44
+ function stripSet(table) {
45
+ const set = new Set();
46
+ for (const range of table.stripRanges || []) {
47
+ const [first, last = first] = String(range).split("-");
48
+ const lo = parseInt(first, 16);
49
+ const hi = parseInt(last, 16);
50
+ if (Number.isNaN(lo) || Number.isNaN(hi)) continue;
51
+ for (let cp = lo; cp <= hi; cp++) set.add(cp);
52
+ }
53
+ return set;
54
+ }
55
+
56
+ const STRIP = stripSet(TABLE);
57
+ const MAP = new Map(Object.entries(TABLE.map || {}));
23
58
 
24
59
  /** @param {string} text */
25
60
  export function normalizeForMatch(text) {
26
61
  if (typeof text !== "string" || !text) return text;
27
62
  let out = "";
28
63
  for (const ch of text.normalize("NFKC")) {
29
- if (STRIP.has(ch)) continue;
64
+ if (STRIP.has(ch.codePointAt(0))) continue;
30
65
  out += MAP.get(ch) ?? ch;
31
66
  }
32
67
  return out;
33
68
  }
34
69
 
35
- /** The text with its invisible characters removed and nothing else changed. */
36
- export function stripInvisible(text) {
37
- if (typeof text !== "string" || !text) return text;
38
- let out = "";
39
- for (const ch of text) if (!STRIP.has(ch)) out += ch;
40
- return out;
70
+ /**
71
+ * The fold of `text` one code point at a time, with the span of the original
72
+ * each folded UTF-16 unit came from: folded unit `j` came from
73
+ * `text.slice(start[j], end[j])`. A match on `folded` from `a` to `b` covers
74
+ * `text.slice(start[a], end[b - 1])`. Folding per code point rather than over
75
+ * the whole string differs from normalizeForMatch only where NFKC would
76
+ * compose a base letter with a following combining mark, which no credential
77
+ * shape contains.
78
+ *
79
+ * @param {string} text
80
+ * @returns {{ folded: string, start: number[], end: number[] }}
81
+ */
82
+ export function foldWithIndex(text) {
83
+ const start = [];
84
+ const end = [];
85
+ let folded = "";
86
+ let i = 0;
87
+ for (const ch of text) {
88
+ const next = i + ch.length;
89
+ for (const part of ch.normalize("NFKC")) {
90
+ if (STRIP.has(part.codePointAt(0))) continue;
91
+ const mapped = MAP.get(part) ?? part;
92
+ for (let k = 0; k < mapped.length; k++) {
93
+ start.push(i);
94
+ end.push(next);
95
+ }
96
+ folded += mapped;
97
+ }
98
+ i = next;
99
+ }
100
+ return { folded, start, end };
41
101
  }
42
102
 
43
103
  if (invokedDirectly(import.meta.url)) {
104
+ const nul = process.argv.includes("--nul");
44
105
  let input = "";
45
106
  process.stdin.setEncoding("utf8");
46
107
  process.stdin.on("data", (d) => (input += d));
47
- process.stdin.on("end", () => process.stdout.write(normalizeForMatch(input)));
108
+ process.stdin.on("end", () => {
109
+ if (!nul) {
110
+ process.stdout.write(normalizeForMatch(input));
111
+ return;
112
+ }
113
+ const records = input.split("\0");
114
+ if (records.at(-1) === "") records.pop();
115
+ process.stdout.write(records.map((r) => `${normalizeForMatch(r)}\0`).join(""));
116
+ });
48
117
  }
@@ -42,14 +42,15 @@
42
42
  * cat body.md | outbound-gate.mjs --warn # report, never block
43
43
  *
44
44
  * Exit codes:
45
- * 0 - nothing found
45
+ * 0 - nothing found, or an empty body
46
46
  * 7 - at least one finding; the caller must not publish
47
- * 2 - usage error (no input)
47
+ * 2 - usage error (--file without a path)
48
+ * or a stdin that cannot be read at all
48
49
  */
49
50
 
50
51
  import { readFileSync } from "node:fs";
51
52
  import { invokedDirectly } from "./invoked-directly.mjs";
52
- import { normalizeForMatch } from "./normalize-text.mjs";
53
+ import { TABLE_LOADED, normalizeForMatch } from "./normalize-text.mjs";
53
54
 
54
55
  /**
55
56
  * Each rule is a SHAPE, not a dictionary. A list of known token prefixes goes
@@ -148,17 +149,25 @@ export const SURFACES = [
148
149
  },
149
150
  ];
150
151
 
152
+ // Printable ASCII and tab fold to themselves; any other line is folded.
153
+ const NEEDS_FOLD = /[^\t -~]/;
154
+
151
155
  /**
152
156
  * @param {string} text
153
157
  * @returns {{rule: string, certain: boolean, line: number}[]}
154
158
  */
155
159
  export function scan(text) {
160
+ // Without the fold table a disguised credential would pass as clean text,
161
+ // so the gate refuses to scan rather than report nothing.
162
+ if (!TABLE_LOADED) {
163
+ throw new Error("outbound-gate: confusables.json unreadable - refusing to scan");
164
+ }
156
165
  const findings = [];
157
166
  const lines = String(text).split("\n");
158
167
  for (let i = 0; i < lines.length; i++) {
159
168
  // A credential written with an invisible character or a look-alike letter
160
169
  // still matches: the certain rules also run on the folded line.
161
- const folded = normalizeForMatch(lines[i]);
170
+ const folded = NEEDS_FOLD.test(lines[i]) ? normalizeForMatch(lines[i]) : lines[i];
162
171
  for (const rule of RULES) {
163
172
  // `lastIndex` survives between calls on a /g regex and would make every
164
173
  // second line skip its start. Reset per line.