@vibe-agent-toolkit/utils 0.2.0-rc.5 → 0.2.0-rc.7

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 (59) hide show
  1. package/README.md +21 -6
  2. package/dist/entrypoint.d.ts +54 -0
  3. package/dist/entrypoint.d.ts.map +1 -0
  4. package/dist/entrypoint.js +71 -0
  5. package/dist/entrypoint.js.map +1 -0
  6. package/dist/env-flag.d.ts +46 -0
  7. package/dist/env-flag.d.ts.map +1 -0
  8. package/dist/env-flag.js +57 -0
  9. package/dist/env-flag.js.map +1 -0
  10. package/dist/file-crawler.d.ts +22 -0
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +74 -13
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +406 -199
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +479 -203
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +2 -2
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +4 -7
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-tracker.d.ts +40 -1
  23. package/dist/git-tracker.d.ts.map +1 -1
  24. package/dist/git-tracker.js +83 -17
  25. package/dist/git-tracker.js.map +1 -1
  26. package/dist/git-utils.d.ts +47 -2
  27. package/dist/git-utils.d.ts.map +1 -1
  28. package/dist/git-utils.js +123 -19
  29. package/dist/git-utils.js.map +1 -1
  30. package/dist/git.d.ts +1 -0
  31. package/dist/git.d.ts.map +1 -1
  32. package/dist/git.js.map +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +30 -19
  36. package/dist/index.js.map +1 -1
  37. package/dist/listing-refusal.d.ts +111 -0
  38. package/dist/listing-refusal.d.ts.map +1 -0
  39. package/dist/listing-refusal.js +105 -0
  40. package/dist/listing-refusal.js.map +1 -0
  41. package/dist/process.d.ts +7 -0
  42. package/dist/process.d.ts.map +1 -1
  43. package/dist/process.js +7 -0
  44. package/dist/process.js.map +1 -1
  45. package/dist/safe-exec.js +1 -1
  46. package/dist/safe-exec.js.map +1 -1
  47. package/dist/test-helpers.d.ts +20 -0
  48. package/dist/test-helpers.d.ts.map +1 -1
  49. package/dist/test-helpers.js +38 -2
  50. package/dist/test-helpers.js.map +1 -1
  51. package/dist/testing.d.ts +16 -0
  52. package/dist/testing.d.ts.map +1 -1
  53. package/dist/testing.js +17 -0
  54. package/dist/testing.js.map +1 -1
  55. package/eslint/README.md +16 -2
  56. package/eslint/index.cjs +15 -0
  57. package/eslint/index.d.cts +7 -4
  58. package/eslint/rules/no-fragile-entrypoint-guard.cjs +215 -0
  59. package/package.json +3 -3
package/dist/testing.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * it. {@link replantableCorpus} exists precisely so the per-test wrapper each
19
19
  * suite still writes is three lines rather than twelve.
20
20
  */
21
+ import type { UnreadablePolicy } from './listing-refusal.js';
21
22
  export * from './test-helpers.js';
22
23
  /** A planted fixture tree and the means to remove it. */
23
24
  export interface TempCorpus {
@@ -75,4 +76,19 @@ export interface ReplantableCorpus {
75
76
  * @returns Plant/clear/root, to be driven from the caller's own hooks
76
77
  */
77
78
  export declare function replantableCorpus(prefix: string, corpus: Readonly<Record<string, string>>): ReplantableCorpus;
79
+ /**
80
+ * The `unreadable` policy a FIXTURE crawl states: refuse, against the fixture
81
+ * root, with a remedy that names the fixture rather than an adopter knob.
82
+ *
83
+ * A suite that is not ABOUT refusals still has to say what one would mean —
84
+ * the policy is required, with no default, and test files are not typechecked
85
+ * — and the honest answer for a planted tree is that a directory it cannot
86
+ * list is a broken fixture, not a case to degrade around. A suite that IS
87
+ * about refusals spells its own policy inline, because the policy is the
88
+ * subject.
89
+ *
90
+ * @param root - The fixture root the refused directory is expressed against
91
+ * @returns A `refuse` policy for `crawlDirectory` and the git listings
92
+ */
93
+ export declare function refuseUnreadableFixture(root: string): UnreadablePolicy;
78
94
  //# sourceMappingURL=testing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAUH,cAAc,mBAAmB,CAAC;AAElC,yDAAyD;AACzD,MAAM,WAAW,UAAU;IACzB,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACvC,UAAU,CAOZ;AAED,+EAA+E;AAC/E,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,wDAAwD;IACxD,IAAI,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACvC,iBAAiB,CAyBnB"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAO7D,cAAc,mBAAmB,CAAC;AAElC,yDAAyD;AACzD,MAAM,WAAW,UAAU;IACzB,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACvC,UAAU,CAOZ;AAED,+EAA+E;AAC/E,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,wDAAwD;IACxD,IAAI,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACvC,iBAAiB,CAyBnB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAEtE"}
package/dist/testing.js CHANGED
@@ -95,4 +95,21 @@ export function replantableCorpus(prefix, corpus) {
95
95
  },
96
96
  };
97
97
  }
98
+ /**
99
+ * The `unreadable` policy a FIXTURE crawl states: refuse, against the fixture
100
+ * root, with a remedy that names the fixture rather than an adopter knob.
101
+ *
102
+ * A suite that is not ABOUT refusals still has to say what one would mean —
103
+ * the policy is required, with no default, and test files are not typechecked
104
+ * — and the honest answer for a planted tree is that a directory it cannot
105
+ * list is a broken fixture, not a case to degrade around. A suite that IS
106
+ * about refusals spells its own policy inline, because the policy is the
107
+ * subject.
108
+ *
109
+ * @param root - The fixture root the refused directory is expressed against
110
+ * @returns A `refuse` policy for `crawlDirectory` and the git listings
111
+ */
112
+ export function refuseUnreadableFixture(root) {
113
+ return { refuse: { root, remedy: 'A test fixture must be listable in full; fix the fixture, not the walk.' } };
114
+ }
98
115
  //# sourceMappingURL=testing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7D,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE7D,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAC/E,sEAAsE;AACtE,cAAc,mBAAmB,CAAC;AAUlC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAc,EACd,MAAwC;IAExC,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;IACpE,KAAK,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACrD,gKAAgK;QAChK,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AACjF,CAAC;AAYD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAc,EACd,MAAwC;IAExC,IAAI,OAA+B,CAAC;IACpC,OAAO;QACL,KAAK,EAAE,GAAG,EAAE;YACV,0EAA0E;YAC1E,uEAAuE;YACvE,wEAAwE;YACxE,wEAAwE;YACxE,iEAAiE;YACjE,OAAO,EAAE,OAAO,EAAE,CAAC;YACnB,OAAO,GAAG,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,EAAE,GAAG,EAAE;YACV,OAAO,EAAE,OAAO,EAAE,CAAC;YACnB,OAAO,GAAG,SAAS,CAAC;QACtB,CAAC;QACD,IAAI,EAAE,GAAG,EAAE;YACT,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBAC1B,MAAM,IAAI,KAAK,CACb,sBAAsB,MAAM,iEAAiE,CAC9F,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,IAAI,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE7D,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAC/E,sEAAsE;AACtE,cAAc,mBAAmB,CAAC;AAUlC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAc,EACd,MAAwC;IAExC,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;IACpE,KAAK,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACrD,gKAAgK;QAChK,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AACjF,CAAC;AAYD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAc,EACd,MAAwC;IAExC,IAAI,OAA+B,CAAC;IACpC,OAAO;QACL,KAAK,EAAE,GAAG,EAAE;YACV,0EAA0E;YAC1E,uEAAuE;YACvE,wEAAwE;YACxE,wEAAwE;YACxE,iEAAiE;YACjE,OAAO,EAAE,OAAO,EAAE,CAAC;YACnB,OAAO,GAAG,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,EAAE,GAAG,EAAE;YACV,OAAO,EAAE,OAAO,EAAE,CAAC;YACnB,OAAO,GAAG,SAAS,CAAC;QACtB,CAAC;QACD,IAAI,EAAE,GAAG,EAAE;YACT,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBAC1B,MAAM,IAAI,KAAK,CACb,sBAAsB,MAAM,iEAAiE,CAC9F,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,IAAI,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAAY;IAClD,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,yEAAyE,EAAE,EAAE,CAAC;AACjH,CAAC"}
package/eslint/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ESLint rules that enforce the cross-platform and agentic-code safety helpers in the rest of [`@vibe-agent-toolkit/utils`](https://www.npmjs.com/package/@vibe-agent-toolkit/utils).
4
4
 
5
- Twenty-one rules, all of them derived from a bug that actually shipped: `os.tmpdir()` returning an 8.3 short path on a Windows CI runner, `path.join()` producing backslashes that then failed a string comparison, `await import(absolutePath)` throwing on Windows without a `file://` URL, `execSync()` interpolating a caller-controlled string into a shell. Most auto-fix.
5
+ Twenty-six rules, all of them derived from a bug that actually shipped: `os.tmpdir()` returning an 8.3 short path on a Windows CI runner, `path.join()` producing backslashes that then failed a string comparison, `await import(absolutePath)` throwing on Windows without a `file://` URL, `execSync()` interpolating a caller-controlled string into a shell. Most auto-fix.
6
6
 
7
7
  ## Installation
8
8
 
@@ -25,7 +25,7 @@ export default [
25
25
  ];
26
26
  ```
27
27
 
28
- `configs.recommended` registers the plugin under the `@vibe-agent-toolkit` namespace and enables the **cross-platform safety core** — 18 of the 22 rules, most at `error` and three at `warn` (see [Severities](#severities)). The other four are opt-in; the [rule tables](#rules) mark each rule's `recommended` severity, and `—` means not in `recommended`.
28
+ `configs.recommended` registers the plugin under the `@vibe-agent-toolkit` namespace and enables the **cross-platform safety core** — 18 of the 26 rules, most at `error` and three at `warn` (see [Severities](#severities)). The other eight are opt-in; the [rule tables](#rules) mark each rule's `recommended` severity, and `—` means not in `recommended`.
29
29
 
30
30
  To pick rules yourself, register the plugin and name them:
31
31
 
@@ -137,6 +137,20 @@ The member-call rules here check the **receiver**, not just the method name, so
137
137
  | `no-bare-dynamic-import-path` | `await import(absolutePath)` | `dynamicImportPath()` / `pathToFileURL(p).href` | `/fs` | | `error` |
138
138
  | `no-file-url-string-concat` | `` `file://${p}` `` | `pathToFileURL(p).href` | — | | `error` |
139
139
 
140
+ ### Entrypoint guards
141
+
142
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
143
+ |---|---|---|---|---|---|
144
+ | `no-fragile-entrypoint-guard` | `import.meta.main`; `import.meta.url === pathToFileURL(process.argv[1]).href`; `fileURLToPath(import.meta.url) === process.argv[1]` | `isEntrypoint(import.meta.url)` | `/process` | | — |
145
+
146
+ All three banned spellings fail the same way: the guard answers **false for the script it is guarding**, so the process exits 0 having run nothing — the quietest failure a CLI has.
147
+
148
+ `import.meta.main` shipped in Node **24.2 / 22.18**. Below that it is `undefined`, measured on a real 22.13.0, and this package's own floor is `>=22`. A repository-structure gate guarded that way printed nothing and exited 0 on the exact Node its CI job installed.
149
+
150
+ The other two are one defect wearing two spellings: a raw string comparison of where the module lives against `process.argv[1]`, with no realpath pass, written either in URL space (`pathToFileURL` the argv path) or in path space (`fileURLToPath` the module URL). A `node_modules/.bin` entry is a **symlink**, so `process.argv[1]` is the link and the module's own location is the resolved target: the strings differ and the guard is false, in either space. Measured false on Node 22.14.0 and 24.13.1 alike, where `isEntrypoint()` is true. Only `process.argv[1]` counts — `argv[2]` and up are ordinary CLI arguments. `import.meta.url`, `import.meta.dirname` and `import.meta.filename` are untouched; only `.main` is banned.
151
+
152
+ Not in `recommended` because the first half depends on **your** Node floor — at or above 24.2 / 22.18, `import.meta.main` is correct. The argv compares depend on nothing and are a defect everywhere; the two share a rule id, so enable it explicitly if your floor is below 24.2 / 22.18 or you ship a `bin`.
153
+
140
154
  ### Content decoding
141
155
 
142
156
  | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
package/eslint/index.cjs CHANGED
@@ -55,6 +55,7 @@ const rules = {
55
55
  'require-justified-skip': require('./rules/require-justified-skip.cjs'),
56
56
  'no-bare-symlink-in-tests': require('./rules/no-bare-symlink-in-tests.cjs'),
57
57
  'no-process-exit-in-phase': require('./rules/no-process-exit-in-phase.cjs'),
58
+ 'no-fragile-entrypoint-guard': require('./rules/no-fragile-entrypoint-guard.cjs'),
58
59
  };
59
60
 
60
61
  /**
@@ -145,6 +146,20 @@ const RECOMMENDED_EXCLUDE = new Set([
145
146
  // that legitimately exits would get a finding they cannot act on. It ships in
146
147
  // `rules` and VAT enables it explicitly, scoped to its own orchestrators.
147
148
  'no-process-exit-in-phase',
149
+ // Excluded because ONE of its two halves is a claim about the CONSUMER's Node
150
+ // floor rather than a portable fact. `import.meta.main` shipped in Node 24.2 /
151
+ // 22.18; an adopter whose floor is at or above that writes it correctly and
152
+ // would get a finding they cannot act on. This package's own floor is `>=22`,
153
+ // which spans 22.13–22.17 where the property is `undefined` — so the hazard is
154
+ // real for some adopters and absent for others, and only they know which.
155
+ //
156
+ // The rule's OTHER half (`rawEntrypointCompare`) has no such dependency: a raw
157
+ // string compare against `pathToFileURL(argv[1]).href` misses a symlinked
158
+ // entry on every Node there has ever been. The two share one rule id and
159
+ // cannot be enabled separately, and the floor-dependent half is what keeps the
160
+ // pair out — the same trade already recorded for `no-bare-symlink-in-tests`.
161
+ // VAT enables the whole rule explicitly, because VAT's floor is 22.13.0.
162
+ 'no-fragile-entrypoint-guard',
148
163
  ]);
149
164
 
150
165
  /**
@@ -47,17 +47,20 @@ declare namespace plugin {
47
47
  rules: Record<string, RuleModule>;
48
48
  configs: {
49
49
  /**
50
- * The cross-platform safety core: 18 of the 25 rules, 15 `error` / 3 `warn`.
50
+ * The cross-platform safety core: 18 of the 26 rules, 15 `error` / 3 `warn`.
51
51
  *
52
- * Seven are excluded, for four reasons. `no-test-scoped-functions`,
52
+ * Eight are excluded, for five reasons. `no-test-scoped-functions`,
53
53
  * `require-justified-skip` and `no-bare-symlink-in-tests` are positions on
54
54
  * TEST STYLE rather than portability facts. `no-unsafe-root-join` and
55
55
  * `no-process-exit-in-phase` key on NAMING rather than on the property they
56
56
  * care about (taint, and an orchestrated call site). `no-raw-text-decode`
57
- * names a decoding SEAM that only exists in the consuming repo. And
57
+ * names a decoding SEAM that only exists in the consuming repo.
58
58
  * `no-self-package-import` REQUIRES an option this config cannot supply.
59
+ * And `no-fragile-entrypoint-guard` half-depends on the CONSUMER's Node
60
+ * floor — `import.meta.main` is correct at or above 24.2 / 22.18 and
61
+ * `undefined` below it.
59
62
  *
60
- * All seven still ship in `rules` and are enabled by naming them — which is
63
+ * All eight still ship in `rules` and are enabled by naming them — which is
61
64
  * what this repo's own `eslint.config.js` does. The count above is asserted
62
65
  * by `packages/utils/test/eslint/rules.test.ts`, so it cannot drift
63
66
  * unnoticed the way it did when this comment said "four".
@@ -0,0 +1,215 @@
1
+ /**
2
+ * ESLint rule: no-fragile-entrypoint-guard
3
+ *
4
+ * Ban the two "am I the script Node was asked to run?" idioms that answer
5
+ * **false for the script they are guarding**, so the process exits 0 having done
6
+ * nothing at all.
7
+ *
8
+ * @example
9
+ * // ❌ BAD — undefined before Node 24.2 / 22.18
10
+ * if (import.meta.main) { await main(); }
11
+ *
12
+ * // ❌ BAD — raw string compare, no realpath: false through any `.bin` symlink
13
+ * if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { … }
14
+ *
15
+ * // ❌ BAD — the same compare done in path space instead of URL space
16
+ * if (fileURLToPath(import.meta.url) === process.argv[1]) { … }
17
+ *
18
+ * // ✅ GOOD
19
+ * import { isEntrypoint } from '@vibe-agent-toolkit/utils/process';
20
+ * if (isEntrypoint(import.meta.url)) { await main(); }
21
+ *
22
+ * ## Why a lint rule and not a comment
23
+ *
24
+ * Both defects have already shipped in this repo, and both were invisible to
25
+ * every gate:
26
+ *
27
+ * - `import.meta.main` was added in Node **24.2 / 22.18**. Measured on the exact
28
+ * floor this repo declares — `>=22.13.0` — the property is `undefined`:
29
+ *
30
+ * ```
31
+ * $ node-v22.13.0 --input-type=module -e "console.log(import.meta.main)" -> undefined
32
+ * $ node-v24.13.1 --input-type=module -e "console.log(import.meta.main)" -> true
33
+ * ```
34
+ *
35
+ * So a repository-structure gate guarded this way printed nothing and exited 0
36
+ * on the very Node its own CI job installs. A contributor sitting exactly on
37
+ * the supported floor got a green pre-commit gate that had run no rule.
38
+ *
39
+ * - `import.meta.url === pathToFileURL(process.argv[1]).href` compares two
40
+ * strings with no realpath pass. Invoked through a `node_modules/.bin` shim —
41
+ * i.e. the normal way a package's own bin is run — `process.argv[1]` is the
42
+ * SYMLINK and `import.meta.url` is the resolved target, the strings differ,
43
+ * and the guard is false. Measured false on Node 22.14.0 and 24.13.1 alike,
44
+ * where `isEntrypoint()` is true.
45
+ *
46
+ * ⚠️ That defect is about the MISSING REALPATH, not about URLs, so it has a
47
+ * second spelling that is just as common and just as wrong:
48
+ * `fileURLToPath(import.meta.url) === process.argv[1]` converts the module URL
49
+ * to a path instead of converting the invoked path to a URL, and then compares
50
+ * the same two unresolved strings. A matcher that only knew the URL-space form
51
+ * would be a mechanism with a hole in the middle of its own premise, which is
52
+ * the thing this rule exists to refuse. Both directions of both spellings are
53
+ * flagged, under one message, because they are one defect.
54
+ *
55
+ * The first of those was fixed once already and the fix was pinned by nothing:
56
+ * reverting all three call sites to `if (import.meta.main)` left the entire test
57
+ * suite green, because the only thing standing against it was three prose
58
+ * comments addressed to a human. A banner is not a mechanism. This rule is.
59
+ *
60
+ * ## What it does NOT flag
61
+ *
62
+ * `import.meta.url`, `import.meta.dirname` and `import.meta.filename` are all
63
+ * fine and all common — only `.main` is the unavailable one. And
64
+ * `pathToFileURL(x).href` is only a finding when it is being compared to
65
+ * `import.meta.url`; on its own it is just a URL.
66
+ *
67
+ * ## No `exemptFiles`
68
+ *
69
+ * Unlike the wrapper rules in this pack, there is no implementation file that
70
+ * has to call the banned thing: `isEntrypoint()` is written in terms of
71
+ * `process.argv[1]` and a realpath comparison, and touches neither idiom. An
72
+ * exemption option here would only ever be used to opt a file out of a fix.
73
+ */
74
+
75
+ 'use strict';
76
+
77
+ /** `import.meta`, as the parser sees it. */
78
+ function isImportMeta(node) {
79
+ return (
80
+ node?.type === 'MetaProperty' && node.meta?.name === 'import' && node.property?.name === 'meta'
81
+ );
82
+ }
83
+
84
+ /**
85
+ * `import.meta.<name>` — a static, non-computed member of `import.meta`.
86
+ *
87
+ * @param {object} node - Any node.
88
+ * @param {string} name - The property to match, e.g. `main`.
89
+ * @returns {boolean}
90
+ */
91
+ function isImportMetaMember(node, name) {
92
+ return (
93
+ node?.type === 'MemberExpression' &&
94
+ node.computed === false &&
95
+ isImportMeta(node.object) &&
96
+ node.property?.type === 'Identifier' &&
97
+ node.property.name === name
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The called function's NAME, whether called bare or off a namespace.
103
+ *
104
+ * `pathToFileURL(p)` and `url.pathToFileURL(p)` are the same call, and a matcher
105
+ * that only understood the bare form would be blind to every file that imports
106
+ * `node:url` as a namespace.
107
+ *
108
+ * @param {object} callee - The `callee` of a CallExpression.
109
+ * @returns {string | undefined}
110
+ */
111
+ function calleeName(callee) {
112
+ if (callee === null || callee === undefined) return undefined;
113
+ if (callee.type === 'Identifier') return callee.name;
114
+ if (callee.type === 'MemberExpression' && callee.computed === false) {
115
+ return callee.property?.type === 'Identifier' ? callee.property.name : undefined;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /** `pathToFileURL(…).href`, in either import style. */
121
+ function isPathToFileUrlHref(node) {
122
+ return (
123
+ node?.type === 'MemberExpression' &&
124
+ node.computed === false &&
125
+ node.property?.type === 'Identifier' &&
126
+ node.property.name === 'href' &&
127
+ node.object?.type === 'CallExpression' &&
128
+ calleeName(node.object.callee) === 'pathToFileURL'
129
+ );
130
+ }
131
+
132
+ /** `fileURLToPath(import.meta.url)`, in either import style. */
133
+ function isFileUrlToPathOfImportMetaUrl(node) {
134
+ return (
135
+ node?.type === 'CallExpression' &&
136
+ calleeName(node.callee) === 'fileURLToPath' &&
137
+ isImportMetaMember(node.arguments?.[0], 'url')
138
+ );
139
+ }
140
+
141
+ /**
142
+ * `process.argv[1]` — and the `const { argv } = process` spelling of it.
143
+ *
144
+ * The index is pinned to `1` on purpose: `argv[2]` and up are ordinary CLI
145
+ * arguments, and comparing one of those to anything is not this defect.
146
+ */
147
+ function isArgvEntry(node) {
148
+ if (node === null || node === undefined) return false;
149
+ if (node.type !== 'MemberExpression' || node.computed !== true) return false;
150
+ if (node.property?.type !== 'Literal' || node.property.value !== 1) return false;
151
+
152
+ const target = node.object;
153
+ if (target?.type === 'Identifier') return target.name === 'argv';
154
+ return (
155
+ target?.type === 'MemberExpression' &&
156
+ target.computed === false &&
157
+ target.property?.type === 'Identifier' &&
158
+ target.property.name === 'argv'
159
+ );
160
+ }
161
+
162
+ /** Where THIS module lives, in whichever space the comparison is written in. */
163
+ function isModuleLocation(node) {
164
+ return isImportMetaMember(node, 'url') || isFileUrlToPathOfImportMetaUrl(node);
165
+ }
166
+
167
+ /** The script Node was ASKED to run, in whichever space the comparison uses. */
168
+ function isInvokedScript(node) {
169
+ return isArgvEntry(node) || isPathToFileUrlHref(node);
170
+ }
171
+
172
+ /** Identity comparisons; `==`/`!=` on these operands never occurs and is not the idiom. */
173
+ const IDENTITY_OPERATORS = new Set(['===', '!==']);
174
+
175
+ module.exports = {
176
+ meta: {
177
+ type: 'problem',
178
+ docs: {
179
+ description:
180
+ 'Ban entrypoint guards that silently answer false — `import.meta.main` (undefined before Node 24.2/22.18) and any raw compare of where the module lives to `process.argv[1]`, in URL space or path space (false through any symlink). Use `isEntrypoint()`.',
181
+ category: 'Cross-Platform',
182
+ recommended: false,
183
+ },
184
+ messages: {
185
+ importMetaMain:
186
+ '`import.meta.main` is undefined before Node 24.2 / 22.18, so this guard is FALSE on older supported Node and the script exits 0 having done nothing. Use `isEntrypoint(import.meta.url)` from `@vibe-agent-toolkit/utils/process`.',
187
+ rawEntrypointCompare:
188
+ 'Comparing where this module lives to `process.argv[1]` (as `import.meta.url === pathToFileURL(argv[1]).href`, or as `fileURLToPath(import.meta.url) === argv[1]`) is a raw string compare with no realpath pass, so it is FALSE whenever the script is reached through a symlink (any `node_modules/.bin` shim) and the script exits 0 having done nothing. Use `isEntrypoint(import.meta.url)` from `@vibe-agent-toolkit/utils/process`.',
189
+ },
190
+ schema: [],
191
+ },
192
+
193
+ create(context) {
194
+ return {
195
+ MemberExpression(node) {
196
+ if (isImportMetaMember(node, 'main')) {
197
+ context.report({ node, messageId: 'importMetaMain' });
198
+ }
199
+ },
200
+
201
+ BinaryExpression(node) {
202
+ if (!IDENTITY_OPERATORS.has(node.operator)) return;
203
+
204
+ // Either operand order: a matcher keyed on side would be half blind.
205
+ const compares =
206
+ (isModuleLocation(node.left) && isInvokedScript(node.right)) ||
207
+ (isModuleLocation(node.right) && isInvokedScript(node.left));
208
+
209
+ if (compares) {
210
+ context.report({ node, messageId: 'rawEntrypointCompare' });
211
+ }
212
+ },
213
+ };
214
+ },
215
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-agent-toolkit/utils",
3
- "version": "0.2.0-rc.5",
3
+ "version": "0.2.0-rc.7",
4
4
  "type": "module",
5
5
  "description": "Core utility functions shared across the vibe-agent-toolkit packages",
6
6
  "sideEffects": [
@@ -17,7 +17,7 @@
17
17
  "author": "Jeff Dutton",
18
18
  "license": "MIT",
19
19
  "engines": {
20
- "node": ">=22.0.0"
20
+ "node": ">=22.13.0"
21
21
  },
22
22
  "main": "./dist/index.js",
23
23
  "types": "./dist/index.d.ts",
@@ -118,7 +118,7 @@
118
118
  "eslint": "^9.39.5",
119
119
  "rimraf": "^6.0.1",
120
120
  "typescript": "^5.9.3",
121
- "vitest": "^3.2.4",
121
+ "vitest": "^4.1.11",
122
122
  "zod": "^3.25.0"
123
123
  },
124
124
  "publishConfig": {