@nadicodeai/ui 8.5.0 → 8.6.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/dist/eslint/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { confirmDestructiveAction } from "./rules/confirm-destructive-action.js"
|
|
|
6
6
|
import { iconControlAccessibleName } from "./rules/icon-control-accessible-name.js";
|
|
7
7
|
import { noAppFeatureComponent } from "./rules/no-app-feature-component.js";
|
|
8
8
|
import { noElevatedEdge } from "./rules/no-elevated-edge.js";
|
|
9
|
+
import { noExistenceOnlyAssertion } from "./rules/no-existence-only-assertion.js";
|
|
9
10
|
import { noNestedOverlay } from "./rules/no-nested-overlay.js";
|
|
10
11
|
import { noOffGridSpacing } from "./rules/no-off-grid-spacing.js";
|
|
11
12
|
import { noRawShadow } from "./rules/no-raw-shadow.js";
|
|
@@ -17,6 +18,7 @@ import { noPackageComponentRedefinition } from "./rules/no-package-component-red
|
|
|
17
18
|
import { noPairedSizeUtility } from "./rules/no-paired-size-utility.js";
|
|
18
19
|
import { noRestingSettingsControl } from "./rules/no-resting-settings-control.js";
|
|
19
20
|
import { noShadcnAppearanceOverride } from "./rules/no-shadcn-appearance-override.js";
|
|
21
|
+
import { noSubjectDerivedExpectation } from "./rules/no-subject-derived-expectation.js";
|
|
20
22
|
import { noUnpinnedVercelFunctionsImport } from "./rules/no-unpinned-vercel-functions-import.js";
|
|
21
23
|
import { noNativeVisibleControl } from "./rules/no-native-visible-control.js";
|
|
22
24
|
import { noRepaintedFocus } from "./rules/no-repainted-focus.js";
|
|
@@ -84,6 +86,7 @@ const plugin = {
|
|
|
84
86
|
"icon-control-accessible-name": iconControlAccessibleName,
|
|
85
87
|
"no-app-feature-component": noAppFeatureComponent,
|
|
86
88
|
"no-elevated-edge": noElevatedEdge,
|
|
89
|
+
"no-existence-only-assertion": noExistenceOnlyAssertion,
|
|
87
90
|
"no-deprecated-zod-api": noDeprecatedZodApi,
|
|
88
91
|
"no-mono-title-register": noMonoTitleRegister,
|
|
89
92
|
"no-nested-overlay": noNestedOverlay,
|
|
@@ -95,6 +98,7 @@ const plugin = {
|
|
|
95
98
|
"no-route-width-override": noRouteWidthOverride,
|
|
96
99
|
"prefer-radio-for-few-static-options": preferRadioForFewStaticOptions,
|
|
97
100
|
"no-shadcn-appearance-override": noShadcnAppearanceOverride,
|
|
101
|
+
"no-subject-derived-expectation": noSubjectDerivedExpectation,
|
|
98
102
|
"no-native-visible-control": noNativeVisibleControl,
|
|
99
103
|
"no-repainted-focus": noRepaintedFocus,
|
|
100
104
|
"no-unpinned-vercel-functions-import": noUnpinnedVercelFunctionsImport,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One reader for `expect(subject).<modifiers>.<matcher>(args)`.
|
|
3
|
+
*
|
|
4
|
+
* Both assertion rules here judge a call by its matcher name, and the matcher
|
|
5
|
+
* is not the callee's property in the general case: `not`, `resolves` and
|
|
6
|
+
* `rejects` sit between `expect(...)` and the matcher, so the name has to be
|
|
7
|
+
* read by walking the member chain back to the `expect` call. This is that
|
|
8
|
+
* walk, kept in one place so the two rules cannot disagree about what counts
|
|
9
|
+
* as an assertion.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function propertyName(member) {
|
|
13
|
+
if (!member.computed && member.property.type === "Identifier") return member.property.name;
|
|
14
|
+
if (member.computed && member.property.type === "Literal") {
|
|
15
|
+
return typeof member.property.value === "string" ? member.property.value : undefined;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `a?.b` and `a?.()` arrive wrapped; the rules want the expression itself. */
|
|
21
|
+
function unwrapChain(node) {
|
|
22
|
+
return node?.type === "ChainExpression" ? node.expression : node;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isExpectCall(node) {
|
|
26
|
+
if (node?.type !== "CallExpression") return false;
|
|
27
|
+
if (node.callee.type === "Identifier") return node.callee.name === "expect";
|
|
28
|
+
// `expect.soft(value)` and `expect.poll(fn)` open the same chain.
|
|
29
|
+
return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier"
|
|
30
|
+
? node.callee.object.name === "expect"
|
|
31
|
+
: false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads an assertion call. Returns `{ matcher, modifiers, subject, args }` for
|
|
36
|
+
* `expect(subject).<modifiers>.<matcher>(args)`, or `undefined` when the call
|
|
37
|
+
* is not an `expect` chain. `subject` is unwrapped of its `ChainExpression`.
|
|
38
|
+
*/
|
|
39
|
+
export function expectAssertion(node) {
|
|
40
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return undefined;
|
|
41
|
+
const matcher = propertyName(node.callee);
|
|
42
|
+
if (!matcher) return undefined;
|
|
43
|
+
|
|
44
|
+
const modifiers = [];
|
|
45
|
+
let current = node.callee.object;
|
|
46
|
+
while (current.type === "MemberExpression") {
|
|
47
|
+
const name = propertyName(current);
|
|
48
|
+
if (!name) return undefined;
|
|
49
|
+
modifiers.unshift(name);
|
|
50
|
+
current = current.object;
|
|
51
|
+
}
|
|
52
|
+
if (!isExpectCall(current)) return undefined;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
matcher,
|
|
56
|
+
modifiers,
|
|
57
|
+
subject: unwrapChain(current.arguments[0]),
|
|
58
|
+
args: node.arguments,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* An assertion that something exists is not an assertion about behavior.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes in this suite pass for nearly any implementation.
|
|
5
|
+
*
|
|
6
|
+
* A `&&` or `||` subject returns one of its operands, not a boolean, so the
|
|
7
|
+
* short-circuit alone can satisfy the matcher: the left operand decides the
|
|
8
|
+
* result and the right one is never evaluated.
|
|
9
|
+
*
|
|
10
|
+
* An optional call yields `undefined` when the method is gone. That is exactly
|
|
11
|
+
* what `toBeFalsy()` and `toBeUndefined()` accept, which is how
|
|
12
|
+
* `expect(options.clientPrivileges?.()).toBeFalsy()` in
|
|
13
|
+
* `apps/portal/src/lib/server/nadia-oauth-provider.test.ts` kept asserting that
|
|
14
|
+
* OAuth grants no configuration privileges after the privilege hooks had been
|
|
15
|
+
* deleted from the provider.
|
|
16
|
+
*
|
|
17
|
+
* What is deliberately left alone: `toBeDefined()` / `toBeTruthy()` on any
|
|
18
|
+
* other subject. On a plain identifier or member read it is the type-narrowing
|
|
19
|
+
* step before reading a property off it. On a Testing Library `getBy*` query it
|
|
20
|
+
* is redundant, since the query already throws, but it is the suite's idiom for
|
|
21
|
+
* "this element renders" and replacing it mechanically produces
|
|
22
|
+
* `expect(getByText("X").textContent).toBe("X")`, which is a tautology of its
|
|
23
|
+
* own: 214 such sites were rewritten that way once and reverted. `toBeNull()`
|
|
24
|
+
* and `toBeUndefined()` on any other subject are a real claim of absence.
|
|
25
|
+
*
|
|
26
|
+
* Standard tools checked first: `vitest/expect-expect` refuses a test with no
|
|
27
|
+
* assertion at all and `vitest/valid-expect` checks the call's arity; neither
|
|
28
|
+
* asks what the asserted expression can produce, which is the whole question
|
|
29
|
+
* here.
|
|
30
|
+
*/
|
|
31
|
+
import { expectAssertion } from "./expect-chain.js";
|
|
32
|
+
|
|
33
|
+
const SHORT_CIRCUIT_MATCHERS = new Set(["toBeDefined", "toBeTruthy", "toBeFalsy", "toBeUndefined"]);
|
|
34
|
+
const MISSING_VALUE_MATCHERS = new Set(["toBeFalsy", "toBeUndefined"]);
|
|
35
|
+
|
|
36
|
+
function isOptionalCall(node) {
|
|
37
|
+
return node?.type === "CallExpression" && node.optional === true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const noExistenceOnlyAssertion = {
|
|
41
|
+
meta: {
|
|
42
|
+
type: "problem",
|
|
43
|
+
docs: {
|
|
44
|
+
description:
|
|
45
|
+
"Disallow expectations that only assert a value exists, which pass for almost any implementation",
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
messages: {
|
|
49
|
+
shortCircuitSubject:
|
|
50
|
+
"`{{operator}}` returns one of its operands rather than a boolean, so `{{assertion}}` passes on the short-circuit alone, with the right operand never evaluated. Assert each operand separately, or assert the value the expression stands for.",
|
|
51
|
+
optionalCallAbsence:
|
|
52
|
+
"`{{assertion}}` on an optional call passes when the call never happens: a deleted or renamed method makes `?.()` yield `undefined`, which is both falsy and undefined. Assert the method is present, then assert what the call returns.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
create(context) {
|
|
57
|
+
function assertionText(matcher, modifiers) {
|
|
58
|
+
return [...modifiers, `${matcher}()`].join(".");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
CallExpression(node) {
|
|
63
|
+
const assertion = expectAssertion(node);
|
|
64
|
+
if (!assertion) return;
|
|
65
|
+
const { matcher, modifiers, subject } = assertion;
|
|
66
|
+
if (!subject) return;
|
|
67
|
+
|
|
68
|
+
if (modifiers.length > 0) return;
|
|
69
|
+
|
|
70
|
+
if (subject.type === "LogicalExpression" && SHORT_CIRCUIT_MATCHERS.has(matcher)) {
|
|
71
|
+
context.report({
|
|
72
|
+
node,
|
|
73
|
+
messageId: "shortCircuitSubject",
|
|
74
|
+
data: { operator: subject.operator, assertion: assertionText(matcher, modifiers) },
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isOptionalCall(subject) && MISSING_VALUE_MATCHERS.has(matcher)) {
|
|
80
|
+
context.report({
|
|
81
|
+
node,
|
|
82
|
+
messageId: "optionalCallAbsence",
|
|
83
|
+
data: { assertion: assertionText(matcher, modifiers) },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The expected value cannot come out of the module under test.
|
|
3
|
+
*
|
|
4
|
+
* When a test compares a result with a constant it imported from the sibling
|
|
5
|
+
* module it is testing, both sides move together: editing the implementation
|
|
6
|
+
* edits the expectation, and no change to the value can ever turn the test
|
|
7
|
+
* red. What looks like a pinned contract is a restatement of the code.
|
|
8
|
+
*
|
|
9
|
+
* Three of these were live here. `viewer-time-zone.test.ts` asserted
|
|
10
|
+
* `expect(await viewerTimeZone()).toBe(DEFAULT_VIEWER_TIME_ZONE)` with
|
|
11
|
+
* `DEFAULT_VIEWER_TIME_ZONE` imported from `./viewer-time-zone`, so the
|
|
12
|
+
* default the Portal falls back to was never pinned to a time zone at all.
|
|
13
|
+
* `contract-v1.test.ts` asserted the served ETag header against
|
|
14
|
+
* `openApiV1Etag` from `./contract-v1`, so the published API contract's
|
|
15
|
+
* validator was compared with itself, and a token TTL went the same way.
|
|
16
|
+
*
|
|
17
|
+
* The fix is to write the literal (`"Europe/Rome"`), or to compute the
|
|
18
|
+
* expectation from something the subject does not own — a checked-in fixture,
|
|
19
|
+
* a hash taken in the test, the wire format itself.
|
|
20
|
+
*
|
|
21
|
+
* Scope: only a relative import (`./`, `../`) is the subject. A constant from
|
|
22
|
+
* a package or an alias (`@/…`, `vitest`) is a third party's contract, and a
|
|
23
|
+
* value the test file declares itself is a value the test chose. Only a bare
|
|
24
|
+
* identifier or a member read off one counts: a call, a template literal or a
|
|
25
|
+
* literal built around the identifier is a transformation the test performs,
|
|
26
|
+
* and the transformation is the thing being asserted.
|
|
27
|
+
*
|
|
28
|
+
* A relative import that resolves into a `tests/` or `fixtures/` directory is
|
|
29
|
+
* the harness, not the subject: `PORTAL_TEST_ADMIN.userId` from
|
|
30
|
+
* `apps/portal/tests/support/postgres-testdb` is the row the test seeded, so
|
|
31
|
+
* comparing a query result with it asserts that the code returned what was put
|
|
32
|
+
* in. Those are excluded by the resolved path, not by the specifier, because a
|
|
33
|
+
* test under `tests/` reaches its own support modules as `../support/…`.
|
|
34
|
+
*/
|
|
35
|
+
import { dirname, resolve, sep } from "node:path";
|
|
36
|
+
|
|
37
|
+
import { expectAssertion } from "./expect-chain.js";
|
|
38
|
+
|
|
39
|
+
const EQUALITY_MATCHERS = new Set(["toBe", "toEqual", "toStrictEqual"]);
|
|
40
|
+
const TEST_FILE = /\.test\.tsx?$/;
|
|
41
|
+
const HARNESS_DIRECTORIES = new Set(["tests", "test", "__tests__", "fixtures", "__fixtures__"]);
|
|
42
|
+
|
|
43
|
+
/** True when the import lands in a directory the test side owns. */
|
|
44
|
+
function isHarnessModule(filename, source) {
|
|
45
|
+
const resolved = resolve(dirname(filename), source);
|
|
46
|
+
const segments = resolved.split(sep);
|
|
47
|
+
if (segments.some((segment) => HARNESS_DIRECTORIES.has(segment))) return true;
|
|
48
|
+
// `openrouter-wire.fixture.ts` beside its subject: a fixture by name, not by directory.
|
|
49
|
+
return /\.fixture(\.[cm]?[jt]sx?)?$/.test(segments[segments.length - 1]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The identifier a bare read or a member read (`contract.etag`) starts from. */
|
|
53
|
+
function rootIdentifier(node) {
|
|
54
|
+
let current = node;
|
|
55
|
+
while (current?.type === "MemberExpression" || current?.type === "ChainExpression") {
|
|
56
|
+
current = current.type === "ChainExpression" ? current.expression : current.object;
|
|
57
|
+
}
|
|
58
|
+
return current?.type === "Identifier" ? current : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const noSubjectDerivedExpectation = {
|
|
62
|
+
meta: {
|
|
63
|
+
type: "problem",
|
|
64
|
+
docs: {
|
|
65
|
+
description: "Disallow an expected value imported from the sibling module the test covers",
|
|
66
|
+
},
|
|
67
|
+
schema: [],
|
|
68
|
+
messages: {
|
|
69
|
+
subjectDerived:
|
|
70
|
+
"`{{name}}` comes from `{{source}}`, the module this test covers, so the expectation moves with the implementation and no change to that value can fail this test. Write the expected value as a literal here, or compute it from something the subject does not own.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
create(context) {
|
|
75
|
+
if (!TEST_FILE.test(context.filename)) return {};
|
|
76
|
+
|
|
77
|
+
const sourceCode = context.sourceCode;
|
|
78
|
+
/** @type {Map<import('eslint').Scope.Variable, string>} */
|
|
79
|
+
const subjectBindings = new Map();
|
|
80
|
+
|
|
81
|
+
function bindingSource(identifier) {
|
|
82
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
83
|
+
const variable = scope.set.get(identifier.name);
|
|
84
|
+
// The nearest binding wins: a local declaration shadowing the import
|
|
85
|
+
// is a value this test chose, so it drops out here.
|
|
86
|
+
if (variable) return subjectBindings.get(variable);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
Program(node) {
|
|
93
|
+
for (const statement of node.body) {
|
|
94
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
95
|
+
if (statement.importKind === "type") continue;
|
|
96
|
+
const source = statement.source.value;
|
|
97
|
+
if (typeof source !== "string") continue;
|
|
98
|
+
if (!source.startsWith("./") && !source.startsWith("../")) continue;
|
|
99
|
+
if (isHarnessModule(context.filename, source)) continue;
|
|
100
|
+
for (const specifier of statement.specifiers) {
|
|
101
|
+
if (specifier.importKind === "type") continue;
|
|
102
|
+
for (const variable of sourceCode.getDeclaredVariables(specifier)) {
|
|
103
|
+
subjectBindings.set(variable, source);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
CallExpression(node) {
|
|
110
|
+
const assertion = expectAssertion(node);
|
|
111
|
+
if (!assertion || !EQUALITY_MATCHERS.has(assertion.matcher)) return;
|
|
112
|
+
const [expected] = assertion.args;
|
|
113
|
+
if (!expected) return;
|
|
114
|
+
if (expected.type !== "Identifier" && expected.type !== "MemberExpression") return;
|
|
115
|
+
|
|
116
|
+
const root = rootIdentifier(expected);
|
|
117
|
+
if (!root) return;
|
|
118
|
+
const source = bindingSource(root);
|
|
119
|
+
if (!source) return;
|
|
120
|
+
|
|
121
|
+
context.report({
|
|
122
|
+
node: expected,
|
|
123
|
+
messageId: "subjectDerived",
|
|
124
|
+
data: { name: sourceCode.getText(expected), source },
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nadicodeai/ui",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@assistant-ui/react": "0.15.20",
|
|
71
71
|
"@assistant-ui/react-markdown": "0.14.15",
|
|
72
72
|
"@base-ui/react": "^1.8.0",
|
|
73
|
-
"@nadicodeai/design-system": "8.
|
|
73
|
+
"@nadicodeai/design-system": "8.6.0",
|
|
74
74
|
"@paper-design/shaders": "0.0.81",
|
|
75
75
|
"class-variance-authority": "^0.7.1",
|
|
76
76
|
"clsx": "^2.1.1",
|