@piagent/platform 1.4.0 → 1.5.1

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 (132) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +12 -2
  3. package/README.vi.md +47 -0
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/en/architecture.md +13 -0
  7. package/docs/languages.json +20 -4
  8. package/docs/mcp-and-tools.md +112 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +42 -0
  11. package/docs/release-install-policy.md +21 -8
  12. package/docs/runtime-harness-standard.md +1 -1
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-implementation-contract.md +11 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +70 -0
  17. package/docs/vi/architecture.md +13 -0
  18. package/docs-site/architecture.html +5 -1
  19. package/docs-site/benchmark.html +5 -1
  20. package/docs-site/commands.html +5 -1
  21. package/docs-site/content/en/index.html +49 -0
  22. package/docs-site/content/en/runtime.html +13 -0
  23. package/docs-site/content/index.html +49 -0
  24. package/docs-site/content/runtime.html +28 -0
  25. package/docs-site/context-engine.html +5 -1
  26. package/docs-site/en/architecture.html +5 -1
  27. package/docs-site/en/benchmark.html +5 -1
  28. package/docs-site/en/commands.html +5 -1
  29. package/docs-site/en/context-engine.html +5 -1
  30. package/docs-site/en/index.html +54 -1
  31. package/docs-site/en/mcp-approval.html +5 -1
  32. package/docs-site/en/mcp-auth.html +5 -1
  33. package/docs-site/en/mcp-servers.html +5 -1
  34. package/docs-site/en/mcp.html +5 -1
  35. package/docs-site/en/permissions.html +5 -1
  36. package/docs-site/en/profiles.html +5 -1
  37. package/docs-site/en/quickstart.html +5 -1
  38. package/docs-site/en/runtime.html +18 -1
  39. package/docs-site/en/scope.html +5 -1
  40. package/docs-site/en/security.html +5 -1
  41. package/docs-site/en/team.html +5 -1
  42. package/docs-site/en/whats-new.html +5 -1
  43. package/docs-site/en/workflows.html +5 -1
  44. package/docs-site/index.html +54 -1
  45. package/docs-site/mcp-approval.html +5 -1
  46. package/docs-site/mcp-auth.html +5 -1
  47. package/docs-site/mcp-servers.html +5 -1
  48. package/docs-site/mcp.html +5 -1
  49. package/docs-site/permissions.html +5 -1
  50. package/docs-site/profiles.html +5 -1
  51. package/docs-site/quickstart.html +5 -1
  52. package/docs-site/runtime.html +33 -1
  53. package/docs-site/scope.html +5 -1
  54. package/docs-site/security.html +5 -1
  55. package/docs-site/team.html +5 -1
  56. package/docs-site/whats-new.html +5 -1
  57. package/docs-site/workflows.html +5 -1
  58. package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  59. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  60. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  61. package/package.json +14 -2
  62. package/packages/piagent-core/README.md +3 -2
  63. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  64. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  65. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  66. package/packages/piagent-core/extensions/piagent-guard.ts +20 -237
  67. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  68. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  69. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  70. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  71. package/packages/piagent-core/package.json +1 -1
  72. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  73. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  74. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  75. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  76. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  77. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  78. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  79. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  80. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  81. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  82. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  83. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  84. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  85. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  86. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  87. package/packages/piagent-core/security/sensitive-data.js +7 -0
  88. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
  89. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  90. package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
  91. package/packages/piagent-webui/dist/client/index.html +2 -2
  92. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  93. package/packages/piagent-webui/extension/piagent-webui.ts +19 -6
  94. package/packages/piagent-webui/extension/same-session-bridge.ts +12 -12
  95. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  96. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  97. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  98. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  99. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  100. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  101. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  102. package/packages/piagent-webui/gateway/session-inspection-registry.ts +67 -12
  103. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -11
  104. package/packages/piagent-webui/package.json +3 -1
  105. package/packages/piagent-webui/server/core-inspection-provider.ts +52 -15
  106. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  107. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  108. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  109. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  110. package/packages/piagent-webui/server/transcript-projection.ts +58 -2
  111. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  112. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  113. package/schemas/piagent-webui/catalog-v1.json +9 -0
  114. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  115. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  116. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  117. package/scripts/build-docs-site.mjs +21 -1
  118. package/scripts/explain-command.mjs +240 -0
  119. package/scripts/install-global.sh +104 -3
  120. package/scripts/mcp-manage.mjs +17 -4
  121. package/scripts/pi-session-stats.sh +10 -2
  122. package/scripts/piagent-cli.mjs +14 -1
  123. package/scripts/piagent-dashboard.mjs +55 -2
  124. package/scripts/setup.sh +69 -33
  125. package/scripts/traffic-snapshot.mjs +214 -0
  126. package/scripts/typescript-loader.mjs +20 -4
  127. package/scripts/uninstall-global.sh +53 -2
  128. package/scripts/update-global.mjs +33 -1
  129. package/scripts/verify-local.sh +20 -1
  130. package/scripts/verify-translation-pairing.mjs +121 -0
  131. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
  132. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
@@ -80,6 +80,46 @@ run_cmd() {
80
80
  fi
81
81
  }
82
82
 
83
+ # The helper can live under a non-default npm prefix: install falls back to a
84
+ # user-writable root when the configured one is not writable, and --npm-prefix
85
+ # picks one deliberately. A bare `npm uninstall -g` resolves against the
86
+ # configured prefix instead, so it removes nothing and still reports success.
87
+ # Derive the prefix from where this script actually runs, the same way
88
+ # update-global.mjs does, and only add --prefix when it differs.
89
+ installed_helper_prefix() {
90
+ local root="$ROOT"
91
+ [[ "$(basename "$root")" == "platform" ]] || return 0
92
+ local scope_root node_modules_root parent
93
+ scope_root="$(dirname "$root")"
94
+ [[ "$(basename "$scope_root")" == "@piagent" ]] || return 0
95
+ node_modules_root="$(dirname "$scope_root")"
96
+ [[ "$(basename "$node_modules_root")" == "node_modules" ]] || return 0
97
+ parent="$(dirname "$node_modules_root")"
98
+ # An npx run lives in a prunable cache that owns no bin on anyone's PATH;
99
+ # uninstalling from there would claim to remove an install nobody has.
100
+ case "$parent" in
101
+ */_npx/*|*/_npx) return 0 ;;
102
+ esac
103
+ if [[ "$(basename "$parent")" == "lib" ]]; then
104
+ dirname "$parent"
105
+ else
106
+ printf '%s' "$parent"
107
+ fi
108
+ }
109
+
110
+ # The prefix npm has to be pointed at, or empty when the helper already lives
111
+ # under the configured one and the plain command is correct. Returns a single
112
+ # value rather than an argument list: macOS still ships bash 3.2, where
113
+ # expanding an empty array under `set -u` aborts the script.
114
+ npm_prefix_override() {
115
+ local helper_prefix configured
116
+ helper_prefix="$(installed_helper_prefix)"
117
+ [[ -n "$helper_prefix" ]] || return 0
118
+ configured="$(npm prefix -g 2>/dev/null || true)"
119
+ [[ "$helper_prefix" != "$configured" ]] || return 0
120
+ printf '%s' "$helper_prefix"
121
+ }
122
+
83
123
  # Reads the package sources Pi has registered, so removal targets what is
84
124
  # actually installed rather than what this version happens to install today. An
85
125
  # older release registered a different source shape, and it still has to come
@@ -174,7 +214,13 @@ if [[ "$WITH_HOST" == true ]]; then
174
214
  echo
175
215
  if command -v npm >/dev/null 2>&1; then
176
216
  echo "Uninstalling Pi host:"
177
- run_cmd npm uninstall -g "$PI_HOST_PACKAGE"
217
+ HOST_PREFIX="$(npm_prefix_override)"
218
+ if [[ -n "$HOST_PREFIX" ]]; then
219
+ echo " (prefix $HOST_PREFIX, where this helper is installed)"
220
+ run_cmd npm uninstall -g --prefix "$HOST_PREFIX" "$PI_HOST_PACKAGE"
221
+ else
222
+ run_cmd npm uninstall -g "$PI_HOST_PACKAGE"
223
+ fi
178
224
  else
179
225
  echo "WARN: npm is not on PATH; cannot uninstall $PI_HOST_PACKAGE." >&2
180
226
  fi
@@ -231,7 +277,12 @@ echo
231
277
  if [[ "$APPLY" == true ]]; then
232
278
  echo "Uninstall complete."
233
279
  echo "The npm-global helper is separate and stays until you remove it:"
234
- echo " npm uninstall -g $PLATFORM_PACKAGE"
280
+ HELPER_PREFIX="$(npm_prefix_override)"
281
+ if [[ -n "$HELPER_PREFIX" ]]; then
282
+ echo " npm uninstall -g --prefix $HELPER_PREFIX $PLATFORM_PACKAGE"
283
+ else
284
+ echo " npm uninstall -g $PLATFORM_PACKAGE"
285
+ fi
235
286
  else
236
287
  echo "Dry run only. Nothing was removed. Re-run with --apply to perform it."
237
288
  fi
@@ -213,8 +213,21 @@ function binForPrefix(prefix) {
213
213
  return process.platform === "win32" ? prefix : path.join(prefix, "bin");
214
214
  }
215
215
 
216
+ // The npx cache is a `node_modules` tree like any other, so the documented
217
+ // bootstrap -- `npm exec -y --package @piagent/platform@X.Y.Z -- piagent-update`
218
+ // -- ran the helper from `~/.npm/_npx/<hash>/node_modules/@piagent/platform` and
219
+ // this function happily reported that hash directory as the prefix to install
220
+ // into. Host and helper then landed in a cache npm is free to prune, with a `bin`
221
+ // that is on nobody's PATH: the command reported success and the machine had
222
+ // nothing installed. A cache is never an install target, so it is refused here
223
+ // and the caller falls back to a real global prefix.
224
+ function isNpxCacheRoot(root) {
225
+ return root.split(path.sep).some((segment) => segment === "_npx" || segment === "_cacache");
226
+ }
227
+
216
228
  function npmPrefixForInstalledHelper(root) {
217
229
  if (path.basename(root) !== "platform") return undefined;
230
+ if (isNpxCacheRoot(root)) return undefined;
218
231
  const scopeRoot = path.dirname(root);
219
232
  if (path.basename(scopeRoot) !== "@piagent") return undefined;
220
233
  const nodeModulesRoot = path.dirname(scopeRoot);
@@ -444,10 +457,29 @@ function main() {
444
457
  console.log(` npm: using explicit global prefix ${npmTarget.prefix}`);
445
458
  }
446
459
 
460
+ // The host version above was read before this prepend, so it described whatever
461
+ // `pi` the caller's PATH happened to resolve. Once the prefix moves -- a default
462
+ // global root that is not writable, an explicit `--npm-prefix`, or a helper
463
+ // already installed elsewhere -- every step below uses a different `pi`, and
464
+ // "(already current)" could be describing a binary this run never touches. The
465
+ // decision belongs to the `pi` that will actually be used, so it is taken again
466
+ // here rather than trusted from before the move.
467
+ let effectiveHost = currentHost;
468
+ let effectiveHostNeedsChange = hostNeedsChange;
469
+ if (npmTarget.bin) {
470
+ const activeHost = installedHostVersion();
471
+ if (activeHost !== currentHost) {
472
+ effectiveHost = activeHost;
473
+ effectiveHostNeedsChange = options.host && (options.force || activeHost !== targetHost);
474
+ console.log(` host: the active prefix resolves pi ${activeHost ?? "not installed"}, not ${currentHost ?? "not installed"};`
475
+ + ` using ${effectiveHost ?? "not installed"} -> ${targetHost}${effectiveHostNeedsChange ? "" : " (already current)"}`);
476
+ }
477
+ }
478
+
447
479
  // Host first: piagent-install refuses to run against a host that does not match
448
480
  // the version its package.json pins, so a helper installed ahead of the host
449
481
  // would fail on its very next step.
450
- if (hostNeedsChange) {
482
+ if (effectiveHostNeedsChange) {
451
483
  const args = npmInstallArgs(`${HOST_PACKAGE}@${targetHost}`, npmTarget);
452
484
  describeStep(options.dryRun, "npm", args);
453
485
  if (!options.dryRun && !run("npm", args).ok) fail(`could not install ${HOST_PACKAGE}@${targetHost}`);
@@ -173,6 +173,7 @@ required_files=(
173
173
  "$ROOT/scripts/build-docs-site.mjs"
174
174
  "$ROOT/scripts/check-architecture.mjs"
175
175
  "$ROOT/scripts/check-doc-languages.mjs"
176
+ "$ROOT/scripts/verify-translation-pairing.mjs"
176
177
  "$ROOT/scripts/check-third-party-neutrality.mjs"
177
178
  "$ROOT/governance/piagent-webui/wui5-18-acceptance-matrix.v1.json"
178
179
  "$ROOT/tests/piagent-webui-wui5-18-gate.test.mjs"
@@ -205,6 +206,7 @@ required_files=(
205
206
  "$ROOT/schemas/piagent-webui/queue-v1.schema.json"
206
207
  "$ROOT/schemas/piagent-webui/model-catalog-v1.schema.json"
207
208
  "$ROOT/schemas/piagent-webui/attachment-v1.schema.json"
209
+ "$ROOT/schemas/piagent-webui/document-workspace-v1.schema.json"
208
210
  "$ROOT/schemas/piagent-webui/control-command-v1.schema.json"
209
211
  "$ROOT/schemas/piagent-webui/approval-v1.schema.json"
210
212
  "$ROOT/schemas/piagent-webui/capabilities-v1.schema.json"
@@ -231,6 +233,7 @@ required_files=(
231
233
  "$ROOT/packages/piagent-webui/contracts/generated/queue-v1.ts"
232
234
  "$ROOT/packages/piagent-webui/contracts/generated/model-catalog-v1.ts"
233
235
  "$ROOT/packages/piagent-webui/contracts/generated/attachment-v1.ts"
236
+ "$ROOT/packages/piagent-webui/contracts/generated/document-workspace-v1.ts"
234
237
  "$ROOT/packages/piagent-webui/contracts/generated/session-catalog-v1.ts"
235
238
  "$ROOT/packages/piagent-webui/contracts/generated/session-command-v1.ts"
236
239
  "$ROOT/packages/piagent-webui/contracts/generated/gateway-capabilities-v1.ts"
@@ -248,6 +251,9 @@ required_files=(
248
251
  "$ROOT/packages/piagent-webui/client/src/evidence-view-model.ts"
249
252
  "$ROOT/packages/piagent-webui/client/src/EvidencePanels.tsx"
250
253
  "$ROOT/packages/piagent-webui/client/src/ChatPanel.tsx"
254
+ "$ROOT/packages/piagent-webui/client/src/MarkdownMessage.tsx"
255
+ "$ROOT/packages/piagent-webui/client/src/DocumentWorkspace.tsx"
256
+ "$ROOT/packages/piagent-webui/client/src/attachment-intake.ts"
251
257
  "$ROOT/packages/piagent-webui/client/src/chat-view-model.ts"
252
258
  "$ROOT/packages/piagent-webui/client/src/chat-command.ts"
253
259
  "$ROOT/packages/piagent-webui/client/src/review-command.ts"
@@ -291,6 +297,8 @@ required_files=(
291
297
  "$ROOT/packages/piagent-webui/gateway/mcp-auth-broker.ts"
292
298
  "$ROOT/tests/piagent-webui-mcp-auth.test.mjs"
293
299
  "$ROOT/packages/piagent-webui/gateway/session-catalog.ts"
300
+ "$ROOT/packages/piagent-webui/gateway/ai-capability-inspection.ts"
301
+ "$ROOT/packages/piagent-webui/gateway/session-attachment-registry.ts"
294
302
  "$ROOT/packages/piagent-webui/gateway/session-command-controller.ts"
295
303
  "$ROOT/packages/piagent-webui/gateway/session-command-store.ts"
296
304
  "$ROOT/packages/piagent-webui/gateway/session-lease-store.ts"
@@ -304,6 +312,11 @@ required_files=(
304
312
  "$ROOT/packages/piagent-webui/extension/source-open-controller.ts"
305
313
  "$ROOT/packages/piagent-webui/extension/vscode-handoff.ts"
306
314
  "$ROOT/packages/piagent-core/runtime/inspection/review-state-contract.ts"
315
+ "$ROOT/packages/piagent-core/runtime/inspection/document-workspace-projection.ts"
316
+ "$ROOT/packages/piagent-core/runtime/inspection/session-identity.ts"
317
+ "$ROOT/packages/piagent-core/runtime/input/attachment-reservation.ts"
318
+ "$ROOT/packages/piagent-core/runtime/input/attachment-document-command.ts"
319
+ "$ROOT/packages/piagent-core/runtime/input/attachment-store.ts"
307
320
  "$ROOT/packages/piagent-core/runtime/inspection/mcp-control.ts"
308
321
  "$ROOT/packages/piagent-core/runtime/inspection/review-state-projection.ts"
309
322
  "$ROOT/packages/piagent-core/runtime/inspection/review-state-store.ts"
@@ -346,7 +359,6 @@ required_files=(
346
359
  "$ROOT/packages/piagent-webui/extension/held-message-queue.ts"
347
360
  "$ROOT/packages/piagent-webui/extension/session-options-controller.ts"
348
361
  "$ROOT/packages/piagent-webui/extension/session-stream-adapter.ts"
349
- "$ROOT/packages/piagent-webui/extension/attachment-store.ts"
350
362
  "$ROOT/packages/piagent-webui/extension/lifecycle-controller.ts"
351
363
  "$ROOT/packages/piagent-webui/extension/lifecycle-event-adapter.ts"
352
364
  "$ROOT/packages/piagent-core/runtime/inspection/approval-broker.ts"
@@ -359,6 +371,8 @@ required_files=(
359
371
  "$ROOT/tests/piagent-webui-held-message-queue.test.mjs"
360
372
  "$ROOT/tests/piagent-webui-session-options.test.mjs"
361
373
  "$ROOT/tests/piagent-webui-attachment-store.test.mjs"
374
+ "$ROOT/tests/piagent-webui-document-workspace.test.mjs"
375
+ "$ROOT/tests/helpers/piagent-docx-fixture.mjs"
362
376
  "$ROOT/tests/piagent-webui-approval-broker.test.mjs"
363
377
  "$ROOT/tests/piagent-webui-lifecycle-control.test.mjs"
364
378
  "$ROOT/tests/piagent-webui-lifecycle-client.test.mjs"
@@ -570,6 +584,8 @@ const jsonFiles = [
570
584
  "schemas/piagent-webui/transcript-v1.schema.json",
571
585
  "schemas/piagent-webui/queue-v1.schema.json",
572
586
  "schemas/piagent-webui/model-catalog-v1.schema.json",
587
+ "schemas/piagent-webui/attachment-v1.schema.json",
588
+ "schemas/piagent-webui/document-workspace-v1.schema.json",
573
589
  "schemas/piagent-webui/control-command-v1.schema.json",
574
590
  "schemas/piagent-webui/approval-v1.schema.json",
575
591
  "schemas/piagent-webui/capabilities-v1.schema.json",
@@ -914,6 +930,9 @@ node --check "$ROOT/scripts/build-docs-site.mjs" >/dev/null
914
930
  node --check "$ROOT/scripts/preview-docs-site.mjs" >/dev/null
915
931
  node "$ROOT/scripts/check-architecture.mjs" >/dev/null
916
932
  node "$ROOT/scripts/check-doc-languages.mjs" >/dev/null
933
+ # check-doc-languages proves the peer exists. This proves neither side moved
934
+ # without the other, which is the way a translation actually goes stale.
935
+ node "$ROOT/scripts/verify-translation-pairing.mjs" >/dev/null
917
936
  node "$ROOT/scripts/check-third-party-neutrality.mjs" >/dev/null
918
937
  run_quietly "npm run build --workspace @piagent/webui" npm run build --workspace @piagent/webui
919
938
  run_quietly "npm test" npm test
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // Fails when one side of a bilingual pair has moved and the other has not.
3
+ //
4
+ // The existing language gate proves the Vietnamese peer exists. Existing is not
5
+ // the same as saying the same thing: editing docs/en/architecture.md and
6
+ // forgetting docs/vi/architecture.md passes every check in this repository,
7
+ // and the reader of the stale side has no way to know.
8
+ //
9
+ // So each pair carries the content digest of both sides as of the last time
10
+ // somebody confirmed them consistent. A digest that no longer matches means the
11
+ // record is stale, and the fix is to bring the other side along and re-record.
12
+ //
13
+ // What this cannot do is read the two documents and decide they agree. It is a
14
+ // record that a human looked, nothing more -- which is worth exactly as much as
15
+ // the honesty of whoever runs --write.
16
+
17
+ import { createHash } from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+
24
+ export function contentDigest(text) {
25
+ // Newline-normalised so a checkout on another platform does not read as drift.
26
+ return createHash("sha256").update(text.replace(/\r\n/g, "\n"), "utf8").digest("hex").slice(0, 40);
27
+ }
28
+
29
+ export function digestForFile(root, relative) {
30
+ const file = path.join(root, relative);
31
+ if (!fs.existsSync(file)) return null;
32
+ return contentDigest(fs.readFileSync(file, "utf8"));
33
+ }
34
+
35
+ /**
36
+ * @param {{root?: string, manifest?: object}} options
37
+ * @returns {{ok: boolean, checked: number, unrecorded: string[], drifted: object[], stale: object[], missing: string[]}}
38
+ */
39
+ export function inspectPairing({ root = repositoryRoot, manifest } = {}) {
40
+ const languages = manifest
41
+ ?? JSON.parse(fs.readFileSync(path.join(root, "docs", "languages.json"), "utf8"));
42
+ const result = { ok: true, checked: 0, unrecorded: [], drifted: [], stale: [], missing: [] };
43
+
44
+ for (const pair of languages.pairs ?? []) {
45
+ const current = { en: digestForFile(root, pair.en), vi: digestForFile(root, pair.vi) };
46
+ for (const side of ["en", "vi"]) {
47
+ if (current[side] === null) result.missing.push(`${pair.topic}: ${pair[side]} does not exist`);
48
+ }
49
+ if (current.en === null || current.vi === null) { result.ok = false; continue; }
50
+
51
+ result.checked += 1;
52
+ const recorded = pair.consistentAt;
53
+ if (!recorded) { result.unrecorded.push(pair.topic); result.ok = false; continue; }
54
+
55
+ const movedSides = ["en", "vi"].filter((side) => recorded[side] !== current[side]);
56
+ if (movedSides.length === 0) continue;
57
+ result.ok = false;
58
+ // One side moving alone is the failure this exists for: the other side is
59
+ // now describing something that changed. Both moving is likely a paired
60
+ // edit, but nothing here can prove it, so it still needs re-recording.
61
+ const entry = { topic: pair.topic, moved: movedSides, en: pair.en, vi: pair.vi };
62
+ if (movedSides.length === 1) result.drifted.push(entry);
63
+ else result.stale.push(entry);
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function record(root) {
69
+ const file = path.join(root, "docs", "languages.json");
70
+ const languages = JSON.parse(fs.readFileSync(file, "utf8"));
71
+ for (const pair of languages.pairs ?? []) {
72
+ const en = digestForFile(root, pair.en);
73
+ const vi = digestForFile(root, pair.vi);
74
+ if (en === null || vi === null) continue;
75
+ pair.consistentAt = { en, vi };
76
+ }
77
+ fs.writeFileSync(file, `${JSON.stringify(languages, null, 2)}\n`);
78
+ return languages.pairs.length;
79
+ }
80
+
81
+ const invokedDirectly = process.argv[1]
82
+ && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
83
+
84
+ if (invokedDirectly) {
85
+ const write = process.argv.includes("--write");
86
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
87
+ console.log(`Usage:
88
+ node scripts/verify-translation-pairing.mjs [--write]
89
+
90
+ Checks that neither side of a bilingual documentation pair has moved since the
91
+ two were last confirmed consistent. --write re-records the current state, which
92
+ is a claim that you brought both sides into line.`);
93
+ process.exit(0);
94
+ }
95
+ if (write) {
96
+ console.log(`Recorded ${record(repositoryRoot)} pairs as consistent.`);
97
+ process.exit(0);
98
+ }
99
+
100
+ const result = inspectPairing();
101
+ if (result.ok) {
102
+ console.log(`PASS: ${result.checked} bilingual pairs match their recorded state`);
103
+ process.exit(0);
104
+ }
105
+ for (const line of result.missing) console.error(`FAIL: ${line}`);
106
+ for (const topic of result.unrecorded) {
107
+ console.error(`FAIL: ${topic} has no recorded consistent state.`);
108
+ }
109
+ for (const entry of result.drifted) {
110
+ const moved = entry.moved[0];
111
+ const other = moved === "en" ? "vi" : "en";
112
+ console.error(`FAIL: ${entry.topic}: ${entry[moved]} changed and ${entry[other]} did not.`);
113
+ console.error(` Bring ${entry[other]} along, then re-record.`);
114
+ }
115
+ for (const entry of result.stale) {
116
+ console.error(`FAIL: ${entry.topic}: both sides changed, so the record no longer says anything.`);
117
+ console.error(` Confirm they still agree, then re-record.`);
118
+ }
119
+ console.error("\nRe-record with: node scripts/verify-translation-pairing.mjs --write");
120
+ process.exit(1);
121
+ }