@alexgorbatchev/pi-skill-library 0.1.0 → 1.0.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 CHANGED
@@ -27,7 +27,7 @@ The extension looks for `skills-library` in these places:
27
27
 
28
28
  - `<cwd>/.pi/skills-library`
29
29
  - `<cwd>` and ancestor `.agents/skills-library` directories, stopping at the git root (or filesystem root when no git root exists)
30
- - `~/.pi/agent/cskills-library`
30
+ - `~/.pi/agent/skills-library`
31
31
  - `~/.agents/skills-library`
32
32
  - package-local `skills-library` directories derived from discovered package skill roots
33
33
  - extra paths configured via Pi settings under `@alexgorbatchev/pi-skills-library.paths`
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "@alexgorbatchev/pi-skill-library",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Pi extension that exposes skills-library roots through /library:<skill-name> commands.",
5
5
  "type": "module",
6
+ "author": "Alex Gorbatchev",
6
7
  "license": "MIT",
8
+ "homepage": "https://github.com/alexgorbatchev/pi-skill-library#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/alexgorbatchev/pi-skill-library/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/alexgorbatchev/pi-skill-library.git"
15
+ },
7
16
  "packageManager": "bun@1.3.10",
8
17
  "keywords": [
9
18
  "pi-package",
@@ -14,7 +23,6 @@
14
23
  ],
15
24
  "files": [
16
25
  "src",
17
- "skills-library",
18
26
  "README.md",
19
27
  "LICENSE"
20
28
  ],
File without changes
@@ -1,51 +0,0 @@
1
- ---
2
- name: opencode-config
3
- description: >-
4
- Set up and configure opencode.jsonc for OpenCode projects. Use when creating a
5
- new opencode.jsonc configuration file, adding instruction file paths, or
6
- configuring OpenCode settings for a repository.
7
- targets:
8
- - '*'
9
- ---
10
-
11
- # OpenCode Config
12
-
13
- ## Overview
14
-
15
- Create and configure `opencode.jsonc` in the repository root to customize OpenCode behavior per project.
16
-
17
- ## Setup
18
-
19
- 1. Create `opencode.jsonc` in the repository root
20
- 2. Add the schema reference for validation and autocompletion
21
- 3. Add instruction file paths to the `instructions` array
22
-
23
- ### Minimal Configuration
24
-
25
- ```jsonc
26
- {
27
- "$schema": "https://opencode.ai/config.json",
28
- }
29
- ```
30
-
31
- ### .github Instructions
32
-
33
- If there is `.github/instructions` folder update config to include `instructions` field, like so:
34
-
35
- ```jsonc
36
- "instructions": [
37
- ".github/instructions/*.instructions.md",
38
- ],
39
- ```
40
-
41
- ### .github Skills
42
-
43
- If there is `.github/skills` folder update config to include `skills` field, like so:
44
-
45
- ```jsonc
46
- "skills": {
47
- "paths": [
48
- ".github/skills",
49
- ]
50
- }
51
- ```
@@ -1,135 +0,0 @@
1
- ---
2
- name: react-development
3
- description: >-
4
- Build and modify React components, hooks, context providers, and JSX render
5
- trees. Use when implementing or refactoring React UI code, component APIs,
6
- render branches, shared primitives, hook-driven state, DOM structure, or test
7
- ID conventions. Covers the required `data-testid` naming contract and the ban
8
- on using `createElement` from React in regular application code.
9
- ---
10
-
11
- # React Development
12
-
13
- Use existing React patterns before introducing new ones. Preserve accessibility, composability, and readable JSX.
14
-
15
- ## Workflow
16
-
17
- 1. Inspect the existing component, shared primitives, hooks, and nearby tests before editing.
18
- 2. Reuse an existing component or hook when one already expresses the pattern.
19
- 3. Keep rendering in JSX. Extract helpers or hooks instead of switching to `createElement`.
20
- 4. Apply the test ID contract exactly when a tagged element is needed.
21
- 5. Run the narrowest relevant validation first, then broader lint and test commands.
22
-
23
- ## Non-negotiable rules
24
-
25
- ### 1. Use the test ID contract exactly
26
-
27
- Apply these rules exactly unless the repository documents a different contract:
28
-
29
- - Use the plain component name on the root rendered element of a component.
30
- - Use `ComponentName--thing` for tagged child elements inside that component.
31
- - Use kebab-case for `thing`.
32
- - Use semantic targets, not positional names.
33
- - Use the local helper component name when a helper component renders its own tagged root.
34
- - Do not invent `--root` or other suffixes on the root unless the repository explicitly requires them.
35
-
36
- Valid:
37
-
38
- ```tsx
39
- export function PlanChat() {
40
- return (
41
- <section data-testid='PlanChat'>
42
- <div data-testid='PlanChat--thread-viewport' />
43
- </section>
44
- );
45
- }
46
- ```
47
-
48
- ```tsx
49
- function ReasoningPart() {
50
- return <ToolCallCard testId='ReasoningPart' />;
51
- }
52
- ```
53
-
54
- Invalid:
55
-
56
- ```tsx
57
- <section data-testid='PlanChat--root' />
58
- ```
59
-
60
- ```tsx
61
- <div data-testid='thread-viewport' />
62
- ```
63
-
64
- ```tsx
65
- function ReasoningPart() {
66
- return <ToolCallCard testId='PlanChat--reasoning-card' />;
67
- }
68
- ```
69
-
70
- Prefer accessible queries in tests. Add `data-testid` only when the element is not reliably targetable through role, label, text, or stable semantic structure.
71
-
72
- ### 2. Keep regular React code in JSX
73
-
74
- Do not use `createElement` or `React.createElement` in ordinary application code.
75
-
76
- Use JSX for:
77
-
78
- - normal component bodies
79
- - conditional rendering
80
- - lists and mapping
81
- - wrapper composition
82
- - provider trees
83
- - tool or widget registries that can be expressed directly in JSX
84
-
85
- Allow `createElement` only when a specific domain constraint requires it and that requirement is documented explicitly at the use site or in adjacent documentation. Typical exceptions are rare and include cases such as:
86
-
87
- - schema-driven renderers
88
- - AST or MDX renderers
89
- - plugin systems that receive component types dynamically
90
- - framework integration points that require raw element factory calls
91
-
92
- Do not introduce `createElement` just to:
93
-
94
- - avoid JSX syntax
95
- - build dynamic props more mechanically
96
- - mimic framework internals
97
- - shorten code that JSX already expresses clearly
98
-
99
- When an exception is truly required, isolate it, document why JSX is insufficient, and test it.
100
-
101
- ## Component design rules
102
-
103
- - Prefer function components and hooks over class components unless the codebase already requires a class boundary.
104
- - Keep props narrow and typed.
105
- - Extract pure transforms into helpers instead of embedding large calculations in render.
106
- - Extract repeated stateful behavior into hooks only when more than one caller needs it or the component becomes hard to read.
107
- - Reuse shared primitives for buttons, inputs, dialogs, cards, and layout shells before adding one-off markup.
108
- - Keep render branches explicit. Make loading, empty, error, and success states easy to read.
109
- - Preserve accessible names and roles when refactoring structure.
110
-
111
- ## Review checklist
112
-
113
- Before finishing a React change, verify all of the following:
114
-
115
- - root test IDs use the plain component name
116
- - child test IDs use `ComponentName--thing`
117
- - helper components with their own tagged roots use their own names
118
- - no new `createElement` usage was introduced without an explicit documented exception
119
- - shared primitives were reused where appropriate
120
- - accessible queries remain possible for user-facing controls
121
- - relevant lint, type, and test commands were run
122
-
123
- ## References
124
-
125
- - Read `references/oxlint.md` when adding or changing React-specific lint rules, custom Oxlint plugins, test ID enforcement, or `createElement` bans.
126
- - Read `references/reactPolicyPlugin.js` for a concrete local Oxlint plugin example.
127
- - Read `references/reactPolicyPlugin.test.ts` for a concrete Bun test harness for the plugin.
128
- - Read `references/oxlintrc.json` for a concrete scoped config example.
129
-
130
- ## Companion skills
131
-
132
- Use related skills when the task goes deeper in those areas:
133
-
134
- - `react-testing` for Storybook stories, play functions, and interaction coverage
135
- - `typescript-code-quality` when TypeScript structure and type-safety rules matter heavily
@@ -1,144 +0,0 @@
1
- # Oxlint for React Repositories
2
-
3
- Read this file when the task involves adding, tightening, or debugging React lint rules, custom Oxlint plugins, or CI enforcement.
4
-
5
- ## Enforcement strategy
6
-
7
- Use Oxlint for fast editor and CI feedback.
8
-
9
- Split enforcement into two groups:
10
-
11
- 1. **Built-in rules** for generic quality and correctness
12
- 2. **Local JS plugins** for repository-specific React contracts
13
-
14
- Prefer built-in rules when they express the requirement directly. Use a local plugin only when the rule is specific to the repository and cannot be expressed well with built-ins.
15
-
16
- ## High-value React policies to enforce
17
-
18
- ### 1. Ban `createElement` in regular application code
19
-
20
- Use a linter rule or repo-local plugin to reject:
21
-
22
- - `import { createElement } from 'react'`
23
- - `React.createElement(...)`
24
-
25
- Scope exceptions explicitly to:
26
-
27
- - tests
28
- - Storybook setup/mocks
29
- - documented framework boundaries
30
- - documented schema/plugin renderers
31
-
32
- If the repository allows a rare production exception, require a short comment at the use site explaining why JSX is insufficient.
33
-
34
- ## 2. Enforce the test ID naming contract
35
-
36
- If the repository uses test IDs, prefer a custom plugin for exact policy enforcement.
37
-
38
- Example policy:
39
-
40
- - component root: `ComponentName`
41
- - child elements: `ComponentName--thing`
42
- - helper components with their own tagged roots use their own names
43
-
44
- Use a local plugin when the policy depends on component names, render branches, or JSX structure.
45
-
46
- ## 3. Scope rules by file type
47
-
48
- Do not run React-specific rules on the whole repository.
49
-
50
- Use `overrides` to target the file types the policy actually governs.
51
-
52
- For repositories where every `.tsx` file is in scope for React UI policy, prefer this broad pattern so new files are not missed:
53
-
54
- ```json
55
- {
56
- "$schema": "./node_modules/oxlint/configuration_schema.json",
57
- "jsPlugins": ["./scripts/oxlint/reactPolicyPlugin.js"],
58
- "overrides": [
59
- {
60
- "files": ["**/*.tsx"],
61
- "rules": {
62
- "repo-react/no-regular-create-element": "error",
63
- "repo-react/require-component-root-testid": "error"
64
- }
65
- }
66
- ]
67
- }
68
- ```
69
-
70
- Use narrower globs only when the repository deliberately excludes some `.tsx` categories from the policy.
71
-
72
- ## Local plugin guidance
73
-
74
- Implement a local plugin when the repository needs React-specific contracts that built-ins do not cover.
75
-
76
- Concrete reference files in this skill:
77
-
78
- - `references/reactPolicyPlugin.js` — example plugin with `no-regular-create-element` and `require-component-root-testid`
79
- - `references/reactPolicyPlugin.test.ts` — Bun tests that run Oxlint against temp fixtures
80
- - `references/oxlintrc.json` — scoped config example wiring the plugin into Oxlint
81
-
82
- Typical cases:
83
-
84
- - test ID naming derived from component names
85
- - banning `createElement` except in scoped files
86
- - repository-specific wrapper conventions
87
- - enforcing root render wrappers for provider-only components
88
-
89
- Keep plugins small and exact. One repository contract per rule is usually the right granularity.
90
-
91
- ## Rule design guidance
92
-
93
- ### `no-regular-create-element`
94
-
95
- The rule should:
96
-
97
- - flag `createElement` imports from `react`
98
- - flag `React.createElement(...)`
99
- - ignore test files and documented exception paths through config scoping, not heuristics
100
- - report a direct message: use JSX instead of `createElement` in regular React code
101
-
102
- ### `require-component-root-testid`
103
-
104
- The rule should:
105
-
106
- - detect exported PascalCase components
107
- - inspect all non-null render branches
108
- - require exact root test IDs
109
- - validate child test IDs against the component name
110
- - ignore nested functions and nested classes while traversing one component body
111
-
112
- Use a repository-level test in addition to the plugin when the rule performs AST-heavy structural checks.
113
-
114
- ## Validation workflow
115
-
116
- When adding or changing React lint rules:
117
-
118
- 1. Run the targeted Oxlint command on the affected files first.
119
- 2. Run the plugin tests if a local plugin changed.
120
- 3. Run the type checker.
121
- 4. Run adjacent React tests.
122
- 5. Run the broader repo lint command last.
123
-
124
- Example:
125
-
126
- ```bash
127
- bun x oxlint -c oxlintrc.json --deny-warnings src/components
128
- bun test scripts/oxlint/__tests__/reactPolicyPlugin.test.ts
129
- bun x tsgo -p . --noEmit
130
- bun test src/components --max-concurrency=1
131
- ```
132
-
133
- ## Failure policy
134
-
135
- Do not weaken a React lint rule just to get a change through.
136
-
137
- If the rule blocks valid code, fix one of these instead:
138
-
139
- - the component structure
140
- - the rule implementation
141
- - the file scope in config
142
- - the documented policy
143
-
144
- Do not add blanket ignores when a scoped override or a better rule is the correct solution.
@@ -1,16 +0,0 @@
1
- {
2
- "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "jsPlugins": ["./scripts/oxlint/reactPolicyPlugin.js"],
4
- "rules": {
5
- "eqeqeq": "error"
6
- },
7
- "overrides": [
8
- {
9
- "files": ["**/*.tsx"],
10
- "rules": {
11
- "repo-react/no-regular-create-element": "error",
12
- "repo-react/require-component-root-testid": "error"
13
- }
14
- }
15
- ]
16
- }
@@ -1,549 +0,0 @@
1
- const noRegularCreateElementRule = {
2
- meta: {
3
- type: 'problem',
4
- docs: {
5
- description: 'Ban React createElement in regular application code; use JSX instead',
6
- },
7
- schema: [],
8
- },
9
- create(context) {
10
- return {
11
- ImportDeclaration(node) {
12
- if (node.source?.value !== 'react') {
13
- return;
14
- }
15
-
16
- node.specifiers.forEach((specifier) => {
17
- if (specifier.type !== 'ImportSpecifier') {
18
- return;
19
- }
20
-
21
- if (specifier.imported.type !== 'Identifier' || specifier.imported.name !== 'createElement') {
22
- return;
23
- }
24
-
25
- context.report({
26
- node: specifier,
27
- message: 'Use JSX instead of importing createElement from react in regular application code.',
28
- });
29
- });
30
- },
31
- CallExpression(node) {
32
- if (
33
- node.callee.type !== 'MemberExpression'
34
- || node.callee.computed
35
- || node.callee.object.type !== 'Identifier'
36
- || node.callee.object.name !== 'React'
37
- || node.callee.property.type !== 'Identifier'
38
- || node.callee.property.name !== 'createElement'
39
- ) {
40
- return;
41
- }
42
-
43
- context.report({
44
- node,
45
- message: 'Use JSX instead of React.createElement in regular application code.',
46
- });
47
- },
48
- };
49
- },
50
- };
51
-
52
- const requireComponentRootTestIdRule = {
53
- meta: {
54
- type: 'problem',
55
- docs: {
56
- description: 'Require exported React component roots to use ComponentName and child test ids to use ComponentName--thing',
57
- },
58
- schema: [],
59
- },
60
- create(context) {
61
- return {
62
- Program(program) {
63
- const componentDefinitions = readComponentDefinitions(program);
64
-
65
- componentDefinitions.forEach((componentDefinition) => {
66
- const rootBranches = readRootBranchesForComponent(componentDefinition);
67
- const rootNodes = new Set();
68
-
69
- rootBranches.forEach((rootBranch) => {
70
- rootBranch.testIdEntries.forEach((testIdEntry) => {
71
- rootNodes.add(testIdEntry.node);
72
- });
73
-
74
- if (componentDefinition.isExported) {
75
- reportInvalidExportedRoot(context, componentDefinition.name, rootBranch);
76
- } else {
77
- reportInvalidLocalRoot(context, componentDefinition.name, rootBranch);
78
- }
79
- });
80
-
81
- const componentTestIdEntries = readComponentTestIdEntries(componentDefinition);
82
- componentTestIdEntries.forEach((testIdEntry) => {
83
- if (rootNodes.has(testIdEntry.node)) {
84
- return;
85
- }
86
-
87
- testIdEntry.candidates.forEach((candidate) => {
88
- if (isValidChildTestId(candidate, componentDefinition.name)) {
89
- return;
90
- }
91
-
92
- context.report({
93
- node: testIdEntry.node,
94
- message:
95
- `Component "${componentDefinition.name}" must use child test ids in the format "${componentDefinition.name}--thing". Received "${candidate}".`,
96
- });
97
- });
98
- });
99
- });
100
- },
101
- };
102
- },
103
- };
104
-
105
- const plugin = {
106
- meta: {
107
- name: 'repo-react',
108
- },
109
- rules: {
110
- 'no-regular-create-element': noRegularCreateElementRule,
111
- 'require-component-root-testid': requireComponentRootTestIdRule,
112
- },
113
- };
114
-
115
- export default plugin;
116
-
117
- function readComponentDefinitions(program) {
118
- return program.body.flatMap((statement) => readStatementComponentDefinitions(statement, false));
119
- }
120
-
121
- function readStatementComponentDefinitions(statement, isExported) {
122
- if (statement.type === 'ExportNamedDeclaration') {
123
- return statement.declaration ? readDeclarationComponentDefinitions(statement.declaration, true) : [];
124
- }
125
-
126
- if (statement.type === 'ExportDefaultDeclaration') {
127
- return readDefaultComponentDefinitions(statement.declaration, true);
128
- }
129
-
130
- return readDeclarationComponentDefinitions(statement, isExported);
131
- }
132
-
133
- function readDeclarationComponentDefinitions(declaration, isExported) {
134
- if (declaration.type === 'FunctionDeclaration') {
135
- if (!declaration.id || !isPascalCase(declaration.id.name)) {
136
- return [];
137
- }
138
-
139
- return [{ name: declaration.id.name, kind: 'function', node: declaration, isExported }];
140
- }
141
-
142
- if (declaration.type === 'ClassDeclaration') {
143
- if (!declaration.id || !isPascalCase(declaration.id.name)) {
144
- return [];
145
- }
146
-
147
- return [{ name: declaration.id.name, kind: 'class', node: declaration, isExported }];
148
- }
149
-
150
- if (declaration.type !== 'VariableDeclaration') {
151
- return [];
152
- }
153
-
154
- return declaration.declarations.flatMap((declarator) => {
155
- if (declarator.id.type !== 'Identifier' || !isPascalCase(declarator.id.name)) {
156
- return [];
157
- }
158
-
159
- const componentNode = readWrappedFunctionLike(declarator.init);
160
- if (!componentNode) {
161
- return [];
162
- }
163
-
164
- return [{ name: declarator.id.name, kind: 'function', node: componentNode, isExported }];
165
- });
166
- }
167
-
168
- function readDefaultComponentDefinitions(declaration, isExported) {
169
- if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
170
- if (!declaration.id || !isPascalCase(declaration.id.name)) {
171
- return [];
172
- }
173
-
174
- return declaration.type === 'FunctionDeclaration'
175
- ? [{ name: declaration.id.name, kind: 'function', node: declaration, isExported }]
176
- : [{ name: declaration.id.name, kind: 'class', node: declaration, isExported }];
177
- }
178
-
179
- const componentNode = readWrappedFunctionLike(declaration);
180
- if (!componentNode || !componentNode.id || !isPascalCase(componentNode.id.name)) {
181
- return [];
182
- }
183
-
184
- return [{ name: componentNode.id.name, kind: 'function', node: componentNode, isExported }];
185
- }
186
-
187
- function readWrappedFunctionLike(initializer) {
188
- if (!initializer) {
189
- return null;
190
- }
191
-
192
- if (initializer.type === 'ArrowFunctionExpression' || initializer.type === 'FunctionExpression') {
193
- return initializer;
194
- }
195
-
196
- if (initializer.type !== 'CallExpression') {
197
- return null;
198
- }
199
-
200
- const wrappedInitializer = initializer.arguments[0];
201
- if (!wrappedInitializer || wrappedInitializer.type === 'SpreadElement') {
202
- return null;
203
- }
204
-
205
- return readWrappedFunctionLike(wrappedInitializer);
206
- }
207
-
208
- function readRootBranchesForComponent(componentDefinition) {
209
- const returnExpressions = componentDefinition.kind === 'class'
210
- ? readClassRenderReturnExpressions(componentDefinition.node)
211
- : readFunctionReturnExpressions(componentDefinition.node);
212
-
213
- return returnExpressions.flatMap((returnExpression) => readRootBranches(returnExpression));
214
- }
215
-
216
- function readClassRenderReturnExpressions(classDeclaration) {
217
- const renderMethod = classDeclaration.body.body.find((member) => {
218
- return member.type === 'MethodDefinition'
219
- && !member.computed
220
- && member.key.type === 'Identifier'
221
- && member.key.name === 'render';
222
- });
223
-
224
- if (!renderMethod || !renderMethod.value.body) {
225
- return [];
226
- }
227
-
228
- return readReturnExpressionsFromBlock(renderMethod.value.body, renderMethod.value);
229
- }
230
-
231
- function readFunctionReturnExpressions(functionNode) {
232
- if (!functionNode.body) {
233
- return [];
234
- }
235
-
236
- if (functionNode.body.type !== 'BlockStatement') {
237
- return [functionNode.body];
238
- }
239
-
240
- return readReturnExpressionsFromBlock(functionNode.body, functionNode);
241
- }
242
-
243
- function readReturnExpressionsFromBlock(blockNode, rootFunctionNode) {
244
- const returnExpressions = [];
245
-
246
- visitNode(blockNode);
247
- return returnExpressions;
248
-
249
- function visitNode(node) {
250
- if (!isAstNode(node)) {
251
- return;
252
- }
253
-
254
- if (node !== rootFunctionNode && isNestedFunctionNode(node)) {
255
- return;
256
- }
257
-
258
- if (node !== rootFunctionNode && isNestedClassNode(node)) {
259
- return;
260
- }
261
-
262
- if (node.type === 'ReturnStatement') {
263
- if (node.argument) {
264
- returnExpressions.push(node.argument);
265
- }
266
- return;
267
- }
268
-
269
- readChildNodes(node).forEach(visitNode);
270
- }
271
- }
272
-
273
- function readRootBranches(expression) {
274
- const unwrappedExpression = unwrapExpression(expression);
275
-
276
- if (unwrappedExpression.type === 'ConditionalExpression') {
277
- return [
278
- ...readRootBranches(unwrappedExpression.consequent),
279
- ...readRootBranches(unwrappedExpression.alternate),
280
- ];
281
- }
282
-
283
- if (unwrappedExpression.type === 'LogicalExpression' && unwrappedExpression.operator === '&&') {
284
- return readRootBranches(unwrappedExpression.right);
285
- }
286
-
287
- if (isNullLiteral(unwrappedExpression)) {
288
- return [{ kind: 'null', node: unwrappedExpression, testIdEntries: [] }];
289
- }
290
-
291
- if (unwrappedExpression.type === 'JSXFragment') {
292
- return [{ kind: 'fragment', node: unwrappedExpression, testIdEntries: [] }];
293
- }
294
-
295
- if (unwrappedExpression.type === 'JSXElement') {
296
- return [{
297
- kind: 'jsx',
298
- node: unwrappedExpression,
299
- testIdEntries: readJsxAttributeTestIdEntries(unwrappedExpression.openingElement.attributes),
300
- }];
301
- }
302
-
303
- if (unwrappedExpression.type === 'JSXSelfClosingElement') {
304
- return [{
305
- kind: 'jsx',
306
- node: unwrappedExpression,
307
- testIdEntries: readJsxAttributeTestIdEntries(unwrappedExpression.attributes),
308
- }];
309
- }
310
-
311
- return [{ kind: 'other', node: unwrappedExpression, testIdEntries: [], summary: summarizeNode(unwrappedExpression) }];
312
- }
313
-
314
- function readComponentTestIdEntries(componentDefinition) {
315
- const componentBody = componentDefinition.kind === 'class'
316
- ? readClassRenderBody(componentDefinition.node)
317
- : componentDefinition.node.body;
318
-
319
- if (!componentBody) {
320
- return [];
321
- }
322
-
323
- return readTestIdEntriesFromNode(componentBody, componentBody);
324
- }
325
-
326
- function readClassRenderBody(classDeclaration) {
327
- const renderMethod = classDeclaration.body.body.find((member) => {
328
- return member.type === 'MethodDefinition'
329
- && !member.computed
330
- && member.key.type === 'Identifier'
331
- && member.key.name === 'render';
332
- });
333
-
334
- return renderMethod?.value.body ?? null;
335
- }
336
-
337
- function readTestIdEntriesFromNode(rootNode, boundaryNode) {
338
- const testIdEntries = [];
339
-
340
- visitNode(rootNode);
341
- return testIdEntries;
342
-
343
- function visitNode(node) {
344
- if (!isAstNode(node)) {
345
- return;
346
- }
347
-
348
- if (node !== boundaryNode && isNestedFunctionNode(node)) {
349
- return;
350
- }
351
-
352
- if (node !== boundaryNode && isNestedClassNode(node)) {
353
- return;
354
- }
355
-
356
- if (node.type === 'JSXElement') {
357
- testIdEntries.push(...readJsxAttributeTestIdEntries(node.openingElement.attributes));
358
- node.children.forEach(visitNode);
359
- return;
360
- }
361
-
362
- if (node.type === 'JSXSelfClosingElement') {
363
- testIdEntries.push(...readJsxAttributeTestIdEntries(node.attributes));
364
- return;
365
- }
366
-
367
- readChildNodes(node).forEach(visitNode);
368
- }
369
- }
370
-
371
- function readJsxAttributeTestIdEntries(attributesNode) {
372
- return attributesNode.filter((attribute) => {
373
- return attribute.type === 'JSXAttribute' && isTestIdAttributeName(readJsxAttributeName(attribute.name));
374
- }).map((attribute) => ({
375
- node: attribute,
376
- candidates: readTestIdCandidatesFromJsxAttribute(attribute),
377
- }));
378
- }
379
-
380
- function readTestIdCandidatesFromJsxAttribute(attribute) {
381
- const initializer = attribute.value;
382
- if (!initializer) {
383
- return [];
384
- }
385
-
386
- if (initializer.type === 'Literal' && typeof initializer.value === 'string') {
387
- return [initializer.value];
388
- }
389
-
390
- if (
391
- initializer.type !== 'JSXExpressionContainer' || !initializer.expression
392
- || initializer.expression.type === 'JSXEmptyExpression'
393
- ) {
394
- return [];
395
- }
396
-
397
- return readExpressionStringCandidates(initializer.expression);
398
- }
399
-
400
- function readExpressionStringCandidates(expression) {
401
- const unwrappedExpression = unwrapExpression(expression);
402
-
403
- if (unwrappedExpression.type === 'Literal') {
404
- return typeof unwrappedExpression.value === 'string' ? [unwrappedExpression.value] : [];
405
- }
406
-
407
- if (unwrappedExpression.type === 'TemplateLiteral') {
408
- if (unwrappedExpression.expressions.length !== 0 || unwrappedExpression.quasis.length !== 1) {
409
- return [];
410
- }
411
-
412
- const cookedValue = unwrappedExpression.quasis[0]?.value.cooked;
413
- return typeof cookedValue === 'string' ? [cookedValue] : [];
414
- }
415
-
416
- if (unwrappedExpression.type === 'ConditionalExpression') {
417
- return [
418
- ...readExpressionStringCandidates(unwrappedExpression.consequent),
419
- ...readExpressionStringCandidates(unwrappedExpression.alternate),
420
- ];
421
- }
422
-
423
- return [];
424
- }
425
-
426
- function reportInvalidExportedRoot(context, componentName, rootBranch) {
427
- if (rootBranch.kind === 'null') {
428
- return;
429
- }
430
-
431
- if (rootBranch.kind === 'fragment') {
432
- context.report({
433
- node: rootBranch.node,
434
- message: `Exported component "${componentName}" returns a fragment root; wrap it in a DOM element with data-testid="${componentName}".`,
435
- });
436
- return;
437
- }
438
-
439
- if (rootBranch.kind === 'other') {
440
- context.report({
441
- node: rootBranch.node,
442
- message: `Exported component "${componentName}" returns ${rootBranch.summary}; render a root data-testid="${componentName}" instead.`,
443
- });
444
- return;
445
- }
446
-
447
- const hasExactRootTestId = rootBranch.testIdEntries.some((testIdEntry) => {
448
- return testIdEntry.candidates.some((candidate) => candidate === componentName);
449
- });
450
-
451
- if (hasExactRootTestId) {
452
- return;
453
- }
454
-
455
- context.report({
456
- node: rootBranch.testIdEntries[0]?.node ?? rootBranch.node,
457
- message: `Exported component "${componentName}" must render a root data-testid or testId exactly equal to "${componentName}".`,
458
- });
459
- }
460
-
461
- function reportInvalidLocalRoot(context, componentName, rootBranch) {
462
- if (rootBranch.kind === 'null' || rootBranch.testIdEntries.length === 0) {
463
- return;
464
- }
465
-
466
- rootBranch.testIdEntries.forEach((testIdEntry) => {
467
- testIdEntry.candidates.forEach((candidate) => {
468
- if (candidate === componentName) {
469
- return;
470
- }
471
-
472
- context.report({
473
- node: testIdEntry.node,
474
- message: `Component "${componentName}" must use the plain root test id "${componentName}" on its root element. Received "${candidate}".`,
475
- });
476
- });
477
- });
478
- }
479
-
480
- function isValidChildTestId(value, componentName) {
481
- return new RegExp(`^${componentName}--[a-z0-9]+(?:-[a-z0-9]+)*$`, 'u').test(value);
482
- }
483
-
484
- function unwrapExpression(expression) {
485
- let currentExpression = expression;
486
-
487
- while (currentExpression.type === 'ParenthesizedExpression') {
488
- currentExpression = currentExpression.expression;
489
- }
490
-
491
- return currentExpression;
492
- }
493
-
494
- function isTestIdAttributeName(attributeName) {
495
- return attributeName === 'data-testid' || attributeName === 'testId';
496
- }
497
-
498
- function readJsxAttributeName(attributeName) {
499
- if (attributeName.type === 'JSXIdentifier') {
500
- return attributeName.name;
501
- }
502
-
503
- if (attributeName.type === 'JSXNamespacedName') {
504
- return `${attributeName.namespace.name}:${attributeName.name.name}`;
505
- }
506
-
507
- return '';
508
- }
509
-
510
- function summarizeNode(node) {
511
- const rawName = node.type.replace(/Expression$/u, ' expression');
512
- return rawName.toLowerCase();
513
- }
514
-
515
- function readChildNodes(node) {
516
- return Object.entries(node).flatMap(([key, value]) => {
517
- if (key === 'parent' || key === 'loc' || key === 'range') {
518
- return [];
519
- }
520
-
521
- if (Array.isArray(value)) {
522
- return value.filter(isAstNode);
523
- }
524
-
525
- return isAstNode(value) ? [value] : [];
526
- });
527
- }
528
-
529
- function isNestedFunctionNode(node) {
530
- return node.type === 'FunctionDeclaration'
531
- || node.type === 'FunctionExpression'
532
- || node.type === 'ArrowFunctionExpression';
533
- }
534
-
535
- function isNestedClassNode(node) {
536
- return node.type === 'ClassDeclaration' || node.type === 'ClassExpression';
537
- }
538
-
539
- function isAstNode(value) {
540
- return value !== null && typeof value === 'object' && typeof value.type === 'string';
541
- }
542
-
543
- function isNullLiteral(node) {
544
- return node.type === 'Literal' && node.value === null;
545
- }
546
-
547
- function isPascalCase(value) {
548
- return /^[A-Z][A-Za-z0-9]*$/u.test(value);
549
- }
@@ -1,152 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import path from 'node:path';
6
-
7
- const PLUGIN_FILE_PATH = path.resolve(import.meta.dir, 'reactPolicyPlugin.js');
8
-
9
- describe('reactPolicyPlugin', () => {
10
- test('accepts valid root and child test ids', () => {
11
- const result = runOxlint(`
12
- export function Valid() {
13
- return (
14
- <section data-testid='Valid'>
15
- <div data-testid='Valid--body'>Ready</div>
16
- </section>
17
- );
18
- }
19
- `);
20
-
21
- expect(result.exitCode).toBe(0);
22
- expect(result.messages).toEqual([]);
23
- });
24
-
25
- test('reports imported createElement usage', () => {
26
- const result = runOxlint(`
27
- import { createElement } from 'react';
28
-
29
- export function Broken() {
30
- return createElement('div', { 'data-testid': 'Broken' });
31
- }
32
- `, {
33
- rules: {
34
- 'repo-react/no-regular-create-element': 'error',
35
- },
36
- });
37
-
38
- expect(result.exitCode).toBe(1);
39
- expect(result.messages).toEqual([
40
- {
41
- code: 'repo-react(no-regular-create-element)',
42
- message: 'Use JSX instead of importing createElement from react in regular application code.',
43
- },
44
- ]);
45
- });
46
-
47
- test('reports React.createElement usage', () => {
48
- const result = runOxlint(`
49
- export function Broken() {
50
- return React.createElement('div', { 'data-testid': 'Broken' });
51
- }
52
- `, {
53
- rules: {
54
- 'repo-react/no-regular-create-element': 'error',
55
- },
56
- });
57
-
58
- expect(result.exitCode).toBe(1);
59
- expect(result.messages).toEqual([
60
- {
61
- code: 'repo-react(no-regular-create-element)',
62
- message: 'Use JSX instead of React.createElement in regular application code.',
63
- },
64
- ]);
65
- });
66
-
67
- test('reports root and child test id violations', () => {
68
- const result = runOxlint(`
69
- export function Broken() {
70
- return (
71
- <div data-testid='Broken--root'>
72
- <button data-testid='Wrong--action'>Action</button>
73
- </div>
74
- );
75
- }
76
- `);
77
-
78
- expect(result.exitCode).toBe(1);
79
- expect(result.messages).toEqual([
80
- {
81
- code: 'repo-react(require-component-root-testid)',
82
- message: 'Exported component "Broken" must render a root data-testid or testId exactly equal to "Broken".',
83
- },
84
- {
85
- code: 'repo-react(require-component-root-testid)',
86
- message: 'Component "Broken" must use child test ids in the format "Broken--thing". Received "Wrong--action".',
87
- },
88
- ]);
89
- });
90
- });
91
-
92
- interface IOxlintResult {
93
- exitCode: number;
94
- messages: Array<{
95
- code: string;
96
- message: string;
97
- }>;
98
- }
99
-
100
- function runOxlint(
101
- fileContents: string,
102
- options: {
103
- rules?: Record<string, 'error'>;
104
- fileName?: string;
105
- } = {},
106
- ): IOxlintResult {
107
- const tempDir = mkdtempSync(path.join(tmpdir(), 'react-policy-plugin-'));
108
-
109
- try {
110
- const fixtureFilePath = path.join(tempDir, options.fileName ?? 'fixture.tsx');
111
- const configFilePath = path.join(tempDir, '.oxlintrc.json');
112
-
113
- writeFileSync(fixtureFilePath, fileContents.trimStart());
114
- writeFileSync(
115
- configFilePath,
116
- JSON.stringify({
117
- jsPlugins: [PLUGIN_FILE_PATH],
118
- rules: options.rules ?? {
119
- 'repo-react/require-component-root-testid': 'error',
120
- },
121
- }),
122
- );
123
-
124
- const spawnResult = Bun.spawnSync({
125
- cmd: [process.execPath, 'x', 'oxlint', '-c', configFilePath, '-f', 'json', fixtureFilePath],
126
- stdout: 'pipe',
127
- stderr: 'pipe',
128
- });
129
-
130
- const stdout = new TextDecoder().decode(spawnResult.stdout);
131
- const stderr = new TextDecoder().decode(spawnResult.stderr);
132
-
133
- assert.equal(stderr, '');
134
-
135
- const payload = JSON.parse(stdout) as {
136
- diagnostics?: Array<{
137
- code?: string;
138
- message?: string;
139
- }>;
140
- };
141
-
142
- return {
143
- exitCode: spawnResult.exitCode,
144
- messages: (payload.diagnostics ?? []).map((diagnostic) => ({
145
- code: diagnostic.code ?? '',
146
- message: diagnostic.message ?? '',
147
- })),
148
- };
149
- } finally {
150
- rmSync(tempDir, { recursive: true, force: true });
151
- }
152
- }