@codyswann/lisa 3.15.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/all/copy-overwrite/scripts/check-state-classification.mjs +3 -5
  2. package/all/copy-overwrite/scripts/lib/invoked-as-script.mjs +96 -0
  3. package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +3 -5
  4. package/all/copy-overwrite/scripts/lisa-work-item.mjs +3 -5
  5. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  6. package/dist/core/lisa-owned-hash-ledger.js +19 -0
  7. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +32 -17
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +128 -0
  12. package/expo/copy-overwrite/scripts/bdd/render.mjs +1 -14
  13. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +3 -3
  14. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +3 -3
  15. package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +3 -5
  16. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +5 -15
  17. package/expo/copy-overwrite/scripts/lib/invoked-as-script.mjs +96 -0
  18. package/package.json +1 -1
  19. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-agy/plugin.json +1 -1
  32. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  52. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-agy/plugin.json +1 -1
  57. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  62. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  67. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  69. package/scripts/build-plugins.sh +29 -0
  70. package/scripts/check-conflict-markers.mjs +4 -5
  71. package/scripts/detect-stale-workflow-inputs.mjs +4 -5
  72. package/scripts/lib/invoked-as-script.mjs +93 -0
  73. package/scripts/plugin-parity-drift.mjs +4 -5
  74. package/scripts/plugin-routing-validate.mjs +4 -5
  75. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +2 -5
  76. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +3 -5
  77. package/typescript/copy-overwrite/scripts/check-verification-coverage.mjs +3 -5
  78. package/typescript/copy-overwrite/scripts/lib/invoked-as-script.mjs +96 -0
  79. package/ui/index.html +271 -12
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
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"
@@ -153,6 +153,35 @@ if [ -f "$ROOT_DIR/scripts/lisa-enforcement-fallback.sh" ]; then
153
153
  chmod +x "$ROOT_DIR/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh"
154
154
  fi
155
155
 
156
+ # The shared ESM entry guard, into every lane that has a consumer of it.
157
+ #
158
+ # Downstream, all of these land flat in one `scripts/` directory, so a single
159
+ # `scripts/lib/invoked-as-script.mjs` serves every lane and `./lib/...` resolves
160
+ # from each. Inside THIS repo the lanes are separate trees and Lisa's own unit
161
+ # tests import the lane copies directly, so each lane that imports the helper
162
+ # must carry it or those imports fail to resolve. Synced from one canonical
163
+ # source rather than hand-copied, for the same reason the ratchet above is: a
164
+ # unit test asserts byte-equality, so the three copies can never drift.
165
+ #
166
+ # `all` is applied to every project type before the stack lanes, so the copies
167
+ # in `typescript` and `expo` are redundant at apply time — they exist for
168
+ # in-repo resolution and are byte-identical, so whichever lane writes last
169
+ # writes the same bytes.
170
+ #
171
+ # Guarded on the GENERATOR as well as the source. This script is run against
172
+ # isolated fixtures that vendor `scripts/lib/` but not the repository's own
173
+ # `scripts/*.mjs`, so testing the source alone would find it present and then
174
+ # fail the whole build on a missing materializer.
175
+ if [ -f "$ROOT_DIR/scripts/lib/invoked-as-script.mjs" ] &&
176
+ [ -f "$ROOT_DIR/scripts/materialize-copy-overwrite.mjs" ]; then
177
+ for guard_lane in all typescript expo; do
178
+ guard_lib_dir="$ROOT_DIR/$guard_lane/copy-overwrite/scripts/lib"
179
+ mkdir -p "$guard_lib_dir"
180
+ materialize "$ROOT_DIR/scripts/lib/invoked-as-script.mjs" \
181
+ "$guard_lib_dir/invoked-as-script.mjs"
182
+ done
183
+ fi
184
+
156
185
  # Stack-specific plugins (NO base copy)
157
186
  STACKS=(typescript expo nestjs cdk harper-fabric phaser rails)
158
187
  for stack in "${STACKS[@]}"; do
@@ -43,7 +43,9 @@ import { execFileSync } from "node:child_process";
43
43
  import fs from "node:fs";
44
44
  import path from "node:path";
45
45
  import process from "node:process";
46
- import { fileURLToPath, pathToFileURL } from "node:url";
46
+ import { fileURLToPath } from "node:url";
47
+
48
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
47
49
 
48
50
  const REPO_ROOT = path.resolve(
49
51
  path.dirname(fileURLToPath(import.meta.url)),
@@ -302,10 +304,7 @@ export function main(argv, io = {}) {
302
304
  return results.length === 0 ? 0 : 1;
303
305
  }
304
306
 
305
- if (
306
- process.argv[1] &&
307
- import.meta.url === pathToFileURL(process.argv[1]).href
308
- ) {
307
+ if (invokedAsScript(import.meta.url)) {
309
308
  // exitCode (not process.exit): when stdout is a pipe, writes are async and
310
309
  // process.exit() truncates the report mid-flush.
311
310
  process.exitCode = main(process.argv.slice(2));
@@ -41,7 +41,9 @@
41
41
  import fs from "node:fs";
42
42
  import path from "node:path";
43
43
  import process from "node:process";
44
- import { fileURLToPath, pathToFileURL } from "node:url";
44
+ import { fileURLToPath } from "node:url";
45
+
46
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
45
47
  import {
46
48
  diffStaleKeys,
47
49
  extractCallerJobs,
@@ -320,9 +322,6 @@ export function main(argv, io = {}) {
320
322
  return results.some(r => r.status === "stale") ? 1 : 0;
321
323
  }
322
324
 
323
- if (
324
- process.argv[1] &&
325
- import.meta.url === pathToFileURL(process.argv[1]).href
326
- ) {
325
+ if (invokedAsScript(import.meta.url)) {
327
326
  process.exit(main(process.argv.slice(2)));
328
327
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * invoked-as-script — the one implementation of "was this module run as the CLI
3
+ * entry point?", shared by every guarded `.mjs` entry point Lisa ships.
4
+ *
5
+ * @remarks
6
+ * ## Why this is shared rather than copied
7
+ *
8
+ * The obvious spelling is `import.meta.url === pathToFileURL(process.argv[1]).href`,
9
+ * and it is wrong in a way that cannot be seen in review. `import.meta.url` is
10
+ * normally the REAL path — node resolves ESM through `realpath` unless
11
+ * `--preserve-symlinks` or `--preserve-symlinks-main` is set — while `argv[1]`
12
+ * is whatever spelling the caller typed. Naming only the first of those two
13
+ * flags is what let a second instance of this very defect survive review here:
14
+ * `--preserve-symlinks-main` is a SEPARATE flag that applies to the entry
15
+ * module specifically, which is exactly the module this guard runs in, so it is
16
+ * the one that matters most and the one easiest to leave out. See the section
17
+ * on realpathing both sides below.
18
+ * Reached through a symlinked checkout, a symlinked bin shim, or a
19
+ * `/tmp` path on macOS (`/tmp` is itself a symlink to `/private/tmp`), the two
20
+ * differ, the guard is false, `main()` never runs, and the process exits 0
21
+ * having done nothing.
22
+ *
23
+ * That failure mode is not symmetric. A no-op GENERATOR usually fails closed —
24
+ * whatever consumes its output notices the missing artifact. A no-op CHECK
25
+ * fails **OPEN**: it prints nothing, exits 0, and the npm script "succeeds", so
26
+ * a gate that is meant to block a merge silently stops having an opinion. Every
27
+ * `check-*.mjs` in this tree is in the second category, and git worktrees —
28
+ * which every Lisa-driven agent uses — put a symlinked path on that code path
29
+ * as a matter of routine.
30
+ *
31
+ * ## `moduleUrl` is REQUIRED, and is the FIRST parameter
32
+ *
33
+ * A deliberate deviation from the obvious signature
34
+ * `invokedAsScript(argv1 = process.argv[1], moduleUrl = import.meta.url)`. A
35
+ * defaulted `import.meta.url` inside a SHARED module resolves to *this* file,
36
+ * which is never anybody's `process.argv[1]` — so a caller that forgot the
37
+ * second argument would get `false` forever and no-op silently. That is exactly
38
+ * the fail-open defect this module exists to remove, re-introduced by its own
39
+ * convenience default. Making it required and first means a call site cannot be
40
+ * written wrong.
41
+ *
42
+ * ## Why ANY resolution error returns false
43
+ *
44
+ * `realpathSync` throws ENOENT, EACCES, ELOOP and ENOTDIR, not just ENOENT. A
45
+ * fallback that narrows to ENOENT and then compares `resolve(argv1)` reinstates
46
+ * the un-normalized comparison the realpath exists to avoid, so the symlink
47
+ * defect survives on precisely the path meant to be the safety net.
48
+ *
49
+ * Returning `false` on an unresolvable `argv[1]` is sound rather than merely
50
+ * convenient: node resolved and LOADED the entry point from that path moments
51
+ * earlier, so a path that cannot be resolved now is not the path this module
52
+ * was loaded through. Comparing an unnormalized spelling instead would answer
53
+ * "maybe" with a confident "yes".
54
+ *
55
+ * ## Why BOTH sides are realpath'd, not just `argv[1]`
56
+ *
57
+ * "`import.meta.url` is always the real path" is true by default and false under
58
+ * `--preserve-symlinks-main`, which tells node not to resolve the main entry.
59
+ * Normalizing only `argv[1]` then compares a real path against a symlinked one
60
+ * and answers `false` for an entry point that WAS invoked directly — the same
61
+ * fail-open this module exists to remove, reappearing on the flag that most
62
+ * looks like it should not matter. Measured: a symlinked entry point reports
63
+ * `true` normally and `false` under the flag.
64
+ *
65
+ * Realpathing both sides is free in the ordinary case, where `moduleUrl` is
66
+ * already canonical and `realpathSync` is the identity.
67
+ *
68
+ * @module scripts/lib/invoked-as-script
69
+ */
70
+ import { realpathSync } from "node:fs";
71
+ import { fileURLToPath } from "node:url";
72
+
73
+ /**
74
+ * True when `moduleUrl` names the module node was asked to run.
75
+ *
76
+ * Both sides are realpath'd before comparison — see the module remarks for why a
77
+ * raw comparison silently answers "no" under a symlinked path, and why
78
+ * normalizing only `argv[1]` leaves the same hole open under
79
+ * `--preserve-symlinks-main`.
80
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
81
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
82
+ * @returns {boolean} Whether the caller should run its CLI body.
83
+ */
84
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
85
+ // `node -e`, `node --eval`, `node --print` and the REPL leave `argv[1]`
86
+ // undefined. Nothing was asked to run, so nothing should.
87
+ if (!argv1) return false;
88
+ try {
89
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
@@ -40,7 +40,9 @@ import fs from "node:fs";
40
40
  import os from "node:os";
41
41
  import path from "node:path";
42
42
  import process from "node:process";
43
- import { fileURLToPath, pathToFileURL } from "node:url";
43
+ import { fileURLToPath } from "node:url";
44
+
45
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
44
46
 
45
47
  const REPO_ROOT = path.resolve(
46
48
  path.dirname(fileURLToPath(import.meta.url)),
@@ -608,10 +610,7 @@ export function main(argv, io = {}) {
608
610
  return results.every(r => r.status === "ok") ? 0 : 1;
609
611
  }
610
612
 
611
- if (
612
- process.argv[1] &&
613
- import.meta.url === pathToFileURL(process.argv[1]).href
614
- ) {
613
+ if (invokedAsScript(import.meta.url)) {
615
614
  // exitCode (not process.exit): when stdout is a pipe, writes are async and
616
615
  // process.exit() truncates the report mid-flush (observed: a consumer's
617
616
  // $(...) capture stopped at the first 512-byte chunk). Setting exitCode lets
@@ -36,7 +36,9 @@ import fs from "node:fs";
36
36
  import os from "node:os";
37
37
  import path from "node:path";
38
38
  import process from "node:process";
39
- import { fileURLToPath, pathToFileURL } from "node:url";
39
+ import { fileURLToPath } from "node:url";
40
+
41
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
40
42
  import { compareSemver, isValidSemver } from "./plugin-parity-drift.mjs";
41
43
 
42
44
  const REPO_ROOT = path.resolve(
@@ -639,9 +641,6 @@ export function main(argv, io = {}) {
639
641
  return results.every(r => r.errors.length === 0) ? 0 : 1;
640
642
  }
641
643
 
642
- if (
643
- process.argv[1] &&
644
- import.meta.url === pathToFileURL(process.argv[1]).href
645
- ) {
644
+ if (invokedAsScript(import.meta.url)) {
646
645
  process.exit(main(process.argv.slice(2)));
647
646
  }
@@ -80,7 +80,7 @@
80
80
  * @module scripts/check-nightly-e2e-health
81
81
  */
82
82
 
83
- import { pathToFileURL } from "node:url";
83
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
84
84
 
85
85
  /**
86
86
  * Contract version of the gate, asserted by the reusable workflow against its
@@ -2368,9 +2368,6 @@ async function writeOutputs(machine) {
2368
2368
  );
2369
2369
  }
2370
2370
 
2371
- if (
2372
- process.argv[1] &&
2373
- import.meta.url === pathToFileURL(process.argv[1]).href
2374
- ) {
2371
+ if (invokedAsScript(import.meta.url)) {
2375
2372
  await main(process.argv.slice(2));
2376
2373
  }
@@ -191,7 +191,8 @@
191
191
  import { execFileSync } from "node:child_process";
192
192
  import { existsSync, readFileSync } from "node:fs";
193
193
  import { resolve } from "node:path";
194
- import { pathToFileURL } from "node:url";
194
+
195
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
195
196
 
196
197
  /** Repo-relative path of the per-repo declaration file. */
197
198
  export const DECLARATION_PATH = ".github/required-checks.json";
@@ -1253,9 +1254,6 @@ function main(argv) {
1253
1254
  }
1254
1255
  }
1255
1256
 
1256
- if (
1257
- process.argv[1] &&
1258
- import.meta.url === pathToFileURL(process.argv[1]).href
1259
- ) {
1257
+ if (invokedAsScript(import.meta.url)) {
1260
1258
  main(process.argv.slice(2));
1261
1259
  }
@@ -27,7 +27,8 @@
27
27
  * @module scripts/check-verification-coverage
28
28
  */
29
29
  import { execSync } from "node:child_process";
30
- import { pathToFileURL } from "node:url";
30
+
31
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
31
32
 
32
33
  const BEHAVIORAL_TYPES = new Set(["feat", "fix"]);
33
34
  const EXEMPT_LABEL = "verification-exempt";
@@ -237,10 +238,7 @@ async function main() {
237
238
  }
238
239
 
239
240
  // Run only when invoked directly — importing for tests must have no side effects.
240
- if (
241
- process.argv[1] &&
242
- import.meta.url === pathToFileURL(process.argv[1]).href
243
- ) {
241
+ if (invokedAsScript(import.meta.url)) {
244
242
  main().catch(error => {
245
243
  console.error(`[verification-coverage] FAIL: ${error.message}`);
246
244
  process.exit(1);
@@ -0,0 +1,96 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /**
5
+ * invoked-as-script — the one implementation of "was this module run as the CLI
6
+ * entry point?", shared by every guarded `.mjs` entry point Lisa ships.
7
+ *
8
+ * @remarks
9
+ * ## Why this is shared rather than copied
10
+ *
11
+ * The obvious spelling is `import.meta.url === pathToFileURL(process.argv[1]).href`,
12
+ * and it is wrong in a way that cannot be seen in review. `import.meta.url` is
13
+ * normally the REAL path — node resolves ESM through `realpath` unless
14
+ * `--preserve-symlinks` or `--preserve-symlinks-main` is set — while `argv[1]`
15
+ * is whatever spelling the caller typed. Naming only the first of those two
16
+ * flags is what let a second instance of this very defect survive review here:
17
+ * `--preserve-symlinks-main` is a SEPARATE flag that applies to the entry
18
+ * module specifically, which is exactly the module this guard runs in, so it is
19
+ * the one that matters most and the one easiest to leave out. See the section
20
+ * on realpathing both sides below.
21
+ * Reached through a symlinked checkout, a symlinked bin shim, or a
22
+ * `/tmp` path on macOS (`/tmp` is itself a symlink to `/private/tmp`), the two
23
+ * differ, the guard is false, `main()` never runs, and the process exits 0
24
+ * having done nothing.
25
+ *
26
+ * That failure mode is not symmetric. A no-op GENERATOR usually fails closed —
27
+ * whatever consumes its output notices the missing artifact. A no-op CHECK
28
+ * fails **OPEN**: it prints nothing, exits 0, and the npm script "succeeds", so
29
+ * a gate that is meant to block a merge silently stops having an opinion. Every
30
+ * `check-*.mjs` in this tree is in the second category, and git worktrees —
31
+ * which every Lisa-driven agent uses — put a symlinked path on that code path
32
+ * as a matter of routine.
33
+ *
34
+ * ## `moduleUrl` is REQUIRED, and is the FIRST parameter
35
+ *
36
+ * A deliberate deviation from the obvious signature
37
+ * `invokedAsScript(argv1 = process.argv[1], moduleUrl = import.meta.url)`. A
38
+ * defaulted `import.meta.url` inside a SHARED module resolves to *this* file,
39
+ * which is never anybody's `process.argv[1]` — so a caller that forgot the
40
+ * second argument would get `false` forever and no-op silently. That is exactly
41
+ * the fail-open defect this module exists to remove, re-introduced by its own
42
+ * convenience default. Making it required and first means a call site cannot be
43
+ * written wrong.
44
+ *
45
+ * ## Why ANY resolution error returns false
46
+ *
47
+ * `realpathSync` throws ENOENT, EACCES, ELOOP and ENOTDIR, not just ENOENT. A
48
+ * fallback that narrows to ENOENT and then compares `resolve(argv1)` reinstates
49
+ * the un-normalized comparison the realpath exists to avoid, so the symlink
50
+ * defect survives on precisely the path meant to be the safety net.
51
+ *
52
+ * Returning `false` on an unresolvable `argv[1]` is sound rather than merely
53
+ * convenient: node resolved and LOADED the entry point from that path moments
54
+ * earlier, so a path that cannot be resolved now is not the path this module
55
+ * was loaded through. Comparing an unnormalized spelling instead would answer
56
+ * "maybe" with a confident "yes".
57
+ *
58
+ * ## Why BOTH sides are realpath'd, not just `argv[1]`
59
+ *
60
+ * "`import.meta.url` is always the real path" is true by default and false under
61
+ * `--preserve-symlinks-main`, which tells node not to resolve the main entry.
62
+ * Normalizing only `argv[1]` then compares a real path against a symlinked one
63
+ * and answers `false` for an entry point that WAS invoked directly — the same
64
+ * fail-open this module exists to remove, reappearing on the flag that most
65
+ * looks like it should not matter. Measured: a symlinked entry point reports
66
+ * `true` normally and `false` under the flag.
67
+ *
68
+ * Realpathing both sides is free in the ordinary case, where `moduleUrl` is
69
+ * already canonical and `realpathSync` is the identity.
70
+ *
71
+ * @module scripts/lib/invoked-as-script
72
+ */
73
+ import { realpathSync } from "node:fs";
74
+ import { fileURLToPath } from "node:url";
75
+
76
+ /**
77
+ * True when `moduleUrl` names the module node was asked to run.
78
+ *
79
+ * Both sides are realpath'd before comparison — see the module remarks for why a
80
+ * raw comparison silently answers "no" under a symlinked path, and why
81
+ * normalizing only `argv[1]` leaves the same hole open under
82
+ * `--preserve-symlinks-main`.
83
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
84
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
85
+ * @returns {boolean} Whether the caller should run its CLI body.
86
+ */
87
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
88
+ // `node -e`, `node --eval`, `node --print` and the REPL leave `argv[1]`
89
+ // undefined. Nothing was asked to run, so nothing should.
90
+ if (!argv1) return false;
91
+ try {
92
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
93
+ } catch {
94
+ return false;
95
+ }
96
+ }