uidex 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/cli/cli.cjs +2502 -2253
- package/dist/cli/cli.cjs.map +1 -1
- package/dist/headless/index.cjs +86 -703
- package/dist/headless/index.cjs.map +1 -1
- package/dist/headless/index.d.cts +46 -22
- package/dist/headless/index.d.ts +46 -22
- package/dist/headless/index.js +86 -707
- package/dist/headless/index.js.map +1 -1
- package/dist/index.cjs +712 -4149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -366
- package/dist/index.d.ts +82 -366
- package/dist/index.js +708 -4156
- package/dist/index.js.map +1 -1
- package/dist/playwright/index.cjs +175 -0
- package/dist/playwright/index.cjs.map +1 -1
- package/dist/playwright/index.d.cts +2 -0
- package/dist/playwright/index.d.ts +2 -0
- package/dist/playwright/index.js +167 -0
- package/dist/playwright/index.js.map +1 -1
- package/dist/playwright/states-reporter.cjs +123 -0
- package/dist/playwright/states-reporter.cjs.map +1 -0
- package/dist/playwright/states-reporter.d.cts +46 -0
- package/dist/playwright/states-reporter.d.ts +46 -0
- package/dist/playwright/states-reporter.js +88 -0
- package/dist/playwright/states-reporter.js.map +1 -0
- package/dist/playwright/states.cjs +118 -0
- package/dist/playwright/states.cjs.map +1 -0
- package/dist/playwright/states.d.cts +120 -0
- package/dist/playwright/states.d.ts +120 -0
- package/dist/playwright/states.js +88 -0
- package/dist/playwright/states.js.map +1 -0
- package/dist/react/index.cjs +750 -4113
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +78 -278
- package/dist/react/index.d.ts +78 -278
- package/dist/react/index.js +748 -4135
- package/dist/react/index.js.map +1 -1
- package/dist/scan/index.cjs +2694 -1541
- package/dist/scan/index.cjs.map +1 -1
- package/dist/scan/index.d.cts +482 -19
- package/dist/scan/index.d.ts +482 -19
- package/dist/scan/index.js +2682 -1540
- package/dist/scan/index.js.map +1 -1
- package/package.json +14 -17
- package/templates/claude/SKILL.md +71 -0
- package/templates/claude/references/audit.md +43 -0
- package/templates/claude/{rules.md → references/conventions.md} +25 -28
- package/dist/cloud/index.cjs +0 -472
- package/dist/cloud/index.cjs.map +0 -1
- package/dist/cloud/index.d.cts +0 -82
- package/dist/cloud/index.d.ts +0 -82
- package/dist/cloud/index.js +0 -445
- package/dist/cloud/index.js.map +0 -1
- package/templates/claude/audit.md +0 -43
- /package/templates/claude/{api.md → references/api.md} +0 -0
package/dist/scan/index.js
CHANGED
|
@@ -3,7 +3,6 @@ import * as fs from "fs";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
|
|
5
5
|
// src/scanner/scan/config.ts
|
|
6
|
-
var DEFAULT_TYPE_MODE = "strict";
|
|
7
6
|
var WELL_KNOWN_FILES = {
|
|
8
7
|
page: "uidex.page.ts",
|
|
9
8
|
feature: "uidex.feature.ts"
|
|
@@ -28,11 +27,11 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
|
28
27
|
"exclude",
|
|
29
28
|
"output",
|
|
30
29
|
"flows",
|
|
31
|
-
"typeMode",
|
|
32
30
|
"audit",
|
|
33
|
-
"conventions"
|
|
31
|
+
"conventions",
|
|
32
|
+
"statesManifest",
|
|
33
|
+
"coverageBaseline"
|
|
34
34
|
]);
|
|
35
|
-
var ALLOWED_TYPE_MODES = /* @__PURE__ */ new Set(["strict", "loose"]);
|
|
36
35
|
var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
|
|
37
36
|
var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
|
|
38
37
|
"primitives",
|
|
@@ -41,18 +40,25 @@ var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
|
|
|
41
40
|
"flows",
|
|
42
41
|
"regions"
|
|
43
42
|
]);
|
|
44
|
-
var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
|
|
43
|
+
var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
|
|
44
|
+
"scopeLeak",
|
|
45
|
+
"coverage",
|
|
46
|
+
"acceptance",
|
|
47
|
+
"states",
|
|
48
|
+
"pageCoverage",
|
|
49
|
+
"stateMatrix"
|
|
50
|
+
]);
|
|
45
51
|
function fail(msg) {
|
|
46
52
|
throw new ConfigError(`Invalid .uidex.json: ${msg}`);
|
|
47
53
|
}
|
|
48
|
-
function assertObject(value,
|
|
54
|
+
function assertObject(value, path12) {
|
|
49
55
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
50
|
-
fail(`${
|
|
56
|
+
fail(`${path12} must be an object`);
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
|
-
function assertStringArray(value,
|
|
59
|
+
function assertStringArray(value, path12) {
|
|
54
60
|
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
55
|
-
fail(`${
|
|
61
|
+
fail(`${path12} must be a string[]`);
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
function validateConfig(raw) {
|
|
@@ -100,10 +106,11 @@ function validateConfig(raw) {
|
|
|
100
106
|
}
|
|
101
107
|
if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
|
|
102
108
|
if (raw.flows !== void 0) assertStringArray(raw.flows, `flows`);
|
|
103
|
-
if (raw.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
if (raw.statesManifest !== void 0 && typeof raw.statesManifest !== "string") {
|
|
110
|
+
fail(`"statesManifest" must be a string`);
|
|
111
|
+
}
|
|
112
|
+
if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
|
|
113
|
+
fail(`"coverageBaseline" must be a string`);
|
|
107
114
|
}
|
|
108
115
|
if (raw.audit !== void 0) {
|
|
109
116
|
assertObject(raw.audit, "audit");
|
|
@@ -143,9 +150,10 @@ function validateConfig(raw) {
|
|
|
143
150
|
exclude: raw.exclude,
|
|
144
151
|
output: raw.output,
|
|
145
152
|
flows: raw.flows,
|
|
146
|
-
typeMode: raw.typeMode ?? DEFAULT_TYPE_MODE,
|
|
147
153
|
audit: raw.audit,
|
|
148
|
-
conventions: raw.conventions
|
|
154
|
+
conventions: raw.conventions,
|
|
155
|
+
statesManifest: raw.statesManifest,
|
|
156
|
+
coverageBaseline: raw.coverageBaseline
|
|
149
157
|
};
|
|
150
158
|
}
|
|
151
159
|
function parseConfig(json) {
|
|
@@ -234,7 +242,12 @@ var BASE_EXCLUDES = [
|
|
|
234
242
|
"**/dist/**",
|
|
235
243
|
"**/build/**",
|
|
236
244
|
"**/.next/**",
|
|
237
|
-
|
|
245
|
+
// Exclude the scanner's own generated siblings so a scan never re-ingests its
|
|
246
|
+
// output. Enumerated (not `*.gen.*`) so a hand-written file that merely contains
|
|
247
|
+
// a `.gen.` segment, e.g. `foo.gen.helpers.ts`, is still scanned.
|
|
248
|
+
"**/*.gen.ts",
|
|
249
|
+
"**/*.gen.data.ts",
|
|
250
|
+
"**/*.gen.locs.ts"
|
|
238
251
|
];
|
|
239
252
|
var TEST_EXCLUDES = [
|
|
240
253
|
"**/*.test.ts",
|
|
@@ -350,6 +363,285 @@ function* walkDir(root, dir) {
|
|
|
350
363
|
}
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
// src/scanner/scan/ast.ts
|
|
367
|
+
import * as path3 from "path";
|
|
368
|
+
import { parseSync } from "oxc-parser";
|
|
369
|
+
function langFor(sourcePath) {
|
|
370
|
+
switch (path3.extname(sourcePath)) {
|
|
371
|
+
case ".tsx":
|
|
372
|
+
return "tsx";
|
|
373
|
+
case ".ts":
|
|
374
|
+
case ".mts":
|
|
375
|
+
case ".cts":
|
|
376
|
+
return "ts";
|
|
377
|
+
default:
|
|
378
|
+
return "jsx";
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function makeLineAt(content) {
|
|
382
|
+
const starts = [0];
|
|
383
|
+
for (let i = 0; i < content.length; i++) {
|
|
384
|
+
if (content[i] === "\n") starts.push(i + 1);
|
|
385
|
+
}
|
|
386
|
+
return (offset) => {
|
|
387
|
+
let lo = 0;
|
|
388
|
+
let hi = starts.length - 1;
|
|
389
|
+
while (lo < hi) {
|
|
390
|
+
const mid = lo + hi + 1 >> 1;
|
|
391
|
+
if (starts[mid] <= offset) lo = mid;
|
|
392
|
+
else hi = mid - 1;
|
|
393
|
+
}
|
|
394
|
+
return lo + 1;
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function parseSource(file) {
|
|
398
|
+
const lineAt = makeLineAt(file.content);
|
|
399
|
+
try {
|
|
400
|
+
const result = parseSync(file.sourcePath, file.content, {
|
|
401
|
+
lang: langFor(file.sourcePath),
|
|
402
|
+
sourceType: "module"
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
program: result.program,
|
|
406
|
+
hasErrors: result.errors.length > 0,
|
|
407
|
+
comments: result.comments ?? [],
|
|
408
|
+
lineAt
|
|
409
|
+
};
|
|
410
|
+
} catch {
|
|
411
|
+
return { program: null, hasErrors: true, comments: [], lineAt };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function isNode(value) {
|
|
415
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
416
|
+
}
|
|
417
|
+
function walkAst(root, visit) {
|
|
418
|
+
if (!isNode(root)) return;
|
|
419
|
+
if (visit(root) === false) return;
|
|
420
|
+
for (const key of Object.keys(root)) {
|
|
421
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
422
|
+
const value = root[key];
|
|
423
|
+
if (Array.isArray(value)) {
|
|
424
|
+
for (const item of value) walkAst(item, visit);
|
|
425
|
+
} else if (isNode(value)) {
|
|
426
|
+
walkAst(value, visit);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function unwrapTsExpression(expr) {
|
|
431
|
+
let node = expr;
|
|
432
|
+
for (; ; ) {
|
|
433
|
+
switch (node.type) {
|
|
434
|
+
case "TSAsExpression":
|
|
435
|
+
case "TSSatisfiesExpression":
|
|
436
|
+
case "TSNonNullExpression":
|
|
437
|
+
case "TSTypeAssertion":
|
|
438
|
+
case "ParenthesizedExpression": {
|
|
439
|
+
const inner = node.expression;
|
|
440
|
+
if (!isNode(inner)) return node;
|
|
441
|
+
node = inner;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
default:
|
|
445
|
+
return node;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/shared/entities/types.ts
|
|
451
|
+
var ENTITY_KINDS = [
|
|
452
|
+
"route",
|
|
453
|
+
"page",
|
|
454
|
+
"feature",
|
|
455
|
+
"widget",
|
|
456
|
+
"region",
|
|
457
|
+
"element",
|
|
458
|
+
"primitive",
|
|
459
|
+
"flow"
|
|
460
|
+
];
|
|
461
|
+
var CORE_STATE_KINDS = [
|
|
462
|
+
"loading",
|
|
463
|
+
"empty",
|
|
464
|
+
"populated",
|
|
465
|
+
"error"
|
|
466
|
+
];
|
|
467
|
+
function isMetaKind(kind) {
|
|
468
|
+
return kind !== "route" && kind !== "flow";
|
|
469
|
+
}
|
|
470
|
+
function isMetaEntity(entity) {
|
|
471
|
+
return isMetaKind(entity.kind);
|
|
472
|
+
}
|
|
473
|
+
function entityKey(entity) {
|
|
474
|
+
return entity.kind === "route" ? entity.path : entity.id;
|
|
475
|
+
}
|
|
476
|
+
var UnknownEntityKindError = class extends Error {
|
|
477
|
+
kind;
|
|
478
|
+
constructor(kind) {
|
|
479
|
+
super(`Unknown entity kind: ${kind}`);
|
|
480
|
+
this.name = "UnknownEntityKindError";
|
|
481
|
+
this.kind = kind;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
var KIND_SET = new Set(ENTITY_KINDS);
|
|
485
|
+
function assertEntityKind(kind) {
|
|
486
|
+
if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/shared/entities/registry.ts
|
|
490
|
+
function emptyStore() {
|
|
491
|
+
return {
|
|
492
|
+
route: /* @__PURE__ */ new Map(),
|
|
493
|
+
page: /* @__PURE__ */ new Map(),
|
|
494
|
+
feature: /* @__PURE__ */ new Map(),
|
|
495
|
+
widget: /* @__PURE__ */ new Map(),
|
|
496
|
+
region: /* @__PURE__ */ new Map(),
|
|
497
|
+
element: /* @__PURE__ */ new Map(),
|
|
498
|
+
primitive: /* @__PURE__ */ new Map(),
|
|
499
|
+
flow: /* @__PURE__ */ new Map()
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function computeFlowIds(flows, targetId) {
|
|
503
|
+
const ids = [];
|
|
504
|
+
for (const flow of flows) {
|
|
505
|
+
if (flow.touches.includes(targetId)) ids.push(flow.id);
|
|
506
|
+
}
|
|
507
|
+
return ids;
|
|
508
|
+
}
|
|
509
|
+
function freezeEntity(entity, flows) {
|
|
510
|
+
if (!isMetaKind(entity.kind)) return entity;
|
|
511
|
+
const withMeta = entity;
|
|
512
|
+
if (withMeta.meta === void 0) return entity;
|
|
513
|
+
const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
|
|
514
|
+
const mergedMeta = { ...withMeta.meta, flows: computedFlows };
|
|
515
|
+
return { ...entity, meta: Object.freeze(mergedMeta) };
|
|
516
|
+
}
|
|
517
|
+
function createRegistry() {
|
|
518
|
+
const store = emptyStore();
|
|
519
|
+
let flowsCache = null;
|
|
520
|
+
const patternCache = /* @__PURE__ */ new Map();
|
|
521
|
+
const getFlows = () => {
|
|
522
|
+
if (flowsCache === null) flowsCache = Array.from(store.flow.values());
|
|
523
|
+
return flowsCache;
|
|
524
|
+
};
|
|
525
|
+
const add = (entity) => {
|
|
526
|
+
assertEntityKind(entity.kind);
|
|
527
|
+
const key = entityKey(entity);
|
|
528
|
+
store[entity.kind].set(key, entity);
|
|
529
|
+
flowsCache = null;
|
|
530
|
+
patternCache.delete(entity.kind);
|
|
531
|
+
};
|
|
532
|
+
const get = (kind, id) => {
|
|
533
|
+
assertEntityKind(kind);
|
|
534
|
+
const raw = store[kind].get(id);
|
|
535
|
+
if (raw === void 0) return void 0;
|
|
536
|
+
return freezeEntity(raw, getFlows());
|
|
537
|
+
};
|
|
538
|
+
const getPatternsForKind = (kind) => {
|
|
539
|
+
const cached = patternCache.get(kind);
|
|
540
|
+
if (cached !== void 0) return cached;
|
|
541
|
+
const patterns = [];
|
|
542
|
+
for (const [key, entity] of store[kind]) {
|
|
543
|
+
if (key.includes("*")) {
|
|
544
|
+
const segments = key.split("*");
|
|
545
|
+
patterns.push({
|
|
546
|
+
segments,
|
|
547
|
+
staticLength: segments.reduce((n, s) => n + s.length, 0),
|
|
548
|
+
entity
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
patternCache.set(
|
|
553
|
+
kind,
|
|
554
|
+
patterns
|
|
555
|
+
);
|
|
556
|
+
return patterns;
|
|
557
|
+
};
|
|
558
|
+
const matchesSegments = (segments, id) => {
|
|
559
|
+
const first = segments[0];
|
|
560
|
+
const last = segments[segments.length - 1];
|
|
561
|
+
if (!id.startsWith(first)) return false;
|
|
562
|
+
let pos = first.length;
|
|
563
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
564
|
+
const idx = id.indexOf(segments[i], pos);
|
|
565
|
+
if (idx === -1) return false;
|
|
566
|
+
pos = idx + segments[i].length;
|
|
567
|
+
}
|
|
568
|
+
return id.endsWith(last) && id.length - last.length >= pos;
|
|
569
|
+
};
|
|
570
|
+
const matchPattern = (kind, id) => {
|
|
571
|
+
assertEntityKind(kind);
|
|
572
|
+
const patterns = getPatternsForKind(kind);
|
|
573
|
+
if (patterns.length === 0) return void 0;
|
|
574
|
+
let best;
|
|
575
|
+
for (const entry of patterns) {
|
|
576
|
+
if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
|
|
577
|
+
best = entry;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (best === void 0) return void 0;
|
|
581
|
+
return freezeEntity(best.entity, getFlows());
|
|
582
|
+
};
|
|
583
|
+
const list = (kind) => {
|
|
584
|
+
assertEntityKind(kind);
|
|
585
|
+
const flows = getFlows();
|
|
586
|
+
return Array.from(
|
|
587
|
+
store[kind].values(),
|
|
588
|
+
(e) => freezeEntity(e, flows)
|
|
589
|
+
);
|
|
590
|
+
};
|
|
591
|
+
const allEntities = function* () {
|
|
592
|
+
for (const kind of Object.keys(store)) {
|
|
593
|
+
for (const entity of store[kind].values()) {
|
|
594
|
+
yield entity;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
const query = (predicate) => {
|
|
599
|
+
const flows = getFlows();
|
|
600
|
+
const result = [];
|
|
601
|
+
for (const entity of allEntities()) {
|
|
602
|
+
if (predicate(entity)) result.push(freezeEntity(entity, flows));
|
|
603
|
+
}
|
|
604
|
+
return result;
|
|
605
|
+
};
|
|
606
|
+
const byScope = (scope) => query(
|
|
607
|
+
(entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
|
|
608
|
+
);
|
|
609
|
+
const touchedBy = (flowId) => {
|
|
610
|
+
const flow = store.flow.get(flowId);
|
|
611
|
+
if (flow === void 0) return [];
|
|
612
|
+
const ids = new Set(flow.touches);
|
|
613
|
+
return query((entity) => {
|
|
614
|
+
if (!isMetaEntity(entity)) return false;
|
|
615
|
+
return ids.has(entity.id);
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
const reports = /* @__PURE__ */ new Map();
|
|
619
|
+
const reportsCbs = /* @__PURE__ */ new Set();
|
|
620
|
+
const setReports = (kind, id, records) => {
|
|
621
|
+
reports.set(`${kind}:${id}`, records);
|
|
622
|
+
for (const cb of reportsCbs) cb();
|
|
623
|
+
};
|
|
624
|
+
const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
|
|
625
|
+
const listReportKeys = () => Array.from(reports.keys());
|
|
626
|
+
const onReportsChange = (cb) => {
|
|
627
|
+
reportsCbs.add(cb);
|
|
628
|
+
return () => reportsCbs.delete(cb);
|
|
629
|
+
};
|
|
630
|
+
return {
|
|
631
|
+
add,
|
|
632
|
+
get,
|
|
633
|
+
matchPattern,
|
|
634
|
+
list,
|
|
635
|
+
query,
|
|
636
|
+
byScope,
|
|
637
|
+
touchedBy,
|
|
638
|
+
setReports,
|
|
639
|
+
getReports,
|
|
640
|
+
listReportKeys,
|
|
641
|
+
onReportsChange
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
353
645
|
// src/scanner/scan/extract-uidex-export.ts
|
|
354
646
|
var KIND_DISCRIMINATORS = [
|
|
355
647
|
"page",
|
|
@@ -367,26 +659,51 @@ var ALLOWED_FIELDS = {
|
|
|
367
659
|
"features",
|
|
368
660
|
"widgets",
|
|
369
661
|
"acceptance",
|
|
662
|
+
"states",
|
|
663
|
+
"capture",
|
|
664
|
+
"waivers",
|
|
370
665
|
"description"
|
|
371
666
|
]),
|
|
667
|
+
// `capture` / `waivers` are route-scoped, and only a page maps to a route — so
|
|
668
|
+
// they are page-only (the gates read them off the page). A feature/widget that
|
|
669
|
+
// sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
|
|
372
670
|
feature: /* @__PURE__ */ new Set([
|
|
373
671
|
"feature",
|
|
374
672
|
"name",
|
|
375
673
|
"features",
|
|
376
674
|
"acceptance",
|
|
675
|
+
"states",
|
|
377
676
|
"description"
|
|
378
677
|
]),
|
|
379
678
|
primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
|
|
380
|
-
widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
|
|
679
|
+
widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
|
|
381
680
|
region: /* @__PURE__ */ new Set(["region", "name", "description"]),
|
|
382
681
|
flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
|
|
383
682
|
};
|
|
683
|
+
var STATE_FIELDS = /* @__PURE__ */ new Set([
|
|
684
|
+
"name",
|
|
685
|
+
"kind",
|
|
686
|
+
"acceptance",
|
|
687
|
+
"description"
|
|
688
|
+
]);
|
|
689
|
+
var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
|
|
690
|
+
var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
|
|
384
691
|
var FALSEABLE = /* @__PURE__ */ new Set([
|
|
385
692
|
"page",
|
|
386
693
|
"feature",
|
|
387
694
|
"primitive",
|
|
388
695
|
"region"
|
|
389
696
|
]);
|
|
697
|
+
var SATISFIES_NAMES = {
|
|
698
|
+
page: "Page",
|
|
699
|
+
feature: "Feature",
|
|
700
|
+
primitive: "Primitive",
|
|
701
|
+
widget: "Widget",
|
|
702
|
+
region: "Region",
|
|
703
|
+
flow: "Flow",
|
|
704
|
+
notFlow: "NotFlow"
|
|
705
|
+
};
|
|
706
|
+
var KNOWN_SATISFIES = new Set(Object.values(SATISFIES_NAMES));
|
|
390
707
|
var ExtractError = class extends Error {
|
|
391
708
|
code;
|
|
392
709
|
hint;
|
|
@@ -398,649 +715,285 @@ var ExtractError = class extends Error {
|
|
|
398
715
|
this.hint = hint;
|
|
399
716
|
}
|
|
400
717
|
};
|
|
401
|
-
function extractUidexExports(file) {
|
|
718
|
+
function extractUidexExports(file, parsed) {
|
|
402
719
|
const exports = [];
|
|
403
720
|
const diagnostics = [];
|
|
404
721
|
const { content, displayPath } = file;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
722
|
+
const p2 = parsed ?? parseSource(file);
|
|
723
|
+
if (p2.program === null) return { exports, diagnostics };
|
|
724
|
+
for (const stmt of p2.program.body) {
|
|
725
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
726
|
+
const decl = stmt.declaration;
|
|
727
|
+
if (!isNode2(decl) || decl.type !== "VariableDeclaration") continue;
|
|
728
|
+
if (decl.kind !== "const") continue;
|
|
729
|
+
for (const declarator of decl.declarations ?? []) {
|
|
730
|
+
const id = declarator.id;
|
|
731
|
+
if (!id || id.type !== "Identifier" || String(id.name) !== "uidex") {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const headerPos = posAt(content, stmt.start, p2);
|
|
735
|
+
try {
|
|
736
|
+
const init = declarator.init;
|
|
737
|
+
if (!isNode2(init)) {
|
|
738
|
+
throw new ExtractError(
|
|
739
|
+
"uidex-export-invalid-literal",
|
|
740
|
+
"`export const uidex` must be assigned an object literal.",
|
|
741
|
+
headerPos
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
const value = toLitValue(unwrapTsExpression(init), content, p2);
|
|
745
|
+
const metadata = buildMetadata(
|
|
746
|
+
value,
|
|
747
|
+
displayPath,
|
|
748
|
+
file.sourcePath,
|
|
749
|
+
headerPos,
|
|
750
|
+
diagnostics
|
|
751
|
+
);
|
|
752
|
+
metadata.span = statementSpan(stmt, content);
|
|
753
|
+
checkSatisfies(init, metadata, displayPath, p2, diagnostics);
|
|
754
|
+
exports.push(metadata);
|
|
755
|
+
} catch (e) {
|
|
756
|
+
if (e instanceof ExtractError) {
|
|
757
|
+
diagnostics.push({
|
|
758
|
+
code: e.code,
|
|
759
|
+
severity: "error",
|
|
760
|
+
message: e.message,
|
|
761
|
+
file: displayPath,
|
|
762
|
+
line: e.pos.line,
|
|
763
|
+
hint: e.hint
|
|
764
|
+
});
|
|
765
|
+
} else {
|
|
766
|
+
throw e;
|
|
767
|
+
}
|
|
427
768
|
}
|
|
428
769
|
}
|
|
429
770
|
}
|
|
430
771
|
return { exports, diagnostics };
|
|
431
772
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
i++;
|
|
462
|
-
continue;
|
|
773
|
+
function toLitValue(node, content, p2) {
|
|
774
|
+
const unwrapped = unwrapTsExpression(node);
|
|
775
|
+
const pos = posAt(content, unwrapped.start, p2);
|
|
776
|
+
const span = { start: unwrapped.start, end: unwrapped.end };
|
|
777
|
+
switch (unwrapped.type) {
|
|
778
|
+
case "Literal": {
|
|
779
|
+
const v = unwrapped.value;
|
|
780
|
+
if (typeof v === "string") return { kind: "string", value: v, pos, span };
|
|
781
|
+
if (typeof v === "number") {
|
|
782
|
+
if (!Number.isFinite(v)) {
|
|
783
|
+
throw new ExtractError(
|
|
784
|
+
"uidex-export-invalid-literal",
|
|
785
|
+
`Invalid numeric literal in \`export const uidex\`.`,
|
|
786
|
+
pos
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
return { kind: "number", value: v, pos, span };
|
|
790
|
+
}
|
|
791
|
+
if (typeof v === "boolean") {
|
|
792
|
+
return { kind: "boolean", value: v, pos, span };
|
|
793
|
+
}
|
|
794
|
+
if (v === null && unwrapped.raw === "null") {
|
|
795
|
+
return { kind: "null", pos, span };
|
|
796
|
+
}
|
|
797
|
+
throw new ExtractError(
|
|
798
|
+
"uidex-export-invalid-literal",
|
|
799
|
+
`Unsupported literal in \`export const uidex\`; only strings, numbers, booleans, and null are allowed.`,
|
|
800
|
+
pos
|
|
801
|
+
);
|
|
463
802
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
continue;
|
|
803
|
+
case "UnaryExpression": {
|
|
804
|
+
const arg = unwrapped.argument;
|
|
805
|
+
if (unwrapped.operator === "-" && isNode2(arg) && arg.type === "Literal" && typeof arg.value === "number") {
|
|
806
|
+
return { kind: "number", value: -arg.value, pos, span };
|
|
469
807
|
}
|
|
470
|
-
|
|
471
|
-
|
|
808
|
+
throw new ExtractError(
|
|
809
|
+
"uidex-export-invalid-literal",
|
|
810
|
+
"Unary expressions are not allowed in `export const uidex`.",
|
|
811
|
+
pos
|
|
812
|
+
);
|
|
472
813
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
814
|
+
case "TemplateLiteral": {
|
|
815
|
+
const expressions = unwrapped.expressions ?? [];
|
|
816
|
+
if (expressions.length > 0) {
|
|
817
|
+
throw new ExtractError(
|
|
818
|
+
"uidex-export-invalid-literal",
|
|
819
|
+
"Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
|
|
820
|
+
pos
|
|
821
|
+
);
|
|
477
822
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
823
|
+
const quasis = unwrapped.quasis ?? [];
|
|
824
|
+
const cooked = quasis[0]?.value?.cooked ?? "";
|
|
825
|
+
return { kind: "string", value: cooked, pos, span };
|
|
481
826
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
492
|
-
if (c === "`" && templateDepth === 0) {
|
|
493
|
-
inTemplate = false;
|
|
494
|
-
i++;
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
if (templateDepth > 0 && c === "}") {
|
|
498
|
-
templateDepth--;
|
|
499
|
-
i++;
|
|
500
|
-
continue;
|
|
827
|
+
case "Identifier": {
|
|
828
|
+
const name = String(unwrapped.name);
|
|
829
|
+
if (name === "undefined") {
|
|
830
|
+
throw new ExtractError(
|
|
831
|
+
"uidex-export-invalid-literal",
|
|
832
|
+
"`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
|
|
833
|
+
pos
|
|
834
|
+
);
|
|
501
835
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
i += 2;
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
if (c === "/" && n === "*") {
|
|
511
|
-
inBlockComment = true;
|
|
512
|
-
i += 2;
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
if (c === '"' || c === "'") {
|
|
516
|
-
stringDelim = c;
|
|
517
|
-
i++;
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
if (c === "`") {
|
|
521
|
-
inTemplate = true;
|
|
522
|
-
i++;
|
|
523
|
-
continue;
|
|
836
|
+
throw new ExtractError(
|
|
837
|
+
"uidex-export-invalid-literal",
|
|
838
|
+
`Identifier reference "${name}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
|
|
839
|
+
pos
|
|
840
|
+
);
|
|
524
841
|
}
|
|
525
|
-
|
|
842
|
+
case "ObjectExpression":
|
|
843
|
+
return objectLit(unwrapped, content, p2, pos, span);
|
|
844
|
+
case "ArrayExpression":
|
|
845
|
+
return arrayLit(unwrapped, content, p2, pos, span);
|
|
846
|
+
case "SpreadElement":
|
|
847
|
+
throw new ExtractError(
|
|
848
|
+
"uidex-export-invalid-literal",
|
|
849
|
+
"Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
|
|
850
|
+
pos
|
|
851
|
+
);
|
|
852
|
+
case "CallExpression":
|
|
853
|
+
throw new ExtractError(
|
|
854
|
+
"uidex-export-invalid-literal",
|
|
855
|
+
"Function calls are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
|
|
856
|
+
pos
|
|
857
|
+
);
|
|
858
|
+
case "ConditionalExpression":
|
|
859
|
+
throw new ExtractError(
|
|
860
|
+
"uidex-export-invalid-literal",
|
|
861
|
+
"Conditional expressions are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
|
|
862
|
+
pos
|
|
863
|
+
);
|
|
864
|
+
default:
|
|
865
|
+
throw new ExtractError(
|
|
866
|
+
"uidex-export-invalid-literal",
|
|
867
|
+
`Computed expressions are not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
|
|
868
|
+
pos
|
|
869
|
+
);
|
|
526
870
|
}
|
|
527
|
-
return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
|
|
528
871
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
this.line = line;
|
|
542
|
-
this.lineStart = lineStart;
|
|
543
|
-
}
|
|
544
|
-
src;
|
|
545
|
-
pos;
|
|
546
|
-
line;
|
|
547
|
-
lineStart;
|
|
548
|
-
currentPos() {
|
|
549
|
-
return {
|
|
550
|
-
offset: this.pos,
|
|
551
|
-
line: this.line,
|
|
552
|
-
column: this.pos - this.lineStart + 1
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
advance(n = 1) {
|
|
556
|
-
for (let i = 0; i < n; i++) {
|
|
557
|
-
if (this.pos < this.src.length && this.src[this.pos] === "\n") {
|
|
558
|
-
this.line++;
|
|
559
|
-
this.lineStart = this.pos + 1;
|
|
560
|
-
}
|
|
561
|
-
this.pos++;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
skipTrivia() {
|
|
565
|
-
while (this.pos < this.src.length) {
|
|
566
|
-
const c = this.src[this.pos];
|
|
567
|
-
const n = this.src[this.pos + 1];
|
|
568
|
-
if (c === " " || c === " " || c === "\r" || c === "\n") {
|
|
569
|
-
this.advance();
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
if (c === "/" && n === "/") {
|
|
573
|
-
while (this.pos < this.src.length && this.src[this.pos] !== "\n") {
|
|
574
|
-
this.advance();
|
|
575
|
-
}
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
if (c === "/" && n === "*") {
|
|
579
|
-
this.advance(2);
|
|
580
|
-
while (this.pos < this.src.length) {
|
|
581
|
-
if (this.src[this.pos] === "*" && this.src[this.pos + 1] === "/") {
|
|
582
|
-
this.advance(2);
|
|
583
|
-
break;
|
|
584
|
-
}
|
|
585
|
-
this.advance();
|
|
586
|
-
}
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
next() {
|
|
593
|
-
this.skipTrivia();
|
|
594
|
-
if (this.pos >= this.src.length) {
|
|
595
|
-
return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
|
|
596
|
-
}
|
|
597
|
-
const pos = this.currentPos();
|
|
598
|
-
const c = this.src[this.pos];
|
|
599
|
-
switch (c) {
|
|
600
|
-
case "{":
|
|
601
|
-
this.advance();
|
|
602
|
-
return { kind: "lbrace", value: c, pos, end: this.pos };
|
|
603
|
-
case "}":
|
|
604
|
-
this.advance();
|
|
605
|
-
return { kind: "rbrace", value: c, pos, end: this.pos };
|
|
606
|
-
case "[":
|
|
607
|
-
this.advance();
|
|
608
|
-
return { kind: "lbracket", value: c, pos, end: this.pos };
|
|
609
|
-
case "]":
|
|
610
|
-
this.advance();
|
|
611
|
-
return { kind: "rbracket", value: c, pos, end: this.pos };
|
|
612
|
-
case "(":
|
|
613
|
-
this.advance();
|
|
614
|
-
return { kind: "lparen", value: c, pos, end: this.pos };
|
|
615
|
-
case ")":
|
|
616
|
-
this.advance();
|
|
617
|
-
return { kind: "rparen", value: c, pos, end: this.pos };
|
|
618
|
-
case ",":
|
|
619
|
-
this.advance();
|
|
620
|
-
return { kind: "comma", value: c, pos, end: this.pos };
|
|
621
|
-
case ":":
|
|
622
|
-
this.advance();
|
|
623
|
-
return { kind: "colon", value: c, pos, end: this.pos };
|
|
624
|
-
}
|
|
625
|
-
if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
|
|
626
|
-
this.advance(3);
|
|
627
|
-
return { kind: "spread", value: "...", pos, end: this.pos };
|
|
628
|
-
}
|
|
629
|
-
if (c === '"' || c === "'") {
|
|
630
|
-
return this.readString(pos, c);
|
|
631
|
-
}
|
|
632
|
-
if (c === "`") {
|
|
633
|
-
return this.readTemplate(pos);
|
|
634
|
-
}
|
|
635
|
-
if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
|
|
636
|
-
return this.readNumber(pos);
|
|
637
|
-
}
|
|
638
|
-
if (isIdentStart(c)) {
|
|
639
|
-
return this.readIdent(pos);
|
|
640
|
-
}
|
|
641
|
-
this.advance();
|
|
642
|
-
return { kind: "punct", value: c, pos, end: this.pos };
|
|
643
|
-
}
|
|
644
|
-
readString(pos, delim) {
|
|
645
|
-
this.advance();
|
|
646
|
-
let value = "";
|
|
647
|
-
while (this.pos < this.src.length) {
|
|
648
|
-
const c = this.src[this.pos];
|
|
649
|
-
if (c === "\\") {
|
|
650
|
-
const esc = this.src[this.pos + 1];
|
|
651
|
-
this.advance(2);
|
|
652
|
-
value += decodeEscape(esc);
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
if (c === delim) {
|
|
656
|
-
this.advance();
|
|
657
|
-
return { kind: "string", value, pos, end: this.pos };
|
|
658
|
-
}
|
|
659
|
-
if (c === "\n") {
|
|
660
|
-
return { kind: "punct", value: delim, pos, end: this.pos };
|
|
661
|
-
}
|
|
662
|
-
value += c;
|
|
663
|
-
this.advance();
|
|
664
|
-
}
|
|
665
|
-
return { kind: "punct", value: delim, pos, end: this.pos };
|
|
666
|
-
}
|
|
667
|
-
readTemplate(pos) {
|
|
668
|
-
this.advance();
|
|
669
|
-
let value = "";
|
|
670
|
-
let hasExpression = false;
|
|
671
|
-
while (this.pos < this.src.length) {
|
|
672
|
-
const c = this.src[this.pos];
|
|
673
|
-
const n = this.src[this.pos + 1];
|
|
674
|
-
if (c === "\\") {
|
|
675
|
-
const esc = this.src[this.pos + 1];
|
|
676
|
-
this.advance(2);
|
|
677
|
-
value += decodeEscape(esc);
|
|
678
|
-
continue;
|
|
679
|
-
}
|
|
680
|
-
if (c === "$" && n === "{") {
|
|
681
|
-
hasExpression = true;
|
|
682
|
-
this.advance(2);
|
|
683
|
-
let depth = 1;
|
|
684
|
-
while (this.pos < this.src.length && depth > 0) {
|
|
685
|
-
const ch = this.src[this.pos];
|
|
686
|
-
if (ch === "{") depth++;
|
|
687
|
-
else if (ch === "}") depth--;
|
|
688
|
-
this.advance();
|
|
689
|
-
}
|
|
690
|
-
continue;
|
|
691
|
-
}
|
|
692
|
-
if (c === "`") {
|
|
693
|
-
this.advance();
|
|
694
|
-
if (hasExpression) {
|
|
695
|
-
return { kind: "template", value, pos, end: this.pos };
|
|
696
|
-
}
|
|
697
|
-
return { kind: "string", value, pos, end: this.pos };
|
|
698
|
-
}
|
|
699
|
-
value += c;
|
|
700
|
-
this.advance();
|
|
701
|
-
}
|
|
702
|
-
return { kind: "template", value, pos, end: this.pos };
|
|
703
|
-
}
|
|
704
|
-
readNumber(pos) {
|
|
705
|
-
const start = this.pos;
|
|
706
|
-
if (this.src[this.pos] === "-") this.advance();
|
|
707
|
-
while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
|
|
708
|
-
this.advance();
|
|
709
|
-
}
|
|
710
|
-
if (this.src[this.pos] === ".") {
|
|
711
|
-
this.advance();
|
|
712
|
-
while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
|
|
713
|
-
this.advance();
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
if (this.src[this.pos] === "e" || this.src[this.pos] === "E") {
|
|
717
|
-
this.advance();
|
|
718
|
-
if (this.src[this.pos] === "+" || this.src[this.pos] === "-") {
|
|
719
|
-
this.advance();
|
|
720
|
-
}
|
|
721
|
-
while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
|
|
722
|
-
this.advance();
|
|
723
|
-
}
|
|
872
|
+
function objectLit(node, content, p2, pos, span) {
|
|
873
|
+
const entries = [];
|
|
874
|
+
const seen = /* @__PURE__ */ new Set();
|
|
875
|
+
for (const prop of node.properties ?? []) {
|
|
876
|
+
if (prop.type === "SpreadElement") {
|
|
877
|
+
throw new ExtractError(
|
|
878
|
+
"uidex-export-invalid-literal",
|
|
879
|
+
"Spread (`...`) is not allowed inside `export const uidex`.",
|
|
880
|
+
posAt(content, prop.start, p2)
|
|
881
|
+
);
|
|
724
882
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
this.advance();
|
|
883
|
+
if (prop.type !== "Property") {
|
|
884
|
+
throw new ExtractError(
|
|
885
|
+
"uidex-export-invalid-literal",
|
|
886
|
+
"Unexpected member inside `export const uidex` object.",
|
|
887
|
+
posAt(content, prop.start, p2)
|
|
888
|
+
);
|
|
732
889
|
}
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if (c === void 0) return false;
|
|
742
|
-
return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_" || c === "$";
|
|
743
|
-
}
|
|
744
|
-
function isIdentPart(c) {
|
|
745
|
-
return isIdentStart(c) || isDigit(c);
|
|
746
|
-
}
|
|
747
|
-
function decodeEscape(esc) {
|
|
748
|
-
switch (esc) {
|
|
749
|
-
case "n":
|
|
750
|
-
return "\n";
|
|
751
|
-
case "t":
|
|
752
|
-
return " ";
|
|
753
|
-
case "r":
|
|
754
|
-
return "\r";
|
|
755
|
-
case "\\":
|
|
756
|
-
return "\\";
|
|
757
|
-
case "'":
|
|
758
|
-
return "'";
|
|
759
|
-
case '"':
|
|
760
|
-
return '"';
|
|
761
|
-
case "`":
|
|
762
|
-
return "`";
|
|
763
|
-
case "0":
|
|
764
|
-
return "\0";
|
|
765
|
-
case "b":
|
|
766
|
-
return "\b";
|
|
767
|
-
case "f":
|
|
768
|
-
return "\f";
|
|
769
|
-
case "v":
|
|
770
|
-
return "\v";
|
|
771
|
-
default:
|
|
772
|
-
return esc ?? "";
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
function parseExpression(content, start) {
|
|
776
|
-
const tokenizer = new Tokenizer(content, start);
|
|
777
|
-
const parser = new Parser(tokenizer);
|
|
778
|
-
const value = parser.parseValue();
|
|
779
|
-
parser.consumeTrailingAssertions();
|
|
780
|
-
return value;
|
|
781
|
-
}
|
|
782
|
-
var Parser = class {
|
|
783
|
-
constructor(tok) {
|
|
784
|
-
this.tok = tok;
|
|
785
|
-
}
|
|
786
|
-
tok;
|
|
787
|
-
lookahead = null;
|
|
788
|
-
peek() {
|
|
789
|
-
if (this.lookahead === null) this.lookahead = this.tok.next();
|
|
790
|
-
return this.lookahead;
|
|
791
|
-
}
|
|
792
|
-
consume() {
|
|
793
|
-
const t = this.peek();
|
|
794
|
-
this.lookahead = null;
|
|
795
|
-
return t;
|
|
796
|
-
}
|
|
797
|
-
parseValue() {
|
|
798
|
-
const t = this.peek();
|
|
799
|
-
switch (t.kind) {
|
|
800
|
-
case "lbrace":
|
|
801
|
-
return this.parseObject();
|
|
802
|
-
case "lbracket":
|
|
803
|
-
return this.parseArray();
|
|
804
|
-
case "string":
|
|
805
|
-
this.consume();
|
|
806
|
-
return { kind: "string", value: t.value, pos: t.pos };
|
|
807
|
-
case "template":
|
|
808
|
-
throw new ExtractError(
|
|
809
|
-
"uidex-export-invalid-literal",
|
|
810
|
-
"Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
|
|
811
|
-
t.pos
|
|
812
|
-
);
|
|
813
|
-
case "number": {
|
|
814
|
-
this.consume();
|
|
815
|
-
const n = Number(t.value);
|
|
816
|
-
if (!Number.isFinite(n)) {
|
|
817
|
-
throw new ExtractError(
|
|
818
|
-
"uidex-export-invalid-literal",
|
|
819
|
-
`Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
|
|
820
|
-
t.pos
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
return { kind: "number", value: n, pos: t.pos };
|
|
824
|
-
}
|
|
825
|
-
case "ident":
|
|
826
|
-
if (t.value === "true" || t.value === "false") {
|
|
827
|
-
this.consume();
|
|
828
|
-
return {
|
|
829
|
-
kind: "boolean",
|
|
830
|
-
value: t.value === "true",
|
|
831
|
-
pos: t.pos
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
if (t.value === "null") {
|
|
835
|
-
this.consume();
|
|
836
|
-
return { kind: "null", pos: t.pos };
|
|
837
|
-
}
|
|
838
|
-
if (t.value === "undefined") {
|
|
839
|
-
throw new ExtractError(
|
|
840
|
-
"uidex-export-invalid-literal",
|
|
841
|
-
"`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
|
|
842
|
-
t.pos
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
throw new ExtractError(
|
|
846
|
-
"uidex-export-invalid-literal",
|
|
847
|
-
`Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
|
|
848
|
-
t.pos
|
|
849
|
-
);
|
|
850
|
-
case "spread":
|
|
851
|
-
throw new ExtractError(
|
|
852
|
-
"uidex-export-invalid-literal",
|
|
853
|
-
"Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
|
|
854
|
-
t.pos
|
|
855
|
-
);
|
|
856
|
-
case "lparen":
|
|
857
|
-
throw new ExtractError(
|
|
858
|
-
"uidex-export-invalid-literal",
|
|
859
|
-
"Parenthesised or grouped expressions are not allowed in `export const uidex`.",
|
|
860
|
-
t.pos
|
|
861
|
-
);
|
|
862
|
-
case "punct":
|
|
863
|
-
throw new ExtractError(
|
|
864
|
-
"uidex-export-invalid-literal",
|
|
865
|
-
`Unexpected token "${t.value}" in \`export const uidex\`.`,
|
|
866
|
-
t.pos
|
|
867
|
-
);
|
|
868
|
-
case "eof":
|
|
869
|
-
throw new ExtractError(
|
|
870
|
-
"uidex-export-invalid-literal",
|
|
871
|
-
"Expected a value for `export const uidex` but reached end of file.",
|
|
872
|
-
t.pos
|
|
873
|
-
);
|
|
874
|
-
default:
|
|
875
|
-
throw new ExtractError(
|
|
876
|
-
"uidex-export-invalid-literal",
|
|
877
|
-
`Unexpected token in \`export const uidex\`.`,
|
|
878
|
-
t.pos
|
|
879
|
-
);
|
|
890
|
+
const keyNode = prop.key;
|
|
891
|
+
const keyPos = posAt(content, keyNode.start, p2);
|
|
892
|
+
if (prop.shorthand) {
|
|
893
|
+
throw new ExtractError(
|
|
894
|
+
"uidex-export-invalid-literal",
|
|
895
|
+
`Shorthand property "${String(keyNode.name)}" is not allowed; write "${String(keyNode.name)}: ..." with a literal value.`,
|
|
896
|
+
keyPos
|
|
897
|
+
);
|
|
880
898
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const entries = [];
|
|
885
|
-
const seen = /* @__PURE__ */ new Set();
|
|
886
|
-
while (true) {
|
|
887
|
-
const t = this.peek();
|
|
888
|
-
if (t.kind === "rbrace") {
|
|
889
|
-
this.consume();
|
|
890
|
-
break;
|
|
891
|
-
}
|
|
892
|
-
if (t.kind === "spread") {
|
|
893
|
-
throw new ExtractError(
|
|
894
|
-
"uidex-export-invalid-literal",
|
|
895
|
-
"Spread (`...`) is not allowed inside `export const uidex`.",
|
|
896
|
-
t.pos
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
if (t.kind === "lbracket") {
|
|
900
|
-
this.consume();
|
|
901
|
-
const keyTok = this.peek();
|
|
902
|
-
if (keyTok.kind !== "string") {
|
|
903
|
-
throw new ExtractError(
|
|
904
|
-
"uidex-export-invalid-literal",
|
|
905
|
-
"Computed property keys must be string literals in `export const uidex`.",
|
|
906
|
-
keyTok.pos
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
this.consume();
|
|
910
|
-
const close = this.peek();
|
|
911
|
-
if (close.kind !== "rbracket") {
|
|
912
|
-
throw new ExtractError(
|
|
913
|
-
"uidex-export-invalid-literal",
|
|
914
|
-
"Expected `]` after computed property key.",
|
|
915
|
-
close.pos
|
|
916
|
-
);
|
|
917
|
-
}
|
|
918
|
-
this.consume();
|
|
919
|
-
const colon = this.peek();
|
|
920
|
-
if (colon.kind !== "colon") {
|
|
921
|
-
throw new ExtractError(
|
|
922
|
-
"uidex-export-invalid-literal",
|
|
923
|
-
"Expected `:` after computed property key.",
|
|
924
|
-
colon.pos
|
|
925
|
-
);
|
|
926
|
-
}
|
|
927
|
-
this.consume();
|
|
928
|
-
const value = this.parseValue();
|
|
929
|
-
this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
|
|
930
|
-
} else if (t.kind === "ident" || t.kind === "string") {
|
|
931
|
-
const keyTok = this.consume();
|
|
932
|
-
const next = this.peek();
|
|
933
|
-
if (next.kind === "colon") {
|
|
934
|
-
this.consume();
|
|
935
|
-
const value = this.parseValue();
|
|
936
|
-
this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
|
|
937
|
-
} else {
|
|
938
|
-
throw new ExtractError(
|
|
939
|
-
"uidex-export-invalid-literal",
|
|
940
|
-
keyTok.kind === "ident" ? `Shorthand property "${keyTok.value}" is not allowed; write "${keyTok.value}: ..." with a literal value.` : "Expected `:` after property key.",
|
|
941
|
-
keyTok.pos
|
|
942
|
-
);
|
|
943
|
-
}
|
|
944
|
-
} else if (t.kind === "number") {
|
|
945
|
-
throw new ExtractError(
|
|
946
|
-
"uidex-export-invalid-literal",
|
|
947
|
-
"Numeric property keys are not allowed in `export const uidex`.",
|
|
948
|
-
t.pos
|
|
949
|
-
);
|
|
950
|
-
} else {
|
|
899
|
+
let key;
|
|
900
|
+
if (prop.computed) {
|
|
901
|
+
if (keyNode.type !== "Literal" || typeof keyNode.value !== "string") {
|
|
951
902
|
throw new ExtractError(
|
|
952
903
|
"uidex-export-invalid-literal",
|
|
953
|
-
`
|
|
954
|
-
|
|
904
|
+
"Computed property keys must be string literals in `export const uidex`.",
|
|
905
|
+
keyPos
|
|
955
906
|
);
|
|
956
907
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
this.consume();
|
|
964
|
-
break;
|
|
965
|
-
}
|
|
908
|
+
key = keyNode.value;
|
|
909
|
+
} else if (keyNode.type === "Identifier") {
|
|
910
|
+
key = String(keyNode.name);
|
|
911
|
+
} else if (keyNode.type === "Literal" && typeof keyNode.value === "string") {
|
|
912
|
+
key = keyNode.value;
|
|
913
|
+
} else {
|
|
966
914
|
throw new ExtractError(
|
|
967
915
|
"uidex-export-invalid-literal",
|
|
968
|
-
|
|
969
|
-
|
|
916
|
+
"Numeric property keys are not allowed in `export const uidex`.",
|
|
917
|
+
keyPos
|
|
970
918
|
);
|
|
971
919
|
}
|
|
972
|
-
return { kind: "object", entries, pos: open.pos };
|
|
973
|
-
}
|
|
974
|
-
recordEntry(entries, seen, key, value, pos) {
|
|
975
920
|
if (seen.has(key)) {
|
|
976
921
|
throw new ExtractError(
|
|
977
922
|
"uidex-export-duplicate-field",
|
|
978
923
|
`Duplicate field "${key}" in \`export const uidex\`.`,
|
|
979
|
-
|
|
924
|
+
keyPos
|
|
980
925
|
);
|
|
981
926
|
}
|
|
982
927
|
seen.add(key);
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
items.push(value);
|
|
1005
|
-
const after = this.peek();
|
|
1006
|
-
if (after.kind === "comma") {
|
|
1007
|
-
this.consume();
|
|
1008
|
-
continue;
|
|
1009
|
-
}
|
|
1010
|
-
if (after.kind === "rbracket") {
|
|
1011
|
-
this.consume();
|
|
1012
|
-
break;
|
|
1013
|
-
}
|
|
928
|
+
const value = toLitValue(prop.value, content, p2);
|
|
929
|
+
entries.push({
|
|
930
|
+
key,
|
|
931
|
+
value,
|
|
932
|
+
keyPos,
|
|
933
|
+
span: removalSpan(content, prop.start, prop.end)
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return { kind: "object", entries, pos, span };
|
|
937
|
+
}
|
|
938
|
+
function arrayLit(node, content, p2, pos, span) {
|
|
939
|
+
const items = [];
|
|
940
|
+
for (const el of node.elements ?? []) {
|
|
941
|
+
if (el === null) {
|
|
942
|
+
throw new ExtractError(
|
|
943
|
+
"uidex-export-invalid-literal",
|
|
944
|
+
"Array holes are not allowed in `export const uidex`.",
|
|
945
|
+
pos
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
if (el.type === "SpreadElement") {
|
|
1014
949
|
throw new ExtractError(
|
|
1015
950
|
"uidex-export-invalid-literal",
|
|
1016
|
-
|
|
1017
|
-
|
|
951
|
+
"Spread (`...`) is not allowed inside `export const uidex`.",
|
|
952
|
+
posAt(content, el.start, p2)
|
|
1018
953
|
);
|
|
1019
954
|
}
|
|
1020
|
-
|
|
955
|
+
items.push(toLitValue(el, content, p2));
|
|
1021
956
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
"uidex-export-invalid-literal",
|
|
1032
|
-
"Only `as const` is allowed after the `export const uidex` value.",
|
|
1033
|
-
next.pos
|
|
1034
|
-
);
|
|
1035
|
-
}
|
|
957
|
+
return { kind: "array", items, pos, span };
|
|
958
|
+
}
|
|
959
|
+
function checkSatisfies(init, metadata, file, p2, diagnostics) {
|
|
960
|
+
let node = init;
|
|
961
|
+
let satisfiesType;
|
|
962
|
+
while (isNode2(node)) {
|
|
963
|
+
if (node.type === "TSSatisfiesExpression") {
|
|
964
|
+
satisfiesType = node.typeAnnotation;
|
|
965
|
+
break;
|
|
1036
966
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
967
|
+
if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
|
|
968
|
+
node = node.expression;
|
|
969
|
+
continue;
|
|
1040
970
|
}
|
|
971
|
+
break;
|
|
1041
972
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
973
|
+
if (!isNode2(satisfiesType) || satisfiesType.type !== "TSTypeReference") return;
|
|
974
|
+
const typeName = satisfiesType.typeName;
|
|
975
|
+
if (!isNode2(typeName) || typeName.type !== "TSQualifiedName") return;
|
|
976
|
+
const left = typeName.left;
|
|
977
|
+
const right = typeName.right;
|
|
978
|
+
if (!isNode2(left) || left.type !== "Identifier" || left.name !== "Uidex") {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (!isNode2(right) || right.type !== "Identifier") return;
|
|
982
|
+
const actual = String(right.name);
|
|
983
|
+
if (!KNOWN_SATISFIES.has(actual)) return;
|
|
984
|
+
const discriminator = metadata.notFlow ? "notFlow" : metadata.kind;
|
|
985
|
+
const expected = SATISFIES_NAMES[discriminator];
|
|
986
|
+
if (actual === expected) return;
|
|
987
|
+
diagnostics.push({
|
|
988
|
+
code: "uidex-export-satisfies-mismatch",
|
|
989
|
+
severity: "warning",
|
|
990
|
+
message: `\`export const uidex\` declares kind "${discriminator}" but is annotated \`satisfies Uidex.${actual}\`; expected \`Uidex.${expected}\`.`,
|
|
991
|
+
file,
|
|
992
|
+
line: p2.lineAt(satisfiesType.start),
|
|
993
|
+
hint: `Change the annotation to \`satisfies Uidex.${expected}\` or fix the kind discriminator.`
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
1044
997
|
if (value.kind !== "object") {
|
|
1045
998
|
throw new ExtractError(
|
|
1046
999
|
"uidex-export-invalid-literal",
|
|
@@ -1049,7 +1002,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
|
|
|
1049
1002
|
);
|
|
1050
1003
|
}
|
|
1051
1004
|
const byKey = /* @__PURE__ */ new Map();
|
|
1052
|
-
for (const
|
|
1005
|
+
for (const entry of value.entries) byKey.set(entry.key, entry);
|
|
1053
1006
|
const presentKinds = KIND_DISCRIMINATORS.filter(
|
|
1054
1007
|
(k) => byKey.has(k)
|
|
1055
1008
|
);
|
|
@@ -1072,49 +1025,61 @@ function buildMetadata(value, file, headerPos, diagnostics) {
|
|
|
1072
1025
|
const discriminator = presentKinds[0];
|
|
1073
1026
|
const kind = discriminator === "notFlow" ? "flow" : discriminator;
|
|
1074
1027
|
const allowed = ALLOWED_FIELDS[kind];
|
|
1075
|
-
for (const
|
|
1076
|
-
if (!allowed.has(
|
|
1077
|
-
const fieldVal = byKey.get(k);
|
|
1028
|
+
for (const entry of value.entries) {
|
|
1029
|
+
if (!allowed.has(entry.key)) {
|
|
1078
1030
|
throw new ExtractError(
|
|
1079
1031
|
"uidex-export-unknown-field",
|
|
1080
|
-
`Unknown field "${
|
|
1032
|
+
`Unknown field "${entry.key}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
|
|
1081
1033
|
allowed
|
|
1082
1034
|
).sort().join(", ")}.`,
|
|
1083
|
-
|
|
1035
|
+
entry.value.pos
|
|
1084
1036
|
);
|
|
1085
1037
|
}
|
|
1086
1038
|
}
|
|
1087
1039
|
const idField = discriminator === "notFlow" ? "flow" : discriminator;
|
|
1088
|
-
const idValue = byKey.get(discriminator);
|
|
1040
|
+
const idValue = byKey.get(discriminator).value;
|
|
1089
1041
|
let id;
|
|
1090
1042
|
if (discriminator === "notFlow") {
|
|
1091
|
-
|
|
1092
|
-
if (v.kind !== "boolean" || v.value !== true) {
|
|
1043
|
+
if (idValue.kind !== "boolean" || idValue.value !== true) {
|
|
1093
1044
|
throw new ExtractError(
|
|
1094
1045
|
"uidex-export-invalid-field",
|
|
1095
1046
|
"`notFlow` must be `true`.",
|
|
1096
|
-
|
|
1047
|
+
idValue.pos
|
|
1097
1048
|
);
|
|
1098
1049
|
}
|
|
1099
1050
|
id = false;
|
|
1100
1051
|
} else {
|
|
1101
1052
|
id = readIdField(idValue, kind, idField);
|
|
1102
1053
|
}
|
|
1103
|
-
const acceptance = readStringArrayField(byKey, "acceptance");
|
|
1054
|
+
const acceptance = readStringArrayField(byKey, "acceptance")?.values;
|
|
1104
1055
|
const description = readStringField(byKey, "description");
|
|
1105
1056
|
const name = readStringField(byKey, "name");
|
|
1106
1057
|
if (name === "") {
|
|
1107
|
-
const
|
|
1058
|
+
const entry = byKey.get("name");
|
|
1108
1059
|
diagnostics.push({
|
|
1109
1060
|
code: "uidex-export-empty-name",
|
|
1110
1061
|
severity: "info",
|
|
1111
1062
|
message: "`name` is an empty string; treating as unset.",
|
|
1112
1063
|
file,
|
|
1113
|
-
line: pos.line
|
|
1064
|
+
line: entry.value.pos.line,
|
|
1065
|
+
fix: {
|
|
1066
|
+
description: "Remove the empty `name` field",
|
|
1067
|
+
edits: [
|
|
1068
|
+
{
|
|
1069
|
+
path: sourcePath,
|
|
1070
|
+
start: entry.span.start,
|
|
1071
|
+
end: entry.span.end,
|
|
1072
|
+
replacement: ""
|
|
1073
|
+
}
|
|
1074
|
+
]
|
|
1075
|
+
}
|
|
1114
1076
|
});
|
|
1115
1077
|
}
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1078
|
+
const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
|
|
1079
|
+
const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
|
|
1080
|
+
const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
|
|
1081
|
+
const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
|
|
1082
|
+
const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
|
|
1118
1083
|
const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
|
|
1119
1084
|
const metadata = {
|
|
1120
1085
|
source: "ts-export",
|
|
@@ -1129,9 +1094,24 @@ function buildMetadata(value, file, headerPos, diagnostics) {
|
|
|
1129
1094
|
if (name) metadata.name = name;
|
|
1130
1095
|
if (acceptance) metadata.acceptance = acceptance;
|
|
1131
1096
|
if (description) metadata.description = description;
|
|
1132
|
-
if (
|
|
1133
|
-
|
|
1097
|
+
if (featuresField) {
|
|
1098
|
+
metadata.features = featuresField.values;
|
|
1099
|
+
metadata.featureSpans = featuresField.spans;
|
|
1100
|
+
}
|
|
1101
|
+
if (widgetsField) {
|
|
1102
|
+
metadata.widgets = widgetsField.values;
|
|
1103
|
+
metadata.widgetSpans = widgetsField.spans;
|
|
1104
|
+
}
|
|
1105
|
+
if (states?.length) metadata.states = states;
|
|
1106
|
+
if (capture !== void 0) metadata.capture = capture;
|
|
1107
|
+
if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
|
|
1134
1108
|
if (notFlow) metadata.notFlow = true;
|
|
1109
|
+
if (typeof id === "string" && idValue.kind === "string") {
|
|
1110
|
+
metadata.idSpan = idValue.span;
|
|
1111
|
+
}
|
|
1112
|
+
const fieldSpans = {};
|
|
1113
|
+
for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
|
|
1114
|
+
metadata.fieldSpans = fieldSpans;
|
|
1135
1115
|
return metadata;
|
|
1136
1116
|
}
|
|
1137
1117
|
function readIdField(value, kind, fieldName) {
|
|
@@ -1161,30 +1141,73 @@ function readIdField(value, kind, fieldName) {
|
|
|
1161
1141
|
value.pos
|
|
1162
1142
|
);
|
|
1163
1143
|
}
|
|
1144
|
+
function readWaiversField(byKey) {
|
|
1145
|
+
const entry = byKey.get("waivers");
|
|
1146
|
+
if (!entry) return void 0;
|
|
1147
|
+
if (entry.value.kind !== "object") {
|
|
1148
|
+
throw new ExtractError(
|
|
1149
|
+
"uidex-export-invalid-field",
|
|
1150
|
+
"`waivers` must be an object mapping a core kind to a reason.",
|
|
1151
|
+
entry.value.pos
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
const waivers = {};
|
|
1155
|
+
for (const e of entry.value.entries) {
|
|
1156
|
+
if (!CORE_STATE_KINDS2.has(e.key)) {
|
|
1157
|
+
throw new ExtractError(
|
|
1158
|
+
"uidex-export-invalid-field",
|
|
1159
|
+
`waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
|
|
1160
|
+
e.keyPos
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
if (e.value.kind !== "string" || e.value.value.length === 0) {
|
|
1164
|
+
throw new ExtractError(
|
|
1165
|
+
"uidex-export-invalid-field",
|
|
1166
|
+
`waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
|
|
1167
|
+
e.value.pos
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
waivers[e.key] = e.value.value;
|
|
1171
|
+
}
|
|
1172
|
+
return waivers;
|
|
1173
|
+
}
|
|
1174
|
+
function readBooleanField(byKey, name) {
|
|
1175
|
+
const entry = byKey.get(name);
|
|
1176
|
+
if (!entry) return void 0;
|
|
1177
|
+
if (entry.value.kind !== "boolean") {
|
|
1178
|
+
throw new ExtractError(
|
|
1179
|
+
"uidex-export-invalid-field",
|
|
1180
|
+
`\`${name}\` must be a boolean.`,
|
|
1181
|
+
entry.value.pos
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
return entry.value.value;
|
|
1185
|
+
}
|
|
1164
1186
|
function readStringField(byKey, name) {
|
|
1165
|
-
const
|
|
1166
|
-
if (!
|
|
1167
|
-
if (
|
|
1187
|
+
const entry = byKey.get(name);
|
|
1188
|
+
if (!entry) return void 0;
|
|
1189
|
+
if (entry.value.kind !== "string") {
|
|
1168
1190
|
throw new ExtractError(
|
|
1169
1191
|
"uidex-export-invalid-field",
|
|
1170
1192
|
`\`${name}\` must be a string.`,
|
|
1171
|
-
|
|
1193
|
+
entry.value.pos
|
|
1172
1194
|
);
|
|
1173
1195
|
}
|
|
1174
|
-
return
|
|
1196
|
+
return entry.value.value;
|
|
1175
1197
|
}
|
|
1176
1198
|
function readStringArrayField(byKey, name) {
|
|
1177
|
-
const
|
|
1178
|
-
if (!
|
|
1179
|
-
if (
|
|
1199
|
+
const entry = byKey.get(name);
|
|
1200
|
+
if (!entry) return void 0;
|
|
1201
|
+
if (entry.value.kind !== "array") {
|
|
1180
1202
|
throw new ExtractError(
|
|
1181
1203
|
"uidex-export-invalid-field",
|
|
1182
1204
|
`\`${name}\` must be an array of strings.`,
|
|
1183
|
-
|
|
1205
|
+
entry.value.pos
|
|
1184
1206
|
);
|
|
1185
1207
|
}
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1208
|
+
const values = [];
|
|
1209
|
+
const spans = [];
|
|
1210
|
+
for (const item of entry.value.items) {
|
|
1188
1211
|
if (item.kind !== "string") {
|
|
1189
1212
|
throw new ExtractError(
|
|
1190
1213
|
"uidex-export-invalid-field",
|
|
@@ -1192,498 +1215,606 @@ function readStringArrayField(byKey, name) {
|
|
|
1192
1215
|
item.pos
|
|
1193
1216
|
);
|
|
1194
1217
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
return out2;
|
|
1198
|
-
}
|
|
1199
|
-
function posAt(content, offset) {
|
|
1200
|
-
let line = 1;
|
|
1201
|
-
let lineStart = 0;
|
|
1202
|
-
for (let i = 0; i < offset && i < content.length; i++) {
|
|
1203
|
-
if (content[i] === "\n") {
|
|
1204
|
-
line++;
|
|
1205
|
-
lineStart = i + 1;
|
|
1206
|
-
}
|
|
1218
|
+
values.push(item.value);
|
|
1219
|
+
spans.push(item.span);
|
|
1207
1220
|
}
|
|
1208
|
-
return {
|
|
1221
|
+
return { values, spans };
|
|
1209
1222
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
const id = m[2] ?? m[3];
|
|
1220
|
-
if (id) out2.push({ kind, id });
|
|
1221
|
-
}
|
|
1222
|
-
for (const m of tagSource.matchAll(PATTERN_ATTR_RE)) {
|
|
1223
|
-
const kind = m[1] ?? "element";
|
|
1224
|
-
const prefix = m[2];
|
|
1225
|
-
if (prefix) out2.push({ kind, id: `${prefix}*` });
|
|
1223
|
+
function readStatesField(byKey) {
|
|
1224
|
+
const entry = byKey.get("states");
|
|
1225
|
+
if (!entry) return void 0;
|
|
1226
|
+
if (entry.value.kind !== "array") {
|
|
1227
|
+
throw new ExtractError(
|
|
1228
|
+
"uidex-export-invalid-field",
|
|
1229
|
+
"`states` must be an array of state names or `{ name, acceptance?, description? }` objects.",
|
|
1230
|
+
entry.value.pos
|
|
1231
|
+
);
|
|
1226
1232
|
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
line++;
|
|
1246
|
-
i++;
|
|
1247
|
-
continue;
|
|
1248
|
-
}
|
|
1249
|
-
if (c === "/" && content[i + 1] === "/") {
|
|
1250
|
-
while (i < N && content[i] !== "\n") i++;
|
|
1251
|
-
continue;
|
|
1252
|
-
}
|
|
1253
|
-
if (c === "/" && content[i + 1] === "*") {
|
|
1254
|
-
const end = content.indexOf("*/", i + 2);
|
|
1255
|
-
const next = end === -1 ? N : end + 2;
|
|
1256
|
-
advanceLines(i, next);
|
|
1257
|
-
i = next;
|
|
1258
|
-
continue;
|
|
1259
|
-
}
|
|
1260
|
-
if (c === '"' || c === "'") {
|
|
1261
|
-
const next = skipString(content, i, c);
|
|
1262
|
-
advanceLines(i, next);
|
|
1263
|
-
i = next;
|
|
1264
|
-
continue;
|
|
1265
|
-
}
|
|
1266
|
-
if (c === "`") {
|
|
1267
|
-
const next = skipTemplate(content, i);
|
|
1268
|
-
advanceLines(i, next);
|
|
1269
|
-
i = next;
|
|
1270
|
-
continue;
|
|
1271
|
-
}
|
|
1272
|
-
if (c === "<") {
|
|
1273
|
-
const nextCh = content[i + 1];
|
|
1274
|
-
if (nextCh === "/") {
|
|
1275
|
-
const end = content.indexOf(">", i);
|
|
1276
|
-
if (end === -1) break;
|
|
1277
|
-
const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
|
|
1278
|
-
if (tagName) {
|
|
1279
|
-
for (let k = stack.length - 1; k >= 0; k--) {
|
|
1280
|
-
if (stack[k].tagName === tagName) {
|
|
1281
|
-
for (let j = stack.length - 1; j >= k; j--) {
|
|
1282
|
-
ancestors.length -= stack[j].pushed;
|
|
1283
|
-
}
|
|
1284
|
-
stack.length = k;
|
|
1285
|
-
break;
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1233
|
+
const states = [];
|
|
1234
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1235
|
+
for (const item of entry.value.items) {
|
|
1236
|
+
let state;
|
|
1237
|
+
if (item.kind === "string") {
|
|
1238
|
+
state = { name: item.value, nameSpan: item.span };
|
|
1239
|
+
} else if (item.kind === "object") {
|
|
1240
|
+
const fieldByKey = /* @__PURE__ */ new Map();
|
|
1241
|
+
for (const e of item.entries) fieldByKey.set(e.key, e);
|
|
1242
|
+
for (const e of item.entries) {
|
|
1243
|
+
if (!STATE_FIELDS.has(e.key)) {
|
|
1244
|
+
throw new ExtractError(
|
|
1245
|
+
"uidex-export-invalid-field",
|
|
1246
|
+
`Unknown field "${e.key}" in a \`states\` entry. Allowed: ${Array.from(
|
|
1247
|
+
STATE_FIELDS
|
|
1248
|
+
).sort().join(", ")}.`,
|
|
1249
|
+
e.value.pos
|
|
1250
|
+
);
|
|
1288
1251
|
}
|
|
1289
|
-
advanceLines(i, end + 1);
|
|
1290
|
-
i = end + 1;
|
|
1291
|
-
continue;
|
|
1292
1252
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
const attrs = parseDataAttrs(tagSource);
|
|
1301
|
-
if (attrs.length > 0) {
|
|
1302
|
-
const snapshot = ancestors.slice();
|
|
1303
|
-
for (const a of attrs) {
|
|
1304
|
-
out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
if (!isSelf) {
|
|
1308
|
-
for (const a of attrs) ancestors.push(a);
|
|
1309
|
-
stack.push({ tagName, pushed: attrs.length });
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
advanceLines(i, end + 1);
|
|
1313
|
-
i = end + 1;
|
|
1314
|
-
continue;
|
|
1253
|
+
const nameEntry = fieldByKey.get("name");
|
|
1254
|
+
if (!nameEntry || nameEntry.value.kind !== "string") {
|
|
1255
|
+
throw new ExtractError(
|
|
1256
|
+
"uidex-export-invalid-field",
|
|
1257
|
+
"Each object `states` entry must have a string `name`.",
|
|
1258
|
+
item.pos
|
|
1259
|
+
);
|
|
1315
1260
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
const c = content[i];
|
|
1326
|
-
if (c === "\\") {
|
|
1327
|
-
i += 2;
|
|
1328
|
-
continue;
|
|
1329
|
-
}
|
|
1330
|
-
if (c === quote) return i + 1;
|
|
1331
|
-
i++;
|
|
1332
|
-
}
|
|
1333
|
-
return N;
|
|
1334
|
-
}
|
|
1335
|
-
function skipTemplate(content, start) {
|
|
1336
|
-
const N = content.length;
|
|
1337
|
-
let i = start + 1;
|
|
1338
|
-
while (i < N) {
|
|
1339
|
-
const c = content[i];
|
|
1340
|
-
if (c === "\\") {
|
|
1341
|
-
i += 2;
|
|
1342
|
-
continue;
|
|
1343
|
-
}
|
|
1344
|
-
if (c === "`") return i + 1;
|
|
1345
|
-
if (c === "$" && content[i + 1] === "{") {
|
|
1346
|
-
i += 2;
|
|
1347
|
-
let depth = 1;
|
|
1348
|
-
while (i < N && depth > 0) {
|
|
1349
|
-
const cj = content[i];
|
|
1350
|
-
if (cj === '"' || cj === "'") {
|
|
1351
|
-
i = skipString(content, i, cj);
|
|
1352
|
-
continue;
|
|
1353
|
-
}
|
|
1354
|
-
if (cj === "`") {
|
|
1355
|
-
i = skipTemplate(content, i);
|
|
1356
|
-
continue;
|
|
1261
|
+
state = { name: nameEntry.value.value, nameSpan: nameEntry.value.span };
|
|
1262
|
+
const kind = readStringField(fieldByKey, "kind");
|
|
1263
|
+
if (kind !== void 0) {
|
|
1264
|
+
if (!STATE_KINDS.has(kind)) {
|
|
1265
|
+
throw new ExtractError(
|
|
1266
|
+
"uidex-export-invalid-field",
|
|
1267
|
+
`state \`kind\` must be one of ${[...STATE_KINDS].join(", ")} (got "${kind}").`,
|
|
1268
|
+
fieldByKey.get("kind").value.pos
|
|
1269
|
+
);
|
|
1357
1270
|
}
|
|
1358
|
-
|
|
1359
|
-
else if (cj === "}") depth--;
|
|
1360
|
-
i++;
|
|
1271
|
+
state.kind = kind;
|
|
1361
1272
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
const c = content[i];
|
|
1373
|
-
if (c === '"' || c === "'") {
|
|
1374
|
-
i = skipString(content, i, c);
|
|
1375
|
-
continue;
|
|
1273
|
+
const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
|
|
1274
|
+
if (acceptance) state.acceptance = acceptance;
|
|
1275
|
+
const description = readStringField(fieldByKey, "description");
|
|
1276
|
+
if (description) state.description = description;
|
|
1277
|
+
} else {
|
|
1278
|
+
throw new ExtractError(
|
|
1279
|
+
"uidex-export-invalid-field",
|
|
1280
|
+
"`states` items must be strings or `{ name, ... }` objects.",
|
|
1281
|
+
item.pos
|
|
1282
|
+
);
|
|
1376
1283
|
}
|
|
1377
|
-
if (
|
|
1378
|
-
|
|
1379
|
-
|
|
1284
|
+
if (state.name.length === 0) {
|
|
1285
|
+
throw new ExtractError(
|
|
1286
|
+
"uidex-export-invalid-field",
|
|
1287
|
+
"A `states` entry has an empty `name`.",
|
|
1288
|
+
item.pos
|
|
1289
|
+
);
|
|
1380
1290
|
}
|
|
1381
|
-
if (
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
i = skipString(content, i, cj);
|
|
1388
|
-
continue;
|
|
1389
|
-
}
|
|
1390
|
-
if (cj === "`") {
|
|
1391
|
-
i = skipTemplate(content, i);
|
|
1392
|
-
continue;
|
|
1393
|
-
}
|
|
1394
|
-
if (cj === "{") depth++;
|
|
1395
|
-
else if (cj === "}") depth--;
|
|
1396
|
-
i++;
|
|
1397
|
-
}
|
|
1398
|
-
continue;
|
|
1291
|
+
if (seen.has(state.name)) {
|
|
1292
|
+
throw new ExtractError(
|
|
1293
|
+
"uidex-export-duplicate-state",
|
|
1294
|
+
`Duplicate state "${state.name}" in \`states\`; each state name must be unique within the entity.`,
|
|
1295
|
+
item.pos
|
|
1296
|
+
);
|
|
1399
1297
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1298
|
+
seen.add(state.name);
|
|
1299
|
+
states.push(state);
|
|
1402
1300
|
}
|
|
1403
|
-
return
|
|
1301
|
+
return states;
|
|
1302
|
+
}
|
|
1303
|
+
function posAt(content, offset, p2) {
|
|
1304
|
+
const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
|
|
1305
|
+
return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
|
|
1306
|
+
}
|
|
1307
|
+
function removalSpan(content, start, end) {
|
|
1308
|
+
let e = end;
|
|
1309
|
+
while (e < content.length && /[ \t]/.test(content[e])) e++;
|
|
1310
|
+
if (content[e] === ",") return { start, end: e + 1 };
|
|
1311
|
+
let s = start;
|
|
1312
|
+
while (s > 0 && /[\s]/.test(content[s - 1])) s--;
|
|
1313
|
+
if (content[s - 1] === ",") return { start: s - 1, end };
|
|
1314
|
+
return { start, end };
|
|
1315
|
+
}
|
|
1316
|
+
function statementSpan(stmt, content) {
|
|
1317
|
+
let end = stmt.end;
|
|
1318
|
+
while (end < content.length && /[ \t]/.test(content[end])) end++;
|
|
1319
|
+
if (content[end] === ";") end++;
|
|
1320
|
+
while (end < content.length && /[ \t]/.test(content[end])) end++;
|
|
1321
|
+
if (content[end] === "\r") end++;
|
|
1322
|
+
if (content[end] === "\n") end++;
|
|
1323
|
+
return { start: stmt.start, end };
|
|
1324
|
+
}
|
|
1325
|
+
function isNode2(value) {
|
|
1326
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
1404
1327
|
}
|
|
1405
1328
|
|
|
1406
|
-
// src/scanner/scan/
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1329
|
+
// src/scanner/scan/flow-facts.ts
|
|
1330
|
+
function collectFlowFacts(parsed, content) {
|
|
1331
|
+
if (parsed.program === null || !content.includes("test.describe")) {
|
|
1332
|
+
return [];
|
|
1333
|
+
}
|
|
1334
|
+
const facts = [];
|
|
1335
|
+
walkAst(parsed.program, (node) => {
|
|
1336
|
+
if (node.type !== "CallExpression") return void 0;
|
|
1337
|
+
const fact = readTaggedDescribe(node, parsed);
|
|
1338
|
+
if (fact) facts.push(fact);
|
|
1339
|
+
return void 0;
|
|
1340
|
+
});
|
|
1341
|
+
return facts;
|
|
1414
1342
|
}
|
|
1415
|
-
function
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
const acceptance = [];
|
|
1420
|
-
const desc = [];
|
|
1421
|
-
let notFlow = false;
|
|
1422
|
-
for (const raw of lines) {
|
|
1423
|
-
const line = raw.trim();
|
|
1424
|
-
if (!line) continue;
|
|
1425
|
-
const uidex = line.match(
|
|
1426
|
-
/^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
|
|
1427
|
-
);
|
|
1428
|
-
if (uidex) {
|
|
1429
|
-
kind = uidex[1];
|
|
1430
|
-
id = uidex[2];
|
|
1431
|
-
if (uidex[3]) desc.push(uidex[3].trim());
|
|
1432
|
-
continue;
|
|
1433
|
-
}
|
|
1434
|
-
if (/^@uidex:not-flow\b/.test(line)) {
|
|
1435
|
-
notFlow = true;
|
|
1436
|
-
continue;
|
|
1437
|
-
}
|
|
1438
|
-
const accept = line.match(/^@acceptance\s+(.+)$/);
|
|
1439
|
-
if (accept) {
|
|
1440
|
-
acceptance.push(accept[1].trim());
|
|
1441
|
-
continue;
|
|
1442
|
-
}
|
|
1443
|
-
if (line.startsWith("@")) continue;
|
|
1444
|
-
desc.push(line);
|
|
1343
|
+
function readTaggedDescribe(call, parsed) {
|
|
1344
|
+
const callee = call.callee;
|
|
1345
|
+
if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
|
|
1346
|
+
return null;
|
|
1445
1347
|
}
|
|
1348
|
+
const args = call.arguments ?? [];
|
|
1349
|
+
const title = stringLiteralValue(args[0]);
|
|
1350
|
+
if (title === null) return null;
|
|
1351
|
+
if (!hasFlowTag(args[1])) return null;
|
|
1352
|
+
const body = args[2];
|
|
1353
|
+
const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
|
|
1446
1354
|
return {
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
notFlow
|
|
1355
|
+
title,
|
|
1356
|
+
line: parsed.lineAt(call.start),
|
|
1357
|
+
calls,
|
|
1358
|
+
...dynamicCalls.length > 0 ? { dynamicCalls } : {}
|
|
1452
1359
|
};
|
|
1453
1360
|
}
|
|
1454
|
-
function
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
if (
|
|
1463
|
-
return
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
const { content, displayPath } = file;
|
|
1469
|
-
for (const occ of collectJSXAncestry(content)) {
|
|
1470
|
-
annotations.push({
|
|
1471
|
-
kind: occ.kind,
|
|
1472
|
-
id: occ.id,
|
|
1473
|
-
file: displayPath,
|
|
1474
|
-
line: occ.line,
|
|
1475
|
-
...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
JSDOC_BLOCK.lastIndex = 0;
|
|
1479
|
-
let jm;
|
|
1480
|
-
while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
|
|
1481
|
-
const parsed = parseJSDoc(jm[1]);
|
|
1482
|
-
const line = lineAt(content, jm.index);
|
|
1483
|
-
if (parsed.notFlow) {
|
|
1484
|
-
annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
|
|
1485
|
-
}
|
|
1486
|
-
if (parsed.kind && parsed.id) {
|
|
1487
|
-
const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
|
|
1488
|
-
annotations.push({
|
|
1489
|
-
kind,
|
|
1490
|
-
id: parsed.id,
|
|
1491
|
-
file: displayPath,
|
|
1492
|
-
line,
|
|
1493
|
-
description: parsed.description || void 0,
|
|
1494
|
-
acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
|
|
1495
|
-
});
|
|
1496
|
-
} else if (parsed.acceptance.length > 0) {
|
|
1497
|
-
annotations.push({
|
|
1498
|
-
kind: "orphan-acceptance",
|
|
1499
|
-
id: "",
|
|
1500
|
-
file: displayPath,
|
|
1501
|
-
line,
|
|
1502
|
-
acceptance: parsed.acceptance
|
|
1503
|
-
});
|
|
1361
|
+
function hasFlowTag(node) {
|
|
1362
|
+
if (!node || node.type !== "ObjectExpression") return false;
|
|
1363
|
+
for (const prop of node.properties ?? []) {
|
|
1364
|
+
if (prop.type !== "Property") continue;
|
|
1365
|
+
const key = prop.key;
|
|
1366
|
+
const keyName = key?.type === "Identifier" ? String(key.name) : key?.type === "Literal" ? String(key.value) : null;
|
|
1367
|
+
if (keyName !== "tag") continue;
|
|
1368
|
+
const value = prop.value;
|
|
1369
|
+
if (!value) return false;
|
|
1370
|
+
if (stringLiteralValue(value) === "@uidex:flow") return true;
|
|
1371
|
+
if (value.type === "ArrayExpression") {
|
|
1372
|
+
for (const el of value.elements ?? []) {
|
|
1373
|
+
if (el && stringLiteralValue(el) === "@uidex:flow") return true;
|
|
1374
|
+
}
|
|
1504
1375
|
}
|
|
1376
|
+
return false;
|
|
1505
1377
|
}
|
|
1506
|
-
return
|
|
1378
|
+
return false;
|
|
1507
1379
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1380
|
+
function collectUidexCalls(body, parsed) {
|
|
1381
|
+
const calls = [];
|
|
1382
|
+
const dynamicCalls = [];
|
|
1383
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1384
|
+
const record = (node, action) => {
|
|
1385
|
+
if (claimed.has(node)) return;
|
|
1386
|
+
claimed.add(node);
|
|
1387
|
+
const resolved = uidexCallId(node);
|
|
1388
|
+
if (resolved === null) {
|
|
1389
|
+
dynamicCalls.push({ line: parsed.lineAt(node.start) });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
calls.push({
|
|
1393
|
+
id: resolved.id,
|
|
1394
|
+
...action ? { action } : {},
|
|
1395
|
+
line: parsed.lineAt(node.start),
|
|
1396
|
+
span: resolved.span
|
|
1397
|
+
});
|
|
1398
|
+
};
|
|
1399
|
+
walkAst(body, (node) => {
|
|
1400
|
+
if (node.type !== "CallExpression") return void 0;
|
|
1401
|
+
const callee = node.callee;
|
|
1402
|
+
if (callee?.type === "MemberExpression") {
|
|
1403
|
+
const inner = callee.object;
|
|
1404
|
+
if (inner && isUidexCall(inner)) {
|
|
1405
|
+
const property = callee.property;
|
|
1406
|
+
const action = property?.type === "Identifier" ? String(property.name) : void 0;
|
|
1407
|
+
record(inner, action);
|
|
1408
|
+
}
|
|
1409
|
+
return void 0;
|
|
1410
|
+
}
|
|
1411
|
+
if (isUidexCall(node)) record(node);
|
|
1412
|
+
return void 0;
|
|
1413
|
+
});
|
|
1414
|
+
return { calls, dynamicCalls };
|
|
1525
1415
|
}
|
|
1526
|
-
function
|
|
1527
|
-
|
|
1416
|
+
function isUidexCall(node) {
|
|
1417
|
+
if (node.type !== "CallExpression") return false;
|
|
1418
|
+
if (!isIdentifier(node.callee, "uidex")) return false;
|
|
1419
|
+
return (node.arguments ?? []).length >= 1;
|
|
1528
1420
|
}
|
|
1529
|
-
function
|
|
1530
|
-
|
|
1421
|
+
function uidexCallId(node) {
|
|
1422
|
+
const args = node.arguments ?? [];
|
|
1423
|
+
const arg = args[0];
|
|
1424
|
+
const id = stringLiteralValue(arg);
|
|
1425
|
+
if (id === null) return null;
|
|
1426
|
+
return { id, span: { start: arg.start, end: arg.end } };
|
|
1531
1427
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1428
|
+
function stringLiteralValue(node) {
|
|
1429
|
+
if (!node) return null;
|
|
1430
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
1431
|
+
return node.value.length > 0 ? node.value : null;
|
|
1432
|
+
}
|
|
1433
|
+
if (node.type === "TemplateLiteral") {
|
|
1434
|
+
const expressions = node.expressions ?? [];
|
|
1435
|
+
if (expressions.length > 0) return null;
|
|
1436
|
+
const quasis = node.quasis ?? [];
|
|
1437
|
+
const cooked = quasis[0]?.value?.cooked;
|
|
1438
|
+
return cooked && cooked.length > 0 ? cooked : null;
|
|
1538
1439
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
function
|
|
1542
|
-
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
function isIdentifier(node, name) {
|
|
1443
|
+
return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
|
|
1543
1444
|
}
|
|
1544
1445
|
|
|
1545
|
-
// src/
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
flow: /* @__PURE__ */ new Map()
|
|
1556
|
-
};
|
|
1446
|
+
// src/scanner/scan/jsx-ancestry.ts
|
|
1447
|
+
var ATTR_NAME_RE = /^data-uidex(?:-(region|widget|primitive))?$/;
|
|
1448
|
+
var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea"]);
|
|
1449
|
+
var LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "nav", "main", "aside", "footer"]);
|
|
1450
|
+
function attrKind(node) {
|
|
1451
|
+
const name = node.name;
|
|
1452
|
+
if (!name || name.type !== "JSXIdentifier") return null;
|
|
1453
|
+
const m = ATTR_NAME_RE.exec(String(name.name));
|
|
1454
|
+
if (!m) return null;
|
|
1455
|
+
return m[1] ?? "element";
|
|
1557
1456
|
}
|
|
1558
|
-
function
|
|
1559
|
-
const
|
|
1560
|
-
|
|
1561
|
-
|
|
1457
|
+
function collectConstStrings(program) {
|
|
1458
|
+
const consts = /* @__PURE__ */ new Map();
|
|
1459
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1460
|
+
walkAst(program, (node) => {
|
|
1461
|
+
if (node.type !== "VariableDeclaration" || node.kind !== "const") {
|
|
1462
|
+
return void 0;
|
|
1463
|
+
}
|
|
1464
|
+
for (const decl of node.declarations ?? []) {
|
|
1465
|
+
const id = decl.id;
|
|
1466
|
+
if (!id || id.type !== "Identifier") continue;
|
|
1467
|
+
const name = String(id.name);
|
|
1468
|
+
if (seen.has(name)) {
|
|
1469
|
+
consts.delete(name);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
seen.add(name);
|
|
1473
|
+
const init = decl.init;
|
|
1474
|
+
if (!init) continue;
|
|
1475
|
+
const value = staticString(unwrapTsExpression(init));
|
|
1476
|
+
if (value !== null) consts.set(name, value);
|
|
1477
|
+
}
|
|
1478
|
+
return void 0;
|
|
1479
|
+
});
|
|
1480
|
+
return consts;
|
|
1481
|
+
}
|
|
1482
|
+
function staticString(node) {
|
|
1483
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
1484
|
+
return node.value;
|
|
1562
1485
|
}
|
|
1563
|
-
|
|
1486
|
+
if (node.type === "TemplateLiteral") {
|
|
1487
|
+
const expressions = node.expressions ?? [];
|
|
1488
|
+
if (expressions.length > 0) return null;
|
|
1489
|
+
const quasis = node.quasis ?? [];
|
|
1490
|
+
return quasis[0]?.value?.cooked ?? "";
|
|
1491
|
+
}
|
|
1492
|
+
return null;
|
|
1564
1493
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1494
|
+
var UNRESOLVED = { resolved: false };
|
|
1495
|
+
function evalIdExpression(expr, consts) {
|
|
1496
|
+
const node = unwrapTsExpression(expr);
|
|
1497
|
+
const literal = staticString(node);
|
|
1498
|
+
if (literal !== null) {
|
|
1499
|
+
return literal.length > 0 ? { resolved: true, ids: [literal] } : UNRESOLVED;
|
|
1500
|
+
}
|
|
1501
|
+
if (node.type === "TemplateLiteral") {
|
|
1502
|
+
const quasis = node.quasis ?? [];
|
|
1503
|
+
const expressions = node.expressions ?? [];
|
|
1504
|
+
let out2 = "";
|
|
1505
|
+
for (let i = 0; i < quasis.length; i++) {
|
|
1506
|
+
out2 += quasis[i].value?.cooked ?? "";
|
|
1507
|
+
if (i < expressions.length) {
|
|
1508
|
+
const part = evalIdExpression(expressions[i], consts);
|
|
1509
|
+
out2 += part.resolved && part.ids.length === 1 ? part.ids[0] : "*";
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
out2 = out2.replace(/\*{2,}/g, "*");
|
|
1513
|
+
if (!out2.includes("*")) {
|
|
1514
|
+
return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
|
|
1515
|
+
}
|
|
1516
|
+
return out2.replace(/\*/g, "").length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
|
|
1517
|
+
}
|
|
1518
|
+
if (node.type === "Identifier") {
|
|
1519
|
+
const value = consts.get(String(node.name));
|
|
1520
|
+
return value !== void 0 && value.length > 0 ? { resolved: true, ids: [value] } : UNRESOLVED;
|
|
1521
|
+
}
|
|
1522
|
+
if (node.type === "ConditionalExpression") {
|
|
1523
|
+
const left = evalIdExpression(node.consequent, consts);
|
|
1524
|
+
const right = evalIdExpression(node.alternate, consts);
|
|
1525
|
+
if (!left.resolved || !right.resolved) return UNRESOLVED;
|
|
1526
|
+
return { resolved: true, ids: [.../* @__PURE__ */ new Set([...left.ids, ...right.ids])] };
|
|
1527
|
+
}
|
|
1528
|
+
return UNRESOLVED;
|
|
1572
1529
|
}
|
|
1573
|
-
function
|
|
1574
|
-
const
|
|
1575
|
-
|
|
1576
|
-
const
|
|
1577
|
-
const
|
|
1578
|
-
if (
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
entity
|
|
1530
|
+
function collectElementAttrs(opening, consts, dynamicAttrs, lineAt) {
|
|
1531
|
+
const statics = [];
|
|
1532
|
+
const patterns = [];
|
|
1533
|
+
const attributes = opening.attributes ?? [];
|
|
1534
|
+
for (const attr of attributes) {
|
|
1535
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
1536
|
+
const kind = attrKind(attr);
|
|
1537
|
+
if (!kind) continue;
|
|
1538
|
+
const value = attr.value;
|
|
1539
|
+
if (!value) continue;
|
|
1540
|
+
let result = UNRESOLVED;
|
|
1541
|
+
let valueSpan;
|
|
1542
|
+
if (value.type === "Literal") {
|
|
1543
|
+
const v = staticString(value);
|
|
1544
|
+
result = v !== null && v.length > 0 ? { resolved: true, ids: [v] } : UNRESOLVED;
|
|
1545
|
+
if (result.resolved) valueSpan = { start: value.start, end: value.end };
|
|
1546
|
+
} else if (value.type === "JSXExpressionContainer") {
|
|
1547
|
+
const expr = value.expression;
|
|
1548
|
+
if (expr && expr.type !== "JSXEmptyExpression") {
|
|
1549
|
+
result = evalIdExpression(expr, consts);
|
|
1550
|
+
const inner = unwrapTsExpression(expr);
|
|
1551
|
+
if (result.resolved && staticString(inner) !== null) {
|
|
1552
|
+
valueSpan = { start: inner.start, end: inner.end };
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (!result.resolved) {
|
|
1556
|
+
dynamicAttrs.push({
|
|
1557
|
+
kind,
|
|
1558
|
+
attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
|
|
1559
|
+
line: lineAt(attr.start)
|
|
1604
1560
|
});
|
|
1561
|
+
continue;
|
|
1605
1562
|
}
|
|
1606
1563
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1564
|
+
if (!result.resolved) continue;
|
|
1565
|
+
for (const id of result.ids) {
|
|
1566
|
+
const resolved = {
|
|
1567
|
+
kind,
|
|
1568
|
+
id,
|
|
1569
|
+
start: attr.start,
|
|
1570
|
+
isPattern: id.includes("*"),
|
|
1571
|
+
// Only a single plain string literal is renameable in place.
|
|
1572
|
+
...result.ids.length === 1 && valueSpan ? { span: valueSpan } : {}
|
|
1573
|
+
};
|
|
1574
|
+
if (resolved.isPattern) patterns.push(resolved);
|
|
1575
|
+
else statics.push(resolved);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return [...statics, ...patterns];
|
|
1579
|
+
}
|
|
1580
|
+
function collectJSXFacts(parsed) {
|
|
1581
|
+
const occurrences = [];
|
|
1582
|
+
const dynamicAttrs = [];
|
|
1583
|
+
const unannotatedInteractive = [];
|
|
1584
|
+
const landmarks = [];
|
|
1585
|
+
if (parsed.program === null) {
|
|
1586
|
+
return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
|
|
1587
|
+
}
|
|
1588
|
+
const consts = collectConstStrings(parsed.program);
|
|
1589
|
+
const ancestors = [];
|
|
1590
|
+
const visit = (node) => {
|
|
1591
|
+
if (!isNode3(node)) return;
|
|
1592
|
+
if (node.type === "JSXElement") {
|
|
1593
|
+
const opening = node.openingElement;
|
|
1594
|
+
const attrs = collectElementAttrs(
|
|
1595
|
+
opening,
|
|
1596
|
+
consts,
|
|
1597
|
+
dynamicAttrs,
|
|
1598
|
+
parsed.lineAt
|
|
1599
|
+
);
|
|
1600
|
+
const interactive = readInteractive(node, parsed.lineAt);
|
|
1601
|
+
if (interactive) unannotatedInteractive.push(interactive);
|
|
1602
|
+
if (attrs.length > 0) {
|
|
1603
|
+
const snapshot = ancestors.slice();
|
|
1604
|
+
for (const a of attrs) {
|
|
1605
|
+
occurrences.push({
|
|
1606
|
+
kind: a.kind,
|
|
1607
|
+
id: a.id,
|
|
1608
|
+
line: parsed.lineAt(a.start),
|
|
1609
|
+
ancestors: snapshot,
|
|
1610
|
+
...a.span ? { span: a.span } : {}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
let pushed = attrs.length;
|
|
1615
|
+
for (const a of attrs) ancestors.push({ kind: a.kind, id: a.id });
|
|
1616
|
+
const landmark = readLandmark(opening, parsed.lineAt);
|
|
1617
|
+
if (landmark) {
|
|
1618
|
+
landmarks.push(landmark);
|
|
1619
|
+
if (!attrs.some((a) => a.kind === "region")) {
|
|
1620
|
+
ancestors.push({ kind: "region", id: landmark.tag });
|
|
1621
|
+
pushed++;
|
|
1622
|
+
}
|
|
1621
1623
|
}
|
|
1624
|
+
visitChildren(opening);
|
|
1625
|
+
for (const child of node.children ?? []) {
|
|
1626
|
+
visit(child);
|
|
1627
|
+
}
|
|
1628
|
+
const closing = node.closingElement;
|
|
1629
|
+
if (isNode3(closing)) visitChildren(closing);
|
|
1630
|
+
ancestors.length -= pushed;
|
|
1631
|
+
return;
|
|
1622
1632
|
}
|
|
1623
|
-
|
|
1624
|
-
return freezeEntity(best.entity, getFlows());
|
|
1625
|
-
};
|
|
1626
|
-
const list = (kind) => {
|
|
1627
|
-
assertEntityKind(kind);
|
|
1628
|
-
const flows = getFlows();
|
|
1629
|
-
return Array.from(
|
|
1630
|
-
store[kind].values(),
|
|
1631
|
-
(e) => freezeEntity(e, flows)
|
|
1632
|
-
);
|
|
1633
|
+
visitChildren(node);
|
|
1633
1634
|
};
|
|
1634
|
-
const
|
|
1635
|
-
for (const
|
|
1636
|
-
|
|
1637
|
-
|
|
1635
|
+
const visitChildren = (node) => {
|
|
1636
|
+
for (const key of Object.keys(node)) {
|
|
1637
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
1638
|
+
const value = node[key];
|
|
1639
|
+
if (Array.isArray(value)) {
|
|
1640
|
+
for (const item of value) visit(item);
|
|
1641
|
+
} else {
|
|
1642
|
+
visit(value);
|
|
1638
1643
|
}
|
|
1639
1644
|
}
|
|
1640
1645
|
};
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
+
visit(parsed.program);
|
|
1647
|
+
return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
|
|
1648
|
+
}
|
|
1649
|
+
function readLandmark(opening, lineAt) {
|
|
1650
|
+
const name = opening.name;
|
|
1651
|
+
if (!name || name.type !== "JSXIdentifier") return null;
|
|
1652
|
+
const tag = String(name.name);
|
|
1653
|
+
if (LANDMARK_TAGS.has(tag)) {
|
|
1654
|
+
return { tag, line: lineAt(opening.start) };
|
|
1655
|
+
}
|
|
1656
|
+
for (const attr of opening.attributes ?? []) {
|
|
1657
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
1658
|
+
const attrName = attr.name;
|
|
1659
|
+
if (!attrName || String(attrName.name) !== "role") continue;
|
|
1660
|
+
const value = attr.value;
|
|
1661
|
+
if (value && value.type === "Literal" && value.value === "region") {
|
|
1662
|
+
return { tag: "region", line: lineAt(opening.start) };
|
|
1646
1663
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
const
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
for (const cb of reportsCbs) cb();
|
|
1666
|
-
};
|
|
1667
|
-
const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
|
|
1668
|
-
const listReportKeys = () => Array.from(reports.keys());
|
|
1669
|
-
const onReportsChange = (cb) => {
|
|
1670
|
-
reportsCbs.add(cb);
|
|
1671
|
-
return () => reportsCbs.delete(cb);
|
|
1672
|
-
};
|
|
1664
|
+
}
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1667
|
+
function readInteractive(element, lineAt) {
|
|
1668
|
+
const opening = element.openingElement;
|
|
1669
|
+
const name = opening.name;
|
|
1670
|
+
if (!name || name.type !== "JSXIdentifier") return null;
|
|
1671
|
+
const tag = String(name.name);
|
|
1672
|
+
if (!INTERACTIVE_TAGS.has(tag)) return null;
|
|
1673
|
+
let hasSpread = false;
|
|
1674
|
+
for (const attr of opening.attributes ?? []) {
|
|
1675
|
+
if (attr.type === "JSXSpreadAttribute") {
|
|
1676
|
+
hasSpread = true;
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
if (attr.type === "JSXAttribute" && attrKind(attr) !== null) return null;
|
|
1680
|
+
}
|
|
1681
|
+
const nameHint = interactiveNameHint(element, opening);
|
|
1673
1682
|
return {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1683
|
+
tag,
|
|
1684
|
+
line: lineAt(opening.start),
|
|
1685
|
+
hasSpread,
|
|
1686
|
+
nameEnd: name.end,
|
|
1687
|
+
...nameHint ? { nameHint } : {}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
function staticAttrValue(opening, attrName) {
|
|
1691
|
+
for (const attr of opening.attributes ?? []) {
|
|
1692
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
1693
|
+
const n = attr.name;
|
|
1694
|
+
if (!n || String(n.name) !== attrName) continue;
|
|
1695
|
+
const value = attr.value;
|
|
1696
|
+
if (!value) return null;
|
|
1697
|
+
if (value.type === "Literal") return staticString(value);
|
|
1698
|
+
if (value.type === "JSXExpressionContainer") {
|
|
1699
|
+
const expr = value.expression;
|
|
1700
|
+
return expr ? staticString(unwrapTsExpression(expr)) : null;
|
|
1701
|
+
}
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
function staticChildText(element) {
|
|
1707
|
+
const parts = [];
|
|
1708
|
+
for (const child of element.children ?? []) {
|
|
1709
|
+
if (child.type === "JSXText") {
|
|
1710
|
+
parts.push(String(child.value ?? ""));
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
1714
|
+
}
|
|
1715
|
+
function interactiveNameHint(element, opening) {
|
|
1716
|
+
const ariaLabel = staticAttrValue(opening, "aria-label");
|
|
1717
|
+
if (ariaLabel) return ariaLabel;
|
|
1718
|
+
const text = staticChildText(element);
|
|
1719
|
+
if (text) return text;
|
|
1720
|
+
for (const attr of ["title", "name", "placeholder"]) {
|
|
1721
|
+
const v = staticAttrValue(opening, attr);
|
|
1722
|
+
if (v) return v;
|
|
1723
|
+
}
|
|
1724
|
+
return void 0;
|
|
1725
|
+
}
|
|
1726
|
+
function isNode3(value) {
|
|
1727
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/scanner/scan/extract.ts
|
|
1731
|
+
function parseFailureDiagnostic(file, parsed) {
|
|
1732
|
+
const fatal = parsed.program === null || parsed.hasErrors && parsed.program.body.length === 0;
|
|
1733
|
+
if (!fatal) return null;
|
|
1734
|
+
return {
|
|
1735
|
+
code: "parse-error",
|
|
1736
|
+
severity: "warning",
|
|
1737
|
+
message: "File could not be parsed \u2014 data-uidex attributes and flow facts in it were skipped, and their ids will drop out of the gen file",
|
|
1738
|
+
file: file.displayPath,
|
|
1739
|
+
line: 1,
|
|
1740
|
+
hint: "Fix the file's syntax (or exclude it from .uidex.json sources) so the scanner can read its annotations"
|
|
1685
1741
|
};
|
|
1686
1742
|
}
|
|
1743
|
+
function extract(files) {
|
|
1744
|
+
return files.map((file) => {
|
|
1745
|
+
const parsed = parseSource(file);
|
|
1746
|
+
const { exports, diagnostics } = extractUidexExports(file, parsed);
|
|
1747
|
+
const parseFailure = parseFailureDiagnostic(file, parsed);
|
|
1748
|
+
if (parseFailure) diagnostics.push(parseFailure);
|
|
1749
|
+
const out2 = { file, annotations: [] };
|
|
1750
|
+
out2.annotations = extractOne(file, parsed, out2);
|
|
1751
|
+
if (exports.length > 0) out2.metadata = exports;
|
|
1752
|
+
if (diagnostics.length > 0) out2.diagnostics = diagnostics;
|
|
1753
|
+
const flows = collectFlowFacts(parsed, file.content);
|
|
1754
|
+
if (flows.length > 0) out2.flows = flows;
|
|
1755
|
+
const imports = collectImportFacts(parsed);
|
|
1756
|
+
if (imports.length > 0) out2.imports = imports;
|
|
1757
|
+
return out2;
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
function extractOne(file, parsed, out2) {
|
|
1761
|
+
const annotations = [];
|
|
1762
|
+
const { displayPath } = file;
|
|
1763
|
+
const jsx = collectJSXFacts(parsed);
|
|
1764
|
+
if (jsx.dynamicAttrs.length > 0) out2.dynamicAttrs = jsx.dynamicAttrs;
|
|
1765
|
+
if (jsx.unannotatedInteractive.length > 0) {
|
|
1766
|
+
out2.unannotatedInteractive = jsx.unannotatedInteractive;
|
|
1767
|
+
}
|
|
1768
|
+
if (jsx.landmarks.length > 0) out2.landmarks = jsx.landmarks;
|
|
1769
|
+
for (const occ of jsx.occurrences) {
|
|
1770
|
+
annotations.push({
|
|
1771
|
+
kind: occ.kind,
|
|
1772
|
+
id: occ.id,
|
|
1773
|
+
file: displayPath,
|
|
1774
|
+
line: occ.line,
|
|
1775
|
+
...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
|
|
1776
|
+
...occ.span ? { span: occ.span } : {}
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
return annotations;
|
|
1780
|
+
}
|
|
1781
|
+
function collectImportFacts(parsed) {
|
|
1782
|
+
if (parsed.program === null) return [];
|
|
1783
|
+
const out2 = [];
|
|
1784
|
+
for (const stmt of parsed.program.body) {
|
|
1785
|
+
let source;
|
|
1786
|
+
let isTypeOnly = false;
|
|
1787
|
+
const names = [];
|
|
1788
|
+
if (stmt.type === "ImportDeclaration") {
|
|
1789
|
+
source = stmt.source;
|
|
1790
|
+
isTypeOnly = stmt.importKind === "type";
|
|
1791
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
1792
|
+
const local = spec.local;
|
|
1793
|
+
if (local && local.type === "Identifier") {
|
|
1794
|
+
names.push(String(local.name));
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
} else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
|
|
1798
|
+
source = stmt.source;
|
|
1799
|
+
isTypeOnly = stmt.exportKind === "type";
|
|
1800
|
+
} else {
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (!source || source.type !== "Literal") continue;
|
|
1804
|
+
if (typeof source.value !== "string") continue;
|
|
1805
|
+
out2.push({
|
|
1806
|
+
specifier: source.value,
|
|
1807
|
+
line: parsed.lineAt(stmt.start),
|
|
1808
|
+
span: { start: stmt.start, end: stmt.end },
|
|
1809
|
+
isTypeOnly,
|
|
1810
|
+
names
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
return out2;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// src/scanner/scan/resolve.ts
|
|
1817
|
+
import * as path4 from "path";
|
|
1687
1818
|
|
|
1688
1819
|
// src/scanner/scan/routes.ts
|
|
1689
1820
|
var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
|
|
@@ -1771,21 +1902,9 @@ function kebab(str) {
|
|
|
1771
1902
|
return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
|
|
1772
1903
|
}
|
|
1773
1904
|
function baseName(file) {
|
|
1774
|
-
const b =
|
|
1905
|
+
const b = path4.posix.basename(file);
|
|
1775
1906
|
return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
|
|
1776
1907
|
}
|
|
1777
|
-
var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
|
|
1778
|
-
function extractLandmarks(file) {
|
|
1779
|
-
const out2 = [];
|
|
1780
|
-
LANDMARK_RE.lastIndex = 0;
|
|
1781
|
-
let m;
|
|
1782
|
-
while ((m = LANDMARK_RE.exec(file.content)) !== null) {
|
|
1783
|
-
const tag = m[1] ?? "region";
|
|
1784
|
-
const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
|
|
1785
|
-
out2.push({ tag, line });
|
|
1786
|
-
}
|
|
1787
|
-
return out2;
|
|
1788
|
-
}
|
|
1789
1908
|
function fileMatchesAny(displayPath, patterns) {
|
|
1790
1909
|
return patterns.some((g) => globToRegExp(g).test(displayPath));
|
|
1791
1910
|
}
|
|
@@ -1796,6 +1915,18 @@ function buildMetaFromExport(exp) {
|
|
|
1796
1915
|
if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
|
|
1797
1916
|
if (exp.features?.length) meta.features = exp.features;
|
|
1798
1917
|
if (exp.widgets?.length) meta.widgets = exp.widgets;
|
|
1918
|
+
if (exp.states?.length) {
|
|
1919
|
+
meta.states = exp.states.map((s) => ({
|
|
1920
|
+
name: s.name,
|
|
1921
|
+
...s.kind ? { kind: s.kind } : {},
|
|
1922
|
+
...s.acceptance?.length ? { acceptance: s.acceptance } : {},
|
|
1923
|
+
...s.description ? { description: s.description } : {}
|
|
1924
|
+
}));
|
|
1925
|
+
}
|
|
1926
|
+
if (exp.capture === false) meta.capture = false;
|
|
1927
|
+
if (exp.waivers && Object.keys(exp.waivers).length > 0) {
|
|
1928
|
+
meta.waivers = exp.waivers;
|
|
1929
|
+
}
|
|
1799
1930
|
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
1800
1931
|
}
|
|
1801
1932
|
function resolve2(ctx) {
|
|
@@ -1846,7 +1977,7 @@ function resolve2(ctx) {
|
|
|
1846
1977
|
const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
|
|
1847
1978
|
const handledPageFiles = /* @__PURE__ */ new Set();
|
|
1848
1979
|
for (const route of routes) {
|
|
1849
|
-
const routeDir =
|
|
1980
|
+
const routeDir = path4.posix.dirname(route.file);
|
|
1850
1981
|
const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
|
|
1851
1982
|
const wellKnownExp = exportFor(wellKnownPath, "page");
|
|
1852
1983
|
const routeExp = exportFor(route.file, "page");
|
|
@@ -1900,7 +2031,7 @@ function resolve2(ctx) {
|
|
|
1900
2031
|
const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
|
|
1901
2032
|
if (!dir) continue;
|
|
1902
2033
|
conventionalFeatureDirs.add(dir);
|
|
1903
|
-
const isWellKnown =
|
|
2034
|
+
const isWellKnown = path4.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
|
|
1904
2035
|
if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
|
|
1905
2036
|
const exp = exportFor(ef.file.displayPath, "feature");
|
|
1906
2037
|
if (exp) {
|
|
@@ -1937,7 +2068,7 @@ function resolve2(ctx) {
|
|
|
1937
2068
|
} else if (allExports.length > 0) {
|
|
1938
2069
|
exp = allExports[0].exp;
|
|
1939
2070
|
}
|
|
1940
|
-
const id = exp && typeof exp.id === "string" ? exp.id :
|
|
2071
|
+
const id = exp && typeof exp.id === "string" ? exp.id : path4.posix.basename(dir);
|
|
1941
2072
|
const meta = exp ? buildMetaFromExport(exp) : void 0;
|
|
1942
2073
|
const feature = {
|
|
1943
2074
|
kind: "feature",
|
|
@@ -2033,8 +2164,8 @@ function resolve2(ctx) {
|
|
|
2033
2164
|
}
|
|
2034
2165
|
if (conventions.regions === "landmarks") {
|
|
2035
2166
|
for (const ef of ctx.extracted) {
|
|
2036
|
-
for (const lm of
|
|
2037
|
-
const id =
|
|
2167
|
+
for (const lm of ef.landmarks ?? []) {
|
|
2168
|
+
const id = lm.tag;
|
|
2038
2169
|
if (!registry.get("region", id)) {
|
|
2039
2170
|
const meta = metaWithComposes("region", id);
|
|
2040
2171
|
const region = {
|
|
@@ -2145,7 +2276,7 @@ function resolve2(ctx) {
|
|
|
2145
2276
|
const flowExport = (ff.metadata ?? []).find(
|
|
2146
2277
|
(m) => m.kind === "flow" && typeof m.id === "string"
|
|
2147
2278
|
);
|
|
2148
|
-
const derived =
|
|
2279
|
+
const derived = flowsFromFacts(ff);
|
|
2149
2280
|
if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
|
|
2150
2281
|
const base = derived[0];
|
|
2151
2282
|
const flow = {
|
|
@@ -2200,60 +2331,312 @@ function computeScope(displayPath) {
|
|
|
2200
2331
|
}
|
|
2201
2332
|
return null;
|
|
2202
2333
|
}
|
|
2203
|
-
function
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2334
|
+
function flowsFromFacts(ff) {
|
|
2335
|
+
return (ff.flows ?? []).map((fact) => ({
|
|
2336
|
+
kind: "flow",
|
|
2337
|
+
id: kebab(fact.title),
|
|
2338
|
+
loc: { file: ff.file.displayPath, line: fact.line },
|
|
2339
|
+
touches: dedupe(fact.calls.map((c) => c.id)),
|
|
2340
|
+
steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
|
|
2341
|
+
}));
|
|
2342
|
+
}
|
|
2343
|
+
function dedupe(arr) {
|
|
2344
|
+
return Array.from(new Set(arr));
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/scanner/scan/audit.ts
|
|
2348
|
+
import * as path5 from "path";
|
|
2349
|
+
|
|
2350
|
+
// src/scanner/scan/page-coverage.ts
|
|
2351
|
+
function compileRoute(pattern) {
|
|
2352
|
+
const segs = pattern.split("/").filter(Boolean);
|
|
2353
|
+
let specificity = 0;
|
|
2354
|
+
const optionalTail = segs.length > 0 && /^\[\[\.{3}.+\]\]$/.test(segs[segs.length - 1]);
|
|
2355
|
+
const effective = optionalTail ? segs.slice(0, -1) : segs;
|
|
2356
|
+
const parts = effective.map((seg) => {
|
|
2357
|
+
const catchAll = /^\[\.{3}.+\]$/.test(seg);
|
|
2358
|
+
const dynamic = /^\[.+\]$/.test(seg);
|
|
2359
|
+
if (catchAll) return "(?:[^/]+)(?:/[^/]+)*";
|
|
2360
|
+
if (dynamic) return "[^/]+";
|
|
2361
|
+
specificity++;
|
|
2362
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2363
|
+
});
|
|
2364
|
+
let body = parts.length === 0 ? "" : "/" + parts.join("/");
|
|
2365
|
+
if (optionalTail) body += "(?:/[^/]+)*";
|
|
2366
|
+
if (body === "") body = "/";
|
|
2367
|
+
const re = new RegExp(`^${body}/?$`);
|
|
2368
|
+
return { test: (url) => re.test(url), specificity };
|
|
2369
|
+
}
|
|
2370
|
+
function matchUrlToRoute(url, routePaths) {
|
|
2371
|
+
const path12 = stripQuery(url);
|
|
2372
|
+
let best = null;
|
|
2373
|
+
for (const rp of routePaths) {
|
|
2374
|
+
const { test, specificity } = compileRoute(rp);
|
|
2375
|
+
if (!test(path12)) continue;
|
|
2376
|
+
if (!best || specificity > best.specificity)
|
|
2377
|
+
best = { path: rp, specificity };
|
|
2378
|
+
}
|
|
2379
|
+
return best?.path ?? null;
|
|
2380
|
+
}
|
|
2381
|
+
function stripQuery(url) {
|
|
2382
|
+
const q = url.indexOf("?");
|
|
2383
|
+
const h = url.indexOf("#");
|
|
2384
|
+
let end = url.length;
|
|
2385
|
+
if (q !== -1) end = Math.min(end, q);
|
|
2386
|
+
if (h !== -1) end = Math.min(end, h);
|
|
2387
|
+
return url.slice(0, end);
|
|
2388
|
+
}
|
|
2389
|
+
function routeForCapture(c, routes, routePaths) {
|
|
2390
|
+
if (c.route && routePaths.includes(c.route)) return c.route;
|
|
2391
|
+
if (c.url) {
|
|
2392
|
+
const m = matchUrlToRoute(c.url, routePaths);
|
|
2393
|
+
if (m) return m;
|
|
2394
|
+
}
|
|
2395
|
+
if (c.kind === "page") {
|
|
2396
|
+
const r = routes.find((r2) => r2.page === c.entity);
|
|
2397
|
+
if (r) return r.path;
|
|
2398
|
+
}
|
|
2399
|
+
return null;
|
|
2400
|
+
}
|
|
2401
|
+
function checkPageCoverage(registry, manifest, baseline) {
|
|
2402
|
+
const routes = registry.list("route");
|
|
2403
|
+
if (routes.length === 0) return [];
|
|
2404
|
+
const routePaths = routes.map((r) => r.path);
|
|
2405
|
+
const captured = manifest.captured ?? [];
|
|
2406
|
+
const optedOut = /* @__PURE__ */ new Set();
|
|
2407
|
+
for (const r of routes) {
|
|
2408
|
+
const page = registry.get("page", r.page);
|
|
2409
|
+
if (page?.meta?.capture === false) optedOut.add(r.path);
|
|
2410
|
+
}
|
|
2411
|
+
const covered = /* @__PURE__ */ new Set();
|
|
2412
|
+
for (const c of captured) {
|
|
2413
|
+
const route = routeForCapture(c, routes, routePaths);
|
|
2414
|
+
if (route) covered.add(route);
|
|
2415
|
+
}
|
|
2416
|
+
const baselineSet = new Set(baseline?.uncapturedPages ?? []);
|
|
2417
|
+
const diagnostics = [];
|
|
2418
|
+
for (const r of routes) {
|
|
2419
|
+
if (covered.has(r.path) || optedOut.has(r.path)) continue;
|
|
2420
|
+
const loc = registry.get("page", r.page)?.loc;
|
|
2421
|
+
if (baselineSet.has(r.path)) {
|
|
2422
|
+
diagnostics.push({
|
|
2423
|
+
code: "page-backlog",
|
|
2424
|
+
severity: "info",
|
|
2425
|
+
message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
|
|
2426
|
+
file: loc?.file,
|
|
2427
|
+
line: loc?.line,
|
|
2428
|
+
hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`.`
|
|
2429
|
+
});
|
|
2430
|
+
continue;
|
|
2227
2431
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
|
|
2432
|
+
diagnostics.push({
|
|
2433
|
+
code: "page-no-capture",
|
|
2434
|
+
severity: "error",
|
|
2435
|
+
message: `route "${r.path}" has no visual-states capture and is not accounted for`,
|
|
2436
|
+
file: loc?.file,
|
|
2437
|
+
line: loc?.line,
|
|
2438
|
+
entity: { kind: "route", id: r.path },
|
|
2439
|
+
hint: `Capture the route in a states spec, opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`, or grandfather it as acknowledged backlog with \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
|
|
2237
2440
|
});
|
|
2238
2441
|
}
|
|
2239
|
-
|
|
2442
|
+
for (const b of baselineSet) {
|
|
2443
|
+
if (!routePaths.includes(b)) {
|
|
2444
|
+
diagnostics.push({
|
|
2445
|
+
code: "stale-page-baseline",
|
|
2446
|
+
severity: "warning",
|
|
2447
|
+
message: `coverage baseline lists "${b}", which is not a route in the registry`,
|
|
2448
|
+
hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
|
|
2449
|
+
});
|
|
2450
|
+
} else if (covered.has(b) || optedOut.has(b)) {
|
|
2451
|
+
diagnostics.push({
|
|
2452
|
+
code: "page-captured",
|
|
2453
|
+
severity: "warning",
|
|
2454
|
+
message: `route "${b}" is now captured but is still in the coverage baseline`,
|
|
2455
|
+
hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
return diagnostics;
|
|
2240
2460
|
}
|
|
2241
|
-
function
|
|
2461
|
+
function computePageBaseline(registry, manifest) {
|
|
2462
|
+
const uncaptured = [];
|
|
2463
|
+
for (const d of checkPageCoverage(registry, manifest, {
|
|
2464
|
+
uncapturedPages: []
|
|
2465
|
+
})) {
|
|
2466
|
+
if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
|
|
2467
|
+
}
|
|
2468
|
+
return { uncapturedPages: uncaptured.sort() };
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// src/scanner/scan/state-coverage.ts
|
|
2472
|
+
var STATE_KINDS2 = ["page", "feature", "widget"];
|
|
2473
|
+
function declaredStateEntities(registry) {
|
|
2242
2474
|
const out2 = [];
|
|
2243
|
-
const
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2475
|
+
for (const kind of STATE_KINDS2) {
|
|
2476
|
+
for (const e of registry.list(kind)) {
|
|
2477
|
+
const states = e.meta?.states;
|
|
2478
|
+
if (!states || states.length === 0) continue;
|
|
2479
|
+
out2.push({
|
|
2480
|
+
kind,
|
|
2481
|
+
id: e.id,
|
|
2482
|
+
states: states.map((s) => s.name),
|
|
2483
|
+
loc: e.loc
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2247
2486
|
}
|
|
2248
2487
|
return out2;
|
|
2249
2488
|
}
|
|
2250
|
-
function
|
|
2251
|
-
|
|
2489
|
+
function captureMatchesEntity(cap, ent) {
|
|
2490
|
+
if (cap.entity !== ent.id) return false;
|
|
2491
|
+
return cap.kind === void 0 || cap.kind === ent.kind;
|
|
2492
|
+
}
|
|
2493
|
+
function checkStateCoverage(registry, manifest) {
|
|
2494
|
+
const diagnostics = [];
|
|
2495
|
+
const declared = declaredStateEntities(registry);
|
|
2496
|
+
const captured = manifest.captured ?? [];
|
|
2497
|
+
for (const ent of declared) {
|
|
2498
|
+
for (const state of ent.states) {
|
|
2499
|
+
const hit = captured.some(
|
|
2500
|
+
(c) => captureMatchesEntity(c, ent) && c.state === state
|
|
2501
|
+
);
|
|
2502
|
+
if (hit) continue;
|
|
2503
|
+
diagnostics.push({
|
|
2504
|
+
code: "missing-state-capture",
|
|
2505
|
+
severity: "warning",
|
|
2506
|
+
message: `${ent.kind} "${ent.id}" declares state "${state}" but no capture produced it`,
|
|
2507
|
+
file: ent.loc?.file,
|
|
2508
|
+
line: ent.loc?.line,
|
|
2509
|
+
entity: { kind: ent.kind, id: ent.id },
|
|
2510
|
+
hint: `Add a capture for "${ent.id}/${state}" to the states matrix, or remove the state from \`export const uidex = { states: [...] }\` if it no longer exists.`
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
for (const cap of captured) {
|
|
2515
|
+
const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
|
|
2516
|
+
if (candidates.length === 0) continue;
|
|
2517
|
+
const declaredSomewhere = candidates.some(
|
|
2518
|
+
(ent2) => ent2.states.includes(cap.state)
|
|
2519
|
+
);
|
|
2520
|
+
if (declaredSomewhere) continue;
|
|
2521
|
+
const ent = candidates[0];
|
|
2522
|
+
diagnostics.push({
|
|
2523
|
+
code: "orphan-state-capture",
|
|
2524
|
+
severity: "warning",
|
|
2525
|
+
message: `Capture produced state "${cap.state}" for ${ent.kind} "${ent.id}", which is not in its declared states [${ent.states.join(", ")}]`,
|
|
2526
|
+
file: ent.loc?.file,
|
|
2527
|
+
line: ent.loc?.line,
|
|
2528
|
+
entity: { kind: ent.kind, id: ent.id },
|
|
2529
|
+
hint: `Declare "${cap.state}" in \`export const uidex = { states: [...] }\` for "${ent.id}", or rename the capture to a declared state.`
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
return diagnostics;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// src/scanner/scan/state-matrix.ts
|
|
2536
|
+
var CORE_KINDS = CORE_STATE_KINDS;
|
|
2537
|
+
var STATE_ENTITY_KINDS = ["page", "feature", "widget"];
|
|
2538
|
+
function resolveMatrixKind(registry, c) {
|
|
2539
|
+
if (c.stateKind) return c.stateKind;
|
|
2540
|
+
for (const kind of STATE_ENTITY_KINDS) {
|
|
2541
|
+
const declared = registry.get(kind, c.entity)?.meta?.states?.find((s) => s.name === c.state)?.kind;
|
|
2542
|
+
if (declared) return declared;
|
|
2543
|
+
}
|
|
2544
|
+
return CORE_KINDS.includes(c.state) ? c.state : "variant";
|
|
2545
|
+
}
|
|
2546
|
+
function coreKindsByRoute(registry, manifest) {
|
|
2547
|
+
const routes = registry.list("route");
|
|
2548
|
+
const routePaths = routes.map((r) => r.path);
|
|
2549
|
+
const byRoute = /* @__PURE__ */ new Map();
|
|
2550
|
+
for (const c of manifest.captured ?? []) {
|
|
2551
|
+
const route = routeForCapture(c, routes, routePaths);
|
|
2552
|
+
if (!route) continue;
|
|
2553
|
+
if (!byRoute.has(route)) byRoute.set(route, /* @__PURE__ */ new Set());
|
|
2554
|
+
const kind = resolveMatrixKind(registry, c);
|
|
2555
|
+
if (CORE_KINDS.includes(kind)) byRoute.get(route).add(kind);
|
|
2556
|
+
}
|
|
2557
|
+
return byRoute;
|
|
2558
|
+
}
|
|
2559
|
+
function pageWaivers(registry, routePage) {
|
|
2560
|
+
return registry.get("page", routePage)?.meta?.waivers ?? {};
|
|
2561
|
+
}
|
|
2562
|
+
function isOptedOut(registry, page) {
|
|
2563
|
+
return page ? registry.get("page", page)?.meta?.capture === false : false;
|
|
2564
|
+
}
|
|
2565
|
+
function checkStateMatrix(registry, manifest, baseline) {
|
|
2566
|
+
const routes = registry.list("route");
|
|
2567
|
+
if (routes.length === 0) return [];
|
|
2568
|
+
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
2569
|
+
const captured = coreKindsByRoute(registry, manifest);
|
|
2570
|
+
const baselined = baseline?.missingKinds ?? {};
|
|
2571
|
+
const diagnostics = [];
|
|
2572
|
+
for (const [route, kinds] of captured) {
|
|
2573
|
+
const page = pageOf.get(route);
|
|
2574
|
+
if (isOptedOut(registry, page)) continue;
|
|
2575
|
+
const waivers = page ? pageWaivers(registry, page) : {};
|
|
2576
|
+
const loc = page ? registry.get("page", page)?.loc : void 0;
|
|
2577
|
+
const baselinedKinds = new Set(baselined[route] ?? []);
|
|
2578
|
+
for (const kind of CORE_KINDS) {
|
|
2579
|
+
if (kinds.has(kind)) continue;
|
|
2580
|
+
if (waivers[kind]) continue;
|
|
2581
|
+
if (baselinedKinds.has(kind)) {
|
|
2582
|
+
diagnostics.push({
|
|
2583
|
+
code: "matrix-backlog",
|
|
2584
|
+
severity: "info",
|
|
2585
|
+
message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
|
|
2586
|
+
file: loc?.file,
|
|
2587
|
+
line: loc?.line,
|
|
2588
|
+
hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
|
|
2589
|
+
});
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
diagnostics.push({
|
|
2593
|
+
code: "core-kind",
|
|
2594
|
+
severity: "error",
|
|
2595
|
+
message: `route "${route}" is captured but never renders core kind "${kind}"`,
|
|
2596
|
+
file: loc?.file,
|
|
2597
|
+
line: loc?.line,
|
|
2598
|
+
entity: { kind: "route", id: route },
|
|
2599
|
+
hint: `Capture the "${kind}" state, or if the route cannot render it add \`export const uidex = { page: "${page}", waivers: { ${kind}: "why it can't" } }\`, or grandfather it via \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
for (const [route, kinds] of captured) {
|
|
2604
|
+
const page = pageOf.get(route);
|
|
2605
|
+
if (!page || isOptedOut(registry, page)) continue;
|
|
2606
|
+
const waivers = pageWaivers(registry, page);
|
|
2607
|
+
const loc = registry.get("page", page)?.loc;
|
|
2608
|
+
for (const kind of Object.keys(waivers)) {
|
|
2609
|
+
if (kinds.has(kind)) {
|
|
2610
|
+
diagnostics.push({
|
|
2611
|
+
code: "stale-waiver",
|
|
2612
|
+
severity: "warning",
|
|
2613
|
+
message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
|
|
2614
|
+
file: loc?.file,
|
|
2615
|
+
line: loc?.line,
|
|
2616
|
+
entity: { kind: "page", id: page },
|
|
2617
|
+
hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
return diagnostics;
|
|
2623
|
+
}
|
|
2624
|
+
function computeMissingKinds(registry, manifest) {
|
|
2625
|
+
const routes = registry.list("route");
|
|
2626
|
+
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
2627
|
+
const captured = coreKindsByRoute(registry, manifest);
|
|
2628
|
+
const out2 = {};
|
|
2629
|
+
for (const [route, kinds] of captured) {
|
|
2630
|
+
const page = pageOf.get(route);
|
|
2631
|
+
if (isOptedOut(registry, page)) continue;
|
|
2632
|
+
const waivers = page ? pageWaivers(registry, page) : {};
|
|
2633
|
+
const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
|
|
2634
|
+
if (missing.length > 0) out2[route] = missing;
|
|
2635
|
+
}
|
|
2636
|
+
return out2;
|
|
2252
2637
|
}
|
|
2253
2638
|
|
|
2254
2639
|
// src/scanner/scan/audit.ts
|
|
2255
|
-
import * as path4 from "path";
|
|
2256
|
-
var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
|
|
2257
2640
|
function audit(opts) {
|
|
2258
2641
|
const diagnostics = [];
|
|
2259
2642
|
const { registry, extracted, files, config } = opts;
|
|
@@ -2262,23 +2645,19 @@ function audit(opts) {
|
|
|
2262
2645
|
const acceptanceEnabled = config.audit?.acceptance ?? true;
|
|
2263
2646
|
const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
|
|
2264
2647
|
const coverageEnabled = config.audit?.coverage ?? true;
|
|
2648
|
+
const statesEnabled = config.audit?.states ?? true;
|
|
2649
|
+
const pageCoverageEnabled = config.audit?.pageCoverage ?? true;
|
|
2650
|
+
const stateMatrixEnabled = config.audit?.stateMatrix ?? true;
|
|
2265
2651
|
if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
code: "marker-md-ignored",
|
|
2272
|
-
severity: "warning",
|
|
2273
|
-
message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
|
|
2274
|
-
file: f.displayPath
|
|
2275
|
-
});
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2652
|
+
for (const ef of extracted) {
|
|
2653
|
+
if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
|
|
2654
|
+
}
|
|
2655
|
+
for (const ef of opts.flowExtracted ?? []) {
|
|
2656
|
+
if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
|
|
2278
2657
|
}
|
|
2279
2658
|
if (check && opts.generated !== void 0) {
|
|
2280
2659
|
const outRel = opts.outputPath ?? config.output;
|
|
2281
|
-
const fresh =
|
|
2660
|
+
const fresh = normalizeForCheck(opts.generated);
|
|
2282
2661
|
if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
|
|
2283
2662
|
diagnostics.push({
|
|
2284
2663
|
code: "gen-missing",
|
|
@@ -2288,7 +2667,7 @@ function audit(opts) {
|
|
|
2288
2667
|
hint: "Run `uidex scan` (without --check) to regenerate"
|
|
2289
2668
|
});
|
|
2290
2669
|
} else {
|
|
2291
|
-
const existing =
|
|
2670
|
+
const existing = normalizeForCheck(opts.existingOnDisk);
|
|
2292
2671
|
if (existing !== fresh) {
|
|
2293
2672
|
const changed = diffEntities(existing, opts.generated, registry);
|
|
2294
2673
|
const summary2 = formatChangedSummary(changed);
|
|
@@ -2302,20 +2681,46 @@ function audit(opts) {
|
|
|
2302
2681
|
}
|
|
2303
2682
|
}
|
|
2304
2683
|
}
|
|
2305
|
-
if (
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2684
|
+
if (check && opts.typesGenerated !== void 0) {
|
|
2685
|
+
const outRel = opts.typesOutputPath ?? config.output;
|
|
2686
|
+
const fresh = normalizeForCheck(opts.typesGenerated);
|
|
2687
|
+
if (opts.typesOnDisk === null || opts.typesOnDisk === void 0) {
|
|
2688
|
+
diagnostics.push({
|
|
2689
|
+
code: "gen-missing",
|
|
2690
|
+
severity: "error",
|
|
2691
|
+
message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
|
|
2692
|
+
file: outRel,
|
|
2693
|
+
hint: "Run `uidex scan` (without --check) to regenerate"
|
|
2694
|
+
});
|
|
2695
|
+
} else if (normalizeForCheck(opts.typesOnDisk) !== fresh) {
|
|
2696
|
+
diagnostics.push({
|
|
2697
|
+
code: "gen-stale",
|
|
2698
|
+
severity: "error",
|
|
2699
|
+
message: `Generated file "${outRel}" is stale`,
|
|
2700
|
+
file: outRel,
|
|
2701
|
+
hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
if (check && opts.locsGenerated !== void 0) {
|
|
2706
|
+
const outRel = opts.locsOutputPath ?? config.output;
|
|
2707
|
+
const fresh = normalizeForCheck(opts.locsGenerated);
|
|
2708
|
+
if (opts.locsOnDisk === null || opts.locsOnDisk === void 0) {
|
|
2709
|
+
diagnostics.push({
|
|
2710
|
+
code: "gen-missing",
|
|
2711
|
+
severity: "error",
|
|
2712
|
+
message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
|
|
2713
|
+
file: outRel,
|
|
2714
|
+
hint: "Run `uidex scan` (without --check) to regenerate"
|
|
2715
|
+
});
|
|
2716
|
+
} else if (normalizeForCheck(opts.locsOnDisk) !== fresh) {
|
|
2717
|
+
diagnostics.push({
|
|
2718
|
+
code: "gen-stale",
|
|
2719
|
+
severity: "error",
|
|
2720
|
+
message: `Generated file "${outRel}" is stale`,
|
|
2721
|
+
file: outRel,
|
|
2722
|
+
hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
|
|
2723
|
+
});
|
|
2319
2724
|
}
|
|
2320
2725
|
}
|
|
2321
2726
|
if (lint && acceptanceEnabled) {
|
|
@@ -2363,8 +2768,8 @@ function audit(opts) {
|
|
|
2363
2768
|
if (typeof m.id !== "string") continue;
|
|
2364
2769
|
const filePath = ef.file.displayPath;
|
|
2365
2770
|
const wellKnownName = WELL_KNOWN_FILES[m.kind];
|
|
2366
|
-
if (
|
|
2367
|
-
const dir =
|
|
2771
|
+
if (path5.posix.basename(filePath) === wellKnownName) continue;
|
|
2772
|
+
const dir = path5.posix.dirname(filePath);
|
|
2368
2773
|
const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
|
|
2369
2774
|
if (scannedPaths.has(wellKnownPath)) continue;
|
|
2370
2775
|
const kindLabel = m.kind === "page" ? "Page" : "Feature";
|
|
@@ -2381,53 +2786,55 @@ function audit(opts) {
|
|
|
2381
2786
|
}
|
|
2382
2787
|
}
|
|
2383
2788
|
if (lint) {
|
|
2384
|
-
const
|
|
2385
|
-
|
|
2386
|
-
for (const f of files) {
|
|
2387
|
-
const templatePrefixPositions = /* @__PURE__ */ new Set();
|
|
2388
|
-
templateWithPrefixRe.lastIndex = 0;
|
|
2389
|
-
let tm;
|
|
2390
|
-
while ((tm = templateWithPrefixRe.exec(f.content)) !== null) {
|
|
2391
|
-
templatePrefixPositions.add(tm.index);
|
|
2392
|
-
}
|
|
2393
|
-
let m;
|
|
2394
|
-
dynamicAttrRe.lastIndex = 0;
|
|
2395
|
-
while ((m = dynamicAttrRe.exec(f.content)) !== null) {
|
|
2396
|
-
if (templatePrefixPositions.has(m.index)) continue;
|
|
2397
|
-
const kind = m[1] ?? "element";
|
|
2398
|
-
let line = 1;
|
|
2399
|
-
for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
|
|
2400
|
-
const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
|
|
2789
|
+
for (const ef of extracted) {
|
|
2790
|
+
for (const fact of ef.dynamicAttrs ?? []) {
|
|
2401
2791
|
diagnostics.push({
|
|
2402
2792
|
code: "dynamic-attr",
|
|
2403
2793
|
severity: "warning",
|
|
2404
|
-
message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
|
|
2405
|
-
file:
|
|
2406
|
-
line,
|
|
2407
|
-
hint: dynamicAttrHint(kind)
|
|
2794
|
+
message: `\`${fact.attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${fact.kind} id statically`,
|
|
2795
|
+
file: ef.file.displayPath,
|
|
2796
|
+
line: fact.line,
|
|
2797
|
+
hint: dynamicAttrHint(fact.kind)
|
|
2408
2798
|
});
|
|
2409
2799
|
}
|
|
2410
2800
|
}
|
|
2411
2801
|
}
|
|
2412
2802
|
if (lint) {
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2803
|
+
const usedElementIds = new Set(registry.list("element").map((e) => e.id));
|
|
2804
|
+
for (const ef of extracted) {
|
|
2805
|
+
for (const fact of ef.unannotatedInteractive ?? []) {
|
|
2806
|
+
if (fact.hasSpread) {
|
|
2807
|
+
diagnostics.push({
|
|
2808
|
+
code: "spread-attr",
|
|
2809
|
+
severity: "info",
|
|
2810
|
+
message: `Interactive <${fact.tag}> spreads dynamic props and has no static data-uidex attribute; if the annotation is forwarded via props the scanner cannot register it`,
|
|
2811
|
+
file: ef.file.displayPath,
|
|
2812
|
+
line: fact.line,
|
|
2813
|
+
hint: "Prefer a string-literal data-uidex on the element itself, or annotate at the call site."
|
|
2814
|
+
});
|
|
2815
|
+
} else {
|
|
2816
|
+
const id = uniqueElementId(fact, usedElementIds);
|
|
2817
|
+
usedElementIds.add(id);
|
|
2818
|
+
diagnostics.push({
|
|
2819
|
+
code: "missing-element-annotation",
|
|
2820
|
+
severity: "info",
|
|
2821
|
+
message: `Interactive <${fact.tag}> without data-uidex annotation`,
|
|
2822
|
+
file: ef.file.displayPath,
|
|
2823
|
+
line: fact.line,
|
|
2824
|
+
hint: `Add \`data-uidex="${id}"\` (or run \`uidex scan --fix\`).`,
|
|
2825
|
+
fix: {
|
|
2826
|
+
description: `Add data-uidex="${id}" to <${fact.tag}>`,
|
|
2827
|
+
edits: [
|
|
2828
|
+
{
|
|
2829
|
+
path: ef.file.sourcePath,
|
|
2830
|
+
start: fact.nameEnd,
|
|
2831
|
+
end: fact.nameEnd,
|
|
2832
|
+
replacement: ` data-uidex="${id}"`
|
|
2833
|
+
}
|
|
2834
|
+
]
|
|
2835
|
+
}
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2431
2838
|
}
|
|
2432
2839
|
}
|
|
2433
2840
|
}
|
|
@@ -2444,12 +2851,11 @@ function audit(opts) {
|
|
|
2444
2851
|
}
|
|
2445
2852
|
}
|
|
2446
2853
|
}
|
|
2447
|
-
for (const
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
const
|
|
2452
|
-
const baseName2 = spec.split("/").pop() ?? "";
|
|
2854
|
+
for (const ef of extracted) {
|
|
2855
|
+
const displayPath = ef.file.displayPath;
|
|
2856
|
+
for (const imp of ef.imports ?? []) {
|
|
2857
|
+
if (imp.isTypeOnly) continue;
|
|
2858
|
+
const baseName2 = imp.specifier.split("/").pop() ?? "";
|
|
2453
2859
|
const primitive = byName.get(
|
|
2454
2860
|
baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
|
|
2455
2861
|
);
|
|
@@ -2457,25 +2863,37 @@ function audit(opts) {
|
|
|
2457
2863
|
const scope = primitive.scopes?.[0];
|
|
2458
2864
|
if (!scope) continue;
|
|
2459
2865
|
const [kind, id] = scope.split(":");
|
|
2460
|
-
const importerSegments =
|
|
2866
|
+
const importerSegments = displayPath.split("/");
|
|
2461
2867
|
if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
|
|
2462
2868
|
continue;
|
|
2463
2869
|
}
|
|
2464
2870
|
if (kind === "feature" && importerSegments.includes(id)) continue;
|
|
2465
|
-
if (kind === "feature" && declaredFeatures.get(
|
|
2871
|
+
if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
|
|
2466
2872
|
continue;
|
|
2467
2873
|
}
|
|
2468
2874
|
diagnostics.push({
|
|
2469
2875
|
code: "scope-leak",
|
|
2470
2876
|
severity: "warning",
|
|
2471
|
-
message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${
|
|
2472
|
-
file:
|
|
2877
|
+
message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
|
|
2878
|
+
file: displayPath,
|
|
2879
|
+
line: imp.line
|
|
2473
2880
|
});
|
|
2474
2881
|
}
|
|
2475
2882
|
}
|
|
2476
2883
|
}
|
|
2477
2884
|
if (lint && coverageEnabled) {
|
|
2885
|
+
const factsByLoc = /* @__PURE__ */ new Map();
|
|
2886
|
+
for (const ef of opts.flowExtracted ?? []) {
|
|
2887
|
+
for (const fact of ef.flows ?? []) {
|
|
2888
|
+
const lines = /* @__PURE__ */ new Map();
|
|
2889
|
+
for (const call of fact.calls) {
|
|
2890
|
+
if (!lines.has(call.id)) lines.set(call.id, call.line);
|
|
2891
|
+
}
|
|
2892
|
+
factsByLoc.set(`${ef.file.displayPath}:${fact.line}`, lines);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2478
2895
|
for (const flow of registry.list("flow")) {
|
|
2896
|
+
const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
|
|
2479
2897
|
for (const touchedId of flow.touches) {
|
|
2480
2898
|
const found = registry.get("element", touchedId) ?? registry.get("widget", touchedId) ?? registry.get("region", touchedId) ?? registry.matchPattern("element", touchedId) ?? registry.matchPattern("widget", touchedId) ?? registry.matchPattern("region", touchedId);
|
|
2481
2899
|
if (!found) {
|
|
@@ -2484,54 +2902,146 @@ function audit(opts) {
|
|
|
2484
2902
|
severity: "warning",
|
|
2485
2903
|
message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
|
|
2486
2904
|
file: flow.loc.file,
|
|
2487
|
-
|
|
2905
|
+
// Point at the uidex() call itself when the spec facts are
|
|
2906
|
+
// available; the describe line is the fallback.
|
|
2907
|
+
line: callLines?.get(touchedId) ?? flow.loc.line
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
if (lint) {
|
|
2914
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
2915
|
+
for (const ef of extracted) {
|
|
2916
|
+
for (const a of ef.annotations) {
|
|
2917
|
+
if (a.kind !== "element" && a.kind !== "region" && a.kind !== "widget" && a.kind !== "primitive") {
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
const key = `${a.kind}:${a.id}`;
|
|
2921
|
+
let list = occurrences.get(key);
|
|
2922
|
+
if (!list) {
|
|
2923
|
+
list = [];
|
|
2924
|
+
occurrences.set(key, list);
|
|
2925
|
+
}
|
|
2926
|
+
list.push({ file: a.file, line: a.line });
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
for (const [key, list] of occurrences) {
|
|
2930
|
+
const filesSeen = new Set(list.map((o) => o.file));
|
|
2931
|
+
if (filesSeen.size < 2) continue;
|
|
2932
|
+
const [kind, id] = key.split(/:(.*)/s);
|
|
2933
|
+
const others = list.slice(1).map((o) => `${o.file}:${o.line}`).join(", ");
|
|
2934
|
+
diagnostics.push({
|
|
2935
|
+
code: "duplicate-id",
|
|
2936
|
+
severity: kind === "widget" || kind === "primitive" ? "warning" : "info",
|
|
2937
|
+
message: `${kind} id "${id}" is declared in ${filesSeen.size} files (also at ${others}); the registry keeps only one entry`,
|
|
2938
|
+
file: list[0].file,
|
|
2939
|
+
line: list[0].line,
|
|
2940
|
+
entity: { kind, id },
|
|
2941
|
+
hint: kind === "element" || kind === "region" ? "If these are variants of the same logical element this is fine; otherwise rename one (`uidex rename` updates flow references too)." : "Rename one of the definitions; two definitions with the same id silently merge."
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
if (lint && coverageEnabled) {
|
|
2946
|
+
for (const ef of opts.flowExtracted ?? []) {
|
|
2947
|
+
for (const fact of ef.flows ?? []) {
|
|
2948
|
+
for (const dyn of fact.dynamicCalls ?? []) {
|
|
2949
|
+
diagnostics.push({
|
|
2950
|
+
code: "dynamic-flow-reference",
|
|
2951
|
+
severity: "warning",
|
|
2952
|
+
message: `\`uidex(\u2026)\` call in flow "${fact.title}" uses a dynamic expression; the id is invisible to coverage and registry validation`,
|
|
2953
|
+
file: ef.file.displayPath,
|
|
2954
|
+
line: dyn.line,
|
|
2955
|
+
hint: "Use a string-literal id (component ids inside uidex() must be statically analysable)."
|
|
2488
2956
|
});
|
|
2489
2957
|
}
|
|
2490
2958
|
}
|
|
2491
2959
|
}
|
|
2492
2960
|
}
|
|
2961
|
+
if (lint && coverageEnabled) {
|
|
2962
|
+
for (const ef of extracted) {
|
|
2963
|
+
if (!ef.metadata) continue;
|
|
2964
|
+
for (const m of ef.metadata) {
|
|
2965
|
+
const check2 = (refKind, ids, spans) => {
|
|
2966
|
+
for (let i = 0; i < (ids?.length ?? 0); i++) {
|
|
2967
|
+
const refId = ids[i];
|
|
2968
|
+
const found = registry.get(refKind, refId) ?? registry.matchPattern(refKind, refId);
|
|
2969
|
+
if (found) continue;
|
|
2970
|
+
diagnostics.push({
|
|
2971
|
+
code: "unknown-reference",
|
|
2972
|
+
severity: "warning",
|
|
2973
|
+
message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
|
|
2974
|
+
file: ef.file.displayPath,
|
|
2975
|
+
line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
|
|
2976
|
+
hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
};
|
|
2980
|
+
check2("feature", m.features, m.featureSpans);
|
|
2981
|
+
check2("widget", m.widgets, m.widgetSpans);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
if (lint && statesEnabled && opts.capturedStates) {
|
|
2986
|
+
diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
|
|
2987
|
+
}
|
|
2988
|
+
if (lint && pageCoverageEnabled && opts.capturedStates) {
|
|
2989
|
+
diagnostics.push(
|
|
2990
|
+
...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
|
|
2991
|
+
);
|
|
2992
|
+
}
|
|
2993
|
+
if (lint && stateMatrixEnabled && opts.capturedStates) {
|
|
2994
|
+
diagnostics.push(
|
|
2995
|
+
...checkStateMatrix(registry, opts.capturedStates, {
|
|
2996
|
+
missingKinds: opts.pageBaseline?.missingKinds ?? {}
|
|
2997
|
+
})
|
|
2998
|
+
);
|
|
2999
|
+
}
|
|
2493
3000
|
const summary = {
|
|
2494
3001
|
errors: diagnostics.filter((d) => d.severity === "error").length,
|
|
2495
3002
|
warnings: diagnostics.filter((d) => d.severity === "warning").length
|
|
2496
3003
|
};
|
|
2497
3004
|
return { diagnostics, summary };
|
|
2498
3005
|
}
|
|
2499
|
-
function
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
};
|
|
2528
|
-
default:
|
|
2529
|
-
return null;
|
|
3006
|
+
function lineOfOffset(content, offset) {
|
|
3007
|
+
let line = 1;
|
|
3008
|
+
for (let i = 0; i < offset && i < content.length; i++) {
|
|
3009
|
+
if (content[i] === "\n") line++;
|
|
3010
|
+
}
|
|
3011
|
+
return line;
|
|
3012
|
+
}
|
|
3013
|
+
var TAG_FALLBACK_ID = {
|
|
3014
|
+
a: "link",
|
|
3015
|
+
button: "button",
|
|
3016
|
+
input: "input",
|
|
3017
|
+
select: "select",
|
|
3018
|
+
textarea: "textarea"
|
|
3019
|
+
};
|
|
3020
|
+
function kebabId(str) {
|
|
3021
|
+
return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
3022
|
+
}
|
|
3023
|
+
function deriveElementId(fact) {
|
|
3024
|
+
const fromHint = fact.nameHint ? kebabId(fact.nameHint) : "";
|
|
3025
|
+
const capped = fromHint.split("-").filter(Boolean).slice(0, 5).join("-");
|
|
3026
|
+
return capped || TAG_FALLBACK_ID[fact.tag] || fact.tag;
|
|
3027
|
+
}
|
|
3028
|
+
function uniqueElementId(fact, used) {
|
|
3029
|
+
const base = deriveElementId(fact);
|
|
3030
|
+
if (!used.has(base)) return base;
|
|
3031
|
+
for (let n = 2; ; n++) {
|
|
3032
|
+
const candidate = `${base}-${n}`;
|
|
3033
|
+
if (!used.has(candidate)) return candidate;
|
|
2530
3034
|
}
|
|
2531
3035
|
}
|
|
2532
3036
|
function normalizeLineEndings(s) {
|
|
2533
3037
|
return s.replace(/\r\n/g, "\n");
|
|
2534
3038
|
}
|
|
3039
|
+
function normalizeForCheck(s) {
|
|
3040
|
+
return normalizeLineEndings(s).replace(
|
|
3041
|
+
/export const gitContext = \{[\s\S]*?\} as const/,
|
|
3042
|
+
"export const gitContext = {} as const"
|
|
3043
|
+
);
|
|
3044
|
+
}
|
|
2535
3045
|
function formatChangedSummary(change) {
|
|
2536
3046
|
const parts = [];
|
|
2537
3047
|
const fmt = (kind, names) => {
|
|
@@ -2615,77 +3125,26 @@ function extractEntitiesArray(source) {
|
|
|
2615
3125
|
inStr = c;
|
|
2616
3126
|
continue;
|
|
2617
3127
|
}
|
|
2618
|
-
if (c === "[") depth++;
|
|
2619
|
-
else if (c === "]") {
|
|
2620
|
-
depth--;
|
|
2621
|
-
if (depth === 0) {
|
|
2622
|
-
const json = source.slice(start, i + 1);
|
|
2623
|
-
try {
|
|
2624
|
-
return JSON.parse(json);
|
|
2625
|
-
} catch {
|
|
2626
|
-
return null;
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
return null;
|
|
2632
|
-
}
|
|
2633
|
-
function findJsxOpeningEnd(src, start) {
|
|
2634
|
-
let i = start;
|
|
2635
|
-
while (i < src.length) {
|
|
2636
|
-
const ch = src[i];
|
|
2637
|
-
if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
|
|
2638
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2639
|
-
i = skipString2(src, i);
|
|
2640
|
-
} else if (ch === "{") {
|
|
2641
|
-
i = skipBraces(src, i);
|
|
2642
|
-
} else {
|
|
2643
|
-
i++;
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
return -1;
|
|
2647
|
-
}
|
|
2648
|
-
function skipString2(src, start) {
|
|
2649
|
-
const quote = src[start];
|
|
2650
|
-
let i = start + 1;
|
|
2651
|
-
while (i < src.length) {
|
|
2652
|
-
if (src[i] === "\\" && quote !== "`") {
|
|
2653
|
-
i += 2;
|
|
2654
|
-
continue;
|
|
2655
|
-
}
|
|
2656
|
-
if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
|
|
2657
|
-
i = skipBraces(src, i + 1);
|
|
2658
|
-
continue;
|
|
2659
|
-
}
|
|
2660
|
-
if (src[i] === quote) return i + 1;
|
|
2661
|
-
i++;
|
|
2662
|
-
}
|
|
2663
|
-
return i;
|
|
2664
|
-
}
|
|
2665
|
-
function skipBraces(src, start) {
|
|
2666
|
-
let depth = 1;
|
|
2667
|
-
let i = start + 1;
|
|
2668
|
-
while (i < src.length && depth > 0) {
|
|
2669
|
-
const ch = src[i];
|
|
2670
|
-
if (ch === "{") {
|
|
2671
|
-
depth++;
|
|
2672
|
-
i++;
|
|
2673
|
-
} else if (ch === "}") {
|
|
3128
|
+
if (c === "[") depth++;
|
|
3129
|
+
else if (c === "]") {
|
|
2674
3130
|
depth--;
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
3131
|
+
if (depth === 0) {
|
|
3132
|
+
const json = source.slice(start, i + 1);
|
|
3133
|
+
try {
|
|
3134
|
+
return JSON.parse(json);
|
|
3135
|
+
} catch {
|
|
3136
|
+
return null;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
2680
3139
|
}
|
|
2681
3140
|
}
|
|
2682
|
-
return
|
|
3141
|
+
return null;
|
|
2683
3142
|
}
|
|
2684
3143
|
function dynamicAttrHint(kind) {
|
|
2685
3144
|
if (kind === "region") {
|
|
2686
3145
|
return `Use a string literal: \`data-uidex-region="id"\`, or declare the region via \`export const uidex = { region: "id" } as const satisfies Uidex.Region\` on the file that passes the region value`;
|
|
2687
3146
|
}
|
|
2688
|
-
return `The scanner
|
|
3147
|
+
return `The scanner resolves string literals, same-file const references, ternaries with literal branches, and template literals with static text (dynamic parts become \`*\` patterns). If this component forwards the annotation via a prop, restructure so the caller provides the annotated element directly (e.g. via a slot or render prop)`;
|
|
2689
3148
|
}
|
|
2690
3149
|
function stableStringify(value) {
|
|
2691
3150
|
return JSON.stringify(value, stableReplacer);
|
|
@@ -2702,6 +3161,148 @@ function stableReplacer(_key, value) {
|
|
|
2702
3161
|
}
|
|
2703
3162
|
|
|
2704
3163
|
// src/scanner/scan/emit.ts
|
|
3164
|
+
var AUTHORED_KINDS = /* @__PURE__ */ new Set([
|
|
3165
|
+
"route",
|
|
3166
|
+
"page",
|
|
3167
|
+
"feature",
|
|
3168
|
+
"widget",
|
|
3169
|
+
"primitive",
|
|
3170
|
+
"flow"
|
|
3171
|
+
]);
|
|
3172
|
+
function internedPayload(entities) {
|
|
3173
|
+
const fileIndex = /* @__PURE__ */ new Map();
|
|
3174
|
+
const files = [];
|
|
3175
|
+
const compact = entities.map((e) => {
|
|
3176
|
+
const loc = e.loc;
|
|
3177
|
+
if (!loc || typeof loc.file !== "string") return e;
|
|
3178
|
+
let idx = fileIndex.get(loc.file);
|
|
3179
|
+
if (idx === void 0) {
|
|
3180
|
+
idx = files.length;
|
|
3181
|
+
files.push(loc.file);
|
|
3182
|
+
fileIndex.set(loc.file, idx);
|
|
3183
|
+
}
|
|
3184
|
+
const rest = { ...loc };
|
|
3185
|
+
delete rest.file;
|
|
3186
|
+
return { ...e, loc: { f: idx, ...rest } };
|
|
3187
|
+
});
|
|
3188
|
+
return {
|
|
3189
|
+
filesDecl: `const files: readonly string[] = ${jsonStable(files)}`,
|
|
3190
|
+
parseExpr: `(JSON.parse(${JSON.stringify(
|
|
3191
|
+
JSON.stringify(compact, replacerSorted)
|
|
3192
|
+
)}) as any[]).map(hydrate)`
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
var HYDRATE_FN = [
|
|
3196
|
+
"function hydrate(e: any): any {",
|
|
3197
|
+
' if (e.loc && typeof e.loc.f === "number") {',
|
|
3198
|
+
" const { f, ...rest } = e.loc",
|
|
3199
|
+
" e.loc = { file: files[f], ...rest }",
|
|
3200
|
+
" }",
|
|
3201
|
+
" return e",
|
|
3202
|
+
"}"
|
|
3203
|
+
].join("\n");
|
|
3204
|
+
function compactMeta(meta) {
|
|
3205
|
+
if (!meta || typeof meta !== "object") return meta;
|
|
3206
|
+
const composes = meta.composes;
|
|
3207
|
+
if (!Array.isArray(composes)) return meta;
|
|
3208
|
+
return {
|
|
3209
|
+
...meta,
|
|
3210
|
+
composes: composes.map(
|
|
3211
|
+
(c) => c && typeof c === "object" && c.kind === "element" && Object.keys(c).length === 2 && typeof c.id === "string" ? c.id : c
|
|
3212
|
+
)
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
function emitLocsModule(thin, uidexImport) {
|
|
3216
|
+
const kinds = [...new Set(thin.map((e) => e.kind))].sort();
|
|
3217
|
+
const kindIdx = new Map(kinds.map((k, i) => [k, i]));
|
|
3218
|
+
const fileIndex = /* @__PURE__ */ new Map();
|
|
3219
|
+
const files = [];
|
|
3220
|
+
const fi = (f) => {
|
|
3221
|
+
let i = fileIndex.get(f);
|
|
3222
|
+
if (i === void 0) {
|
|
3223
|
+
i = files.length;
|
|
3224
|
+
files.push(f);
|
|
3225
|
+
fileIndex.set(f, i);
|
|
3226
|
+
}
|
|
3227
|
+
return i;
|
|
3228
|
+
};
|
|
3229
|
+
const bare = [];
|
|
3230
|
+
const rich = [];
|
|
3231
|
+
for (const e of thin) {
|
|
3232
|
+
const loc = e.loc;
|
|
3233
|
+
const topOnlyKindIdLoc = Object.keys(e).every(
|
|
3234
|
+
(k) => k === "kind" || k === "id" || k === "loc"
|
|
3235
|
+
);
|
|
3236
|
+
const locOnlyKnown = !!loc && typeof loc.file === "string" && Object.keys(loc).every(
|
|
3237
|
+
(k) => k === "file" || k === "line" || k === "column"
|
|
3238
|
+
);
|
|
3239
|
+
if (topOnlyKindIdLoc && locOnlyKnown) {
|
|
3240
|
+
const row = [
|
|
3241
|
+
kindIdx.get(e.kind),
|
|
3242
|
+
e.id,
|
|
3243
|
+
fi(loc.file)
|
|
3244
|
+
];
|
|
3245
|
+
if (loc.line != null || loc.column != null)
|
|
3246
|
+
row.push(Number(loc.line ?? 0));
|
|
3247
|
+
if (loc.column != null) row.push(Number(loc.column));
|
|
3248
|
+
bare.push(row);
|
|
3249
|
+
} else {
|
|
3250
|
+
const richEntity = { ...e };
|
|
3251
|
+
if (loc && typeof loc.file === "string") {
|
|
3252
|
+
const { file, ...rest } = loc;
|
|
3253
|
+
richEntity.loc = { f: fi(file), ...rest };
|
|
3254
|
+
}
|
|
3255
|
+
if (richEntity.meta) richEntity.meta = compactMeta(richEntity.meta);
|
|
3256
|
+
rich.push(richEntity);
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
const str = (v) => JSON.stringify(JSON.stringify(v, replacerSorted));
|
|
3260
|
+
const l = [];
|
|
3261
|
+
l.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
|
|
3262
|
+
l.push(
|
|
3263
|
+
"// Deferred element/region source locations for the inspector's copy-path."
|
|
3264
|
+
);
|
|
3265
|
+
l.push("// @ts-nocheck");
|
|
3266
|
+
l.push("/* eslint-disable */");
|
|
3267
|
+
l.push(`import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`);
|
|
3268
|
+
l.push("");
|
|
3269
|
+
l.push(`const files: readonly string[] = ${jsonStable(files)}`);
|
|
3270
|
+
l.push(`const kinds: readonly string[] = ${jsonStable(kinds)}`);
|
|
3271
|
+
l.push("function loc(fi: number, line?: number, col?: number) {");
|
|
3272
|
+
l.push(" const o: any = { file: files[fi] }");
|
|
3273
|
+
l.push(" if (line) o.line = line");
|
|
3274
|
+
l.push(" if (col) o.column = col");
|
|
3275
|
+
l.push(" return o");
|
|
3276
|
+
l.push("}");
|
|
3277
|
+
l.push(`const bare: any[] = JSON.parse(${str(bare)})`);
|
|
3278
|
+
l.push(`const rich: any[] = JSON.parse(${str(rich)})`);
|
|
3279
|
+
l.push("");
|
|
3280
|
+
l.push(
|
|
3281
|
+
"/** Add the thin element/region entities (source locations) to a registry. */"
|
|
3282
|
+
);
|
|
3283
|
+
l.push("export function loadLocs(target: Registry): Registry {");
|
|
3284
|
+
l.push(" for (const r of bare)");
|
|
3285
|
+
l.push(
|
|
3286
|
+
" target.add({ kind: kinds[r[0]], id: r[1], loc: loc(r[2], r[3], r[4]) } as Entity)"
|
|
3287
|
+
);
|
|
3288
|
+
l.push(" for (const e of rich) {");
|
|
3289
|
+
l.push(" const out: any = { ...e }");
|
|
3290
|
+
l.push(' if (e.loc && typeof e.loc.f === "number") {');
|
|
3291
|
+
l.push(" const { f, ...rest } = e.loc");
|
|
3292
|
+
l.push(" out.loc = { file: files[f], ...rest }");
|
|
3293
|
+
l.push(" }");
|
|
3294
|
+
l.push(" if (e.meta && Array.isArray(e.meta.composes)) {");
|
|
3295
|
+
l.push(
|
|
3296
|
+
' out.meta = { ...e.meta, composes: e.meta.composes.map((c: any) => typeof c === "string" ? { id: c, kind: "element" } : c) }'
|
|
3297
|
+
);
|
|
3298
|
+
l.push(" }");
|
|
3299
|
+
l.push(" target.add(out)");
|
|
3300
|
+
l.push(" }");
|
|
3301
|
+
l.push(" return target");
|
|
3302
|
+
l.push("}");
|
|
3303
|
+
l.push("");
|
|
3304
|
+
return l.join("\n");
|
|
3305
|
+
}
|
|
2705
3306
|
function sortById(arr) {
|
|
2706
3307
|
return [...arr].sort((a, b) => a.id.localeCompare(b.id));
|
|
2707
3308
|
}
|
|
@@ -2718,9 +3319,7 @@ function replacerSorted(_key, value) {
|
|
|
2718
3319
|
}
|
|
2719
3320
|
return value;
|
|
2720
3321
|
}
|
|
2721
|
-
function emitIdUnion(name, ids
|
|
2722
|
-
if (typeMode === "loose") return `export type ${name} = string
|
|
2723
|
-
`;
|
|
3322
|
+
function emitIdUnion(name, ids) {
|
|
2724
3323
|
if (ids.length === 0) return `export type ${name} = never
|
|
2725
3324
|
`;
|
|
2726
3325
|
const sorted = [...ids].sort();
|
|
@@ -2729,133 +3328,153 @@ function emitIdUnion(name, ids, typeMode) {
|
|
|
2729
3328
|
${body}
|
|
2730
3329
|
`;
|
|
2731
3330
|
}
|
|
3331
|
+
var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
|
|
3332
|
+
function emitId(name, ids) {
|
|
3333
|
+
if (OPAQUE_ID_UNIONS.has(name)) {
|
|
3334
|
+
return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
|
|
3335
|
+
`;
|
|
3336
|
+
}
|
|
3337
|
+
return emitIdUnion(name, ids);
|
|
3338
|
+
}
|
|
3339
|
+
function dataOutputPath(output) {
|
|
3340
|
+
return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".data.$1");
|
|
3341
|
+
}
|
|
3342
|
+
function locsOutputPath(output) {
|
|
3343
|
+
return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".locs.$1");
|
|
3344
|
+
}
|
|
2732
3345
|
function emit(opts) {
|
|
2733
|
-
const {
|
|
2734
|
-
registry,
|
|
2735
|
-
gitContext,
|
|
2736
|
-
uidexImport = "uidex",
|
|
2737
|
-
typeMode = "strict"
|
|
2738
|
-
} = opts;
|
|
3346
|
+
const { registry, gitContext, uidexImport = "uidex" } = opts;
|
|
2739
3347
|
const routes = [...registry.list("route")].sort(
|
|
2740
3348
|
(a, b) => a.path.localeCompare(b.path)
|
|
2741
3349
|
);
|
|
2742
3350
|
const pages = sortById(registry.list("page"));
|
|
2743
3351
|
const features = sortById(registry.list("feature"));
|
|
2744
3352
|
const widgets = sortById(registry.list("widget"));
|
|
3353
|
+
const stateNames = /* @__PURE__ */ new Set();
|
|
3354
|
+
for (const e of [...pages, ...features, ...widgets]) {
|
|
3355
|
+
for (const s of e.meta?.states ?? []) stateNames.add(s.name);
|
|
3356
|
+
}
|
|
2745
3357
|
const regions = sortById(registry.list("region"));
|
|
2746
3358
|
const elements = sortById(registry.list("element"));
|
|
2747
3359
|
const primitives = sortById(registry.list("primitive"));
|
|
2748
3360
|
const flows = sortById(registry.list("flow"));
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
lines.push(
|
|
2754
|
-
`import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`
|
|
3361
|
+
const t = [];
|
|
3362
|
+
t.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
|
|
3363
|
+
t.push(
|
|
3364
|
+
"// Types only \u2014 the runtime registry lives in the sibling `.data` module."
|
|
2755
3365
|
);
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
3366
|
+
t.push("/* eslint-disable */");
|
|
3367
|
+
t.push("");
|
|
3368
|
+
t.push("// ---- id unions ----");
|
|
3369
|
+
t.push(
|
|
3370
|
+
emitId(
|
|
2760
3371
|
"PageId",
|
|
2761
|
-
pages.map((e) => e.id)
|
|
2762
|
-
typeMode
|
|
3372
|
+
pages.map((e) => e.id)
|
|
2763
3373
|
)
|
|
2764
3374
|
);
|
|
2765
|
-
|
|
2766
|
-
|
|
3375
|
+
t.push(
|
|
3376
|
+
emitId(
|
|
2767
3377
|
"FeatureId",
|
|
2768
|
-
features.map((e) => e.id)
|
|
2769
|
-
typeMode
|
|
3378
|
+
features.map((e) => e.id)
|
|
2770
3379
|
)
|
|
2771
3380
|
);
|
|
2772
|
-
|
|
2773
|
-
|
|
3381
|
+
t.push(
|
|
3382
|
+
emitId(
|
|
2774
3383
|
"WidgetId",
|
|
2775
|
-
widgets.map((e) => e.id)
|
|
2776
|
-
typeMode
|
|
3384
|
+
widgets.map((e) => e.id)
|
|
2777
3385
|
)
|
|
2778
3386
|
);
|
|
2779
|
-
|
|
2780
|
-
|
|
3387
|
+
t.push(
|
|
3388
|
+
emitId(
|
|
2781
3389
|
"RegionId",
|
|
2782
|
-
regions.map((e) => e.id)
|
|
2783
|
-
typeMode
|
|
3390
|
+
regions.map((e) => e.id)
|
|
2784
3391
|
)
|
|
2785
3392
|
);
|
|
2786
|
-
|
|
2787
|
-
|
|
3393
|
+
t.push(
|
|
3394
|
+
emitId(
|
|
2788
3395
|
"ElementId",
|
|
2789
|
-
elements.map((e) => e.id)
|
|
2790
|
-
typeMode
|
|
3396
|
+
elements.map((e) => e.id)
|
|
2791
3397
|
)
|
|
2792
3398
|
);
|
|
2793
|
-
|
|
2794
|
-
|
|
3399
|
+
t.push(
|
|
3400
|
+
emitId(
|
|
2795
3401
|
"PrimitiveId",
|
|
2796
|
-
primitives.map((e) => e.id)
|
|
2797
|
-
typeMode
|
|
3402
|
+
primitives.map((e) => e.id)
|
|
2798
3403
|
)
|
|
2799
3404
|
);
|
|
2800
|
-
|
|
2801
|
-
|
|
3405
|
+
t.push(
|
|
3406
|
+
emitId(
|
|
2802
3407
|
"FlowId",
|
|
2803
|
-
flows.map((e) => e.id)
|
|
2804
|
-
typeMode
|
|
3408
|
+
flows.map((e) => e.id)
|
|
2805
3409
|
)
|
|
2806
3410
|
);
|
|
2807
|
-
|
|
2808
|
-
|
|
3411
|
+
t.push(
|
|
3412
|
+
emitId(
|
|
2809
3413
|
"RouteId",
|
|
2810
|
-
routes.map((e) => e.path)
|
|
2811
|
-
typeMode
|
|
3414
|
+
routes.map((e) => e.path)
|
|
2812
3415
|
)
|
|
2813
3416
|
);
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
3417
|
+
t.push(emitId("StateId", [...stateNames]));
|
|
3418
|
+
t.push("");
|
|
3419
|
+
t.push("// ---- authoring-surface shape types ----");
|
|
3420
|
+
t.push("export namespace Uidex {");
|
|
3421
|
+
t.push(
|
|
3422
|
+
' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
|
|
3423
|
+
);
|
|
3424
|
+
t.push(' export type StateKind = CoreStateKind | "variant"');
|
|
3425
|
+
t.push(" export interface State {");
|
|
3426
|
+
t.push(" name: string");
|
|
3427
|
+
t.push(" kind?: StateKind");
|
|
3428
|
+
t.push(" acceptance?: readonly string[]");
|
|
3429
|
+
t.push(" description?: string");
|
|
3430
|
+
t.push(" }");
|
|
3431
|
+
t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
|
|
3432
|
+
t.push(" export interface Page {");
|
|
3433
|
+
t.push(" page: PageId | false");
|
|
3434
|
+
t.push(" name?: string");
|
|
3435
|
+
t.push(" features?: readonly FeatureId[]");
|
|
3436
|
+
t.push(" widgets?: readonly WidgetId[]");
|
|
3437
|
+
t.push(" acceptance?: readonly string[]");
|
|
3438
|
+
t.push(" states?: readonly (string | State)[]");
|
|
3439
|
+
t.push(" capture?: boolean");
|
|
3440
|
+
t.push(" waivers?: Waivers");
|
|
3441
|
+
t.push(" description?: string");
|
|
3442
|
+
t.push(" }");
|
|
3443
|
+
t.push(" export interface Feature {");
|
|
3444
|
+
t.push(" feature: FeatureId | false");
|
|
3445
|
+
t.push(" name?: string");
|
|
3446
|
+
t.push(" features?: readonly FeatureId[]");
|
|
3447
|
+
t.push(" acceptance?: readonly string[]");
|
|
3448
|
+
t.push(" states?: readonly (string | State)[]");
|
|
3449
|
+
t.push(" description?: string");
|
|
3450
|
+
t.push(" }");
|
|
3451
|
+
t.push(" export interface Primitive {");
|
|
3452
|
+
t.push(" primitive: PrimitiveId");
|
|
3453
|
+
t.push(" name?: string");
|
|
3454
|
+
t.push(" description?: string");
|
|
3455
|
+
t.push(" }");
|
|
3456
|
+
t.push(" export interface Widget {");
|
|
3457
|
+
t.push(" widget: WidgetId");
|
|
3458
|
+
t.push(" name?: string");
|
|
3459
|
+
t.push(" acceptance?: readonly string[]");
|
|
3460
|
+
t.push(" states?: readonly (string | State)[]");
|
|
3461
|
+
t.push(" description?: string");
|
|
3462
|
+
t.push(" }");
|
|
3463
|
+
t.push(" export interface Region {");
|
|
3464
|
+
t.push(" region: RegionId | false");
|
|
3465
|
+
t.push(" name?: string");
|
|
3466
|
+
t.push(" description?: string");
|
|
3467
|
+
t.push(" }");
|
|
3468
|
+
t.push(" export interface Flow {");
|
|
3469
|
+
t.push(" flow: FlowId");
|
|
3470
|
+
t.push(" name?: string");
|
|
3471
|
+
t.push(" description?: string");
|
|
3472
|
+
t.push(" }");
|
|
3473
|
+
t.push(" export interface NotFlow {");
|
|
3474
|
+
t.push(" notFlow: true");
|
|
3475
|
+
t.push(" }");
|
|
3476
|
+
t.push("}");
|
|
3477
|
+
t.push("");
|
|
2859
3478
|
const allEntities = [
|
|
2860
3479
|
...routes,
|
|
2861
3480
|
...pages,
|
|
@@ -2866,25 +3485,39 @@ function emit(opts) {
|
|
|
2866
3485
|
...primitives,
|
|
2867
3486
|
...flows
|
|
2868
3487
|
];
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
);
|
|
2872
|
-
lines.push("");
|
|
2873
|
-
lines.push("// ---- git context ----");
|
|
3488
|
+
const authored = allEntities.filter((e) => AUTHORED_KINDS.has(e.kind));
|
|
3489
|
+
const thin = allEntities.filter((e) => !AUTHORED_KINDS.has(e.kind));
|
|
2874
3490
|
const gc = gitContext ?? { branch: null, commit: null, pr: null };
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
3491
|
+
const authoredPayload = internedPayload(authored);
|
|
3492
|
+
const d = [];
|
|
3493
|
+
d.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
|
|
3494
|
+
d.push(
|
|
3495
|
+
"// Authored-metadata registry. The thin element/region entities (source"
|
|
3496
|
+
);
|
|
3497
|
+
d.push(
|
|
3498
|
+
"// locations only) live in the sibling `.locs` module, loaded lazily."
|
|
3499
|
+
);
|
|
3500
|
+
d.push("// @ts-nocheck");
|
|
3501
|
+
d.push("/* eslint-disable */");
|
|
3502
|
+
d.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
|
|
3503
|
+
d.push(`import type { Registry } from ${JSON.stringify(uidexImport)}`);
|
|
3504
|
+
d.push("");
|
|
3505
|
+
d.push(authoredPayload.filesDecl);
|
|
3506
|
+
d.push(HYDRATE_FN);
|
|
3507
|
+
d.push(`export const entities = ${authoredPayload.parseExpr}`);
|
|
3508
|
+
d.push("");
|
|
3509
|
+
d.push(`export const gitContext = ${jsonStable(gc)} as const`);
|
|
3510
|
+
d.push("");
|
|
3511
|
+
d.push("export function loadRegistry(target: Registry): Registry {");
|
|
3512
|
+
d.push(" for (const entity of entities) target.add(entity)");
|
|
3513
|
+
d.push(" return target");
|
|
3514
|
+
d.push("}");
|
|
3515
|
+
d.push("");
|
|
3516
|
+
d.push("export const uidex = createUidex()");
|
|
3517
|
+
d.push("for (const entity of entities) uidex.registry.add(entity)");
|
|
3518
|
+
d.push("");
|
|
3519
|
+
const locs = emitLocsModule(thin, uidexImport);
|
|
3520
|
+
return { types: t.join("\n"), data: d.join("\n"), locs };
|
|
2888
3521
|
}
|
|
2889
3522
|
|
|
2890
3523
|
// src/scanner/scan/git.ts
|
|
@@ -2921,22 +3554,33 @@ function parseGitHubRef(ref) {
|
|
|
2921
3554
|
|
|
2922
3555
|
// src/scanner/scan/scaffold.ts
|
|
2923
3556
|
import * as fs3 from "fs";
|
|
2924
|
-
import * as
|
|
3557
|
+
import * as path6 from "path";
|
|
2925
3558
|
function scaffoldWidgetSpec(opts) {
|
|
3559
|
+
return scaffoldSpec({
|
|
3560
|
+
registry: opts.registry,
|
|
3561
|
+
kind: "widget",
|
|
3562
|
+
id: opts.widgetId,
|
|
3563
|
+
outDir: opts.outDir,
|
|
3564
|
+
force: opts.force,
|
|
3565
|
+
fixtureImport: opts.fixtureImport
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
function scaffoldSpec(opts) {
|
|
2926
3569
|
const {
|
|
2927
3570
|
registry,
|
|
2928
|
-
|
|
3571
|
+
kind,
|
|
3572
|
+
id,
|
|
2929
3573
|
outDir,
|
|
2930
3574
|
force = false,
|
|
2931
3575
|
fixtureImport = "./fixtures"
|
|
2932
3576
|
} = opts;
|
|
2933
|
-
const
|
|
2934
|
-
if (!
|
|
2935
|
-
throw new Error(
|
|
3577
|
+
const entity = registry.get(kind, id);
|
|
3578
|
+
if (!entity) {
|
|
3579
|
+
throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
|
|
2936
3580
|
}
|
|
2937
|
-
const criteria =
|
|
2938
|
-
const filename = `widget-${
|
|
2939
|
-
const outputPath =
|
|
3581
|
+
const criteria = entity.meta?.acceptance ?? [];
|
|
3582
|
+
const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
|
|
3583
|
+
const outputPath = path6.resolve(outDir, filename);
|
|
2940
3584
|
if (fs3.existsSync(outputPath) && !force) {
|
|
2941
3585
|
return {
|
|
2942
3586
|
outputPath,
|
|
@@ -2945,15 +3589,14 @@ function scaffoldWidgetSpec(opts) {
|
|
|
2945
3589
|
reason: `spec already exists at ${outputPath}; pass --force to overwrite`
|
|
2946
3590
|
};
|
|
2947
3591
|
}
|
|
2948
|
-
const content = renderSpec({
|
|
2949
|
-
|
|
2950
|
-
criteria,
|
|
2951
|
-
fixtureImport
|
|
2952
|
-
});
|
|
2953
|
-
fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
|
|
3592
|
+
const content = renderSpec({ id, criteria, fixtureImport });
|
|
3593
|
+
fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
|
|
2954
3594
|
fs3.writeFileSync(outputPath, content, "utf8");
|
|
2955
3595
|
return { outputPath, written: true, skipped: false };
|
|
2956
3596
|
}
|
|
3597
|
+
function capitalize(s) {
|
|
3598
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
3599
|
+
}
|
|
2957
3600
|
function renderSpec(args) {
|
|
2958
3601
|
const lines = [];
|
|
2959
3602
|
lines.push(
|
|
@@ -2961,7 +3604,7 @@ function renderSpec(args) {
|
|
|
2961
3604
|
);
|
|
2962
3605
|
lines.push("");
|
|
2963
3606
|
lines.push(
|
|
2964
|
-
`test.describe(${JSON.stringify(args.
|
|
3607
|
+
`test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
|
|
2965
3608
|
);
|
|
2966
3609
|
if (args.criteria.length === 0) {
|
|
2967
3610
|
lines.push(` test("TODO: add acceptance criteria", async () => {`);
|
|
@@ -2984,7 +3627,69 @@ function renderSpec(args) {
|
|
|
2984
3627
|
|
|
2985
3628
|
// src/scanner/scan/pipeline.ts
|
|
2986
3629
|
import * as fs4 from "fs";
|
|
2987
|
-
import * as
|
|
3630
|
+
import * as path7 from "path";
|
|
3631
|
+
var DEFAULT_STATES_MANIFEST = "uidex-states.json";
|
|
3632
|
+
var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
|
|
3633
|
+
function loadPageBaseline(configDir, config) {
|
|
3634
|
+
const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3635
|
+
const abs = path7.resolve(configDir, rel);
|
|
3636
|
+
let raw;
|
|
3637
|
+
try {
|
|
3638
|
+
raw = fs4.readFileSync(abs, "utf8");
|
|
3639
|
+
} catch {
|
|
3640
|
+
return void 0;
|
|
3641
|
+
}
|
|
3642
|
+
try {
|
|
3643
|
+
const parsed = JSON.parse(raw);
|
|
3644
|
+
if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
|
|
3645
|
+
const uncapturedPages = parsed.uncapturedPages.filter(
|
|
3646
|
+
(p2) => typeof p2 === "string"
|
|
3647
|
+
);
|
|
3648
|
+
let missingKinds;
|
|
3649
|
+
const mkRaw = parsed.missingKinds;
|
|
3650
|
+
if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
|
|
3651
|
+
const mk = {};
|
|
3652
|
+
for (const [route, kinds] of Object.entries(mkRaw)) {
|
|
3653
|
+
if (Array.isArray(kinds)) {
|
|
3654
|
+
mk[route] = kinds.filter((k) => typeof k === "string");
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
missingKinds = mk;
|
|
3658
|
+
}
|
|
3659
|
+
return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
|
|
3660
|
+
} catch {
|
|
3661
|
+
return void 0;
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
function loadCapturedStates(configDir, config) {
|
|
3665
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
|
|
3666
|
+
const abs = path7.resolve(configDir, rel);
|
|
3667
|
+
let raw;
|
|
3668
|
+
try {
|
|
3669
|
+
raw = fs4.readFileSync(abs, "utf8");
|
|
3670
|
+
} catch {
|
|
3671
|
+
return void 0;
|
|
3672
|
+
}
|
|
3673
|
+
try {
|
|
3674
|
+
const parsed = JSON.parse(raw);
|
|
3675
|
+
if (!parsed || !Array.isArray(parsed.captured)) return void 0;
|
|
3676
|
+
const captured = [];
|
|
3677
|
+
for (const entry of parsed.captured) {
|
|
3678
|
+
if (!entry || typeof entry !== "object") continue;
|
|
3679
|
+
const c = entry;
|
|
3680
|
+
if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
|
|
3681
|
+
const rec = { entity: c.entity, state: c.state };
|
|
3682
|
+
if (typeof c.kind === "string") rec.kind = c.kind;
|
|
3683
|
+
if (typeof c.stateKind === "string") rec.stateKind = c.stateKind;
|
|
3684
|
+
if (typeof c.url === "string") rec.url = c.url;
|
|
3685
|
+
if (typeof c.route === "string") rec.route = c.route;
|
|
3686
|
+
captured.push(rec);
|
|
3687
|
+
}
|
|
3688
|
+
return { captured };
|
|
3689
|
+
} catch {
|
|
3690
|
+
return void 0;
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
2988
3693
|
function runScan(opts = {}) {
|
|
2989
3694
|
const cwd = opts.cwd ?? process.cwd();
|
|
2990
3695
|
const configs = opts.configs ?? discover({ cwd });
|
|
@@ -3011,34 +3716,54 @@ function runOne(dc, opts) {
|
|
|
3011
3716
|
flowFiles: extractedFlows
|
|
3012
3717
|
});
|
|
3013
3718
|
const gitContext = resolveGitContext({ cwd: configDir });
|
|
3014
|
-
const
|
|
3719
|
+
const emitted = emit({
|
|
3015
3720
|
registry: resolved.registry,
|
|
3016
|
-
gitContext
|
|
3017
|
-
typeMode: config.typeMode
|
|
3721
|
+
gitContext
|
|
3018
3722
|
});
|
|
3019
|
-
const
|
|
3020
|
-
const
|
|
3021
|
-
|
|
3723
|
+
const typesRel = config.output;
|
|
3724
|
+
const typesPath = path7.resolve(configDir, typesRel);
|
|
3725
|
+
const dataRel = dataOutputPath(config.output);
|
|
3726
|
+
const dataPath = path7.resolve(configDir, dataRel);
|
|
3727
|
+
const locsRel = locsOutputPath(config.output);
|
|
3728
|
+
const locsPath = path7.resolve(configDir, locsRel);
|
|
3729
|
+
let typesOnDisk = null;
|
|
3730
|
+
let dataOnDisk = null;
|
|
3731
|
+
let locsOnDisk = null;
|
|
3022
3732
|
if (opts.check) {
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
existingOnDisk = null;
|
|
3027
|
-
}
|
|
3733
|
+
typesOnDisk = readOrNull(typesPath);
|
|
3734
|
+
dataOnDisk = readOrNull(dataPath);
|
|
3735
|
+
locsOnDisk = readOrNull(locsPath);
|
|
3028
3736
|
}
|
|
3737
|
+
const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
|
|
3738
|
+
(ef) => (ef.diagnostics?.length ?? 0) > 0
|
|
3739
|
+
);
|
|
3740
|
+
const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
|
|
3741
|
+
const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
|
|
3029
3742
|
let auditResult;
|
|
3030
|
-
if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
|
|
3743
|
+
if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
|
|
3031
3744
|
auditResult = audit({
|
|
3032
3745
|
registry: resolved.registry,
|
|
3033
3746
|
extracted,
|
|
3034
3747
|
files: sourceFiles,
|
|
3748
|
+
flowExtracted: extractedFlows,
|
|
3035
3749
|
config,
|
|
3036
3750
|
check: opts.check,
|
|
3037
3751
|
lint: opts.lint,
|
|
3038
3752
|
resolveDiagnostics: resolved.diagnostics,
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3753
|
+
// The entity array (and thus the meaningful drift) lives in the data file,
|
|
3754
|
+
// so the entity-aware gen-stale diff runs against it; the types file gets a
|
|
3755
|
+
// plain content compare (see audit()).
|
|
3756
|
+
generated: emitted.data,
|
|
3757
|
+
existingOnDisk: dataOnDisk,
|
|
3758
|
+
outputPath: dataRel,
|
|
3759
|
+
typesGenerated: emitted.types,
|
|
3760
|
+
typesOnDisk,
|
|
3761
|
+
typesOutputPath: typesRel,
|
|
3762
|
+
locsGenerated: emitted.locs,
|
|
3763
|
+
locsOnDisk,
|
|
3764
|
+
locsOutputPath: locsRel,
|
|
3765
|
+
capturedStates,
|
|
3766
|
+
pageBaseline
|
|
3042
3767
|
});
|
|
3043
3768
|
}
|
|
3044
3769
|
return {
|
|
@@ -3047,39 +3772,311 @@ function runOne(dc, opts) {
|
|
|
3047
3772
|
registry: resolved.registry,
|
|
3048
3773
|
gitContext,
|
|
3049
3774
|
audit: auditResult,
|
|
3050
|
-
|
|
3051
|
-
|
|
3775
|
+
types: {
|
|
3776
|
+
generated: emitted.types,
|
|
3777
|
+
outputPath: typesPath,
|
|
3778
|
+
outputRel: typesRel
|
|
3779
|
+
},
|
|
3780
|
+
data: { generated: emitted.data, outputPath: dataPath, outputRel: dataRel },
|
|
3781
|
+
locs: { generated: emitted.locs, outputPath: locsPath, outputRel: locsRel },
|
|
3782
|
+
capturedStates
|
|
3052
3783
|
};
|
|
3053
3784
|
}
|
|
3785
|
+
function readOrNull(p2) {
|
|
3786
|
+
try {
|
|
3787
|
+
return fs4.readFileSync(p2, "utf8");
|
|
3788
|
+
} catch {
|
|
3789
|
+
return null;
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
function writeArtifact(artifact) {
|
|
3793
|
+
if (readOrNull(artifact.outputPath) === artifact.generated) return false;
|
|
3794
|
+
fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
|
|
3795
|
+
fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
|
|
3796
|
+
return true;
|
|
3797
|
+
}
|
|
3054
3798
|
function writeScanResult(result) {
|
|
3055
|
-
|
|
3056
|
-
|
|
3799
|
+
const wroteTypes = writeArtifact(result.types);
|
|
3800
|
+
const wroteData = writeArtifact(result.data);
|
|
3801
|
+
const wroteLocs = writeArtifact(result.locs);
|
|
3802
|
+
return wroteTypes || wroteData || wroteLocs;
|
|
3803
|
+
}
|
|
3804
|
+
function pageBaselinePath(result) {
|
|
3805
|
+
const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3806
|
+
return path7.resolve(result.configDir, rel);
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
// src/scanner/scan/fix.ts
|
|
3810
|
+
import * as fs5 from "fs";
|
|
3811
|
+
import * as path8 from "path";
|
|
3812
|
+
function applyFixes(diagnostics) {
|
|
3813
|
+
const entries = [];
|
|
3814
|
+
for (const d of diagnostics) {
|
|
3815
|
+
if (!d.fix) continue;
|
|
3816
|
+
entries.push({
|
|
3817
|
+
code: d.code,
|
|
3818
|
+
description: d.fix.description,
|
|
3819
|
+
file: d.file,
|
|
3820
|
+
edits: d.fix.edits ?? [],
|
|
3821
|
+
createFiles: d.fix.createFiles ?? [],
|
|
3822
|
+
deleteFiles: d.fix.deleteFiles ?? []
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
if (entries.length === 0) return { applied: [], skipped: [] };
|
|
3826
|
+
const seenEdits = /* @__PURE__ */ new Set();
|
|
3827
|
+
const editsByFile = /* @__PURE__ */ new Map();
|
|
3828
|
+
for (const entry of entries) {
|
|
3829
|
+
for (const edit of entry.edits) {
|
|
3830
|
+
const key = `${edit.path}:${edit.start}:${edit.end}:${edit.replacement}`;
|
|
3831
|
+
if (seenEdits.has(key)) continue;
|
|
3832
|
+
seenEdits.add(key);
|
|
3833
|
+
let list = editsByFile.get(edit.path);
|
|
3834
|
+
if (!list) {
|
|
3835
|
+
list = [];
|
|
3836
|
+
editsByFile.set(edit.path, list);
|
|
3837
|
+
}
|
|
3838
|
+
list.push({ ...edit, entry });
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
for (const [filePath, edits] of editsByFile) {
|
|
3842
|
+
let content;
|
|
3843
|
+
try {
|
|
3844
|
+
content = fs5.readFileSync(filePath, "utf8");
|
|
3845
|
+
} catch {
|
|
3846
|
+
for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
|
|
3847
|
+
continue;
|
|
3848
|
+
}
|
|
3849
|
+
edits.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
3850
|
+
const kept = [];
|
|
3851
|
+
let prevEnd = -1;
|
|
3852
|
+
for (const edit of edits) {
|
|
3853
|
+
if (edit.start < prevEnd) {
|
|
3854
|
+
edit.entry.skippedReason ??= "overlapping edit";
|
|
3855
|
+
continue;
|
|
3856
|
+
}
|
|
3857
|
+
kept.push(edit);
|
|
3858
|
+
prevEnd = edit.end;
|
|
3859
|
+
}
|
|
3860
|
+
for (let i = kept.length - 1; i >= 0; i--) {
|
|
3861
|
+
const edit = kept[i];
|
|
3862
|
+
content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
|
|
3863
|
+
}
|
|
3864
|
+
if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
|
|
3865
|
+
}
|
|
3866
|
+
for (const entry of entries) {
|
|
3867
|
+
if (entry.skippedReason) continue;
|
|
3868
|
+
for (const create of entry.createFiles) {
|
|
3869
|
+
if (fs5.existsSync(create.path)) {
|
|
3870
|
+
entry.skippedReason = `${path8.basename(create.path)} already exists`;
|
|
3871
|
+
continue;
|
|
3872
|
+
}
|
|
3873
|
+
fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
|
|
3874
|
+
fs5.writeFileSync(create.path, create.content, "utf8");
|
|
3875
|
+
}
|
|
3876
|
+
if (entry.skippedReason) continue;
|
|
3877
|
+
for (const del of entry.deleteFiles) {
|
|
3878
|
+
try {
|
|
3879
|
+
fs5.unlinkSync(del);
|
|
3880
|
+
} catch {
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
const applied = [];
|
|
3885
|
+
const skipped = [];
|
|
3886
|
+
for (const entry of entries) {
|
|
3887
|
+
const summary = {
|
|
3888
|
+
code: entry.code,
|
|
3889
|
+
description: entry.description,
|
|
3890
|
+
file: entry.file
|
|
3891
|
+
};
|
|
3892
|
+
if (entry.skippedReason) {
|
|
3893
|
+
skipped.push({ ...summary, reason: entry.skippedReason });
|
|
3894
|
+
} else {
|
|
3895
|
+
applied.push(summary);
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
return { applied, skipped };
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3901
|
+
// src/scanner/scan/rename.ts
|
|
3902
|
+
var ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3903
|
+
function renameEntity(opts) {
|
|
3904
|
+
const { cwd, kind, oldId, newId, force = false } = opts;
|
|
3905
|
+
const manual = [];
|
|
3906
|
+
const errors = [];
|
|
3907
|
+
if (!ID_RE.test(newId)) {
|
|
3908
|
+
return {
|
|
3909
|
+
edits: 0,
|
|
3910
|
+
manual,
|
|
3911
|
+
errors: [`New id "${newId}" is not kebab-case`]
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
const configs = discover({ cwd });
|
|
3915
|
+
if (configs.length === 0) {
|
|
3916
|
+
return { edits: 0, manual, errors: [`No .uidex.json found under ${cwd}`] };
|
|
3917
|
+
}
|
|
3918
|
+
const edits = [];
|
|
3919
|
+
for (const dc of configs) {
|
|
3920
|
+
const { config, configDir } = dc;
|
|
3921
|
+
const sourceFiles = walk(config.sources, {
|
|
3922
|
+
cwd: configDir,
|
|
3923
|
+
globalExcludes: config.exclude
|
|
3924
|
+
});
|
|
3925
|
+
const extracted = extract(sourceFiles);
|
|
3926
|
+
const flowFiles = config.flows ? walk(
|
|
3927
|
+
config.flows.map((glob) => ({ rootDir: ".", include: [glob] })),
|
|
3928
|
+
{ cwd: configDir, includeTests: true }
|
|
3929
|
+
) : [];
|
|
3930
|
+
const extractedFlows = extract(flowFiles);
|
|
3931
|
+
const scan = runScan({ cwd: configDir, configs: [dc] })[0];
|
|
3932
|
+
const registry = scan.registry;
|
|
3933
|
+
if (!registry.get(kind, oldId)) {
|
|
3934
|
+
if (registry.matchPattern(kind, oldId)) {
|
|
3935
|
+
errors.push(
|
|
3936
|
+
`${kind} "${oldId}" only matches via a pattern id; pattern-backed ids cannot be renamed mechanically`
|
|
3937
|
+
);
|
|
3938
|
+
} else {
|
|
3939
|
+
errors.push(`${kind} "${oldId}" not found in registry`);
|
|
3940
|
+
}
|
|
3941
|
+
continue;
|
|
3942
|
+
}
|
|
3943
|
+
if (registry.get(kind, newId) && !force) {
|
|
3944
|
+
errors.push(
|
|
3945
|
+
`${kind} "${newId}" already exists; pass --force to merge the ids`
|
|
3946
|
+
);
|
|
3947
|
+
continue;
|
|
3948
|
+
}
|
|
3949
|
+
const quoteAs = (content, start) => {
|
|
3950
|
+
const q = content[start];
|
|
3951
|
+
return q === '"' || q === "'" || q === "`" ? `${q}${newId}${q}` : `"${newId}"`;
|
|
3952
|
+
};
|
|
3953
|
+
for (const ef of extracted) {
|
|
3954
|
+
for (const a of ef.annotations) {
|
|
3955
|
+
if (a.kind !== kind || a.id !== oldId) continue;
|
|
3956
|
+
if (a.span) {
|
|
3957
|
+
edits.push({
|
|
3958
|
+
path: ef.file.sourcePath,
|
|
3959
|
+
start: a.span.start,
|
|
3960
|
+
end: a.span.end,
|
|
3961
|
+
replacement: quoteAs(ef.file.content, a.span.start)
|
|
3962
|
+
});
|
|
3963
|
+
} else {
|
|
3964
|
+
manual.push({
|
|
3965
|
+
file: a.file,
|
|
3966
|
+
line: a.line,
|
|
3967
|
+
reason: "attribute value is not a plain string literal (const reference, ternary, or template)"
|
|
3968
|
+
});
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
for (const m of ef.metadata ?? []) {
|
|
3972
|
+
if (kind === "widget" && m.kind === "widget" && m.id === oldId) {
|
|
3973
|
+
if (m.idSpan) {
|
|
3974
|
+
edits.push({
|
|
3975
|
+
path: ef.file.sourcePath,
|
|
3976
|
+
start: m.idSpan.start,
|
|
3977
|
+
end: m.idSpan.end,
|
|
3978
|
+
replacement: quoteAs(ef.file.content, m.idSpan.start)
|
|
3979
|
+
});
|
|
3980
|
+
} else {
|
|
3981
|
+
manual.push({
|
|
3982
|
+
file: ef.file.displayPath,
|
|
3983
|
+
line: m.loc.line ?? 1,
|
|
3984
|
+
reason: "widget export id is not a plain string literal"
|
|
3985
|
+
});
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
if (kind === "widget" && m.widgets) {
|
|
3989
|
+
for (let i = 0; i < m.widgets.length; i++) {
|
|
3990
|
+
if (m.widgets[i] !== oldId) continue;
|
|
3991
|
+
const span = m.widgetSpans?.[i];
|
|
3992
|
+
if (span) {
|
|
3993
|
+
edits.push({
|
|
3994
|
+
path: ef.file.sourcePath,
|
|
3995
|
+
start: span.start,
|
|
3996
|
+
end: span.end,
|
|
3997
|
+
replacement: quoteAs(ef.file.content, span.start)
|
|
3998
|
+
});
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
for (const ef of extractedFlows) {
|
|
4005
|
+
for (const fact of ef.flows ?? []) {
|
|
4006
|
+
for (const call of fact.calls) {
|
|
4007
|
+
if (call.id !== oldId) continue;
|
|
4008
|
+
if (call.span) {
|
|
4009
|
+
edits.push({
|
|
4010
|
+
path: ef.file.sourcePath,
|
|
4011
|
+
start: call.span.start,
|
|
4012
|
+
end: call.span.end,
|
|
4013
|
+
replacement: quoteAs(ef.file.content, call.span.start)
|
|
4014
|
+
});
|
|
4015
|
+
} else {
|
|
4016
|
+
manual.push({
|
|
4017
|
+
file: ef.file.displayPath,
|
|
4018
|
+
line: call.line,
|
|
4019
|
+
reason: "uidex() argument is not a plain string literal"
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
if (errors.length > 0) {
|
|
4027
|
+
return { edits: 0, manual, errors };
|
|
4028
|
+
}
|
|
4029
|
+
if (edits.length === 0 && manual.length === 0) {
|
|
4030
|
+
return {
|
|
4031
|
+
edits: 0,
|
|
4032
|
+
manual,
|
|
4033
|
+
errors: [
|
|
4034
|
+
`${kind} "${oldId}" has no editable occurrences (convention-derived ids like landmarks cannot be renamed)`
|
|
4035
|
+
]
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
const result = applyFixes([
|
|
4039
|
+
{
|
|
4040
|
+
code: "rename",
|
|
4041
|
+
severity: "info",
|
|
4042
|
+
message: "",
|
|
4043
|
+
fix: {
|
|
4044
|
+
description: `Rename ${kind} "${oldId}" to "${newId}"`,
|
|
4045
|
+
edits
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
]);
|
|
4049
|
+
if (result.skipped.length > 0) {
|
|
4050
|
+
errors.push(`Some edits were skipped: ${result.skipped[0].reason}`);
|
|
4051
|
+
}
|
|
4052
|
+
for (const r of runScan({ cwd })) writeScanResult(r);
|
|
4053
|
+
return { edits: edits.length, manual, errors };
|
|
3057
4054
|
}
|
|
3058
4055
|
|
|
3059
4056
|
// src/scanner/scan/cli.ts
|
|
3060
|
-
import * as
|
|
3061
|
-
import * as
|
|
4057
|
+
import * as fs8 from "fs";
|
|
4058
|
+
import * as path11 from "path";
|
|
3062
4059
|
|
|
3063
4060
|
// src/scanner/scan/ai/index.ts
|
|
3064
4061
|
import * as p from "@clack/prompts";
|
|
3065
4062
|
|
|
3066
4063
|
// src/scanner/scan/ai/providers/claude.ts
|
|
3067
|
-
import * as
|
|
3068
|
-
import * as
|
|
4064
|
+
import * as fs7 from "fs";
|
|
4065
|
+
import * as path10 from "path";
|
|
3069
4066
|
|
|
3070
4067
|
// src/scanner/scan/ai/templates.ts
|
|
3071
|
-
import * as
|
|
3072
|
-
import * as
|
|
4068
|
+
import * as fs6 from "fs";
|
|
4069
|
+
import * as path9 from "path";
|
|
3073
4070
|
function templatePath(rel) {
|
|
3074
4071
|
const candidates = [
|
|
3075
|
-
|
|
3076
|
-
// dist/cli/cli.cjs
|
|
3077
|
-
|
|
3078
|
-
// src/scan/ai
|
|
4072
|
+
path9.resolve(__dirname, "../../templates", rel),
|
|
4073
|
+
// dist/cli/cli.cjs → ../../templates
|
|
4074
|
+
path9.resolve(__dirname, "../../../../templates", rel)
|
|
4075
|
+
// src/scanner/scan/ai → ../../../../templates
|
|
3079
4076
|
];
|
|
3080
4077
|
for (const c of candidates) {
|
|
3081
4078
|
try {
|
|
3082
|
-
|
|
4079
|
+
fs6.accessSync(c, fs6.constants.R_OK);
|
|
3083
4080
|
return c;
|
|
3084
4081
|
} catch {
|
|
3085
4082
|
continue;
|
|
@@ -3091,24 +4088,39 @@ function templatePath(rel) {
|
|
|
3091
4088
|
);
|
|
3092
4089
|
}
|
|
3093
4090
|
function readTemplate(rel) {
|
|
3094
|
-
return
|
|
4091
|
+
return fs6.readFileSync(templatePath(rel), "utf8");
|
|
3095
4092
|
}
|
|
3096
4093
|
|
|
3097
4094
|
// src/scanner/scan/ai/providers/claude.ts
|
|
3098
|
-
var
|
|
3099
|
-
{ dest: ".claude/
|
|
3100
|
-
{
|
|
3101
|
-
|
|
4095
|
+
var SKILL_FILES = [
|
|
4096
|
+
{ dest: ".claude/skills/uidex/SKILL.md", template: "claude/SKILL.md" },
|
|
4097
|
+
{
|
|
4098
|
+
dest: ".claude/skills/uidex/references/conventions.md",
|
|
4099
|
+
template: "claude/references/conventions.md"
|
|
4100
|
+
},
|
|
4101
|
+
{
|
|
4102
|
+
dest: ".claude/skills/uidex/references/audit.md",
|
|
4103
|
+
template: "claude/references/audit.md"
|
|
4104
|
+
},
|
|
4105
|
+
{
|
|
4106
|
+
dest: ".claude/skills/uidex/references/api.md",
|
|
4107
|
+
template: "claude/references/api.md"
|
|
4108
|
+
}
|
|
4109
|
+
];
|
|
4110
|
+
var LEGACY_FILES = [
|
|
4111
|
+
".claude/rules/uidex.md",
|
|
4112
|
+
".claude/commands/uidex/audit.md",
|
|
4113
|
+
".claude/commands/uidex/api.md"
|
|
3102
4114
|
];
|
|
3103
4115
|
var claudeProvider = {
|
|
3104
4116
|
id: "claude",
|
|
3105
4117
|
label: "Claude Code",
|
|
3106
|
-
description: "Adds .claude/
|
|
4118
|
+
description: "Adds .claude/skills/uidex/ skill with conventions, audit, and API references.",
|
|
3107
4119
|
async install({ cwd, force }) {
|
|
3108
4120
|
const changes = [];
|
|
3109
|
-
for (const file of
|
|
3110
|
-
const dest =
|
|
3111
|
-
const exists =
|
|
4121
|
+
for (const file of SKILL_FILES) {
|
|
4122
|
+
const dest = path10.join(cwd, file.dest);
|
|
4123
|
+
const exists = fs7.existsSync(dest);
|
|
3112
4124
|
if (exists && !force) {
|
|
3113
4125
|
changes.push({
|
|
3114
4126
|
path: file.dest,
|
|
@@ -3117,36 +4129,56 @@ var claudeProvider = {
|
|
|
3117
4129
|
});
|
|
3118
4130
|
continue;
|
|
3119
4131
|
}
|
|
3120
|
-
|
|
3121
|
-
|
|
4132
|
+
fs7.mkdirSync(path10.dirname(dest), { recursive: true });
|
|
4133
|
+
fs7.writeFileSync(dest, readTemplate(file.template));
|
|
3122
4134
|
changes.push({
|
|
3123
4135
|
path: file.dest,
|
|
3124
4136
|
action: exists ? "overwritten" : "created"
|
|
3125
4137
|
});
|
|
3126
4138
|
}
|
|
4139
|
+
for (const rel of LEGACY_FILES) {
|
|
4140
|
+
const dest = path10.join(cwd, rel);
|
|
4141
|
+
if (fs7.existsSync(dest)) {
|
|
4142
|
+
fs7.unlinkSync(dest);
|
|
4143
|
+
changes.push({ path: rel, action: "removed" });
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
|
|
4147
|
+
cleanupEmpty(path10.join(cwd, ".claude/commands"));
|
|
4148
|
+
cleanupEmpty(path10.join(cwd, ".claude/rules"));
|
|
3127
4149
|
return { changes };
|
|
3128
4150
|
},
|
|
3129
4151
|
async uninstall({ cwd }) {
|
|
3130
4152
|
const changes = [];
|
|
3131
|
-
for (const file of
|
|
3132
|
-
const dest =
|
|
3133
|
-
if (!
|
|
4153
|
+
for (const file of SKILL_FILES) {
|
|
4154
|
+
const dest = path10.join(cwd, file.dest);
|
|
4155
|
+
if (!fs7.existsSync(dest)) {
|
|
3134
4156
|
changes.push({ path: file.dest, action: "skipped", reason: "absent" });
|
|
3135
4157
|
continue;
|
|
3136
4158
|
}
|
|
3137
|
-
|
|
4159
|
+
fs7.unlinkSync(dest);
|
|
3138
4160
|
changes.push({ path: file.dest, action: "removed" });
|
|
3139
4161
|
}
|
|
3140
|
-
cleanupEmpty(
|
|
3141
|
-
cleanupEmpty(
|
|
3142
|
-
cleanupEmpty(
|
|
4162
|
+
cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
|
|
4163
|
+
cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
|
|
4164
|
+
cleanupEmpty(path10.join(cwd, ".claude/skills"));
|
|
4165
|
+
for (const rel of LEGACY_FILES) {
|
|
4166
|
+
const dest = path10.join(cwd, rel);
|
|
4167
|
+
if (fs7.existsSync(dest)) {
|
|
4168
|
+
fs7.unlinkSync(dest);
|
|
4169
|
+
changes.push({ path: rel, action: "removed" });
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
|
|
4173
|
+
cleanupEmpty(path10.join(cwd, ".claude/commands"));
|
|
4174
|
+
cleanupEmpty(path10.join(cwd, ".claude/rules"));
|
|
3143
4175
|
return { changes };
|
|
3144
4176
|
}
|
|
3145
4177
|
};
|
|
3146
4178
|
function cleanupEmpty(dir) {
|
|
3147
4179
|
try {
|
|
3148
|
-
const entries =
|
|
3149
|
-
if (entries.length === 0)
|
|
4180
|
+
const entries = fs7.readdirSync(dir);
|
|
4181
|
+
if (entries.length === 0) fs7.rmdirSync(dir);
|
|
3150
4182
|
} catch {
|
|
3151
4183
|
}
|
|
3152
4184
|
}
|
|
@@ -3313,6 +4345,8 @@ async function run(opts) {
|
|
|
3313
4345
|
return runScanCommand(cwd, flags, writer);
|
|
3314
4346
|
case "scaffold":
|
|
3315
4347
|
return runScaffold(cwd, positional.slice(1), flags, writer);
|
|
4348
|
+
case "rename":
|
|
4349
|
+
return runRename(cwd, positional.slice(1), flags, writer);
|
|
3316
4350
|
case "ai": {
|
|
3317
4351
|
const result = await runAiCommand({
|
|
3318
4352
|
cwd,
|
|
@@ -3339,25 +4373,24 @@ function helpText2() {
|
|
|
3339
4373
|
"Commands:",
|
|
3340
4374
|
" init Create a .uidex.json",
|
|
3341
4375
|
" scan [flags] Run the scanner pipeline",
|
|
3342
|
-
" scaffold widget <id> Emit a Playwright spec from
|
|
4376
|
+
" scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
|
|
4377
|
+
" rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
|
|
3343
4378
|
" ai <install|uninstall|providers> Manage AI assistant integrations",
|
|
3344
|
-
" api <METHOD> <PATH> Call the uidex API",
|
|
3345
|
-
" api --list Show available API routes",
|
|
3346
|
-
" api login Authenticate via browser",
|
|
3347
|
-
" api login --token <tok> Store an auth token directly",
|
|
3348
4379
|
"",
|
|
3349
4380
|
"Flags:",
|
|
3350
4381
|
" --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
|
|
3351
|
-
" --lint Run lint diagnostics (missing annotations, scope leak,
|
|
4382
|
+
" --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
|
|
3352
4383
|
" --audit Equivalent to --check --lint (read-only)",
|
|
4384
|
+
" --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
|
|
4385
|
+
" --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
|
|
3353
4386
|
" --json Emit JSON diagnostics on stdout",
|
|
3354
4387
|
" --force (scaffold) overwrite existing spec",
|
|
3355
4388
|
""
|
|
3356
4389
|
].join("\n");
|
|
3357
4390
|
}
|
|
3358
4391
|
function runInit(cwd, w) {
|
|
3359
|
-
const configPath =
|
|
3360
|
-
if (
|
|
4392
|
+
const configPath = path11.join(cwd, CONFIG_FILENAME);
|
|
4393
|
+
if (fs8.existsSync(configPath)) {
|
|
3361
4394
|
w.err(`.uidex.json already exists at ${configPath}`);
|
|
3362
4395
|
return w.result(1);
|
|
3363
4396
|
}
|
|
@@ -3366,16 +4399,16 @@ function runInit(cwd, w) {
|
|
|
3366
4399
|
sources: [{ rootDir: "src" }],
|
|
3367
4400
|
output: "src/uidex.gen.ts"
|
|
3368
4401
|
};
|
|
3369
|
-
|
|
4402
|
+
fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
3370
4403
|
w.out(`Created ${configPath}`);
|
|
3371
|
-
const gitignorePath =
|
|
3372
|
-
const entry = "*.gen
|
|
3373
|
-
if (
|
|
3374
|
-
const existing =
|
|
4404
|
+
const gitignorePath = path11.join(cwd, ".gitignore");
|
|
4405
|
+
const entry = "*.gen.*";
|
|
4406
|
+
if (fs8.existsSync(gitignorePath)) {
|
|
4407
|
+
const existing = fs8.readFileSync(gitignorePath, "utf8");
|
|
3375
4408
|
const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
|
|
3376
4409
|
if (!hasEntry) {
|
|
3377
4410
|
const needsNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
3378
|
-
|
|
4411
|
+
fs8.appendFileSync(
|
|
3379
4412
|
gitignorePath,
|
|
3380
4413
|
`${needsNewline ? "\n" : ""}${entry}
|
|
3381
4414
|
`,
|
|
@@ -3384,24 +4417,62 @@ function runInit(cwd, w) {
|
|
|
3384
4417
|
w.out(`Appended ${entry} to ${gitignorePath}`);
|
|
3385
4418
|
}
|
|
3386
4419
|
} else {
|
|
3387
|
-
|
|
4420
|
+
fs8.writeFileSync(gitignorePath, `${entry}
|
|
3388
4421
|
`, "utf8");
|
|
3389
4422
|
w.out(`Created ${gitignorePath} with ${entry}`);
|
|
3390
4423
|
}
|
|
3391
4424
|
return w.result(0);
|
|
3392
4425
|
}
|
|
3393
4426
|
function runScanCommand(cwd, flags, w) {
|
|
3394
|
-
const
|
|
3395
|
-
const
|
|
4427
|
+
const fix = Boolean(flags.fix);
|
|
4428
|
+
const updateBaseline = Boolean(flags["update-baseline"]);
|
|
4429
|
+
const check = !fix && !updateBaseline && Boolean(flags.check || flags.audit);
|
|
4430
|
+
const lint = Boolean(flags.lint || flags.audit || fix || updateBaseline);
|
|
3396
4431
|
const asJson = Boolean(flags.json);
|
|
3397
|
-
|
|
4432
|
+
let configs = discover({ cwd });
|
|
3398
4433
|
if (configs.length === 0) {
|
|
3399
4434
|
w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
|
|
3400
4435
|
return w.result(1);
|
|
3401
4436
|
}
|
|
4437
|
+
let fixed = [];
|
|
4438
|
+
let fixSkipped = [];
|
|
4439
|
+
if (fix) {
|
|
4440
|
+
const discovery = runScan({ cwd, check: true, lint: true, configs });
|
|
4441
|
+
const result = applyFixes(
|
|
4442
|
+
discovery.flatMap((r) => r.audit?.diagnostics ?? [])
|
|
4443
|
+
);
|
|
4444
|
+
fixed = result.applied;
|
|
4445
|
+
fixSkipped = result.skipped;
|
|
4446
|
+
configs = discover({ cwd });
|
|
4447
|
+
}
|
|
3402
4448
|
const results = runScan({ cwd, check, lint, configs });
|
|
4449
|
+
const artifacts = results.flatMap((r) => [r.types, r.data, r.locs]);
|
|
4450
|
+
const wrote = /* @__PURE__ */ new Set();
|
|
3403
4451
|
if (!check) {
|
|
3404
|
-
for (const
|
|
4452
|
+
for (const a of artifacts) {
|
|
4453
|
+
if (writeArtifact(a)) wrote.add(a);
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
if (updateBaseline) {
|
|
4457
|
+
for (const r of results) {
|
|
4458
|
+
const manifest = r.capturedStates ?? { captured: [] };
|
|
4459
|
+
const pageBaseline = computePageBaseline(r.registry, manifest);
|
|
4460
|
+
const missingKinds = computeMissingKinds(r.registry, manifest);
|
|
4461
|
+
const baseline = {
|
|
4462
|
+
...pageBaseline,
|
|
4463
|
+
...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
|
|
4464
|
+
};
|
|
4465
|
+
const p2 = pageBaselinePath(r);
|
|
4466
|
+
fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
|
|
4467
|
+
const kindGaps = Object.values(missingKinds).reduce(
|
|
4468
|
+
(n, ks) => n + ks.length,
|
|
4469
|
+
0
|
|
4470
|
+
);
|
|
4471
|
+
w.out(
|
|
4472
|
+
`Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
|
|
4473
|
+
);
|
|
4474
|
+
}
|
|
4475
|
+
return w.result(0);
|
|
3405
4476
|
}
|
|
3406
4477
|
const allDiagnostics = results.flatMap((r) => r.audit?.diagnostics ?? []);
|
|
3407
4478
|
const summary = results.reduce(
|
|
@@ -3413,19 +4484,38 @@ function runScanCommand(cwd, flags, w) {
|
|
|
3413
4484
|
{ errors: 0, warnings: 0 }
|
|
3414
4485
|
);
|
|
3415
4486
|
if (asJson) {
|
|
3416
|
-
const out2 = {
|
|
4487
|
+
const out2 = {
|
|
4488
|
+
diagnostics: allDiagnostics.map(jsonDiagnostic),
|
|
4489
|
+
summary,
|
|
4490
|
+
...fix ? { fixed, fixSkipped } : {}
|
|
4491
|
+
};
|
|
3417
4492
|
w.out(JSON.stringify(out2, null, 2));
|
|
3418
4493
|
} else {
|
|
4494
|
+
for (const f of fixed) {
|
|
4495
|
+
w.out(`FIXED [${f.code}] ${f.file ?? ""} ${f.description}`);
|
|
4496
|
+
}
|
|
4497
|
+
for (const s of fixSkipped) {
|
|
4498
|
+
w.out(
|
|
4499
|
+
`SKIPPED [${s.code}] ${s.file ?? ""} ${s.description} (${s.reason})`
|
|
4500
|
+
);
|
|
4501
|
+
}
|
|
3419
4502
|
for (const r of results) {
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
4503
|
+
for (const art of [r.types, r.data, r.locs]) {
|
|
4504
|
+
if (check) {
|
|
4505
|
+
w.out(`Checked ${art.outputPath}`);
|
|
4506
|
+
} else if (wrote.has(art)) {
|
|
4507
|
+
w.out(`Wrote ${art.outputPath}`);
|
|
4508
|
+
} else {
|
|
4509
|
+
w.out(`Unchanged ${art.outputPath}`);
|
|
4510
|
+
}
|
|
3424
4511
|
}
|
|
3425
4512
|
for (const d of r.audit?.diagnostics ?? []) {
|
|
3426
4513
|
const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
|
|
3427
4514
|
const stream = d.severity === "error" ? w.err : w.out;
|
|
3428
|
-
|
|
4515
|
+
const fixable = d.fix && !fix ? " [fixable: run with --fix]" : "";
|
|
4516
|
+
stream(
|
|
4517
|
+
`${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}${fixable}`
|
|
4518
|
+
);
|
|
3429
4519
|
if (d.hint) stream(` hint: ${d.hint}`);
|
|
3430
4520
|
}
|
|
3431
4521
|
}
|
|
@@ -3436,20 +4526,27 @@ function runScanCommand(cwd, flags, w) {
|
|
|
3436
4526
|
const exit = summary.errors > 0 ? 1 : 0;
|
|
3437
4527
|
return w.result(exit);
|
|
3438
4528
|
}
|
|
4529
|
+
function jsonDiagnostic(d) {
|
|
4530
|
+
const { fix, ...rest } = d;
|
|
4531
|
+
return fix ? { ...rest, fixable: true } : rest;
|
|
4532
|
+
}
|
|
4533
|
+
var SCAFFOLD_KINDS = /* @__PURE__ */ new Set(["widget", "page", "feature"]);
|
|
3439
4534
|
function runScaffold(cwd, args, flags, w) {
|
|
3440
4535
|
const [kind, id] = args;
|
|
3441
|
-
if (kind
|
|
3442
|
-
w.err("Usage: uidex scaffold widget <id> [--force]");
|
|
4536
|
+
if (!kind || !SCAFFOLD_KINDS.has(kind) || !id) {
|
|
4537
|
+
w.err("Usage: uidex scaffold <widget|page|feature> <id> [--force]");
|
|
3443
4538
|
return w.result(1);
|
|
3444
4539
|
}
|
|
4540
|
+
const scaffoldKind = kind;
|
|
3445
4541
|
const results = runScan({ cwd });
|
|
3446
4542
|
for (const r of results) {
|
|
3447
|
-
const
|
|
3448
|
-
if (!
|
|
3449
|
-
const outDir =
|
|
3450
|
-
const result =
|
|
4543
|
+
const entity = r.registry.get(scaffoldKind, id);
|
|
4544
|
+
if (!entity) continue;
|
|
4545
|
+
const outDir = path11.resolve(r.configDir, "e2e");
|
|
4546
|
+
const result = scaffoldSpec({
|
|
3451
4547
|
registry: r.registry,
|
|
3452
|
-
|
|
4548
|
+
kind: scaffoldKind,
|
|
4549
|
+
id,
|
|
3453
4550
|
outDir,
|
|
3454
4551
|
force: Boolean(flags.force)
|
|
3455
4552
|
});
|
|
@@ -3460,9 +4557,43 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
3460
4557
|
w.out(`Wrote ${result.outputPath}`);
|
|
3461
4558
|
return w.result(0);
|
|
3462
4559
|
}
|
|
3463
|
-
w.err(
|
|
4560
|
+
w.err(
|
|
4561
|
+
`${scaffoldKind.charAt(0).toUpperCase() + scaffoldKind.slice(1)} "${id}" not found in registry`
|
|
4562
|
+
);
|
|
3464
4563
|
return w.result(1);
|
|
3465
4564
|
}
|
|
4565
|
+
var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
|
|
4566
|
+
function runRename(cwd, args, flags, w) {
|
|
4567
|
+
const [kind, oldId, newId] = args;
|
|
4568
|
+
if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
|
|
4569
|
+
w.err(
|
|
4570
|
+
"Usage: uidex rename <element|widget|region> <old-id> <new-id> [--force]"
|
|
4571
|
+
);
|
|
4572
|
+
return w.result(1);
|
|
4573
|
+
}
|
|
4574
|
+
const result = renameEntity({
|
|
4575
|
+
cwd,
|
|
4576
|
+
kind,
|
|
4577
|
+
oldId,
|
|
4578
|
+
newId,
|
|
4579
|
+
force: Boolean(flags.force)
|
|
4580
|
+
});
|
|
4581
|
+
for (const e of result.errors) w.err(e);
|
|
4582
|
+
for (const m of result.manual) {
|
|
4583
|
+
w.err(`MANUAL ${m.file}:${m.line} \u2014 ${m.reason}`);
|
|
4584
|
+
}
|
|
4585
|
+
if (result.errors.length > 0) return w.result(1);
|
|
4586
|
+
w.out(
|
|
4587
|
+
`Renamed ${kind} "${oldId}" \u2192 "${newId}" (${result.edits} edit(s)); gen file regenerated`
|
|
4588
|
+
);
|
|
4589
|
+
if (result.manual.length > 0) {
|
|
4590
|
+
w.err(
|
|
4591
|
+
`${result.manual.length} occurrence(s) need manual follow-up (listed above)`
|
|
4592
|
+
);
|
|
4593
|
+
return w.result(1);
|
|
4594
|
+
}
|
|
4595
|
+
return w.result(0);
|
|
4596
|
+
}
|
|
3466
4597
|
function createWriter() {
|
|
3467
4598
|
let stdout = "";
|
|
3468
4599
|
let stderr = "";
|
|
@@ -3482,20 +4613,31 @@ export {
|
|
|
3482
4613
|
CONFIG_FILENAME,
|
|
3483
4614
|
ConfigError,
|
|
3484
4615
|
DEFAULT_CONVENTIONS,
|
|
3485
|
-
|
|
4616
|
+
applyFixes,
|
|
3486
4617
|
audit,
|
|
4618
|
+
checkPageCoverage,
|
|
4619
|
+
checkStateCoverage,
|
|
4620
|
+
checkStateMatrix,
|
|
4621
|
+
compileRoute,
|
|
4622
|
+
computeMissingKinds,
|
|
4623
|
+
computePageBaseline,
|
|
3487
4624
|
detectRoutes,
|
|
3488
4625
|
discover,
|
|
3489
4626
|
emit,
|
|
3490
4627
|
extract,
|
|
3491
4628
|
extractUidexExports,
|
|
3492
4629
|
globToRegExp,
|
|
4630
|
+
loadPageBaseline,
|
|
4631
|
+
matchUrlToRoute,
|
|
4632
|
+
pageBaselinePath,
|
|
3493
4633
|
parseConfig,
|
|
3494
4634
|
pathToId,
|
|
4635
|
+
renameEntity,
|
|
3495
4636
|
resolve2 as resolve,
|
|
3496
4637
|
resolveGitContext,
|
|
3497
4638
|
run as runCli,
|
|
3498
4639
|
runScan,
|
|
4640
|
+
scaffoldSpec,
|
|
3499
4641
|
scaffoldWidgetSpec,
|
|
3500
4642
|
validateConfig,
|
|
3501
4643
|
walk,
|