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