@codyswann/lisa 4.70.4 → 4.71.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 (139) hide show
  1. package/all/copy-overwrite/scripts/lib/automation-provenance-contract.mjs +174 -0
  2. package/all/copy-overwrite/scripts/lib/automation-provenance-local.mjs +279 -0
  3. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +17 -7
  4. package/all/copy-overwrite/scripts/lib/github-attestation-provider.mjs +241 -0
  5. package/all/copy-overwrite/scripts/lib/github-attestation-recovery.mjs +283 -0
  6. package/all/copy-overwrite/scripts/lib/github-attestation-verifier.mjs +244 -0
  7. package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +289 -0
  8. package/all/copy-overwrite/scripts/lib/npm-update-authorization.mjs +235 -0
  9. package/all/copy-overwrite/scripts/lib/npm-update-broker-client.mjs +92 -0
  10. package/all/copy-overwrite/scripts/lib/npm-update-checkpoint.mjs +241 -0
  11. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +278 -0
  12. package/all/copy-overwrite/scripts/lib/npm-update-controller-broker.mjs +234 -0
  13. package/all/copy-overwrite/scripts/lib/npm-update-controller-factory.mjs +278 -0
  14. package/all/copy-overwrite/scripts/lib/npm-update-controller-recipe.mjs +135 -0
  15. package/all/copy-overwrite/scripts/lib/npm-update-execution-adapter.mjs +292 -0
  16. package/all/copy-overwrite/scripts/lib/npm-update-gate-hooks.mjs +214 -0
  17. package/all/copy-overwrite/scripts/lib/npm-update-gate-install.mjs +267 -0
  18. package/all/copy-overwrite/scripts/lib/npm-update-gate-proof.mjs +99 -0
  19. package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +300 -0
  20. package/all/copy-overwrite/scripts/lib/npm-update-gh-dispatch.mjs +182 -0
  21. package/all/copy-overwrite/scripts/lib/npm-update-gh-grants.mjs +156 -0
  22. package/all/copy-overwrite/scripts/lib/npm-update-gh-requests.mjs +221 -0
  23. package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +214 -0
  24. package/all/copy-overwrite/scripts/lib/npm-update-helper-graph.mjs +238 -0
  25. package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +126 -0
  26. package/all/copy-overwrite/scripts/lib/npm-update-helper.mjs +292 -0
  27. package/all/copy-overwrite/scripts/lib/npm-update-hook-installation.mjs +116 -0
  28. package/all/copy-overwrite/scripts/lib/npm-update-hook-preload.mjs +153 -0
  29. package/all/copy-overwrite/scripts/lib/npm-update-hook-provider.mjs +207 -0
  30. package/all/copy-overwrite/scripts/lib/npm-update-hook-read-client.mjs +164 -0
  31. package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +300 -0
  32. package/all/copy-overwrite/scripts/lib/npm-update-hosted-hook.mjs +157 -0
  33. package/all/copy-overwrite/scripts/lib/npm-update-invariants.mjs +28 -0
  34. package/all/copy-overwrite/scripts/lib/npm-update-isolation.mjs +11 -0
  35. package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +171 -0
  36. package/all/copy-overwrite/scripts/lib/npm-update-leaf.mjs +194 -0
  37. package/all/copy-overwrite/scripts/lib/npm-update-native-process.mjs +155 -0
  38. package/all/copy-overwrite/scripts/lib/npm-update-npm.mjs +190 -0
  39. package/all/copy-overwrite/scripts/lib/npm-update-object.mjs +112 -0
  40. package/all/copy-overwrite/scripts/lib/npm-update-orchestrator.mjs +273 -0
  41. package/all/copy-overwrite/scripts/lib/npm-update-owner.mjs +292 -0
  42. package/all/copy-overwrite/scripts/lib/npm-update-prepare.mjs +263 -0
  43. package/all/copy-overwrite/scripts/lib/npm-update-process-core.mjs +179 -0
  44. package/all/copy-overwrite/scripts/lib/npm-update-process.mjs +16 -0
  45. package/all/copy-overwrite/scripts/lib/npm-update-publication.mjs +258 -0
  46. package/all/copy-overwrite/scripts/lib/npm-update-publish.mjs +254 -0
  47. package/all/copy-overwrite/scripts/lib/npm-update-quality.mjs +153 -0
  48. package/all/copy-overwrite/scripts/lib/npm-update-recovery.mjs +264 -0
  49. package/all/copy-overwrite/scripts/lib/npm-update-runtime-archive.mjs +204 -0
  50. package/all/copy-overwrite/scripts/lib/npm-update-runtime-graph.mjs +185 -0
  51. package/all/copy-overwrite/scripts/lib/npm-update-runtime-transport.mjs +221 -0
  52. package/all/copy-overwrite/scripts/lib/npm-update-runtime.mjs +284 -0
  53. package/all/copy-overwrite/scripts/lib/npm-update-tool-launcher.mjs +287 -0
  54. package/all/copy-overwrite/scripts/lib/npm-update-worker-environment.mjs +161 -0
  55. package/all/copy-overwrite/scripts/lib/npm-update-worker-inspection.mjs +142 -0
  56. package/all/copy-overwrite/scripts/lib/npm-update-worker-lifecycle.mjs +256 -0
  57. package/all/copy-overwrite/scripts/lib/npm-update-worker-policy.mjs +266 -0
  58. package/all/copy-overwrite/scripts/lib/process-tree-runner.mjs +230 -36
  59. package/all/copy-overwrite/scripts/lisa-automation-provenance.mjs +161 -0
  60. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +89 -5
  61. package/all/copy-overwrite/scripts/lisa-npm-updater.mjs +204 -0
  62. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +48 -123
  63. package/all/copy-overwrite/scripts/lisa-work-item.mjs +107 -41
  64. package/all/copy-overwrite/scripts/npm-updater-gate-runtime.json +1 -0
  65. package/all/copy-overwrite/scripts/npm-updater-gate-supervisor.c +121 -0
  66. package/all/copy-overwrite/scripts/npm-updater-gate.Dockerfile +49 -0
  67. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +1085 -0
  68. package/dist/codex/scripts/block-no-verify.sh +89 -5
  69. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  70. package/dist/core/lisa-owned-hash-ledger.js +604 -0
  71. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  72. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  73. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  74. package/dist/core/upstream-evidence-manifest.js +159 -11
  75. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  76. package/dist/opencode/plugin-templates/block-no-verify.sh +89 -5
  77. package/package.json +4 -4
  78. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa/hooks/block-no-verify.agy.sh +89 -5
  81. package/plugins/lisa/hooks/block-no-verify.sh +89 -5
  82. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +89 -5
  83. package/plugins/lisa-agy/plugin.json +1 -1
  84. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  87. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-copilot/hooks/block-no-verify.sh +89 -5
  91. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-cursor/hooks/block-no-verify.sh +89 -5
  93. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-expo-agy/plugin.json +1 -1
  96. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  101. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  106. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  111. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  115. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  116. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  120. package/plugins/lisa-rails-agy/plugin.json +1 -1
  121. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  124. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  125. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  126. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  127. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  128. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  130. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  131. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  132. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  133. package/plugins/src/base/hooks/block-no-verify.agy.sh +89 -5
  134. package/plugins/src/base/hooks/block-no-verify.sh +89 -5
  135. package/scripts/check-shipped-surface-removals.mjs +96 -24
  136. package/scripts/generate-npm-updater-helper-graph.mjs +184 -0
  137. package/scripts/two-channel-couplings.json +31 -7
  138. package/typescript/copy-contents/.husky/commit-msg +18 -0
  139. package/typescript/copy-overwrite/.prettierignore +2 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.70.4",
3
+ "version": "4.71.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -51,6 +51,7 @@ command -v python3 >/dev/null 2>&1 || allow
51
51
 
52
52
  if ! BLOCK_NO_VERIFY_COMMAND="$command_str" python3 - <<'PY'
53
53
  import os
54
+ import io
54
55
  import re
55
56
  import shlex
56
57
  import sys
@@ -1183,6 +1184,57 @@ def git_skips_verification(text, depth=0):
1183
1184
  return False
1184
1185
 
1185
1186
 
1187
+ def git_config_reads_key(tokens, index):
1188
+ """Whether this key is a read operand of this exact Git config invocation.
1189
+
1190
+ Keep the command boundary: a query cannot authorize the next command or
1191
+ turn a later setting into a read. Unknown options remain conservative.
1192
+ """
1193
+ start = index
1194
+ while start > 0 and tokens[start - 1] not in COMMAND_SEPARATORS:
1195
+ start -= 1
1196
+ end = index + 1
1197
+ while end < len(tokens) and tokens[end] not in COMMAND_SEPARATORS:
1198
+ end += 1
1199
+ program, args, opaque = command_word(tokens[start:end])
1200
+ if opaque or program != "git":
1201
+ return False
1202
+ found = subcommand_after_git(args, 0)
1203
+ if not found or found[0] != "config":
1204
+ return False
1205
+ argv = found[1]
1206
+ key_offset = index - (end - len(argv))
1207
+ read = bool(argv and argv[0] == "get")
1208
+ cursor = 1 if read else 0
1209
+ operands = []
1210
+ read_actions = {"--get", "--get-all", "--get-regexp", "--get-urlmatch"}
1211
+ modifiers = {
1212
+ "--local", "--global", "--system", "--worktree", "--includes",
1213
+ "--no-includes", "--show-origin", "--show-scope", "--null", "-z",
1214
+ "--name-only", "--all", "--regexp",
1215
+ }
1216
+ value_options = {"--file", "-f", "--blob", "--type", "--default", "--value"}
1217
+ while cursor < len(argv):
1218
+ token = argv[cursor]
1219
+ if token in read_actions:
1220
+ if read:
1221
+ return False
1222
+ read = True
1223
+ elif token in value_options:
1224
+ if cursor + 1 >= len(argv):
1225
+ return False
1226
+ cursor += 1
1227
+ elif token in modifiers:
1228
+ pass
1229
+ elif token.startswith("-"):
1230
+ if not any(token.startswith(option + "=") for option in value_options):
1231
+ return False
1232
+ else:
1233
+ operands.append(cursor)
1234
+ cursor += 1
1235
+ return bool(operands and operands[0] == key_offset and (read or len(operands) == 1))
1236
+
1237
+
1186
1238
  def token_bypass(tokens):
1187
1239
  """Whether any token disables hooks by environment or by git config.
1188
1240
 
@@ -1194,7 +1246,7 @@ def token_bypass(tokens):
1194
1246
  option, and an option belongs to an argv.
1195
1247
 
1196
1248
  Args:
1197
- tokens: Punctuation-stripped tokens of one command or script.
1249
+ tokens: Operator-aware tokens of one command or script.
1198
1250
 
1199
1251
  Returns:
1200
1252
  True when a token disables verification.
@@ -1220,7 +1272,9 @@ def token_bypass(tokens):
1220
1272
  if lowered.startswith("core.hookspath="):
1221
1273
  if not is_permitted_hooks_path(token.split("=", 1)[1]):
1222
1274
  return True
1223
- if lowered == "core.hookspath" and i + 1 < len(tokens):
1275
+ if (lowered == "core.hookspath" and i + 1 < len(tokens)
1276
+ and tokens[i + 1] not in COMMAND_SEPARATORS
1277
+ and not git_config_reads_key(tokens, i)):
1224
1278
  if not is_permitted_hooks_path(tokens[i + 1]):
1225
1279
  return True
1226
1280
  # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
@@ -1287,8 +1341,22 @@ def token_bypass(tokens):
1287
1341
  return False
1288
1342
 
1289
1343
 
1344
+ class OperatorStream(io.StringIO):
1345
+ """Remember punctuation read while shlex is inside a quote or escape."""
1346
+
1347
+ lexer = None
1348
+ literal_operator = False
1349
+
1350
+ def read(self, size=-1):
1351
+ value = super().read(size)
1352
+ if (self.lexer is not None and self.lexer.state in {"'", '"', "\\"}
1353
+ and any(char in "();|&<>" for char in value)):
1354
+ self.literal_operator = True
1355
+ return value
1356
+
1357
+
1290
1358
  def flat_tokens(text):
1291
- """Punctuation-stripped tokens, or None when the text does not lex.
1359
+ """Operator-aware tokens without printed data, or None on a lex failure.
1292
1360
 
1293
1361
  Args:
1294
1362
  text: A command line or a script's contents.
@@ -1297,9 +1365,25 @@ def flat_tokens(text):
1297
1365
  The token list, or None.
1298
1366
  """
1299
1367
  try:
1300
- tokens = shell_tokens(text)
1368
+ stream = OperatorStream(line_boundaries_as_separators(text))
1369
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
1370
+ lexer.whitespace_split = True
1371
+ lexer.commenters = ""
1372
+ stream.lexer = lexer
1373
+ tokens = []
1374
+ while True:
1375
+ stream.literal_operator = False
1376
+ token = lexer.get_token()
1377
+ if token is None:
1378
+ break
1379
+ # shlex otherwise makes quoted ";" indistinguishable from a real
1380
+ # command boundary. Keep literal destinations as data, so a setter
1381
+ # cannot masquerade as a one-operand configuration read.
1382
+ if token in COMMAND_SEPARATORS and stream.literal_operator:
1383
+ token = "\x00" + token
1384
+ tokens.append(token)
1301
1385
  return [
1302
- token.strip("();|&")
1386
+ token
1303
1387
  for index, token in enumerate(tokens)
1304
1388
  if not printed_argument(tokens, index)
1305
1389
  ]
@@ -149,6 +149,7 @@ fi
149
149
 
150
150
  if ! BLOCK_NO_VERIFY_COMMAND="$command_str" python3 - <<'PY'
151
151
  import os
152
+ import io
152
153
  import re
153
154
  import shlex
154
155
  import sys
@@ -1281,6 +1282,57 @@ def git_skips_verification(text, depth=0):
1281
1282
  return False
1282
1283
 
1283
1284
 
1285
+ def git_config_reads_key(tokens, index):
1286
+ """Whether this key is a read operand of this exact Git config invocation.
1287
+
1288
+ Keep the command boundary: a query cannot authorize the next command or
1289
+ turn a later setting into a read. Unknown options remain conservative.
1290
+ """
1291
+ start = index
1292
+ while start > 0 and tokens[start - 1] not in COMMAND_SEPARATORS:
1293
+ start -= 1
1294
+ end = index + 1
1295
+ while end < len(tokens) and tokens[end] not in COMMAND_SEPARATORS:
1296
+ end += 1
1297
+ program, args, opaque = command_word(tokens[start:end])
1298
+ if opaque or program != "git":
1299
+ return False
1300
+ found = subcommand_after_git(args, 0)
1301
+ if not found or found[0] != "config":
1302
+ return False
1303
+ argv = found[1]
1304
+ key_offset = index - (end - len(argv))
1305
+ read = bool(argv and argv[0] == "get")
1306
+ cursor = 1 if read else 0
1307
+ operands = []
1308
+ read_actions = {"--get", "--get-all", "--get-regexp", "--get-urlmatch"}
1309
+ modifiers = {
1310
+ "--local", "--global", "--system", "--worktree", "--includes",
1311
+ "--no-includes", "--show-origin", "--show-scope", "--null", "-z",
1312
+ "--name-only", "--all", "--regexp",
1313
+ }
1314
+ value_options = {"--file", "-f", "--blob", "--type", "--default", "--value"}
1315
+ while cursor < len(argv):
1316
+ token = argv[cursor]
1317
+ if token in read_actions:
1318
+ if read:
1319
+ return False
1320
+ read = True
1321
+ elif token in value_options:
1322
+ if cursor + 1 >= len(argv):
1323
+ return False
1324
+ cursor += 1
1325
+ elif token in modifiers:
1326
+ pass
1327
+ elif token.startswith("-"):
1328
+ if not any(token.startswith(option + "=") for option in value_options):
1329
+ return False
1330
+ else:
1331
+ operands.append(cursor)
1332
+ cursor += 1
1333
+ return bool(operands and operands[0] == key_offset and (read or len(operands) == 1))
1334
+
1335
+
1284
1336
  def token_bypass(tokens):
1285
1337
  """Whether any token disables hooks by environment or by git config.
1286
1338
 
@@ -1292,7 +1344,7 @@ def token_bypass(tokens):
1292
1344
  option, and an option belongs to an argv.
1293
1345
 
1294
1346
  Args:
1295
- tokens: Punctuation-stripped tokens of one command or script.
1347
+ tokens: Operator-aware tokens of one command or script.
1296
1348
 
1297
1349
  Returns:
1298
1350
  True when a token disables verification.
@@ -1318,7 +1370,9 @@ def token_bypass(tokens):
1318
1370
  if lowered.startswith("core.hookspath="):
1319
1371
  if not is_permitted_hooks_path(token.split("=", 1)[1]):
1320
1372
  return True
1321
- if lowered == "core.hookspath" and i + 1 < len(tokens):
1373
+ if (lowered == "core.hookspath" and i + 1 < len(tokens)
1374
+ and tokens[i + 1] not in COMMAND_SEPARATORS
1375
+ and not git_config_reads_key(tokens, i)):
1322
1376
  if not is_permitted_hooks_path(tokens[i + 1]):
1323
1377
  return True
1324
1378
  # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
@@ -1385,8 +1439,22 @@ def token_bypass(tokens):
1385
1439
  return False
1386
1440
 
1387
1441
 
1442
+ class OperatorStream(io.StringIO):
1443
+ """Remember punctuation read while shlex is inside a quote or escape."""
1444
+
1445
+ lexer = None
1446
+ literal_operator = False
1447
+
1448
+ def read(self, size=-1):
1449
+ value = super().read(size)
1450
+ if (self.lexer is not None and self.lexer.state in {"'", '"', "\\"}
1451
+ and any(char in "();|&<>" for char in value)):
1452
+ self.literal_operator = True
1453
+ return value
1454
+
1455
+
1388
1456
  def flat_tokens(text):
1389
- """Punctuation-stripped tokens, or None when the text does not lex.
1457
+ """Operator-aware tokens without printed data, or None on a lex failure.
1390
1458
 
1391
1459
  Args:
1392
1460
  text: A command line or a script's contents.
@@ -1395,9 +1463,25 @@ def flat_tokens(text):
1395
1463
  The token list, or None.
1396
1464
  """
1397
1465
  try:
1398
- tokens = shell_tokens(text)
1466
+ stream = OperatorStream(line_boundaries_as_separators(text))
1467
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
1468
+ lexer.whitespace_split = True
1469
+ lexer.commenters = ""
1470
+ stream.lexer = lexer
1471
+ tokens = []
1472
+ while True:
1473
+ stream.literal_operator = False
1474
+ token = lexer.get_token()
1475
+ if token is None:
1476
+ break
1477
+ # shlex otherwise makes quoted ";" indistinguishable from a real
1478
+ # command boundary. Keep literal destinations as data, so a setter
1479
+ # cannot masquerade as a one-operand configuration read.
1480
+ if token in COMMAND_SEPARATORS and stream.literal_operator:
1481
+ token = "\x00" + token
1482
+ tokens.append(token)
1399
1483
  return [
1400
- token.strip("();|&")
1484
+ token
1401
1485
  for index, token in enumerate(tokens)
1402
1486
  if not printed_argument(tokens, index)
1403
1487
  ]
@@ -289,13 +289,14 @@ const RELEASE_TAG = /^v\d+\.\d+\.\d+$/u;
289
289
  *
290
290
  * @param {string} root - repository root.
291
291
  * @param {string} rev - the revision to test.
292
+ * @param {string} [head] - pinned scan commit, defaulting to HEAD.
292
293
  * @returns {boolean} true when `rev` is reachable from HEAD.
293
294
  */
294
- function isAncestorOfHead(root, rev) {
295
+ function isAncestorOfHead(root, rev, head = "HEAD") {
295
296
  try {
296
297
  boundedExecFileSync(
297
298
  "git",
298
- ["-C", root, "merge-base", "--is-ancestor", rev, "HEAD"],
299
+ ["-C", root, "merge-base", "--is-ancestor", rev, head],
299
300
  { stdio: "ignore" }
300
301
  );
301
302
  return true;
@@ -321,11 +322,12 @@ function isAncestorOfHead(root, rev) {
321
322
  *
322
323
  * @param {string} root - repository root.
323
324
  * @returns {string | null} the tag name, or null when no release tag is reachable.
325
+ * @param {string} [head] - pinned scan commit, defaulting to HEAD.
324
326
  */
325
- export function oldestReachableReleaseTag(root) {
327
+ export function oldestReachableReleaseTag(root, head = "HEAD") {
326
328
  const listed = git(
327
329
  root,
328
- ["tag", "--merged", "HEAD", "--sort=creatordate"],
330
+ ["tag", "--merged", head, "--sort=creatordate"],
329
331
  "could not list tags reachable from HEAD"
330
332
  );
331
333
  const tags = listed
@@ -386,12 +388,13 @@ export function oldestReachableReleaseTag(root) {
386
388
  * @param {string} root - repository root.
387
389
  * @param {string} ref - the baseline as the caller named it, for the message.
388
390
  * @param {string} sha - the commit that baseline resolved to.
391
+ * @param {string} [head] - pinned scan commit, defaulting to HEAD.
389
392
  * @throws {UsageError} When `sha..HEAD` contains no commits.
390
393
  */
391
- function assertNonEmptyWindow(root, ref, sha) {
394
+ function assertNonEmptyWindow(root, ref, sha, head = "HEAD") {
392
395
  const span = git(
393
396
  root,
394
- ["rev-list", "--count", `${sha}..HEAD`],
397
+ ["rev-list", "--count", `${sha}..${head}`],
395
398
  `could not measure the window ${ref}..HEAD`
396
399
  ).trim();
397
400
  if (span === "0") {
@@ -402,13 +405,21 @@ function assertNonEmptyWindow(root, ref, sha) {
402
405
  }
403
406
  }
404
407
 
405
- export function resolveBaseline(root, ref, mayFallBack = false) {
408
+ /**
409
+ * Resolve the configured or explicit baseline against the same scan commit.
410
+ * @param {string} root - repository root.
411
+ * @param {string} ref - requested baseline.
412
+ * @param {boolean} [mayFallBack] - whether the ledger pin may fall back.
413
+ * @param {string} [head] - pinned scan commit, defaulting to HEAD.
414
+ * @returns {{ sha: string, ref: string, requested: string, substituted: boolean }} baseline.
415
+ */
416
+ export function resolveBaseline(root, ref, mayFallBack = false, head = "HEAD") {
406
417
  const sha = git(
407
418
  root,
408
419
  ["rev-parse", "--verify", `${ref}^{commit}`],
409
420
  `baseline ${ref} does not resolve to a commit`
410
421
  ).trim();
411
- if (isAncestorOfHead(root, sha)) {
422
+ if (isAncestorOfHead(root, sha, head)) {
412
423
  // A reachable baseline can still be HEAD itself, and then the window holds
413
424
  // no commits at all. The scan reads nothing and reports every removal
414
425
  // governed, which is a confident pass over nothing examined — the failure
@@ -416,7 +427,7 @@ export function resolveBaseline(root, ref, mayFallBack = false) {
416
427
  // below already refuses that; an explicit `--since` reached the same state
417
428
  // by a different route and did not, so the check belongs on every path out
418
429
  // of here rather than on one of them.
419
- assertNonEmptyWindow(root, ref, sha);
430
+ assertNonEmptyWindow(root, ref, sha, head);
420
431
  return { ref, requested: ref, sha, substituted: false };
421
432
  }
422
433
  if (!mayFallBack) {
@@ -425,7 +436,7 @@ export function resolveBaseline(root, ref, mayFallBack = false) {
425
436
  `release history this gate can read`
426
437
  );
427
438
  }
428
- const fallback = oldestReachableReleaseTag(root);
439
+ const fallback = oldestReachableReleaseTag(root, head);
429
440
  if (fallback === null) {
430
441
  throw new UsageError(
431
442
  `baseline ${ref} is not an ancestor of HEAD, and no release tag is ` +
@@ -440,7 +451,7 @@ export function resolveBaseline(root, ref, mayFallBack = false) {
440
451
  ).trim();
441
452
  const span = git(
442
453
  root,
443
- ["rev-list", "--count", `${fallbackSha}..HEAD`],
454
+ ["rev-list", "--count", `${fallbackSha}..${head}`],
444
455
  `could not measure the window ${fallback}..HEAD`
445
456
  ).trim();
446
457
  if (span === "0") {
@@ -463,13 +474,22 @@ export function resolveBaseline(root, ref, mayFallBack = false) {
463
474
  * @param {string} [symbol] - the removed export; when given, the commit is
464
475
  * found by pickaxe on that symbol rather than by last touch of the file, so
465
476
  * an unrelated later edit does not get the blame.
477
+ * @param {string} [head] - pinned scan commit, defaulting to HEAD.
466
478
  * @returns {string} a tag name, or a description of why there is none.
467
479
  */
468
- export function removalRelease(root, baseline, file, symbol) {
480
+ export function removalRelease(root, baseline, file, symbol, head = "HEAD") {
469
481
  const pickaxe = symbol === undefined ? [] : [`-S${symbol}`];
470
482
  const sha = git(
471
483
  root,
472
- ["log", "-1", "--format=%H", ...pickaxe, `${baseline}..HEAD`, "--", file],
484
+ [
485
+ "log",
486
+ "-1",
487
+ "--format=%H",
488
+ ...pickaxe,
489
+ `${baseline}..${head}`,
490
+ "--",
491
+ file,
492
+ ],
473
493
  `could not date the removal of ${file}`
474
494
  ).trim();
475
495
  if (sha === "") return "an unidentified commit";
@@ -508,7 +528,7 @@ export function findGoverningDeletion(stack, destination, manifests) {
508
528
  /**
509
529
  * Findings for shipped FILES that existed at the baseline and are gone at HEAD.
510
530
  *
511
- * @param {{ root: string, baseline: string, before: Map<string, object>, after: Map<string, object>, manifests: Map<string, object>, ledger: Map<string, object> }} input
531
+ * @param {{ root: string, baseline: string, head?: string, before: Map<string, object>, after: Map<string, object>, manifests: Map<string, object>, ledger: Map<string, object> }} input
512
532
  * the resolved scan inputs.
513
533
  * @returns {Array<Record<string, unknown>>} one row per ungoverned removal.
514
534
  */
@@ -529,7 +549,13 @@ export function findRemovedPaths(input) {
529
549
  kind: verdict.kind,
530
550
  path: file,
531
551
  reason: verdict.reason,
532
- release: removalRelease(input.root, input.baseline, file),
552
+ release: removalRelease(
553
+ input.root,
554
+ input.baseline,
555
+ file,
556
+ undefined,
557
+ input.head
558
+ ),
533
559
  });
534
560
  }
535
561
  return rows.sort((left, right) => left.path.localeCompare(right.path));
@@ -579,16 +605,20 @@ export function classifyRemovedPath(destination, deletion, noted) {
579
605
  /**
580
606
  * Findings for named EXPORTS present at the baseline and gone at HEAD.
581
607
  *
582
- * @param {{ root: string, baseline: string, before: Map<string, object>, after: Map<string, object>, ledger: Map<string, object>, importers: Map<string, number> }} input
608
+ * @param {{ root: string, baseline: string, head?: string, read?: (file: string) => string, before: Map<string, object>, after: Map<string, object>, ledger: Map<string, object>, importers: Map<string, number> }} input
583
609
  * the resolved scan inputs.
584
610
  * @returns {Array<Record<string, unknown>>} one row per unrecorded export removal.
585
611
  */
586
612
  export function findRemovedExports(input) {
587
613
  const rows = [];
588
- for (const file of changedModules(input.root, input.baseline)) {
614
+ for (const file of changedModules(input.root, input.baseline, input.head)) {
589
615
  if (!input.before.has(file) || !input.after.has(file)) continue;
590
616
  const was = parseNamedExports(showFile(input.root, input.baseline, file));
591
- const now = parseNamedExports(showFile(input.root, "HEAD", file));
617
+ const now = parseNamedExports(
618
+ input.read
619
+ ? input.read(file)
620
+ : showFile(input.root, input.head ?? "HEAD", file)
621
+ );
592
622
  for (const name of [...was.names].sort()) {
593
623
  if (now.names.has(name)) continue;
594
624
  if (hasUsableNote(input.ledger.get(removalKey(file, name)))) continue;
@@ -597,7 +627,13 @@ export function findRemovedExports(input) {
597
627
  importers: input.importers.get(file) ?? 0,
598
628
  kind: "unrecorded-export",
599
629
  path: file,
600
- release: removalRelease(input.root, input.baseline, file, name),
630
+ release: removalRelease(
631
+ input.root,
632
+ input.baseline,
633
+ file,
634
+ name,
635
+ input.head
636
+ ),
601
637
  });
602
638
  }
603
639
  }
@@ -612,11 +648,12 @@ export function findRemovedExports(input) {
612
648
  * @param {string} root - repository root.
613
649
  * @param {string} baseline - the baseline sha.
614
650
  * @returns {string[]} repo-relative paths.
651
+ * @param {string} [head] - pinned scan commit, defaulting to HEAD.
615
652
  */
616
- function changedModules(root, baseline) {
653
+ function changedModules(root, baseline, head = "HEAD") {
617
654
  const stdout = git(
618
655
  root,
619
- ["diff", "--name-only", "-z", `${baseline}..HEAD`],
656
+ ["diff", "--name-only", "-z", `${baseline}..${head}`],
620
657
  "could not diff the baseline against HEAD"
621
658
  );
622
659
  return stdout
@@ -641,6 +678,37 @@ function showFile(root, ref, file) {
641
678
  return git(root, ["show", `${ref}:${file}`], `could not read ${file}@${ref}`);
642
679
  }
643
680
 
681
+ /**
682
+ * One complete committed snapshot with an invocation-local blob cache.
683
+ * Resolve mutable refs once; never cache live files, failures or other roots.
684
+ * The existing output bound also caps retained UTF-8 bytes. Once full, reads
685
+ * continue uncached so cache capacity cannot truncate the scan.
686
+ * @param {string} root - repository root.
687
+ * @param {string} ref - revision to resolve to an immutable commit.
688
+ * @returns {{ commit: string, read: (file: string) => string }} snapshot reader.
689
+ */
690
+ export function createBlobSnapshot(root, ref) {
691
+ const commit = git(
692
+ root,
693
+ ["rev-parse", "--verify", `${ref}^{commit}`],
694
+ `could not resolve snapshot ${ref}`
695
+ ).trim();
696
+ const cache = new Map();
697
+ let retainedBytes = 0;
698
+ /** @param {string} file - repo path. @returns {string} Complete blob text. */
699
+ const read = file => {
700
+ if (cache.has(file)) return cache.get(file);
701
+ const source = showFile(root, commit, file);
702
+ const bytes = Buffer.byteLength(source, "utf8");
703
+ if (bytes <= MAX_GIT_OUTPUT_BYTES - retainedBytes) {
704
+ cache.set(file, source);
705
+ retainedBytes += bytes;
706
+ }
707
+ return source;
708
+ };
709
+ return { commit, read };
710
+ }
711
+
644
712
  /**
645
713
  * The delivery view a consumer of `stack` sees: destination path to repo path,
646
714
  * more specific stacks winning over their ancestors.
@@ -932,21 +1000,22 @@ export function countImporters(shipped, read) {
932
1000
  */
933
1001
  export function scan(opts) {
934
1002
  const ledger = loadLedger(opts.root);
1003
+ const { commit: head, read } = createBlobSnapshot(opts.root, "HEAD");
935
1004
  const resolved = resolveBaseline(
936
1005
  opts.root,
937
1006
  opts.since ?? ledger.baseline,
938
- opts.since === null || opts.since === undefined
1007
+ opts.since === null || opts.since === undefined,
1008
+ head
939
1009
  );
940
1010
  const baseline = resolved.sha;
941
1011
  const before = shippedFilesAt(opts.root, baseline);
942
- const after = shippedFilesAt(opts.root, "HEAD");
1012
+ const after = shippedFilesAt(opts.root, head);
943
1013
  if (after.size === 0) {
944
1014
  throw new UsageError(
945
1015
  `no shipped files found under ${opts.root} - refusing to report a clean ` +
946
1016
  `scan of a tree this gate could not read`
947
1017
  );
948
1018
  }
949
- const read = file => showFile(opts.root, "HEAD", file);
950
1019
  const index = indexRemovals(ledger.removals);
951
1020
  const findings = {
952
1021
  exports: findRemovedExports({
@@ -954,7 +1023,9 @@ export function scan(opts) {
954
1023
  baseline,
955
1024
  before,
956
1025
  importers: countImporters(after, read),
1026
+ head,
957
1027
  ledger: index,
1028
+ read,
958
1029
  root: opts.root,
959
1030
  }),
960
1031
  imports: findUnresolvedImports({ read, shipped: after }),
@@ -967,6 +1038,7 @@ export function scan(opts) {
967
1038
  after,
968
1039
  baseline,
969
1040
  before,
1041
+ head,
970
1042
  ledger: index,
971
1043
  manifests: readDeletionManifests(opts.root),
972
1044
  root: opts.root,