@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 +20 -0
- package/README.md +12 -4
- package/dist/index.d.ts +28 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +235 -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,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.
|
|
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
|
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,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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
643
|
+
const objectEnd = matchBrace(blanked, objectStart);
|
|
556
644
|
if (objectEnd > 0) {
|
|
557
645
|
const literal = source.slice(objectStart, objectEnd + 1);
|
|
558
|
-
const
|
|
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(
|
|
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.
|
|
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
|
-
|
|
639
|
-
if (desc.length >=
|
|
640
|
-
if (desc.length >=
|
|
641
|
-
if (desc.length >=
|
|
642
|
-
|
|
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(
|
|
786
|
-
const description = readStringProp(
|
|
787
|
-
const examples = countArrayProp(
|
|
788
|
-
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");
|
|
789
1001
|
const tags = [];
|
|
790
1002
|
if (tagsArr.present && tagsArr.count > 0) {
|
|
791
|
-
const m = /tags\s*:\s*\[([^\]]*)\]/.exec(
|
|
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(
|
|
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) {
|