@graphorin/eslint-plugin 0.6.0 → 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 +26 -0
- package/README.md +12 -4
- package/dist/index.d.ts +30 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -22
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/index.ts +113 -0
- package/src/rules/_comment-utils.ts +37 -0
- package/src/rules/no-bare-tool-exec.ts +120 -0
- package/src/rules/no-implicit-network-call.ts +236 -0
- package/src/rules/no-secret-in-deps.ts +113 -0
- package/src/rules/no-secret-unwrap.ts +118 -0
- package/src/rules/no-third-party-workflow-aliases.ts +100 -0
- package/src/rules/provider-middleware-order.ts +113 -0
- package/src/rules/tool-description-required.ts +75 -0
- package/src/rules/tool-examples-recommended.ts +61 -0
- package/src/rules/tool-parameter-naming.ts +75 -0
- package/src/tool-discovery.ts +804 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
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
|
+
|
|
23
|
+
## 0.6.1
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- [#142](https://github.com/o-stepper/graphorin/pull/142) [`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430) Thanks [@o-stepper](https://github.com/o-stepper)! - Version constants and version-bearing strings now derive from each package's manifest at build time (`VERSION = pkg.version`; writer ids, client/server info, OTLP framework attributes, build-info metrics interpolate it). No behavioral change at the current version: the rendered strings are byte-identical. A release bump no longer edits source; the new `check-version-consistency` gate fails any reintroduced hardcoded framework version.
|
|
28
|
+
|
|
3
29
|
## 0.6.0
|
|
4
30
|
|
|
5
31
|
### 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.
|
|
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
|
|
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.
|
|
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
|
|
68
|
-
*
|
|
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
|
|
@@ -186,10 +212,10 @@ declare function gradeTool(tool: DiscoveredTool, findings: ReadonlyArray<LintFin
|
|
|
186
212
|
*
|
|
187
213
|
* @packageDocumentation
|
|
188
214
|
*/
|
|
189
|
-
declare const VERSION
|
|
215
|
+
declare const VERSION: string;
|
|
190
216
|
declare const meta: {
|
|
191
217
|
readonly name: "@graphorin/eslint-plugin";
|
|
192
|
-
readonly version:
|
|
218
|
+
readonly version: string;
|
|
193
219
|
};
|
|
194
220
|
declare const rules: {
|
|
195
221
|
readonly 'no-bare-tool-exec': eslint0.Rule.RuleModule;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/tool-discovery.ts","../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;
|
|
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,3 +1,10 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
//#region package.json
|
|
5
|
+
var version = "0.7.0";
|
|
6
|
+
|
|
7
|
+
//#endregion
|
|
1
8
|
//#region src/rules/_comment-utils.ts
|
|
2
9
|
/**
|
|
3
10
|
* Returns `true` when the source contains a comment whose `value`
|
|
@@ -93,8 +100,72 @@ var no_bare_tool_exec_default = rule$8;
|
|
|
93
100
|
|
|
94
101
|
//#endregion
|
|
95
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
|
+
*/
|
|
96
142
|
const ALLOW_TAG$2 = /graphorin-allow-network/;
|
|
97
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
|
+
}
|
|
98
169
|
const NETWORK_CALLEES = new Set(["fetch", "XMLHttpRequest"]);
|
|
99
170
|
const HTTP_VERBS = new Set([
|
|
100
171
|
"get",
|
|
@@ -133,14 +204,25 @@ const rule$7 = {
|
|
|
133
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).",
|
|
134
205
|
recommended: true
|
|
135
206
|
},
|
|
136
|
-
schema: [
|
|
207
|
+
schema: [{
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: { packagePrefixes: {
|
|
210
|
+
type: "array",
|
|
211
|
+
items: { type: "string" }
|
|
212
|
+
} },
|
|
213
|
+
additionalProperties: false
|
|
214
|
+
}],
|
|
137
215
|
messages: {
|
|
138
216
|
forbidden: "direct network call '{{callee}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out.",
|
|
139
217
|
forbiddenImport: "HTTP-client import '{{specifier}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out."
|
|
140
218
|
}
|
|
141
219
|
},
|
|
142
220
|
create(context) {
|
|
143
|
-
|
|
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 {};
|
|
144
226
|
return {
|
|
145
227
|
CallExpression(node) {
|
|
146
228
|
const specifier = requiredClientSpecifier(node);
|
|
@@ -305,13 +387,19 @@ const rule$5 = {
|
|
|
305
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()`.",
|
|
306
388
|
recommended: true
|
|
307
389
|
},
|
|
308
|
-
schema: [
|
|
390
|
+
schema: [{
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: { allowReceiverPattern: { type: "string" } },
|
|
393
|
+
additionalProperties: false
|
|
394
|
+
}],
|
|
309
395
|
messages: {
|
|
310
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.",
|
|
311
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."
|
|
312
398
|
}
|
|
313
399
|
},
|
|
314
400
|
create(context) {
|
|
401
|
+
const options = context.options?.[0] ?? {};
|
|
402
|
+
const allowReceiver = options.allowReceiverPattern !== void 0 ? new RegExp(options.allowReceiverPattern) : null;
|
|
315
403
|
return { CallExpression(node) {
|
|
316
404
|
const callee = node.callee;
|
|
317
405
|
if (callee.type !== "MemberExpression") return;
|
|
@@ -319,6 +407,8 @@ const rule$5 = {
|
|
|
319
407
|
if (me.computed) return;
|
|
320
408
|
if (me.property.type !== "Identifier") return;
|
|
321
409
|
const propName = me.property.name;
|
|
410
|
+
if (propName !== "unwrap" && propName !== "reveal") return;
|
|
411
|
+
if (allowReceiver?.test(context.sourceCode.getText(me.object)) === true) return;
|
|
322
412
|
if (propName === "unwrap") {
|
|
323
413
|
context.report({
|
|
324
414
|
node,
|
|
@@ -544,21 +634,138 @@ const MAX_EXAMPLES$1 = 5;
|
|
|
544
634
|
*/
|
|
545
635
|
function discoverToolCallsInSource(file, source) {
|
|
546
636
|
const out = [];
|
|
637
|
+
const blanked = blankComments(source);
|
|
638
|
+
const searchable = blankStringContents(blanked);
|
|
547
639
|
const regex = /\btool\s*\(\s*\{/g;
|
|
548
|
-
let match = regex.exec(
|
|
640
|
+
let match = regex.exec(searchable);
|
|
549
641
|
while (match !== null) {
|
|
550
642
|
const objectStart = match.index + match[0].length - 1;
|
|
551
|
-
const objectEnd = matchBrace(
|
|
643
|
+
const objectEnd = matchBrace(blanked, objectStart);
|
|
552
644
|
if (objectEnd > 0) {
|
|
553
645
|
const literal = source.slice(objectStart, objectEnd + 1);
|
|
554
|
-
const
|
|
646
|
+
const gradingLiteral = blanked.slice(objectStart, objectEnd + 1);
|
|
647
|
+
const tool = parseToolLiteral(file, countLines(source, match.index), literal, gradingLiteral);
|
|
555
648
|
if (tool !== null) out.push(tool);
|
|
556
649
|
}
|
|
557
650
|
regex.lastIndex = objectEnd + 1;
|
|
558
|
-
match = regex.exec(
|
|
651
|
+
match = regex.exec(searchable);
|
|
559
652
|
}
|
|
560
653
|
return out;
|
|
561
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
|
+
}
|
|
562
769
|
/** Email PII pattern used by the examples-pii sub-check. */
|
|
563
770
|
const EMAIL_PATTERN = /[\w.+%-]+@[\w.-]+\.[A-Za-z]{2,}/;
|
|
564
771
|
/** Numeric-suffix pattern used by the parameter-naming sub-check. */
|
|
@@ -583,7 +790,7 @@ function runToolRules(tool, severityOverrides) {
|
|
|
583
790
|
if (examplesSeverity !== "off") {
|
|
584
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.`));
|
|
585
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}.`));
|
|
586
|
-
const emailMatch = EMAIL_PATTERN.exec(extractExamplesBlock(tool.
|
|
793
|
+
const emailMatch = EMAIL_PATTERN.exec(extractExamplesBlock(tool.gradingSource));
|
|
587
794
|
if (emailMatch !== null) {
|
|
588
795
|
const matched = emailMatch[0];
|
|
589
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').`, {
|
|
@@ -631,11 +838,20 @@ function gradeTool(tool, findings) {
|
|
|
631
838
|
function scoreDescription(tool, findings) {
|
|
632
839
|
if (findings.length > 0) return 0;
|
|
633
840
|
const desc = tool.description?.trim() ?? "";
|
|
634
|
-
|
|
635
|
-
if (desc.length >=
|
|
636
|
-
if (desc.length >=
|
|
637
|
-
if (desc.length >=
|
|
638
|
-
|
|
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;
|
|
639
855
|
}
|
|
640
856
|
function scoreExamples(tool, findings) {
|
|
641
857
|
if (tool.examplesCount === 0) return 0;
|
|
@@ -777,14 +993,14 @@ function extractParameterNames(literal) {
|
|
|
777
993
|
}
|
|
778
994
|
return names;
|
|
779
995
|
}
|
|
780
|
-
function parseToolLiteral(file, line, literal) {
|
|
781
|
-
const name = readStringProp(
|
|
782
|
-
const description = readStringProp(
|
|
783
|
-
const examples = countArrayProp(
|
|
784
|
-
const tagsArr = countArrayProp(
|
|
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");
|
|
785
1001
|
const tags = [];
|
|
786
1002
|
if (tagsArr.present && tagsArr.count > 0) {
|
|
787
|
-
const m = /tags\s*:\s*\[([^\]]*)\]/.exec(
|
|
1003
|
+
const m = /tags\s*:\s*\[([^\]]*)\]/.exec(gradingLiteral);
|
|
788
1004
|
if (m !== null) {
|
|
789
1005
|
const inner = m[1] ?? "";
|
|
790
1006
|
const tagRegex = /['"`]([^'"`]+)['"`]/g;
|
|
@@ -795,7 +1011,7 @@ function parseToolLiteral(file, line, literal) {
|
|
|
795
1011
|
}
|
|
796
1012
|
}
|
|
797
1013
|
}
|
|
798
|
-
const parameterNames = extractParameterNames(
|
|
1014
|
+
const parameterNames = extractParameterNames(gradingLiteral);
|
|
799
1015
|
return Object.freeze({
|
|
800
1016
|
file,
|
|
801
1017
|
line,
|
|
@@ -805,7 +1021,8 @@ function parseToolLiteral(file, line, literal) {
|
|
|
805
1021
|
hasExamples: examples.present,
|
|
806
1022
|
parameterNames: Object.freeze(parameterNames),
|
|
807
1023
|
tags: Object.freeze(tags),
|
|
808
|
-
source: literal
|
|
1024
|
+
source: literal,
|
|
1025
|
+
gradingSource: gradingLiteral
|
|
809
1026
|
});
|
|
810
1027
|
}
|
|
811
1028
|
function countLines(source, index) {
|
|
@@ -1027,7 +1244,7 @@ var tool_parameter_naming_default = rule;
|
|
|
1027
1244
|
*
|
|
1028
1245
|
* @packageDocumentation
|
|
1029
1246
|
*/
|
|
1030
|
-
const VERSION =
|
|
1247
|
+
const VERSION = version;
|
|
1031
1248
|
const meta = {
|
|
1032
1249
|
name: "@graphorin/eslint-plugin",
|
|
1033
1250
|
version: VERSION
|