@graphorin/eslint-plugin 0.6.1 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @graphorin/eslint-plugin
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-072: every export map's `import` condition becomes `default`, and the Node floor rises to `>=22.12.0`.
8
+
9
+ CJS consumers previously hit a bewildering `ERR_PACKAGE_PATH_NOT_EXPORTED` instead of a clear ESM-only signal. With the `default` condition, plain `require('@graphorin/core')` works via Node's stable `require(esm)` - which shipped in 22.12, hence the engines bump across every workspace manifest (packages, examples, benchmarks, docs; enforced by the widened mvp-readiness sweep). No dual-instance hazard: there is no CJS build, `require()` returns the same ESM module instance. ESM consumers are unaffected (`default` serves both paths; `types` stays first). The pack gate now runs attw under the full `node16` profile (was `esm-only`) and adds a runtime `require(esm)` smoke against the packed tarballs. Installs on Node 22.0-22.11 with `engine-strict` will refuse - upgrade Node (see the migration guide).
10
+
11
+ - [#162](https://github.com/o-stepper/graphorin/pull/162) [`73b19ca`](https://github.com/o-stepper/graphorin/commit/73b19caeda388bda628a48138cb7d70b1db839a3) Thanks [@o-stepper](https://github.com/o-stepper)! - `no-implicit-network-call` now activates in two stages: the linted file path must match `packages/*/src` AND the nearest `package.json` name must start with a prefix from the new `packagePrefixes` option (default `['@graphorin/']`). Downstream pnpm monorepos with the standard layout stop getting errors on their own `fetch()` calls just for adopting `flat/recommended`; a consumer that wants the rule to police their own scope passes `['error', { packagePrefixes: ['@myorg/'] }]`. When no package.json resolves above the file (virtual paths, programmatic Linter runs) the rule fails OPEN to the old path-only activation, so resolution hiccups can only over-flag, never silently disable the guard. The rule is now also dogfooded against the framework's own source in tests (sharing the check-no-network ALLOW_LIST as the single source of exemptions), and a lockstep contract test pins both matchers' verdicts over a shared corpus - closing the known axios asymmetry (the CI script now flags `axios(...)`/`axios.get(...)` call sites, not just imports).
12
+
13
+ - [#158](https://github.com/o-stepper/graphorin/pull/158) [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab) Thanks [@o-stepper](https://github.com/o-stepper)! - Tool discovery and grading are comment-aware (W-044). Discovery and every grading path run over a comment-blanked view of the source (newlines preserved, offsets stable; string/template and - conservatively - regex literals untouched): a commented-out `tool({...})` no longer appears in `graphorin tools lint` reports or the three ESLint tool rules, a commented-out property inside a live literal is never extracted, a commented email inside a live `examples:` block no longer penalizes the axis, and a `tool(` inside a string never matches. `DiscoveredTool` gains `gradingSource` (the blanked slice all graders consume) while `source` keeps the original text for reports. The description axis gets a deterministic anti-degenerate guard: 80+ chars of repeated filler (under 4 unique words, or one word over half the text) caps at 16 instead of scoring 40 - RB-49 calibration fixtures are unchanged; degenerate descriptions may now fall below `--threshold` gates. The false-positive contract (any callee lexically ending in `tool(`, renamed/wrapped calls invisible) is now documented in the module header and README.
14
+
15
+ ### Patch Changes
16
+
17
+ - [#162](https://github.com/o-stepper/graphorin/pull/162) [`73b19ca`](https://github.com/o-stepper/graphorin/commit/73b19caeda388bda628a48138cb7d70b1db839a3) Thanks [@o-stepper](https://github.com/o-stepper)! - `no-secret-unwrap` gains an opt-in `allowReceiverPattern` option for the documented collision with Zod's `.unwrap()` (ZodOptional/ZodNullable/ZodDefault) and Rust-style result libraries: when the source text of the receiver expression matches the pattern, both `unwrap` and `reveal` reports are skipped, so `['error', { allowReceiverPattern: 'Schema$' }]` lets schema-introspection code lint clean while `secret.unwrap()` keeps erroring. Default behaviour is byte-identical (no pattern, sharp deprecation cliff), and there is deliberately no built-in "looks like Zod" heuristic - an explicit narrow pattern or a file-glob override beats a nondeterministic guess. README documents both recipes.
18
+
19
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Tarballs now ship `src/` so the published `dist/**/*.d.ts.map` files actually work (W-136): the maps referenced `../src/*.ts` that the `files` whitelist excluded, so go-to-definition fell back into `.d.ts` and the shipped maps were dead weight. The pack gate gains a `map-integrity` leg: every source referenced by a shipped map must resolve inside the tarball (or be embedded via `sourcesContent`), with an anti-vacuous guard - a package whose tsdown config emits declaration maps must contain a non-zero number of `.d.ts.map` files, so a cache-restored dist that silently dropped maps fails the gate instead of passing vacuously. `mvp-readiness` now requires `src` in every publishable `files` array.
20
+
21
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Every published package now declares its tree-shaking contract via `sideEffects` (W-137): 18 packages audited to a pure module scope get `false`, the CLI declares its bin entry (`["./dist/bin/*"]`), and `@graphorin/security` gets an explicit `true` - its secrets subsystem registers built-in resolvers and the SecretValue caller-context provider at import time, so marking it pure would let bundlers drop those registrations. `mvp-readiness` now fails any publishable manifest without a declared `sideEffects`, closing the drift for future packages.
22
+
3
23
  ## 0.6.1
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > ESLint rules for projects that build on **Graphorin**.
4
4
 
5
- - **Status:** v0.6.1 - final Phase 16 ruleset.
5
+ - **Status:** v0.7.0 - final Phase 16 ruleset.
6
6
  - **License:** [MIT](./LICENSE) - © 2026 Oleksiy Stepurenko.
7
7
  - **Engines:** Node.js 22+ (ESM only).
8
8
  - **Peer dependency:** `eslint >= 9`.
@@ -58,10 +58,10 @@ The legacy `.eslintrc` form is still exported as `configs.recommended`
58
58
  | `tool-description-required` | Active. Flags `tool({...})` registrations whose `description` is missing, shorter than 20 characters, or a placeholder value (`'TODO'`, `'FIXME'`, `'tbd'`, `'description'`, `'placeholder'`). |
59
59
  | `tool-examples-recommended` | Active. Flags missing or empty `examples` arrays and rejects more than the documented upper bound (5). |
60
60
  | `tool-parameter-naming` | Active. Flags ambiguous single-word parameter names (`user`, `id`, `name`, `value`, `data`, `input`, `output`, `result`, `to`, `from`, `key`, `field`) and numeric-suffix names (`arg1`, `param2`) on `inputSchema: z.object({ ... })`. Per-tool opt-out via `tags: ['experimental']` or `tags: ['legacy']`. |
61
- | `no-secret-unwrap` | Active. Flags `.unwrap()` and `.reveal()` calls on `SecretValue`-shaped expressions. `.unwrap()` is reported as `'error'` regardless of comments (the method is `@deprecated`); `.reveal()` honours the `// graphorin-allow-secret-unwrap: <reason>` opt-out. |
61
+ | `no-secret-unwrap` | Active. Flags `.unwrap()` and `.reveal()` calls on `SecretValue`-shaped expressions. `.unwrap()` is reported as `'error'` regardless of comments (the method is `@deprecated`); `.reveal()` honours the `// graphorin-allow-secret-unwrap: <reason>` opt-out. Known collision: Zod's `ZodOptional`/`ZodNullable`/`ZodDefault` `.unwrap()` (and Rust-style result libraries) false-positive - either turn the rule off for introspection files (`{ files: ['**/schema-introspection/**'], rules: { '@graphorin/no-secret-unwrap': 'off' } }`) or set the carve-out option `['error', { allowReceiverPattern: 'Schema$' }]`, which skips receivers whose source text matches. Pick a NARROW suffix pattern (a broad regex would silence real `SecretValue.unwrap` calls); prefer the file-glob override when the collision is confined to a directory. |
62
62
  | `no-secret-in-deps` | Active. Flags `withChildToolSecretsContext({ secretsAllowed: [...] })` grants whose allowlist is non-empty and lacks an `// rb-24-justification: <reason>` comment (DEC-137). |
63
63
  | `provider-middleware-order` | Active. Lint-time enforcement of the canonical `withTracing → withRetry → withRateLimit → withCostLimit → withCostTracking → withFallback → withRedaction` ordering. |
64
- | `no-implicit-network-call` | Active. Flags network primitives in `@graphorin/*` framework code without the explicit `// graphorin-allow-network: <reason>` opt-out: `fetch(...)` / `axios.*` / `undici.*` / `got.*` / `http(s).request` / raw `net`·`tls`·`dgram` sockets / `new WebSocket` / `new EventSource` / `new XMLHttpRequest`, plus static, dynamic, and `require()` imports of HTTP clients (`node-fetch`, `undici`, `got`, `axios`, `ky`, `ws`). Kept in lockstep with `scripts/check-no-network.mjs`. |
64
+ | `no-implicit-network-call` | Active. Flags network primitives in framework code without the explicit `// graphorin-allow-network: <reason>` opt-out: `fetch(...)` / `axios.*` / `undici.*` / `got.*` / `http(s).request` / raw `net`·`tls`·`dgram` sockets / `new WebSocket` / `new EventSource` / `new XMLHttpRequest`, plus static, dynamic, and `require()` imports of HTTP clients (`node-fetch`, `undici`, `got`, `axios`, `ky`, `ws`). Activation is two-stage (W-039): the file path must match `packages/*/src` AND the nearest `package.json` name must start with a prefix from `packagePrefixes` (default `['@graphorin/']`), so a downstream monorepo with the standard layout is not flagged; when no package.json resolves (virtual paths), the rule FAILS OPEN to path-only activation. Polices your own scope via `'@graphorin/no-implicit-network-call': ['error', { packagePrefixes: ['@myorg/'] }]`. Kept in lockstep with `scripts/check-no-network.mjs` (contract-tested). |
65
65
  | `no-third-party-workflow-aliases` | Active. Flags identifiers that mirror third-party-library workflow primitives in the `@graphorin/workflow` package's source so the framework keeps its own naming. |
66
66
  | `no-bare-tool-exec` | Active. Flags `tool({ execute })` functions that do not reference `signal` so long-running tools always propagate the cancellation contract. |
67
67
 
@@ -88,10 +88,18 @@ for (const tool of tools) {
88
88
  }
89
89
  ```
90
90
 
91
+ ### Discovery contract (W-044)
92
+
93
+ The discovery is **text-based by design** (one implementation serves both the CLI and the ESLint rules). Its contract:
94
+
95
+ - **Comment-aware.** Discovery and grading run over a comment-blanked view of the source: a commented-out `tool({...})` is never discovered, a commented-out property inside a live literal is never extracted, and a commented email inside a live `examples:` block never penalizes the axis. `DiscoveredTool.source` keeps the original text for reports; grading consumes `DiscoveredTool.gradingSource`. String, template, and (conservatively) regex literals are left untouched, and a `tool(` inside a string never matches.
96
+ - **False positives.** Any callee whose last lexical token is `tool(` matches - including method calls like `.tool(` and same-named local helpers. Renamed or wrapped invocations (`const t = tool; t({...})`) are NOT seen. This is the accepted cost of the text-based surface.
97
+ - **Anti-degenerate description guard.** A description of 80+ chars scores the top tier only if it looks like prose: under 4 unique words, or one word carrying more than half the text, caps the axis at 16.
98
+
91
99
  ## Versioning
92
100
 
93
101
  `@graphorin/eslint-plugin` follows the same lockstep release as the rest of the `@graphorin/*` packages while the framework is on the `0.x` line. Once Graphorin reaches `1.0`, the plugin will move to its own release cadence.
94
102
 
95
103
  ---
96
104
 
97
- **Graphorin** · v0.6.1 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
105
+ **Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
package/dist/index.d.ts CHANGED
@@ -19,6 +19,24 @@ import * as eslint0 from "eslint";
19
19
  * dynamic import will not be picked up by this lint surface; that
20
20
  * is the documented contract for the v0.1 lint surface.
21
21
  *
22
+ * **Comment-awareness (W-044).** Discovery AND grading run over a
23
+ * comment-blanked view of the source: line-comment and block-comment
24
+ * content is replaced with spaces (newlines preserved, so line numbers
25
+ * and offsets never shift) while string/template literals - and,
26
+ * conservatively, regex literals - are left untouched. A
27
+ * commented-out `tool({...})` is therefore never discovered, and a
28
+ * commented-out property (or a commented email inside a live
29
+ * `examples:` block) never feeds the grader. The ORIGINAL slice stays
30
+ * available as {@link DiscoveredTool.source} for reports; grading
31
+ * paths consume {@link DiscoveredTool.gradingSource}.
32
+ *
33
+ * **False-positive contract.** The scanner matches ANY callee whose
34
+ * last lexical token is `tool(` - including method calls like
35
+ * `.tool(` and locally-defined helpers that happen to share the name.
36
+ * Renamed or wrapped invocations (`const t = tool; t({...})`) are NOT
37
+ * seen. This is the accepted cost of the text-based surface that lets
38
+ * the CLI and the ESLint rules share one implementation (RB-49).
39
+ *
22
40
  * **Per-tool grader rubric (RB-49 calibration):**
23
41
  *
24
42
  * - **description axis (0..40 points):**
@@ -64,10 +82,18 @@ interface DiscoveredTool {
64
82
  /** Tags declared on the call (best-effort). */
65
83
  readonly tags: ReadonlyArray<string>;
66
84
  /**
67
- * Raw object-literal source. Useful for tests + as a context blob
68
- * when the CLI needs to surface the original source in a report.
85
+ * Raw object-literal source (ORIGINAL text, comments included).
86
+ * Useful for tests + as a context blob when the CLI needs to
87
+ * surface the original source in a report.
69
88
  */
70
89
  readonly source: string;
90
+ /**
91
+ * W-044: the same slice with comments blanked - what discovery
92
+ * parsed and what every grading path (examples PII scan,
93
+ * description/parameter scoring) consumes. Same length and line
94
+ * structure as `source`.
95
+ */
96
+ readonly gradingSource: string;
71
97
  }
72
98
  /**
73
99
  * @stable
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/tool-discovery.ts","../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;AA6CA;AA2BA;AAaA;AAuBA;AAqBA;AAwBA;AAWA;AAkBA;AAgCA;AAkIA;;;;;;;;;ACtTA;AAEA;AAKA;;;;;;;;;;;AAUW;AAaD;;;;;;;AAiCV;;AAhBe,UDpCE,cAAA,CCoCF;EACc;EAAiB,SAAA,IAAA,EAAA,MAAA;;;;;;;;;;;;2BDvBnB;;iBAEV;;;;;;;;;;KAWL,eAAA;;;;UAaK,WAAA;;iBAKA;;;;;;;;;;;;;;;;;UAkBA,eAAA;;;;;;;;;;qBAUI,cAAc;;;;;;;;;;cAWtB,2BAA2B;;;;;;;;;;cAwB3B,+BAA+B;;;;;;;cAW/B,0BAA0B;;;;;;;;iBAkBvB,yBAAA,gCAAyD;;;;;;;;iBAgCzD,YAAA,OACR;;;;IAML;;;;;;;;;iBA2Ha,SAAA,OACR,0BACI,cAAc,eACvB;;;;;;AA9SH;AA2BA;AAaA;AAuBA;AAqBA;AAwBA;AAWA;AAkBA;AAgCA;AAkIA;;;;;;;;;ACtTA;AAEa,cAFA,OAKH,EAAA,MAAA;AAEG,cALA,IAeH,EAAA;;;;cAVG;;;;;;;EAaP,SAAA,2BAUI,yBAAA;EAQJ,SAsBL,2BAAA,yBAAA;EArBuB,SAAA,uBAAA,yBAAA;CACC;;cApBnB,iBA2BS,EAAA;EACc,SAAA,8BAAA,EAAA,MAAA;EAAiB,SAAA,qCAAA,EAAA,OAAA;EAejC,SAAA,8BAAwB,EAAA,OAAA;EAnBR,SAAA,6BAAA,EAAA,OAAA;EAGd,SAAA,4CAAA,EAAA,OAAA;EACc,SAAA,sCAAA,EAAA,OAAA;EAAiB,SAAA,sCAAA,EAAA,OAAA;;;;;;;;;;cAVxC;wBACkB;yBACC;;;;6BAII;;;eAGd;6BACc;;;;cAehB;;;2BAnBgB;;;aAGd;2BACc"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/tool-discovery.ts","../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;AA+DA;AAmCA;AAaA;AAuBA;AAqBA;AAwBA;AAWA;AAkBA;AAoKA;AAoIA;;;;;;;;;ACtdA;AAEA;AAKA;;;;;;;;;;;AAUW;AAaD;;;;;;;AAiCV;;;;;;;;;;;;;;;;;;;;UDlCiB,cAAA;;;;;;;;;;;;;;2BAcU;;iBAEV;;;;;;;;;;;;;;;;;;KAmBL,eAAA;;;;UAaK,WAAA;;iBAKA;;;;;;;;;;;;;;;;;UAkBA,eAAA;;;;;;;;;;qBAUI,cAAc;;;;;;;;;;cAWtB,2BAA2B;;;;;;;;;;cAwB3B,+BAA+B;;;;;;;cAW/B,0BAA0B;;;;;;;;iBAkBvB,yBAAA,gCAAyD;;;;;;;;iBAoKzD,YAAA,OACR;;;;IAML;;;;;;;;;iBA6Ha,SAAA,OACR,0BACI,cAAc,eACvB;;;;;;AA5bH;AAmCA;AAaA;AAuBA;AAqBA;AAwBA;AAWA;AAkBA;AAoKA;AAoIA;;;;;;;;;ACtdA;AAEa,cAFA,OAKH,EAAA,MAAA;AAEG,cALA,IAeH,EAAA;;;;cAVG;;;;;;;EAaP,SAAA,2BAUI,yBAAA;EAQJ,SAsBL,2BAAA,yBAAA;EArBuB,SAAA,uBAAA,yBAAA;CACC;;cApBnB,iBA2BS,EAAA;EACc,SAAA,8BAAA,EAAA,MAAA;EAAiB,SAAA,qCAAA,EAAA,OAAA;EAejC,SAAA,8BAAwB,EAAA,OAAA;EAnBR,SAAA,6BAAA,EAAA,OAAA;EAGd,SAAA,4CAAA,EAAA,OAAA;EACc,SAAA,sCAAA,EAAA,OAAA;EAAiB,SAAA,sCAAA,EAAA,OAAA;;;;;;;;;;cAVxC;wBACkB;yBACC;;;;6BAII;;;eAGd;6BACc;;;;cAehB;;;2BAnBgB;;;aAGd;2BACc"}
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
1
4
  //#region package.json
2
- var version = "0.6.1";
5
+ var version = "0.7.0";
3
6
 
4
7
  //#endregion
5
8
  //#region src/rules/_comment-utils.ts
@@ -97,8 +100,72 @@ var no_bare_tool_exec_default = rule$8;
97
100
 
98
101
  //#endregion
99
102
  //#region src/rules/no-implicit-network-call.ts
103
+ /**
104
+ * Rule: `@graphorin/no-implicit-network-call` (DEC-154 / ADR-041).
105
+ * Flags any direct network primitive in code under a `@graphorin/*`
106
+ * package's `src/` directory unless the call site carries an opt-out
107
+ * comment whose text contains `'graphorin-allow-network'`:
108
+ *
109
+ * - calls: `fetch(...)`, `http(s).request/get(...)`, `axios(...)` /
110
+ * `axios.<verb>(...)`, `undici.<verb>(...)` / `got.<verb>(...)`,
111
+ * `net.createConnection/connect(...)`, `tls.connect(...)`,
112
+ * `dgram.createSocket(...)`
113
+ * - constructors: `new XMLHttpRequest()`, `new WebSocket(...)`,
114
+ * `new EventSource(...)`
115
+ * - imports of HTTP clients: `node-fetch`, `undici`, `got`, `axios`,
116
+ * `ky`, `ws` (static, dynamic `import(...)`, and `require(...)`)
117
+ *
118
+ * Companion to the `pnpm run check-no-network` static analysis script;
119
+ * the two matchers are kept in lockstep (this rule mirrors the EB-10
120
+ * hardening that taught the script about undici/got, raw sockets,
121
+ * WebSocket/EventSource, and HTTP-client import specifiers). The lint
122
+ * surface catches the pattern at author time so reviewers do not need
123
+ * to wait for CI to flag a missed network gate.
124
+ *
125
+ * The rule is intentionally limited to the framework's own code paths
126
+ * - consumer applications can call `fetch` freely. Activation is
127
+ * two-stage (W-039): the linted file path must match
128
+ * `/packages/<pkg>/src/` AND the nearest package.json's `name` must
129
+ * start with one of `options[0].packagePrefixes` (default
130
+ * `['@graphorin/']`), so a downstream pnpm monorepo with the standard
131
+ * `packages/*\/src` layout no longer gets errors on its own `fetch()`
132
+ * calls just for adopting `flat/recommended`.
133
+ *
134
+ * FAIL-OPEN fallback: when no package.json resolves above the file
135
+ * (virtual paths in editor buffers or programmatic `Linter` runs), the
136
+ * rule activates on the path match alone - exactly the pre-W-039
137
+ * behaviour, so a resolution hiccup can only over-flag framework-shaped
138
+ * paths, never silently disable the guard.
139
+ *
140
+ * @packageDocumentation
141
+ */
100
142
  const ALLOW_TAG$2 = /graphorin-allow-network/;
101
143
  const FRAMEWORK_PATH_RE = /\bpackages\/[a-z0-9_-]+\/src\b/;
144
+ /**
145
+ * Directory -> nearest package.json `name` (or null when none
146
+ * resolves). Module-level so one walk serves every file of a package
147
+ * within a lint run.
148
+ */
149
+ const packageNameCache = /* @__PURE__ */ new Map();
150
+ /** Walk up from `fileDir` to the nearest package.json name (cached). */
151
+ function nearestPackageName(fileDir) {
152
+ const cached = packageNameCache.get(fileDir);
153
+ if (cached !== void 0) return cached;
154
+ let name = null;
155
+ let dir = fileDir;
156
+ for (;;) try {
157
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
158
+ const parsed = JSON.parse(raw);
159
+ name = typeof parsed.name === "string" ? parsed.name : null;
160
+ break;
161
+ } catch {
162
+ const parent = dirname(dir);
163
+ if (parent === dir) break;
164
+ dir = parent;
165
+ }
166
+ packageNameCache.set(fileDir, name);
167
+ return name;
168
+ }
102
169
  const NETWORK_CALLEES = new Set(["fetch", "XMLHttpRequest"]);
103
170
  const HTTP_VERBS = new Set([
104
171
  "get",
@@ -137,14 +204,25 @@ const rule$7 = {
137
204
  description: "Disallow direct network primitives (`fetch`, `axios`/`undici`/`got`, `http.request`, raw `net`/`tls`/`dgram` sockets, `WebSocket`/`EventSource`/`XMLHttpRequest`, HTTP-client imports) in `@graphorin/*` framework code without an explicit opt-out comment (DEC-154).",
138
205
  recommended: true
139
206
  },
140
- schema: [],
207
+ schema: [{
208
+ type: "object",
209
+ properties: { packagePrefixes: {
210
+ type: "array",
211
+ items: { type: "string" }
212
+ } },
213
+ additionalProperties: false
214
+ }],
141
215
  messages: {
142
216
  forbidden: "direct network call '{{callee}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out.",
143
217
  forbiddenImport: "HTTP-client import '{{specifier}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out."
144
218
  }
145
219
  },
146
220
  create(context) {
147
- if (!FRAMEWORK_PATH_RE.test(context.filename.replace(/\\/g, "/"))) return {};
221
+ const filename = context.filename.replace(/\\/g, "/");
222
+ if (!FRAMEWORK_PATH_RE.test(filename)) return {};
223
+ const prefixes = (context.options?.[0] ?? {}).packagePrefixes ?? ["@graphorin/"];
224
+ const packageName = nearestPackageName(dirname(context.filename));
225
+ if (packageName !== null && !prefixes.some((p) => packageName.startsWith(p))) return {};
148
226
  return {
149
227
  CallExpression(node) {
150
228
  const specifier = requiredClientSpecifier(node);
@@ -309,13 +387,19 @@ const rule$5 = {
309
387
  description: "Disallow `.unwrap()` / `.reveal()` calls on `SecretValue` instances. Prefer `.use(fn)` (scoped) or attach a `// graphorin-allow-secret-unwrap: <reason>` opt-out comment for `.reveal()`.",
310
388
  recommended: true
311
389
  },
312
- schema: [],
390
+ schema: [{
391
+ type: "object",
392
+ properties: { allowReceiverPattern: { type: "string" } },
393
+ additionalProperties: false
394
+ }],
313
395
  messages: {
314
396
  avoidReveal: "`.reveal()` returns the unwrapped secret as a V8 string. Prefer `.use(fn)` so the value is scoped to a single callback. Add `// graphorin-allow-secret-unwrap: <reason>` to opt out.",
315
397
  avoidUnwrap: "`.unwrap()` is deprecated - call `.reveal()` (audited) or `.use(fn)` (scoped). The opt-out comment is intentionally NOT honoured for `.unwrap()` so the deprecation stays sharp."
316
398
  }
317
399
  },
318
400
  create(context) {
401
+ const options = context.options?.[0] ?? {};
402
+ const allowReceiver = options.allowReceiverPattern !== void 0 ? new RegExp(options.allowReceiverPattern) : null;
319
403
  return { CallExpression(node) {
320
404
  const callee = node.callee;
321
405
  if (callee.type !== "MemberExpression") return;
@@ -323,6 +407,8 @@ const rule$5 = {
323
407
  if (me.computed) return;
324
408
  if (me.property.type !== "Identifier") return;
325
409
  const propName = me.property.name;
410
+ if (propName !== "unwrap" && propName !== "reveal") return;
411
+ if (allowReceiver?.test(context.sourceCode.getText(me.object)) === true) return;
326
412
  if (propName === "unwrap") {
327
413
  context.report({
328
414
  node,
@@ -548,21 +634,138 @@ const MAX_EXAMPLES$1 = 5;
548
634
  */
549
635
  function discoverToolCallsInSource(file, source) {
550
636
  const out = [];
637
+ const blanked = blankComments(source);
638
+ const searchable = blankStringContents(blanked);
551
639
  const regex = /\btool\s*\(\s*\{/g;
552
- let match = regex.exec(source);
640
+ let match = regex.exec(searchable);
553
641
  while (match !== null) {
554
642
  const objectStart = match.index + match[0].length - 1;
555
- const objectEnd = matchBrace(source, objectStart);
643
+ const objectEnd = matchBrace(blanked, objectStart);
556
644
  if (objectEnd > 0) {
557
645
  const literal = source.slice(objectStart, objectEnd + 1);
558
- const tool = parseToolLiteral(file, countLines(source, match.index), literal);
646
+ const gradingLiteral = blanked.slice(objectStart, objectEnd + 1);
647
+ const tool = parseToolLiteral(file, countLines(source, match.index), literal, gradingLiteral);
559
648
  if (tool !== null) out.push(tool);
560
649
  }
561
650
  regex.lastIndex = objectEnd + 1;
562
- match = regex.exec(source);
651
+ match = regex.exec(searchable);
563
652
  }
564
653
  return out;
565
654
  }
655
+ /**
656
+ * W-044: blank the CONTENTS of string/template literals (quotes kept,
657
+ * newlines preserved) so the discovery regex cannot match `tool(`
658
+ * inside prose. Used only for SEARCHING - parsing reads the
659
+ * strings-intact view.
660
+ */
661
+ function blankStringContents(source) {
662
+ const out = source.split("");
663
+ let inString = null;
664
+ let i = 0;
665
+ while (i < source.length) {
666
+ const ch = source[i];
667
+ if (inString !== null) {
668
+ if (ch === "\\") {
669
+ out[i] = " ";
670
+ if (i + 1 < source.length && source[i + 1] !== "\n") out[i + 1] = " ";
671
+ i += 2;
672
+ continue;
673
+ }
674
+ if (ch === inString) {
675
+ inString = null;
676
+ i += 1;
677
+ continue;
678
+ }
679
+ if (ch !== "\n") out[i] = " ";
680
+ i += 1;
681
+ continue;
682
+ }
683
+ if (ch === "\"" || ch === "'" || ch === "`") inString = ch;
684
+ i += 1;
685
+ }
686
+ return out.join("");
687
+ }
688
+ /**
689
+ * W-044: replace line-comment and block-comment CONTENT with spaces
690
+ * while preserving every newline (offsets and 1-indexed lines never
691
+ * shift).
692
+ * String and template literals pass through untouched; regex literals
693
+ * are tracked with the classic prev-significant-token heuristic so a
694
+ * `/` inside one is never mistaken for a comment opener - when in
695
+ * doubt the scanner does NOT blank.
696
+ *
697
+ * @stable
698
+ */
699
+ function blankComments(source) {
700
+ const out = source.split("");
701
+ let inString = null;
702
+ let prevSignificant = "";
703
+ let i = 0;
704
+ const isRegexStartContext = () => prevSignificant === "" || "([{=,:;!&|?+-*%<>~^".includes(prevSignificant);
705
+ while (i < source.length) {
706
+ const ch = source[i];
707
+ if (inString !== null) {
708
+ if (ch === "\\") {
709
+ i += 2;
710
+ continue;
711
+ }
712
+ if (ch === inString) inString = null;
713
+ i += 1;
714
+ continue;
715
+ }
716
+ if (ch === "\"" || ch === "'" || ch === "`") {
717
+ inString = ch;
718
+ prevSignificant = ch;
719
+ i += 1;
720
+ continue;
721
+ }
722
+ if (ch === "/" && source[i + 1] === "/") {
723
+ while (i < source.length && source[i] !== "\n") {
724
+ out[i] = " ";
725
+ i += 1;
726
+ }
727
+ continue;
728
+ }
729
+ if (ch === "/" && source[i + 1] === "*") {
730
+ out[i] = " ";
731
+ out[i + 1] = " ";
732
+ i += 2;
733
+ while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) {
734
+ if (source[i] !== "\n") out[i] = " ";
735
+ i += 1;
736
+ }
737
+ if (i < source.length) {
738
+ out[i] = " ";
739
+ out[i + 1] = " ";
740
+ i += 2;
741
+ }
742
+ continue;
743
+ }
744
+ if (ch === "/" && isRegexStartContext()) {
745
+ let j = i + 1;
746
+ let inClass = false;
747
+ while (j < source.length && source[j] !== "\n") {
748
+ const cj = source[j];
749
+ if (cj === "\\") {
750
+ j += 2;
751
+ continue;
752
+ }
753
+ if (cj === "[") inClass = true;
754
+ else if (cj === "]") inClass = false;
755
+ else if (cj === "/" && !inClass) break;
756
+ j += 1;
757
+ }
758
+ if (j < source.length && source[j] === "/") {
759
+ prevSignificant = "/";
760
+ i = j + 1;
761
+ continue;
762
+ }
763
+ }
764
+ if (!/\s/.test(ch)) prevSignificant = ch;
765
+ i += 1;
766
+ }
767
+ return out.join("");
768
+ }
566
769
  /** Email PII pattern used by the examples-pii sub-check. */
567
770
  const EMAIL_PATTERN = /[\w.+%-]+@[\w.-]+\.[A-Za-z]{2,}/;
568
771
  /** Numeric-suffix pattern used by the parameter-naming sub-check. */
@@ -587,7 +790,7 @@ function runToolRules(tool, severityOverrides) {
587
790
  if (examplesSeverity !== "off") {
588
791
  if (!tool.hasExamples || tool.examplesCount === 0) findings.push(finding("graphorin/tool-examples-recommended", "examples-missing", examplesSeverity, tool, `tool '${tool.name}' has no examples; add 1-5 worked examples per Anthropic 2026 guidance.`));
589
792
  else if (tool.examplesCount > MAX_EXAMPLES$1) findings.push(finding("graphorin/tool-examples-recommended", "examples-too-many", "error", tool, `tool '${tool.name}' declares ${tool.examplesCount} examples; the upper bound is ${MAX_EXAMPLES$1}.`));
590
- const emailMatch = EMAIL_PATTERN.exec(extractExamplesBlock(tool.source));
793
+ const emailMatch = EMAIL_PATTERN.exec(extractExamplesBlock(tool.gradingSource));
591
794
  if (emailMatch !== null) {
592
795
  const matched = emailMatch[0];
593
796
  findings.push(finding("graphorin/tool-examples-recommended", "examples-pii-detected", "error", tool, `tool '${tool.name}' example contains a real-looking email '${matched}'; replace with synthetic data (e.g. 'user@example.com').`, {
@@ -635,11 +838,20 @@ function gradeTool(tool, findings) {
635
838
  function scoreDescription(tool, findings) {
636
839
  if (findings.length > 0) return 0;
637
840
  const desc = tool.description?.trim() ?? "";
638
- if (desc.length >= 80) return 40;
639
- if (desc.length >= 50) return 32;
640
- if (desc.length >= 30) return 24;
641
- if (desc.length >= MIN_DESCRIPTION_LENGTH$1) return 16;
642
- return 0;
841
+ let lengthScore = 0;
842
+ if (desc.length >= 80) lengthScore = 40;
843
+ else if (desc.length >= 50) lengthScore = 32;
844
+ else if (desc.length >= 30) lengthScore = 24;
845
+ else if (desc.length >= MIN_DESCRIPTION_LENGTH$1) lengthScore = 16;
846
+ if (lengthScore > 16 && desc.length > 0) {
847
+ const words = desc.toLowerCase().split(/\s+/).filter((w) => w.length > 0);
848
+ const counts = /* @__PURE__ */ new Map();
849
+ for (const w of words) counts.set(w, (counts.get(w) ?? 0) + 1);
850
+ const unique = counts.size;
851
+ const topShare = words.length === 0 ? 0 : Math.max(...counts.values()) / words.length;
852
+ if (unique < 4 || topShare > .5) return 16;
853
+ }
854
+ return lengthScore;
643
855
  }
644
856
  function scoreExamples(tool, findings) {
645
857
  if (tool.examplesCount === 0) return 0;
@@ -781,14 +993,14 @@ function extractParameterNames(literal) {
781
993
  }
782
994
  return names;
783
995
  }
784
- function parseToolLiteral(file, line, literal) {
785
- const name = readStringProp(literal, "name") ?? "<anonymous>";
786
- const description = readStringProp(literal, "description");
787
- const examples = countArrayProp(literal, "examples");
788
- const tagsArr = countArrayProp(literal, "tags");
996
+ function parseToolLiteral(file, line, literal, gradingLiteral) {
997
+ const name = readStringProp(gradingLiteral, "name") ?? "<anonymous>";
998
+ const description = readStringProp(gradingLiteral, "description");
999
+ const examples = countArrayProp(gradingLiteral, "examples");
1000
+ const tagsArr = countArrayProp(gradingLiteral, "tags");
789
1001
  const tags = [];
790
1002
  if (tagsArr.present && tagsArr.count > 0) {
791
- const m = /tags\s*:\s*\[([^\]]*)\]/.exec(literal);
1003
+ const m = /tags\s*:\s*\[([^\]]*)\]/.exec(gradingLiteral);
792
1004
  if (m !== null) {
793
1005
  const inner = m[1] ?? "";
794
1006
  const tagRegex = /['"`]([^'"`]+)['"`]/g;
@@ -799,7 +1011,7 @@ function parseToolLiteral(file, line, literal) {
799
1011
  }
800
1012
  }
801
1013
  }
802
- const parameterNames = extractParameterNames(literal);
1014
+ const parameterNames = extractParameterNames(gradingLiteral);
803
1015
  return Object.freeze({
804
1016
  file,
805
1017
  line,
@@ -809,7 +1021,8 @@ function parseToolLiteral(file, line, literal) {
809
1021
  hasExamples: examples.present,
810
1022
  parameterNames: Object.freeze(parameterNames),
811
1023
  tags: Object.freeze(tags),
812
- source: literal
1024
+ source: literal,
1025
+ gradingSource: gradingLiteral
813
1026
  });
814
1027
  }
815
1028
  function countLines(source, index) {