@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +26 -0
  3. package/bin/architecture/engine.mjs +225 -0
  4. package/bin/architecture/packs.mjs +107 -0
  5. package/bin/cli.mjs +57 -0
  6. package/bin/commands/check-architecture.mjs +38 -0
  7. package/bin/commands/e2e-timings.mjs +58 -0
  8. package/bin/commands/feature.mjs +179 -0
  9. package/bin/commands/lint.mjs +532 -0
  10. package/bin/config.mjs +72 -0
  11. package/bin/templates/feature-crud/api.ts.tpl +35 -0
  12. package/bin/templates/feature-crud/components/__FEATURE_ENTITY__Modal.vue.tpl +36 -0
  13. package/bin/templates/feature-crud/form.ts.tpl +6 -0
  14. package/bin/templates/feature-crud/index.ts.tpl +7 -0
  15. package/bin/templates/feature-crud/routes.ts.tpl +13 -0
  16. package/bin/templates/feature-crud/tests/scaffold.test.ts.tpl +9 -0
  17. package/bin/templates/feature-crud/types.ts.tpl +3 -0
  18. package/bin/templates/feature-crud/views/Index.vue.tpl +36 -0
  19. package/bin/vue-core.mjs +4 -0
  20. package/dist/config/index.d.ts +97 -0
  21. package/dist/config/index.js +6 -0
  22. package/dist/eslint/index.d.ts +28 -0
  23. package/dist/eslint/index.js +87 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/modules/identity/entities.d.ts +3 -2
  27. package/dist/playwright/access.d.ts +61 -0
  28. package/dist/playwright/envelope.d.ts +37 -0
  29. package/dist/playwright/index.d.ts +13 -0
  30. package/dist/playwright/index.js +403 -0
  31. package/dist/playwright/notifications.d.ts +48 -0
  32. package/dist/playwright/profile.d.ts +35 -0
  33. package/dist/playwright/request.d.ts +14 -0
  34. package/dist/playwright/session.d.ts +48 -0
  35. package/dist/playwright/users.d.ts +57 -0
  36. package/package.json +48 -2
@@ -0,0 +1,532 @@
1
+ // `vue-core lint`: runs ESLint once, enforces errors at zero, and enforces every ratcheted (warn-level) rule against a baseline file.
2
+ //
3
+ // The application's guards are ratchets: a guard fails on a NEW violation and also on a STALE allowlist entry, so the list only
4
+ // shrinks. Type-aware linting produces rules with hundreds of pre-existing hits, where a path set is unmanageable and "turn it
5
+ // on later, in its own PR" means it never turns on. This is the same ratchet expressed as a COUNT: the rule is live today at
6
+ // `warn`, a new violation pushes the count over the ceiling and fails the build, and the existing debt is grandfathered at a
7
+ // number that may only go down.
8
+ //
9
+ // Two modes, because type-aware linting cannot run over the whole tree on every push (it is per file, ~1.5s each, through the
10
+ // Vue parser):
11
+ //
12
+ // vue-core lint untyped, whole tree -> pre-push + CI
13
+ // LINT_TYPED=1 vue-core lint type-aware, CHANGED FILES -> CI (and fine locally)
14
+ //
15
+ // Typed debt is tracked PER FILE (`typedByFile`) so a partial run can judge exactly what it linted; a global count would read a
16
+ // 20-file run as a huge false improvement. Which rules are type-aware is TYPED_RULES of `@wssto2/vue-core/eslint`, the list the
17
+ // application's eslint.config.js enables them from.
18
+ //
19
+ // ESLint is invoked exactly ONCE per run and both the error check and the ratchet read that one result.
20
+ //
21
+ // vue-core lint enforce (fast, untyped)
22
+ // vue-core lint --fix autofix, then enforce
23
+ // vue-core lint --update rewrite the ceilings
24
+ // LINT_TYPED=1 vue-core lint enforce typed, changed files
25
+ // LINT_TYPED=1 vue-core lint --update lower ceilings for those files
26
+ // LINT_TYPED=1 vue-core lint --update --all full reseed
27
+ // --report [--rule=<substr>] [--file=<substr>] [--summary] list the grandfathered debt (read-only)
28
+ // --file=<substr> lint every .ts/.vue file whose path contains <substr> (typed mode), in every mode
29
+ import { execFileSync } from "node:child_process";
30
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
31
+ import path from "node:path";
32
+ import { DEFAULTS } from "../config.mjs";
33
+
34
+ const baselineComment = (commands) => [
35
+ "Count ratchet. Generated by `vue-core lint` — do not hand-edit.",
36
+ "",
37
+ "Every ceiling is keyed BY FILE, then by rule. Per file is deliberate: it is",
38
+ "the stricter ratchet (you cannot add a violation in one file and pay for it",
39
+ "by deleting one in another), and it is what makes a failure actionable — the",
40
+ "driver can print the offending lines for that one file and rule instead of a",
41
+ "bare count with nowhere to look.",
42
+ "",
43
+ "`syntacticByFile` + `patternByFile` are measured by the untyped whole-tree run",
44
+ "and enforced on every push.",
45
+ "",
46
+ "`typedByFile` needs type information (LINT_TYPED=1). The full tree is slow, so",
47
+ "the typed run lints only the files the branch changed and judges exactly those;",
48
+ "the rest keep their recorded ceilings.",
49
+ "",
50
+ "Counts may only go DOWN. Fix, rerun the matching update command, commit the",
51
+ "smaller number. Raising one needs a recorded decision, exactly like adding an",
52
+ "allowlist entry.",
53
+ "",
54
+ `Reseed: ${commands.update} && ${commands.typedUpdate} --all`,
55
+ ];
56
+
57
+ const toPosix = (value) => value.split(path.sep).join("/");
58
+
59
+ /**
60
+ * The commit a typed run measures the branch against. Picking the base matters more than it looks. A fixed preference list
61
+ * ("try origin/main, then main, ...") is wrong: on a long-lived branch the merge-base with main can be hundreds of commits back,
62
+ * and at ~1.5s per file a 443-file scope is 11 minutes — worse than useless. The base has to be what this branch is actually
63
+ * being compared against:
64
+ *
65
+ * 1. LINT_BASE — an explicit ref.
66
+ * 2. GITHUB_BASE_REF — the PR's target branch, when running in CI.
67
+ * 3. @{upstream} — the tracking branch, i.e. what `git push` updates.
68
+ * 4. the NEAREST of the known trunks, by merge-base commit date — never the first one that happens to resolve.
69
+ *
70
+ * Returns null when no base resolves at all (a shallow clone), which makes the caller lint the whole tree: correct, just slow,
71
+ * and rare.
72
+ *
73
+ * @param {(args: string[]) => string} git runs git and returns its trimmed stdout; throws when git fails
74
+ */
75
+ export function resolveBase(git) {
76
+ const commitTime = (ref) => {
77
+ try {
78
+ return Number(git(["show", "-s", "--format=%ct", ref]));
79
+ } catch {
80
+ return 0;
81
+ }
82
+ };
83
+ const mergeBase = (ref) => {
84
+ try {
85
+ return git(["merge-base", "HEAD", ref]) || null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ };
90
+
91
+ // An explicit base wins: a worktree branched off a long-lived branch would
92
+ // otherwise measure against the nearest trunk and lint its whole lead.
93
+ if (process.env.LINT_BASE) {
94
+ const base = mergeBase(process.env.LINT_BASE);
95
+ if (base) return base;
96
+ }
97
+ if (process.env.GITHUB_BASE_REF) {
98
+ const base = mergeBase(`origin/${process.env.GITHUB_BASE_REF}`);
99
+ if (base) return base;
100
+ }
101
+ try {
102
+ const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
103
+ const base = mergeBase(upstream);
104
+ if (base) return base;
105
+ } catch {
106
+ // no tracking branch configured — fall through
107
+ }
108
+
109
+ let best = null;
110
+ for (const trunk of ["origin/main", "main", "origin/dev", "dev", "origin/master", "master"]) {
111
+ const base = mergeBase(trunk);
112
+ if (!base) continue;
113
+ const when = commitTime(base);
114
+ if (!best || when > best.when) best = { base, when };
115
+ }
116
+ return best?.base ?? null;
117
+ }
118
+
119
+ /**
120
+ * The .ts/.vue files under `rootDir` that changed since the branch point, plus uncommitted work (relative to `rootDir`);
121
+ * null when no base resolves. Git prints repo-relative paths, so the folder `rootDir` has inside the repository
122
+ * (`git rev-parse --show-prefix`, "frontend/" for a monorepo, "" at the top) is stripped.
123
+ */
124
+ export function changedFiles(rootDir, generated) {
125
+ const git = (args) => execFileSync("git", args, { cwd: rootDir, encoding: "utf8" }).trim();
126
+
127
+ let prefix;
128
+ try {
129
+ prefix = git(["rev-parse", "--show-prefix"]);
130
+ } catch {
131
+ return null; // not a git checkout: lint the whole tree
132
+ }
133
+ const base = resolveBase(git);
134
+ if (!base) return null;
135
+
136
+ const out = new Set();
137
+ for (const args of [
138
+ ["diff", "--name-only", "--diff-filter=ACMR", base, "--"],
139
+ ["diff", "--name-only", "--diff-filter=ACMR", "--"],
140
+ ["ls-files", "--others", "--exclude-standard", "--full-name"],
141
+ ]) {
142
+ for (const line of git(args).split("\n")) {
143
+ if (!line) continue;
144
+ // Paths are repo-relative; this runs from rootDir.
145
+ if (!line.startsWith(prefix)) continue;
146
+ const rel = line.slice(prefix.length);
147
+ if (!/\.(ts|vue)$/.test(rel)) continue;
148
+ if (generated.some((dir) => rel.startsWith(`${dir}/`))) continue;
149
+ if (existsSync(path.join(rootDir, rel))) out.add(rel);
150
+ }
151
+ }
152
+ return [...out];
153
+ }
154
+
155
+ function collectFiles(dir, extensions, ignoredDirs, acc = []) {
156
+ for (const entry of readdirSync(dir)) {
157
+ if (ignoredDirs.has(entry)) continue;
158
+ const full = path.join(dir, entry);
159
+ if (statSync(full).isDirectory()) collectFiles(full, extensions, ignoredDirs, acc);
160
+ else if (extensions.includes(path.extname(full))) acc.push(full);
161
+ }
162
+ return acc;
163
+ }
164
+
165
+ function sorted(entries) {
166
+ return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)));
167
+ }
168
+
169
+ function sortedByFile(map) {
170
+ return sorted(Object.entries(map).map(([file, rules]) => [file, sorted(Object.entries(rules))]));
171
+ }
172
+
173
+ function totalOf(map) {
174
+ return Object.values(map).reduce((sum, rules) => sum + Object.values(rules).reduce((a, b) => a + b, 0), 0);
175
+ }
176
+
177
+ // Merge rather than replace, so an incremental run never discards ceilings for
178
+ // files it did not look at: entries inside the scope are rewritten from what was
179
+ // just measured, and a file whose debt reached zero drops out entirely.
180
+ function mergeByFile(previous, observed, touchedFiles) {
181
+ if (!touchedFiles) return sortedByFile(observed);
182
+ const merged = { ...previous };
183
+ for (const file of touchedFiles) delete merged[file];
184
+ return sortedByFile({ ...merged, ...observed });
185
+ }
186
+
187
+ /** @returns {Promise<number>} the exit code */
188
+ export default async function lint({ args, config: given, root, out, err }) {
189
+ const log = out;
190
+ const rootDir = root;
191
+ const config = { ...DEFAULTS.lint, ...given };
192
+ const baselineFile = path.resolve(rootDir, config.baselines);
193
+ const { generated, patternCounters, policy } = config;
194
+ const commands = { ...DEFAULTS.lint.commands, ...config.commands };
195
+ const ignoredDirs = new Set(["node_modules", "dist", "coverage", ...generated, ".git", "playwright-report", "test-results"]);
196
+
197
+ const { TYPED_RULE_SET } = await import("@wssto2/vue-core/eslint");
198
+
199
+ const argv = new Set(args);
200
+ const shouldUpdate = argv.has("--update");
201
+ const shouldFix = argv.has("--fix");
202
+
203
+ // --report lists the grandfathered debt itself. Without it the debt is
204
+ // invisible: this prints ERRORS and ratchet failures, while the
205
+ // warn-level violations under a ceiling are only counted — which is right for a
206
+ // gate (they are known and accepted) and useless for anyone trying to pay them
207
+ // down. --report is the read-only view: what is left, where, and what to fix.
208
+ // --rule=<substr> narrow to matching rule ids
209
+ // --file=<substr> narrow to matching paths
210
+ // --summary rankings only, no per-line listing
211
+ const shouldReport = argv.has("--report");
212
+ const summaryOnly = argv.has("--summary");
213
+ const argValue = (name) => {
214
+ const hit = [...argv].find((a) => a.startsWith(`--${name}=`));
215
+ return hit ? hit.slice(name.length + 3) : null;
216
+ };
217
+ const ruleFilter = argValue("rule");
218
+ const fileFilter = argValue("file");
219
+
220
+ // Which mode ESLint ran in — read by the application's eslint.config.js (vueCoreConfig).
221
+ // It decides which half of the baselines is enforceable: the fast path never
222
+ // runs the typed rules, so their counts come back 0 and must NOT be compared
223
+ // (that would look like "everything fixed" and fail on a false improvement).
224
+ const TYPED = process.env.LINT_TYPED === "1";
225
+
226
+ // Typed mode lints only the files the branch changed, because the full tree
227
+ // is slow while 5 files take ~8s — the marginal per-file cost is ~1.5s and
228
+ // the program build is ~7s of that, so volume is the whole problem. `--all`
229
+ // forces the full sweep (used to seed or re-seed the per-file baselines).
230
+ const ALL = argv.has("--all");
231
+
232
+ // Pattern counters cover debt ESLint cannot count itself: an inline
233
+ // `eslint-disable` is invisible to the rule it silences, so a rule kept at
234
+ // `error` with justified per-site exemptions still needs a ceiling on how many
235
+ // exemptions exist. Same ratchet, different source of truth.
236
+ function countPatterns() {
237
+ const byFile = {};
238
+ const messages = {};
239
+ for (const counter of patternCounters) {
240
+ for (const file of collectFiles(rootDir, counter.extensions, ignoredDirs)) {
241
+ const rel = toPosix(path.relative(rootDir, file));
242
+ readFileSync(file, "utf8")
243
+ .split("\n")
244
+ .forEach((line, i) => {
245
+ counter.pattern.lastIndex = 0;
246
+ if (!counter.pattern.test(line)) return;
247
+ ((byFile[rel] ??= {})[counter.name] ??= 0);
248
+ byFile[rel][counter.name]++;
249
+ (messages[`${rel}\u0000${counter.name}`] ??= []).push(`${rel}:${i + 1} ${counter.hint}`);
250
+ });
251
+ }
252
+ }
253
+ return { byFile, messages };
254
+ }
255
+
256
+ // What to lint. Typed runs are scoped because the full tree is slow:
257
+ // --all everything (seeding, or a full audit)
258
+ // --file=<sub> every file whose path contains <sub>, in every mode (check,
259
+ // report, update) — the cheap way to judge the files you touched;
260
+ // a filter that matches nothing lints nothing
261
+ // otherwise the files this branch changed (LINT_BASE=<ref> sets the base)
262
+ function typedScope() {
263
+ if (ALL) return null;
264
+ if (fileFilter) {
265
+ return collectFiles(rootDir, [".ts", ".vue"], ignoredDirs)
266
+ .map((f) => toPosix(path.relative(rootDir, f)))
267
+ .filter((f) => f.includes(fileFilter));
268
+ }
269
+ return changedFiles(rootDir, generated);
270
+ }
271
+
272
+ const scope = TYPED ? typedScope() : null;
273
+ const targets = scope ?? ["."];
274
+
275
+ if (TYPED) {
276
+ if (scope && scope.length === 0) {
277
+ log(
278
+ fileFilter
279
+ ? `lint (typed): no .ts/.vue file path contains "${fileFilter}" — nothing to check`
280
+ : shouldReport
281
+ ? "lint (typed): nothing in scope — pass --file=<substring> or --all"
282
+ : "lint (typed): no changed .ts/.vue files — nothing to check",
283
+ );
284
+ return 0;
285
+ }
286
+ log(scope ? `lint (typed): ${scope.length} file(s)` : "lint (typed): full tree — this takes ~22min");
287
+ }
288
+
289
+ const { loadESLint } = await import("eslint");
290
+ const ESLint = await loadESLint({ useFlatConfig: true });
291
+ const eslint = new ESLint({ cwd: rootDir, fix: shouldFix });
292
+ const results = await eslint.lintFiles(targets);
293
+
294
+ if (shouldFix) await ESLint.outputFixes(results);
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Errors: always zero-tolerance. These are the rules with no outstanding debt.
298
+ // ---------------------------------------------------------------------------
299
+ const errorResults = results
300
+ .map((r) => ({
301
+ ...r,
302
+ messages: r.messages.filter((m) => m.severity === 2),
303
+ warningCount: 0,
304
+ }))
305
+ .filter((r) => r.messages.length > 0);
306
+
307
+ let failed = false;
308
+
309
+ if (errorResults.length > 0) {
310
+ const formatter = await eslint.loadFormatter("stylish");
311
+ err(await formatter.format(errorResults));
312
+ err("eslint: errors must be fixed — they are not ratcheted.\n");
313
+ failed = true;
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Warnings: ratcheted PER FILE against the baseline.
318
+ // ---------------------------------------------------------------------------
319
+ // Per file rather than one number per rule, for two reasons. It is the stricter
320
+ // ratchet — you cannot add a violation in one file and pay for it by deleting
321
+ // one somewhere else. And it is the only way the failure can be actionable: a
322
+ // global "190 (ceiling 189)" cannot say where the new one is, and dumping all
323
+ // 190 locations is not an answer. Scoped to one file and one rule, printing
324
+ // every occurrence IS the answer — the offending lines are few, and your own
325
+ // diff tells you which is yours.
326
+ const byFile = {};
327
+ const messagesFor = {};
328
+ for (const result of results) {
329
+ const rel = toPosix(path.relative(rootDir, result.filePath));
330
+ for (const message of result.messages) {
331
+ if (message.severity !== 1) continue;
332
+ const rule = message.ruleId ?? "(unknown)";
333
+ ((byFile[rel] ??= {})[rule] ??= 0);
334
+ byFile[rel][rule]++;
335
+ (messagesFor[`${rel}\u0000${rule}`] ??= []).push(`${rel}:${message.line}:${message.column} ${message.message}`);
336
+ }
337
+ }
338
+
339
+ function splitByFile(source, wantTyped) {
340
+ const split = {};
341
+ for (const [file, rules] of Object.entries(source)) {
342
+ for (const [rule, count] of Object.entries(rules)) {
343
+ if (TYPED_RULE_SET.has(rule) !== wantTyped) continue;
344
+ (split[file] ??= {})[rule] = count;
345
+ }
346
+ }
347
+ return split;
348
+ }
349
+
350
+ const syntacticByFile = splitByFile(byFile, false);
351
+ const typedByFile = splitByFile(byFile, true);
352
+
353
+ // Pattern counters scan the tree themselves (an inline eslint-disable is
354
+ // invisible to the rule it silences), so they only mean anything on a run that
355
+ // covered the whole tree.
356
+ const fullTree = !scope;
357
+ const { byFile: patternByFile, messages: patternMessages } = fullTree ? countPatterns() : { byFile: {}, messages: {} };
358
+
359
+ const baseline = existsSync(baselineFile) ? JSON.parse(readFileSync(baselineFile, "utf8")) : {};
360
+ const baselineRel = toPosix(path.relative(rootDir, baselineFile));
361
+
362
+ if (shouldReport) {
363
+ const rows = [];
364
+ for (const [key, lines] of Object.entries(messagesFor)) {
365
+ const [file, rule] = key.split("\u0000");
366
+ if (ruleFilter && !rule.includes(ruleFilter)) continue;
367
+ if (fileFilter && !file.includes(fileFilter)) continue;
368
+ rows.push({ file, rule, lines });
369
+ }
370
+ for (const [key, lines] of Object.entries(patternMessages)) {
371
+ const [file, name] = key.split("\u0000");
372
+ if (ruleFilter && !name.includes(ruleFilter)) continue;
373
+ if (fileFilter && !file.includes(fileFilter)) continue;
374
+ rows.push({ file, rule: name, lines });
375
+ }
376
+
377
+ if (rows.length === 0) {
378
+ log("no grandfathered violations match that filter — nothing to pay down");
379
+ return 0;
380
+ }
381
+
382
+ const byRule = {};
383
+ const byFileTotal = {};
384
+ for (const row of rows) {
385
+ byRule[row.rule] = (byRule[row.rule] ?? 0) + row.lines.length;
386
+ byFileTotal[row.file] = (byFileTotal[row.file] ?? 0) + row.lines.length;
387
+ }
388
+ const total = Object.values(byRule).reduce((a, b) => a + b, 0);
389
+
390
+ if (!summaryOnly) {
391
+ // Heaviest files first: that is the order you would actually work in, since
392
+ // one file's violations tend to share a root cause (an untyped helper, a
393
+ // props block with no defaults) and fall together.
394
+ const files = Object.keys(byFileTotal).sort((a, b) => byFileTotal[b] - byFileTotal[a] || a.localeCompare(b));
395
+ for (const file of files) {
396
+ log(`\n${file} (${byFileTotal[file]})`);
397
+ for (const row of rows.filter((r) => r.file === file).sort((a, b) => a.rule.localeCompare(b.rule))) {
398
+ log(` ${row.rule} x${row.lines.length}`);
399
+ for (const line of row.lines) log(` ${line}`);
400
+ }
401
+ }
402
+ }
403
+
404
+ const pad = Math.max(...Object.keys(byRule).map((r) => r.length));
405
+ log(`\n${"-".repeat(pad + 12)}`);
406
+ log(`grandfathered debt: ${total} violation(s) in ${Object.keys(byFileTotal).length} file(s)`);
407
+ log(`${"-".repeat(pad + 12)}`);
408
+ for (const [rule, n] of Object.entries(byRule).sort((a, b) => b[1] - a[1])) {
409
+ log(` ${String(n).padStart(5)} ${rule}`);
410
+ }
411
+ log("\nheaviest files:");
412
+ for (const [file, n] of Object.entries(byFileTotal)
413
+ .sort((a, b) => b[1] - a[1])
414
+ .slice(0, 15)) {
415
+ log(` ${String(n).padStart(5)} ${file}`);
416
+ }
417
+ log(`\nFix some, then: ${TYPED ? commands.typedUpdate : commands.update}`);
418
+ if (!TYPED) {
419
+ const column = Math.max(commands.typedReport.length, commands.typedReportAll.length) + 4;
420
+ log("Type-aware debt (floating promises, unsafe access) needs:");
421
+ log(` ${commands.typedReport.padEnd(column)}# changed files`);
422
+ log(` ${commands.typedReportAll.padEnd(column)}# whole tree, ~22min`);
423
+ }
424
+ return 0;
425
+ }
426
+
427
+ if (shouldUpdate) {
428
+ const updated = {
429
+ _comment: baseline._comment ?? baselineComment(commands),
430
+ // Only the untyped whole-tree run can judge the syntactic and pattern
431
+ // sections; a typed run leaves them exactly as it found them.
432
+ syntacticByFile: TYPED ? (baseline.syntacticByFile ?? {}) : mergeByFile(baseline.syntacticByFile ?? {}, syntacticByFile, null),
433
+ patternByFile: fullTree ? sortedByFile(patternByFile) : (baseline.patternByFile ?? {}),
434
+ typedByFile: TYPED ? mergeByFile(baseline.typedByFile ?? {}, typedByFile, ALL ? null : scope) : (baseline.typedByFile ?? {}),
435
+ };
436
+ mkdirSync(path.dirname(baselineFile), { recursive: true });
437
+ writeFileSync(baselineFile, `${JSON.stringify(updated, null, 2)}\n`);
438
+
439
+ const mode = TYPED ? (ALL ? "typed, full tree" : "typed, changed files") : "fast";
440
+ log(`baselines written to ${baselineRel} (${mode})`);
441
+ if (TYPED) {
442
+ log(` typed debt: ${totalOf(updated.typedByFile)} violation(s) across ${Object.keys(updated.typedByFile).length} file(s)`);
443
+ } else {
444
+ log(` syntactic debt: ${totalOf(updated.syntacticByFile)} violation(s) across ${Object.keys(updated.syntacticByFile).length} file(s)`);
445
+ log(` typed section untouched — refresh it with ${commands.typedUpdate}`);
446
+ }
447
+ return failed ? 1 : 0;
448
+ }
449
+
450
+ const regressions = [];
451
+ const improvements = [];
452
+
453
+ // Compare one file's rule counts against its recorded ceiling. `detail` looks up
454
+ // the actual ESLint messages so a failure can print the offending lines rather
455
+ // than just a number.
456
+ function compareFile(file, current, expected, detail) {
457
+ const rules = new Set([...Object.keys(current), ...Object.keys(expected)]);
458
+ for (const rule of rules) {
459
+ const now = current[rule] ?? 0;
460
+ const max = expected[rule] ?? 0;
461
+ if (now > max) {
462
+ regressions.push({ file, rule, now, max, lines: detail?.[`${file}\u0000${rule}`] ?? [] });
463
+ } else if (now < max) {
464
+ improvements.push({ file, rule, now, max });
465
+ }
466
+ }
467
+ }
468
+
469
+ // The untyped run is the only one that sees the whole tree, so it is the only
470
+ // one that can judge the syntactic and pattern sections. A typed run skips them:
471
+ // it covers a handful of files and would read as a huge false improvement.
472
+ if (!TYPED) {
473
+ const expected = baseline.syntacticByFile ?? {};
474
+ for (const file of new Set([...Object.keys(expected), ...Object.keys(syntacticByFile)])) {
475
+ compareFile(file, syntacticByFile[file] ?? {}, expected[file] ?? {}, messagesFor);
476
+ }
477
+ const expectedPatterns = baseline.patternByFile ?? {};
478
+ for (const file of new Set([...Object.keys(expectedPatterns), ...Object.keys(patternByFile)])) {
479
+ compareFile(file, patternByFile[file] ?? {}, expectedPatterns[file] ?? {}, patternMessages);
480
+ }
481
+ } else {
482
+ // Only the files actually linted are judged — everything outside this run's
483
+ // scope keeps its recorded ceiling untouched.
484
+ const expected = baseline.typedByFile ?? {};
485
+ const linted = scope ?? Object.keys({ ...expected, ...typedByFile });
486
+ for (const file of linted) {
487
+ compareFile(file, typedByFile[file] ?? {}, expected[file] ?? {}, messagesFor);
488
+ }
489
+ }
490
+
491
+ const updateCmd = TYPED ? commands.typedUpdate : commands.update;
492
+
493
+ if (regressions.length > 0) {
494
+ failed = true;
495
+ const added = regressions.reduce((sum, r) => sum + (r.now - r.max), 0);
496
+ err(`\nlint ratchet: ${added} NEW violation(s).\n`);
497
+ err("These rules are `warn` only because existing code predates them. The");
498
+ err("ceiling is grandfathered debt, not headroom — new code lands clean.\n");
499
+ for (const r of regressions) {
500
+ err(` ${r.file}`);
501
+ err(` ${r.rule}: ${r.now}, was ${r.max} (+${r.now - r.max})`);
502
+ // Scoped to one file and one rule the list is short, so print every
503
+ // occurrence with its message. The ratchet cannot know which one you added —
504
+ // your diff can, and these are the candidates.
505
+ for (const line of r.lines) err(` ${line}`);
506
+ err("");
507
+ }
508
+ err("Fix the new one(s). If a violation is unavoidable and reviewed, raising a");
509
+ err(`ceiling is a CODEOWNERS decision — see ${policy}.\n`);
510
+ }
511
+
512
+ if (improvements.length > 0) {
513
+ const totalFixed = improvements.reduce((sum, i) => sum + (i.max - i.now), 0);
514
+ err(`\nlint ratchet: ${totalFixed} violation(s) fixed — lock it in:\n`);
515
+ for (const i of improvements) {
516
+ err(` ${i.file}`);
517
+ err(` ${i.rule}: ${i.max} -> ${i.now}`);
518
+ }
519
+ err(`\n ${updateCmd} && git add ${baselineRel}\n`);
520
+ err("A ratchet that does not tighten is just a warning, so this is a hard");
521
+ err("failure for the same reason a stale allowlist entry is.\n");
522
+ failed = true;
523
+ }
524
+
525
+ if (!failed) {
526
+ const debt = TYPED ? totalOf(typedByFile) : totalOf(syntacticByFile) + totalOf(patternByFile);
527
+ const where = TYPED ? `${results.length} changed file(s)` : "whole tree";
528
+ log(`eslint${TYPED ? " (typed)" : ""}: clean — ${where}, ${debt} grandfathered violation(s) at ceiling, none new`);
529
+ }
530
+
531
+ return failed ? 1 : 0;
532
+ }
package/bin/config.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // Finds, loads and validates `vue-core.config.mjs` for the `vue-core` command. Plain Node, no dependency:
2
+ // the types of the file are src/config (`@wssto2/vue-core/config`).
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ export const CONFIG_FILE = "vue-core.config.mjs";
8
+
9
+ /** The top-level sections, one per command, with their defaults; a command that has defaults adds them here. */
10
+ export const DEFAULTS = {
11
+ lint: {
12
+ baselines: "scripts/lint-baselines.json",
13
+ generated: ["generated"],
14
+ patternCounters: [],
15
+ // What the messages tell the developer to run; an application points them at its own npm scripts.
16
+ commands: {
17
+ update: "vue-core lint --update",
18
+ typedUpdate: "LINT_TYPED=1 vue-core lint --update",
19
+ typedReport: "LINT_TYPED=1 vue-core lint --report",
20
+ typedReportAll: "LINT_TYPED=1 vue-core lint --report --all",
21
+ },
22
+ policy: "the project's lint policy",
23
+ },
24
+ architecture: {
25
+ featuresDir: "features",
26
+ alias: "@/",
27
+ sharedLayers: ["components", "composables"],
28
+ skip: ["dist", "generated", "node_modules"],
29
+ allow: {},
30
+ packs: [],
31
+ rules: [],
32
+ doc: {},
33
+ },
34
+ generator: { template: join(dirname(fileURLToPath(import.meta.url)), "templates", "feature-crud"), out: "features" },
35
+ e2eTimings: { report: "e2e-timings/results.json" },
36
+ };
37
+
38
+ export class ConfigError extends Error {}
39
+
40
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
41
+
42
+ /** The nearest `vue-core.config.mjs` from `from` upwards, or null. */
43
+ export function findConfig(from) {
44
+ for (let dir = resolve(from); ; dir = dirname(dir)) {
45
+ const candidate = join(dir, CONFIG_FILE);
46
+ if (existsSync(candidate)) return candidate;
47
+ if (dirname(dir) === dir) return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {{ cwd: string, path?: string }} options `path` is `--config`; without it the file is searched from `cwd`
53
+ * @returns {Promise<{ path: string | null, root: string, config: typeof DEFAULTS }>} `root` is the config file's folder (`cwd` without a file);
54
+ * no file is valid and gives the defaults
55
+ */
56
+ export async function loadConfig({ cwd, path }) {
57
+ const file = path ? resolve(cwd, path) : findConfig(cwd);
58
+ if (path && !existsSync(file)) throw new ConfigError(`--config ${path}: no such file`);
59
+ let given = {};
60
+ if (file) {
61
+ const module = await import(pathToFileURL(file).href);
62
+ given = module.default;
63
+ if (!isObject(given)) throw new ConfigError(`${file}: the default export must be an object (export default defineConfig({ … }))`);
64
+ const unknown = Object.keys(given).filter((key) => !(key in DEFAULTS));
65
+ if (unknown.length > 0) throw new ConfigError(`${file}: unknown section ${unknown.join(", ")}; the sections are ${Object.keys(DEFAULTS).join(", ")}`);
66
+ for (const [name, section] of Object.entries(given)) {
67
+ if (!isObject(section)) throw new ConfigError(`${file}: "${name}" must be an object`);
68
+ }
69
+ }
70
+ const config = Object.fromEntries(Object.entries(DEFAULTS).map(([name, defaults]) => [name, { ...defaults, ...given[name] }]));
71
+ return { path: file, root: file ? dirname(file) : resolve(cwd), config };
72
+ }
@@ -0,0 +1,35 @@
1
+ import type { HttpClient } from "@wssto2/vue-core/client";
2
+ import { defineCollection, httpList } from "@wssto2/vue-core/collection";
3
+ import { __FEATURE_ROUTE_TABLE__ } from "@/generated/routes/__FEATURE_ROUTE_MODULE__/routes";
4
+ import type { __FEATURE_FORM_TYPE__ } from "./form";
5
+ import type { __FEATURE_ENTITY__ } from "./types";
6
+
7
+ export type __FEATURE_ENTITY__Sort = "name" | "created_at" | "updated_at";
8
+
9
+ // The requests are the route table go-core generates from the Go feature's Contract (ts:generate-routes): the ids
10
+ // are the URL's, the rest of the input is the body.
11
+ export const __FEATURE_API_CLIENT__ = (http: HttpClient) => ({
12
+ list: httpList<__FEATURE_ENTITY__, __FEATURE_ENTITY__Sort, never>(http, __FEATURE_ROUTE_TABLE__.list.path),
13
+
14
+ async get(id: number): Promise<__FEATURE_ENTITY__> {
15
+ return (await http.request(__FEATURE_ROUTE_TABLE__.show, { id })).data;
16
+ },
17
+
18
+ /** A 422 lands on the form's fields (`form.submit`). */
19
+ async create(payload: __FEATURE_FORM_TYPE__, idempotencyKey: string): Promise<__FEATURE_ENTITY__> {
20
+ return (await http.request(__FEATURE_ROUTE_TABLE__.create, payload, { headers: { "Idempotency-Key": idempotencyKey } })).data;
21
+ },
22
+ });
23
+
24
+ export type __FEATURE_ENTITY__Api = ReturnType<typeof __FEATURE_API_CLIENT__>;
25
+
26
+ /** The list: the list page and a record's previous / next both read this one definition. */
27
+ export const __FEATURE_LIST__ = (api: __FEATURE_ENTITY__Api) =>
28
+ defineCollection({
29
+ id: "__FEATURE_I18N_MODULE__",
30
+ stateVersion: 1,
31
+ load: api.list,
32
+ key: (item: __FEATURE_ENTITY__) => item.id ?? 0, // generated entities type `id` as nullable
33
+ query: { sorts: ["name", "created_at", "updated_at"], filters: [] },
34
+ defaults: { sort: "created_at", direction: "asc" },
35
+ });
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { TextField, useForm } from "@wssto2/vue-core/form";
3
+ import { Modal } from "@wssto2/vue-core/modal";
4
+ import { usePlatform } from "@wssto2/vue-core/platform";
5
+ import { ref } from "vue";
6
+ import { useI18n } from "vue-i18n";
7
+ import { __FEATURE_API_CLIENT__ } from "../api";
8
+ import { __FEATURE_FORM_SCHEMA__ } from "../form";
9
+
10
+ const emit = defineEmits<{ changed: [] }>();
11
+ const { t } = useI18n();
12
+ const api = __FEATURE_API_CLIENT__(usePlatform().http);
13
+
14
+ const modal = ref<InstanceType<typeof Modal> | null>(null);
15
+ const form = useForm({ defaults: () => ({ name: "" }), validator: __FEATURE_FORM_SCHEMA__ });
16
+
17
+ async function save() {
18
+ const result = await form.submit((payload, { idempotencyKey }) => api.create(payload, idempotencyKey));
19
+ if (result.status !== "saved") return;
20
+ void modal.value?.dismiss();
21
+ emit("changed");
22
+ }
23
+
24
+ function show() {
25
+ form.reset();
26
+ modal.value?.present();
27
+ }
28
+
29
+ defineExpose({ show });
30
+ </script>
31
+
32
+ <template>
33
+ <Modal ref="modal" :title="t('__FEATURE_I18N_MODULE__.actions.create')" :primary-label="t('general.actions.save')" :status="form.submitting.value ? 'processing' : 'idle'" @primary="save">
34
+ <TextField v-bind="form.bind('name')" :label="t('__FEATURE_I18N_MODULE__.table_columns.name')" />
35
+ </Modal>
36
+ </template>