@wssto2/vue-core 0.11.0 → 0.12.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 +25 -0
- package/README.md +26 -0
- package/bin/architecture/engine.mjs +225 -0
- package/bin/architecture/packs.mjs +107 -0
- package/bin/cli.mjs +57 -0
- package/bin/commands/check-architecture.mjs +38 -0
- package/bin/commands/e2e-timings.mjs +58 -0
- package/bin/commands/feature.mjs +179 -0
- package/bin/commands/lint.mjs +532 -0
- package/bin/config.mjs +72 -0
- package/bin/templates/feature-crud/api.ts.tpl +35 -0
- package/bin/templates/feature-crud/components/__FEATURE_ENTITY__Modal.vue.tpl +36 -0
- package/bin/templates/feature-crud/form.ts.tpl +6 -0
- package/bin/templates/feature-crud/index.ts.tpl +7 -0
- package/bin/templates/feature-crud/routes.ts.tpl +13 -0
- package/bin/templates/feature-crud/tests/scaffold.test.ts.tpl +9 -0
- package/bin/templates/feature-crud/types.ts.tpl +3 -0
- package/bin/templates/feature-crud/views/Index.vue.tpl +36 -0
- package/bin/vue-core.mjs +4 -0
- package/dist/config/index.d.ts +97 -0
- package/dist/config/index.js +6 -0
- package/dist/eslint/index.d.ts +28 -0
- package/dist/eslint/index.js +87 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/modules/identity/entities.d.ts +3 -2
- package/dist/playwright/access.d.ts +61 -0
- package/dist/playwright/envelope.d.ts +37 -0
- package/dist/playwright/index.d.ts +13 -0
- package/dist/playwright/index.js +403 -0
- package/dist/playwright/notifications.d.ts +48 -0
- package/dist/playwright/profile.d.ts +35 -0
- package/dist/playwright/request.d.ts +14 -0
- package/dist/playwright/session.d.ts +48 -0
- package/dist/playwright/users.d.ts +57 -0
- package/package.json +48 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.1 — 2026-10-18
|
|
4
|
+
|
|
5
|
+
go-core `v1.16.1`: a type the wire writes as text is generated as a string.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **modules:** regenerated from go-core v1.16.1. In the identity entities `ClauseInfo.qualifier` and `PermissionAccess.qualifier` are `Qualifier` (`"own" | "own_location" | "all"`, exported from `modules/identity/entities`); they were `number`, while the wire sends the text.
|
|
9
|
+
- **playwright:** `SessionExtra.access` and `accessOf` use the generated `MyAccess`; the `WireAccess` workaround for the number-typed qualifier is gone.
|
|
10
|
+
|
|
11
|
+
## 0.12.0 — 2026-10-18
|
|
12
|
+
|
|
13
|
+
The developer kit an application copied: one `vue-core` command for the ratcheted lint, the feature-folder architecture rules, the feature generator and the e2e timings, the ESLint config, and Playwright mocks of the library's own endpoints. Nothing existing changes. go-core is unchanged (`v1.15.0`; v1.16.0 changes no wire type).
|
|
14
|
+
|
|
15
|
+
### New
|
|
16
|
+
- **bin:** the `vue-core` command, `vue-core [--config <path>] <command>`: `lint`, `check-architecture`, `feature`, `e2e-timings`, all reading one `vue-core.config.mjs` (found from the current folder upwards). `@wssto2/vue-core/config` exports `defineConfig` and the config types.
|
|
17
|
+
- **lint:** `vue-core lint` runs ESLint with per-file, per-rule ceilings that only go down (`--fix`, `--update`, `--all`, `--report`, `--rule=`, `--file=`, `--summary`; `LINT_TYPED=1` lints the changed files type-aware). The `lint` section sets `baselines`, `generated`, `patternCounters`, `commands` and `policy`.
|
|
18
|
+
- **eslint:** `@wssto2/vue-core/eslint`: `vueCoreConfig({ typed, tsconfigRootDir, ignores, rules })`, the generic flat config, and `TYPED_RULES` / `TYPED_RULE_SET`, the one list of type-aware rules.
|
|
19
|
+
- **architecture:** `vue-core check-architecture`: outside code reaches a feature only through `index.ts` / `routes.ts`, no feature imports another, the shared layer imports no feature, generated code only through its subpaths, HTTP only in `api.ts`, one `ApiError`, single-word folder names, `views/` needs `index.ts` + `routes.ts`. Opt-in packs `design-values` and `presentation`, allowlists that only shrink, and an application's own rules (`architecture.rules`).
|
|
20
|
+
- **feature:** `vue-core feature <a/b> --entity <Name>` scaffolds a CRUD feature folder on go-core's route table. The default template uses vue-core subpaths only and creates its own modal; `generator.template` and `generator.out` configure it.
|
|
21
|
+
- **e2e-timings:** `vue-core e2e-timings [--report] [--top]`: the slowest files and tests of a Playwright JSON report.
|
|
22
|
+
- **playwright:** `@wssto2/vue-core/playwright`: Playwright mocks for the library's own endpoints (the go-core envelope, `/auth/me`, access, notifications, profile, users and service accounts), typed from the go-core module types. See docs/recipes/e2e.md.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- `eslint`, `@eslint/js`, `eslint-plugin-vue`, `typescript-eslint`, `vue-eslint-parser`, `globals` and `@playwright/test` are optional peer dependencies: an application that does not lint or test with Playwright installs nothing.
|
|
26
|
+
- The library's own `eslint.config.js` uses `vueCoreConfig`.
|
|
27
|
+
|
|
3
28
|
## 0.11.0 — 2026-10-17
|
|
4
29
|
|
|
5
30
|
The five gaps an application met adopting 0.10.0's users screens and quick search. go-core is unchanged (`v1.15.0`).
|
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Its plan and rules are in [PLAN.md](PLAN.md).
|
|
|
23
23
|
| `@wssto2/vue-core/notifications` | go-core's notification and events modules: `notificationsFeature({ categories, deadLetters })`: the bell with the unread count, the inbox (popover, bottom sheet on phones), the live stream, the failed-events page: [notifications](docs/recipes/notifications.md) |
|
|
24
24
|
| `@wssto2/vue-core/incidents` | go-core's incident module: `incidentsFeature({ destination, appVersion })`: the list, an incident with its reports, comments and management form, "Report a bug" in the account menu; `clientErrorReporter({ platform })`: the browser's own errors reported as incidents: [incidents](docs/recipes/incidents.md) |
|
|
25
25
|
| `@wssto2/vue-core/testing` | `createTestPlatform`, `createTestApp`, fake transports: for your tests, [testing](docs/recipes/testing.md) |
|
|
26
|
+
| `@wssto2/vue-core/playwright` | `mockAuth`, `serveAccess`, `serveNotifications`, `serveProfile`, `serveUsers`: Playwright mocks of the library's endpoints, [end-to-end specs](docs/recipes/e2e.md) |
|
|
26
27
|
|
|
27
28
|
Nothing is reachable by a deep import. The peers are `vue`, `vue-router` and `vue-i18n`; the library never bundles them.
|
|
28
29
|
|
|
@@ -339,13 +340,38 @@ The library's own texts are under the `core` namespace in English, Croatian, Bos
|
|
|
339
340
|
- [Users and profile](docs/recipes/users.md): the users list and a person's record, "my profile" with the e-mail change by code, opting out, extra sections, refusals
|
|
340
341
|
- [Service accounts](docs/recipes/service-accounts.md): the list and an account's record, its roles, API keys shown once, tenants, linking
|
|
341
342
|
- [Roles and access](docs/recipes/access.md): go-core's access module: the roles list and editor from your catalogue, a person's roles and effective access, where a role applies, the refusals
|
|
343
|
+
- [Lint](docs/recipes/lint.md): `vue-core lint` and `@wssto2/vue-core/eslint`: ceilings per file and rule that only go down, the typed run on changed files
|
|
342
344
|
- [Notifications](docs/recipes/notifications.md): the bell, the inbox and the live stream, categories, the failed-events page, trying it against go-core's dev server
|
|
343
345
|
- [Incidents](docs/recipes/incidents.md): go-core's incident module: the list and an incident, "Report a bug", the browser's errors reported (what is and is not sent), source maps
|
|
344
346
|
- [Theming](docs/recipes/theming.md): brand accent, dark mode, tokens, status tones and category hues
|
|
345
347
|
- [Testing](docs/recipes/testing.md): the application's environment and a fake backend for your tests
|
|
348
|
+
- [End-to-end specs](docs/recipes/e2e.md): `@wssto2/vue-core/playwright`, the Playwright mocks of go-core's envelope, session, access, notification, profile and users routes
|
|
349
|
+
- [Feature generator and e2e timings](docs/recipes/feature-generator.md): `vue-core feature` and `vue-core e2e-timings`
|
|
346
350
|
|
|
347
351
|
Forms have their own recipe when that phase lands.
|
|
348
352
|
|
|
353
|
+
## The `vue-core` command
|
|
354
|
+
|
|
355
|
+
The package installs one command, `vue-core`, with the subcommands `lint`, `check-architecture`, `feature` and `e2e-timings` (`npx vue-core --help`). Each reads its own section of one `vue-core.config.mjs`, found from the current folder upwards (or `--config <path>`); a command with no section runs on its defaults. `eslint`, its plugins and `@playwright/test` are optional peers, installed only by the applications that use them.
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
npx vue-core --help
|
|
359
|
+
npx vue-core lint --config tools/vue-core.config.mjs
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
<!-- example: docs/examples/devkit/vue-core.config.ts -->
|
|
363
|
+
```ts
|
|
364
|
+
import { defineConfig } from "@wssto2/vue-core/config";
|
|
365
|
+
|
|
366
|
+
// vue-core.config.mjs at the root of the application; one section per command.
|
|
367
|
+
export default defineConfig({
|
|
368
|
+
lint: {},
|
|
369
|
+
architecture: {},
|
|
370
|
+
generator: {},
|
|
371
|
+
e2eTimings: {},
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
349
375
|
## Working on the library
|
|
350
376
|
|
|
351
377
|
```bash
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// The checks behind `vue-core check-architecture`: walks the source tree under `root` and returns the violations and warnings
|
|
2
|
+
// in the order they are found (per file in path order, then the stale allowlist entries, then the feature-folder rules).
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { PACKS } from "./packs.mjs";
|
|
7
|
+
|
|
8
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".vue"]);
|
|
9
|
+
const toPosix = (value) => value.split(path.sep).join("/");
|
|
10
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
11
|
+
const entries = (dir) => readdirSync(dir, { withFileTypes: true }).sort(byName);
|
|
12
|
+
const sentence = (items) => (items.length > 1 ? `${items.slice(0, -1).join(", ")} or ${items.at(-1)}` : items[0]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} options
|
|
16
|
+
* @param {string} options.root the folder the config file lives in; `alias` points at it
|
|
17
|
+
* @param {object} options.config the `architecture` section, defaults applied
|
|
18
|
+
* @param {string} options.configName how a stale allowlist message names the config file
|
|
19
|
+
*/
|
|
20
|
+
export function check({ root, config, configName }) {
|
|
21
|
+
const { featuresDir, alias, sharedLayers, generated, skip, uiDirs, doc } = config;
|
|
22
|
+
const allow = Object.fromEntries(Object.entries(config.allow).map(([rule, files]) => [rule, new Set(files)]));
|
|
23
|
+
const allowed = (rule) => allow[rule] ?? new Set();
|
|
24
|
+
const docRef = (key, join = " (") => (doc[key] ? `${join}${doc[key]}${join === " (" ? ")" : ""}` : "");
|
|
25
|
+
const packs = config.packs.map((name) => PACKS[name]);
|
|
26
|
+
const packRules = packs.flatMap((pack, index) => pack.rules.map((rule) => ({ ...rule, pack: packs[index], seen: new Set() })));
|
|
27
|
+
const featuresRoot = path.join(root, featuresDir);
|
|
28
|
+
const violations = [];
|
|
29
|
+
const warnings = [];
|
|
30
|
+
|
|
31
|
+
const featureRoots = getFeatureRoots();
|
|
32
|
+
const owningFeature = (relativePath) => featureRoots.find((feature) => relativePath === feature || relativePath.startsWith(`${feature}/`)) ?? null;
|
|
33
|
+
const featuresPrefix = `${alias}${featuresDir}/`;
|
|
34
|
+
const generatedPrefix = `${alias}${generated.dir}/`;
|
|
35
|
+
const generatedAllowed = generated.allowed.map((entry) => `${alias}${generated.dir}/${entry}`);
|
|
36
|
+
const isGeneratedAllowed = (specifier) =>
|
|
37
|
+
generatedAllowed.some((entry) => (entry.endsWith("/*") ? specifier.startsWith(entry.slice(0, -1)) : specifier === entry));
|
|
38
|
+
|
|
39
|
+
for (const filePath of collectSourceFiles(root)) {
|
|
40
|
+
const file = toPosix(path.relative(root, filePath));
|
|
41
|
+
const importerFeature = owningFeature(file);
|
|
42
|
+
const content = readFileSync(filePath, "utf8");
|
|
43
|
+
const isVue = file.endsWith(".vue");
|
|
44
|
+
const isApiFile = /(^|\/)api\.ts$/.test(file);
|
|
45
|
+
const isTest = /(^|\/)tests?\//.test(file) || /\.(test|spec)\.[jt]sx?$/.test(file);
|
|
46
|
+
const imports = extractImportSpecifiers(content);
|
|
47
|
+
|
|
48
|
+
for (const specifier of imports) {
|
|
49
|
+
// the shared layer imports no feature (aliased or relative)
|
|
50
|
+
if (sharedLayers.some((layer) => file.startsWith(`${layer}/`))) {
|
|
51
|
+
const target = specifier.startsWith(alias) ? specifier.slice(alias.length) : specifier.startsWith(".") ? toPosix(path.join(path.dirname(file), specifier)) : null;
|
|
52
|
+
if (target === featuresDir || target?.startsWith(`${featuresDir}/`)) {
|
|
53
|
+
violations.push(
|
|
54
|
+
`${file}: the shared layer (${sharedLayers.map((layer) => `${layer}/`).join(", ")}) cannot import "${specifier}" — take what the feature provides as a prop or callback, or move this code into the feature${docRef("shared-layer")}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const [from, to] of Object.entries(generated.moved)) {
|
|
60
|
+
if (specifier.startsWith(from)) violations.push(`${file}: replace legacy "${from.replace(/\/$/, "")}" imports with "${to}".`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (specifier.startsWith(generatedPrefix) && !isGeneratedAllowed(specifier)) {
|
|
64
|
+
violations.push(`${file}: import generated code only through ${sentence(generatedAllowed.map((entry) => `"${entry}"`))}.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// deep import into a feature from outside every feature: `index.ts` is the public surface, `routes.ts` the one extra entry point
|
|
68
|
+
if (!importerFeature && specifier.startsWith(featuresPrefix)) {
|
|
69
|
+
const bare = specifier.slice(alias.length);
|
|
70
|
+
const importedFeature = owningFeature(bare);
|
|
71
|
+
if (importedFeature && bare !== importedFeature && bare !== `${importedFeature}/routes` && !allowed("deep-import").has(`${file} -> ${specifier}`)) {
|
|
72
|
+
violations.push(`${file}: import feature code through "${alias}${importedFeature}" (or "${alias}${importedFeature}/routes"), not the internal path "${specifier}".`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!importerFeature || !specifier.startsWith(featuresPrefix)) continue;
|
|
77
|
+
const importedFeature = owningFeature(specifier.slice(alias.length));
|
|
78
|
+
if (!importedFeature || importedFeature === importerFeature) continue;
|
|
79
|
+
violations.push(`${file}: feature "${importerFeature}" cannot import "${specifier}". Import shared code or the owning app-shell route instead.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// raw fetch()/axios outside an api.ts (tests exempt). A URL/string first argument tells a real global-fetch call from a
|
|
83
|
+
// bare `fetch()` invocation of a local refresh function.
|
|
84
|
+
if (!isApiFile && !isTest) {
|
|
85
|
+
const rawFetch = /(?<![.\w])fetch\s*\(\s*[`'"]/.test(content);
|
|
86
|
+
const usesAxios = /(?<![.\w])axios\s*[.(]/.test(content) || /["'`]axios["'`]/.test(content);
|
|
87
|
+
if ((rawFetch || usesAxios) && !allowed("raw-http").has(file)) {
|
|
88
|
+
violations.push(
|
|
89
|
+
`${file}: no raw fetch()/axios outside an api.ts — route the call through the platform client (currentRuntime().platform.http) so it shares error handling and ApiError.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// the application's own rules
|
|
95
|
+
if (config.rules.length > 0) {
|
|
96
|
+
const template = content.match(/<template>([\s\S]*)<\/template>/)?.[1] ?? "";
|
|
97
|
+
for (const rule of config.rules) {
|
|
98
|
+
for (const message of rule({ file, content, template, imports, isVue, isTest }) ?? []) violations.push(`${file}: ${message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// a failed request is vue-core's `ApiError`; a second class makes `isApiError` miss the errors of the other client
|
|
103
|
+
if (/\bclass\s+\w*ApiError\b/.test(content)) {
|
|
104
|
+
violations.push(
|
|
105
|
+
`${file}: declares a second ApiError class — a failed request is vue-core's ApiError (import { ApiError, isApiError } from "@wssto2/vue-core/client"${docRef("api-error", "; ")}).`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
for (const m of content.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g)) {
|
|
109
|
+
if (m[2] !== "@wssto2/vue-core/client" && /(^|[\s,])(ApiError|isApiError)\b/.test(m[1].replace(/\bas\s+\w+/g, ""))) {
|
|
110
|
+
violations.push(`${file}: imports ApiError from "${m[2]}" — the one error class comes from "@wssto2/vue-core/client"${docRef("api-error")}.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// the packs
|
|
115
|
+
if (packs.length > 0) {
|
|
116
|
+
const template = content.match(/<template>([\s\S]*)<\/template>/)?.[1] ?? "";
|
|
117
|
+
const classLists = [...content.matchAll(/(?:\bclass|:class)="([^"]*)"/g)].map((m) => m[1].split(/\s+/).filter(Boolean));
|
|
118
|
+
for (const rule of packRules) {
|
|
119
|
+
if (!rule.pack.applies({ file, isVue, featuresDir, uiDirs }) || !rule.test(content, template, classLists)) continue;
|
|
120
|
+
if (allowed(rule.id).has(file)) rule.seen.add(file);
|
|
121
|
+
else violations.push(`${file}: ${rule.id} — ${rule.hint}${docRef(rule.id)}.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// a stale allowlist entry (the file no longer trips the rule) is a violation: the lists only shrink
|
|
127
|
+
for (const rule of packRules) {
|
|
128
|
+
for (const entry of allowed(rule.id)) {
|
|
129
|
+
if (!rule.seen.has(entry)) {
|
|
130
|
+
violations.push(`${entry}: stale ${rule.id} allowlist entry in ${configName} — the file no longer trips the rule; remove it from the allowlist.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// every directory name under the features folder is a single lowercase word
|
|
136
|
+
for (const dirPath of collectFeatureDirs(featuresRoot)) {
|
|
137
|
+
const rel = toPosix(path.relative(root, dirPath));
|
|
138
|
+
const name = rel.split("/").pop();
|
|
139
|
+
if (!/^[a-z][a-z0-9]*$/.test(name) && !allowed("feature-name").has(rel)) {
|
|
140
|
+
violations.push(`${rel}: feature directory name "${name}" must be a single lowercase word (^[a-z][a-z0-9]*$), matching the backend route/policy convention.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// a feature leaf (a directory at depth 1 or 2 that directly holds views/) ships index.ts and routes.ts; api.ts is a warning
|
|
145
|
+
for (const leaf of collectFeatureLeaves(featuresRoot)) {
|
|
146
|
+
const rel = toPosix(path.relative(root, leaf));
|
|
147
|
+
if (allowed("feature-shape").has(rel)) continue;
|
|
148
|
+
for (const required of ["index.ts", "routes.ts"]) {
|
|
149
|
+
if (!existsSync(path.join(leaf, required))) violations.push(`${rel}: a feature with views/ must ship ${required} (its public surface / route table).`);
|
|
150
|
+
}
|
|
151
|
+
if (!existsSync(path.join(leaf, "api.ts"))) warnings.push(`${rel}: no api.ts — expected for any feature that talks to the backend.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { violations, warnings };
|
|
155
|
+
|
|
156
|
+
// the directories with an index.ts at depth 1 or 2 under the features folder, the longest path first
|
|
157
|
+
function getFeatureRoots() {
|
|
158
|
+
const roots = [];
|
|
159
|
+
for (const entry of entries(featuresRoot)) {
|
|
160
|
+
if (!entry.isDirectory()) continue;
|
|
161
|
+
const top = path.join(featuresRoot, entry.name);
|
|
162
|
+
if (existsSync(path.join(top, "index.ts"))) roots.push(toPosix(path.relative(root, top)));
|
|
163
|
+
for (const nested of entries(top)) {
|
|
164
|
+
if (!nested.isDirectory()) continue;
|
|
165
|
+
const nestedPath = path.join(top, nested.name);
|
|
166
|
+
if (existsSync(path.join(nestedPath, "index.ts"))) roots.push(toPosix(path.relative(root, nestedPath)));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return roots.sort((left, right) => right.length - left.length);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collectSourceFiles(directoryPath) {
|
|
173
|
+
const files = [];
|
|
174
|
+
for (const entry of entries(directoryPath)) {
|
|
175
|
+
if (skip.includes(entry.name)) continue;
|
|
176
|
+
const entryPath = path.join(directoryPath, entry.name);
|
|
177
|
+
if (entry.isDirectory()) files.push(...collectSourceFiles(entryPath));
|
|
178
|
+
else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
|
|
179
|
+
}
|
|
180
|
+
return files;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function collectFeatureDirs(directoryPath) {
|
|
185
|
+
const dirs = [];
|
|
186
|
+
for (const entry of entries(directoryPath)) {
|
|
187
|
+
if (!entry.isDirectory() || entry.name === "node_modules") continue;
|
|
188
|
+
const entryPath = path.join(directoryPath, entry.name);
|
|
189
|
+
dirs.push(entryPath, ...collectFeatureDirs(entryPath));
|
|
190
|
+
}
|
|
191
|
+
return dirs;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// depth 1 or 2 keeps nested view groups (features/vehicles/views/new/<phase>/views) out of scope
|
|
195
|
+
function collectFeatureLeaves(directoryPath) {
|
|
196
|
+
const hasViews = (dir) => existsSync(path.join(dir, "views")) && statSync(path.join(dir, "views")).isDirectory();
|
|
197
|
+
const leaves = [];
|
|
198
|
+
for (const entry of entries(directoryPath)) {
|
|
199
|
+
if (!entry.isDirectory()) continue;
|
|
200
|
+
const level1 = path.join(directoryPath, entry.name);
|
|
201
|
+
if (hasViews(level1)) leaves.push(level1);
|
|
202
|
+
for (const nested of entries(level1)) {
|
|
203
|
+
if (!nested.isDirectory()) continue;
|
|
204
|
+
const level2 = path.join(level1, nested.name);
|
|
205
|
+
if (hasViews(level2)) leaves.push(level2);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return leaves;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function extractImportSpecifiers(fileContent) {
|
|
212
|
+
const specifiers = new Set();
|
|
213
|
+
const patterns = [
|
|
214
|
+
/import\s+(?:[^"'`]*?\sfrom\s*)?["'`]([^"'`]+)["'`]/g,
|
|
215
|
+
/import\(\s*["'`]([^"'`]+)["'`]\s*\)/g,
|
|
216
|
+
// re-exports reach a module as much as imports do
|
|
217
|
+
/export\s+(?:type\s+)?(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s*from\s*["'`]([^"'`]+)["'`]/g,
|
|
218
|
+
];
|
|
219
|
+
for (const pattern of patterns) {
|
|
220
|
+
for (const match of fileContent.matchAll(pattern)) {
|
|
221
|
+
if (match[1]) specifiers.add(match[1]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [...specifiers];
|
|
225
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The rule packs `vue-core check-architecture` ships, opt in by name (`packs: ["design-values", "presentation"]`):
|
|
2
|
+
// "design-values" keeps the look in vue-core's theme (colours, type weights, shadows), "presentation" asks for the library's
|
|
3
|
+
// components instead of hand-built markup. A pack's rule has an `id`, a `hint` and `test(content, template, classLists)`;
|
|
4
|
+
// the engine reports `<file>: <id> — <hint>.` and keeps the pack's per-rule allowlist (`allow[<id>]`) a ratchet in both
|
|
5
|
+
// directions: an entry that no longer trips its rule is a violation too.
|
|
6
|
+
|
|
7
|
+
const UTIL = "(?:bg|text|border|border-[trblxy]|ring|ring-offset|divide|fill|stroke|outline|decoration|from|via|to|placeholder|caret|accent|shadow)";
|
|
8
|
+
|
|
9
|
+
const pattern = (id, hint, regex) => ({ id, hint, test: (content) => regex.test(content) });
|
|
10
|
+
|
|
11
|
+
// The shared field components' opening tags in a template that renders FormGroups,
|
|
12
|
+
// keeping only those that bind their own `editable` (rule no-field-editable-in-group).
|
|
13
|
+
function fieldTagsWithEditable(template) {
|
|
14
|
+
if (!/<(?:FormGroup|form-group)[\s>]/.test(template)) return [];
|
|
15
|
+
const names = "(?:text|textarea|select|combo|date|date-time|month-year|switch|segmented-select)-field|(?:Text|Textarea|Select|Combo|Date|DateTime|MonthYear|Switch|SegmentedSelect)Field|choice-chips|ChoiceChips";
|
|
16
|
+
const found = [];
|
|
17
|
+
for (const match of template.matchAll(new RegExp(`<(${names})(?=[\\s/>])`, "g"))) {
|
|
18
|
+
let end = match.index + match[0].length;
|
|
19
|
+
let quote = null;
|
|
20
|
+
for (; end < template.length; end += 1) {
|
|
21
|
+
const char = template[end];
|
|
22
|
+
if (quote) { if (char === quote) quote = null; }
|
|
23
|
+
else if (char === '"' || char === "'") quote = char;
|
|
24
|
+
else if (char === ">") break;
|
|
25
|
+
}
|
|
26
|
+
const tag = template.slice(match.index, end);
|
|
27
|
+
if (/(?<![\w-])(?::|v-bind:)?editable=/.test(tag)) found.push(tag);
|
|
28
|
+
}
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const PACKS = {
|
|
33
|
+
"design-values": {
|
|
34
|
+
// .vue / .ts of the UI folders, tests excluded
|
|
35
|
+
applies: ({ file, uiDirs }) => uiDirs.some((dir) => file.startsWith(`${dir}/`)) && /\.(vue|ts)$/.test(file) && !/(^|\/)tests\/|\.test\.ts$|\.spec\.ts$/.test(file),
|
|
36
|
+
rules: [
|
|
37
|
+
pattern("no-stray-neutral", "neutral / zinc / slate / stone colour — the one neutral is gray (vue-core theme.css)", new RegExp(`(?<![\\w-])(?:[\\w-]+:)*${UTIL}-(?:neutral|zinc|slate|stone)-\\d{2,3}(?![\\w-])`)),
|
|
38
|
+
pattern("no-raw-brand-hue", "green / emerald colour — the brand green is primary-* (a stepper tick, switch, success or active state)", new RegExp(`(?<![\\w-])(?:[\\w-]+:)*${UTIL}-(?:green|emerald)-\\d{2,3}(?![\\w-])`)),
|
|
39
|
+
pattern("no-unbranded-focus", "blue / sky / cyan / indigo focus colour — focus rings and borders are primary-* everywhere (decorative rings are fine)", /(?<![\w-])(?:[\w-]+:)*(?:focus|focus-within|focus-visible|peer-focus|checked)(?::[\w-]+)*:(?:ring|border|outline)-(?:blue|sky|cyan|indigo)-\d{2,3}/),
|
|
40
|
+
pattern("no-unloaded-font-weight", "font-bold / extrabold / black — the type scale stops at semibold until decision D9; use font-semibold", /(?<![\w-])(?:[\w-]+:)*font-(?:bold|extrabold|black)(?![\w-])/),
|
|
41
|
+
pattern("no-arbitrary-design-value", "arbitrary shadow / radius / hex colour — add the value to vue-core theme.css and use its utility", /(?<![\w-])(?:[\w-]+:)*(?:shadow-\[[^\]]+\]|rounded(?:-[trblse]{1,2})?-\[[^\]]+\]|(?:bg|text|border|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\])/),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
presentation: {
|
|
45
|
+
// the .vue files of the features folder
|
|
46
|
+
applies: ({ file, isVue, featuresDir }) => isVue && file.startsWith(`${featuresDir}/`),
|
|
47
|
+
rules: [
|
|
48
|
+
{
|
|
49
|
+
id: "no-field-editable-in-group",
|
|
50
|
+
hint: "a shared field with its own `editable` in a grouped form — put read mode on the FormGroup / ResourceForm (`:editable`) and lock a single value with `disabled`",
|
|
51
|
+
test: (_content, template) => fieldTagsWithEditable(template).length > 0,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "no-adhoc-alert",
|
|
55
|
+
// a class list with an un-prefixed (optionally dark:) bg-{red|amber|green|emerald|blue|orange}-{50|100}, a rounded* and a p*- token
|
|
56
|
+
hint: "coloured alert box — use <Banner type=…> instead of a bg-*-50 rounded p-* div",
|
|
57
|
+
test: (_content, _template, classLists) =>
|
|
58
|
+
classLists.some((toks) => {
|
|
59
|
+
const bg = toks.some((t) => /^(?:dark:)?bg-(?:red|amber|green|emerald|blue|orange)-(?:50|100)(?:\/\d+)?$/.test(t));
|
|
60
|
+
const rounded = toks.some((t) => /^rounded(-.+)?$/.test(t));
|
|
61
|
+
const pad = toks.some((t) => /^p[xytblr]?-/.test(t));
|
|
62
|
+
return bg && rounded && pad;
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "no-raw-table",
|
|
67
|
+
hint: "raw <table> — use <DataTable> (child collections) or <collection-table> (paginated lists)",
|
|
68
|
+
test: (content) => /<table[\s>]/.test(content),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "no-raw-form-control",
|
|
72
|
+
hint: "raw <input>/<select>/<textarea>/<label> in a feature .vue — use the field components",
|
|
73
|
+
test: (_content, template) => /<(input|select|textarea|label)(\s|>|\/)/.test(template),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "no-arbitrary-type-scale",
|
|
77
|
+
hint: "arbitrary text-[N px|rem] or font-[N] — use the type scale (text-2xs…text-3xl, font-medium/semibold; vue-core theme.css)",
|
|
78
|
+
test: (content) => /text-\[\d*\.?\d+(?:px|rem|em)\]|font-\[\d{3}\]/.test(content),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "no-bare-loading-text",
|
|
82
|
+
hint: "the word “Loading” rendered directly — render <AsyncSection>'s skeleton instead",
|
|
83
|
+
test: (_content, template) => /general\.loading/.test(template),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
// a class list with `uppercase`, a micro text size, `rounded*` and `px-*` (a hand-built pill; .label-micro / <MicroLabel> is a label)
|
|
87
|
+
id: "no-adhoc-badge",
|
|
88
|
+
hint: "hand-built uppercase badge — use <Badge tone=…> (@wssto2/vue-core/state)",
|
|
89
|
+
test: (_content, _template, classLists) =>
|
|
90
|
+
classLists.some(
|
|
91
|
+
(toks) =>
|
|
92
|
+
toks.includes("uppercase") &&
|
|
93
|
+
toks.some((t) => /^text-(?:3xs|2xs)$/.test(t)) &&
|
|
94
|
+
toks.some((t) => /^rounded(-.+)?$/.test(t)) &&
|
|
95
|
+
toks.some((t) => /^px-/.test(t)),
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// columns built in a separate .ts must still carry the role in the view
|
|
100
|
+
id: "collection-table-roles",
|
|
101
|
+
hint: "CollectionTable without column `mobile` roles (or with the retired #mobile-row slot) — give the columns primary / accessory / meta roles",
|
|
102
|
+
test: (content, template) =>
|
|
103
|
+
(/<(?:collection-table|CollectionTable)[\s>/]/.test(template) && !/\bmobile:\s*["']/.test(content)) || /(?:#|v-slot:)mobile-row\b/.test(template),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
};
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The `vue-core` dispatcher: `vue-core <command> [options]`. A command is bin/commands/<name>.mjs whose default export is
|
|
2
|
+
// `async (context) => exitCode | void`, where context = { args, config, root, configPath, cwd, out, err } and `config` is that
|
|
3
|
+
// command's own section of vue-core.config.mjs (defaults applied).
|
|
4
|
+
import { ConfigError, loadConfig } from "./config.mjs";
|
|
5
|
+
|
|
6
|
+
/** command name -> its section in the config, and the line `--help` shows */
|
|
7
|
+
export const COMMANDS = {
|
|
8
|
+
lint: { section: "lint", summary: "ESLint with per-file ceilings that only go down" },
|
|
9
|
+
"check-architecture": { section: "architecture", summary: "feature-folder import and layer rules" },
|
|
10
|
+
feature: { section: "generator", summary: "scaffold a CRUD feature folder" },
|
|
11
|
+
"e2e-timings": { section: "e2eTimings", summary: "the slowest specs of a Playwright JSON report" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const usage = () =>
|
|
15
|
+
[
|
|
16
|
+
"Usage: vue-core [--config <path>] <command> [options]",
|
|
17
|
+
"",
|
|
18
|
+
"Commands:",
|
|
19
|
+
...Object.entries(COMMANDS).map(([name, { summary }]) => ` ${name.padEnd(20)}${summary}`),
|
|
20
|
+
"",
|
|
21
|
+
"Options are read from vue-core.config.mjs, found from the current folder upwards (or --config <path>).",
|
|
22
|
+
].join("\n");
|
|
23
|
+
|
|
24
|
+
/** Takes `--config <path>` / `--config=<path>` out of `argv`, wherever it stands. */
|
|
25
|
+
function takeConfigFlag(argv) {
|
|
26
|
+
const rest = [];
|
|
27
|
+
let path;
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
if (argv[i] === "--config") path = argv[++i];
|
|
30
|
+
else if (argv[i].startsWith("--config=")) path = argv[i].slice("--config=".length);
|
|
31
|
+
else rest.push(argv[i]);
|
|
32
|
+
}
|
|
33
|
+
return { path, rest };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @returns {Promise<number>} the exit code */
|
|
37
|
+
export async function main(argv, { cwd = process.cwd(), out = console.log, err = console.error, load = (name) => import(`./commands/${name}.mjs`) } = {}) {
|
|
38
|
+
const { path, rest } = takeConfigFlag(argv);
|
|
39
|
+
const [name, ...args] = rest;
|
|
40
|
+
if (name === "--help" || name === "-h" || name === "help") {
|
|
41
|
+
out(usage());
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
if (!name || !Object.hasOwn(COMMANDS, name)) {
|
|
45
|
+
err(name ? `vue-core: unknown command "${name}"\n\n${usage()}` : usage());
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const { config, root, path: configPath } = await loadConfig({ cwd, path });
|
|
50
|
+
const command = await load(name);
|
|
51
|
+
return (await command.default({ args, config: config[COMMANDS[name].section], root, configPath, cwd, out, err })) ?? 0;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!(error instanceof ConfigError)) throw error;
|
|
54
|
+
err(`vue-core: ${error.message}`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `vue-core check-architecture`: the feature-folder rules (features import no other feature, outside code reaches a feature
|
|
2
|
+
// only through index.ts / routes.ts, the shared layer imports no feature, generated code only through its subpaths, HTTP only in
|
|
3
|
+
// api.ts, one ApiError, single-word folder names, views/ needs index.ts + routes.ts) plus the opt-in packs. Exit 1 on a violation.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { check } from "../architecture/engine.mjs";
|
|
8
|
+
import { PACKS } from "../architecture/packs.mjs";
|
|
9
|
+
|
|
10
|
+
const ALLOW_RULES = ["deep-import", "raw-http", "feature-name", "feature-shape"];
|
|
11
|
+
const DOC_KEYS = ["shared-layer", "api-error"];
|
|
12
|
+
|
|
13
|
+
export default function run({ config, root, configPath, err }) {
|
|
14
|
+
const generated = { dir: "generated", allowed: ["entities/*", "schemas/*", "routes/*"], moved: {}, ...config.generated };
|
|
15
|
+
const settings = { ...config, generated, uiDirs: config.uiDirs ?? [config.featuresDir, "components"] };
|
|
16
|
+
|
|
17
|
+
const problems = [];
|
|
18
|
+
for (const name of settings.packs) if (!Object.hasOwn(PACKS, name)) problems.push(`packs: unknown pack "${name}"; the packs are ${Object.keys(PACKS).join(", ")}`);
|
|
19
|
+
const packRules = settings.packs.filter((name) => Object.hasOwn(PACKS, name)).flatMap((name) => PACKS[name].rules.map((rule) => rule.id));
|
|
20
|
+
for (const rule of Object.keys(settings.allow)) {
|
|
21
|
+
if (!ALLOW_RULES.includes(rule) && !packRules.includes(rule)) problems.push(`allow.${rule}: no such rule; the allowlists are ${[...ALLOW_RULES, ...packRules].join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
for (const key of Object.keys(settings.doc)) if (!DOC_KEYS.includes(key) && !packRules.includes(key)) problems.push(`doc.${key}: no such rule; the doc references are ${[...DOC_KEYS, ...packRules].join(", ")}`);
|
|
24
|
+
for (const rule of settings.rules) if (typeof rule !== "function") problems.push("rules: every entry must be a function ({ file, content, template, imports, isVue, isTest }) => string[]");
|
|
25
|
+
if (!existsSync(path.join(root, settings.featuresDir))) problems.push(`featuresDir: ${path.join(root, settings.featuresDir)} does not exist`);
|
|
26
|
+
if (problems.length > 0) {
|
|
27
|
+
err(`vue-core check-architecture: ${configPath ?? "no config file"}\n${problems.map((problem) => ` ${problem}`).join("\n")}`);
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { violations, warnings } = check({ root, config: settings, configName: configPath ? path.basename(configPath) : "the architecture config" });
|
|
32
|
+
if (warnings.length > 0) err(`Frontend architecture warnings:\n\n${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
|
|
33
|
+
if (violations.length > 0) {
|
|
34
|
+
err(`Frontend architecture violations:\n\n${violations.map((violation) => `- ${violation}`).join("\n")}`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Summarises a Playwright JSON report (the `json` reporter, `e2eTimings.report`): the slowest spec files and tests,
|
|
2
|
+
// so trimming or splitting the e2e suite starts from measurements.
|
|
3
|
+
// vue-core e2e-timings [--report <path>] [--top 20]
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export default function run({ args, config, root, cwd, out, err }) {
|
|
8
|
+
const topArg = args.indexOf("--top");
|
|
9
|
+
const TOP = topArg >= 0 ? Number(args[topArg + 1]) || 15 : 15;
|
|
10
|
+
const reportArg = args.indexOf("--report");
|
|
11
|
+
const REPORT = reportArg >= 0 ? args[reportArg + 1] : config.report;
|
|
12
|
+
// a `--report` is relative to where the command runs, the configured one to the config's folder
|
|
13
|
+
const reportPath = reportArg >= 0 ? path.resolve(cwd, REPORT) : path.resolve(root, REPORT);
|
|
14
|
+
|
|
15
|
+
let report;
|
|
16
|
+
try {
|
|
17
|
+
report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
err(`No ${REPORT} — run the e2e suite first (npm run test:e2e).`);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tests = [];
|
|
24
|
+
function walk(suite, file) {
|
|
25
|
+
const here = suite.file ?? file;
|
|
26
|
+
for (const spec of suite.specs ?? []) {
|
|
27
|
+
for (const test of spec.tests ?? []) {
|
|
28
|
+
const duration = (test.results ?? []).reduce((sum, result) => sum + (result.duration ?? 0), 0);
|
|
29
|
+
tests.push({ file: here, title: spec.title, duration, status: test.status });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const child of suite.suites ?? []) walk(child, here);
|
|
33
|
+
}
|
|
34
|
+
for (const suite of report.suites ?? []) walk(suite, suite.file);
|
|
35
|
+
|
|
36
|
+
const byFile = new Map();
|
|
37
|
+
for (const test of tests) {
|
|
38
|
+
const entry = byFile.get(test.file) ?? { file: test.file, duration: 0, count: 0 };
|
|
39
|
+
entry.duration += test.duration;
|
|
40
|
+
entry.count += 1;
|
|
41
|
+
byFile.set(test.file, entry);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`.padStart(7);
|
|
45
|
+
const total = tests.reduce((sum, test) => sum + test.duration, 0);
|
|
46
|
+
const wall = report.stats?.duration ?? 0;
|
|
47
|
+
|
|
48
|
+
out(`${tests.length} tests, ${seconds(total).trim()} of test time, ${seconds(wall).trim()} wall clock\n`);
|
|
49
|
+
out(`Slowest files (test time, share, tests):`);
|
|
50
|
+
for (const entry of [...byFile.values()].sort((a, b) => b.duration - a.duration).slice(0, TOP)) {
|
|
51
|
+
out(`${seconds(entry.duration)} ${((entry.duration / total) * 100).toFixed(1).padStart(5)}% ${String(entry.count).padStart(3)} ${entry.file}`);
|
|
52
|
+
}
|
|
53
|
+
out(`\nSlowest tests:`);
|
|
54
|
+
for (const test of [...tests].sort((a, b) => b.duration - a.duration).slice(0, TOP)) {
|
|
55
|
+
out(`${seconds(test.duration)} ${test.file} › ${test.title}${test.status === "expected" ? "" : ` [${test.status}]`}`);
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|