@supersigil/eslint-plugin 0.1.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/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @supersigil/eslint-plugin
2
+
3
+ ESLint plugin for validating [Supersigil](https://supersigil.org) criterion refs.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install -D @supersigil/eslint-plugin
9
+ ```
10
+
11
+ Requires the `supersigil` CLI to be available on `PATH`.
12
+
13
+ ## Usage
14
+
15
+ Add the plugin to your ESLint flat config:
16
+
17
+ ```js
18
+ import supersigil from '@supersigil/eslint-plugin'
19
+
20
+ export default [
21
+ supersigil.configs.recommended,
22
+ // ...your other configs
23
+ ]
24
+ ```
25
+
26
+ Or configure manually:
27
+
28
+ ```js
29
+ import supersigil from '@supersigil/eslint-plugin'
30
+
31
+ export default [
32
+ {
33
+ plugins: { '@supersigil': supersigil },
34
+ rules: {
35
+ '@supersigil/valid-criterion-ref': 'error',
36
+ },
37
+ },
38
+ ]
39
+ ```
40
+
41
+ ## Rules
42
+
43
+ ### `valid-criterion-ref`
44
+
45
+ Validates that criterion refs passed to `verifies()` or listed in `meta.verifies` arrays point to criteria that actually exist in your specifications.
46
+
47
+ Reports errors for:
48
+
49
+ - **Malformed refs** — missing the `#` separator (e.g. `'auth/req'` instead of `'auth/req#req-1-1'`)
50
+ - **Unknown documents** — the document part of the ref doesn't match any specification
51
+ - **Unknown criteria** — the criterion doesn't exist in the referenced document
52
+
53
+ The rule loads valid refs from the `supersigil` CLI on first run and caches them for the ESLint session. If the CLI is unavailable, the rule silently disables itself.
54
+
55
+ ## License
56
+
57
+ [MIT](../../LICENSE-MIT) OR [Apache-2.0](../../LICENSE-APACHE)
@@ -0,0 +1,8 @@
1
+ declare const plugin: {
2
+ rules: {
3
+ 'valid-criterion-ref': import("eslint").Rule.RuleModule;
4
+ };
5
+ configs: Record<string, unknown>;
6
+ };
7
+ export default plugin;
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,QAAA,MAAM,MAAM;;;;aAIK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CACvC,CAAA;AAYD,eAAe,MAAM,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,142 @@
1
+ // src/rules/valid-criterion-ref.ts
2
+ import { execFileSync } from "node:child_process";
3
+ var cachedRefMap = null;
4
+ var cachedLoadError = null;
5
+ var loaded = false;
6
+ var warnedBinaryUnavailable = false;
7
+ var _testMode = false;
8
+ var _testRefMap = null;
9
+ var _testLoadError = null;
10
+ function loadRefMap() {
11
+ if (_testMode) {
12
+ return { refMap: _testRefMap, error: _testLoadError };
13
+ }
14
+ if (loaded) {
15
+ return { refMap: cachedRefMap, error: cachedLoadError };
16
+ }
17
+ loaded = true;
18
+ try {
19
+ const output = execFileSync("supersigil", ["refs", "--all", "--format", "json"], {
20
+ encoding: "utf-8",
21
+ timeout: 3e4
22
+ });
23
+ const parsed = JSON.parse(output);
24
+ const entries = parsed.refs ?? parsed;
25
+ const refMap = /* @__PURE__ */ new Map();
26
+ for (const entry of entries) {
27
+ let criteria = refMap.get(entry.doc_id);
28
+ if (!criteria) {
29
+ criteria = /* @__PURE__ */ new Set();
30
+ refMap.set(entry.doc_id, criteria);
31
+ }
32
+ criteria.add(entry.criterion_id);
33
+ }
34
+ cachedRefMap = refMap;
35
+ cachedLoadError = null;
36
+ return { refMap, error: null };
37
+ } catch (err) {
38
+ const message = err instanceof Error ? err.message : "Unknown error loading supersigil refs";
39
+ cachedRefMap = null;
40
+ cachedLoadError = message;
41
+ return { refMap: null, error: message };
42
+ }
43
+ }
44
+ function validateRef(context, node, value, refMap) {
45
+ const hashIndex = value.indexOf("#");
46
+ if (hashIndex === -1) {
47
+ context.report({
48
+ node,
49
+ messageId: "malformed",
50
+ data: { ref: value }
51
+ });
52
+ return;
53
+ }
54
+ const docId = value.slice(0, hashIndex);
55
+ const criterionId = value.slice(hashIndex + 1);
56
+ const criteria = refMap.get(docId);
57
+ if (!criteria) {
58
+ context.report({
59
+ node,
60
+ messageId: "unknownDocument",
61
+ data: { docId, ref: value }
62
+ });
63
+ return;
64
+ }
65
+ if (!criteria.has(criterionId)) {
66
+ context.report({
67
+ node,
68
+ messageId: "unknownCriterion",
69
+ data: { criterionId, docId }
70
+ });
71
+ }
72
+ }
73
+ var rule = {
74
+ meta: {
75
+ type: "problem",
76
+ docs: {
77
+ description: "Validate Supersigil criterion refs in verifies() calls"
78
+ },
79
+ messages: {
80
+ malformed: "Malformed criterion ref '{{ref}}'. Expected format: document-id#criterion-id",
81
+ unknownDocument: "Unknown document '{{docId}}' in criterion ref '{{ref}}'",
82
+ unknownCriterion: "Unknown criterion '{{criterionId}}' in document '{{docId}}'"
83
+ },
84
+ schema: []
85
+ },
86
+ create(context) {
87
+ const { refMap, error } = loadRefMap();
88
+ if (refMap === null) {
89
+ if (error !== null && !warnedBinaryUnavailable) {
90
+ warnedBinaryUnavailable = true;
91
+ console.warn(
92
+ "[@supersigil/eslint-plugin] supersigil binary not available; criterion ref validation disabled"
93
+ );
94
+ }
95
+ return {};
96
+ }
97
+ const refs = refMap;
98
+ function checkStringLiteral(node) {
99
+ validateRef(context, node, node.value, refs);
100
+ }
101
+ return {
102
+ // Match: verifies('ref', 'ref', ...)
103
+ CallExpression(node) {
104
+ if (node.callee.type === "Identifier" && node.callee.name === "verifies") {
105
+ for (const arg of node.arguments) {
106
+ if (arg.type === "Literal" && typeof arg.value === "string") {
107
+ checkStringLiteral(arg);
108
+ }
109
+ }
110
+ }
111
+ },
112
+ // Match: { verifies: ['ref', ...] } in object expressions
113
+ 'Property[key.name="verifies"] > ArrayExpression > Literal'(node) {
114
+ if (typeof node.value === "string") {
115
+ checkStringLiteral(node);
116
+ }
117
+ }
118
+ };
119
+ }
120
+ };
121
+ var valid_criterion_ref_default = rule;
122
+
123
+ // src/index.ts
124
+ var plugin = {
125
+ rules: {
126
+ "valid-criterion-ref": valid_criterion_ref_default
127
+ },
128
+ configs: {}
129
+ };
130
+ plugin.configs.recommended = {
131
+ plugins: {
132
+ "@supersigil": plugin
133
+ },
134
+ rules: {
135
+ "@supersigil/valid-criterion-ref": "error"
136
+ }
137
+ };
138
+ var index_default = plugin;
139
+ export {
140
+ index_default as default
141
+ };
142
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/rules/valid-criterion-ref.ts", "../src/index.ts"],
4
+ "sourcesContent": ["import type { Rule } from 'eslint'\nimport { execFileSync } from 'node:child_process'\n\n// Session-level cache for the ref map (docId -> Set<criterionId>).\n// This persists for the lifetime of the ESLint process. In long-lived\n// servers (eslint_d, editor integrations), the cache is never invalidated \u2014\n// restart the ESLint server if specs change.\nlet cachedRefMap: Map<string, Set<string>> | null = null\nlet cachedLoadError: string | null = null\nlet loaded = false\nlet warnedBinaryUnavailable = false\n\n// Test seam: allow overriding the ref loading result in tests.\nlet _testMode = false\nlet _testRefMap: Map<string, Set<string>> | null = null\nlet _testLoadError: string | null = null\n\n/** @internal \u2014 test-only: inject a ref map to avoid shelling out */\nexport function _setRefMapForTesting(\n map: Map<string, Set<string>> | null,\n): void {\n _testMode = true\n _testRefMap = map\n}\n\n/** @internal \u2014 test-only: simulate a load error */\nexport function _setLoadErrorForTesting(error: string | null): void {\n _testLoadError = error\n}\n\n/** @internal \u2014 test-only: reset all overrides */\nexport function _resetTestOverrides(): void {\n _testMode = false\n _testRefMap = null\n _testLoadError = null\n warnedBinaryUnavailable = false\n}\n\ninterface RefEntry {\n ref: string\n doc_id: string\n criterion_id: string\n body_text: string\n}\n\nfunction loadRefMap(): { refMap: Map<string, Set<string>> | null; error: string | null } {\n if (_testMode) {\n return { refMap: _testRefMap, error: _testLoadError }\n }\n\n if (loaded) {\n return { refMap: cachedRefMap, error: cachedLoadError }\n }\n\n loaded = true\n\n try {\n const output = execFileSync('supersigil', ['refs', '--all', '--format', 'json'], {\n encoding: 'utf-8',\n timeout: 30_000,\n })\n\n const parsed = JSON.parse(output)\n const entries: RefEntry[] = parsed.refs ?? parsed\n const refMap = new Map<string, Set<string>>()\n\n for (const entry of entries) {\n let criteria = refMap.get(entry.doc_id)\n if (!criteria) {\n criteria = new Set<string>()\n refMap.set(entry.doc_id, criteria)\n }\n criteria.add(entry.criterion_id)\n }\n\n cachedRefMap = refMap\n cachedLoadError = null\n return { refMap, error: null }\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : 'Unknown error loading supersigil refs'\n cachedRefMap = null\n cachedLoadError = message\n return { refMap: null, error: message }\n }\n}\n\nfunction validateRef(\n context: Rule.RuleContext,\n node: Rule.Node,\n value: string,\n refMap: Map<string, Set<string>>,\n): void {\n const hashIndex = value.indexOf('#')\n\n if (hashIndex === -1) {\n context.report({\n node,\n messageId: 'malformed',\n data: { ref: value },\n })\n return\n }\n\n const docId = value.slice(0, hashIndex)\n const criterionId = value.slice(hashIndex + 1)\n\n const criteria = refMap.get(docId)\n if (!criteria) {\n context.report({\n node,\n messageId: 'unknownDocument',\n data: { docId, ref: value },\n })\n return\n }\n\n if (!criteria.has(criterionId)) {\n context.report({\n node,\n messageId: 'unknownCriterion',\n data: { criterionId, docId },\n })\n }\n}\n\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'problem',\n docs: {\n description: 'Validate Supersigil criterion refs in verifies() calls',\n },\n messages: {\n malformed:\n \"Malformed criterion ref '{{ref}}'. Expected format: document-id#criterion-id\",\n unknownDocument:\n \"Unknown document '{{docId}}' in criterion ref '{{ref}}'\",\n unknownCriterion:\n \"Unknown criterion '{{criterionId}}' in document '{{docId}}'\",\n },\n schema: [],\n },\n\n create(context) {\n const { refMap, error } = loadRefMap()\n\n // When supersigil binary is unavailable, log a warning to stderr and\n // return an empty visitor so no refs are checked. This avoids failing\n // lint (which would happen if we used context.report at error severity).\n if (refMap === null) {\n if (error !== null && !warnedBinaryUnavailable) {\n warnedBinaryUnavailable = true\n console.warn(\n '[@supersigil/eslint-plugin] supersigil binary not available; criterion ref validation disabled',\n )\n }\n return {}\n }\n\n const refs = refMap\n\n function checkStringLiteral(node: Rule.Node & { value: string }): void {\n validateRef(context, node, node.value, refs)\n }\n\n return {\n // Match: verifies('ref', 'ref', ...)\n CallExpression(node) {\n if (\n node.callee.type === 'Identifier' &&\n node.callee.name === 'verifies'\n ) {\n for (const arg of node.arguments) {\n if (arg.type === 'Literal' && typeof arg.value === 'string') {\n checkStringLiteral(arg as Rule.Node & { value: string })\n }\n }\n }\n },\n\n // Match: { verifies: ['ref', ...] } in object expressions\n 'Property[key.name=\"verifies\"] > ArrayExpression > Literal'(\n node: Rule.Node & { value: unknown },\n ) {\n if (typeof node.value === 'string') {\n checkStringLiteral(node as Rule.Node & { value: string })\n }\n },\n }\n },\n}\n\nexport default rule\n", "import validCriterionRef from './rules/valid-criterion-ref.ts'\n\nconst plugin = {\n rules: {\n 'valid-criterion-ref': validCriterionRef,\n },\n configs: {} as Record<string, unknown>,\n}\n\n// Self-referencing plugin for flat config\nplugin.configs.recommended = {\n plugins: {\n '@supersigil': plugin,\n },\n rules: {\n '@supersigil/valid-criterion-ref': 'error',\n },\n}\n\nexport default plugin\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,10 @@
1
+ import type { Rule } from 'eslint';
2
+ /** @internal — test-only: inject a ref map to avoid shelling out */
3
+ export declare function _setRefMapForTesting(map: Map<string, Set<string>> | null): void;
4
+ /** @internal — test-only: simulate a load error */
5
+ export declare function _setLoadErrorForTesting(error: string | null): void;
6
+ /** @internal — test-only: reset all overrides */
7
+ export declare function _resetTestOverrides(): void;
8
+ declare const rule: Rule.RuleModule;
9
+ export default rule;
10
+ //# sourceMappingURL=valid-criterion-ref.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"valid-criterion-ref.d.ts","sourceRoot":"","sources":["../../src/rules/valid-criterion-ref.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAiBlC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,GACnC,IAAI,CAGN;AAED,mDAAmD;AACnD,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAElE;AAED,iDAAiD;AACjD,wBAAgB,mBAAmB,IAAI,IAAI,CAK1C;AA0FD,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,UAgEhB,CAAA;AAED,eAAe,IAAI,CAAA"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@supersigil/eslint-plugin",
3
+ "version": "0.1.0",
4
+ "description": "ESLint plugin for validating Supersigil criterion refs",
5
+ "license": "MIT OR Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jonisavo/supersigil.git",
9
+ "directory": "packages/eslint-plugin"
10
+ },
11
+ "homepage": "https://supersigil.org",
12
+ "bugs": "https://github.com/jonisavo/supersigil/issues",
13
+ "keywords": [
14
+ "eslint",
15
+ "eslintplugin",
16
+ "eslint-plugin",
17
+ "supersigil",
18
+ "specification",
19
+ "traceability"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist/",
33
+ "src/",
34
+ "README.md"
35
+ ],
36
+ "peerDependencies": {
37
+ "eslint": ">=9.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.19.17",
41
+ "esbuild": "^0.28.0",
42
+ "eslint": "^9.39.4",
43
+ "typescript": "^6.0.2",
44
+ "vitest": "^4.1.3",
45
+ "@supersigil/vitest": "0.1.0"
46
+ },
47
+ "scripts": {
48
+ "build": "node esbuild.mjs && tsc --emitDeclarationOnly --noEmit false --outDir dist",
49
+ "test": "vitest run"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import validCriterionRef from './rules/valid-criterion-ref.ts'
2
+
3
+ const plugin = {
4
+ rules: {
5
+ 'valid-criterion-ref': validCriterionRef,
6
+ },
7
+ configs: {} as Record<string, unknown>,
8
+ }
9
+
10
+ // Self-referencing plugin for flat config
11
+ plugin.configs.recommended = {
12
+ plugins: {
13
+ '@supersigil': plugin,
14
+ },
15
+ rules: {
16
+ '@supersigil/valid-criterion-ref': 'error',
17
+ },
18
+ }
19
+
20
+ export default plugin
@@ -0,0 +1,193 @@
1
+ import type { Rule } from 'eslint'
2
+ import { execFileSync } from 'node:child_process'
3
+
4
+ // Session-level cache for the ref map (docId -> Set<criterionId>).
5
+ // This persists for the lifetime of the ESLint process. In long-lived
6
+ // servers (eslint_d, editor integrations), the cache is never invalidated —
7
+ // restart the ESLint server if specs change.
8
+ let cachedRefMap: Map<string, Set<string>> | null = null
9
+ let cachedLoadError: string | null = null
10
+ let loaded = false
11
+ let warnedBinaryUnavailable = false
12
+
13
+ // Test seam: allow overriding the ref loading result in tests.
14
+ let _testMode = false
15
+ let _testRefMap: Map<string, Set<string>> | null = null
16
+ let _testLoadError: string | null = null
17
+
18
+ /** @internal — test-only: inject a ref map to avoid shelling out */
19
+ export function _setRefMapForTesting(
20
+ map: Map<string, Set<string>> | null,
21
+ ): void {
22
+ _testMode = true
23
+ _testRefMap = map
24
+ }
25
+
26
+ /** @internal — test-only: simulate a load error */
27
+ export function _setLoadErrorForTesting(error: string | null): void {
28
+ _testLoadError = error
29
+ }
30
+
31
+ /** @internal — test-only: reset all overrides */
32
+ export function _resetTestOverrides(): void {
33
+ _testMode = false
34
+ _testRefMap = null
35
+ _testLoadError = null
36
+ warnedBinaryUnavailable = false
37
+ }
38
+
39
+ interface RefEntry {
40
+ ref: string
41
+ doc_id: string
42
+ criterion_id: string
43
+ body_text: string
44
+ }
45
+
46
+ function loadRefMap(): { refMap: Map<string, Set<string>> | null; error: string | null } {
47
+ if (_testMode) {
48
+ return { refMap: _testRefMap, error: _testLoadError }
49
+ }
50
+
51
+ if (loaded) {
52
+ return { refMap: cachedRefMap, error: cachedLoadError }
53
+ }
54
+
55
+ loaded = true
56
+
57
+ try {
58
+ const output = execFileSync('supersigil', ['refs', '--all', '--format', 'json'], {
59
+ encoding: 'utf-8',
60
+ timeout: 30_000,
61
+ })
62
+
63
+ const parsed = JSON.parse(output)
64
+ const entries: RefEntry[] = parsed.refs ?? parsed
65
+ const refMap = new Map<string, Set<string>>()
66
+
67
+ for (const entry of entries) {
68
+ let criteria = refMap.get(entry.doc_id)
69
+ if (!criteria) {
70
+ criteria = new Set<string>()
71
+ refMap.set(entry.doc_id, criteria)
72
+ }
73
+ criteria.add(entry.criterion_id)
74
+ }
75
+
76
+ cachedRefMap = refMap
77
+ cachedLoadError = null
78
+ return { refMap, error: null }
79
+ } catch (err: unknown) {
80
+ const message =
81
+ err instanceof Error ? err.message : 'Unknown error loading supersigil refs'
82
+ cachedRefMap = null
83
+ cachedLoadError = message
84
+ return { refMap: null, error: message }
85
+ }
86
+ }
87
+
88
+ function validateRef(
89
+ context: Rule.RuleContext,
90
+ node: Rule.Node,
91
+ value: string,
92
+ refMap: Map<string, Set<string>>,
93
+ ): void {
94
+ const hashIndex = value.indexOf('#')
95
+
96
+ if (hashIndex === -1) {
97
+ context.report({
98
+ node,
99
+ messageId: 'malformed',
100
+ data: { ref: value },
101
+ })
102
+ return
103
+ }
104
+
105
+ const docId = value.slice(0, hashIndex)
106
+ const criterionId = value.slice(hashIndex + 1)
107
+
108
+ const criteria = refMap.get(docId)
109
+ if (!criteria) {
110
+ context.report({
111
+ node,
112
+ messageId: 'unknownDocument',
113
+ data: { docId, ref: value },
114
+ })
115
+ return
116
+ }
117
+
118
+ if (!criteria.has(criterionId)) {
119
+ context.report({
120
+ node,
121
+ messageId: 'unknownCriterion',
122
+ data: { criterionId, docId },
123
+ })
124
+ }
125
+ }
126
+
127
+ const rule: Rule.RuleModule = {
128
+ meta: {
129
+ type: 'problem',
130
+ docs: {
131
+ description: 'Validate Supersigil criterion refs in verifies() calls',
132
+ },
133
+ messages: {
134
+ malformed:
135
+ "Malformed criterion ref '{{ref}}'. Expected format: document-id#criterion-id",
136
+ unknownDocument:
137
+ "Unknown document '{{docId}}' in criterion ref '{{ref}}'",
138
+ unknownCriterion:
139
+ "Unknown criterion '{{criterionId}}' in document '{{docId}}'",
140
+ },
141
+ schema: [],
142
+ },
143
+
144
+ create(context) {
145
+ const { refMap, error } = loadRefMap()
146
+
147
+ // When supersigil binary is unavailable, log a warning to stderr and
148
+ // return an empty visitor so no refs are checked. This avoids failing
149
+ // lint (which would happen if we used context.report at error severity).
150
+ if (refMap === null) {
151
+ if (error !== null && !warnedBinaryUnavailable) {
152
+ warnedBinaryUnavailable = true
153
+ console.warn(
154
+ '[@supersigil/eslint-plugin] supersigil binary not available; criterion ref validation disabled',
155
+ )
156
+ }
157
+ return {}
158
+ }
159
+
160
+ const refs = refMap
161
+
162
+ function checkStringLiteral(node: Rule.Node & { value: string }): void {
163
+ validateRef(context, node, node.value, refs)
164
+ }
165
+
166
+ return {
167
+ // Match: verifies('ref', 'ref', ...)
168
+ CallExpression(node) {
169
+ if (
170
+ node.callee.type === 'Identifier' &&
171
+ node.callee.name === 'verifies'
172
+ ) {
173
+ for (const arg of node.arguments) {
174
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
175
+ checkStringLiteral(arg as Rule.Node & { value: string })
176
+ }
177
+ }
178
+ }
179
+ },
180
+
181
+ // Match: { verifies: ['ref', ...] } in object expressions
182
+ 'Property[key.name="verifies"] > ArrayExpression > Literal'(
183
+ node: Rule.Node & { value: unknown },
184
+ ) {
185
+ if (typeof node.value === 'string') {
186
+ checkStringLiteral(node as Rule.Node & { value: string })
187
+ }
188
+ },
189
+ }
190
+ },
191
+ }
192
+
193
+ export default rule