uidex 0.9.0 → 0.11.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/dist/cli/cli.cjs +4117 -2902
- package/dist/cli/cli.cjs.map +1 -1
- package/dist/headless/index.cjs +3 -0
- package/dist/headless/index.cjs.map +1 -1
- package/dist/headless/index.d.cts +55 -16
- package/dist/headless/index.d.ts +55 -16
- package/dist/headless/index.js +3 -0
- package/dist/headless/index.js.map +1 -1
- package/dist/index.cjs +7 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -16
- package/dist/index.d.ts +55 -16
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/playwright/index.cjs +40 -7
- package/dist/playwright/index.cjs.map +1 -1
- package/dist/playwright/index.d.cts +15 -1
- package/dist/playwright/index.d.ts +15 -1
- package/dist/playwright/index.js +39 -7
- package/dist/playwright/index.js.map +1 -1
- package/dist/playwright/states-reporter.cjs +36 -7
- package/dist/playwright/states-reporter.cjs.map +1 -1
- package/dist/playwright/states-reporter.d.cts +15 -0
- package/dist/playwright/states-reporter.d.ts +15 -0
- package/dist/playwright/states-reporter.js +36 -7
- package/dist/playwright/states-reporter.js.map +1 -1
- package/dist/playwright/states.cjs +8 -0
- package/dist/playwright/states.cjs.map +1 -1
- package/dist/playwright/states.d.cts +44 -1
- package/dist/playwright/states.d.ts +44 -1
- package/dist/playwright/states.js +7 -0
- package/dist/playwright/states.js.map +1 -1
- package/dist/react/index.cjs +7 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +55 -16
- package/dist/react/index.d.ts +55 -16
- package/dist/react/index.js +7 -1
- package/dist/react/index.js.map +1 -1
- package/dist/scan/index.cjs +1533 -294
- package/dist/scan/index.cjs.map +1 -1
- package/dist/scan/index.d.cts +459 -112
- package/dist/scan/index.d.ts +459 -112
- package/dist/scan/index.js +1517 -290
- package/dist/scan/index.js.map +1 -1
- package/package.json +1 -1
package/dist/scan/index.js
CHANGED
|
@@ -27,10 +27,12 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
|
27
27
|
"exclude",
|
|
28
28
|
"output",
|
|
29
29
|
"flows",
|
|
30
|
+
"resolve",
|
|
30
31
|
"audit",
|
|
31
32
|
"conventions",
|
|
32
33
|
"statesManifest",
|
|
33
|
-
"coverageBaseline"
|
|
34
|
+
"coverageBaseline",
|
|
35
|
+
"workspace"
|
|
34
36
|
]);
|
|
35
37
|
var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
|
|
36
38
|
var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -44,21 +46,74 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
|
|
|
44
46
|
"scopeLeak",
|
|
45
47
|
"coverage",
|
|
46
48
|
"acceptance",
|
|
49
|
+
"surfaces",
|
|
50
|
+
"requireAcceptance",
|
|
47
51
|
"states",
|
|
48
52
|
"pageCoverage",
|
|
49
|
-
"stateMatrix"
|
|
53
|
+
"stateMatrix",
|
|
54
|
+
"severity"
|
|
50
55
|
]);
|
|
56
|
+
var DIAGNOSTIC_CODES = [
|
|
57
|
+
"acceptance-ambiguous",
|
|
58
|
+
"acceptance-duplicate-id",
|
|
59
|
+
"acceptance-orphaned",
|
|
60
|
+
"acceptance-outdated",
|
|
61
|
+
"acceptance-uncovered",
|
|
62
|
+
"acknowledged-elsewhere",
|
|
63
|
+
"competing-uidex-export",
|
|
64
|
+
"component-state-uncaptured",
|
|
65
|
+
"core-kind",
|
|
66
|
+
"covers-missing-rev",
|
|
67
|
+
"duplicate-id",
|
|
68
|
+
"dynamic-attr",
|
|
69
|
+
"dynamic-flow-reference",
|
|
70
|
+
"feature-missing-acceptance",
|
|
71
|
+
"feature-surface-uncaptured",
|
|
72
|
+
"feature-surface-unreferenced",
|
|
73
|
+
"gen-missing",
|
|
74
|
+
"gen-stale",
|
|
75
|
+
"matrix-backlog",
|
|
76
|
+
"missing-element-annotation",
|
|
77
|
+
"missing-state-capture",
|
|
78
|
+
"orphan-state-capture",
|
|
79
|
+
"page-backlog",
|
|
80
|
+
"page-captured",
|
|
81
|
+
"page-no-capture",
|
|
82
|
+
"parse-error",
|
|
83
|
+
"placeholder-reason",
|
|
84
|
+
"prefer-well-known-file",
|
|
85
|
+
"rename",
|
|
86
|
+
"scope-leak",
|
|
87
|
+
"spread-attr",
|
|
88
|
+
"stale-acknowledgment",
|
|
89
|
+
"stale-page-baseline",
|
|
90
|
+
"uidex-export-ambiguous-kind",
|
|
91
|
+
"uidex-export-duplicate-field",
|
|
92
|
+
"uidex-export-duplicate-state",
|
|
93
|
+
"uidex-export-empty-name",
|
|
94
|
+
"uidex-export-invalid-field",
|
|
95
|
+
"uidex-export-invalid-literal",
|
|
96
|
+
"uidex-export-missing-kind",
|
|
97
|
+
"uidex-export-satisfies-mismatch",
|
|
98
|
+
"uidex-export-unknown-field",
|
|
99
|
+
"unknown-reference",
|
|
100
|
+
"widget-id-mismatch",
|
|
101
|
+
"widget-missing-acceptance",
|
|
102
|
+
"widget-missing-dom"
|
|
103
|
+
];
|
|
104
|
+
var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
|
|
105
|
+
var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
|
|
51
106
|
function fail(msg) {
|
|
52
107
|
throw new ConfigError(`Invalid .uidex.json: ${msg}`);
|
|
53
108
|
}
|
|
54
|
-
function assertObject(value,
|
|
109
|
+
function assertObject(value, path14) {
|
|
55
110
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
56
|
-
fail(`${
|
|
111
|
+
fail(`${path14} must be an object`);
|
|
57
112
|
}
|
|
58
113
|
}
|
|
59
|
-
function assertStringArray(value,
|
|
114
|
+
function assertStringArray(value, path14) {
|
|
60
115
|
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
61
|
-
fail(`${
|
|
116
|
+
fail(`${path14} must be a string[]`);
|
|
62
117
|
}
|
|
63
118
|
}
|
|
64
119
|
function validateConfig(raw) {
|
|
@@ -112,11 +167,47 @@ function validateConfig(raw) {
|
|
|
112
167
|
if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
|
|
113
168
|
fail(`"coverageBaseline" must be a string`);
|
|
114
169
|
}
|
|
170
|
+
if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
|
|
171
|
+
fail(`"workspace" must be a path string or false`);
|
|
172
|
+
}
|
|
173
|
+
if (raw.resolve !== void 0) {
|
|
174
|
+
assertObject(raw.resolve, "resolve");
|
|
175
|
+
for (const key of Object.keys(raw.resolve)) {
|
|
176
|
+
if (key !== "aliases") fail(`unknown key "${key}" in resolve`);
|
|
177
|
+
}
|
|
178
|
+
const aliases = raw.resolve.aliases;
|
|
179
|
+
if (aliases === void 0) {
|
|
180
|
+
fail(`resolve.aliases is required when "resolve" is present`);
|
|
181
|
+
}
|
|
182
|
+
assertObject(aliases, "resolve.aliases");
|
|
183
|
+
for (const [alias, target] of Object.entries(aliases)) {
|
|
184
|
+
if (alias.length === 0) fail(`resolve.aliases keys must be non-empty`);
|
|
185
|
+
if (typeof target !== "string") {
|
|
186
|
+
fail(`resolve.aliases["${alias}"] must be a string`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
115
190
|
if (raw.audit !== void 0) {
|
|
116
191
|
assertObject(raw.audit, "audit");
|
|
117
192
|
for (const key of Object.keys(raw.audit)) {
|
|
118
193
|
if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
|
|
119
194
|
const v = raw.audit[key];
|
|
195
|
+
if (key === "severity") {
|
|
196
|
+
assertObject(v, "audit.severity");
|
|
197
|
+
for (const [code, level] of Object.entries(v)) {
|
|
198
|
+
if (!DIAGNOSTIC_CODE_SET.has(code)) {
|
|
199
|
+
fail(
|
|
200
|
+
`unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
|
|
204
|
+
fail(
|
|
205
|
+
`audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
120
211
|
if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
|
|
121
212
|
}
|
|
122
213
|
}
|
|
@@ -156,10 +247,12 @@ function validateConfig(raw) {
|
|
|
156
247
|
exclude: raw.exclude,
|
|
157
248
|
output: raw.output,
|
|
158
249
|
flows: raw.flows,
|
|
250
|
+
resolve: raw.resolve,
|
|
159
251
|
audit: raw.audit,
|
|
160
252
|
conventions: raw.conventions,
|
|
161
253
|
statesManifest: raw.statesManifest,
|
|
162
|
-
coverageBaseline: raw.coverageBaseline
|
|
254
|
+
coverageBaseline: raw.coverageBaseline,
|
|
255
|
+
workspace: raw.workspace
|
|
163
256
|
};
|
|
164
257
|
}
|
|
165
258
|
function parseConfig(json) {
|
|
@@ -209,9 +302,11 @@ function tryReadConfig(configPath) {
|
|
|
209
302
|
function discover(options = {}) {
|
|
210
303
|
const cwd = options.cwd ?? process.cwd();
|
|
211
304
|
const maxDepth = options.maxDepth ?? MAX_DEPTH;
|
|
305
|
+
const all = options.all ?? false;
|
|
212
306
|
const rootMatch = tryReadConfig(path.join(cwd, CONFIG_FILENAME));
|
|
213
|
-
if (rootMatch) return [rootMatch];
|
|
307
|
+
if (rootMatch && !all) return [rootMatch];
|
|
214
308
|
const results = [];
|
|
309
|
+
if (rootMatch) results.push(rootMatch);
|
|
215
310
|
const queue = [[cwd, 0]];
|
|
216
311
|
while (queue.length > 0) {
|
|
217
312
|
const [dir, depth] = queue.shift();
|
|
@@ -315,6 +410,26 @@ function toPosix(p2) {
|
|
|
315
410
|
function matchesAny(rel, patterns) {
|
|
316
411
|
return patterns.some((g) => globToRegExp(g).test(rel));
|
|
317
412
|
}
|
|
413
|
+
function createSourceMatcher(sources, options) {
|
|
414
|
+
const { cwd, globalExcludes = [], includeTests = false } = options;
|
|
415
|
+
const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
|
|
416
|
+
const compiled = sources.map((source) => ({
|
|
417
|
+
absRoot: path2.resolve(cwd, source.rootDir),
|
|
418
|
+
includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
|
|
419
|
+
excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
|
|
420
|
+
}));
|
|
421
|
+
return (absFile) => {
|
|
422
|
+
const abs = path2.resolve(absFile);
|
|
423
|
+
return compiled.some(({ absRoot, includes, excludes }) => {
|
|
424
|
+
const rel = toPosix(path2.relative(absRoot, abs));
|
|
425
|
+
if (rel.length === 0 || rel.startsWith("../") || path2.isAbsolute(rel)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (matchesAny(rel, excludes)) return false;
|
|
429
|
+
return matchesAny(rel, includes);
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
}
|
|
318
433
|
function walk(sources, options) {
|
|
319
434
|
const { cwd, globalExcludes = [], includeTests = false } = options;
|
|
320
435
|
const out2 = [];
|
|
@@ -648,6 +763,109 @@ function createRegistry() {
|
|
|
648
763
|
};
|
|
649
764
|
}
|
|
650
765
|
|
|
766
|
+
// src/scanner/scan/acknowledge.ts
|
|
767
|
+
var ACK_ALL = "*";
|
|
768
|
+
var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
|
|
769
|
+
var COMPONENT_ACK_SCOPES = [ACK_ALL];
|
|
770
|
+
var AUTHORABLE_ACK_TYPES = [
|
|
771
|
+
"impossible",
|
|
772
|
+
"backlog"
|
|
773
|
+
];
|
|
774
|
+
var CORE_SET = new Set(CORE_STATE_KINDS);
|
|
775
|
+
function isCoreKind(scope) {
|
|
776
|
+
return CORE_SET.has(scope);
|
|
777
|
+
}
|
|
778
|
+
function isPlaceholderReason(reason) {
|
|
779
|
+
return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
|
|
780
|
+
}
|
|
781
|
+
function describeAck(a) {
|
|
782
|
+
if (a.type === "captured-elsewhere") {
|
|
783
|
+
return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
|
|
784
|
+
}
|
|
785
|
+
return a.reason ? `${a.type} ("${a.reason}")` : a.type;
|
|
786
|
+
}
|
|
787
|
+
function originHint(a, page) {
|
|
788
|
+
if (a.origin === "baseline") {
|
|
789
|
+
return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
|
|
790
|
+
}
|
|
791
|
+
if (a.origin === "workspace") {
|
|
792
|
+
return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
|
|
793
|
+
}
|
|
794
|
+
const where = page ? ` on page "${page}"` : "";
|
|
795
|
+
return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
|
|
796
|
+
}
|
|
797
|
+
function toResolved(map, origin, into) {
|
|
798
|
+
for (const [scope, ack] of Object.entries(map ?? {})) {
|
|
799
|
+
into.set(scope, { ...ack, scope, origin });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function routeAcknowledge(registry, baseline, route, page) {
|
|
803
|
+
const out2 = /* @__PURE__ */ new Map();
|
|
804
|
+
toResolved(baseline?.acknowledge?.[route], "baseline", out2);
|
|
805
|
+
if (page) {
|
|
806
|
+
toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
|
|
807
|
+
}
|
|
808
|
+
return out2;
|
|
809
|
+
}
|
|
810
|
+
function entityAcknowledge(meta, capturedElsewhereBy) {
|
|
811
|
+
const source = meta?.acknowledge?.[ACK_ALL];
|
|
812
|
+
if (source) return { ...source, scope: ACK_ALL, origin: "source" };
|
|
813
|
+
if (capturedElsewhereBy) {
|
|
814
|
+
return {
|
|
815
|
+
type: "captured-elsewhere",
|
|
816
|
+
by: capturedElsewhereBy,
|
|
817
|
+
scope: ACK_ALL,
|
|
818
|
+
origin: "workspace"
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return void 0;
|
|
822
|
+
}
|
|
823
|
+
function readAck(raw) {
|
|
824
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
825
|
+
const r = raw;
|
|
826
|
+
if (r.type !== "impossible" && r.type !== "backlog") return void 0;
|
|
827
|
+
const out2 = { type: r.type };
|
|
828
|
+
if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
|
|
829
|
+
return out2;
|
|
830
|
+
}
|
|
831
|
+
function parseCoverageBaseline(raw) {
|
|
832
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
833
|
+
const r = raw;
|
|
834
|
+
const ackRaw = r.acknowledge;
|
|
835
|
+
if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
|
|
836
|
+
return null;
|
|
837
|
+
const acknowledge = {};
|
|
838
|
+
for (const [route, scopesRaw] of Object.entries(
|
|
839
|
+
ackRaw
|
|
840
|
+
)) {
|
|
841
|
+
if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
|
|
842
|
+
continue;
|
|
843
|
+
const scopes = {};
|
|
844
|
+
for (const [scope, ackVal] of Object.entries(
|
|
845
|
+
scopesRaw
|
|
846
|
+
)) {
|
|
847
|
+
if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
|
|
848
|
+
const ack = readAck(ackVal);
|
|
849
|
+
if (ack) scopes[scope] = ack;
|
|
850
|
+
}
|
|
851
|
+
if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
|
|
852
|
+
}
|
|
853
|
+
return { version: 2, acknowledge };
|
|
854
|
+
}
|
|
855
|
+
function serializeCoverageBaseline(baseline) {
|
|
856
|
+
const routes = Object.keys(baseline.acknowledge).sort();
|
|
857
|
+
const ordered = {};
|
|
858
|
+
for (const route of routes) {
|
|
859
|
+
const scopes = baseline.acknowledge[route];
|
|
860
|
+
const sorted = {};
|
|
861
|
+
for (const scope of ACK_SCOPES) {
|
|
862
|
+
if (scopes[scope]) sorted[scope] = scopes[scope];
|
|
863
|
+
}
|
|
864
|
+
ordered[route] = sorted;
|
|
865
|
+
}
|
|
866
|
+
return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
|
|
867
|
+
}
|
|
868
|
+
|
|
651
869
|
// src/scanner/scan/extract-uidex-export.ts
|
|
652
870
|
var KIND_DISCRIMINATORS = [
|
|
653
871
|
"page",
|
|
@@ -666,33 +884,44 @@ var ALLOWED_FIELDS = {
|
|
|
666
884
|
"widgets",
|
|
667
885
|
"acceptance",
|
|
668
886
|
"states",
|
|
669
|
-
"
|
|
670
|
-
"waivers",
|
|
887
|
+
"acknowledge",
|
|
671
888
|
"description"
|
|
672
889
|
]),
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
890
|
+
// A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
|
|
891
|
+
// and a core kind (one cell of its matrix). A feature/widget has no route, so
|
|
892
|
+
// the core-kind matrix is meaningless there and only `"*"` is accepted —
|
|
893
|
+
// giving a component a scoped opt-out instead of forcing you to delete its
|
|
894
|
+
// whole `states` block.
|
|
676
895
|
feature: /* @__PURE__ */ new Set([
|
|
677
896
|
"feature",
|
|
678
897
|
"name",
|
|
679
898
|
"features",
|
|
899
|
+
"surfaces",
|
|
680
900
|
"acceptance",
|
|
681
901
|
"states",
|
|
902
|
+
"acknowledge",
|
|
682
903
|
"description"
|
|
683
904
|
]),
|
|
684
905
|
primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
|
|
685
|
-
widget: /* @__PURE__ */ new Set([
|
|
906
|
+
widget: /* @__PURE__ */ new Set([
|
|
907
|
+
"widget",
|
|
908
|
+
"name",
|
|
909
|
+
"acceptance",
|
|
910
|
+
"states",
|
|
911
|
+
"acknowledge",
|
|
912
|
+
"description"
|
|
913
|
+
]),
|
|
686
914
|
region: /* @__PURE__ */ new Set(["region", "name", "description"]),
|
|
687
915
|
flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
|
|
688
916
|
};
|
|
917
|
+
var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
|
|
918
|
+
var ACCEPTANCE_FIELDS = /* @__PURE__ */ new Set(["id", "rev", "text"]);
|
|
689
919
|
var STATE_FIELDS = /* @__PURE__ */ new Set([
|
|
690
920
|
"name",
|
|
691
921
|
"kind",
|
|
692
922
|
"acceptance",
|
|
693
923
|
"description"
|
|
694
924
|
]);
|
|
695
|
-
var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
|
|
696
925
|
var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
|
|
697
926
|
var FALSEABLE = /* @__PURE__ */ new Set([
|
|
698
927
|
"page",
|
|
@@ -936,7 +1165,8 @@ function objectLit(node, content, p2, pos, span) {
|
|
|
936
1165
|
key,
|
|
937
1166
|
value,
|
|
938
1167
|
keyPos,
|
|
939
|
-
span: removalSpan(content, prop.start, prop.end)
|
|
1168
|
+
span: removalSpan(content, prop.start, prop.end),
|
|
1169
|
+
propSpan: { start: prop.start, end: prop.end }
|
|
940
1170
|
});
|
|
941
1171
|
}
|
|
942
1172
|
return { kind: "object", entries, pos, span };
|
|
@@ -1057,7 +1287,8 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1057
1287
|
} else {
|
|
1058
1288
|
id = readIdField(idValue, kind, idField);
|
|
1059
1289
|
}
|
|
1060
|
-
const
|
|
1290
|
+
const criterionIds = /* @__PURE__ */ new Set();
|
|
1291
|
+
const acceptance = readAcceptanceField(byKey, criterionIds);
|
|
1061
1292
|
const description = readStringField(byKey, "description");
|
|
1062
1293
|
const name = readStringField(byKey, "name");
|
|
1063
1294
|
if (name === "") {
|
|
@@ -1083,9 +1314,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1083
1314
|
}
|
|
1084
1315
|
const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
|
|
1085
1316
|
const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
|
|
1086
|
-
const
|
|
1087
|
-
const
|
|
1088
|
-
const
|
|
1317
|
+
const surfacesField = kind === "feature" ? readStringArrayField(byKey, "surfaces") : void 0;
|
|
1318
|
+
const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey, criterionIds) : void 0;
|
|
1319
|
+
const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
|
|
1320
|
+
const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
|
|
1089
1321
|
const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
|
|
1090
1322
|
const metadata = {
|
|
1091
1323
|
source: "ts-export",
|
|
@@ -1108,16 +1340,26 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1108
1340
|
metadata.widgets = widgetsField.values;
|
|
1109
1341
|
metadata.widgetSpans = widgetsField.spans;
|
|
1110
1342
|
}
|
|
1343
|
+
if (surfacesField) {
|
|
1344
|
+
metadata.surfaces = surfacesField.values;
|
|
1345
|
+
metadata.surfaceSpans = surfacesField.spans;
|
|
1346
|
+
}
|
|
1111
1347
|
if (states?.length) metadata.states = states;
|
|
1112
|
-
if (
|
|
1113
|
-
|
|
1348
|
+
if (acknowledge && Object.keys(acknowledge).length > 0) {
|
|
1349
|
+
metadata.acknowledge = acknowledge;
|
|
1350
|
+
}
|
|
1114
1351
|
if (notFlow) metadata.notFlow = true;
|
|
1115
1352
|
if (typeof id === "string" && idValue.kind === "string") {
|
|
1116
1353
|
metadata.idSpan = idValue.span;
|
|
1117
1354
|
}
|
|
1118
1355
|
const fieldSpans = {};
|
|
1119
|
-
|
|
1356
|
+
const fieldPropSpans = {};
|
|
1357
|
+
for (const entry of value.entries) {
|
|
1358
|
+
fieldSpans[entry.key] = entry.span;
|
|
1359
|
+
fieldPropSpans[entry.key] = entry.propSpan;
|
|
1360
|
+
}
|
|
1120
1361
|
metadata.fieldSpans = fieldSpans;
|
|
1362
|
+
metadata.fieldPropSpans = fieldPropSpans;
|
|
1121
1363
|
return metadata;
|
|
1122
1364
|
}
|
|
1123
1365
|
function readIdField(value, kind, fieldName) {
|
|
@@ -1147,47 +1389,185 @@ function readIdField(value, kind, fieldName) {
|
|
|
1147
1389
|
value.pos
|
|
1148
1390
|
);
|
|
1149
1391
|
}
|
|
1150
|
-
function
|
|
1151
|
-
const entry = byKey.get("
|
|
1392
|
+
function readAcknowledgeField(byKey, isPage) {
|
|
1393
|
+
const entry = byKey.get("acknowledge");
|
|
1152
1394
|
if (!entry) return void 0;
|
|
1153
1395
|
if (entry.value.kind !== "object") {
|
|
1154
1396
|
throw new ExtractError(
|
|
1155
1397
|
"uidex-export-invalid-field",
|
|
1156
|
-
|
|
1398
|
+
'`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
|
|
1157
1399
|
entry.value.pos
|
|
1158
1400
|
);
|
|
1159
1401
|
}
|
|
1160
|
-
const
|
|
1402
|
+
const out2 = {};
|
|
1161
1403
|
for (const e of entry.value.entries) {
|
|
1162
|
-
|
|
1404
|
+
const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
|
|
1405
|
+
if (!scopes.includes(e.key)) {
|
|
1163
1406
|
throw new ExtractError(
|
|
1164
1407
|
"uidex-export-invalid-field",
|
|
1165
|
-
`
|
|
1408
|
+
`acknowledge scope "${e.key}" is not valid here; expected ${scopes.map((s) => `"${s}"`).join(", ")}${isPage ? "" : " (core-kind scopes are page-only \u2014 the state matrix is route-scoped)"}.`,
|
|
1166
1409
|
e.keyPos
|
|
1167
1410
|
);
|
|
1168
1411
|
}
|
|
1169
|
-
if (e.value.kind !== "
|
|
1412
|
+
if (e.value.kind !== "object") {
|
|
1413
|
+
throw new ExtractError(
|
|
1414
|
+
"uidex-export-invalid-field",
|
|
1415
|
+
`acknowledge "${e.key}" must be an object \`{ type, reason? }\`.`,
|
|
1416
|
+
e.value.pos
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
const fieldByKey = /* @__PURE__ */ new Map();
|
|
1420
|
+
for (const f of e.value.entries) {
|
|
1421
|
+
if (!ACK_FIELDS.has(f.key)) {
|
|
1422
|
+
throw new ExtractError(
|
|
1423
|
+
"uidex-export-invalid-field",
|
|
1424
|
+
`Unknown field "${f.key}" in an \`acknowledge\` entry. Allowed: ${[...ACK_FIELDS].sort().join(", ")}.`,
|
|
1425
|
+
f.value.pos
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
fieldByKey.set(f.key, f);
|
|
1429
|
+
}
|
|
1430
|
+
const type = readStringField(fieldByKey, "type");
|
|
1431
|
+
if (type === void 0) {
|
|
1170
1432
|
throw new ExtractError(
|
|
1171
1433
|
"uidex-export-invalid-field",
|
|
1172
|
-
`
|
|
1434
|
+
`acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
|
|
1173
1435
|
e.value.pos
|
|
1174
1436
|
);
|
|
1175
1437
|
}
|
|
1176
|
-
|
|
1438
|
+
if (type === "captured-elsewhere") {
|
|
1439
|
+
throw new ExtractError(
|
|
1440
|
+
"uidex-export-invalid-field",
|
|
1441
|
+
'`type: "captured-elsewhere"` cannot be authored; the workspace computes it from the sibling app that captured the declaration.',
|
|
1442
|
+
fieldByKey.get("type").value.pos,
|
|
1443
|
+
"Remove the entry \u2014 if a sibling app really captures this, the workspace reports it as `acknowledged-elsewhere` on its own."
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
if (type !== "impossible" && type !== "backlog") {
|
|
1447
|
+
throw new ExtractError(
|
|
1448
|
+
"uidex-export-invalid-field",
|
|
1449
|
+
`acknowledge \`type\` must be ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")} (got "${type}").`,
|
|
1450
|
+
fieldByKey.get("type").value.pos
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
const reason = readStringField(fieldByKey, "reason");
|
|
1454
|
+
if (type === "impossible" && (reason === void 0 || reason.length === 0)) {
|
|
1455
|
+
throw new ExtractError(
|
|
1456
|
+
"uidex-export-invalid-field",
|
|
1457
|
+
`acknowledge "${e.key}" is \`type: "impossible"\` and needs a non-empty \`reason\` (why this can NEVER be rendered/captured).`,
|
|
1458
|
+
e.value.pos,
|
|
1459
|
+
'If it merely has not been captured yet, that is `type: "backlog"` \u2014 which needs no reason.'
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
out2[e.key] = reason ? { type, reason } : { type };
|
|
1177
1463
|
}
|
|
1178
|
-
return
|
|
1464
|
+
return out2;
|
|
1179
1465
|
}
|
|
1180
|
-
function
|
|
1181
|
-
|
|
1466
|
+
function acceptanceSlug(text) {
|
|
1467
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8).join("-");
|
|
1468
|
+
}
|
|
1469
|
+
function readAcceptanceField(byKey, seen) {
|
|
1470
|
+
const entry = byKey.get("acceptance");
|
|
1182
1471
|
if (!entry) return void 0;
|
|
1183
|
-
|
|
1472
|
+
return readAcceptanceItems(entry, seen);
|
|
1473
|
+
}
|
|
1474
|
+
function readAcceptanceItems(entry, seen) {
|
|
1475
|
+
if (entry.value.kind !== "array") {
|
|
1184
1476
|
throw new ExtractError(
|
|
1185
1477
|
"uidex-export-invalid-field",
|
|
1186
|
-
|
|
1478
|
+
"`acceptance` must be an array of strings or `{ id, rev?, text }` objects.",
|
|
1187
1479
|
entry.value.pos
|
|
1188
1480
|
);
|
|
1189
1481
|
}
|
|
1190
|
-
|
|
1482
|
+
const criteria = [];
|
|
1483
|
+
for (const item of entry.value.items) {
|
|
1484
|
+
let criterion;
|
|
1485
|
+
if (item.kind === "string") {
|
|
1486
|
+
if (item.value.length === 0) {
|
|
1487
|
+
throw new ExtractError(
|
|
1488
|
+
"uidex-export-invalid-field",
|
|
1489
|
+
"An `acceptance` entry is an empty string.",
|
|
1490
|
+
item.pos
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
const id = acceptanceSlug(item.value);
|
|
1494
|
+
if (!id) {
|
|
1495
|
+
throw new ExtractError(
|
|
1496
|
+
"uidex-export-invalid-field",
|
|
1497
|
+
`Acceptance text "${item.value}" contains no alphanumeric characters to derive an id from; use the object form with an explicit \`id\`.`,
|
|
1498
|
+
item.pos
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
criterion = { id, rev: 1, text: item.value };
|
|
1502
|
+
} else if (item.kind === "object") {
|
|
1503
|
+
const fieldByKey = /* @__PURE__ */ new Map();
|
|
1504
|
+
for (const e of item.entries) {
|
|
1505
|
+
if (!ACCEPTANCE_FIELDS.has(e.key)) {
|
|
1506
|
+
throw new ExtractError(
|
|
1507
|
+
"uidex-export-invalid-field",
|
|
1508
|
+
`Unknown field "${e.key}" in an \`acceptance\` entry. Allowed: ${[
|
|
1509
|
+
...ACCEPTANCE_FIELDS
|
|
1510
|
+
].sort().join(", ")}.`,
|
|
1511
|
+
e.value.pos
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
fieldByKey.set(e.key, e);
|
|
1515
|
+
}
|
|
1516
|
+
const id = readStringField(fieldByKey, "id");
|
|
1517
|
+
if (id === void 0 || id.length === 0) {
|
|
1518
|
+
throw new ExtractError(
|
|
1519
|
+
"uidex-export-invalid-field",
|
|
1520
|
+
"An object `acceptance` entry needs a non-empty string `id`.",
|
|
1521
|
+
item.pos
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
if (id.includes("@")) {
|
|
1525
|
+
throw new ExtractError(
|
|
1526
|
+
"uidex-export-invalid-field",
|
|
1527
|
+
`acceptance id "${id}" must not contain "@" \u2014 it is reserved for the \`@<rev>\` pin in \`uidexCovers\` claims.`,
|
|
1528
|
+
item.pos
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
const text = readStringField(fieldByKey, "text");
|
|
1532
|
+
if (text === void 0 || text.length === 0) {
|
|
1533
|
+
throw new ExtractError(
|
|
1534
|
+
"uidex-export-invalid-field",
|
|
1535
|
+
`acceptance "${id}" needs a non-empty string \`text\`.`,
|
|
1536
|
+
item.pos
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
let rev = 1;
|
|
1540
|
+
const revEntry = fieldByKey.get("rev");
|
|
1541
|
+
if (revEntry) {
|
|
1542
|
+
if (revEntry.value.kind !== "number" || !Number.isInteger(revEntry.value.value) || revEntry.value.value < 1) {
|
|
1543
|
+
throw new ExtractError(
|
|
1544
|
+
"uidex-export-invalid-field",
|
|
1545
|
+
`acceptance "${id}" \`rev\` must be a positive integer.`,
|
|
1546
|
+
revEntry.value.pos
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
rev = revEntry.value.value;
|
|
1550
|
+
}
|
|
1551
|
+
criterion = { id, rev, text };
|
|
1552
|
+
} else {
|
|
1553
|
+
throw new ExtractError(
|
|
1554
|
+
"uidex-export-invalid-field",
|
|
1555
|
+
"`acceptance` items must be strings or `{ id, rev?, text }` objects.",
|
|
1556
|
+
item.pos
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
if (seen.has(criterion.id)) {
|
|
1560
|
+
throw new ExtractError(
|
|
1561
|
+
"acceptance-duplicate-id",
|
|
1562
|
+
`Duplicate acceptance criterion id "${criterion.id}"; each criterion id must be unique within the entity.`,
|
|
1563
|
+
item.pos,
|
|
1564
|
+
"Give the criterion an explicit distinct `id` (object form) \u2014 two string criteria can slug to the same id."
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
seen.add(criterion.id);
|
|
1568
|
+
criteria.push(criterion);
|
|
1569
|
+
}
|
|
1570
|
+
return criteria;
|
|
1191
1571
|
}
|
|
1192
1572
|
function readStringField(byKey, name) {
|
|
1193
1573
|
const entry = byKey.get(name);
|
|
@@ -1226,7 +1606,7 @@ function readStringArrayField(byKey, name) {
|
|
|
1226
1606
|
}
|
|
1227
1607
|
return { values, spans };
|
|
1228
1608
|
}
|
|
1229
|
-
function readStatesField(byKey) {
|
|
1609
|
+
function readStatesField(byKey, criterionIds) {
|
|
1230
1610
|
const entry = byKey.get("states");
|
|
1231
1611
|
if (!entry) return void 0;
|
|
1232
1612
|
if (entry.value.kind !== "array") {
|
|
@@ -1276,8 +1656,11 @@ function readStatesField(byKey) {
|
|
|
1276
1656
|
}
|
|
1277
1657
|
state.kind = kind;
|
|
1278
1658
|
}
|
|
1279
|
-
const
|
|
1280
|
-
if (
|
|
1659
|
+
const accEntry = fieldByKey.get("acceptance");
|
|
1660
|
+
if (accEntry) {
|
|
1661
|
+
const acceptance = readAcceptanceItems(accEntry, criterionIds);
|
|
1662
|
+
if (acceptance.length > 0) state.acceptance = acceptance;
|
|
1663
|
+
}
|
|
1281
1664
|
const description = readStringField(fieldByKey, "description");
|
|
1282
1665
|
if (description) state.description = description;
|
|
1283
1666
|
} else {
|
|
@@ -1333,20 +1716,21 @@ function isNode2(value) {
|
|
|
1333
1716
|
}
|
|
1334
1717
|
|
|
1335
1718
|
// src/scanner/scan/flow-facts.ts
|
|
1336
|
-
function collectFlowFacts(parsed, content) {
|
|
1719
|
+
function collectFlowFacts(parsed, content, displayPath) {
|
|
1337
1720
|
if (parsed.program === null || !content.includes("test.describe")) {
|
|
1338
|
-
return [];
|
|
1721
|
+
return { facts: [], diagnostics: [] };
|
|
1339
1722
|
}
|
|
1340
1723
|
const facts = [];
|
|
1724
|
+
const diagnostics = [];
|
|
1341
1725
|
walkAst(parsed.program, (node) => {
|
|
1342
1726
|
if (node.type !== "CallExpression") return void 0;
|
|
1343
|
-
const fact = readTaggedDescribe(node, parsed);
|
|
1727
|
+
const fact = readTaggedDescribe(node, parsed, diagnostics, displayPath);
|
|
1344
1728
|
if (fact) facts.push(fact);
|
|
1345
1729
|
return void 0;
|
|
1346
1730
|
});
|
|
1347
|
-
return facts;
|
|
1731
|
+
return { facts, diagnostics };
|
|
1348
1732
|
}
|
|
1349
|
-
function readTaggedDescribe(call, parsed) {
|
|
1733
|
+
function readTaggedDescribe(call, parsed, diagnostics, displayPath) {
|
|
1350
1734
|
const callee = call.callee;
|
|
1351
1735
|
if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
|
|
1352
1736
|
return null;
|
|
@@ -1357,13 +1741,83 @@ function readTaggedDescribe(call, parsed) {
|
|
|
1357
1741
|
if (!hasFlowTag(args[1])) return null;
|
|
1358
1742
|
const body = args[2];
|
|
1359
1743
|
const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
|
|
1744
|
+
const covers = body ? collectCoversClaims(body, parsed, title, diagnostics, displayPath) : [];
|
|
1360
1745
|
return {
|
|
1361
1746
|
title,
|
|
1362
1747
|
line: parsed.lineAt(call.start),
|
|
1363
1748
|
calls,
|
|
1364
|
-
...dynamicCalls.length > 0 ? { dynamicCalls } : {}
|
|
1749
|
+
...dynamicCalls.length > 0 ? { dynamicCalls } : {},
|
|
1750
|
+
...covers.length > 0 ? { covers } : {}
|
|
1365
1751
|
};
|
|
1366
1752
|
}
|
|
1753
|
+
function collectCoversClaims(body, parsed, flowTitle, diagnostics, displayPath) {
|
|
1754
|
+
const covers = [];
|
|
1755
|
+
walkAst(body, (node) => {
|
|
1756
|
+
if (node.type !== "CallExpression") return void 0;
|
|
1757
|
+
if (!isIdentifier(node.callee, "uidexCovers")) return void 0;
|
|
1758
|
+
const line = parsed.lineAt(node.start);
|
|
1759
|
+
const args = node.arguments ?? [];
|
|
1760
|
+
const entity = stringLiteralValue(args[0]);
|
|
1761
|
+
if (entity === null) {
|
|
1762
|
+
diagnostics.push({
|
|
1763
|
+
code: "covers-missing-rev",
|
|
1764
|
+
severity: "error",
|
|
1765
|
+
message: `\`uidexCovers(\u2026)\` in flow "${flowTitle}" needs a static string entity id as its first argument.`,
|
|
1766
|
+
file: displayPath,
|
|
1767
|
+
line,
|
|
1768
|
+
hint: 'Write `uidexCovers("<entityId>", "<criterionId>@<rev>")` with string literals.'
|
|
1769
|
+
});
|
|
1770
|
+
return void 0;
|
|
1771
|
+
}
|
|
1772
|
+
if (args.length < 2) {
|
|
1773
|
+
diagnostics.push({
|
|
1774
|
+
code: "covers-missing-rev",
|
|
1775
|
+
severity: "error",
|
|
1776
|
+
message: `\`uidexCovers("${entity}")\` in flow "${flowTitle}" names no criterion; a claim covers nothing without at least one \`"<criterionId>@<rev>"\` argument.`,
|
|
1777
|
+
file: displayPath,
|
|
1778
|
+
line,
|
|
1779
|
+
hint: 'List the criteria this flow verifies: `uidexCovers("<entityId>", "<criterionId>@<rev>", ...)`.'
|
|
1780
|
+
});
|
|
1781
|
+
return void 0;
|
|
1782
|
+
}
|
|
1783
|
+
for (const arg of args.slice(1)) {
|
|
1784
|
+
const claimLine = parsed.lineAt(arg.start);
|
|
1785
|
+
const raw = stringLiteralValue(arg);
|
|
1786
|
+
const at = raw === null ? -1 : raw.lastIndexOf("@");
|
|
1787
|
+
if (raw === null || at <= 0) {
|
|
1788
|
+
diagnostics.push({
|
|
1789
|
+
code: "covers-missing-rev",
|
|
1790
|
+
severity: "error",
|
|
1791
|
+
message: `\`uidexCovers(\u2026)\` claim ${raw === null ? "" : `"${raw}" `}in flow "${flowTitle}" is missing its \`@<rev>\` pin.`,
|
|
1792
|
+
file: displayPath,
|
|
1793
|
+
line: claimLine,
|
|
1794
|
+
hint: 'Pin the criterion rev \u2014 `uidexCovers("<entityId>", "<criterionId>@<rev>")` \u2014 so a later rev bump surfaces this claim as outdated.'
|
|
1795
|
+
});
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
const revStr = raw.slice(at + 1);
|
|
1799
|
+
if (!/^[1-9]\d*$/.test(revStr)) {
|
|
1800
|
+
diagnostics.push({
|
|
1801
|
+
code: "covers-missing-rev",
|
|
1802
|
+
severity: "error",
|
|
1803
|
+
message: `\`uidexCovers(\u2026)\` claim "${raw}" in flow "${flowTitle}" has an invalid rev "@${revStr}"; the rev must be a positive integer (no leading zeros).`,
|
|
1804
|
+
file: displayPath,
|
|
1805
|
+
line: claimLine,
|
|
1806
|
+
hint: 'Write the pin as `"<criterionId>@<rev>"` with a positive integer rev, e.g. `"gated@1"`.'
|
|
1807
|
+
});
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
covers.push({
|
|
1811
|
+
entity,
|
|
1812
|
+
criterion: raw.slice(0, at),
|
|
1813
|
+
rev: Number(revStr),
|
|
1814
|
+
line: claimLine
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
return void 0;
|
|
1818
|
+
});
|
|
1819
|
+
return covers;
|
|
1820
|
+
}
|
|
1367
1821
|
function hasFlowTag(node) {
|
|
1368
1822
|
if (!node || node.type !== "ObjectExpression") return false;
|
|
1369
1823
|
for (const prop of node.properties ?? []) {
|
|
@@ -1755,11 +2209,13 @@ function extract(files) {
|
|
|
1755
2209
|
const out2 = { file, annotations: [] };
|
|
1756
2210
|
out2.annotations = extractOne(file, parsed, out2);
|
|
1757
2211
|
if (exports.length > 0) out2.metadata = exports;
|
|
2212
|
+
const flowFacts = collectFlowFacts(parsed, file.content, file.displayPath);
|
|
2213
|
+
diagnostics.push(...flowFacts.diagnostics);
|
|
1758
2214
|
if (diagnostics.length > 0) out2.diagnostics = diagnostics;
|
|
1759
|
-
|
|
1760
|
-
if (flows.length > 0) out2.flows = flows;
|
|
2215
|
+
if (flowFacts.facts.length > 0) out2.flows = flowFacts.facts;
|
|
1761
2216
|
const imports = collectImportFacts(parsed);
|
|
1762
2217
|
if (imports.length > 0) out2.imports = imports;
|
|
2218
|
+
if (containsJsx(parsed)) out2.hasJsx = true;
|
|
1763
2219
|
return out2;
|
|
1764
2220
|
});
|
|
1765
2221
|
}
|
|
@@ -1784,6 +2240,32 @@ function extractOne(file, parsed, out2) {
|
|
|
1784
2240
|
}
|
|
1785
2241
|
return annotations;
|
|
1786
2242
|
}
|
|
2243
|
+
function containsJsx(parsed) {
|
|
2244
|
+
if (parsed.program === null) return false;
|
|
2245
|
+
let found = false;
|
|
2246
|
+
walkAst(parsed.program, (node) => {
|
|
2247
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") {
|
|
2248
|
+
found = true;
|
|
2249
|
+
return false;
|
|
2250
|
+
}
|
|
2251
|
+
if (node.type === "CallExpression" && isCreateElementCallee(node.callee)) {
|
|
2252
|
+
found = true;
|
|
2253
|
+
return false;
|
|
2254
|
+
}
|
|
2255
|
+
return void 0;
|
|
2256
|
+
});
|
|
2257
|
+
return found;
|
|
2258
|
+
}
|
|
2259
|
+
function isCreateElementCallee(callee) {
|
|
2260
|
+
const node = callee;
|
|
2261
|
+
if (!node) return false;
|
|
2262
|
+
if (node.type === "Identifier") return String(node.name) === "createElement";
|
|
2263
|
+
if (node.type === "MemberExpression") {
|
|
2264
|
+
const prop = node.property;
|
|
2265
|
+
return prop?.type === "Identifier" && String(prop.name) === "createElement";
|
|
2266
|
+
}
|
|
2267
|
+
return false;
|
|
2268
|
+
}
|
|
1787
2269
|
function collectImportFacts(parsed) {
|
|
1788
2270
|
if (parsed.program === null) return [];
|
|
1789
2271
|
const out2 = [];
|
|
@@ -1816,6 +2298,21 @@ function collectImportFacts(parsed) {
|
|
|
1816
2298
|
names
|
|
1817
2299
|
});
|
|
1818
2300
|
}
|
|
2301
|
+
walkAst(parsed.program, (node) => {
|
|
2302
|
+
if (node.type !== "ImportExpression") return void 0;
|
|
2303
|
+
const source = node.source;
|
|
2304
|
+
if (!source || source.type !== "Literal") return void 0;
|
|
2305
|
+
if (typeof source.value !== "string") return void 0;
|
|
2306
|
+
out2.push({
|
|
2307
|
+
specifier: source.value,
|
|
2308
|
+
line: parsed.lineAt(node.start),
|
|
2309
|
+
span: { start: node.start, end: node.end },
|
|
2310
|
+
isTypeOnly: false,
|
|
2311
|
+
names: [],
|
|
2312
|
+
dynamic: true
|
|
2313
|
+
});
|
|
2314
|
+
return void 0;
|
|
2315
|
+
});
|
|
1819
2316
|
return out2;
|
|
1820
2317
|
}
|
|
1821
2318
|
|
|
@@ -1987,6 +2484,7 @@ function buildMetaFromExport(exp) {
|
|
|
1987
2484
|
if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
|
|
1988
2485
|
if (exp.features?.length) meta.features = exp.features;
|
|
1989
2486
|
if (exp.widgets?.length) meta.widgets = exp.widgets;
|
|
2487
|
+
if (exp.surfaces?.length) meta.surfaces = exp.surfaces;
|
|
1990
2488
|
if (exp.states?.length) {
|
|
1991
2489
|
meta.states = exp.states.map((s) => ({
|
|
1992
2490
|
name: s.name,
|
|
@@ -1995,9 +2493,8 @@ function buildMetaFromExport(exp) {
|
|
|
1995
2493
|
...s.description ? { description: s.description } : {}
|
|
1996
2494
|
}));
|
|
1997
2495
|
}
|
|
1998
|
-
if (exp.
|
|
1999
|
-
|
|
2000
|
-
meta.waivers = exp.waivers;
|
|
2496
|
+
if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
|
|
2497
|
+
meta.acknowledge = exp.acknowledge;
|
|
2001
2498
|
}
|
|
2002
2499
|
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
2003
2500
|
}
|
|
@@ -2342,6 +2839,45 @@ function resolve2(ctx) {
|
|
|
2342
2839
|
};
|
|
2343
2840
|
registry.add(element);
|
|
2344
2841
|
}
|
|
2842
|
+
const ancestorsById = /* @__PURE__ */ new Map();
|
|
2843
|
+
const fileById = /* @__PURE__ */ new Map();
|
|
2844
|
+
for (const a of allAnnotations) {
|
|
2845
|
+
if (!fileById.has(a.id)) fileById.set(a.id, a.file);
|
|
2846
|
+
if (a.ancestors?.length) {
|
|
2847
|
+
const prev = ancestorsById.get(a.id) ?? [];
|
|
2848
|
+
ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
const ownersByFile = /* @__PURE__ */ new Map();
|
|
2852
|
+
const addOwner = (file, id) => {
|
|
2853
|
+
ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
|
|
2854
|
+
};
|
|
2855
|
+
const featureDirs = [];
|
|
2856
|
+
for (const f of registry.list("feature")) {
|
|
2857
|
+
const loc = f.loc?.file;
|
|
2858
|
+
if (!loc) continue;
|
|
2859
|
+
const dir = loc.endsWith("/") ? loc.slice(0, -1) : path4.posix.dirname(loc);
|
|
2860
|
+
featureDirs.push({ dir, id: f.id });
|
|
2861
|
+
}
|
|
2862
|
+
for (const p2 of registry.list("page")) {
|
|
2863
|
+
if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
|
|
2864
|
+
}
|
|
2865
|
+
const ownersFor = (file) => {
|
|
2866
|
+
const out2 = [...ownersByFile.get(file) ?? []];
|
|
2867
|
+
for (const { dir, id } of featureDirs) {
|
|
2868
|
+
if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
|
|
2869
|
+
}
|
|
2870
|
+
return out2;
|
|
2871
|
+
};
|
|
2872
|
+
const expandTouches = (ids) => {
|
|
2873
|
+
const out2 = new Set(ids);
|
|
2874
|
+
for (const id of ids) {
|
|
2875
|
+
for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
|
|
2876
|
+
const file = fileById.get(id);
|
|
2877
|
+
if (file) for (const owner of ownersFor(file)) out2.add(owner);
|
|
2878
|
+
}
|
|
2879
|
+
return [...out2];
|
|
2880
|
+
};
|
|
2345
2881
|
if (ctx.flowFiles && conventions.flows) {
|
|
2346
2882
|
for (const ff of ctx.flowFiles) {
|
|
2347
2883
|
const notFlowExport = (ff.metadata ?? []).find(
|
|
@@ -2351,7 +2887,10 @@ function resolve2(ctx) {
|
|
|
2351
2887
|
const flowExport = (ff.metadata ?? []).find(
|
|
2352
2888
|
(m) => m.kind === "flow" && typeof m.id === "string"
|
|
2353
2889
|
);
|
|
2354
|
-
const derived = flowsFromFacts(ff)
|
|
2890
|
+
const derived = flowsFromFacts(ff).map((f) => ({
|
|
2891
|
+
...f,
|
|
2892
|
+
touches: expandTouches(f.touches)
|
|
2893
|
+
}));
|
|
2355
2894
|
if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
|
|
2356
2895
|
const base = derived[0];
|
|
2357
2896
|
const flow = {
|
|
@@ -2359,7 +2898,8 @@ function resolve2(ctx) {
|
|
|
2359
2898
|
id: flowExport.id,
|
|
2360
2899
|
loc: base.loc,
|
|
2361
2900
|
touches: base.touches,
|
|
2362
|
-
steps: base.steps
|
|
2901
|
+
steps: base.steps,
|
|
2902
|
+
...base.covers ? { covers: base.covers } : {}
|
|
2363
2903
|
};
|
|
2364
2904
|
registry.add(flow);
|
|
2365
2905
|
} else {
|
|
@@ -2407,20 +2947,95 @@ function computeScope(displayPath) {
|
|
|
2407
2947
|
return null;
|
|
2408
2948
|
}
|
|
2409
2949
|
function flowsFromFacts(ff) {
|
|
2410
|
-
return (ff.flows ?? []).map((fact) =>
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2950
|
+
return (ff.flows ?? []).map((fact) => {
|
|
2951
|
+
const covers = dedupeBy(
|
|
2952
|
+
(fact.covers ?? []).map((c) => ({
|
|
2953
|
+
entity: c.entity,
|
|
2954
|
+
criterion: c.criterion,
|
|
2955
|
+
rev: c.rev
|
|
2956
|
+
})),
|
|
2957
|
+
(c) => `${c.entity} ${c.criterion} ${c.rev}`
|
|
2958
|
+
);
|
|
2959
|
+
return {
|
|
2960
|
+
kind: "flow",
|
|
2961
|
+
id: kebab(fact.title),
|
|
2962
|
+
loc: { file: ff.file.displayPath, line: fact.line },
|
|
2963
|
+
touches: dedupe(fact.calls.map((c) => c.id)),
|
|
2964
|
+
steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action })),
|
|
2965
|
+
...covers.length > 0 ? { covers } : {}
|
|
2966
|
+
};
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
function dedupeBy(arr, key) {
|
|
2970
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2971
|
+
const out2 = [];
|
|
2972
|
+
for (const item of arr) {
|
|
2973
|
+
const k = key(item);
|
|
2974
|
+
if (seen.has(k)) continue;
|
|
2975
|
+
seen.add(k);
|
|
2976
|
+
out2.push(item);
|
|
2977
|
+
}
|
|
2978
|
+
return out2;
|
|
2417
2979
|
}
|
|
2418
2980
|
function dedupe(arr) {
|
|
2419
2981
|
return Array.from(new Set(arr));
|
|
2420
2982
|
}
|
|
2421
2983
|
|
|
2422
2984
|
// src/scanner/scan/audit.ts
|
|
2423
|
-
import * as
|
|
2985
|
+
import * as path7 from "path";
|
|
2986
|
+
|
|
2987
|
+
// src/scanner/scan/component-coverage.ts
|
|
2988
|
+
var COMPOSED_KINDS = ["widget", "feature"];
|
|
2989
|
+
function composedStateEntities(registry, page) {
|
|
2990
|
+
const meta = registry.get("page", page)?.meta;
|
|
2991
|
+
if (!meta) return [];
|
|
2992
|
+
const out2 = [];
|
|
2993
|
+
for (const kind of COMPOSED_KINDS) {
|
|
2994
|
+
const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
|
|
2995
|
+
for (const id of ids) {
|
|
2996
|
+
const e = registry.get(kind, id);
|
|
2997
|
+
if (!e?.meta?.states?.length) continue;
|
|
2998
|
+
if (e.meta.acknowledge?.[ACK_ALL]) continue;
|
|
2999
|
+
out2.push({ kind, id });
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
return out2;
|
|
3003
|
+
}
|
|
3004
|
+
function capturedStatesFor(captured, kind, id) {
|
|
3005
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3006
|
+
for (const c of captured) {
|
|
3007
|
+
if (c.entity !== id) continue;
|
|
3008
|
+
if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
|
|
3009
|
+
out2.add(c.state);
|
|
3010
|
+
}
|
|
3011
|
+
return out2;
|
|
3012
|
+
}
|
|
3013
|
+
function checkComponentCoverage(registry, manifest) {
|
|
3014
|
+
const routes = registry.list("route");
|
|
3015
|
+
if (routes.length === 0) return [];
|
|
3016
|
+
const captured = manifest.captured ?? [];
|
|
3017
|
+
const diagnostics = [];
|
|
3018
|
+
for (const route of routes) {
|
|
3019
|
+
if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
|
|
3020
|
+
for (const { kind, id } of composedStateEntities(registry, route.page)) {
|
|
3021
|
+
const entity = registry.get(kind, id);
|
|
3022
|
+
const declared = entity.meta.states.map((s) => s.name);
|
|
3023
|
+
const shot = capturedStatesFor(captured, kind, id);
|
|
3024
|
+
const missing = declared.filter((s) => !shot.has(s));
|
|
3025
|
+
if (missing.length === 0) continue;
|
|
3026
|
+
diagnostics.push({
|
|
3027
|
+
code: "component-state-uncaptured",
|
|
3028
|
+
severity: "info",
|
|
3029
|
+
message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
|
|
3030
|
+
file: entity.loc?.file,
|
|
3031
|
+
line: entity.loc?.line,
|
|
3032
|
+
entity: { kind, id },
|
|
3033
|
+
hint: `Capture the missing state(s) for "${id}", or drop them from its \`states\` if they no longer exist. This is the integration view: an isolated component capture never proves the state renders correctly INSIDE "${route.path}".`
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
return diagnostics;
|
|
3038
|
+
}
|
|
2424
3039
|
|
|
2425
3040
|
// src/scanner/scan/page-coverage.ts
|
|
2426
3041
|
function compileRoute(pattern) {
|
|
@@ -2443,11 +3058,11 @@ function compileRoute(pattern) {
|
|
|
2443
3058
|
return { test: (url) => re.test(url), specificity };
|
|
2444
3059
|
}
|
|
2445
3060
|
function matchUrlToRoute(url, routePaths) {
|
|
2446
|
-
const
|
|
3061
|
+
const path14 = stripQuery(url);
|
|
2447
3062
|
let best = null;
|
|
2448
3063
|
for (const rp of routePaths) {
|
|
2449
3064
|
const { test, specificity } = compileRoute(rp);
|
|
2450
|
-
if (!test(
|
|
3065
|
+
if (!test(path14)) continue;
|
|
2451
3066
|
if (!best || specificity > best.specificity)
|
|
2452
3067
|
best = { path: rp, specificity };
|
|
2453
3068
|
}
|
|
@@ -2478,29 +3093,49 @@ function checkPageCoverage(registry, manifest, baseline) {
|
|
|
2478
3093
|
if (routes.length === 0) return [];
|
|
2479
3094
|
const routePaths = routes.map((r) => r.path);
|
|
2480
3095
|
const captured = manifest.captured ?? [];
|
|
2481
|
-
const optedOut = /* @__PURE__ */ new Set();
|
|
2482
|
-
for (const r of routes) {
|
|
2483
|
-
const page = registry.get("page", r.page);
|
|
2484
|
-
if (page?.meta?.capture === false) optedOut.add(r.path);
|
|
2485
|
-
}
|
|
2486
3096
|
const covered = /* @__PURE__ */ new Set();
|
|
2487
3097
|
for (const c of captured) {
|
|
2488
3098
|
const route = routeForCapture(c, routes, routePaths);
|
|
2489
3099
|
if (route) covered.add(route);
|
|
2490
3100
|
}
|
|
2491
|
-
const baselineSet = new Set(baseline?.uncapturedPages ?? []);
|
|
2492
3101
|
const diagnostics = [];
|
|
2493
3102
|
for (const r of routes) {
|
|
2494
|
-
|
|
3103
|
+
const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
|
|
3104
|
+
ACK_ALL
|
|
3105
|
+
);
|
|
2495
3106
|
const loc = registry.get("page", r.page)?.loc;
|
|
2496
|
-
if (
|
|
3107
|
+
if (covered.has(r.path)) {
|
|
3108
|
+
if (ack?.type === "impossible") {
|
|
3109
|
+
diagnostics.push({
|
|
3110
|
+
code: "stale-acknowledgment",
|
|
3111
|
+
severity: "warning",
|
|
3112
|
+
message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
|
|
3113
|
+
file: loc?.file,
|
|
3114
|
+
line: loc?.line,
|
|
3115
|
+
entity: { kind: "page", id: r.page },
|
|
3116
|
+
hint: originHint(ack, r.page)
|
|
3117
|
+
});
|
|
3118
|
+
} else if (ack?.type === "backlog") {
|
|
3119
|
+
diagnostics.push({
|
|
3120
|
+
code: "page-captured",
|
|
3121
|
+
severity: "warning",
|
|
3122
|
+
message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
|
|
3123
|
+
file: loc?.file,
|
|
3124
|
+
line: loc?.line,
|
|
3125
|
+
hint: originHint(ack, r.page)
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
if (ack?.type === "impossible") continue;
|
|
3131
|
+
if (ack?.type === "backlog") {
|
|
2497
3132
|
diagnostics.push({
|
|
2498
3133
|
code: "page-backlog",
|
|
2499
3134
|
severity: "info",
|
|
2500
3135
|
message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
|
|
2501
3136
|
file: loc?.file,
|
|
2502
3137
|
line: loc?.line,
|
|
2503
|
-
hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or
|
|
3138
|
+
hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or \u2014 if it can NEVER be captured \u2014 say so with \`acknowledge: { "*": { type: "impossible", reason: "\u2026" } }\` on page "${r.page}".`
|
|
2504
3139
|
});
|
|
2505
3140
|
continue;
|
|
2506
3141
|
}
|
|
@@ -2511,46 +3146,155 @@ function checkPageCoverage(registry, manifest, baseline) {
|
|
|
2511
3146
|
file: loc?.file,
|
|
2512
3147
|
line: loc?.line,
|
|
2513
3148
|
entity: { kind: "route", id: r.path },
|
|
2514
|
-
hint: `Capture the route in a states spec
|
|
3149
|
+
hint: `Capture the route in a states spec; or if it can NEVER be captured add \`acknowledge: { "*": { type: "impossible", reason: "why" } }\` to page "${r.page}"; or record it as debt with \`uidex scan --update-baseline\` (which writes \`{ type: "backlog" }\`, visible in the diff for review).`
|
|
3150
|
+
});
|
|
3151
|
+
}
|
|
3152
|
+
for (const b of Object.keys(baseline?.acknowledge ?? {})) {
|
|
3153
|
+
if (routePaths.includes(b)) continue;
|
|
3154
|
+
diagnostics.push({
|
|
3155
|
+
code: "stale-page-baseline",
|
|
3156
|
+
severity: "warning",
|
|
3157
|
+
message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
|
|
3158
|
+
hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
|
|
2515
3159
|
});
|
|
2516
3160
|
}
|
|
2517
|
-
for (const b of baselineSet) {
|
|
2518
|
-
if (!routePaths.includes(b)) {
|
|
2519
|
-
diagnostics.push({
|
|
2520
|
-
code: "stale-page-baseline",
|
|
2521
|
-
severity: "warning",
|
|
2522
|
-
message: `coverage baseline lists "${b}", which is not a route in the registry`,
|
|
2523
|
-
hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
|
|
2524
|
-
});
|
|
2525
|
-
} else if (covered.has(b) || optedOut.has(b)) {
|
|
2526
|
-
diagnostics.push({
|
|
2527
|
-
code: "page-captured",
|
|
2528
|
-
severity: "warning",
|
|
2529
|
-
message: `route "${b}" is now captured but is still in the coverage baseline`,
|
|
2530
|
-
hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
|
|
2531
|
-
});
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
3161
|
return diagnostics;
|
|
2535
3162
|
}
|
|
2536
|
-
function
|
|
2537
|
-
const
|
|
3163
|
+
function computePageBacklog(registry, manifest) {
|
|
3164
|
+
const out2 = {};
|
|
2538
3165
|
for (const d of checkPageCoverage(registry, manifest, {
|
|
2539
|
-
|
|
3166
|
+
version: 2,
|
|
3167
|
+
acknowledge: {}
|
|
2540
3168
|
})) {
|
|
2541
|
-
if (d.code === "page-no-capture" && d.entity)
|
|
3169
|
+
if (d.code === "page-no-capture" && d.entity) {
|
|
3170
|
+
out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
return out2;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
// src/scanner/scan/workspace.ts
|
|
3177
|
+
import * as fs3 from "fs";
|
|
3178
|
+
import * as path5 from "path";
|
|
3179
|
+
var WORKSPACE_MARKERS = [
|
|
3180
|
+
"pnpm-workspace.yaml",
|
|
3181
|
+
"pnpm-workspace.yml",
|
|
3182
|
+
"turbo.json",
|
|
3183
|
+
"lerna.json",
|
|
3184
|
+
"nx.json",
|
|
3185
|
+
".git"
|
|
3186
|
+
];
|
|
3187
|
+
var MAX_ASCENT = 8;
|
|
3188
|
+
var DEFAULT_STATES_MANIFEST = "uidex-states.json";
|
|
3189
|
+
function captureKey(kind, entity) {
|
|
3190
|
+
return `${kind ?? ""}|${entity}`;
|
|
3191
|
+
}
|
|
3192
|
+
function hasMarker(dir) {
|
|
3193
|
+
return WORKSPACE_MARKERS.some((m) => fs3.existsSync(path5.join(dir, m)));
|
|
3194
|
+
}
|
|
3195
|
+
function findWorkspaceRoot(configDir) {
|
|
3196
|
+
let dir = path5.resolve(configDir);
|
|
3197
|
+
for (let i = 0; i < MAX_ASCENT; i++) {
|
|
3198
|
+
const parent = path5.dirname(dir);
|
|
3199
|
+
if (parent === dir) return void 0;
|
|
3200
|
+
dir = parent;
|
|
3201
|
+
if (hasMarker(dir)) return dir;
|
|
3202
|
+
}
|
|
3203
|
+
return void 0;
|
|
3204
|
+
}
|
|
3205
|
+
function captureKeys(manifest) {
|
|
3206
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3207
|
+
for (const c of manifest?.captured ?? []) {
|
|
3208
|
+
out2.add(captureKey(c.kind, c.entity));
|
|
3209
|
+
}
|
|
3210
|
+
return out2;
|
|
3211
|
+
}
|
|
3212
|
+
function readManifest(configDir, config) {
|
|
3213
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
|
|
3214
|
+
let raw;
|
|
3215
|
+
try {
|
|
3216
|
+
raw = fs3.readFileSync(path5.resolve(configDir, rel), "utf8");
|
|
3217
|
+
} catch {
|
|
3218
|
+
return void 0;
|
|
3219
|
+
}
|
|
3220
|
+
try {
|
|
3221
|
+
const parsed = JSON.parse(raw);
|
|
3222
|
+
if (!parsed || !Array.isArray(parsed.captured)) return void 0;
|
|
3223
|
+
const captured = [];
|
|
3224
|
+
for (const entry of parsed.captured) {
|
|
3225
|
+
if (!entry || typeof entry !== "object") continue;
|
|
3226
|
+
const c = entry;
|
|
3227
|
+
if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
|
|
3228
|
+
const rec = { entity: c.entity, state: c.state };
|
|
3229
|
+
if (typeof c.kind === "string") rec.kind = c.kind;
|
|
3230
|
+
captured.push(rec);
|
|
3231
|
+
}
|
|
3232
|
+
return { captured };
|
|
3233
|
+
} catch {
|
|
3234
|
+
return void 0;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
function toApp(dc) {
|
|
3238
|
+
return {
|
|
3239
|
+
name: path5.basename(dc.configDir),
|
|
3240
|
+
configDir: dc.configDir,
|
|
3241
|
+
configPath: dc.configPath,
|
|
3242
|
+
registers: createSourceMatcher(dc.config.sources, {
|
|
3243
|
+
cwd: dc.configDir,
|
|
3244
|
+
globalExcludes: dc.config.exclude
|
|
3245
|
+
}),
|
|
3246
|
+
captured: captureKeys(readManifest(dc.configDir, dc.config))
|
|
3247
|
+
};
|
|
3248
|
+
}
|
|
3249
|
+
function resolveWorkspace(opts) {
|
|
3250
|
+
const { configDir, config } = opts;
|
|
3251
|
+
if (config.workspace === false) return void 0;
|
|
3252
|
+
const root = typeof config.workspace === "string" ? path5.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
|
|
3253
|
+
if (!root) return void 0;
|
|
3254
|
+
const configs = opts.configs ?? discover({ cwd: root, all: true });
|
|
3255
|
+
const self = path5.resolve(configDir, CONFIG_FILENAME);
|
|
3256
|
+
const siblings = configs.filter((dc) => path5.resolve(dc.configPath) !== self).map(toApp);
|
|
3257
|
+
if (siblings.length === 0) return void 0;
|
|
3258
|
+
return { root, siblings };
|
|
3259
|
+
}
|
|
3260
|
+
function capturedElsewhere(workspace, absFile, kind, id) {
|
|
3261
|
+
if (!workspace || !absFile) return void 0;
|
|
3262
|
+
for (const app of workspace.siblings) {
|
|
3263
|
+
if (!app.registers(absFile)) continue;
|
|
3264
|
+
if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
|
|
3265
|
+
return app.name;
|
|
3266
|
+
}
|
|
2542
3267
|
}
|
|
2543
|
-
return
|
|
3268
|
+
return void 0;
|
|
2544
3269
|
}
|
|
2545
3270
|
|
|
2546
3271
|
// src/scanner/scan/state-coverage.ts
|
|
2547
3272
|
var STATE_KINDS2 = ["page", "feature", "widget"];
|
|
2548
|
-
function declaredStateEntities(registry) {
|
|
3273
|
+
function declaredStateEntities(registry, opts, ungated) {
|
|
2549
3274
|
const out2 = [];
|
|
2550
3275
|
for (const kind of STATE_KINDS2) {
|
|
2551
3276
|
for (const e of registry.list(kind)) {
|
|
2552
3277
|
const states = e.meta?.states;
|
|
2553
3278
|
if (!states || states.length === 0) continue;
|
|
3279
|
+
const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
|
|
3280
|
+
const ack = entityAcknowledge(
|
|
3281
|
+
e.meta,
|
|
3282
|
+
capturedElsewhere(opts.workspace, absFile, kind, e.id)
|
|
3283
|
+
);
|
|
3284
|
+
if (ack) {
|
|
3285
|
+
if (ack.origin === "workspace") {
|
|
3286
|
+
ungated.push({
|
|
3287
|
+
code: "acknowledged-elsewhere",
|
|
3288
|
+
severity: "info",
|
|
3289
|
+
message: `${kind} "${e.id}" declares states that no capture here produced, but sibling app "${ack.by}" registers the same declaration and captured it (${describeAck(ack)})`,
|
|
3290
|
+
file: e.loc?.file,
|
|
3291
|
+
line: e.loc?.line,
|
|
3292
|
+
entity: { kind, id: e.id },
|
|
3293
|
+
hint: `This is the workspace answering, not a guess: "${ack.by}" sources the declaring file and its manifest has a capture for "${e.id}". Set \`workspace: false\` in .uidex.json to gate it here anyway.`
|
|
3294
|
+
});
|
|
3295
|
+
}
|
|
3296
|
+
continue;
|
|
3297
|
+
}
|
|
2554
3298
|
out2.push({
|
|
2555
3299
|
kind,
|
|
2556
3300
|
id: e.id,
|
|
@@ -2561,18 +3305,31 @@ function declaredStateEntities(registry) {
|
|
|
2561
3305
|
}
|
|
2562
3306
|
return out2;
|
|
2563
3307
|
}
|
|
2564
|
-
function
|
|
3308
|
+
function unreliablePageKinds(registry, captured) {
|
|
3309
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3310
|
+
for (const c of captured) {
|
|
3311
|
+
if (c.kind !== "page") continue;
|
|
3312
|
+
if (registry.get("page", c.entity)) continue;
|
|
3313
|
+
out2.add(c.entity);
|
|
3314
|
+
}
|
|
3315
|
+
return out2;
|
|
3316
|
+
}
|
|
3317
|
+
function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
|
|
2565
3318
|
if (cap.entity !== ent.id) return false;
|
|
2566
|
-
|
|
3319
|
+
if (cap.kind === void 0) return true;
|
|
3320
|
+
if (cap.kind === ent.kind) return true;
|
|
3321
|
+
return cap.kind === "page" && unreliable.has(cap.entity);
|
|
2567
3322
|
}
|
|
2568
|
-
|
|
3323
|
+
var EMPTY = /* @__PURE__ */ new Set();
|
|
3324
|
+
function checkStateCoverage(registry, manifest, opts = {}) {
|
|
2569
3325
|
const diagnostics = [];
|
|
2570
|
-
const declared = declaredStateEntities(registry);
|
|
2571
3326
|
const captured = manifest.captured ?? [];
|
|
3327
|
+
const unreliable = unreliablePageKinds(registry, captured);
|
|
3328
|
+
const declared = declaredStateEntities(registry, opts, diagnostics);
|
|
2572
3329
|
for (const ent of declared) {
|
|
2573
3330
|
for (const state of ent.states) {
|
|
2574
3331
|
const hit = captured.some(
|
|
2575
|
-
(c) => captureMatchesEntity(c, ent) && c.state === state
|
|
3332
|
+
(c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
|
|
2576
3333
|
);
|
|
2577
3334
|
if (hit) continue;
|
|
2578
3335
|
diagnostics.push({
|
|
@@ -2587,7 +3344,9 @@ function checkStateCoverage(registry, manifest) {
|
|
|
2587
3344
|
}
|
|
2588
3345
|
}
|
|
2589
3346
|
for (const cap of captured) {
|
|
2590
|
-
const candidates = declared.filter(
|
|
3347
|
+
const candidates = declared.filter(
|
|
3348
|
+
(ent2) => captureMatchesEntity(cap, ent2, unreliable)
|
|
3349
|
+
);
|
|
2591
3350
|
if (candidates.length === 0) continue;
|
|
2592
3351
|
const declaredSomewhere = candidates.some(
|
|
2593
3352
|
(ent2) => ent2.states.includes(cap.state)
|
|
@@ -2631,36 +3390,35 @@ function coreKindsByRoute(registry, manifest) {
|
|
|
2631
3390
|
}
|
|
2632
3391
|
return byRoute;
|
|
2633
3392
|
}
|
|
2634
|
-
function pageWaivers(registry, routePage) {
|
|
2635
|
-
return registry.get("page", routePage)?.meta?.waivers ?? {};
|
|
2636
|
-
}
|
|
2637
|
-
function isOptedOut(registry, page) {
|
|
2638
|
-
return page ? registry.get("page", page)?.meta?.capture === false : false;
|
|
2639
|
-
}
|
|
2640
3393
|
function checkStateMatrix(registry, manifest, baseline) {
|
|
2641
3394
|
const routes = registry.list("route");
|
|
2642
3395
|
if (routes.length === 0) return [];
|
|
2643
3396
|
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
2644
3397
|
const captured = coreKindsByRoute(registry, manifest);
|
|
2645
|
-
const baselined = baseline?.missingKinds ?? {};
|
|
2646
3398
|
const diagnostics = [];
|
|
2647
3399
|
for (const [route, kinds] of captured) {
|
|
2648
3400
|
const page = pageOf.get(route);
|
|
2649
|
-
|
|
2650
|
-
const waivers = page ? pageWaivers(registry, page) : {};
|
|
3401
|
+
const ack = routeAcknowledge(registry, baseline, route, page);
|
|
2651
3402
|
const loc = page ? registry.get("page", page)?.loc : void 0;
|
|
2652
|
-
const
|
|
3403
|
+
const all = ack.get(ACK_ALL);
|
|
3404
|
+
if (all) {
|
|
3405
|
+
if (all.type === "impossible") {
|
|
3406
|
+
diagnostics.push(staleDiagnostic(all, route, page, loc));
|
|
3407
|
+
}
|
|
3408
|
+
continue;
|
|
3409
|
+
}
|
|
2653
3410
|
for (const kind of CORE_KINDS) {
|
|
2654
3411
|
if (kinds.has(kind)) continue;
|
|
2655
|
-
|
|
2656
|
-
if (
|
|
3412
|
+
const entry = ack.get(kind);
|
|
3413
|
+
if (entry?.type === "impossible") continue;
|
|
3414
|
+
if (entry?.type === "backlog") {
|
|
2657
3415
|
diagnostics.push({
|
|
2658
3416
|
code: "matrix-backlog",
|
|
2659
3417
|
severity: "info",
|
|
2660
|
-
message: `route "${route}" does not render core kind "${kind}" (acknowledged
|
|
3418
|
+
message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
|
|
2661
3419
|
file: loc?.file,
|
|
2662
3420
|
line: loc?.line,
|
|
2663
|
-
hint: `Capture the "${kind}" state for "${route}",
|
|
3421
|
+
hint: `Capture the "${kind}" state for "${route}", or if the route CANNOT render it change the acknowledgment to \`{ type: "impossible", reason: "why it can't" }\`.`
|
|
2664
3422
|
});
|
|
2665
3423
|
continue;
|
|
2666
3424
|
}
|
|
@@ -2671,44 +3429,221 @@ function checkStateMatrix(registry, manifest, baseline) {
|
|
|
2671
3429
|
file: loc?.file,
|
|
2672
3430
|
line: loc?.line,
|
|
2673
3431
|
entity: { kind: "route", id: route },
|
|
2674
|
-
hint: `Capture the "${kind}" state
|
|
3432
|
+
hint: `Capture the "${kind}" state; or if the route CANNOT render it, add \`acknowledge: { ${kind}: { type: "impossible", reason: "why it can't" } }\` to page "${page}"; or record it as debt with \`uidex scan --update-baseline\` (which writes \`{ type: "backlog" }\`, visible in the diff for review).`
|
|
2675
3433
|
});
|
|
2676
3434
|
}
|
|
3435
|
+
for (const [scope, entry] of ack) {
|
|
3436
|
+
if (scope === ACK_ALL || !kinds.has(scope)) continue;
|
|
3437
|
+
diagnostics.push(staleDiagnostic(entry, route, page, loc));
|
|
3438
|
+
}
|
|
2677
3439
|
}
|
|
3440
|
+
return diagnostics;
|
|
3441
|
+
}
|
|
3442
|
+
function staleDiagnostic(entry, route, page, loc) {
|
|
3443
|
+
const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
|
|
3444
|
+
return {
|
|
3445
|
+
code: "stale-acknowledgment",
|
|
3446
|
+
severity: "warning",
|
|
3447
|
+
message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
|
|
3448
|
+
file: loc?.file,
|
|
3449
|
+
line: loc?.line,
|
|
3450
|
+
...page ? { entity: { kind: "page", id: page } } : {},
|
|
3451
|
+
hint: originHint(entry, page)
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
function computeMatrixBacklog(registry, manifest) {
|
|
3455
|
+
const routes = registry.list("route");
|
|
3456
|
+
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
3457
|
+
const captured = coreKindsByRoute(registry, manifest);
|
|
3458
|
+
const out2 = {};
|
|
2678
3459
|
for (const [route, kinds] of captured) {
|
|
2679
3460
|
const page = pageOf.get(route);
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
const
|
|
2683
|
-
for (const kind of
|
|
2684
|
-
if (kinds.has(kind))
|
|
3461
|
+
const ack = routeAcknowledge(registry, void 0, route, page);
|
|
3462
|
+
if (ack.has(ACK_ALL)) continue;
|
|
3463
|
+
const scopes = {};
|
|
3464
|
+
for (const kind of CORE_KINDS) {
|
|
3465
|
+
if (kinds.has(kind) || ack.has(kind)) continue;
|
|
3466
|
+
scopes[kind] = { type: "backlog" };
|
|
3467
|
+
}
|
|
3468
|
+
if (Object.keys(scopes).length > 0) out2[route] = scopes;
|
|
3469
|
+
}
|
|
3470
|
+
return out2;
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
// src/scanner/scan/surface-check.ts
|
|
3474
|
+
import * as path6 from "path";
|
|
3475
|
+
var EXT_CANDIDATES = [
|
|
3476
|
+
"",
|
|
3477
|
+
".tsx",
|
|
3478
|
+
".ts",
|
|
3479
|
+
".jsx",
|
|
3480
|
+
".js",
|
|
3481
|
+
"/index.tsx",
|
|
3482
|
+
"/index.ts",
|
|
3483
|
+
"/index.jsx",
|
|
3484
|
+
"/index.js"
|
|
3485
|
+
];
|
|
3486
|
+
function checkFeatureSurfaces(registry, extracted, config) {
|
|
3487
|
+
const declarations = [];
|
|
3488
|
+
for (const ef of extracted) {
|
|
3489
|
+
for (const m of ef.metadata ?? []) {
|
|
3490
|
+
if (m.kind !== "feature") continue;
|
|
3491
|
+
if (typeof m.id !== "string") continue;
|
|
3492
|
+
if (!m.surfaces || m.surfaces.length === 0) continue;
|
|
3493
|
+
if (m.acknowledge?.["*"]) continue;
|
|
3494
|
+
declarations.push({
|
|
3495
|
+
ef,
|
|
3496
|
+
id: m.id,
|
|
3497
|
+
surfaces: m.surfaces,
|
|
3498
|
+
surfaceSpans: m.surfaceSpans,
|
|
3499
|
+
line: m.loc.line ?? 1
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
if (declarations.length === 0) return [];
|
|
3504
|
+
const fileSet = /* @__PURE__ */ new Set();
|
|
3505
|
+
const jsxFiles = /* @__PURE__ */ new Set();
|
|
3506
|
+
for (const ef of extracted) {
|
|
3507
|
+
fileSet.add(ef.file.displayPath);
|
|
3508
|
+
if (ef.hasJsx) jsxFiles.add(ef.file.displayPath);
|
|
3509
|
+
}
|
|
3510
|
+
const aliases = Object.entries(config.resolve?.aliases ?? {}).sort(
|
|
3511
|
+
(a, b) => b[0].length - a[0].length
|
|
3512
|
+
);
|
|
3513
|
+
const resolveSpecifier = (importer, spec) => {
|
|
3514
|
+
const bases = [];
|
|
3515
|
+
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
3516
|
+
bases.push(path6.posix.join(path6.posix.dirname(importer), spec));
|
|
3517
|
+
} else {
|
|
3518
|
+
const alias = aliases.find(([prefix]) => spec.startsWith(prefix));
|
|
3519
|
+
if (alias) {
|
|
3520
|
+
bases.push(alias[1] + spec.slice(alias[0].length));
|
|
3521
|
+
} else if (aliases.length === 0) {
|
|
3522
|
+
if (spec.startsWith("@/") || spec.startsWith("~/")) {
|
|
3523
|
+
bases.push("src/" + spec.slice(2));
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
bases.push(spec);
|
|
3527
|
+
}
|
|
3528
|
+
const hits = [];
|
|
3529
|
+
for (const base of bases) {
|
|
3530
|
+
const normalized = path6.posix.normalize(base);
|
|
3531
|
+
for (const ext of EXT_CANDIDATES) {
|
|
3532
|
+
const candidate = normalized + ext;
|
|
3533
|
+
if (fileSet.has(candidate)) hits.push(candidate);
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
return hits;
|
|
3537
|
+
};
|
|
3538
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
3539
|
+
for (const ef of extracted) {
|
|
3540
|
+
const targets = [];
|
|
3541
|
+
for (const imp of ef.imports ?? []) {
|
|
3542
|
+
if (imp.isTypeOnly) continue;
|
|
3543
|
+
targets.push(...resolveSpecifier(ef.file.displayPath, imp.specifier));
|
|
3544
|
+
}
|
|
3545
|
+
if (targets.length > 0) adjacency.set(ef.file.displayPath, targets);
|
|
3546
|
+
}
|
|
3547
|
+
const inDir = (file, dir) => file === dir || file.startsWith(dir + "/");
|
|
3548
|
+
const reachesDir = (seeds, dir) => {
|
|
3549
|
+
const queue = [...seeds];
|
|
3550
|
+
const visited = new Set(queue);
|
|
3551
|
+
while (queue.length > 0) {
|
|
3552
|
+
const current = queue.pop();
|
|
3553
|
+
if (inDir(current, dir) && jsxFiles.has(current)) return true;
|
|
3554
|
+
for (const next of adjacency.get(current) ?? []) {
|
|
3555
|
+
if (visited.has(next)) continue;
|
|
3556
|
+
visited.add(next);
|
|
3557
|
+
queue.push(next);
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
return false;
|
|
3561
|
+
};
|
|
3562
|
+
const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
|
|
3563
|
+
const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
|
|
3564
|
+
const diagnostics = [];
|
|
3565
|
+
for (const decl of declarations) {
|
|
3566
|
+
const declPath = decl.ef.file.displayPath;
|
|
3567
|
+
const featureDir = (featureGlob !== null ? extractFeatureDir(declPath, featureGlob) : null) ?? path6.posix.dirname(declPath);
|
|
3568
|
+
for (let i = 0; i < decl.surfaces.length; i++) {
|
|
3569
|
+
const pageId = decl.surfaces[i];
|
|
3570
|
+
const line = decl.surfaceSpans?.[i] ? lineOfOffset(decl.ef.file.content, decl.surfaceSpans[i].start) : decl.line;
|
|
3571
|
+
const page = registry.get("page", pageId) ?? registry.matchPattern("page", pageId);
|
|
3572
|
+
if (!page) {
|
|
2685
3573
|
diagnostics.push({
|
|
2686
|
-
code: "
|
|
3574
|
+
code: "unknown-reference",
|
|
2687
3575
|
severity: "warning",
|
|
2688
|
-
message: `
|
|
2689
|
-
file:
|
|
2690
|
-
line
|
|
2691
|
-
entity: { kind: "
|
|
2692
|
-
hint: `
|
|
3576
|
+
message: `Feature "${decl.id}" declares a surface on unknown page "${pageId}"`,
|
|
3577
|
+
file: decl.ef.file.displayPath,
|
|
3578
|
+
line,
|
|
3579
|
+
entity: { kind: "feature", id: decl.id },
|
|
3580
|
+
hint: `No page with id "${pageId}" exists in the registry; fix the reference or add the page.`
|
|
2693
3581
|
});
|
|
3582
|
+
continue;
|
|
2694
3583
|
}
|
|
3584
|
+
const pageFile = page.loc?.file;
|
|
3585
|
+
if (!pageFile) continue;
|
|
3586
|
+
const seeds = [pageFile];
|
|
3587
|
+
if (path6.posix.basename(pageFile) === WELL_KNOWN_FILES.page) {
|
|
3588
|
+
const dir = path6.posix.dirname(pageFile);
|
|
3589
|
+
for (const f of fileSet) {
|
|
3590
|
+
if (f !== pageFile && path6.posix.dirname(f) === dir) seeds.push(f);
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
if (reachesDir(seeds, featureDir)) continue;
|
|
3594
|
+
diagnostics.push({
|
|
3595
|
+
code: "feature-surface-unreferenced",
|
|
3596
|
+
severity: "warning",
|
|
3597
|
+
message: `Feature "${decl.id}" declares page "${pageId}" as a surface, but nothing reachable from ${pageFile} references a component file in ${featureDir}/`,
|
|
3598
|
+
file: decl.ef.file.displayPath,
|
|
3599
|
+
line,
|
|
3600
|
+
entity: { kind: "feature", id: decl.id },
|
|
3601
|
+
hint: `This is a static tripwire, not proof either way: verify page "${pageId}" really mounts a component from ${featureDir}/ (and that the import resolves within the scanned sources), or remove "${pageId}" from the feature's \`surfaces\`.`
|
|
3602
|
+
});
|
|
2695
3603
|
}
|
|
2696
3604
|
}
|
|
2697
3605
|
return diagnostics;
|
|
2698
3606
|
}
|
|
2699
|
-
function
|
|
3607
|
+
function checkFeatureSurfaceCaptures(registry, manifest) {
|
|
2700
3608
|
const routes = registry.list("route");
|
|
2701
|
-
|
|
2702
|
-
const
|
|
2703
|
-
const
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
3609
|
+
if (routes.length === 0) return [];
|
|
3610
|
+
const routePaths = routes.map((r) => r.path);
|
|
3611
|
+
const captured = manifest.captured ?? [];
|
|
3612
|
+
const diagnostics = [];
|
|
3613
|
+
for (const feature of registry.list("feature")) {
|
|
3614
|
+
const surfaces = feature.meta?.surfaces ?? [];
|
|
3615
|
+
if (surfaces.length === 0) continue;
|
|
3616
|
+
if (entityAcknowledge(feature.meta)) continue;
|
|
3617
|
+
const coveredRoutes = /* @__PURE__ */ new Set();
|
|
3618
|
+
for (const c of captured) {
|
|
3619
|
+
if (c.kind !== "feature" || c.entity !== feature.id) continue;
|
|
3620
|
+
const route = routeForCapture(c, routes, routePaths);
|
|
3621
|
+
if (route) coveredRoutes.add(route);
|
|
3622
|
+
}
|
|
3623
|
+
for (const pageId of surfaces) {
|
|
3624
|
+
const pageRoutes = routes.filter((r) => r.page === pageId);
|
|
3625
|
+
if (pageRoutes.length === 0) continue;
|
|
3626
|
+
if (pageRoutes.some((r) => coveredRoutes.has(r.path))) continue;
|
|
3627
|
+
const routeList = pageRoutes.map((r) => `"${r.path}"`).join(", ");
|
|
3628
|
+
diagnostics.push({
|
|
3629
|
+
code: "feature-surface-uncaptured",
|
|
3630
|
+
severity: "warning",
|
|
3631
|
+
message: `Feature "${feature.id}" declares page "${pageId}" as a surface, but no capture rendered the feature on ${pageRoutes.length === 1 ? `route ${routeList}` : `any of its routes (${routeList})`}`,
|
|
3632
|
+
file: feature.loc?.file,
|
|
3633
|
+
line: feature.loc?.line,
|
|
3634
|
+
entity: { kind: "feature", id: feature.id },
|
|
3635
|
+
hint: `Author a states capture spec that renders feature "${feature.id}" on page "${pageId}" (a capture with kind "feature" driving ${routeList}), or acknowledge the feature with \`acknowledge: { "*": \u2026 }\`.`
|
|
3636
|
+
});
|
|
3637
|
+
}
|
|
2710
3638
|
}
|
|
2711
|
-
return
|
|
3639
|
+
return diagnostics;
|
|
3640
|
+
}
|
|
3641
|
+
function lineOfOffset(content, offset) {
|
|
3642
|
+
let line = 1;
|
|
3643
|
+
for (let i = 0; i < offset && i < content.length; i++) {
|
|
3644
|
+
if (content[i] === "\n") line++;
|
|
3645
|
+
}
|
|
3646
|
+
return line;
|
|
2712
3647
|
}
|
|
2713
3648
|
|
|
2714
3649
|
// src/scanner/scan/audit.ts
|
|
@@ -2718,6 +3653,8 @@ function audit(opts) {
|
|
|
2718
3653
|
const check = opts.check ?? false;
|
|
2719
3654
|
const lint = opts.lint ?? false;
|
|
2720
3655
|
const acceptanceEnabled = config.audit?.acceptance ?? true;
|
|
3656
|
+
const surfacesEnabled = config.audit?.surfaces ?? true;
|
|
3657
|
+
const requireAcceptanceEnabled = config.audit?.requireAcceptance ?? true;
|
|
2721
3658
|
const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
|
|
2722
3659
|
const coverageEnabled = config.audit?.coverage ?? true;
|
|
2723
3660
|
const statesEnabled = config.audit?.states ?? true;
|
|
@@ -2730,6 +3667,21 @@ function audit(opts) {
|
|
|
2730
3667
|
for (const ef of opts.flowExtracted ?? []) {
|
|
2731
3668
|
if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
|
|
2732
3669
|
}
|
|
3670
|
+
for (const ef of extracted) {
|
|
3671
|
+
for (const m of ef.metadata ?? []) {
|
|
3672
|
+
for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
|
|
3673
|
+
if (!isPlaceholderReason(ack.reason)) continue;
|
|
3674
|
+
diagnostics.push({
|
|
3675
|
+
code: "placeholder-reason",
|
|
3676
|
+
severity: "warning",
|
|
3677
|
+
message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
|
|
3678
|
+
file: ef.file.displayPath,
|
|
3679
|
+
line: m.loc.line,
|
|
3680
|
+
hint: `Replace it with why this can NEVER be captured \u2014 or, if it merely has not been captured yet, change the type to "backlog" (which needs no reason).`
|
|
3681
|
+
});
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
2733
3685
|
if (check && opts.generated !== void 0) {
|
|
2734
3686
|
const outRel = opts.outputPath ?? config.output;
|
|
2735
3687
|
const fresh = normalizeForCheck(opts.generated);
|
|
@@ -2799,9 +3751,66 @@ function audit(opts) {
|
|
|
2799
3751
|
}
|
|
2800
3752
|
}
|
|
2801
3753
|
if (lint && acceptanceEnabled) {
|
|
2802
|
-
|
|
3754
|
+
const flowEntities = registry.list("flow");
|
|
3755
|
+
const acceptanceKinds = ["widget", "feature", "page"];
|
|
3756
|
+
const claims = /* @__PURE__ */ new Map();
|
|
3757
|
+
const addClaim = (kind, entityId, criterion, claim) => {
|
|
3758
|
+
const key = `${kind}:${entityId}`;
|
|
3759
|
+
let byCrit = claims.get(key);
|
|
3760
|
+
if (!byCrit) {
|
|
3761
|
+
byCrit = /* @__PURE__ */ new Map();
|
|
3762
|
+
claims.set(key, byCrit);
|
|
3763
|
+
}
|
|
3764
|
+
let list = byCrit.get(criterion);
|
|
3765
|
+
if (!list) {
|
|
3766
|
+
list = [];
|
|
3767
|
+
byCrit.set(criterion, list);
|
|
3768
|
+
}
|
|
3769
|
+
list.push(claim);
|
|
3770
|
+
};
|
|
3771
|
+
for (const flow of flowEntities) {
|
|
3772
|
+
for (const c of flow.covers ?? []) {
|
|
3773
|
+
const candidates = acceptanceKinds.flatMap((k) => {
|
|
3774
|
+
const e = registry.get(k, c.entity);
|
|
3775
|
+
return e ? [e] : [];
|
|
3776
|
+
});
|
|
3777
|
+
const matching = candidates.filter(
|
|
3778
|
+
(e) => entityCriteria(e).some((crit) => crit.id === c.criterion)
|
|
3779
|
+
);
|
|
3780
|
+
if (matching.length === 0) {
|
|
3781
|
+
diagnostics.push({
|
|
3782
|
+
code: "acceptance-orphaned",
|
|
3783
|
+
severity: "warning",
|
|
3784
|
+
message: `Flow "${flow.id}" covers criterion "${c.criterion}" on "${c.entity}", but ${candidates.length > 0 ? `no criterion with that id exists on ${candidates.map((e) => `${e.kind} "${c.entity}"`).join(" or ")}` : `no page/feature/widget "${c.entity}" exists`}`,
|
|
3785
|
+
file: flow.loc.file,
|
|
3786
|
+
line: flow.loc.line,
|
|
3787
|
+
entity: { kind: "flow", id: flow.id },
|
|
3788
|
+
hint: "Remove the stale uidexCovers claim, or fix the entity/criterion id it names."
|
|
3789
|
+
});
|
|
3790
|
+
continue;
|
|
3791
|
+
}
|
|
3792
|
+
if (matching.length > 1) {
|
|
3793
|
+
diagnostics.push({
|
|
3794
|
+
code: "acceptance-ambiguous",
|
|
3795
|
+
severity: "warning",
|
|
3796
|
+
message: `Flow "${flow.id}" covers criterion "${c.criterion}" on "${c.entity}", which matches ${matching.map((e) => `${e.kind} "${c.entity}"`).join(" and ")}; the claim credits all of them`,
|
|
3797
|
+
file: flow.loc.file,
|
|
3798
|
+
line: flow.loc.line,
|
|
3799
|
+
entity: { kind: "flow", id: flow.id },
|
|
3800
|
+
hint: "The claim syntax carries no kind, so a criterion id shared by same-id entities cannot be told apart \u2014 rename one entity or criterion id to disambiguate."
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3803
|
+
for (const target of matching) {
|
|
3804
|
+
addClaim(target.kind, target.id, c.criterion, {
|
|
3805
|
+
flowId: flow.id,
|
|
3806
|
+
rev: c.rev
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
for (const kind of acceptanceKinds) {
|
|
2803
3812
|
for (const e of registry.list(kind)) {
|
|
2804
|
-
const criteria = e
|
|
3813
|
+
const criteria = entityCriteria(e);
|
|
2805
3814
|
if (criteria.length === 0) {
|
|
2806
3815
|
if (kind === "widget") {
|
|
2807
3816
|
diagnostics.push({
|
|
@@ -2816,14 +3825,27 @@ function audit(opts) {
|
|
|
2816
3825
|
}
|
|
2817
3826
|
continue;
|
|
2818
3827
|
}
|
|
2819
|
-
const
|
|
2820
|
-
|
|
2821
|
-
const
|
|
2822
|
-
|
|
3828
|
+
const entityClaims = claims.get(`${kind}:${e.id}`);
|
|
3829
|
+
for (const c of criteria) {
|
|
3830
|
+
const critClaims = entityClaims?.get(c.id) ?? [];
|
|
3831
|
+
const stale = critClaims.filter((cl) => cl.rev !== c.rev);
|
|
3832
|
+
for (const cl of stale) {
|
|
3833
|
+
diagnostics.push({
|
|
3834
|
+
code: "acceptance-outdated",
|
|
3835
|
+
severity: "warning",
|
|
3836
|
+
message: `Flow "${cl.flowId}" covers ${kind} "${e.id}" criterion "${c.id}" at rev ${cl.rev}, but the criterion is now rev ${c.rev}`,
|
|
3837
|
+
file: e.loc?.file,
|
|
3838
|
+
line: e.loc?.line,
|
|
3839
|
+
entity: { kind, id: e.id },
|
|
3840
|
+
hint: `Re-verify the flow against the current criterion text, then update its claim to \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`.`
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
if (critClaims.length === 0) {
|
|
3844
|
+
const hint = kind === "widget" ? `uidex scaffold widget ${e.id}, then claim it with \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`` : `Add a Playwright flow under e2e/** that touches ${kind} "${e.id}" (tag the describe with @uidex:flow) and claim the criterion with \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\``;
|
|
2823
3845
|
diagnostics.push({
|
|
2824
3846
|
code: "acceptance-uncovered",
|
|
2825
3847
|
severity: "warning",
|
|
2826
|
-
message: `${kind} "${e.id}" has acceptance criterion not covered by any flow: "${c}"`,
|
|
3848
|
+
message: `${kind} "${e.id}" has acceptance criterion "${c.id}" not covered by any flow: "${c.text}"`,
|
|
2827
3849
|
file: e.loc?.file,
|
|
2828
3850
|
line: e.loc?.line,
|
|
2829
3851
|
entity: { kind, id: e.id },
|
|
@@ -2834,7 +3856,31 @@ function audit(opts) {
|
|
|
2834
3856
|
}
|
|
2835
3857
|
}
|
|
2836
3858
|
}
|
|
3859
|
+
if (lint && surfacesEnabled) {
|
|
3860
|
+
diagnostics.push(...checkFeatureSurfaces(registry, extracted, config));
|
|
3861
|
+
}
|
|
3862
|
+
if (lint && surfacesEnabled && opts.capturedStates) {
|
|
3863
|
+
diagnostics.push(
|
|
3864
|
+
...checkFeatureSurfaceCaptures(registry, opts.capturedStates)
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
if (lint && requireAcceptanceEnabled) {
|
|
3868
|
+
for (const e of registry.list("feature")) {
|
|
3869
|
+
if (entityCriteria(e).length > 0) continue;
|
|
3870
|
+
diagnostics.push({
|
|
3871
|
+
code: "feature-missing-acceptance",
|
|
3872
|
+
severity: "warning",
|
|
3873
|
+
message: `Feature "${e.id}" declares no acceptance criteria`,
|
|
3874
|
+
file: e.loc?.file,
|
|
3875
|
+
line: e.loc?.line,
|
|
3876
|
+
entity: { kind: "feature", id: e.id },
|
|
3877
|
+
hint: `Add \`acceptance: [...]\` to the feature's \`export const uidex\` \u2014 observable outcomes, not implementation. They feed \`uidex scaffold feature ${e.id}\` and the acceptance-uncovered gate.`
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
2837
3881
|
if (lint) {
|
|
3882
|
+
const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
|
|
3883
|
+
const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
|
|
2838
3884
|
const scannedPaths = new Set(files.map((f) => f.displayPath));
|
|
2839
3885
|
for (const ef of extracted) {
|
|
2840
3886
|
if (!ef.metadata) continue;
|
|
@@ -2843,10 +3889,24 @@ function audit(opts) {
|
|
|
2843
3889
|
if (typeof m.id !== "string") continue;
|
|
2844
3890
|
const filePath = ef.file.displayPath;
|
|
2845
3891
|
const wellKnownName = WELL_KNOWN_FILES[m.kind];
|
|
2846
|
-
if (
|
|
2847
|
-
const
|
|
3892
|
+
if (path7.posix.basename(filePath) === wellKnownName) continue;
|
|
3893
|
+
const featureDir = m.kind === "feature" && featureGlob !== null ? extractFeatureDir(filePath, featureGlob) : null;
|
|
3894
|
+
const dir = featureDir ?? path7.posix.dirname(filePath);
|
|
2848
3895
|
const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
|
|
2849
3896
|
if (scannedPaths.has(wellKnownPath)) continue;
|
|
3897
|
+
const inConventionDir = featureDir !== null;
|
|
3898
|
+
if (inConventionDir) {
|
|
3899
|
+
diagnostics.push({
|
|
3900
|
+
code: "prefer-well-known-file",
|
|
3901
|
+
severity: "error",
|
|
3902
|
+
message: `Feature "${m.id}" metadata lives on ${filePath}; a feature in a convention-derived directory must declare its metadata in ${wellKnownPath}`,
|
|
3903
|
+
file: filePath,
|
|
3904
|
+
line: m.loc.line,
|
|
3905
|
+
entity: { kind: "feature", id: m.id },
|
|
3906
|
+
hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
|
|
3907
|
+
});
|
|
3908
|
+
continue;
|
|
3909
|
+
}
|
|
2850
3910
|
const kindLabel = m.kind === "page" ? "Page" : "Feature";
|
|
2851
3911
|
diagnostics.push({
|
|
2852
3912
|
code: "prefer-well-known-file",
|
|
@@ -2930,6 +3990,7 @@ function audit(opts) {
|
|
|
2930
3990
|
const displayPath = ef.file.displayPath;
|
|
2931
3991
|
for (const imp of ef.imports ?? []) {
|
|
2932
3992
|
if (imp.isTypeOnly) continue;
|
|
3993
|
+
if (imp.dynamic) continue;
|
|
2933
3994
|
const baseName2 = imp.specifier.split("/").pop() ?? "";
|
|
2934
3995
|
const primitive = byName.get(
|
|
2935
3996
|
baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
|
|
@@ -3047,7 +4108,7 @@ function audit(opts) {
|
|
|
3047
4108
|
severity: "warning",
|
|
3048
4109
|
message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
|
|
3049
4110
|
file: ef.file.displayPath,
|
|
3050
|
-
line: spans?.[i] ?
|
|
4111
|
+
line: spans?.[i] ? lineOfOffset2(ef.file.content, spans[i].start) : m.loc.line,
|
|
3051
4112
|
hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
|
|
3052
4113
|
});
|
|
3053
4114
|
}
|
|
@@ -3058,33 +4119,57 @@ function audit(opts) {
|
|
|
3058
4119
|
}
|
|
3059
4120
|
}
|
|
3060
4121
|
if (lint && statesEnabled && opts.capturedStates) {
|
|
3061
|
-
|
|
4122
|
+
const sourceByDisplay = new Map(
|
|
4123
|
+
files.map((f) => [f.displayPath, f.sourcePath])
|
|
4124
|
+
);
|
|
4125
|
+
diagnostics.push(
|
|
4126
|
+
...checkStateCoverage(registry, opts.capturedStates, {
|
|
4127
|
+
workspace: opts.workspace,
|
|
4128
|
+
resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
|
|
4129
|
+
})
|
|
4130
|
+
);
|
|
3062
4131
|
}
|
|
3063
4132
|
if (lint && pageCoverageEnabled && opts.capturedStates) {
|
|
3064
4133
|
diagnostics.push(
|
|
3065
|
-
...checkPageCoverage(registry, opts.capturedStates, opts.
|
|
4134
|
+
...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
|
|
3066
4135
|
);
|
|
3067
4136
|
}
|
|
4137
|
+
if (lint && statesEnabled && opts.capturedStates) {
|
|
4138
|
+
diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
|
|
4139
|
+
}
|
|
3068
4140
|
if (lint && stateMatrixEnabled && opts.capturedStates) {
|
|
3069
4141
|
diagnostics.push(
|
|
3070
|
-
...checkStateMatrix(registry, opts.capturedStates,
|
|
3071
|
-
missingKinds: opts.pageBaseline?.missingKinds ?? {}
|
|
3072
|
-
})
|
|
4142
|
+
...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
|
|
3073
4143
|
);
|
|
3074
4144
|
}
|
|
4145
|
+
const overrides = config.audit?.severity ?? {};
|
|
4146
|
+
const governed = diagnostics.flatMap((d) => {
|
|
4147
|
+
const level = overrides[d.code];
|
|
4148
|
+
if (level === void 0) return [d];
|
|
4149
|
+
if (level === "off") return [];
|
|
4150
|
+
return [{ ...d, severity: level }];
|
|
4151
|
+
});
|
|
3075
4152
|
const summary = {
|
|
3076
|
-
errors:
|
|
3077
|
-
warnings:
|
|
4153
|
+
errors: governed.filter((d) => d.severity === "error").length,
|
|
4154
|
+
warnings: governed.filter((d) => d.severity === "warning").length
|
|
3078
4155
|
};
|
|
3079
|
-
return { diagnostics, summary };
|
|
4156
|
+
return { diagnostics: governed, summary };
|
|
3080
4157
|
}
|
|
3081
|
-
function
|
|
4158
|
+
function lineOfOffset2(content, offset) {
|
|
3082
4159
|
let line = 1;
|
|
3083
4160
|
for (let i = 0; i < offset && i < content.length; i++) {
|
|
3084
4161
|
if (content[i] === "\n") line++;
|
|
3085
4162
|
}
|
|
3086
4163
|
return line;
|
|
3087
4164
|
}
|
|
4165
|
+
function entityCriteria(e) {
|
|
4166
|
+
const meta = e.meta;
|
|
4167
|
+
if (!meta) return [];
|
|
4168
|
+
return [
|
|
4169
|
+
...meta.acceptance ?? [],
|
|
4170
|
+
...(meta.states ?? []).flatMap((s) => s.acceptance ?? [])
|
|
4171
|
+
];
|
|
4172
|
+
}
|
|
3088
4173
|
var TAG_FALLBACK_ID = {
|
|
3089
4174
|
a: "link",
|
|
3090
4175
|
button: "button",
|
|
@@ -3404,6 +4489,30 @@ ${body}
|
|
|
3404
4489
|
`;
|
|
3405
4490
|
}
|
|
3406
4491
|
var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
|
|
4492
|
+
function stateEntities(list) {
|
|
4493
|
+
return list.map((e) => ({
|
|
4494
|
+
id: e.id,
|
|
4495
|
+
states: (e.meta?.states ?? []).map((s) => s.name)
|
|
4496
|
+
}));
|
|
4497
|
+
}
|
|
4498
|
+
function emitStateMap(groups) {
|
|
4499
|
+
const lines = ["export interface UidexStates {"];
|
|
4500
|
+
for (const g of groups) {
|
|
4501
|
+
const withStates = g.entities.filter((e) => e.states.length > 0);
|
|
4502
|
+
if (withStates.length === 0) {
|
|
4503
|
+
lines.push(` ${g.kind}: never`);
|
|
4504
|
+
continue;
|
|
4505
|
+
}
|
|
4506
|
+
lines.push(` ${g.kind}: {`);
|
|
4507
|
+
for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
4508
|
+
const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
|
|
4509
|
+
lines.push(` ${JSON.stringify(e.id)}: ${states}`);
|
|
4510
|
+
}
|
|
4511
|
+
lines.push(" }");
|
|
4512
|
+
}
|
|
4513
|
+
lines.push("}");
|
|
4514
|
+
return lines.join("\n") + "\n";
|
|
4515
|
+
}
|
|
3407
4516
|
function emitId(name, ids) {
|
|
3408
4517
|
if (OPAQUE_ID_UNIONS.has(name)) {
|
|
3409
4518
|
return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
|
|
@@ -3491,36 +4600,64 @@ function emit(opts) {
|
|
|
3491
4600
|
);
|
|
3492
4601
|
t.push(emitId("StateId", [...stateNames]));
|
|
3493
4602
|
t.push("");
|
|
4603
|
+
t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
|
|
4604
|
+
t.push(
|
|
4605
|
+
emitStateMap([
|
|
4606
|
+
{ kind: "page", entities: stateEntities(pages) },
|
|
4607
|
+
{ kind: "feature", entities: stateEntities(features) },
|
|
4608
|
+
{ kind: "widget", entities: stateEntities(widgets) }
|
|
4609
|
+
])
|
|
4610
|
+
);
|
|
3494
4611
|
t.push("// ---- authoring-surface shape types ----");
|
|
3495
4612
|
t.push("export namespace Uidex {");
|
|
3496
4613
|
t.push(
|
|
3497
4614
|
' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
|
|
3498
4615
|
);
|
|
3499
4616
|
t.push(' export type StateKind = CoreStateKind | "variant"');
|
|
4617
|
+
t.push(" export interface AcceptanceCriterion {");
|
|
4618
|
+
t.push(" id: string");
|
|
4619
|
+
t.push(" rev?: number");
|
|
4620
|
+
t.push(" text: string");
|
|
4621
|
+
t.push(" }");
|
|
4622
|
+
t.push(" export type Acceptance = readonly (string | AcceptanceCriterion)[]");
|
|
3500
4623
|
t.push(" export interface State {");
|
|
3501
4624
|
t.push(" name: string");
|
|
3502
4625
|
t.push(" kind?: StateKind");
|
|
3503
|
-
t.push(" acceptance?:
|
|
4626
|
+
t.push(" acceptance?: Acceptance");
|
|
3504
4627
|
t.push(" description?: string");
|
|
3505
4628
|
t.push(" }");
|
|
3506
|
-
t.push(
|
|
4629
|
+
t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
|
|
4630
|
+
t.push(" export interface Impossible {");
|
|
4631
|
+
t.push(' type: "impossible"');
|
|
4632
|
+
t.push(" reason: string");
|
|
4633
|
+
t.push(" }");
|
|
4634
|
+
t.push(" export interface Backlog {");
|
|
4635
|
+
t.push(' type: "backlog"');
|
|
4636
|
+
t.push(" reason?: string");
|
|
4637
|
+
t.push(" }");
|
|
4638
|
+
t.push(" export type Acknowledgment = Impossible | Backlog");
|
|
4639
|
+
t.push(
|
|
4640
|
+
" export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
|
|
4641
|
+
);
|
|
4642
|
+
t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
|
|
3507
4643
|
t.push(" export interface Page {");
|
|
3508
4644
|
t.push(" page: PageId | false");
|
|
3509
4645
|
t.push(" name?: string");
|
|
3510
4646
|
t.push(" features?: readonly FeatureId[]");
|
|
3511
4647
|
t.push(" widgets?: readonly WidgetId[]");
|
|
3512
|
-
t.push(" acceptance?:
|
|
4648
|
+
t.push(" acceptance?: Acceptance");
|
|
3513
4649
|
t.push(" states?: readonly (string | State)[]");
|
|
3514
|
-
t.push("
|
|
3515
|
-
t.push(" waivers?: Waivers");
|
|
4650
|
+
t.push(" acknowledge?: Acknowledge");
|
|
3516
4651
|
t.push(" description?: string");
|
|
3517
4652
|
t.push(" }");
|
|
3518
4653
|
t.push(" export interface Feature {");
|
|
3519
4654
|
t.push(" feature: FeatureId | false");
|
|
3520
4655
|
t.push(" name?: string");
|
|
3521
4656
|
t.push(" features?: readonly FeatureId[]");
|
|
3522
|
-
t.push("
|
|
4657
|
+
t.push(" surfaces?: readonly PageId[]");
|
|
4658
|
+
t.push(" acceptance?: Acceptance");
|
|
3523
4659
|
t.push(" states?: readonly (string | State)[]");
|
|
4660
|
+
t.push(" acknowledge?: ComponentAcknowledge");
|
|
3524
4661
|
t.push(" description?: string");
|
|
3525
4662
|
t.push(" }");
|
|
3526
4663
|
t.push(" export interface Primitive {");
|
|
@@ -3531,8 +4668,9 @@ function emit(opts) {
|
|
|
3531
4668
|
t.push(" export interface Widget {");
|
|
3532
4669
|
t.push(" widget: WidgetId");
|
|
3533
4670
|
t.push(" name?: string");
|
|
3534
|
-
t.push(" acceptance?:
|
|
4671
|
+
t.push(" acceptance?: Acceptance");
|
|
3535
4672
|
t.push(" states?: readonly (string | State)[]");
|
|
4673
|
+
t.push(" acknowledge?: ComponentAcknowledge");
|
|
3536
4674
|
t.push(" description?: string");
|
|
3537
4675
|
t.push(" }");
|
|
3538
4676
|
t.push(" export interface Region {");
|
|
@@ -3628,8 +4766,8 @@ function parseGitHubRef(ref) {
|
|
|
3628
4766
|
}
|
|
3629
4767
|
|
|
3630
4768
|
// src/scanner/scan/scaffold.ts
|
|
3631
|
-
import * as
|
|
3632
|
-
import * as
|
|
4769
|
+
import * as fs4 from "fs";
|
|
4770
|
+
import * as path8 from "path";
|
|
3633
4771
|
function scaffoldWidgetSpec(opts) {
|
|
3634
4772
|
return scaffoldSpec({
|
|
3635
4773
|
registry: opts.registry,
|
|
@@ -3653,10 +4791,10 @@ function scaffoldSpec(opts) {
|
|
|
3653
4791
|
if (!entity) {
|
|
3654
4792
|
throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
|
|
3655
4793
|
}
|
|
3656
|
-
const criteria = entity
|
|
4794
|
+
const criteria = entityCriteria(entity);
|
|
3657
4795
|
const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
|
|
3658
|
-
const outputPath =
|
|
3659
|
-
if (
|
|
4796
|
+
const outputPath = path8.resolve(outDir, filename);
|
|
4797
|
+
if (fs4.existsSync(outputPath) && !force) {
|
|
3660
4798
|
return {
|
|
3661
4799
|
outputPath,
|
|
3662
4800
|
written: false,
|
|
@@ -3664,9 +4802,9 @@ function scaffoldSpec(opts) {
|
|
|
3664
4802
|
reason: `spec already exists at ${outputPath}; pass --force to overwrite`
|
|
3665
4803
|
};
|
|
3666
4804
|
}
|
|
3667
|
-
const content = renderSpec({ id, criteria, fixtureImport });
|
|
3668
|
-
|
|
3669
|
-
|
|
4805
|
+
const content = renderSpec({ id, entityId: id, criteria, fixtureImport });
|
|
4806
|
+
fs4.mkdirSync(path8.dirname(outputPath), { recursive: true });
|
|
4807
|
+
fs4.writeFileSync(outputPath, content, "utf8");
|
|
3670
4808
|
return { outputPath, written: true, skipped: false };
|
|
3671
4809
|
}
|
|
3672
4810
|
function capitalize(s) {
|
|
@@ -3674,25 +4812,28 @@ function capitalize(s) {
|
|
|
3674
4812
|
}
|
|
3675
4813
|
function renderSpec(args) {
|
|
3676
4814
|
const lines = [];
|
|
3677
|
-
lines.push(
|
|
3678
|
-
`import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
|
|
3679
|
-
);
|
|
4815
|
+
lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
|
|
3680
4816
|
lines.push("");
|
|
3681
4817
|
lines.push(
|
|
3682
4818
|
`test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
|
|
3683
4819
|
);
|
|
3684
|
-
|
|
3685
|
-
lines.push(` test(
|
|
3686
|
-
lines.push(`
|
|
4820
|
+
const stub = (title, body) => {
|
|
4821
|
+
lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
|
|
4822
|
+
for (const l of body) lines.push(` ${l}`);
|
|
4823
|
+
lines.push(` void uidex`);
|
|
3687
4824
|
lines.push(` })`);
|
|
4825
|
+
lines.push("");
|
|
4826
|
+
};
|
|
4827
|
+
if (args.criteria.length === 0) {
|
|
4828
|
+
stub("TODO: add acceptance criteria", [
|
|
4829
|
+
"// Declare `acceptance: [...]` on the entity, then re-run scaffold."
|
|
4830
|
+
]);
|
|
3688
4831
|
} else {
|
|
3689
4832
|
for (const criterion of args.criteria) {
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
lines.push(` })`);
|
|
3695
|
-
lines.push("");
|
|
4833
|
+
stub(`${criterion.id}: ${criterion.text}`, [
|
|
4834
|
+
"// TODO: drive this criterion, then remove `.fixme` to arm the test",
|
|
4835
|
+
`// and claim it: uidexCovers(${JSON.stringify(args.entityId)}, ${JSON.stringify(`${criterion.id}@${criterion.rev}`)})`
|
|
4836
|
+
]);
|
|
3696
4837
|
}
|
|
3697
4838
|
}
|
|
3698
4839
|
lines.push("})");
|
|
@@ -3701,47 +4842,33 @@ function renderSpec(args) {
|
|
|
3701
4842
|
}
|
|
3702
4843
|
|
|
3703
4844
|
// src/scanner/scan/pipeline.ts
|
|
3704
|
-
import * as
|
|
3705
|
-
import * as
|
|
3706
|
-
var
|
|
4845
|
+
import * as fs5 from "fs";
|
|
4846
|
+
import * as path9 from "path";
|
|
4847
|
+
var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
|
|
3707
4848
|
var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
|
|
3708
|
-
function
|
|
4849
|
+
function loadCoverageBaseline(configDir, config) {
|
|
3709
4850
|
const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3710
|
-
const abs =
|
|
4851
|
+
const abs = path9.resolve(configDir, rel);
|
|
3711
4852
|
let raw;
|
|
3712
4853
|
try {
|
|
3713
|
-
raw =
|
|
4854
|
+
raw = fs5.readFileSync(abs, "utf8");
|
|
3714
4855
|
} catch {
|
|
3715
4856
|
return void 0;
|
|
3716
4857
|
}
|
|
4858
|
+
let parsed;
|
|
3717
4859
|
try {
|
|
3718
|
-
|
|
3719
|
-
if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
|
|
3720
|
-
const uncapturedPages = parsed.uncapturedPages.filter(
|
|
3721
|
-
(p2) => typeof p2 === "string"
|
|
3722
|
-
);
|
|
3723
|
-
let missingKinds;
|
|
3724
|
-
const mkRaw = parsed.missingKinds;
|
|
3725
|
-
if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
|
|
3726
|
-
const mk = {};
|
|
3727
|
-
for (const [route, kinds] of Object.entries(mkRaw)) {
|
|
3728
|
-
if (Array.isArray(kinds)) {
|
|
3729
|
-
mk[route] = kinds.filter((k) => typeof k === "string");
|
|
3730
|
-
}
|
|
3731
|
-
}
|
|
3732
|
-
missingKinds = mk;
|
|
3733
|
-
}
|
|
3734
|
-
return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
|
|
4860
|
+
parsed = JSON.parse(raw);
|
|
3735
4861
|
} catch {
|
|
3736
4862
|
return void 0;
|
|
3737
4863
|
}
|
|
4864
|
+
return parseCoverageBaseline(parsed) ?? void 0;
|
|
3738
4865
|
}
|
|
3739
4866
|
function loadCapturedStates(configDir, config) {
|
|
3740
|
-
const rel = config.statesManifest ??
|
|
3741
|
-
const abs =
|
|
4867
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
|
|
4868
|
+
const abs = path9.resolve(configDir, rel);
|
|
3742
4869
|
let raw;
|
|
3743
4870
|
try {
|
|
3744
|
-
raw =
|
|
4871
|
+
raw = fs5.readFileSync(abs, "utf8");
|
|
3745
4872
|
} catch {
|
|
3746
4873
|
return void 0;
|
|
3747
4874
|
}
|
|
@@ -3760,7 +4887,7 @@ function loadCapturedStates(configDir, config) {
|
|
|
3760
4887
|
if (typeof c.route === "string") rec.route = c.route;
|
|
3761
4888
|
captured.push(rec);
|
|
3762
4889
|
}
|
|
3763
|
-
return { captured };
|
|
4890
|
+
return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
|
|
3764
4891
|
} catch {
|
|
3765
4892
|
return void 0;
|
|
3766
4893
|
}
|
|
@@ -3796,11 +4923,11 @@ function runOne(dc, opts) {
|
|
|
3796
4923
|
gitContext
|
|
3797
4924
|
});
|
|
3798
4925
|
const typesRel = config.output;
|
|
3799
|
-
const typesPath =
|
|
4926
|
+
const typesPath = path9.resolve(configDir, typesRel);
|
|
3800
4927
|
const dataRel = dataOutputPath(config.output);
|
|
3801
|
-
const dataPath =
|
|
4928
|
+
const dataPath = path9.resolve(configDir, dataRel);
|
|
3802
4929
|
const locsRel = locsOutputPath(config.output);
|
|
3803
|
-
const locsPath =
|
|
4930
|
+
const locsPath = path9.resolve(configDir, locsRel);
|
|
3804
4931
|
let typesOnDisk = null;
|
|
3805
4932
|
let dataOnDisk = null;
|
|
3806
4933
|
let locsOnDisk = null;
|
|
@@ -3813,7 +4940,8 @@ function runOne(dc, opts) {
|
|
|
3813
4940
|
(ef) => (ef.diagnostics?.length ?? 0) > 0
|
|
3814
4941
|
);
|
|
3815
4942
|
const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
|
|
3816
|
-
const
|
|
4943
|
+
const coverageBaseline = opts.coverageBaseline ?? loadCoverageBaseline(configDir, config);
|
|
4944
|
+
const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
|
|
3817
4945
|
let auditResult;
|
|
3818
4946
|
if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
|
|
3819
4947
|
auditResult = audit({
|
|
@@ -3838,7 +4966,8 @@ function runOne(dc, opts) {
|
|
|
3838
4966
|
locsOnDisk,
|
|
3839
4967
|
locsOutputPath: locsRel,
|
|
3840
4968
|
capturedStates,
|
|
3841
|
-
|
|
4969
|
+
coverageBaseline,
|
|
4970
|
+
workspace
|
|
3842
4971
|
});
|
|
3843
4972
|
}
|
|
3844
4973
|
return {
|
|
@@ -3859,15 +4988,15 @@ function runOne(dc, opts) {
|
|
|
3859
4988
|
}
|
|
3860
4989
|
function readOrNull(p2) {
|
|
3861
4990
|
try {
|
|
3862
|
-
return
|
|
4991
|
+
return fs5.readFileSync(p2, "utf8");
|
|
3863
4992
|
} catch {
|
|
3864
4993
|
return null;
|
|
3865
4994
|
}
|
|
3866
4995
|
}
|
|
3867
4996
|
function writeArtifact(artifact) {
|
|
3868
4997
|
if (readOrNull(artifact.outputPath) === artifact.generated) return false;
|
|
3869
|
-
|
|
3870
|
-
|
|
4998
|
+
fs5.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
|
|
4999
|
+
fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
|
|
3871
5000
|
return true;
|
|
3872
5001
|
}
|
|
3873
5002
|
function writeScanResult(result) {
|
|
@@ -3876,14 +5005,45 @@ function writeScanResult(result) {
|
|
|
3876
5005
|
const wroteLocs = writeArtifact(result.locs);
|
|
3877
5006
|
return wroteTypes || wroteData || wroteLocs;
|
|
3878
5007
|
}
|
|
3879
|
-
function
|
|
5008
|
+
function coverageBaselinePath(result) {
|
|
3880
5009
|
const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3881
|
-
return
|
|
5010
|
+
return path9.resolve(result.configDir, rel);
|
|
5011
|
+
}
|
|
5012
|
+
|
|
5013
|
+
// src/scanner/scan/states-merge.ts
|
|
5014
|
+
function keyOf(c) {
|
|
5015
|
+
return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
|
|
5016
|
+
}
|
|
5017
|
+
function mergeStates(committed, partial) {
|
|
5018
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
5019
|
+
for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
|
|
5020
|
+
const updated = [];
|
|
5021
|
+
const added = [];
|
|
5022
|
+
for (const c of partial.captured ?? []) {
|
|
5023
|
+
const k = keyOf(c);
|
|
5024
|
+
(byKey.has(k) ? updated : added).push(k);
|
|
5025
|
+
byKey.set(k, c);
|
|
5026
|
+
}
|
|
5027
|
+
const captured = [...byKey.values()].sort(
|
|
5028
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
|
|
5029
|
+
);
|
|
5030
|
+
return {
|
|
5031
|
+
manifest: {
|
|
5032
|
+
...committed,
|
|
5033
|
+
captured,
|
|
5034
|
+
// The merged file is only as fresh as its OLDEST evidence: rows carried
|
|
5035
|
+
// over from the committed manifest were not re-verified by this run, so
|
|
5036
|
+
// claiming the partial's newer stamp would defeat the staleness check.
|
|
5037
|
+
...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
|
|
5038
|
+
},
|
|
5039
|
+
updated: updated.sort(),
|
|
5040
|
+
added: added.sort()
|
|
5041
|
+
};
|
|
3882
5042
|
}
|
|
3883
5043
|
|
|
3884
5044
|
// src/scanner/scan/fix.ts
|
|
3885
|
-
import * as
|
|
3886
|
-
import * as
|
|
5045
|
+
import * as fs6 from "fs";
|
|
5046
|
+
import * as path10 from "path";
|
|
3887
5047
|
function applyFixes(diagnostics) {
|
|
3888
5048
|
const entries = [];
|
|
3889
5049
|
for (const d of diagnostics) {
|
|
@@ -3916,7 +5076,7 @@ function applyFixes(diagnostics) {
|
|
|
3916
5076
|
for (const [filePath, edits] of editsByFile) {
|
|
3917
5077
|
let content;
|
|
3918
5078
|
try {
|
|
3919
|
-
content =
|
|
5079
|
+
content = fs6.readFileSync(filePath, "utf8");
|
|
3920
5080
|
} catch {
|
|
3921
5081
|
for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
|
|
3922
5082
|
continue;
|
|
@@ -3936,22 +5096,22 @@ function applyFixes(diagnostics) {
|
|
|
3936
5096
|
const edit = kept[i];
|
|
3937
5097
|
content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
|
|
3938
5098
|
}
|
|
3939
|
-
if (kept.length > 0)
|
|
5099
|
+
if (kept.length > 0) fs6.writeFileSync(filePath, content, "utf8");
|
|
3940
5100
|
}
|
|
3941
5101
|
for (const entry of entries) {
|
|
3942
5102
|
if (entry.skippedReason) continue;
|
|
3943
5103
|
for (const create of entry.createFiles) {
|
|
3944
|
-
if (
|
|
3945
|
-
entry.skippedReason = `${
|
|
5104
|
+
if (fs6.existsSync(create.path)) {
|
|
5105
|
+
entry.skippedReason = `${path10.basename(create.path)} already exists`;
|
|
3946
5106
|
continue;
|
|
3947
5107
|
}
|
|
3948
|
-
|
|
3949
|
-
|
|
5108
|
+
fs6.mkdirSync(path10.dirname(create.path), { recursive: true });
|
|
5109
|
+
fs6.writeFileSync(create.path, create.content, "utf8");
|
|
3950
5110
|
}
|
|
3951
5111
|
if (entry.skippedReason) continue;
|
|
3952
5112
|
for (const del of entry.deleteFiles) {
|
|
3953
5113
|
try {
|
|
3954
|
-
|
|
5114
|
+
fs6.unlinkSync(del);
|
|
3955
5115
|
} catch {
|
|
3956
5116
|
}
|
|
3957
5117
|
}
|
|
@@ -4129,29 +5289,29 @@ function renameEntity(opts) {
|
|
|
4129
5289
|
}
|
|
4130
5290
|
|
|
4131
5291
|
// src/scanner/scan/cli.ts
|
|
4132
|
-
import * as
|
|
4133
|
-
import * as
|
|
5292
|
+
import * as fs9 from "fs";
|
|
5293
|
+
import * as path13 from "path";
|
|
4134
5294
|
|
|
4135
5295
|
// src/scanner/scan/ai/index.ts
|
|
4136
5296
|
import * as p from "@clack/prompts";
|
|
4137
5297
|
|
|
4138
5298
|
// src/scanner/scan/ai/providers/claude.ts
|
|
4139
|
-
import * as
|
|
4140
|
-
import * as
|
|
5299
|
+
import * as fs8 from "fs";
|
|
5300
|
+
import * as path12 from "path";
|
|
4141
5301
|
|
|
4142
5302
|
// src/scanner/scan/ai/templates.ts
|
|
4143
|
-
import * as
|
|
4144
|
-
import * as
|
|
5303
|
+
import * as fs7 from "fs";
|
|
5304
|
+
import * as path11 from "path";
|
|
4145
5305
|
function templatePath(rel) {
|
|
4146
5306
|
const candidates = [
|
|
4147
|
-
|
|
5307
|
+
path11.resolve(__dirname, "../../templates", rel),
|
|
4148
5308
|
// dist/cli/cli.cjs → ../../templates
|
|
4149
|
-
|
|
5309
|
+
path11.resolve(__dirname, "../../../../templates", rel)
|
|
4150
5310
|
// src/scanner/scan/ai → ../../../../templates
|
|
4151
5311
|
];
|
|
4152
5312
|
for (const c of candidates) {
|
|
4153
5313
|
try {
|
|
4154
|
-
|
|
5314
|
+
fs7.accessSync(c, fs7.constants.R_OK);
|
|
4155
5315
|
return c;
|
|
4156
5316
|
} catch {
|
|
4157
5317
|
continue;
|
|
@@ -4163,7 +5323,7 @@ function templatePath(rel) {
|
|
|
4163
5323
|
);
|
|
4164
5324
|
}
|
|
4165
5325
|
function readTemplate(rel) {
|
|
4166
|
-
return
|
|
5326
|
+
return fs7.readFileSync(templatePath(rel), "utf8");
|
|
4167
5327
|
}
|
|
4168
5328
|
|
|
4169
5329
|
// src/scanner/scan/ai/providers/claude.ts
|
|
@@ -4194,8 +5354,8 @@ var claudeProvider = {
|
|
|
4194
5354
|
async install({ cwd, force }) {
|
|
4195
5355
|
const changes = [];
|
|
4196
5356
|
for (const file of SKILL_FILES) {
|
|
4197
|
-
const dest =
|
|
4198
|
-
const exists =
|
|
5357
|
+
const dest = path12.join(cwd, file.dest);
|
|
5358
|
+
const exists = fs8.existsSync(dest);
|
|
4199
5359
|
if (exists && !force) {
|
|
4200
5360
|
changes.push({
|
|
4201
5361
|
path: file.dest,
|
|
@@ -4204,56 +5364,56 @@ var claudeProvider = {
|
|
|
4204
5364
|
});
|
|
4205
5365
|
continue;
|
|
4206
5366
|
}
|
|
4207
|
-
|
|
4208
|
-
|
|
5367
|
+
fs8.mkdirSync(path12.dirname(dest), { recursive: true });
|
|
5368
|
+
fs8.writeFileSync(dest, readTemplate(file.template));
|
|
4209
5369
|
changes.push({
|
|
4210
5370
|
path: file.dest,
|
|
4211
5371
|
action: exists ? "overwritten" : "created"
|
|
4212
5372
|
});
|
|
4213
5373
|
}
|
|
4214
5374
|
for (const rel of LEGACY_FILES) {
|
|
4215
|
-
const dest =
|
|
4216
|
-
if (
|
|
4217
|
-
|
|
5375
|
+
const dest = path12.join(cwd, rel);
|
|
5376
|
+
if (fs8.existsSync(dest)) {
|
|
5377
|
+
fs8.unlinkSync(dest);
|
|
4218
5378
|
changes.push({ path: rel, action: "removed" });
|
|
4219
5379
|
}
|
|
4220
5380
|
}
|
|
4221
|
-
cleanupEmpty(
|
|
4222
|
-
cleanupEmpty(
|
|
4223
|
-
cleanupEmpty(
|
|
5381
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
|
|
5382
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands"));
|
|
5383
|
+
cleanupEmpty(path12.join(cwd, ".claude/rules"));
|
|
4224
5384
|
return { changes };
|
|
4225
5385
|
},
|
|
4226
5386
|
async uninstall({ cwd }) {
|
|
4227
5387
|
const changes = [];
|
|
4228
5388
|
for (const file of SKILL_FILES) {
|
|
4229
|
-
const dest =
|
|
4230
|
-
if (!
|
|
5389
|
+
const dest = path12.join(cwd, file.dest);
|
|
5390
|
+
if (!fs8.existsSync(dest)) {
|
|
4231
5391
|
changes.push({ path: file.dest, action: "skipped", reason: "absent" });
|
|
4232
5392
|
continue;
|
|
4233
5393
|
}
|
|
4234
|
-
|
|
5394
|
+
fs8.unlinkSync(dest);
|
|
4235
5395
|
changes.push({ path: file.dest, action: "removed" });
|
|
4236
5396
|
}
|
|
4237
|
-
cleanupEmpty(
|
|
4238
|
-
cleanupEmpty(
|
|
4239
|
-
cleanupEmpty(
|
|
5397
|
+
cleanupEmpty(path12.join(cwd, ".claude/skills/uidex/references"));
|
|
5398
|
+
cleanupEmpty(path12.join(cwd, ".claude/skills/uidex"));
|
|
5399
|
+
cleanupEmpty(path12.join(cwd, ".claude/skills"));
|
|
4240
5400
|
for (const rel of LEGACY_FILES) {
|
|
4241
|
-
const dest =
|
|
4242
|
-
if (
|
|
4243
|
-
|
|
5401
|
+
const dest = path12.join(cwd, rel);
|
|
5402
|
+
if (fs8.existsSync(dest)) {
|
|
5403
|
+
fs8.unlinkSync(dest);
|
|
4244
5404
|
changes.push({ path: rel, action: "removed" });
|
|
4245
5405
|
}
|
|
4246
5406
|
}
|
|
4247
|
-
cleanupEmpty(
|
|
4248
|
-
cleanupEmpty(
|
|
4249
|
-
cleanupEmpty(
|
|
5407
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
|
|
5408
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands"));
|
|
5409
|
+
cleanupEmpty(path12.join(cwd, ".claude/rules"));
|
|
4250
5410
|
return { changes };
|
|
4251
5411
|
}
|
|
4252
5412
|
};
|
|
4253
5413
|
function cleanupEmpty(dir) {
|
|
4254
5414
|
try {
|
|
4255
|
-
const entries =
|
|
4256
|
-
if (entries.length === 0)
|
|
5415
|
+
const entries = fs8.readdirSync(dir);
|
|
5416
|
+
if (entries.length === 0) fs8.rmdirSync(dir);
|
|
4257
5417
|
} catch {
|
|
4258
5418
|
}
|
|
4259
5419
|
}
|
|
@@ -4422,6 +5582,8 @@ async function run(opts) {
|
|
|
4422
5582
|
return runScaffold(cwd, positional.slice(1), flags, writer);
|
|
4423
5583
|
case "rename":
|
|
4424
5584
|
return runRename(cwd, positional.slice(1), flags, writer);
|
|
5585
|
+
case "states":
|
|
5586
|
+
return runStates(cwd, positional.slice(1), writer);
|
|
4425
5587
|
case "ai": {
|
|
4426
5588
|
const result = await runAiCommand({
|
|
4427
5589
|
cwd,
|
|
@@ -4450,6 +5612,7 @@ function helpText2() {
|
|
|
4450
5612
|
" scan [flags] Run the scanner pipeline",
|
|
4451
5613
|
" scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
|
|
4452
5614
|
" rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
|
|
5615
|
+
" states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
|
|
4453
5616
|
" ai <install|uninstall|providers> Manage AI assistant integrations",
|
|
4454
5617
|
"",
|
|
4455
5618
|
"Flags:",
|
|
@@ -4457,15 +5620,15 @@ function helpText2() {
|
|
|
4457
5620
|
" --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
|
|
4458
5621
|
" --audit Equivalent to --check --lint (read-only)",
|
|
4459
5622
|
" --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
|
|
4460
|
-
" --update-baseline Regenerate the
|
|
5623
|
+
" --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
|
|
4461
5624
|
" --json Emit JSON diagnostics on stdout",
|
|
4462
5625
|
" --force (scaffold) overwrite existing spec",
|
|
4463
5626
|
""
|
|
4464
5627
|
].join("\n");
|
|
4465
5628
|
}
|
|
4466
5629
|
function runInit(cwd, w) {
|
|
4467
|
-
const configPath =
|
|
4468
|
-
if (
|
|
5630
|
+
const configPath = path13.join(cwd, CONFIG_FILENAME);
|
|
5631
|
+
if (fs9.existsSync(configPath)) {
|
|
4469
5632
|
w.err(`.uidex.json already exists at ${configPath}`);
|
|
4470
5633
|
return w.result(1);
|
|
4471
5634
|
}
|
|
@@ -4474,16 +5637,16 @@ function runInit(cwd, w) {
|
|
|
4474
5637
|
sources: [{ rootDir: "src" }],
|
|
4475
5638
|
output: "src/uidex.gen.ts"
|
|
4476
5639
|
};
|
|
4477
|
-
|
|
5640
|
+
fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
4478
5641
|
w.out(`Created ${configPath}`);
|
|
4479
|
-
const gitignorePath =
|
|
5642
|
+
const gitignorePath = path13.join(cwd, ".gitignore");
|
|
4480
5643
|
const entry = "*.gen.*";
|
|
4481
|
-
if (
|
|
4482
|
-
const existing =
|
|
5644
|
+
if (fs9.existsSync(gitignorePath)) {
|
|
5645
|
+
const existing = fs9.readFileSync(gitignorePath, "utf8");
|
|
4483
5646
|
const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
|
|
4484
5647
|
if (!hasEntry) {
|
|
4485
5648
|
const needsNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
4486
|
-
|
|
5649
|
+
fs9.appendFileSync(
|
|
4487
5650
|
gitignorePath,
|
|
4488
5651
|
`${needsNewline ? "\n" : ""}${entry}
|
|
4489
5652
|
`,
|
|
@@ -4492,7 +5655,7 @@ function runInit(cwd, w) {
|
|
|
4492
5655
|
w.out(`Appended ${entry} to ${gitignorePath}`);
|
|
4493
5656
|
}
|
|
4494
5657
|
} else {
|
|
4495
|
-
|
|
5658
|
+
fs9.writeFileSync(gitignorePath, `${entry}
|
|
4496
5659
|
`, "utf8");
|
|
4497
5660
|
w.out(`Created ${gitignorePath} with ${entry}`);
|
|
4498
5661
|
}
|
|
@@ -4531,20 +5694,25 @@ function runScanCommand(cwd, flags, w) {
|
|
|
4531
5694
|
if (updateBaseline) {
|
|
4532
5695
|
for (const r of results) {
|
|
4533
5696
|
const manifest = r.capturedStates ?? { captured: [] };
|
|
4534
|
-
const
|
|
4535
|
-
const
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
5697
|
+
const acknowledge = {};
|
|
5698
|
+
const merge = (from) => {
|
|
5699
|
+
for (const [route, scopes] of Object.entries(from)) {
|
|
5700
|
+
acknowledge[route] = { ...acknowledge[route], ...scopes };
|
|
5701
|
+
}
|
|
4539
5702
|
};
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
const
|
|
4543
|
-
|
|
5703
|
+
merge(computePageBacklog(r.registry, manifest));
|
|
5704
|
+
merge(computeMatrixBacklog(r.registry, manifest));
|
|
5705
|
+
const baseline = { version: 2, acknowledge };
|
|
5706
|
+
const p2 = coverageBaselinePath(r);
|
|
5707
|
+
fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
|
|
5708
|
+
const entries = Object.values(acknowledge);
|
|
5709
|
+
const pages = entries.filter((s) => s["*"]).length;
|
|
5710
|
+
const kindGaps = entries.reduce(
|
|
5711
|
+
(n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
|
|
4544
5712
|
0
|
|
4545
5713
|
);
|
|
4546
5714
|
w.out(
|
|
4547
|
-
`Wrote ${p2} \u2014 ${
|
|
5715
|
+
`Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
|
|
4548
5716
|
);
|
|
4549
5717
|
}
|
|
4550
5718
|
return w.result(0);
|
|
@@ -4617,7 +5785,7 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
4617
5785
|
for (const r of results) {
|
|
4618
5786
|
const entity = r.registry.get(scaffoldKind, id);
|
|
4619
5787
|
if (!entity) continue;
|
|
4620
|
-
const outDir =
|
|
5788
|
+
const outDir = path13.resolve(r.configDir, "e2e");
|
|
4621
5789
|
const result = scaffoldSpec({
|
|
4622
5790
|
registry: r.registry,
|
|
4623
5791
|
kind: scaffoldKind,
|
|
@@ -4638,6 +5806,53 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
4638
5806
|
return w.result(1);
|
|
4639
5807
|
}
|
|
4640
5808
|
var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
|
|
5809
|
+
function runStates(cwd, args, w) {
|
|
5810
|
+
const [sub, explicitPartial] = args;
|
|
5811
|
+
if (sub !== "merge") {
|
|
5812
|
+
w.err("Usage: uidex states merge [partial-manifest]");
|
|
5813
|
+
return w.result(1);
|
|
5814
|
+
}
|
|
5815
|
+
const configs = discover({ cwd });
|
|
5816
|
+
if (configs.length === 0) {
|
|
5817
|
+
w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
|
|
5818
|
+
return w.result(1);
|
|
5819
|
+
}
|
|
5820
|
+
let merged = 0;
|
|
5821
|
+
for (const { configDir, config } of configs) {
|
|
5822
|
+
const rel = config.statesManifest ?? "uidex-states.json";
|
|
5823
|
+
const target = path13.resolve(configDir, rel);
|
|
5824
|
+
const partial = explicitPartial ? path13.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
|
|
5825
|
+
const committedRaw = readJson(target);
|
|
5826
|
+
const partialRaw = readJson(partial);
|
|
5827
|
+
if (!partialRaw) continue;
|
|
5828
|
+
if (!committedRaw) {
|
|
5829
|
+
w.err(`No committed manifest at ${rel}; nothing to merge into`);
|
|
5830
|
+
return w.result(1);
|
|
5831
|
+
}
|
|
5832
|
+
const result = mergeStates(committedRaw, partialRaw);
|
|
5833
|
+
fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
|
|
5834
|
+
merged++;
|
|
5835
|
+
w.out(
|
|
5836
|
+
`Merged ${path13.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
|
|
5837
|
+
);
|
|
5838
|
+
for (const k of result.added) w.out(` + ${k}`);
|
|
5839
|
+
for (const k of result.updated) w.out(` ~ ${k}`);
|
|
5840
|
+
}
|
|
5841
|
+
if (merged === 0) {
|
|
5842
|
+
w.out("No partial manifest found; nothing to merge.");
|
|
5843
|
+
}
|
|
5844
|
+
return w.result(0);
|
|
5845
|
+
}
|
|
5846
|
+
function readJson(p2) {
|
|
5847
|
+
try {
|
|
5848
|
+
const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
|
|
5849
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
5850
|
+
const m = parsed;
|
|
5851
|
+
return Array.isArray(m.captured) ? m : null;
|
|
5852
|
+
} catch {
|
|
5853
|
+
return null;
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
4641
5856
|
function runRename(cwd, args, flags, w) {
|
|
4642
5857
|
const [kind, oldId, newId] = args;
|
|
4643
5858
|
if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
|
|
@@ -4685,35 +5900,47 @@ function createWriter() {
|
|
|
4685
5900
|
};
|
|
4686
5901
|
}
|
|
4687
5902
|
export {
|
|
5903
|
+
ACK_ALL,
|
|
5904
|
+
ACK_SCOPES,
|
|
4688
5905
|
CONFIG_FILENAME,
|
|
4689
5906
|
ConfigError,
|
|
4690
5907
|
DEFAULT_CONVENTIONS,
|
|
5908
|
+
DIAGNOSTIC_CODES,
|
|
4691
5909
|
applyFixes,
|
|
4692
5910
|
audit,
|
|
5911
|
+
capturedElsewhere,
|
|
5912
|
+
checkComponentCoverage,
|
|
4693
5913
|
checkPageCoverage,
|
|
4694
5914
|
checkStateCoverage,
|
|
4695
5915
|
checkStateMatrix,
|
|
4696
5916
|
compileRoute,
|
|
4697
|
-
|
|
4698
|
-
|
|
5917
|
+
computeMatrixBacklog,
|
|
5918
|
+
computePageBacklog,
|
|
5919
|
+
coverageBaselinePath,
|
|
4699
5920
|
detectRoutes,
|
|
4700
5921
|
discover,
|
|
4701
5922
|
emit,
|
|
5923
|
+
entityAcknowledge,
|
|
4702
5924
|
extract,
|
|
4703
5925
|
extractUidexExports,
|
|
5926
|
+
findWorkspaceRoot,
|
|
4704
5927
|
globToRegExp,
|
|
4705
|
-
|
|
5928
|
+
loadCoverageBaseline,
|
|
4706
5929
|
matchUrlToRoute,
|
|
4707
|
-
|
|
5930
|
+
mergeStates,
|
|
4708
5931
|
parseConfig,
|
|
5932
|
+
parseCoverageBaseline,
|
|
4709
5933
|
pathToId,
|
|
4710
5934
|
renameEntity,
|
|
4711
5935
|
resolve2 as resolve,
|
|
4712
5936
|
resolveGitContext,
|
|
5937
|
+
resolveWorkspace,
|
|
5938
|
+
routeAcknowledge,
|
|
4713
5939
|
run as runCli,
|
|
4714
5940
|
runScan,
|
|
4715
5941
|
scaffoldSpec,
|
|
4716
5942
|
scaffoldWidgetSpec,
|
|
5943
|
+
serializeCoverageBaseline,
|
|
4717
5944
|
validateConfig,
|
|
4718
5945
|
walk,
|
|
4719
5946
|
writeScanResult
|