@sveltesentio/core 0.1.0 → 0.2.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/golusoris/sveltesentio/compare/core-v0.2.0...core-v0.2.1) (2026-06-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **core:** restore eslint.config.ts + chart-a11y rule (prior commit's git add aborted) ([a978cf6](https://github.com/golusoris/sveltesentio/commit/a978cf6f4a362b1fb83eabdc426e6b04276607c3))
9
+
10
+ ## [0.2.0](https://github.com/golusoris/sveltesentio/compare/core-v0.1.0...core-v0.2.0) (2026-06-19)
11
+
12
+
13
+ ### Features
14
+
15
+ * **core:** no-direct-time ESLint rule + bundle-size gate in sentioPlugin ([f3e223b](https://github.com/golusoris/sveltesentio/commit/f3e223b24cafdfae9646315e9ab64ea0899fd297))
16
+
3
17
  ## [0.1.0](https://github.com/golusoris/sveltesentio/compare/core-v0.0.1...core-v0.1.0) (2026-06-14)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltesentio/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Vite plugin, env schema, error types, id/clock utils — sveltesentio core",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,6 +37,10 @@
37
37
  "./vite": {
38
38
  "import": "./src/vite.ts",
39
39
  "types": "./src/vite.ts"
40
+ },
41
+ "./eslint": {
42
+ "import": "./src/eslint.ts",
43
+ "types": "./src/eslint.ts"
40
44
  }
41
45
  },
42
46
  "peerDependencies": {
@@ -54,15 +58,16 @@
54
58
  }
55
59
  },
56
60
  "devDependencies": {
57
- "@sveltejs/kit": "^2.0.0",
58
- "@types/node": "^24.0.0",
61
+ "@sveltejs/kit": "^2.65.2",
62
+ "@types/node": "^24.13.2",
59
63
  "esm-env": "^1.2.2",
60
64
  "openapi-fetch": "^0.17.0",
61
- "svelte": "^5.55.4",
65
+ "svelte": "^5.56.3",
62
66
  "typescript": "^6.0.3",
63
- "uuid": "^13.0.0",
64
- "vitest": "^4.1.4",
65
- "zod": "^4.3.6"
67
+ "uuid": "^14.0.0",
68
+ "vitest": "^4.1.9",
69
+ "zod": "^4.4.3",
70
+ "svelte-eslint-parser": "^1.8.0"
66
71
  },
67
72
  "keywords": [
68
73
  "sveltesentio",
@@ -80,6 +85,6 @@
80
85
  "build": "tsc",
81
86
  "lint": "eslint src/",
82
87
  "typecheck": "tsc --noEmit",
83
- "test": "vitest run"
88
+ "test": "vitest run --coverage"
84
89
  }
85
90
  }
package/src/eslint.ts ADDED
@@ -0,0 +1,209 @@
1
+ import type { Rule } from 'eslint';
2
+
3
+ /**
4
+ * Flat-config ESLint plugin bundling sveltesentio's two cross-package
5
+ * invariants:
6
+ *
7
+ * - `no-direct-time` — time must flow through the injected {@link Clock}
8
+ * (`useClock` / `getClock` from `@sveltesentio/core`) so it is deterministic
9
+ * and testable (AGENTS.md §Invariants + docs/principles.md §2.1). Banned forms:
10
+ * `Date.now()`, zero-argument `new Date()`, `performance.now()`.
11
+ * - `chart-a11y-wrapper` — every chart visual rendered from `layerchart` /
12
+ * `uplot` must go through `@sveltesentio/charts`' `<ChartFigure>` so the WCAG
13
+ * 2.2 SC 1.1.1 text alternative cannot be skipped (charts/AGENTS.md §Invariants,
14
+ * ADR-0013). A bare `<LineChart>` / `<Chart>` / uPlot element with no
15
+ * `<ChartFigure>` ancestor is an error.
16
+ *
17
+ * Register in a flat `eslint.config.js`:
18
+ *
19
+ * ```js
20
+ * import sentio from '@sveltesentio/core/eslint';
21
+ * export default [
22
+ * {
23
+ * files: ['src/**\/*.ts'],
24
+ * plugins: { '@sveltesentio': sentio },
25
+ * rules: { '@sveltesentio/no-direct-time': 'error' },
26
+ * },
27
+ * {
28
+ * files: ['src/**\/*.svelte'],
29
+ * plugins: { '@sveltesentio': sentio },
30
+ * rules: { '@sveltesentio/chart-a11y-wrapper': 'error' },
31
+ * },
32
+ * ];
33
+ * ```
34
+ */
35
+
36
+ // Pull the concrete AST node shapes from ESLint's own `Rule.Node` union so we
37
+ // don't take a direct dependency on `@types/estree` (ESLint owns that peer).
38
+ type Node = Rule.Node;
39
+ type CallExpressionNode = Extract<Node, { type: 'CallExpression' }>;
40
+ type NewExpressionNode = Extract<Node, { type: 'NewExpression' }>;
41
+ type ImportDeclarationNode = Extract<Node, { type: 'ImportDeclaration' }>;
42
+ type Callee = CallExpressionNode['callee'];
43
+
44
+ const CLOCK_HINT =
45
+ 'route time through the injected Clock — `useClock()`/`getClock()` ' +
46
+ '(or `testClock` in tests) from @sveltesentio/core — so it stays ' +
47
+ 'deterministic and testable';
48
+
49
+ /** Matches `<object>.<property>` where both are plain (non-computed) identifiers. */
50
+ function isMemberCall(
51
+ callee: Callee,
52
+ objectName: string,
53
+ propertyName: string,
54
+ ): boolean {
55
+ if (callee.type !== 'MemberExpression') return false;
56
+ if (callee.computed) return false;
57
+ if (callee.property.type !== 'Identifier') return false;
58
+ if (callee.property.name !== propertyName) return false;
59
+ return (
60
+ callee.object.type === 'Identifier' && callee.object.name === objectName
61
+ );
62
+ }
63
+
64
+ const noDirectTime: Rule.RuleModule = {
65
+ meta: {
66
+ type: 'problem',
67
+ docs: {
68
+ description:
69
+ 'disallow direct wall-clock / monotonic time reads; use the injected Clock',
70
+ recommended: true,
71
+ },
72
+ schema: [],
73
+ messages: {
74
+ dateNow: `Avoid \`Date.now()\` — ${CLOCK_HINT}.`,
75
+ newDate: `Avoid argument-less \`new Date()\` — ${CLOCK_HINT}.`,
76
+ performanceNow: `Avoid \`performance.now()\` — ${CLOCK_HINT}.`,
77
+ },
78
+ },
79
+
80
+ create(context: Rule.RuleContext): Rule.RuleListener {
81
+ return {
82
+ CallExpression(node: CallExpressionNode): void {
83
+ if (isMemberCall(node.callee, 'Date', 'now')) {
84
+ context.report({ node, messageId: 'dateNow' });
85
+ return;
86
+ }
87
+ if (isMemberCall(node.callee, 'performance', 'now')) {
88
+ context.report({ node, messageId: 'performanceNow' });
89
+ }
90
+ },
91
+
92
+ NewExpression(node: NewExpressionNode): void {
93
+ if (node.callee.type !== 'Identifier') return;
94
+ if (node.callee.name !== 'Date') return;
95
+ // `new Date(serverMs)` is an explicit, deterministic construction —
96
+ // only the zero-argument form reads ambient wall-clock time.
97
+ if (node.arguments.length === 0) {
98
+ context.report({ node, messageId: 'newDate' });
99
+ }
100
+ },
101
+ };
102
+ },
103
+ };
104
+
105
+ // --- chart-a11y-wrapper ------------------------------------------------------
106
+
107
+ const CHART_LIBS = ['layerchart', 'uplot'] as const;
108
+
109
+ /** The a11y wrappers from `@sveltesentio/charts` that satisfy the invariant. */
110
+ const A11Y_WRAPPER_ELEMENTS = new Set(['ChartFigure']);
111
+
112
+ const CHART_HINT =
113
+ 'render it inside `<ChartFigure>` from @sveltesentio/charts so the chart ' +
114
+ 'ships the required visually-hidden data table (WCAG 2.2 SC 1.1.1, ADR-0013)';
115
+
116
+ /**
117
+ * Structural read of a `svelte-eslint-parser` `SvelteElement` name without
118
+ * depending on the parser's types: a component element exposes
119
+ * `name: { type: 'Identifier' | 'SvelteName', name: string }`. Anything else
120
+ * (HTML tags, member-expression names) is not a chart-library binding and is
121
+ * intentionally ignored.
122
+ */
123
+ function svelteElementName(node: unknown): string | undefined {
124
+ if (typeof node !== 'object' || node === null) return undefined;
125
+ const name = (node as { name?: unknown }).name;
126
+ if (typeof name !== 'object' || name === null) return undefined;
127
+ const raw = (name as { name?: unknown }).name;
128
+ return typeof raw === 'string' ? raw : undefined;
129
+ }
130
+
131
+ /** Reads the source string of a (validated) `ImportDeclaration`. */
132
+ function importSource(node: ImportDeclarationNode): string {
133
+ const value = node.source.value;
134
+ return typeof value === 'string' ? value : '';
135
+ }
136
+
137
+ /** True for `layerchart`, `layerchart/...`, `uplot`, `uplot/...`. */
138
+ function isChartLibSource(source: string): boolean {
139
+ return CHART_LIBS.some(
140
+ (lib) => source === lib || source.startsWith(`${lib}/`),
141
+ );
142
+ }
143
+
144
+ const chartA11yWrapper: Rule.RuleModule = {
145
+ meta: {
146
+ type: 'problem',
147
+ docs: {
148
+ description:
149
+ 'require chart visuals from layerchart / uplot to be wrapped in <ChartFigure>',
150
+ recommended: true,
151
+ },
152
+ schema: [],
153
+ messages: {
154
+ bareChart: `Bare \`<{{name}}>\` from \`{{source}}\` bypasses the a11y wrapper — ${CHART_HINT}.`,
155
+ },
156
+ },
157
+
158
+ create(context: Rule.RuleContext): Rule.RuleListener {
159
+ // Local binding names imported from a chart library → the source they
160
+ // came from, so the message can name it.
161
+ const chartBindings = new Map<string, string>();
162
+
163
+ return {
164
+ ImportDeclaration(node: ImportDeclarationNode): void {
165
+ const source = importSource(node);
166
+ if (!isChartLibSource(source)) return;
167
+ for (const spec of node.specifiers) {
168
+ chartBindings.set(spec.local.name, source);
169
+ }
170
+ },
171
+
172
+ // `SvelteElement` is the svelte-eslint-parser node for any element;
173
+ // not part of ESLint's core `NodeListener`, so it rides the
174
+ // RuleListener index signature.
175
+ SvelteElement(node: Rule.Node): void {
176
+ const name = svelteElementName(node);
177
+ if (name === undefined) return;
178
+ const source = chartBindings.get(name);
179
+ if (source === undefined) return;
180
+ // Allowed when nested under a sanctioned a11y wrapper element.
181
+ const ancestors = context.sourceCode.getAncestors(node);
182
+ const wrapped = ancestors.some((ancestor) =>
183
+ A11Y_WRAPPER_ELEMENTS.has(svelteElementName(ancestor) ?? ''),
184
+ );
185
+ if (wrapped) return;
186
+ context.report({
187
+ node,
188
+ messageId: 'bareChart',
189
+ data: { name, source },
190
+ });
191
+ },
192
+ };
193
+ },
194
+ };
195
+
196
+ /** The flat-config plugin object (`plugins: { '@sveltesentio': sentioEslint }`). */
197
+ const sentioEslint = {
198
+ meta: { name: '@sveltesentio/core', version: '0.2.0' },
199
+ rules: {
200
+ 'no-direct-time': noDirectTime,
201
+ 'chart-a11y-wrapper': chartA11yWrapper,
202
+ },
203
+ } satisfies {
204
+ meta: { name: string; version: string };
205
+ rules: Record<string, Rule.RuleModule>;
206
+ };
207
+
208
+ export { noDirectTime, chartA11yWrapper, sentioEslint };
209
+ export default sentioEslint;
package/src/index.ts CHANGED
@@ -39,5 +39,12 @@ export {
39
39
  strictCsp,
40
40
  } from './csp.js';
41
41
 
42
- export type { SentioPluginOptions } from './vite.js';
43
- export { sentioPlugin } from './vite.js';
42
+ export type {
43
+ BudgetViolation,
44
+ BundleBudget,
45
+ BundleLike,
46
+ SentioPluginOptions,
47
+ } from './vite.js';
48
+ export { checkBundleBudget, sentioPlugin } from './vite.js';
49
+
50
+ export { noDirectTime, sentioEslint } from './eslint.js';
package/src/vite.ts CHANGED
@@ -1,16 +1,89 @@
1
1
  import type { Plugin } from 'vite';
2
2
 
3
+ /** A budget map: output chunk file name → maximum allowed size in bytes. */
4
+ export type BundleBudget = Readonly<Record<string, number>>;
5
+
3
6
  export interface SentioPluginOptions {
4
7
  requiredEnv?: readonly string[];
5
8
  verbose?: boolean;
6
9
  virtualModule?: Readonly<Record<string, unknown>>;
10
+ /**
11
+ * Per-chunk size budgets (§2.9 perf budgets). Keys are output `fileName`s
12
+ * (exact match) — e.g. `{ 'entry.js': 150_000 }`. On `generateBundle`, any
13
+ * emitted chunk whose byte size exceeds its budget fails the build, unless
14
+ * {@link bundleBudgetWarnOnly} is set.
15
+ */
16
+ bundleBudget?: BundleBudget;
17
+ /** When true, over-budget chunks warn instead of failing the build. */
18
+ bundleBudgetWarnOnly?: boolean;
7
19
  }
8
20
 
9
21
  const VIRTUAL_ID = '$sentio';
10
22
  const RESOLVED_ID = '\0$sentio';
11
23
 
24
+ /** Minimal structural view of a Rollup/Rolldown output item we need to size. */
25
+ interface BundleEntry {
26
+ type: 'chunk' | 'asset';
27
+ code?: string;
28
+ source?: string | Uint8Array;
29
+ }
30
+
31
+ /** A bundle is a `fileName → entry` map, as passed to `generateBundle`. */
32
+ export type BundleLike = Readonly<Record<string, BundleEntry>>;
33
+
34
+ export interface BudgetViolation {
35
+ fileName: string;
36
+ size: number;
37
+ budget: number;
38
+ }
39
+
40
+ /** Byte size of a chunk's code or an asset's source. */
41
+ function entrySize(entry: BundleEntry): number {
42
+ if (entry.type === 'chunk') {
43
+ return entry.code === undefined ? 0 : Buffer.byteLength(entry.code, 'utf8');
44
+ }
45
+ const { source } = entry;
46
+ if (source === undefined) return 0;
47
+ if (typeof source === 'string') return Buffer.byteLength(source, 'utf8');
48
+ return source.byteLength;
49
+ }
50
+
51
+ /**
52
+ * Pure budget check: returns every output entry that exceeds its budget. An
53
+ * entry with no matching budget key is unconstrained. Exported for unit tests.
54
+ */
55
+ export function checkBundleBudget(
56
+ bundle: BundleLike,
57
+ budget: BundleBudget,
58
+ ): BudgetViolation[] {
59
+ const violations: BudgetViolation[] = [];
60
+ for (const [fileName, entry] of Object.entries(bundle)) {
61
+ const max = budget[fileName];
62
+ if (max === undefined) continue;
63
+ const size = entrySize(entry);
64
+ if (size > max) violations.push({ fileName, size, budget: max });
65
+ }
66
+ return violations;
67
+ }
68
+
69
+ function formatViolations(violations: readonly BudgetViolation[]): string {
70
+ const lines = violations.map(
71
+ (v) =>
72
+ ` - ${v.fileName}: ${v.size} B exceeds budget ${v.budget} B (+${
73
+ v.size - v.budget
74
+ } B)`,
75
+ );
76
+ return `[sentio] Bundle-size budget exceeded:\n${lines.join('\n')}`;
77
+ }
78
+
12
79
  export function sentioPlugin(options: SentioPluginOptions = {}): Plugin {
13
- const { requiredEnv = [], verbose = false, virtualModule = {} } = options;
80
+ const {
81
+ requiredEnv = [],
82
+ verbose = false,
83
+ virtualModule = {},
84
+ bundleBudget,
85
+ bundleBudgetWarnOnly = false,
86
+ } = options;
14
87
 
15
88
  return {
16
89
  name: 'vite-plugin-sentio',
@@ -52,5 +125,22 @@ export function sentioPlugin(options: SentioPluginOptions = {}): Plugin {
52
125
  );
53
126
  }
54
127
  },
128
+
129
+ generateBundle(_options, bundle) {
130
+ if (!bundleBudget || Object.keys(bundleBudget).length === 0) return;
131
+ const violations = checkBundleBudget(bundle, bundleBudget);
132
+ if (violations.length === 0) {
133
+ if (verbose) {
134
+ console.warn('[sentio] Bundle-size budget: all chunks within budget.');
135
+ }
136
+ return;
137
+ }
138
+ const message = formatViolations(violations);
139
+ if (bundleBudgetWarnOnly) {
140
+ console.warn(message);
141
+ return;
142
+ }
143
+ throw new Error(message);
144
+ },
55
145
  };
56
146
  }