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.cjs
CHANGED
|
@@ -30,35 +30,47 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/scanner/scan/index.ts
|
|
31
31
|
var scan_exports = {};
|
|
32
32
|
__export(scan_exports, {
|
|
33
|
+
ACK_ALL: () => ACK_ALL,
|
|
34
|
+
ACK_SCOPES: () => ACK_SCOPES,
|
|
33
35
|
CONFIG_FILENAME: () => CONFIG_FILENAME,
|
|
34
36
|
ConfigError: () => ConfigError,
|
|
35
37
|
DEFAULT_CONVENTIONS: () => DEFAULT_CONVENTIONS,
|
|
38
|
+
DIAGNOSTIC_CODES: () => DIAGNOSTIC_CODES,
|
|
36
39
|
applyFixes: () => applyFixes,
|
|
37
40
|
audit: () => audit,
|
|
41
|
+
capturedElsewhere: () => capturedElsewhere,
|
|
42
|
+
checkComponentCoverage: () => checkComponentCoverage,
|
|
38
43
|
checkPageCoverage: () => checkPageCoverage,
|
|
39
44
|
checkStateCoverage: () => checkStateCoverage,
|
|
40
45
|
checkStateMatrix: () => checkStateMatrix,
|
|
41
46
|
compileRoute: () => compileRoute,
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
computeMatrixBacklog: () => computeMatrixBacklog,
|
|
48
|
+
computePageBacklog: () => computePageBacklog,
|
|
49
|
+
coverageBaselinePath: () => coverageBaselinePath,
|
|
44
50
|
detectRoutes: () => detectRoutes,
|
|
45
51
|
discover: () => discover,
|
|
46
52
|
emit: () => emit,
|
|
53
|
+
entityAcknowledge: () => entityAcknowledge,
|
|
47
54
|
extract: () => extract,
|
|
48
55
|
extractUidexExports: () => extractUidexExports,
|
|
56
|
+
findWorkspaceRoot: () => findWorkspaceRoot,
|
|
49
57
|
globToRegExp: () => globToRegExp,
|
|
50
|
-
|
|
58
|
+
loadCoverageBaseline: () => loadCoverageBaseline,
|
|
51
59
|
matchUrlToRoute: () => matchUrlToRoute,
|
|
52
|
-
|
|
60
|
+
mergeStates: () => mergeStates,
|
|
53
61
|
parseConfig: () => parseConfig,
|
|
62
|
+
parseCoverageBaseline: () => parseCoverageBaseline,
|
|
54
63
|
pathToId: () => pathToId,
|
|
55
64
|
renameEntity: () => renameEntity,
|
|
56
65
|
resolve: () => resolve2,
|
|
57
66
|
resolveGitContext: () => resolveGitContext,
|
|
67
|
+
resolveWorkspace: () => resolveWorkspace,
|
|
68
|
+
routeAcknowledge: () => routeAcknowledge,
|
|
58
69
|
runCli: () => run,
|
|
59
70
|
runScan: () => runScan,
|
|
60
71
|
scaffoldSpec: () => scaffoldSpec,
|
|
61
72
|
scaffoldWidgetSpec: () => scaffoldWidgetSpec,
|
|
73
|
+
serializeCoverageBaseline: () => serializeCoverageBaseline,
|
|
62
74
|
validateConfig: () => validateConfig,
|
|
63
75
|
walk: () => walk,
|
|
64
76
|
writeScanResult: () => writeScanResult
|
|
@@ -94,10 +106,12 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
|
94
106
|
"exclude",
|
|
95
107
|
"output",
|
|
96
108
|
"flows",
|
|
109
|
+
"resolve",
|
|
97
110
|
"audit",
|
|
98
111
|
"conventions",
|
|
99
112
|
"statesManifest",
|
|
100
|
-
"coverageBaseline"
|
|
113
|
+
"coverageBaseline",
|
|
114
|
+
"workspace"
|
|
101
115
|
]);
|
|
102
116
|
var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
|
|
103
117
|
var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -111,21 +125,74 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
|
|
|
111
125
|
"scopeLeak",
|
|
112
126
|
"coverage",
|
|
113
127
|
"acceptance",
|
|
128
|
+
"surfaces",
|
|
129
|
+
"requireAcceptance",
|
|
114
130
|
"states",
|
|
115
131
|
"pageCoverage",
|
|
116
|
-
"stateMatrix"
|
|
132
|
+
"stateMatrix",
|
|
133
|
+
"severity"
|
|
117
134
|
]);
|
|
135
|
+
var DIAGNOSTIC_CODES = [
|
|
136
|
+
"acceptance-ambiguous",
|
|
137
|
+
"acceptance-duplicate-id",
|
|
138
|
+
"acceptance-orphaned",
|
|
139
|
+
"acceptance-outdated",
|
|
140
|
+
"acceptance-uncovered",
|
|
141
|
+
"acknowledged-elsewhere",
|
|
142
|
+
"competing-uidex-export",
|
|
143
|
+
"component-state-uncaptured",
|
|
144
|
+
"core-kind",
|
|
145
|
+
"covers-missing-rev",
|
|
146
|
+
"duplicate-id",
|
|
147
|
+
"dynamic-attr",
|
|
148
|
+
"dynamic-flow-reference",
|
|
149
|
+
"feature-missing-acceptance",
|
|
150
|
+
"feature-surface-uncaptured",
|
|
151
|
+
"feature-surface-unreferenced",
|
|
152
|
+
"gen-missing",
|
|
153
|
+
"gen-stale",
|
|
154
|
+
"matrix-backlog",
|
|
155
|
+
"missing-element-annotation",
|
|
156
|
+
"missing-state-capture",
|
|
157
|
+
"orphan-state-capture",
|
|
158
|
+
"page-backlog",
|
|
159
|
+
"page-captured",
|
|
160
|
+
"page-no-capture",
|
|
161
|
+
"parse-error",
|
|
162
|
+
"placeholder-reason",
|
|
163
|
+
"prefer-well-known-file",
|
|
164
|
+
"rename",
|
|
165
|
+
"scope-leak",
|
|
166
|
+
"spread-attr",
|
|
167
|
+
"stale-acknowledgment",
|
|
168
|
+
"stale-page-baseline",
|
|
169
|
+
"uidex-export-ambiguous-kind",
|
|
170
|
+
"uidex-export-duplicate-field",
|
|
171
|
+
"uidex-export-duplicate-state",
|
|
172
|
+
"uidex-export-empty-name",
|
|
173
|
+
"uidex-export-invalid-field",
|
|
174
|
+
"uidex-export-invalid-literal",
|
|
175
|
+
"uidex-export-missing-kind",
|
|
176
|
+
"uidex-export-satisfies-mismatch",
|
|
177
|
+
"uidex-export-unknown-field",
|
|
178
|
+
"unknown-reference",
|
|
179
|
+
"widget-id-mismatch",
|
|
180
|
+
"widget-missing-acceptance",
|
|
181
|
+
"widget-missing-dom"
|
|
182
|
+
];
|
|
183
|
+
var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
|
|
184
|
+
var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
|
|
118
185
|
function fail(msg) {
|
|
119
186
|
throw new ConfigError(`Invalid .uidex.json: ${msg}`);
|
|
120
187
|
}
|
|
121
|
-
function assertObject(value,
|
|
188
|
+
function assertObject(value, path14) {
|
|
122
189
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
123
|
-
fail(`${
|
|
190
|
+
fail(`${path14} must be an object`);
|
|
124
191
|
}
|
|
125
192
|
}
|
|
126
|
-
function assertStringArray(value,
|
|
193
|
+
function assertStringArray(value, path14) {
|
|
127
194
|
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
128
|
-
fail(`${
|
|
195
|
+
fail(`${path14} must be a string[]`);
|
|
129
196
|
}
|
|
130
197
|
}
|
|
131
198
|
function validateConfig(raw) {
|
|
@@ -179,11 +246,47 @@ function validateConfig(raw) {
|
|
|
179
246
|
if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
|
|
180
247
|
fail(`"coverageBaseline" must be a string`);
|
|
181
248
|
}
|
|
249
|
+
if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
|
|
250
|
+
fail(`"workspace" must be a path string or false`);
|
|
251
|
+
}
|
|
252
|
+
if (raw.resolve !== void 0) {
|
|
253
|
+
assertObject(raw.resolve, "resolve");
|
|
254
|
+
for (const key of Object.keys(raw.resolve)) {
|
|
255
|
+
if (key !== "aliases") fail(`unknown key "${key}" in resolve`);
|
|
256
|
+
}
|
|
257
|
+
const aliases = raw.resolve.aliases;
|
|
258
|
+
if (aliases === void 0) {
|
|
259
|
+
fail(`resolve.aliases is required when "resolve" is present`);
|
|
260
|
+
}
|
|
261
|
+
assertObject(aliases, "resolve.aliases");
|
|
262
|
+
for (const [alias, target] of Object.entries(aliases)) {
|
|
263
|
+
if (alias.length === 0) fail(`resolve.aliases keys must be non-empty`);
|
|
264
|
+
if (typeof target !== "string") {
|
|
265
|
+
fail(`resolve.aliases["${alias}"] must be a string`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
182
269
|
if (raw.audit !== void 0) {
|
|
183
270
|
assertObject(raw.audit, "audit");
|
|
184
271
|
for (const key of Object.keys(raw.audit)) {
|
|
185
272
|
if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
|
|
186
273
|
const v = raw.audit[key];
|
|
274
|
+
if (key === "severity") {
|
|
275
|
+
assertObject(v, "audit.severity");
|
|
276
|
+
for (const [code, level] of Object.entries(v)) {
|
|
277
|
+
if (!DIAGNOSTIC_CODE_SET.has(code)) {
|
|
278
|
+
fail(
|
|
279
|
+
`unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
|
|
283
|
+
fail(
|
|
284
|
+
`audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
187
290
|
if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
|
|
188
291
|
}
|
|
189
292
|
}
|
|
@@ -223,10 +326,12 @@ function validateConfig(raw) {
|
|
|
223
326
|
exclude: raw.exclude,
|
|
224
327
|
output: raw.output,
|
|
225
328
|
flows: raw.flows,
|
|
329
|
+
resolve: raw.resolve,
|
|
226
330
|
audit: raw.audit,
|
|
227
331
|
conventions: raw.conventions,
|
|
228
332
|
statesManifest: raw.statesManifest,
|
|
229
|
-
coverageBaseline: raw.coverageBaseline
|
|
333
|
+
coverageBaseline: raw.coverageBaseline,
|
|
334
|
+
workspace: raw.workspace
|
|
230
335
|
};
|
|
231
336
|
}
|
|
232
337
|
function parseConfig(json) {
|
|
@@ -276,9 +381,11 @@ function tryReadConfig(configPath) {
|
|
|
276
381
|
function discover(options = {}) {
|
|
277
382
|
const cwd = options.cwd ?? process.cwd();
|
|
278
383
|
const maxDepth = options.maxDepth ?? MAX_DEPTH;
|
|
384
|
+
const all = options.all ?? false;
|
|
279
385
|
const rootMatch = tryReadConfig(path.join(cwd, CONFIG_FILENAME));
|
|
280
|
-
if (rootMatch) return [rootMatch];
|
|
386
|
+
if (rootMatch && !all) return [rootMatch];
|
|
281
387
|
const results = [];
|
|
388
|
+
if (rootMatch) results.push(rootMatch);
|
|
282
389
|
const queue = [[cwd, 0]];
|
|
283
390
|
while (queue.length > 0) {
|
|
284
391
|
const [dir, depth] = queue.shift();
|
|
@@ -382,6 +489,26 @@ function toPosix(p2) {
|
|
|
382
489
|
function matchesAny(rel, patterns) {
|
|
383
490
|
return patterns.some((g) => globToRegExp(g).test(rel));
|
|
384
491
|
}
|
|
492
|
+
function createSourceMatcher(sources, options) {
|
|
493
|
+
const { cwd, globalExcludes = [], includeTests = false } = options;
|
|
494
|
+
const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
|
|
495
|
+
const compiled = sources.map((source) => ({
|
|
496
|
+
absRoot: path2.resolve(cwd, source.rootDir),
|
|
497
|
+
includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
|
|
498
|
+
excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
|
|
499
|
+
}));
|
|
500
|
+
return (absFile) => {
|
|
501
|
+
const abs = path2.resolve(absFile);
|
|
502
|
+
return compiled.some(({ absRoot, includes, excludes }) => {
|
|
503
|
+
const rel = toPosix(path2.relative(absRoot, abs));
|
|
504
|
+
if (rel.length === 0 || rel.startsWith("../") || path2.isAbsolute(rel)) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
if (matchesAny(rel, excludes)) return false;
|
|
508
|
+
return matchesAny(rel, includes);
|
|
509
|
+
});
|
|
510
|
+
};
|
|
511
|
+
}
|
|
385
512
|
function walk(sources, options) {
|
|
386
513
|
const { cwd, globalExcludes = [], includeTests = false } = options;
|
|
387
514
|
const out2 = [];
|
|
@@ -715,6 +842,109 @@ function createRegistry() {
|
|
|
715
842
|
};
|
|
716
843
|
}
|
|
717
844
|
|
|
845
|
+
// src/scanner/scan/acknowledge.ts
|
|
846
|
+
var ACK_ALL = "*";
|
|
847
|
+
var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
|
|
848
|
+
var COMPONENT_ACK_SCOPES = [ACK_ALL];
|
|
849
|
+
var AUTHORABLE_ACK_TYPES = [
|
|
850
|
+
"impossible",
|
|
851
|
+
"backlog"
|
|
852
|
+
];
|
|
853
|
+
var CORE_SET = new Set(CORE_STATE_KINDS);
|
|
854
|
+
function isCoreKind(scope) {
|
|
855
|
+
return CORE_SET.has(scope);
|
|
856
|
+
}
|
|
857
|
+
function isPlaceholderReason(reason) {
|
|
858
|
+
return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
|
|
859
|
+
}
|
|
860
|
+
function describeAck(a) {
|
|
861
|
+
if (a.type === "captured-elsewhere") {
|
|
862
|
+
return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
|
|
863
|
+
}
|
|
864
|
+
return a.reason ? `${a.type} ("${a.reason}")` : a.type;
|
|
865
|
+
}
|
|
866
|
+
function originHint(a, page) {
|
|
867
|
+
if (a.origin === "baseline") {
|
|
868
|
+
return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
|
|
869
|
+
}
|
|
870
|
+
if (a.origin === "workspace") {
|
|
871
|
+
return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
|
|
872
|
+
}
|
|
873
|
+
const where = page ? ` on page "${page}"` : "";
|
|
874
|
+
return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
|
|
875
|
+
}
|
|
876
|
+
function toResolved(map, origin, into) {
|
|
877
|
+
for (const [scope, ack] of Object.entries(map ?? {})) {
|
|
878
|
+
into.set(scope, { ...ack, scope, origin });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function routeAcknowledge(registry, baseline, route, page) {
|
|
882
|
+
const out2 = /* @__PURE__ */ new Map();
|
|
883
|
+
toResolved(baseline?.acknowledge?.[route], "baseline", out2);
|
|
884
|
+
if (page) {
|
|
885
|
+
toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
|
|
886
|
+
}
|
|
887
|
+
return out2;
|
|
888
|
+
}
|
|
889
|
+
function entityAcknowledge(meta, capturedElsewhereBy) {
|
|
890
|
+
const source = meta?.acknowledge?.[ACK_ALL];
|
|
891
|
+
if (source) return { ...source, scope: ACK_ALL, origin: "source" };
|
|
892
|
+
if (capturedElsewhereBy) {
|
|
893
|
+
return {
|
|
894
|
+
type: "captured-elsewhere",
|
|
895
|
+
by: capturedElsewhereBy,
|
|
896
|
+
scope: ACK_ALL,
|
|
897
|
+
origin: "workspace"
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
return void 0;
|
|
901
|
+
}
|
|
902
|
+
function readAck(raw) {
|
|
903
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
904
|
+
const r = raw;
|
|
905
|
+
if (r.type !== "impossible" && r.type !== "backlog") return void 0;
|
|
906
|
+
const out2 = { type: r.type };
|
|
907
|
+
if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
|
|
908
|
+
return out2;
|
|
909
|
+
}
|
|
910
|
+
function parseCoverageBaseline(raw) {
|
|
911
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
912
|
+
const r = raw;
|
|
913
|
+
const ackRaw = r.acknowledge;
|
|
914
|
+
if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
|
|
915
|
+
return null;
|
|
916
|
+
const acknowledge = {};
|
|
917
|
+
for (const [route, scopesRaw] of Object.entries(
|
|
918
|
+
ackRaw
|
|
919
|
+
)) {
|
|
920
|
+
if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
|
|
921
|
+
continue;
|
|
922
|
+
const scopes = {};
|
|
923
|
+
for (const [scope, ackVal] of Object.entries(
|
|
924
|
+
scopesRaw
|
|
925
|
+
)) {
|
|
926
|
+
if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
|
|
927
|
+
const ack = readAck(ackVal);
|
|
928
|
+
if (ack) scopes[scope] = ack;
|
|
929
|
+
}
|
|
930
|
+
if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
|
|
931
|
+
}
|
|
932
|
+
return { version: 2, acknowledge };
|
|
933
|
+
}
|
|
934
|
+
function serializeCoverageBaseline(baseline) {
|
|
935
|
+
const routes = Object.keys(baseline.acknowledge).sort();
|
|
936
|
+
const ordered = {};
|
|
937
|
+
for (const route of routes) {
|
|
938
|
+
const scopes = baseline.acknowledge[route];
|
|
939
|
+
const sorted = {};
|
|
940
|
+
for (const scope of ACK_SCOPES) {
|
|
941
|
+
if (scopes[scope]) sorted[scope] = scopes[scope];
|
|
942
|
+
}
|
|
943
|
+
ordered[route] = sorted;
|
|
944
|
+
}
|
|
945
|
+
return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
|
|
946
|
+
}
|
|
947
|
+
|
|
718
948
|
// src/scanner/scan/extract-uidex-export.ts
|
|
719
949
|
var KIND_DISCRIMINATORS = [
|
|
720
950
|
"page",
|
|
@@ -733,33 +963,44 @@ var ALLOWED_FIELDS = {
|
|
|
733
963
|
"widgets",
|
|
734
964
|
"acceptance",
|
|
735
965
|
"states",
|
|
736
|
-
"
|
|
737
|
-
"waivers",
|
|
966
|
+
"acknowledge",
|
|
738
967
|
"description"
|
|
739
968
|
]),
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
969
|
+
// A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
|
|
970
|
+
// and a core kind (one cell of its matrix). A feature/widget has no route, so
|
|
971
|
+
// the core-kind matrix is meaningless there and only `"*"` is accepted —
|
|
972
|
+
// giving a component a scoped opt-out instead of forcing you to delete its
|
|
973
|
+
// whole `states` block.
|
|
743
974
|
feature: /* @__PURE__ */ new Set([
|
|
744
975
|
"feature",
|
|
745
976
|
"name",
|
|
746
977
|
"features",
|
|
978
|
+
"surfaces",
|
|
747
979
|
"acceptance",
|
|
748
980
|
"states",
|
|
981
|
+
"acknowledge",
|
|
749
982
|
"description"
|
|
750
983
|
]),
|
|
751
984
|
primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
|
|
752
|
-
widget: /* @__PURE__ */ new Set([
|
|
985
|
+
widget: /* @__PURE__ */ new Set([
|
|
986
|
+
"widget",
|
|
987
|
+
"name",
|
|
988
|
+
"acceptance",
|
|
989
|
+
"states",
|
|
990
|
+
"acknowledge",
|
|
991
|
+
"description"
|
|
992
|
+
]),
|
|
753
993
|
region: /* @__PURE__ */ new Set(["region", "name", "description"]),
|
|
754
994
|
flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
|
|
755
995
|
};
|
|
996
|
+
var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
|
|
997
|
+
var ACCEPTANCE_FIELDS = /* @__PURE__ */ new Set(["id", "rev", "text"]);
|
|
756
998
|
var STATE_FIELDS = /* @__PURE__ */ new Set([
|
|
757
999
|
"name",
|
|
758
1000
|
"kind",
|
|
759
1001
|
"acceptance",
|
|
760
1002
|
"description"
|
|
761
1003
|
]);
|
|
762
|
-
var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
|
|
763
1004
|
var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
|
|
764
1005
|
var FALSEABLE = /* @__PURE__ */ new Set([
|
|
765
1006
|
"page",
|
|
@@ -1003,7 +1244,8 @@ function objectLit(node, content, p2, pos, span) {
|
|
|
1003
1244
|
key,
|
|
1004
1245
|
value,
|
|
1005
1246
|
keyPos,
|
|
1006
|
-
span: removalSpan(content, prop.start, prop.end)
|
|
1247
|
+
span: removalSpan(content, prop.start, prop.end),
|
|
1248
|
+
propSpan: { start: prop.start, end: prop.end }
|
|
1007
1249
|
});
|
|
1008
1250
|
}
|
|
1009
1251
|
return { kind: "object", entries, pos, span };
|
|
@@ -1124,7 +1366,8 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1124
1366
|
} else {
|
|
1125
1367
|
id = readIdField(idValue, kind, idField);
|
|
1126
1368
|
}
|
|
1127
|
-
const
|
|
1369
|
+
const criterionIds = /* @__PURE__ */ new Set();
|
|
1370
|
+
const acceptance = readAcceptanceField(byKey, criterionIds);
|
|
1128
1371
|
const description = readStringField(byKey, "description");
|
|
1129
1372
|
const name = readStringField(byKey, "name");
|
|
1130
1373
|
if (name === "") {
|
|
@@ -1150,9 +1393,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1150
1393
|
}
|
|
1151
1394
|
const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
|
|
1152
1395
|
const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
|
|
1153
|
-
const
|
|
1154
|
-
const
|
|
1155
|
-
const
|
|
1396
|
+
const surfacesField = kind === "feature" ? readStringArrayField(byKey, "surfaces") : void 0;
|
|
1397
|
+
const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey, criterionIds) : void 0;
|
|
1398
|
+
const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
|
|
1399
|
+
const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
|
|
1156
1400
|
const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
|
|
1157
1401
|
const metadata = {
|
|
1158
1402
|
source: "ts-export",
|
|
@@ -1175,16 +1419,26 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1175
1419
|
metadata.widgets = widgetsField.values;
|
|
1176
1420
|
metadata.widgetSpans = widgetsField.spans;
|
|
1177
1421
|
}
|
|
1422
|
+
if (surfacesField) {
|
|
1423
|
+
metadata.surfaces = surfacesField.values;
|
|
1424
|
+
metadata.surfaceSpans = surfacesField.spans;
|
|
1425
|
+
}
|
|
1178
1426
|
if (states?.length) metadata.states = states;
|
|
1179
|
-
if (
|
|
1180
|
-
|
|
1427
|
+
if (acknowledge && Object.keys(acknowledge).length > 0) {
|
|
1428
|
+
metadata.acknowledge = acknowledge;
|
|
1429
|
+
}
|
|
1181
1430
|
if (notFlow) metadata.notFlow = true;
|
|
1182
1431
|
if (typeof id === "string" && idValue.kind === "string") {
|
|
1183
1432
|
metadata.idSpan = idValue.span;
|
|
1184
1433
|
}
|
|
1185
1434
|
const fieldSpans = {};
|
|
1186
|
-
|
|
1435
|
+
const fieldPropSpans = {};
|
|
1436
|
+
for (const entry of value.entries) {
|
|
1437
|
+
fieldSpans[entry.key] = entry.span;
|
|
1438
|
+
fieldPropSpans[entry.key] = entry.propSpan;
|
|
1439
|
+
}
|
|
1187
1440
|
metadata.fieldSpans = fieldSpans;
|
|
1441
|
+
metadata.fieldPropSpans = fieldPropSpans;
|
|
1188
1442
|
return metadata;
|
|
1189
1443
|
}
|
|
1190
1444
|
function readIdField(value, kind, fieldName) {
|
|
@@ -1214,47 +1468,185 @@ function readIdField(value, kind, fieldName) {
|
|
|
1214
1468
|
value.pos
|
|
1215
1469
|
);
|
|
1216
1470
|
}
|
|
1217
|
-
function
|
|
1218
|
-
const entry = byKey.get("
|
|
1471
|
+
function readAcknowledgeField(byKey, isPage) {
|
|
1472
|
+
const entry = byKey.get("acknowledge");
|
|
1219
1473
|
if (!entry) return void 0;
|
|
1220
1474
|
if (entry.value.kind !== "object") {
|
|
1221
1475
|
throw new ExtractError(
|
|
1222
1476
|
"uidex-export-invalid-field",
|
|
1223
|
-
|
|
1477
|
+
'`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
|
|
1224
1478
|
entry.value.pos
|
|
1225
1479
|
);
|
|
1226
1480
|
}
|
|
1227
|
-
const
|
|
1481
|
+
const out2 = {};
|
|
1228
1482
|
for (const e of entry.value.entries) {
|
|
1229
|
-
|
|
1483
|
+
const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
|
|
1484
|
+
if (!scopes.includes(e.key)) {
|
|
1230
1485
|
throw new ExtractError(
|
|
1231
1486
|
"uidex-export-invalid-field",
|
|
1232
|
-
`
|
|
1487
|
+
`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)"}.`,
|
|
1233
1488
|
e.keyPos
|
|
1234
1489
|
);
|
|
1235
1490
|
}
|
|
1236
|
-
if (e.value.kind !== "
|
|
1491
|
+
if (e.value.kind !== "object") {
|
|
1492
|
+
throw new ExtractError(
|
|
1493
|
+
"uidex-export-invalid-field",
|
|
1494
|
+
`acknowledge "${e.key}" must be an object \`{ type, reason? }\`.`,
|
|
1495
|
+
e.value.pos
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
const fieldByKey = /* @__PURE__ */ new Map();
|
|
1499
|
+
for (const f of e.value.entries) {
|
|
1500
|
+
if (!ACK_FIELDS.has(f.key)) {
|
|
1501
|
+
throw new ExtractError(
|
|
1502
|
+
"uidex-export-invalid-field",
|
|
1503
|
+
`Unknown field "${f.key}" in an \`acknowledge\` entry. Allowed: ${[...ACK_FIELDS].sort().join(", ")}.`,
|
|
1504
|
+
f.value.pos
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
fieldByKey.set(f.key, f);
|
|
1508
|
+
}
|
|
1509
|
+
const type = readStringField(fieldByKey, "type");
|
|
1510
|
+
if (type === void 0) {
|
|
1237
1511
|
throw new ExtractError(
|
|
1238
1512
|
"uidex-export-invalid-field",
|
|
1239
|
-
`
|
|
1513
|
+
`acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
|
|
1240
1514
|
e.value.pos
|
|
1241
1515
|
);
|
|
1242
1516
|
}
|
|
1243
|
-
|
|
1517
|
+
if (type === "captured-elsewhere") {
|
|
1518
|
+
throw new ExtractError(
|
|
1519
|
+
"uidex-export-invalid-field",
|
|
1520
|
+
'`type: "captured-elsewhere"` cannot be authored; the workspace computes it from the sibling app that captured the declaration.',
|
|
1521
|
+
fieldByKey.get("type").value.pos,
|
|
1522
|
+
"Remove the entry \u2014 if a sibling app really captures this, the workspace reports it as `acknowledged-elsewhere` on its own."
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
if (type !== "impossible" && type !== "backlog") {
|
|
1526
|
+
throw new ExtractError(
|
|
1527
|
+
"uidex-export-invalid-field",
|
|
1528
|
+
`acknowledge \`type\` must be ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")} (got "${type}").`,
|
|
1529
|
+
fieldByKey.get("type").value.pos
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
const reason = readStringField(fieldByKey, "reason");
|
|
1533
|
+
if (type === "impossible" && (reason === void 0 || reason.length === 0)) {
|
|
1534
|
+
throw new ExtractError(
|
|
1535
|
+
"uidex-export-invalid-field",
|
|
1536
|
+
`acknowledge "${e.key}" is \`type: "impossible"\` and needs a non-empty \`reason\` (why this can NEVER be rendered/captured).`,
|
|
1537
|
+
e.value.pos,
|
|
1538
|
+
'If it merely has not been captured yet, that is `type: "backlog"` \u2014 which needs no reason.'
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
out2[e.key] = reason ? { type, reason } : { type };
|
|
1244
1542
|
}
|
|
1245
|
-
return
|
|
1543
|
+
return out2;
|
|
1246
1544
|
}
|
|
1247
|
-
function
|
|
1248
|
-
|
|
1545
|
+
function acceptanceSlug(text) {
|
|
1546
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8).join("-");
|
|
1547
|
+
}
|
|
1548
|
+
function readAcceptanceField(byKey, seen) {
|
|
1549
|
+
const entry = byKey.get("acceptance");
|
|
1249
1550
|
if (!entry) return void 0;
|
|
1250
|
-
|
|
1551
|
+
return readAcceptanceItems(entry, seen);
|
|
1552
|
+
}
|
|
1553
|
+
function readAcceptanceItems(entry, seen) {
|
|
1554
|
+
if (entry.value.kind !== "array") {
|
|
1251
1555
|
throw new ExtractError(
|
|
1252
1556
|
"uidex-export-invalid-field",
|
|
1253
|
-
|
|
1557
|
+
"`acceptance` must be an array of strings or `{ id, rev?, text }` objects.",
|
|
1254
1558
|
entry.value.pos
|
|
1255
1559
|
);
|
|
1256
1560
|
}
|
|
1257
|
-
|
|
1561
|
+
const criteria = [];
|
|
1562
|
+
for (const item of entry.value.items) {
|
|
1563
|
+
let criterion;
|
|
1564
|
+
if (item.kind === "string") {
|
|
1565
|
+
if (item.value.length === 0) {
|
|
1566
|
+
throw new ExtractError(
|
|
1567
|
+
"uidex-export-invalid-field",
|
|
1568
|
+
"An `acceptance` entry is an empty string.",
|
|
1569
|
+
item.pos
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
const id = acceptanceSlug(item.value);
|
|
1573
|
+
if (!id) {
|
|
1574
|
+
throw new ExtractError(
|
|
1575
|
+
"uidex-export-invalid-field",
|
|
1576
|
+
`Acceptance text "${item.value}" contains no alphanumeric characters to derive an id from; use the object form with an explicit \`id\`.`,
|
|
1577
|
+
item.pos
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
criterion = { id, rev: 1, text: item.value };
|
|
1581
|
+
} else if (item.kind === "object") {
|
|
1582
|
+
const fieldByKey = /* @__PURE__ */ new Map();
|
|
1583
|
+
for (const e of item.entries) {
|
|
1584
|
+
if (!ACCEPTANCE_FIELDS.has(e.key)) {
|
|
1585
|
+
throw new ExtractError(
|
|
1586
|
+
"uidex-export-invalid-field",
|
|
1587
|
+
`Unknown field "${e.key}" in an \`acceptance\` entry. Allowed: ${[
|
|
1588
|
+
...ACCEPTANCE_FIELDS
|
|
1589
|
+
].sort().join(", ")}.`,
|
|
1590
|
+
e.value.pos
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
fieldByKey.set(e.key, e);
|
|
1594
|
+
}
|
|
1595
|
+
const id = readStringField(fieldByKey, "id");
|
|
1596
|
+
if (id === void 0 || id.length === 0) {
|
|
1597
|
+
throw new ExtractError(
|
|
1598
|
+
"uidex-export-invalid-field",
|
|
1599
|
+
"An object `acceptance` entry needs a non-empty string `id`.",
|
|
1600
|
+
item.pos
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
if (id.includes("@")) {
|
|
1604
|
+
throw new ExtractError(
|
|
1605
|
+
"uidex-export-invalid-field",
|
|
1606
|
+
`acceptance id "${id}" must not contain "@" \u2014 it is reserved for the \`@<rev>\` pin in \`uidexCovers\` claims.`,
|
|
1607
|
+
item.pos
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
const text = readStringField(fieldByKey, "text");
|
|
1611
|
+
if (text === void 0 || text.length === 0) {
|
|
1612
|
+
throw new ExtractError(
|
|
1613
|
+
"uidex-export-invalid-field",
|
|
1614
|
+
`acceptance "${id}" needs a non-empty string \`text\`.`,
|
|
1615
|
+
item.pos
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
let rev = 1;
|
|
1619
|
+
const revEntry = fieldByKey.get("rev");
|
|
1620
|
+
if (revEntry) {
|
|
1621
|
+
if (revEntry.value.kind !== "number" || !Number.isInteger(revEntry.value.value) || revEntry.value.value < 1) {
|
|
1622
|
+
throw new ExtractError(
|
|
1623
|
+
"uidex-export-invalid-field",
|
|
1624
|
+
`acceptance "${id}" \`rev\` must be a positive integer.`,
|
|
1625
|
+
revEntry.value.pos
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
rev = revEntry.value.value;
|
|
1629
|
+
}
|
|
1630
|
+
criterion = { id, rev, text };
|
|
1631
|
+
} else {
|
|
1632
|
+
throw new ExtractError(
|
|
1633
|
+
"uidex-export-invalid-field",
|
|
1634
|
+
"`acceptance` items must be strings or `{ id, rev?, text }` objects.",
|
|
1635
|
+
item.pos
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
if (seen.has(criterion.id)) {
|
|
1639
|
+
throw new ExtractError(
|
|
1640
|
+
"acceptance-duplicate-id",
|
|
1641
|
+
`Duplicate acceptance criterion id "${criterion.id}"; each criterion id must be unique within the entity.`,
|
|
1642
|
+
item.pos,
|
|
1643
|
+
"Give the criterion an explicit distinct `id` (object form) \u2014 two string criteria can slug to the same id."
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
seen.add(criterion.id);
|
|
1647
|
+
criteria.push(criterion);
|
|
1648
|
+
}
|
|
1649
|
+
return criteria;
|
|
1258
1650
|
}
|
|
1259
1651
|
function readStringField(byKey, name) {
|
|
1260
1652
|
const entry = byKey.get(name);
|
|
@@ -1293,7 +1685,7 @@ function readStringArrayField(byKey, name) {
|
|
|
1293
1685
|
}
|
|
1294
1686
|
return { values, spans };
|
|
1295
1687
|
}
|
|
1296
|
-
function readStatesField(byKey) {
|
|
1688
|
+
function readStatesField(byKey, criterionIds) {
|
|
1297
1689
|
const entry = byKey.get("states");
|
|
1298
1690
|
if (!entry) return void 0;
|
|
1299
1691
|
if (entry.value.kind !== "array") {
|
|
@@ -1343,8 +1735,11 @@ function readStatesField(byKey) {
|
|
|
1343
1735
|
}
|
|
1344
1736
|
state.kind = kind;
|
|
1345
1737
|
}
|
|
1346
|
-
const
|
|
1347
|
-
if (
|
|
1738
|
+
const accEntry = fieldByKey.get("acceptance");
|
|
1739
|
+
if (accEntry) {
|
|
1740
|
+
const acceptance = readAcceptanceItems(accEntry, criterionIds);
|
|
1741
|
+
if (acceptance.length > 0) state.acceptance = acceptance;
|
|
1742
|
+
}
|
|
1348
1743
|
const description = readStringField(fieldByKey, "description");
|
|
1349
1744
|
if (description) state.description = description;
|
|
1350
1745
|
} else {
|
|
@@ -1400,20 +1795,21 @@ function isNode2(value) {
|
|
|
1400
1795
|
}
|
|
1401
1796
|
|
|
1402
1797
|
// src/scanner/scan/flow-facts.ts
|
|
1403
|
-
function collectFlowFacts(parsed, content) {
|
|
1798
|
+
function collectFlowFacts(parsed, content, displayPath) {
|
|
1404
1799
|
if (parsed.program === null || !content.includes("test.describe")) {
|
|
1405
|
-
return [];
|
|
1800
|
+
return { facts: [], diagnostics: [] };
|
|
1406
1801
|
}
|
|
1407
1802
|
const facts = [];
|
|
1803
|
+
const diagnostics = [];
|
|
1408
1804
|
walkAst(parsed.program, (node) => {
|
|
1409
1805
|
if (node.type !== "CallExpression") return void 0;
|
|
1410
|
-
const fact = readTaggedDescribe(node, parsed);
|
|
1806
|
+
const fact = readTaggedDescribe(node, parsed, diagnostics, displayPath);
|
|
1411
1807
|
if (fact) facts.push(fact);
|
|
1412
1808
|
return void 0;
|
|
1413
1809
|
});
|
|
1414
|
-
return facts;
|
|
1810
|
+
return { facts, diagnostics };
|
|
1415
1811
|
}
|
|
1416
|
-
function readTaggedDescribe(call, parsed) {
|
|
1812
|
+
function readTaggedDescribe(call, parsed, diagnostics, displayPath) {
|
|
1417
1813
|
const callee = call.callee;
|
|
1418
1814
|
if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
|
|
1419
1815
|
return null;
|
|
@@ -1424,13 +1820,83 @@ function readTaggedDescribe(call, parsed) {
|
|
|
1424
1820
|
if (!hasFlowTag(args[1])) return null;
|
|
1425
1821
|
const body = args[2];
|
|
1426
1822
|
const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
|
|
1823
|
+
const covers = body ? collectCoversClaims(body, parsed, title, diagnostics, displayPath) : [];
|
|
1427
1824
|
return {
|
|
1428
1825
|
title,
|
|
1429
1826
|
line: parsed.lineAt(call.start),
|
|
1430
1827
|
calls,
|
|
1431
|
-
...dynamicCalls.length > 0 ? { dynamicCalls } : {}
|
|
1828
|
+
...dynamicCalls.length > 0 ? { dynamicCalls } : {},
|
|
1829
|
+
...covers.length > 0 ? { covers } : {}
|
|
1432
1830
|
};
|
|
1433
1831
|
}
|
|
1832
|
+
function collectCoversClaims(body, parsed, flowTitle, diagnostics, displayPath) {
|
|
1833
|
+
const covers = [];
|
|
1834
|
+
walkAst(body, (node) => {
|
|
1835
|
+
if (node.type !== "CallExpression") return void 0;
|
|
1836
|
+
if (!isIdentifier(node.callee, "uidexCovers")) return void 0;
|
|
1837
|
+
const line = parsed.lineAt(node.start);
|
|
1838
|
+
const args = node.arguments ?? [];
|
|
1839
|
+
const entity = stringLiteralValue(args[0]);
|
|
1840
|
+
if (entity === null) {
|
|
1841
|
+
diagnostics.push({
|
|
1842
|
+
code: "covers-missing-rev",
|
|
1843
|
+
severity: "error",
|
|
1844
|
+
message: `\`uidexCovers(\u2026)\` in flow "${flowTitle}" needs a static string entity id as its first argument.`,
|
|
1845
|
+
file: displayPath,
|
|
1846
|
+
line,
|
|
1847
|
+
hint: 'Write `uidexCovers("<entityId>", "<criterionId>@<rev>")` with string literals.'
|
|
1848
|
+
});
|
|
1849
|
+
return void 0;
|
|
1850
|
+
}
|
|
1851
|
+
if (args.length < 2) {
|
|
1852
|
+
diagnostics.push({
|
|
1853
|
+
code: "covers-missing-rev",
|
|
1854
|
+
severity: "error",
|
|
1855
|
+
message: `\`uidexCovers("${entity}")\` in flow "${flowTitle}" names no criterion; a claim covers nothing without at least one \`"<criterionId>@<rev>"\` argument.`,
|
|
1856
|
+
file: displayPath,
|
|
1857
|
+
line,
|
|
1858
|
+
hint: 'List the criteria this flow verifies: `uidexCovers("<entityId>", "<criterionId>@<rev>", ...)`.'
|
|
1859
|
+
});
|
|
1860
|
+
return void 0;
|
|
1861
|
+
}
|
|
1862
|
+
for (const arg of args.slice(1)) {
|
|
1863
|
+
const claimLine = parsed.lineAt(arg.start);
|
|
1864
|
+
const raw = stringLiteralValue(arg);
|
|
1865
|
+
const at = raw === null ? -1 : raw.lastIndexOf("@");
|
|
1866
|
+
if (raw === null || at <= 0) {
|
|
1867
|
+
diagnostics.push({
|
|
1868
|
+
code: "covers-missing-rev",
|
|
1869
|
+
severity: "error",
|
|
1870
|
+
message: `\`uidexCovers(\u2026)\` claim ${raw === null ? "" : `"${raw}" `}in flow "${flowTitle}" is missing its \`@<rev>\` pin.`,
|
|
1871
|
+
file: displayPath,
|
|
1872
|
+
line: claimLine,
|
|
1873
|
+
hint: 'Pin the criterion rev \u2014 `uidexCovers("<entityId>", "<criterionId>@<rev>")` \u2014 so a later rev bump surfaces this claim as outdated.'
|
|
1874
|
+
});
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
const revStr = raw.slice(at + 1);
|
|
1878
|
+
if (!/^[1-9]\d*$/.test(revStr)) {
|
|
1879
|
+
diagnostics.push({
|
|
1880
|
+
code: "covers-missing-rev",
|
|
1881
|
+
severity: "error",
|
|
1882
|
+
message: `\`uidexCovers(\u2026)\` claim "${raw}" in flow "${flowTitle}" has an invalid rev "@${revStr}"; the rev must be a positive integer (no leading zeros).`,
|
|
1883
|
+
file: displayPath,
|
|
1884
|
+
line: claimLine,
|
|
1885
|
+
hint: 'Write the pin as `"<criterionId>@<rev>"` with a positive integer rev, e.g. `"gated@1"`.'
|
|
1886
|
+
});
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
covers.push({
|
|
1890
|
+
entity,
|
|
1891
|
+
criterion: raw.slice(0, at),
|
|
1892
|
+
rev: Number(revStr),
|
|
1893
|
+
line: claimLine
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
return void 0;
|
|
1897
|
+
});
|
|
1898
|
+
return covers;
|
|
1899
|
+
}
|
|
1434
1900
|
function hasFlowTag(node) {
|
|
1435
1901
|
if (!node || node.type !== "ObjectExpression") return false;
|
|
1436
1902
|
for (const prop of node.properties ?? []) {
|
|
@@ -1822,11 +2288,13 @@ function extract(files) {
|
|
|
1822
2288
|
const out2 = { file, annotations: [] };
|
|
1823
2289
|
out2.annotations = extractOne(file, parsed, out2);
|
|
1824
2290
|
if (exports2.length > 0) out2.metadata = exports2;
|
|
2291
|
+
const flowFacts = collectFlowFacts(parsed, file.content, file.displayPath);
|
|
2292
|
+
diagnostics.push(...flowFacts.diagnostics);
|
|
1825
2293
|
if (diagnostics.length > 0) out2.diagnostics = diagnostics;
|
|
1826
|
-
|
|
1827
|
-
if (flows.length > 0) out2.flows = flows;
|
|
2294
|
+
if (flowFacts.facts.length > 0) out2.flows = flowFacts.facts;
|
|
1828
2295
|
const imports = collectImportFacts(parsed);
|
|
1829
2296
|
if (imports.length > 0) out2.imports = imports;
|
|
2297
|
+
if (containsJsx(parsed)) out2.hasJsx = true;
|
|
1830
2298
|
return out2;
|
|
1831
2299
|
});
|
|
1832
2300
|
}
|
|
@@ -1851,6 +2319,32 @@ function extractOne(file, parsed, out2) {
|
|
|
1851
2319
|
}
|
|
1852
2320
|
return annotations;
|
|
1853
2321
|
}
|
|
2322
|
+
function containsJsx(parsed) {
|
|
2323
|
+
if (parsed.program === null) return false;
|
|
2324
|
+
let found = false;
|
|
2325
|
+
walkAst(parsed.program, (node) => {
|
|
2326
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") {
|
|
2327
|
+
found = true;
|
|
2328
|
+
return false;
|
|
2329
|
+
}
|
|
2330
|
+
if (node.type === "CallExpression" && isCreateElementCallee(node.callee)) {
|
|
2331
|
+
found = true;
|
|
2332
|
+
return false;
|
|
2333
|
+
}
|
|
2334
|
+
return void 0;
|
|
2335
|
+
});
|
|
2336
|
+
return found;
|
|
2337
|
+
}
|
|
2338
|
+
function isCreateElementCallee(callee) {
|
|
2339
|
+
const node = callee;
|
|
2340
|
+
if (!node) return false;
|
|
2341
|
+
if (node.type === "Identifier") return String(node.name) === "createElement";
|
|
2342
|
+
if (node.type === "MemberExpression") {
|
|
2343
|
+
const prop = node.property;
|
|
2344
|
+
return prop?.type === "Identifier" && String(prop.name) === "createElement";
|
|
2345
|
+
}
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
1854
2348
|
function collectImportFacts(parsed) {
|
|
1855
2349
|
if (parsed.program === null) return [];
|
|
1856
2350
|
const out2 = [];
|
|
@@ -1883,6 +2377,21 @@ function collectImportFacts(parsed) {
|
|
|
1883
2377
|
names
|
|
1884
2378
|
});
|
|
1885
2379
|
}
|
|
2380
|
+
walkAst(parsed.program, (node) => {
|
|
2381
|
+
if (node.type !== "ImportExpression") return void 0;
|
|
2382
|
+
const source = node.source;
|
|
2383
|
+
if (!source || source.type !== "Literal") return void 0;
|
|
2384
|
+
if (typeof source.value !== "string") return void 0;
|
|
2385
|
+
out2.push({
|
|
2386
|
+
specifier: source.value,
|
|
2387
|
+
line: parsed.lineAt(node.start),
|
|
2388
|
+
span: { start: node.start, end: node.end },
|
|
2389
|
+
isTypeOnly: false,
|
|
2390
|
+
names: [],
|
|
2391
|
+
dynamic: true
|
|
2392
|
+
});
|
|
2393
|
+
return void 0;
|
|
2394
|
+
});
|
|
1886
2395
|
return out2;
|
|
1887
2396
|
}
|
|
1888
2397
|
|
|
@@ -2054,6 +2563,7 @@ function buildMetaFromExport(exp) {
|
|
|
2054
2563
|
if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
|
|
2055
2564
|
if (exp.features?.length) meta.features = exp.features;
|
|
2056
2565
|
if (exp.widgets?.length) meta.widgets = exp.widgets;
|
|
2566
|
+
if (exp.surfaces?.length) meta.surfaces = exp.surfaces;
|
|
2057
2567
|
if (exp.states?.length) {
|
|
2058
2568
|
meta.states = exp.states.map((s) => ({
|
|
2059
2569
|
name: s.name,
|
|
@@ -2062,9 +2572,8 @@ function buildMetaFromExport(exp) {
|
|
|
2062
2572
|
...s.description ? { description: s.description } : {}
|
|
2063
2573
|
}));
|
|
2064
2574
|
}
|
|
2065
|
-
if (exp.
|
|
2066
|
-
|
|
2067
|
-
meta.waivers = exp.waivers;
|
|
2575
|
+
if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
|
|
2576
|
+
meta.acknowledge = exp.acknowledge;
|
|
2068
2577
|
}
|
|
2069
2578
|
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
2070
2579
|
}
|
|
@@ -2409,6 +2918,45 @@ function resolve2(ctx) {
|
|
|
2409
2918
|
};
|
|
2410
2919
|
registry.add(element);
|
|
2411
2920
|
}
|
|
2921
|
+
const ancestorsById = /* @__PURE__ */ new Map();
|
|
2922
|
+
const fileById = /* @__PURE__ */ new Map();
|
|
2923
|
+
for (const a of allAnnotations) {
|
|
2924
|
+
if (!fileById.has(a.id)) fileById.set(a.id, a.file);
|
|
2925
|
+
if (a.ancestors?.length) {
|
|
2926
|
+
const prev = ancestorsById.get(a.id) ?? [];
|
|
2927
|
+
ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
const ownersByFile = /* @__PURE__ */ new Map();
|
|
2931
|
+
const addOwner = (file, id) => {
|
|
2932
|
+
ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
|
|
2933
|
+
};
|
|
2934
|
+
const featureDirs = [];
|
|
2935
|
+
for (const f of registry.list("feature")) {
|
|
2936
|
+
const loc = f.loc?.file;
|
|
2937
|
+
if (!loc) continue;
|
|
2938
|
+
const dir = loc.endsWith("/") ? loc.slice(0, -1) : path4.posix.dirname(loc);
|
|
2939
|
+
featureDirs.push({ dir, id: f.id });
|
|
2940
|
+
}
|
|
2941
|
+
for (const p2 of registry.list("page")) {
|
|
2942
|
+
if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
|
|
2943
|
+
}
|
|
2944
|
+
const ownersFor = (file) => {
|
|
2945
|
+
const out2 = [...ownersByFile.get(file) ?? []];
|
|
2946
|
+
for (const { dir, id } of featureDirs) {
|
|
2947
|
+
if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
|
|
2948
|
+
}
|
|
2949
|
+
return out2;
|
|
2950
|
+
};
|
|
2951
|
+
const expandTouches = (ids) => {
|
|
2952
|
+
const out2 = new Set(ids);
|
|
2953
|
+
for (const id of ids) {
|
|
2954
|
+
for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
|
|
2955
|
+
const file = fileById.get(id);
|
|
2956
|
+
if (file) for (const owner of ownersFor(file)) out2.add(owner);
|
|
2957
|
+
}
|
|
2958
|
+
return [...out2];
|
|
2959
|
+
};
|
|
2412
2960
|
if (ctx.flowFiles && conventions.flows) {
|
|
2413
2961
|
for (const ff of ctx.flowFiles) {
|
|
2414
2962
|
const notFlowExport = (ff.metadata ?? []).find(
|
|
@@ -2418,7 +2966,10 @@ function resolve2(ctx) {
|
|
|
2418
2966
|
const flowExport = (ff.metadata ?? []).find(
|
|
2419
2967
|
(m) => m.kind === "flow" && typeof m.id === "string"
|
|
2420
2968
|
);
|
|
2421
|
-
const derived = flowsFromFacts(ff)
|
|
2969
|
+
const derived = flowsFromFacts(ff).map((f) => ({
|
|
2970
|
+
...f,
|
|
2971
|
+
touches: expandTouches(f.touches)
|
|
2972
|
+
}));
|
|
2422
2973
|
if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
|
|
2423
2974
|
const base = derived[0];
|
|
2424
2975
|
const flow = {
|
|
@@ -2426,7 +2977,8 @@ function resolve2(ctx) {
|
|
|
2426
2977
|
id: flowExport.id,
|
|
2427
2978
|
loc: base.loc,
|
|
2428
2979
|
touches: base.touches,
|
|
2429
|
-
steps: base.steps
|
|
2980
|
+
steps: base.steps,
|
|
2981
|
+
...base.covers ? { covers: base.covers } : {}
|
|
2430
2982
|
};
|
|
2431
2983
|
registry.add(flow);
|
|
2432
2984
|
} else {
|
|
@@ -2474,20 +3026,95 @@ function computeScope(displayPath) {
|
|
|
2474
3026
|
return null;
|
|
2475
3027
|
}
|
|
2476
3028
|
function flowsFromFacts(ff) {
|
|
2477
|
-
return (ff.flows ?? []).map((fact) =>
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
3029
|
+
return (ff.flows ?? []).map((fact) => {
|
|
3030
|
+
const covers = dedupeBy(
|
|
3031
|
+
(fact.covers ?? []).map((c) => ({
|
|
3032
|
+
entity: c.entity,
|
|
3033
|
+
criterion: c.criterion,
|
|
3034
|
+
rev: c.rev
|
|
3035
|
+
})),
|
|
3036
|
+
(c) => `${c.entity} ${c.criterion} ${c.rev}`
|
|
3037
|
+
);
|
|
3038
|
+
return {
|
|
3039
|
+
kind: "flow",
|
|
3040
|
+
id: kebab(fact.title),
|
|
3041
|
+
loc: { file: ff.file.displayPath, line: fact.line },
|
|
3042
|
+
touches: dedupe(fact.calls.map((c) => c.id)),
|
|
3043
|
+
steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action })),
|
|
3044
|
+
...covers.length > 0 ? { covers } : {}
|
|
3045
|
+
};
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
function dedupeBy(arr, key) {
|
|
3049
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3050
|
+
const out2 = [];
|
|
3051
|
+
for (const item of arr) {
|
|
3052
|
+
const k = key(item);
|
|
3053
|
+
if (seen.has(k)) continue;
|
|
3054
|
+
seen.add(k);
|
|
3055
|
+
out2.push(item);
|
|
3056
|
+
}
|
|
3057
|
+
return out2;
|
|
2484
3058
|
}
|
|
2485
3059
|
function dedupe(arr) {
|
|
2486
3060
|
return Array.from(new Set(arr));
|
|
2487
3061
|
}
|
|
2488
3062
|
|
|
2489
3063
|
// src/scanner/scan/audit.ts
|
|
2490
|
-
var
|
|
3064
|
+
var path7 = __toESM(require("path"), 1);
|
|
3065
|
+
|
|
3066
|
+
// src/scanner/scan/component-coverage.ts
|
|
3067
|
+
var COMPOSED_KINDS = ["widget", "feature"];
|
|
3068
|
+
function composedStateEntities(registry, page) {
|
|
3069
|
+
const meta = registry.get("page", page)?.meta;
|
|
3070
|
+
if (!meta) return [];
|
|
3071
|
+
const out2 = [];
|
|
3072
|
+
for (const kind of COMPOSED_KINDS) {
|
|
3073
|
+
const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
|
|
3074
|
+
for (const id of ids) {
|
|
3075
|
+
const e = registry.get(kind, id);
|
|
3076
|
+
if (!e?.meta?.states?.length) continue;
|
|
3077
|
+
if (e.meta.acknowledge?.[ACK_ALL]) continue;
|
|
3078
|
+
out2.push({ kind, id });
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
return out2;
|
|
3082
|
+
}
|
|
3083
|
+
function capturedStatesFor(captured, kind, id) {
|
|
3084
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3085
|
+
for (const c of captured) {
|
|
3086
|
+
if (c.entity !== id) continue;
|
|
3087
|
+
if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
|
|
3088
|
+
out2.add(c.state);
|
|
3089
|
+
}
|
|
3090
|
+
return out2;
|
|
3091
|
+
}
|
|
3092
|
+
function checkComponentCoverage(registry, manifest) {
|
|
3093
|
+
const routes = registry.list("route");
|
|
3094
|
+
if (routes.length === 0) return [];
|
|
3095
|
+
const captured = manifest.captured ?? [];
|
|
3096
|
+
const diagnostics = [];
|
|
3097
|
+
for (const route of routes) {
|
|
3098
|
+
if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
|
|
3099
|
+
for (const { kind, id } of composedStateEntities(registry, route.page)) {
|
|
3100
|
+
const entity = registry.get(kind, id);
|
|
3101
|
+
const declared = entity.meta.states.map((s) => s.name);
|
|
3102
|
+
const shot = capturedStatesFor(captured, kind, id);
|
|
3103
|
+
const missing = declared.filter((s) => !shot.has(s));
|
|
3104
|
+
if (missing.length === 0) continue;
|
|
3105
|
+
diagnostics.push({
|
|
3106
|
+
code: "component-state-uncaptured",
|
|
3107
|
+
severity: "info",
|
|
3108
|
+
message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
|
|
3109
|
+
file: entity.loc?.file,
|
|
3110
|
+
line: entity.loc?.line,
|
|
3111
|
+
entity: { kind, id },
|
|
3112
|
+
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}".`
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
return diagnostics;
|
|
3117
|
+
}
|
|
2491
3118
|
|
|
2492
3119
|
// src/scanner/scan/page-coverage.ts
|
|
2493
3120
|
function compileRoute(pattern) {
|
|
@@ -2510,11 +3137,11 @@ function compileRoute(pattern) {
|
|
|
2510
3137
|
return { test: (url) => re.test(url), specificity };
|
|
2511
3138
|
}
|
|
2512
3139
|
function matchUrlToRoute(url, routePaths) {
|
|
2513
|
-
const
|
|
3140
|
+
const path14 = stripQuery(url);
|
|
2514
3141
|
let best = null;
|
|
2515
3142
|
for (const rp of routePaths) {
|
|
2516
3143
|
const { test, specificity } = compileRoute(rp);
|
|
2517
|
-
if (!test(
|
|
3144
|
+
if (!test(path14)) continue;
|
|
2518
3145
|
if (!best || specificity > best.specificity)
|
|
2519
3146
|
best = { path: rp, specificity };
|
|
2520
3147
|
}
|
|
@@ -2545,29 +3172,49 @@ function checkPageCoverage(registry, manifest, baseline) {
|
|
|
2545
3172
|
if (routes.length === 0) return [];
|
|
2546
3173
|
const routePaths = routes.map((r) => r.path);
|
|
2547
3174
|
const captured = manifest.captured ?? [];
|
|
2548
|
-
const optedOut = /* @__PURE__ */ new Set();
|
|
2549
|
-
for (const r of routes) {
|
|
2550
|
-
const page = registry.get("page", r.page);
|
|
2551
|
-
if (page?.meta?.capture === false) optedOut.add(r.path);
|
|
2552
|
-
}
|
|
2553
3175
|
const covered = /* @__PURE__ */ new Set();
|
|
2554
3176
|
for (const c of captured) {
|
|
2555
3177
|
const route = routeForCapture(c, routes, routePaths);
|
|
2556
3178
|
if (route) covered.add(route);
|
|
2557
3179
|
}
|
|
2558
|
-
const baselineSet = new Set(baseline?.uncapturedPages ?? []);
|
|
2559
3180
|
const diagnostics = [];
|
|
2560
3181
|
for (const r of routes) {
|
|
2561
|
-
|
|
3182
|
+
const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
|
|
3183
|
+
ACK_ALL
|
|
3184
|
+
);
|
|
2562
3185
|
const loc = registry.get("page", r.page)?.loc;
|
|
2563
|
-
if (
|
|
3186
|
+
if (covered.has(r.path)) {
|
|
3187
|
+
if (ack?.type === "impossible") {
|
|
3188
|
+
diagnostics.push({
|
|
3189
|
+
code: "stale-acknowledgment",
|
|
3190
|
+
severity: "warning",
|
|
3191
|
+
message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
|
|
3192
|
+
file: loc?.file,
|
|
3193
|
+
line: loc?.line,
|
|
3194
|
+
entity: { kind: "page", id: r.page },
|
|
3195
|
+
hint: originHint(ack, r.page)
|
|
3196
|
+
});
|
|
3197
|
+
} else if (ack?.type === "backlog") {
|
|
3198
|
+
diagnostics.push({
|
|
3199
|
+
code: "page-captured",
|
|
3200
|
+
severity: "warning",
|
|
3201
|
+
message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
|
|
3202
|
+
file: loc?.file,
|
|
3203
|
+
line: loc?.line,
|
|
3204
|
+
hint: originHint(ack, r.page)
|
|
3205
|
+
});
|
|
3206
|
+
}
|
|
3207
|
+
continue;
|
|
3208
|
+
}
|
|
3209
|
+
if (ack?.type === "impossible") continue;
|
|
3210
|
+
if (ack?.type === "backlog") {
|
|
2564
3211
|
diagnostics.push({
|
|
2565
3212
|
code: "page-backlog",
|
|
2566
3213
|
severity: "info",
|
|
2567
3214
|
message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
|
|
2568
3215
|
file: loc?.file,
|
|
2569
3216
|
line: loc?.line,
|
|
2570
|
-
hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or
|
|
3217
|
+
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}".`
|
|
2571
3218
|
});
|
|
2572
3219
|
continue;
|
|
2573
3220
|
}
|
|
@@ -2578,46 +3225,155 @@ function checkPageCoverage(registry, manifest, baseline) {
|
|
|
2578
3225
|
file: loc?.file,
|
|
2579
3226
|
line: loc?.line,
|
|
2580
3227
|
entity: { kind: "route", id: r.path },
|
|
2581
|
-
hint: `Capture the route in a states spec
|
|
3228
|
+
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).`
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
for (const b of Object.keys(baseline?.acknowledge ?? {})) {
|
|
3232
|
+
if (routePaths.includes(b)) continue;
|
|
3233
|
+
diagnostics.push({
|
|
3234
|
+
code: "stale-page-baseline",
|
|
3235
|
+
severity: "warning",
|
|
3236
|
+
message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
|
|
3237
|
+
hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
|
|
2582
3238
|
});
|
|
2583
3239
|
}
|
|
2584
|
-
for (const b of baselineSet) {
|
|
2585
|
-
if (!routePaths.includes(b)) {
|
|
2586
|
-
diagnostics.push({
|
|
2587
|
-
code: "stale-page-baseline",
|
|
2588
|
-
severity: "warning",
|
|
2589
|
-
message: `coverage baseline lists "${b}", which is not a route in the registry`,
|
|
2590
|
-
hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
|
|
2591
|
-
});
|
|
2592
|
-
} else if (covered.has(b) || optedOut.has(b)) {
|
|
2593
|
-
diagnostics.push({
|
|
2594
|
-
code: "page-captured",
|
|
2595
|
-
severity: "warning",
|
|
2596
|
-
message: `route "${b}" is now captured but is still in the coverage baseline`,
|
|
2597
|
-
hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
|
|
2598
|
-
});
|
|
2599
|
-
}
|
|
2600
|
-
}
|
|
2601
3240
|
return diagnostics;
|
|
2602
3241
|
}
|
|
2603
|
-
function
|
|
2604
|
-
const
|
|
3242
|
+
function computePageBacklog(registry, manifest) {
|
|
3243
|
+
const out2 = {};
|
|
2605
3244
|
for (const d of checkPageCoverage(registry, manifest, {
|
|
2606
|
-
|
|
3245
|
+
version: 2,
|
|
3246
|
+
acknowledge: {}
|
|
2607
3247
|
})) {
|
|
2608
|
-
if (d.code === "page-no-capture" && d.entity)
|
|
3248
|
+
if (d.code === "page-no-capture" && d.entity) {
|
|
3249
|
+
out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
return out2;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
// src/scanner/scan/workspace.ts
|
|
3256
|
+
var fs3 = __toESM(require("fs"), 1);
|
|
3257
|
+
var path5 = __toESM(require("path"), 1);
|
|
3258
|
+
var WORKSPACE_MARKERS = [
|
|
3259
|
+
"pnpm-workspace.yaml",
|
|
3260
|
+
"pnpm-workspace.yml",
|
|
3261
|
+
"turbo.json",
|
|
3262
|
+
"lerna.json",
|
|
3263
|
+
"nx.json",
|
|
3264
|
+
".git"
|
|
3265
|
+
];
|
|
3266
|
+
var MAX_ASCENT = 8;
|
|
3267
|
+
var DEFAULT_STATES_MANIFEST = "uidex-states.json";
|
|
3268
|
+
function captureKey(kind, entity) {
|
|
3269
|
+
return `${kind ?? ""}|${entity}`;
|
|
3270
|
+
}
|
|
3271
|
+
function hasMarker(dir) {
|
|
3272
|
+
return WORKSPACE_MARKERS.some((m) => fs3.existsSync(path5.join(dir, m)));
|
|
3273
|
+
}
|
|
3274
|
+
function findWorkspaceRoot(configDir) {
|
|
3275
|
+
let dir = path5.resolve(configDir);
|
|
3276
|
+
for (let i = 0; i < MAX_ASCENT; i++) {
|
|
3277
|
+
const parent = path5.dirname(dir);
|
|
3278
|
+
if (parent === dir) return void 0;
|
|
3279
|
+
dir = parent;
|
|
3280
|
+
if (hasMarker(dir)) return dir;
|
|
3281
|
+
}
|
|
3282
|
+
return void 0;
|
|
3283
|
+
}
|
|
3284
|
+
function captureKeys(manifest) {
|
|
3285
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3286
|
+
for (const c of manifest?.captured ?? []) {
|
|
3287
|
+
out2.add(captureKey(c.kind, c.entity));
|
|
3288
|
+
}
|
|
3289
|
+
return out2;
|
|
3290
|
+
}
|
|
3291
|
+
function readManifest(configDir, config) {
|
|
3292
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
|
|
3293
|
+
let raw;
|
|
3294
|
+
try {
|
|
3295
|
+
raw = fs3.readFileSync(path5.resolve(configDir, rel), "utf8");
|
|
3296
|
+
} catch {
|
|
3297
|
+
return void 0;
|
|
3298
|
+
}
|
|
3299
|
+
try {
|
|
3300
|
+
const parsed = JSON.parse(raw);
|
|
3301
|
+
if (!parsed || !Array.isArray(parsed.captured)) return void 0;
|
|
3302
|
+
const captured = [];
|
|
3303
|
+
for (const entry of parsed.captured) {
|
|
3304
|
+
if (!entry || typeof entry !== "object") continue;
|
|
3305
|
+
const c = entry;
|
|
3306
|
+
if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
|
|
3307
|
+
const rec = { entity: c.entity, state: c.state };
|
|
3308
|
+
if (typeof c.kind === "string") rec.kind = c.kind;
|
|
3309
|
+
captured.push(rec);
|
|
3310
|
+
}
|
|
3311
|
+
return { captured };
|
|
3312
|
+
} catch {
|
|
3313
|
+
return void 0;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
function toApp(dc) {
|
|
3317
|
+
return {
|
|
3318
|
+
name: path5.basename(dc.configDir),
|
|
3319
|
+
configDir: dc.configDir,
|
|
3320
|
+
configPath: dc.configPath,
|
|
3321
|
+
registers: createSourceMatcher(dc.config.sources, {
|
|
3322
|
+
cwd: dc.configDir,
|
|
3323
|
+
globalExcludes: dc.config.exclude
|
|
3324
|
+
}),
|
|
3325
|
+
captured: captureKeys(readManifest(dc.configDir, dc.config))
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3328
|
+
function resolveWorkspace(opts) {
|
|
3329
|
+
const { configDir, config } = opts;
|
|
3330
|
+
if (config.workspace === false) return void 0;
|
|
3331
|
+
const root = typeof config.workspace === "string" ? path5.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
|
|
3332
|
+
if (!root) return void 0;
|
|
3333
|
+
const configs = opts.configs ?? discover({ cwd: root, all: true });
|
|
3334
|
+
const self = path5.resolve(configDir, CONFIG_FILENAME);
|
|
3335
|
+
const siblings = configs.filter((dc) => path5.resolve(dc.configPath) !== self).map(toApp);
|
|
3336
|
+
if (siblings.length === 0) return void 0;
|
|
3337
|
+
return { root, siblings };
|
|
3338
|
+
}
|
|
3339
|
+
function capturedElsewhere(workspace, absFile, kind, id) {
|
|
3340
|
+
if (!workspace || !absFile) return void 0;
|
|
3341
|
+
for (const app of workspace.siblings) {
|
|
3342
|
+
if (!app.registers(absFile)) continue;
|
|
3343
|
+
if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
|
|
3344
|
+
return app.name;
|
|
3345
|
+
}
|
|
2609
3346
|
}
|
|
2610
|
-
return
|
|
3347
|
+
return void 0;
|
|
2611
3348
|
}
|
|
2612
3349
|
|
|
2613
3350
|
// src/scanner/scan/state-coverage.ts
|
|
2614
3351
|
var STATE_KINDS2 = ["page", "feature", "widget"];
|
|
2615
|
-
function declaredStateEntities(registry) {
|
|
3352
|
+
function declaredStateEntities(registry, opts, ungated) {
|
|
2616
3353
|
const out2 = [];
|
|
2617
3354
|
for (const kind of STATE_KINDS2) {
|
|
2618
3355
|
for (const e of registry.list(kind)) {
|
|
2619
3356
|
const states = e.meta?.states;
|
|
2620
3357
|
if (!states || states.length === 0) continue;
|
|
3358
|
+
const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
|
|
3359
|
+
const ack = entityAcknowledge(
|
|
3360
|
+
e.meta,
|
|
3361
|
+
capturedElsewhere(opts.workspace, absFile, kind, e.id)
|
|
3362
|
+
);
|
|
3363
|
+
if (ack) {
|
|
3364
|
+
if (ack.origin === "workspace") {
|
|
3365
|
+
ungated.push({
|
|
3366
|
+
code: "acknowledged-elsewhere",
|
|
3367
|
+
severity: "info",
|
|
3368
|
+
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)})`,
|
|
3369
|
+
file: e.loc?.file,
|
|
3370
|
+
line: e.loc?.line,
|
|
3371
|
+
entity: { kind, id: e.id },
|
|
3372
|
+
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.`
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
2621
3377
|
out2.push({
|
|
2622
3378
|
kind,
|
|
2623
3379
|
id: e.id,
|
|
@@ -2628,18 +3384,31 @@ function declaredStateEntities(registry) {
|
|
|
2628
3384
|
}
|
|
2629
3385
|
return out2;
|
|
2630
3386
|
}
|
|
2631
|
-
function
|
|
3387
|
+
function unreliablePageKinds(registry, captured) {
|
|
3388
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3389
|
+
for (const c of captured) {
|
|
3390
|
+
if (c.kind !== "page") continue;
|
|
3391
|
+
if (registry.get("page", c.entity)) continue;
|
|
3392
|
+
out2.add(c.entity);
|
|
3393
|
+
}
|
|
3394
|
+
return out2;
|
|
3395
|
+
}
|
|
3396
|
+
function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
|
|
2632
3397
|
if (cap.entity !== ent.id) return false;
|
|
2633
|
-
|
|
3398
|
+
if (cap.kind === void 0) return true;
|
|
3399
|
+
if (cap.kind === ent.kind) return true;
|
|
3400
|
+
return cap.kind === "page" && unreliable.has(cap.entity);
|
|
2634
3401
|
}
|
|
2635
|
-
|
|
3402
|
+
var EMPTY = /* @__PURE__ */ new Set();
|
|
3403
|
+
function checkStateCoverage(registry, manifest, opts = {}) {
|
|
2636
3404
|
const diagnostics = [];
|
|
2637
|
-
const declared = declaredStateEntities(registry);
|
|
2638
3405
|
const captured = manifest.captured ?? [];
|
|
3406
|
+
const unreliable = unreliablePageKinds(registry, captured);
|
|
3407
|
+
const declared = declaredStateEntities(registry, opts, diagnostics);
|
|
2639
3408
|
for (const ent of declared) {
|
|
2640
3409
|
for (const state of ent.states) {
|
|
2641
3410
|
const hit = captured.some(
|
|
2642
|
-
(c) => captureMatchesEntity(c, ent) && c.state === state
|
|
3411
|
+
(c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
|
|
2643
3412
|
);
|
|
2644
3413
|
if (hit) continue;
|
|
2645
3414
|
diagnostics.push({
|
|
@@ -2654,7 +3423,9 @@ function checkStateCoverage(registry, manifest) {
|
|
|
2654
3423
|
}
|
|
2655
3424
|
}
|
|
2656
3425
|
for (const cap of captured) {
|
|
2657
|
-
const candidates = declared.filter(
|
|
3426
|
+
const candidates = declared.filter(
|
|
3427
|
+
(ent2) => captureMatchesEntity(cap, ent2, unreliable)
|
|
3428
|
+
);
|
|
2658
3429
|
if (candidates.length === 0) continue;
|
|
2659
3430
|
const declaredSomewhere = candidates.some(
|
|
2660
3431
|
(ent2) => ent2.states.includes(cap.state)
|
|
@@ -2698,36 +3469,35 @@ function coreKindsByRoute(registry, manifest) {
|
|
|
2698
3469
|
}
|
|
2699
3470
|
return byRoute;
|
|
2700
3471
|
}
|
|
2701
|
-
function pageWaivers(registry, routePage) {
|
|
2702
|
-
return registry.get("page", routePage)?.meta?.waivers ?? {};
|
|
2703
|
-
}
|
|
2704
|
-
function isOptedOut(registry, page) {
|
|
2705
|
-
return page ? registry.get("page", page)?.meta?.capture === false : false;
|
|
2706
|
-
}
|
|
2707
3472
|
function checkStateMatrix(registry, manifest, baseline) {
|
|
2708
3473
|
const routes = registry.list("route");
|
|
2709
3474
|
if (routes.length === 0) return [];
|
|
2710
3475
|
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
2711
3476
|
const captured = coreKindsByRoute(registry, manifest);
|
|
2712
|
-
const baselined = baseline?.missingKinds ?? {};
|
|
2713
3477
|
const diagnostics = [];
|
|
2714
3478
|
for (const [route, kinds] of captured) {
|
|
2715
3479
|
const page = pageOf.get(route);
|
|
2716
|
-
|
|
2717
|
-
const waivers = page ? pageWaivers(registry, page) : {};
|
|
3480
|
+
const ack = routeAcknowledge(registry, baseline, route, page);
|
|
2718
3481
|
const loc = page ? registry.get("page", page)?.loc : void 0;
|
|
2719
|
-
const
|
|
3482
|
+
const all = ack.get(ACK_ALL);
|
|
3483
|
+
if (all) {
|
|
3484
|
+
if (all.type === "impossible") {
|
|
3485
|
+
diagnostics.push(staleDiagnostic(all, route, page, loc));
|
|
3486
|
+
}
|
|
3487
|
+
continue;
|
|
3488
|
+
}
|
|
2720
3489
|
for (const kind of CORE_KINDS) {
|
|
2721
3490
|
if (kinds.has(kind)) continue;
|
|
2722
|
-
|
|
2723
|
-
if (
|
|
3491
|
+
const entry = ack.get(kind);
|
|
3492
|
+
if (entry?.type === "impossible") continue;
|
|
3493
|
+
if (entry?.type === "backlog") {
|
|
2724
3494
|
diagnostics.push({
|
|
2725
3495
|
code: "matrix-backlog",
|
|
2726
3496
|
severity: "info",
|
|
2727
|
-
message: `route "${route}" does not render core kind "${kind}" (acknowledged
|
|
3497
|
+
message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
|
|
2728
3498
|
file: loc?.file,
|
|
2729
3499
|
line: loc?.line,
|
|
2730
|
-
hint: `Capture the "${kind}" state for "${route}",
|
|
3500
|
+
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" }\`.`
|
|
2731
3501
|
});
|
|
2732
3502
|
continue;
|
|
2733
3503
|
}
|
|
@@ -2738,44 +3508,221 @@ function checkStateMatrix(registry, manifest, baseline) {
|
|
|
2738
3508
|
file: loc?.file,
|
|
2739
3509
|
line: loc?.line,
|
|
2740
3510
|
entity: { kind: "route", id: route },
|
|
2741
|
-
hint: `Capture the "${kind}" state
|
|
3511
|
+
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).`
|
|
2742
3512
|
});
|
|
2743
3513
|
}
|
|
3514
|
+
for (const [scope, entry] of ack) {
|
|
3515
|
+
if (scope === ACK_ALL || !kinds.has(scope)) continue;
|
|
3516
|
+
diagnostics.push(staleDiagnostic(entry, route, page, loc));
|
|
3517
|
+
}
|
|
2744
3518
|
}
|
|
3519
|
+
return diagnostics;
|
|
3520
|
+
}
|
|
3521
|
+
function staleDiagnostic(entry, route, page, loc) {
|
|
3522
|
+
const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
|
|
3523
|
+
return {
|
|
3524
|
+
code: "stale-acknowledgment",
|
|
3525
|
+
severity: "warning",
|
|
3526
|
+
message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
|
|
3527
|
+
file: loc?.file,
|
|
3528
|
+
line: loc?.line,
|
|
3529
|
+
...page ? { entity: { kind: "page", id: page } } : {},
|
|
3530
|
+
hint: originHint(entry, page)
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function computeMatrixBacklog(registry, manifest) {
|
|
3534
|
+
const routes = registry.list("route");
|
|
3535
|
+
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
3536
|
+
const captured = coreKindsByRoute(registry, manifest);
|
|
3537
|
+
const out2 = {};
|
|
2745
3538
|
for (const [route, kinds] of captured) {
|
|
2746
3539
|
const page = pageOf.get(route);
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
const
|
|
2750
|
-
for (const kind of
|
|
2751
|
-
if (kinds.has(kind))
|
|
3540
|
+
const ack = routeAcknowledge(registry, void 0, route, page);
|
|
3541
|
+
if (ack.has(ACK_ALL)) continue;
|
|
3542
|
+
const scopes = {};
|
|
3543
|
+
for (const kind of CORE_KINDS) {
|
|
3544
|
+
if (kinds.has(kind) || ack.has(kind)) continue;
|
|
3545
|
+
scopes[kind] = { type: "backlog" };
|
|
3546
|
+
}
|
|
3547
|
+
if (Object.keys(scopes).length > 0) out2[route] = scopes;
|
|
3548
|
+
}
|
|
3549
|
+
return out2;
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// src/scanner/scan/surface-check.ts
|
|
3553
|
+
var path6 = __toESM(require("path"), 1);
|
|
3554
|
+
var EXT_CANDIDATES = [
|
|
3555
|
+
"",
|
|
3556
|
+
".tsx",
|
|
3557
|
+
".ts",
|
|
3558
|
+
".jsx",
|
|
3559
|
+
".js",
|
|
3560
|
+
"/index.tsx",
|
|
3561
|
+
"/index.ts",
|
|
3562
|
+
"/index.jsx",
|
|
3563
|
+
"/index.js"
|
|
3564
|
+
];
|
|
3565
|
+
function checkFeatureSurfaces(registry, extracted, config) {
|
|
3566
|
+
const declarations = [];
|
|
3567
|
+
for (const ef of extracted) {
|
|
3568
|
+
for (const m of ef.metadata ?? []) {
|
|
3569
|
+
if (m.kind !== "feature") continue;
|
|
3570
|
+
if (typeof m.id !== "string") continue;
|
|
3571
|
+
if (!m.surfaces || m.surfaces.length === 0) continue;
|
|
3572
|
+
if (m.acknowledge?.["*"]) continue;
|
|
3573
|
+
declarations.push({
|
|
3574
|
+
ef,
|
|
3575
|
+
id: m.id,
|
|
3576
|
+
surfaces: m.surfaces,
|
|
3577
|
+
surfaceSpans: m.surfaceSpans,
|
|
3578
|
+
line: m.loc.line ?? 1
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
if (declarations.length === 0) return [];
|
|
3583
|
+
const fileSet = /* @__PURE__ */ new Set();
|
|
3584
|
+
const jsxFiles = /* @__PURE__ */ new Set();
|
|
3585
|
+
for (const ef of extracted) {
|
|
3586
|
+
fileSet.add(ef.file.displayPath);
|
|
3587
|
+
if (ef.hasJsx) jsxFiles.add(ef.file.displayPath);
|
|
3588
|
+
}
|
|
3589
|
+
const aliases = Object.entries(config.resolve?.aliases ?? {}).sort(
|
|
3590
|
+
(a, b) => b[0].length - a[0].length
|
|
3591
|
+
);
|
|
3592
|
+
const resolveSpecifier = (importer, spec) => {
|
|
3593
|
+
const bases = [];
|
|
3594
|
+
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
3595
|
+
bases.push(path6.posix.join(path6.posix.dirname(importer), spec));
|
|
3596
|
+
} else {
|
|
3597
|
+
const alias = aliases.find(([prefix]) => spec.startsWith(prefix));
|
|
3598
|
+
if (alias) {
|
|
3599
|
+
bases.push(alias[1] + spec.slice(alias[0].length));
|
|
3600
|
+
} else if (aliases.length === 0) {
|
|
3601
|
+
if (spec.startsWith("@/") || spec.startsWith("~/")) {
|
|
3602
|
+
bases.push("src/" + spec.slice(2));
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
bases.push(spec);
|
|
3606
|
+
}
|
|
3607
|
+
const hits = [];
|
|
3608
|
+
for (const base of bases) {
|
|
3609
|
+
const normalized = path6.posix.normalize(base);
|
|
3610
|
+
for (const ext of EXT_CANDIDATES) {
|
|
3611
|
+
const candidate = normalized + ext;
|
|
3612
|
+
if (fileSet.has(candidate)) hits.push(candidate);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
return hits;
|
|
3616
|
+
};
|
|
3617
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
3618
|
+
for (const ef of extracted) {
|
|
3619
|
+
const targets = [];
|
|
3620
|
+
for (const imp of ef.imports ?? []) {
|
|
3621
|
+
if (imp.isTypeOnly) continue;
|
|
3622
|
+
targets.push(...resolveSpecifier(ef.file.displayPath, imp.specifier));
|
|
3623
|
+
}
|
|
3624
|
+
if (targets.length > 0) adjacency.set(ef.file.displayPath, targets);
|
|
3625
|
+
}
|
|
3626
|
+
const inDir = (file, dir) => file === dir || file.startsWith(dir + "/");
|
|
3627
|
+
const reachesDir = (seeds, dir) => {
|
|
3628
|
+
const queue = [...seeds];
|
|
3629
|
+
const visited = new Set(queue);
|
|
3630
|
+
while (queue.length > 0) {
|
|
3631
|
+
const current = queue.pop();
|
|
3632
|
+
if (inDir(current, dir) && jsxFiles.has(current)) return true;
|
|
3633
|
+
for (const next of adjacency.get(current) ?? []) {
|
|
3634
|
+
if (visited.has(next)) continue;
|
|
3635
|
+
visited.add(next);
|
|
3636
|
+
queue.push(next);
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
return false;
|
|
3640
|
+
};
|
|
3641
|
+
const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
|
|
3642
|
+
const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
|
|
3643
|
+
const diagnostics = [];
|
|
3644
|
+
for (const decl of declarations) {
|
|
3645
|
+
const declPath = decl.ef.file.displayPath;
|
|
3646
|
+
const featureDir = (featureGlob !== null ? extractFeatureDir(declPath, featureGlob) : null) ?? path6.posix.dirname(declPath);
|
|
3647
|
+
for (let i = 0; i < decl.surfaces.length; i++) {
|
|
3648
|
+
const pageId = decl.surfaces[i];
|
|
3649
|
+
const line = decl.surfaceSpans?.[i] ? lineOfOffset(decl.ef.file.content, decl.surfaceSpans[i].start) : decl.line;
|
|
3650
|
+
const page = registry.get("page", pageId) ?? registry.matchPattern("page", pageId);
|
|
3651
|
+
if (!page) {
|
|
2752
3652
|
diagnostics.push({
|
|
2753
|
-
code: "
|
|
3653
|
+
code: "unknown-reference",
|
|
2754
3654
|
severity: "warning",
|
|
2755
|
-
message: `
|
|
2756
|
-
file:
|
|
2757
|
-
line
|
|
2758
|
-
entity: { kind: "
|
|
2759
|
-
hint: `
|
|
3655
|
+
message: `Feature "${decl.id}" declares a surface on unknown page "${pageId}"`,
|
|
3656
|
+
file: decl.ef.file.displayPath,
|
|
3657
|
+
line,
|
|
3658
|
+
entity: { kind: "feature", id: decl.id },
|
|
3659
|
+
hint: `No page with id "${pageId}" exists in the registry; fix the reference or add the page.`
|
|
2760
3660
|
});
|
|
3661
|
+
continue;
|
|
2761
3662
|
}
|
|
3663
|
+
const pageFile = page.loc?.file;
|
|
3664
|
+
if (!pageFile) continue;
|
|
3665
|
+
const seeds = [pageFile];
|
|
3666
|
+
if (path6.posix.basename(pageFile) === WELL_KNOWN_FILES.page) {
|
|
3667
|
+
const dir = path6.posix.dirname(pageFile);
|
|
3668
|
+
for (const f of fileSet) {
|
|
3669
|
+
if (f !== pageFile && path6.posix.dirname(f) === dir) seeds.push(f);
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
if (reachesDir(seeds, featureDir)) continue;
|
|
3673
|
+
diagnostics.push({
|
|
3674
|
+
code: "feature-surface-unreferenced",
|
|
3675
|
+
severity: "warning",
|
|
3676
|
+
message: `Feature "${decl.id}" declares page "${pageId}" as a surface, but nothing reachable from ${pageFile} references a component file in ${featureDir}/`,
|
|
3677
|
+
file: decl.ef.file.displayPath,
|
|
3678
|
+
line,
|
|
3679
|
+
entity: { kind: "feature", id: decl.id },
|
|
3680
|
+
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\`.`
|
|
3681
|
+
});
|
|
2762
3682
|
}
|
|
2763
3683
|
}
|
|
2764
3684
|
return diagnostics;
|
|
2765
3685
|
}
|
|
2766
|
-
function
|
|
3686
|
+
function checkFeatureSurfaceCaptures(registry, manifest) {
|
|
2767
3687
|
const routes = registry.list("route");
|
|
2768
|
-
|
|
2769
|
-
const
|
|
2770
|
-
const
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
3688
|
+
if (routes.length === 0) return [];
|
|
3689
|
+
const routePaths = routes.map((r) => r.path);
|
|
3690
|
+
const captured = manifest.captured ?? [];
|
|
3691
|
+
const diagnostics = [];
|
|
3692
|
+
for (const feature of registry.list("feature")) {
|
|
3693
|
+
const surfaces = feature.meta?.surfaces ?? [];
|
|
3694
|
+
if (surfaces.length === 0) continue;
|
|
3695
|
+
if (entityAcknowledge(feature.meta)) continue;
|
|
3696
|
+
const coveredRoutes = /* @__PURE__ */ new Set();
|
|
3697
|
+
for (const c of captured) {
|
|
3698
|
+
if (c.kind !== "feature" || c.entity !== feature.id) continue;
|
|
3699
|
+
const route = routeForCapture(c, routes, routePaths);
|
|
3700
|
+
if (route) coveredRoutes.add(route);
|
|
3701
|
+
}
|
|
3702
|
+
for (const pageId of surfaces) {
|
|
3703
|
+
const pageRoutes = routes.filter((r) => r.page === pageId);
|
|
3704
|
+
if (pageRoutes.length === 0) continue;
|
|
3705
|
+
if (pageRoutes.some((r) => coveredRoutes.has(r.path))) continue;
|
|
3706
|
+
const routeList = pageRoutes.map((r) => `"${r.path}"`).join(", ");
|
|
3707
|
+
diagnostics.push({
|
|
3708
|
+
code: "feature-surface-uncaptured",
|
|
3709
|
+
severity: "warning",
|
|
3710
|
+
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})`}`,
|
|
3711
|
+
file: feature.loc?.file,
|
|
3712
|
+
line: feature.loc?.line,
|
|
3713
|
+
entity: { kind: "feature", id: feature.id },
|
|
3714
|
+
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 }\`.`
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
2777
3717
|
}
|
|
2778
|
-
return
|
|
3718
|
+
return diagnostics;
|
|
3719
|
+
}
|
|
3720
|
+
function lineOfOffset(content, offset) {
|
|
3721
|
+
let line = 1;
|
|
3722
|
+
for (let i = 0; i < offset && i < content.length; i++) {
|
|
3723
|
+
if (content[i] === "\n") line++;
|
|
3724
|
+
}
|
|
3725
|
+
return line;
|
|
2779
3726
|
}
|
|
2780
3727
|
|
|
2781
3728
|
// src/scanner/scan/audit.ts
|
|
@@ -2785,6 +3732,8 @@ function audit(opts) {
|
|
|
2785
3732
|
const check = opts.check ?? false;
|
|
2786
3733
|
const lint = opts.lint ?? false;
|
|
2787
3734
|
const acceptanceEnabled = config.audit?.acceptance ?? true;
|
|
3735
|
+
const surfacesEnabled = config.audit?.surfaces ?? true;
|
|
3736
|
+
const requireAcceptanceEnabled = config.audit?.requireAcceptance ?? true;
|
|
2788
3737
|
const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
|
|
2789
3738
|
const coverageEnabled = config.audit?.coverage ?? true;
|
|
2790
3739
|
const statesEnabled = config.audit?.states ?? true;
|
|
@@ -2797,6 +3746,21 @@ function audit(opts) {
|
|
|
2797
3746
|
for (const ef of opts.flowExtracted ?? []) {
|
|
2798
3747
|
if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
|
|
2799
3748
|
}
|
|
3749
|
+
for (const ef of extracted) {
|
|
3750
|
+
for (const m of ef.metadata ?? []) {
|
|
3751
|
+
for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
|
|
3752
|
+
if (!isPlaceholderReason(ack.reason)) continue;
|
|
3753
|
+
diagnostics.push({
|
|
3754
|
+
code: "placeholder-reason",
|
|
3755
|
+
severity: "warning",
|
|
3756
|
+
message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
|
|
3757
|
+
file: ef.file.displayPath,
|
|
3758
|
+
line: m.loc.line,
|
|
3759
|
+
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).`
|
|
3760
|
+
});
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
2800
3764
|
if (check && opts.generated !== void 0) {
|
|
2801
3765
|
const outRel = opts.outputPath ?? config.output;
|
|
2802
3766
|
const fresh = normalizeForCheck(opts.generated);
|
|
@@ -2866,9 +3830,66 @@ function audit(opts) {
|
|
|
2866
3830
|
}
|
|
2867
3831
|
}
|
|
2868
3832
|
if (lint && acceptanceEnabled) {
|
|
2869
|
-
|
|
3833
|
+
const flowEntities = registry.list("flow");
|
|
3834
|
+
const acceptanceKinds = ["widget", "feature", "page"];
|
|
3835
|
+
const claims = /* @__PURE__ */ new Map();
|
|
3836
|
+
const addClaim = (kind, entityId, criterion, claim) => {
|
|
3837
|
+
const key = `${kind}:${entityId}`;
|
|
3838
|
+
let byCrit = claims.get(key);
|
|
3839
|
+
if (!byCrit) {
|
|
3840
|
+
byCrit = /* @__PURE__ */ new Map();
|
|
3841
|
+
claims.set(key, byCrit);
|
|
3842
|
+
}
|
|
3843
|
+
let list = byCrit.get(criterion);
|
|
3844
|
+
if (!list) {
|
|
3845
|
+
list = [];
|
|
3846
|
+
byCrit.set(criterion, list);
|
|
3847
|
+
}
|
|
3848
|
+
list.push(claim);
|
|
3849
|
+
};
|
|
3850
|
+
for (const flow of flowEntities) {
|
|
3851
|
+
for (const c of flow.covers ?? []) {
|
|
3852
|
+
const candidates = acceptanceKinds.flatMap((k) => {
|
|
3853
|
+
const e = registry.get(k, c.entity);
|
|
3854
|
+
return e ? [e] : [];
|
|
3855
|
+
});
|
|
3856
|
+
const matching = candidates.filter(
|
|
3857
|
+
(e) => entityCriteria(e).some((crit) => crit.id === c.criterion)
|
|
3858
|
+
);
|
|
3859
|
+
if (matching.length === 0) {
|
|
3860
|
+
diagnostics.push({
|
|
3861
|
+
code: "acceptance-orphaned",
|
|
3862
|
+
severity: "warning",
|
|
3863
|
+
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`}`,
|
|
3864
|
+
file: flow.loc.file,
|
|
3865
|
+
line: flow.loc.line,
|
|
3866
|
+
entity: { kind: "flow", id: flow.id },
|
|
3867
|
+
hint: "Remove the stale uidexCovers claim, or fix the entity/criterion id it names."
|
|
3868
|
+
});
|
|
3869
|
+
continue;
|
|
3870
|
+
}
|
|
3871
|
+
if (matching.length > 1) {
|
|
3872
|
+
diagnostics.push({
|
|
3873
|
+
code: "acceptance-ambiguous",
|
|
3874
|
+
severity: "warning",
|
|
3875
|
+
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`,
|
|
3876
|
+
file: flow.loc.file,
|
|
3877
|
+
line: flow.loc.line,
|
|
3878
|
+
entity: { kind: "flow", id: flow.id },
|
|
3879
|
+
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."
|
|
3880
|
+
});
|
|
3881
|
+
}
|
|
3882
|
+
for (const target of matching) {
|
|
3883
|
+
addClaim(target.kind, target.id, c.criterion, {
|
|
3884
|
+
flowId: flow.id,
|
|
3885
|
+
rev: c.rev
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
for (const kind of acceptanceKinds) {
|
|
2870
3891
|
for (const e of registry.list(kind)) {
|
|
2871
|
-
const criteria = e
|
|
3892
|
+
const criteria = entityCriteria(e);
|
|
2872
3893
|
if (criteria.length === 0) {
|
|
2873
3894
|
if (kind === "widget") {
|
|
2874
3895
|
diagnostics.push({
|
|
@@ -2883,14 +3904,27 @@ function audit(opts) {
|
|
|
2883
3904
|
}
|
|
2884
3905
|
continue;
|
|
2885
3906
|
}
|
|
2886
|
-
const
|
|
2887
|
-
|
|
2888
|
-
const
|
|
2889
|
-
|
|
3907
|
+
const entityClaims = claims.get(`${kind}:${e.id}`);
|
|
3908
|
+
for (const c of criteria) {
|
|
3909
|
+
const critClaims = entityClaims?.get(c.id) ?? [];
|
|
3910
|
+
const stale = critClaims.filter((cl) => cl.rev !== c.rev);
|
|
3911
|
+
for (const cl of stale) {
|
|
3912
|
+
diagnostics.push({
|
|
3913
|
+
code: "acceptance-outdated",
|
|
3914
|
+
severity: "warning",
|
|
3915
|
+
message: `Flow "${cl.flowId}" covers ${kind} "${e.id}" criterion "${c.id}" at rev ${cl.rev}, but the criterion is now rev ${c.rev}`,
|
|
3916
|
+
file: e.loc?.file,
|
|
3917
|
+
line: e.loc?.line,
|
|
3918
|
+
entity: { kind, id: e.id },
|
|
3919
|
+
hint: `Re-verify the flow against the current criterion text, then update its claim to \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`.`
|
|
3920
|
+
});
|
|
3921
|
+
}
|
|
3922
|
+
if (critClaims.length === 0) {
|
|
3923
|
+
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}")\``;
|
|
2890
3924
|
diagnostics.push({
|
|
2891
3925
|
code: "acceptance-uncovered",
|
|
2892
3926
|
severity: "warning",
|
|
2893
|
-
message: `${kind} "${e.id}" has acceptance criterion not covered by any flow: "${c}"`,
|
|
3927
|
+
message: `${kind} "${e.id}" has acceptance criterion "${c.id}" not covered by any flow: "${c.text}"`,
|
|
2894
3928
|
file: e.loc?.file,
|
|
2895
3929
|
line: e.loc?.line,
|
|
2896
3930
|
entity: { kind, id: e.id },
|
|
@@ -2901,7 +3935,31 @@ function audit(opts) {
|
|
|
2901
3935
|
}
|
|
2902
3936
|
}
|
|
2903
3937
|
}
|
|
3938
|
+
if (lint && surfacesEnabled) {
|
|
3939
|
+
diagnostics.push(...checkFeatureSurfaces(registry, extracted, config));
|
|
3940
|
+
}
|
|
3941
|
+
if (lint && surfacesEnabled && opts.capturedStates) {
|
|
3942
|
+
diagnostics.push(
|
|
3943
|
+
...checkFeatureSurfaceCaptures(registry, opts.capturedStates)
|
|
3944
|
+
);
|
|
3945
|
+
}
|
|
3946
|
+
if (lint && requireAcceptanceEnabled) {
|
|
3947
|
+
for (const e of registry.list("feature")) {
|
|
3948
|
+
if (entityCriteria(e).length > 0) continue;
|
|
3949
|
+
diagnostics.push({
|
|
3950
|
+
code: "feature-missing-acceptance",
|
|
3951
|
+
severity: "warning",
|
|
3952
|
+
message: `Feature "${e.id}" declares no acceptance criteria`,
|
|
3953
|
+
file: e.loc?.file,
|
|
3954
|
+
line: e.loc?.line,
|
|
3955
|
+
entity: { kind: "feature", id: e.id },
|
|
3956
|
+
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.`
|
|
3957
|
+
});
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
2904
3960
|
if (lint) {
|
|
3961
|
+
const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
|
|
3962
|
+
const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
|
|
2905
3963
|
const scannedPaths = new Set(files.map((f) => f.displayPath));
|
|
2906
3964
|
for (const ef of extracted) {
|
|
2907
3965
|
if (!ef.metadata) continue;
|
|
@@ -2910,10 +3968,24 @@ function audit(opts) {
|
|
|
2910
3968
|
if (typeof m.id !== "string") continue;
|
|
2911
3969
|
const filePath = ef.file.displayPath;
|
|
2912
3970
|
const wellKnownName = WELL_KNOWN_FILES[m.kind];
|
|
2913
|
-
if (
|
|
2914
|
-
const
|
|
3971
|
+
if (path7.posix.basename(filePath) === wellKnownName) continue;
|
|
3972
|
+
const featureDir = m.kind === "feature" && featureGlob !== null ? extractFeatureDir(filePath, featureGlob) : null;
|
|
3973
|
+
const dir = featureDir ?? path7.posix.dirname(filePath);
|
|
2915
3974
|
const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
|
|
2916
3975
|
if (scannedPaths.has(wellKnownPath)) continue;
|
|
3976
|
+
const inConventionDir = featureDir !== null;
|
|
3977
|
+
if (inConventionDir) {
|
|
3978
|
+
diagnostics.push({
|
|
3979
|
+
code: "prefer-well-known-file",
|
|
3980
|
+
severity: "error",
|
|
3981
|
+
message: `Feature "${m.id}" metadata lives on ${filePath}; a feature in a convention-derived directory must declare its metadata in ${wellKnownPath}`,
|
|
3982
|
+
file: filePath,
|
|
3983
|
+
line: m.loc.line,
|
|
3984
|
+
entity: { kind: "feature", id: m.id },
|
|
3985
|
+
hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
|
|
3986
|
+
});
|
|
3987
|
+
continue;
|
|
3988
|
+
}
|
|
2917
3989
|
const kindLabel = m.kind === "page" ? "Page" : "Feature";
|
|
2918
3990
|
diagnostics.push({
|
|
2919
3991
|
code: "prefer-well-known-file",
|
|
@@ -2997,6 +4069,7 @@ function audit(opts) {
|
|
|
2997
4069
|
const displayPath = ef.file.displayPath;
|
|
2998
4070
|
for (const imp of ef.imports ?? []) {
|
|
2999
4071
|
if (imp.isTypeOnly) continue;
|
|
4072
|
+
if (imp.dynamic) continue;
|
|
3000
4073
|
const baseName2 = imp.specifier.split("/").pop() ?? "";
|
|
3001
4074
|
const primitive = byName.get(
|
|
3002
4075
|
baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
|
|
@@ -3114,7 +4187,7 @@ function audit(opts) {
|
|
|
3114
4187
|
severity: "warning",
|
|
3115
4188
|
message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
|
|
3116
4189
|
file: ef.file.displayPath,
|
|
3117
|
-
line: spans?.[i] ?
|
|
4190
|
+
line: spans?.[i] ? lineOfOffset2(ef.file.content, spans[i].start) : m.loc.line,
|
|
3118
4191
|
hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
|
|
3119
4192
|
});
|
|
3120
4193
|
}
|
|
@@ -3125,33 +4198,57 @@ function audit(opts) {
|
|
|
3125
4198
|
}
|
|
3126
4199
|
}
|
|
3127
4200
|
if (lint && statesEnabled && opts.capturedStates) {
|
|
3128
|
-
|
|
4201
|
+
const sourceByDisplay = new Map(
|
|
4202
|
+
files.map((f) => [f.displayPath, f.sourcePath])
|
|
4203
|
+
);
|
|
4204
|
+
diagnostics.push(
|
|
4205
|
+
...checkStateCoverage(registry, opts.capturedStates, {
|
|
4206
|
+
workspace: opts.workspace,
|
|
4207
|
+
resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
|
|
4208
|
+
})
|
|
4209
|
+
);
|
|
3129
4210
|
}
|
|
3130
4211
|
if (lint && pageCoverageEnabled && opts.capturedStates) {
|
|
3131
4212
|
diagnostics.push(
|
|
3132
|
-
...checkPageCoverage(registry, opts.capturedStates, opts.
|
|
4213
|
+
...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
|
|
3133
4214
|
);
|
|
3134
4215
|
}
|
|
4216
|
+
if (lint && statesEnabled && opts.capturedStates) {
|
|
4217
|
+
diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
|
|
4218
|
+
}
|
|
3135
4219
|
if (lint && stateMatrixEnabled && opts.capturedStates) {
|
|
3136
4220
|
diagnostics.push(
|
|
3137
|
-
...checkStateMatrix(registry, opts.capturedStates,
|
|
3138
|
-
missingKinds: opts.pageBaseline?.missingKinds ?? {}
|
|
3139
|
-
})
|
|
4221
|
+
...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
|
|
3140
4222
|
);
|
|
3141
4223
|
}
|
|
4224
|
+
const overrides = config.audit?.severity ?? {};
|
|
4225
|
+
const governed = diagnostics.flatMap((d) => {
|
|
4226
|
+
const level = overrides[d.code];
|
|
4227
|
+
if (level === void 0) return [d];
|
|
4228
|
+
if (level === "off") return [];
|
|
4229
|
+
return [{ ...d, severity: level }];
|
|
4230
|
+
});
|
|
3142
4231
|
const summary = {
|
|
3143
|
-
errors:
|
|
3144
|
-
warnings:
|
|
4232
|
+
errors: governed.filter((d) => d.severity === "error").length,
|
|
4233
|
+
warnings: governed.filter((d) => d.severity === "warning").length
|
|
3145
4234
|
};
|
|
3146
|
-
return { diagnostics, summary };
|
|
4235
|
+
return { diagnostics: governed, summary };
|
|
3147
4236
|
}
|
|
3148
|
-
function
|
|
4237
|
+
function lineOfOffset2(content, offset) {
|
|
3149
4238
|
let line = 1;
|
|
3150
4239
|
for (let i = 0; i < offset && i < content.length; i++) {
|
|
3151
4240
|
if (content[i] === "\n") line++;
|
|
3152
4241
|
}
|
|
3153
4242
|
return line;
|
|
3154
4243
|
}
|
|
4244
|
+
function entityCriteria(e) {
|
|
4245
|
+
const meta = e.meta;
|
|
4246
|
+
if (!meta) return [];
|
|
4247
|
+
return [
|
|
4248
|
+
...meta.acceptance ?? [],
|
|
4249
|
+
...(meta.states ?? []).flatMap((s) => s.acceptance ?? [])
|
|
4250
|
+
];
|
|
4251
|
+
}
|
|
3155
4252
|
var TAG_FALLBACK_ID = {
|
|
3156
4253
|
a: "link",
|
|
3157
4254
|
button: "button",
|
|
@@ -3471,6 +4568,30 @@ ${body}
|
|
|
3471
4568
|
`;
|
|
3472
4569
|
}
|
|
3473
4570
|
var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
|
|
4571
|
+
function stateEntities(list) {
|
|
4572
|
+
return list.map((e) => ({
|
|
4573
|
+
id: e.id,
|
|
4574
|
+
states: (e.meta?.states ?? []).map((s) => s.name)
|
|
4575
|
+
}));
|
|
4576
|
+
}
|
|
4577
|
+
function emitStateMap(groups) {
|
|
4578
|
+
const lines = ["export interface UidexStates {"];
|
|
4579
|
+
for (const g of groups) {
|
|
4580
|
+
const withStates = g.entities.filter((e) => e.states.length > 0);
|
|
4581
|
+
if (withStates.length === 0) {
|
|
4582
|
+
lines.push(` ${g.kind}: never`);
|
|
4583
|
+
continue;
|
|
4584
|
+
}
|
|
4585
|
+
lines.push(` ${g.kind}: {`);
|
|
4586
|
+
for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
4587
|
+
const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
|
|
4588
|
+
lines.push(` ${JSON.stringify(e.id)}: ${states}`);
|
|
4589
|
+
}
|
|
4590
|
+
lines.push(" }");
|
|
4591
|
+
}
|
|
4592
|
+
lines.push("}");
|
|
4593
|
+
return lines.join("\n") + "\n";
|
|
4594
|
+
}
|
|
3474
4595
|
function emitId(name, ids) {
|
|
3475
4596
|
if (OPAQUE_ID_UNIONS.has(name)) {
|
|
3476
4597
|
return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
|
|
@@ -3558,36 +4679,64 @@ function emit(opts) {
|
|
|
3558
4679
|
);
|
|
3559
4680
|
t.push(emitId("StateId", [...stateNames]));
|
|
3560
4681
|
t.push("");
|
|
4682
|
+
t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
|
|
4683
|
+
t.push(
|
|
4684
|
+
emitStateMap([
|
|
4685
|
+
{ kind: "page", entities: stateEntities(pages) },
|
|
4686
|
+
{ kind: "feature", entities: stateEntities(features) },
|
|
4687
|
+
{ kind: "widget", entities: stateEntities(widgets) }
|
|
4688
|
+
])
|
|
4689
|
+
);
|
|
3561
4690
|
t.push("// ---- authoring-surface shape types ----");
|
|
3562
4691
|
t.push("export namespace Uidex {");
|
|
3563
4692
|
t.push(
|
|
3564
4693
|
' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
|
|
3565
4694
|
);
|
|
3566
4695
|
t.push(' export type StateKind = CoreStateKind | "variant"');
|
|
4696
|
+
t.push(" export interface AcceptanceCriterion {");
|
|
4697
|
+
t.push(" id: string");
|
|
4698
|
+
t.push(" rev?: number");
|
|
4699
|
+
t.push(" text: string");
|
|
4700
|
+
t.push(" }");
|
|
4701
|
+
t.push(" export type Acceptance = readonly (string | AcceptanceCriterion)[]");
|
|
3567
4702
|
t.push(" export interface State {");
|
|
3568
4703
|
t.push(" name: string");
|
|
3569
4704
|
t.push(" kind?: StateKind");
|
|
3570
|
-
t.push(" acceptance?:
|
|
4705
|
+
t.push(" acceptance?: Acceptance");
|
|
3571
4706
|
t.push(" description?: string");
|
|
3572
4707
|
t.push(" }");
|
|
3573
|
-
t.push(
|
|
4708
|
+
t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
|
|
4709
|
+
t.push(" export interface Impossible {");
|
|
4710
|
+
t.push(' type: "impossible"');
|
|
4711
|
+
t.push(" reason: string");
|
|
4712
|
+
t.push(" }");
|
|
4713
|
+
t.push(" export interface Backlog {");
|
|
4714
|
+
t.push(' type: "backlog"');
|
|
4715
|
+
t.push(" reason?: string");
|
|
4716
|
+
t.push(" }");
|
|
4717
|
+
t.push(" export type Acknowledgment = Impossible | Backlog");
|
|
4718
|
+
t.push(
|
|
4719
|
+
" export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
|
|
4720
|
+
);
|
|
4721
|
+
t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
|
|
3574
4722
|
t.push(" export interface Page {");
|
|
3575
4723
|
t.push(" page: PageId | false");
|
|
3576
4724
|
t.push(" name?: string");
|
|
3577
4725
|
t.push(" features?: readonly FeatureId[]");
|
|
3578
4726
|
t.push(" widgets?: readonly WidgetId[]");
|
|
3579
|
-
t.push(" acceptance?:
|
|
4727
|
+
t.push(" acceptance?: Acceptance");
|
|
3580
4728
|
t.push(" states?: readonly (string | State)[]");
|
|
3581
|
-
t.push("
|
|
3582
|
-
t.push(" waivers?: Waivers");
|
|
4729
|
+
t.push(" acknowledge?: Acknowledge");
|
|
3583
4730
|
t.push(" description?: string");
|
|
3584
4731
|
t.push(" }");
|
|
3585
4732
|
t.push(" export interface Feature {");
|
|
3586
4733
|
t.push(" feature: FeatureId | false");
|
|
3587
4734
|
t.push(" name?: string");
|
|
3588
4735
|
t.push(" features?: readonly FeatureId[]");
|
|
3589
|
-
t.push("
|
|
4736
|
+
t.push(" surfaces?: readonly PageId[]");
|
|
4737
|
+
t.push(" acceptance?: Acceptance");
|
|
3590
4738
|
t.push(" states?: readonly (string | State)[]");
|
|
4739
|
+
t.push(" acknowledge?: ComponentAcknowledge");
|
|
3591
4740
|
t.push(" description?: string");
|
|
3592
4741
|
t.push(" }");
|
|
3593
4742
|
t.push(" export interface Primitive {");
|
|
@@ -3598,8 +4747,9 @@ function emit(opts) {
|
|
|
3598
4747
|
t.push(" export interface Widget {");
|
|
3599
4748
|
t.push(" widget: WidgetId");
|
|
3600
4749
|
t.push(" name?: string");
|
|
3601
|
-
t.push(" acceptance?:
|
|
4750
|
+
t.push(" acceptance?: Acceptance");
|
|
3602
4751
|
t.push(" states?: readonly (string | State)[]");
|
|
4752
|
+
t.push(" acknowledge?: ComponentAcknowledge");
|
|
3603
4753
|
t.push(" description?: string");
|
|
3604
4754
|
t.push(" }");
|
|
3605
4755
|
t.push(" export interface Region {");
|
|
@@ -3695,8 +4845,8 @@ function parseGitHubRef(ref) {
|
|
|
3695
4845
|
}
|
|
3696
4846
|
|
|
3697
4847
|
// src/scanner/scan/scaffold.ts
|
|
3698
|
-
var
|
|
3699
|
-
var
|
|
4848
|
+
var fs4 = __toESM(require("fs"), 1);
|
|
4849
|
+
var path8 = __toESM(require("path"), 1);
|
|
3700
4850
|
function scaffoldWidgetSpec(opts) {
|
|
3701
4851
|
return scaffoldSpec({
|
|
3702
4852
|
registry: opts.registry,
|
|
@@ -3720,10 +4870,10 @@ function scaffoldSpec(opts) {
|
|
|
3720
4870
|
if (!entity) {
|
|
3721
4871
|
throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
|
|
3722
4872
|
}
|
|
3723
|
-
const criteria = entity
|
|
4873
|
+
const criteria = entityCriteria(entity);
|
|
3724
4874
|
const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
|
|
3725
|
-
const outputPath =
|
|
3726
|
-
if (
|
|
4875
|
+
const outputPath = path8.resolve(outDir, filename);
|
|
4876
|
+
if (fs4.existsSync(outputPath) && !force) {
|
|
3727
4877
|
return {
|
|
3728
4878
|
outputPath,
|
|
3729
4879
|
written: false,
|
|
@@ -3731,9 +4881,9 @@ function scaffoldSpec(opts) {
|
|
|
3731
4881
|
reason: `spec already exists at ${outputPath}; pass --force to overwrite`
|
|
3732
4882
|
};
|
|
3733
4883
|
}
|
|
3734
|
-
const content = renderSpec({ id, criteria, fixtureImport });
|
|
3735
|
-
|
|
3736
|
-
|
|
4884
|
+
const content = renderSpec({ id, entityId: id, criteria, fixtureImport });
|
|
4885
|
+
fs4.mkdirSync(path8.dirname(outputPath), { recursive: true });
|
|
4886
|
+
fs4.writeFileSync(outputPath, content, "utf8");
|
|
3737
4887
|
return { outputPath, written: true, skipped: false };
|
|
3738
4888
|
}
|
|
3739
4889
|
function capitalize(s) {
|
|
@@ -3741,25 +4891,28 @@ function capitalize(s) {
|
|
|
3741
4891
|
}
|
|
3742
4892
|
function renderSpec(args) {
|
|
3743
4893
|
const lines = [];
|
|
3744
|
-
lines.push(
|
|
3745
|
-
`import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
|
|
3746
|
-
);
|
|
4894
|
+
lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
|
|
3747
4895
|
lines.push("");
|
|
3748
4896
|
lines.push(
|
|
3749
4897
|
`test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
|
|
3750
4898
|
);
|
|
3751
|
-
|
|
3752
|
-
lines.push(` test(
|
|
3753
|
-
lines.push(`
|
|
4899
|
+
const stub = (title, body) => {
|
|
4900
|
+
lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
|
|
4901
|
+
for (const l of body) lines.push(` ${l}`);
|
|
4902
|
+
lines.push(` void uidex`);
|
|
3754
4903
|
lines.push(` })`);
|
|
4904
|
+
lines.push("");
|
|
4905
|
+
};
|
|
4906
|
+
if (args.criteria.length === 0) {
|
|
4907
|
+
stub("TODO: add acceptance criteria", [
|
|
4908
|
+
"// Declare `acceptance: [...]` on the entity, then re-run scaffold."
|
|
4909
|
+
]);
|
|
3755
4910
|
} else {
|
|
3756
4911
|
for (const criterion of args.criteria) {
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
lines.push(` })`);
|
|
3762
|
-
lines.push("");
|
|
4912
|
+
stub(`${criterion.id}: ${criterion.text}`, [
|
|
4913
|
+
"// TODO: drive this criterion, then remove `.fixme` to arm the test",
|
|
4914
|
+
`// and claim it: uidexCovers(${JSON.stringify(args.entityId)}, ${JSON.stringify(`${criterion.id}@${criterion.rev}`)})`
|
|
4915
|
+
]);
|
|
3763
4916
|
}
|
|
3764
4917
|
}
|
|
3765
4918
|
lines.push("})");
|
|
@@ -3768,47 +4921,33 @@ function renderSpec(args) {
|
|
|
3768
4921
|
}
|
|
3769
4922
|
|
|
3770
4923
|
// src/scanner/scan/pipeline.ts
|
|
3771
|
-
var
|
|
3772
|
-
var
|
|
3773
|
-
var
|
|
4924
|
+
var fs5 = __toESM(require("fs"), 1);
|
|
4925
|
+
var path9 = __toESM(require("path"), 1);
|
|
4926
|
+
var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
|
|
3774
4927
|
var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
|
|
3775
|
-
function
|
|
4928
|
+
function loadCoverageBaseline(configDir, config) {
|
|
3776
4929
|
const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3777
|
-
const abs =
|
|
4930
|
+
const abs = path9.resolve(configDir, rel);
|
|
3778
4931
|
let raw;
|
|
3779
4932
|
try {
|
|
3780
|
-
raw =
|
|
4933
|
+
raw = fs5.readFileSync(abs, "utf8");
|
|
3781
4934
|
} catch {
|
|
3782
4935
|
return void 0;
|
|
3783
4936
|
}
|
|
4937
|
+
let parsed;
|
|
3784
4938
|
try {
|
|
3785
|
-
|
|
3786
|
-
if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
|
|
3787
|
-
const uncapturedPages = parsed.uncapturedPages.filter(
|
|
3788
|
-
(p2) => typeof p2 === "string"
|
|
3789
|
-
);
|
|
3790
|
-
let missingKinds;
|
|
3791
|
-
const mkRaw = parsed.missingKinds;
|
|
3792
|
-
if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
|
|
3793
|
-
const mk = {};
|
|
3794
|
-
for (const [route, kinds] of Object.entries(mkRaw)) {
|
|
3795
|
-
if (Array.isArray(kinds)) {
|
|
3796
|
-
mk[route] = kinds.filter((k) => typeof k === "string");
|
|
3797
|
-
}
|
|
3798
|
-
}
|
|
3799
|
-
missingKinds = mk;
|
|
3800
|
-
}
|
|
3801
|
-
return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
|
|
4939
|
+
parsed = JSON.parse(raw);
|
|
3802
4940
|
} catch {
|
|
3803
4941
|
return void 0;
|
|
3804
4942
|
}
|
|
4943
|
+
return parseCoverageBaseline(parsed) ?? void 0;
|
|
3805
4944
|
}
|
|
3806
4945
|
function loadCapturedStates(configDir, config) {
|
|
3807
|
-
const rel = config.statesManifest ??
|
|
3808
|
-
const abs =
|
|
4946
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
|
|
4947
|
+
const abs = path9.resolve(configDir, rel);
|
|
3809
4948
|
let raw;
|
|
3810
4949
|
try {
|
|
3811
|
-
raw =
|
|
4950
|
+
raw = fs5.readFileSync(abs, "utf8");
|
|
3812
4951
|
} catch {
|
|
3813
4952
|
return void 0;
|
|
3814
4953
|
}
|
|
@@ -3827,7 +4966,7 @@ function loadCapturedStates(configDir, config) {
|
|
|
3827
4966
|
if (typeof c.route === "string") rec.route = c.route;
|
|
3828
4967
|
captured.push(rec);
|
|
3829
4968
|
}
|
|
3830
|
-
return { captured };
|
|
4969
|
+
return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
|
|
3831
4970
|
} catch {
|
|
3832
4971
|
return void 0;
|
|
3833
4972
|
}
|
|
@@ -3863,11 +5002,11 @@ function runOne(dc, opts) {
|
|
|
3863
5002
|
gitContext
|
|
3864
5003
|
});
|
|
3865
5004
|
const typesRel = config.output;
|
|
3866
|
-
const typesPath =
|
|
5005
|
+
const typesPath = path9.resolve(configDir, typesRel);
|
|
3867
5006
|
const dataRel = dataOutputPath(config.output);
|
|
3868
|
-
const dataPath =
|
|
5007
|
+
const dataPath = path9.resolve(configDir, dataRel);
|
|
3869
5008
|
const locsRel = locsOutputPath(config.output);
|
|
3870
|
-
const locsPath =
|
|
5009
|
+
const locsPath = path9.resolve(configDir, locsRel);
|
|
3871
5010
|
let typesOnDisk = null;
|
|
3872
5011
|
let dataOnDisk = null;
|
|
3873
5012
|
let locsOnDisk = null;
|
|
@@ -3880,7 +5019,8 @@ function runOne(dc, opts) {
|
|
|
3880
5019
|
(ef) => (ef.diagnostics?.length ?? 0) > 0
|
|
3881
5020
|
);
|
|
3882
5021
|
const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
|
|
3883
|
-
const
|
|
5022
|
+
const coverageBaseline = opts.coverageBaseline ?? loadCoverageBaseline(configDir, config);
|
|
5023
|
+
const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
|
|
3884
5024
|
let auditResult;
|
|
3885
5025
|
if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
|
|
3886
5026
|
auditResult = audit({
|
|
@@ -3905,7 +5045,8 @@ function runOne(dc, opts) {
|
|
|
3905
5045
|
locsOnDisk,
|
|
3906
5046
|
locsOutputPath: locsRel,
|
|
3907
5047
|
capturedStates,
|
|
3908
|
-
|
|
5048
|
+
coverageBaseline,
|
|
5049
|
+
workspace
|
|
3909
5050
|
});
|
|
3910
5051
|
}
|
|
3911
5052
|
return {
|
|
@@ -3926,15 +5067,15 @@ function runOne(dc, opts) {
|
|
|
3926
5067
|
}
|
|
3927
5068
|
function readOrNull(p2) {
|
|
3928
5069
|
try {
|
|
3929
|
-
return
|
|
5070
|
+
return fs5.readFileSync(p2, "utf8");
|
|
3930
5071
|
} catch {
|
|
3931
5072
|
return null;
|
|
3932
5073
|
}
|
|
3933
5074
|
}
|
|
3934
5075
|
function writeArtifact(artifact) {
|
|
3935
5076
|
if (readOrNull(artifact.outputPath) === artifact.generated) return false;
|
|
3936
|
-
|
|
3937
|
-
|
|
5077
|
+
fs5.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
|
|
5078
|
+
fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
|
|
3938
5079
|
return true;
|
|
3939
5080
|
}
|
|
3940
5081
|
function writeScanResult(result) {
|
|
@@ -3943,14 +5084,45 @@ function writeScanResult(result) {
|
|
|
3943
5084
|
const wroteLocs = writeArtifact(result.locs);
|
|
3944
5085
|
return wroteTypes || wroteData || wroteLocs;
|
|
3945
5086
|
}
|
|
3946
|
-
function
|
|
5087
|
+
function coverageBaselinePath(result) {
|
|
3947
5088
|
const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3948
|
-
return
|
|
5089
|
+
return path9.resolve(result.configDir, rel);
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
// src/scanner/scan/states-merge.ts
|
|
5093
|
+
function keyOf(c) {
|
|
5094
|
+
return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
|
|
5095
|
+
}
|
|
5096
|
+
function mergeStates(committed, partial) {
|
|
5097
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
5098
|
+
for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
|
|
5099
|
+
const updated = [];
|
|
5100
|
+
const added = [];
|
|
5101
|
+
for (const c of partial.captured ?? []) {
|
|
5102
|
+
const k = keyOf(c);
|
|
5103
|
+
(byKey.has(k) ? updated : added).push(k);
|
|
5104
|
+
byKey.set(k, c);
|
|
5105
|
+
}
|
|
5106
|
+
const captured = [...byKey.values()].sort(
|
|
5107
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
|
|
5108
|
+
);
|
|
5109
|
+
return {
|
|
5110
|
+
manifest: {
|
|
5111
|
+
...committed,
|
|
5112
|
+
captured,
|
|
5113
|
+
// The merged file is only as fresh as its OLDEST evidence: rows carried
|
|
5114
|
+
// over from the committed manifest were not re-verified by this run, so
|
|
5115
|
+
// claiming the partial's newer stamp would defeat the staleness check.
|
|
5116
|
+
...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
|
|
5117
|
+
},
|
|
5118
|
+
updated: updated.sort(),
|
|
5119
|
+
added: added.sort()
|
|
5120
|
+
};
|
|
3949
5121
|
}
|
|
3950
5122
|
|
|
3951
5123
|
// src/scanner/scan/fix.ts
|
|
3952
|
-
var
|
|
3953
|
-
var
|
|
5124
|
+
var fs6 = __toESM(require("fs"), 1);
|
|
5125
|
+
var path10 = __toESM(require("path"), 1);
|
|
3954
5126
|
function applyFixes(diagnostics) {
|
|
3955
5127
|
const entries = [];
|
|
3956
5128
|
for (const d of diagnostics) {
|
|
@@ -3983,7 +5155,7 @@ function applyFixes(diagnostics) {
|
|
|
3983
5155
|
for (const [filePath, edits] of editsByFile) {
|
|
3984
5156
|
let content;
|
|
3985
5157
|
try {
|
|
3986
|
-
content =
|
|
5158
|
+
content = fs6.readFileSync(filePath, "utf8");
|
|
3987
5159
|
} catch {
|
|
3988
5160
|
for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
|
|
3989
5161
|
continue;
|
|
@@ -4003,22 +5175,22 @@ function applyFixes(diagnostics) {
|
|
|
4003
5175
|
const edit = kept[i];
|
|
4004
5176
|
content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
|
|
4005
5177
|
}
|
|
4006
|
-
if (kept.length > 0)
|
|
5178
|
+
if (kept.length > 0) fs6.writeFileSync(filePath, content, "utf8");
|
|
4007
5179
|
}
|
|
4008
5180
|
for (const entry of entries) {
|
|
4009
5181
|
if (entry.skippedReason) continue;
|
|
4010
5182
|
for (const create of entry.createFiles) {
|
|
4011
|
-
if (
|
|
4012
|
-
entry.skippedReason = `${
|
|
5183
|
+
if (fs6.existsSync(create.path)) {
|
|
5184
|
+
entry.skippedReason = `${path10.basename(create.path)} already exists`;
|
|
4013
5185
|
continue;
|
|
4014
5186
|
}
|
|
4015
|
-
|
|
4016
|
-
|
|
5187
|
+
fs6.mkdirSync(path10.dirname(create.path), { recursive: true });
|
|
5188
|
+
fs6.writeFileSync(create.path, create.content, "utf8");
|
|
4017
5189
|
}
|
|
4018
5190
|
if (entry.skippedReason) continue;
|
|
4019
5191
|
for (const del of entry.deleteFiles) {
|
|
4020
5192
|
try {
|
|
4021
|
-
|
|
5193
|
+
fs6.unlinkSync(del);
|
|
4022
5194
|
} catch {
|
|
4023
5195
|
}
|
|
4024
5196
|
}
|
|
@@ -4196,29 +5368,29 @@ function renameEntity(opts) {
|
|
|
4196
5368
|
}
|
|
4197
5369
|
|
|
4198
5370
|
// src/scanner/scan/cli.ts
|
|
4199
|
-
var
|
|
4200
|
-
var
|
|
5371
|
+
var fs9 = __toESM(require("fs"), 1);
|
|
5372
|
+
var path13 = __toESM(require("path"), 1);
|
|
4201
5373
|
|
|
4202
5374
|
// src/scanner/scan/ai/index.ts
|
|
4203
5375
|
var p = __toESM(require("@clack/prompts"), 1);
|
|
4204
5376
|
|
|
4205
5377
|
// src/scanner/scan/ai/providers/claude.ts
|
|
4206
|
-
var
|
|
4207
|
-
var
|
|
5378
|
+
var fs8 = __toESM(require("fs"), 1);
|
|
5379
|
+
var path12 = __toESM(require("path"), 1);
|
|
4208
5380
|
|
|
4209
5381
|
// src/scanner/scan/ai/templates.ts
|
|
4210
|
-
var
|
|
4211
|
-
var
|
|
5382
|
+
var fs7 = __toESM(require("fs"), 1);
|
|
5383
|
+
var path11 = __toESM(require("path"), 1);
|
|
4212
5384
|
function templatePath(rel) {
|
|
4213
5385
|
const candidates = [
|
|
4214
|
-
|
|
5386
|
+
path11.resolve(__dirname, "../../templates", rel),
|
|
4215
5387
|
// dist/cli/cli.cjs → ../../templates
|
|
4216
|
-
|
|
5388
|
+
path11.resolve(__dirname, "../../../../templates", rel)
|
|
4217
5389
|
// src/scanner/scan/ai → ../../../../templates
|
|
4218
5390
|
];
|
|
4219
5391
|
for (const c of candidates) {
|
|
4220
5392
|
try {
|
|
4221
|
-
|
|
5393
|
+
fs7.accessSync(c, fs7.constants.R_OK);
|
|
4222
5394
|
return c;
|
|
4223
5395
|
} catch {
|
|
4224
5396
|
continue;
|
|
@@ -4230,7 +5402,7 @@ function templatePath(rel) {
|
|
|
4230
5402
|
);
|
|
4231
5403
|
}
|
|
4232
5404
|
function readTemplate(rel) {
|
|
4233
|
-
return
|
|
5405
|
+
return fs7.readFileSync(templatePath(rel), "utf8");
|
|
4234
5406
|
}
|
|
4235
5407
|
|
|
4236
5408
|
// src/scanner/scan/ai/providers/claude.ts
|
|
@@ -4261,8 +5433,8 @@ var claudeProvider = {
|
|
|
4261
5433
|
async install({ cwd, force }) {
|
|
4262
5434
|
const changes = [];
|
|
4263
5435
|
for (const file of SKILL_FILES) {
|
|
4264
|
-
const dest =
|
|
4265
|
-
const exists =
|
|
5436
|
+
const dest = path12.join(cwd, file.dest);
|
|
5437
|
+
const exists = fs8.existsSync(dest);
|
|
4266
5438
|
if (exists && !force) {
|
|
4267
5439
|
changes.push({
|
|
4268
5440
|
path: file.dest,
|
|
@@ -4271,56 +5443,56 @@ var claudeProvider = {
|
|
|
4271
5443
|
});
|
|
4272
5444
|
continue;
|
|
4273
5445
|
}
|
|
4274
|
-
|
|
4275
|
-
|
|
5446
|
+
fs8.mkdirSync(path12.dirname(dest), { recursive: true });
|
|
5447
|
+
fs8.writeFileSync(dest, readTemplate(file.template));
|
|
4276
5448
|
changes.push({
|
|
4277
5449
|
path: file.dest,
|
|
4278
5450
|
action: exists ? "overwritten" : "created"
|
|
4279
5451
|
});
|
|
4280
5452
|
}
|
|
4281
5453
|
for (const rel of LEGACY_FILES) {
|
|
4282
|
-
const dest =
|
|
4283
|
-
if (
|
|
4284
|
-
|
|
5454
|
+
const dest = path12.join(cwd, rel);
|
|
5455
|
+
if (fs8.existsSync(dest)) {
|
|
5456
|
+
fs8.unlinkSync(dest);
|
|
4285
5457
|
changes.push({ path: rel, action: "removed" });
|
|
4286
5458
|
}
|
|
4287
5459
|
}
|
|
4288
|
-
cleanupEmpty(
|
|
4289
|
-
cleanupEmpty(
|
|
4290
|
-
cleanupEmpty(
|
|
5460
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
|
|
5461
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands"));
|
|
5462
|
+
cleanupEmpty(path12.join(cwd, ".claude/rules"));
|
|
4291
5463
|
return { changes };
|
|
4292
5464
|
},
|
|
4293
5465
|
async uninstall({ cwd }) {
|
|
4294
5466
|
const changes = [];
|
|
4295
5467
|
for (const file of SKILL_FILES) {
|
|
4296
|
-
const dest =
|
|
4297
|
-
if (!
|
|
5468
|
+
const dest = path12.join(cwd, file.dest);
|
|
5469
|
+
if (!fs8.existsSync(dest)) {
|
|
4298
5470
|
changes.push({ path: file.dest, action: "skipped", reason: "absent" });
|
|
4299
5471
|
continue;
|
|
4300
5472
|
}
|
|
4301
|
-
|
|
5473
|
+
fs8.unlinkSync(dest);
|
|
4302
5474
|
changes.push({ path: file.dest, action: "removed" });
|
|
4303
5475
|
}
|
|
4304
|
-
cleanupEmpty(
|
|
4305
|
-
cleanupEmpty(
|
|
4306
|
-
cleanupEmpty(
|
|
5476
|
+
cleanupEmpty(path12.join(cwd, ".claude/skills/uidex/references"));
|
|
5477
|
+
cleanupEmpty(path12.join(cwd, ".claude/skills/uidex"));
|
|
5478
|
+
cleanupEmpty(path12.join(cwd, ".claude/skills"));
|
|
4307
5479
|
for (const rel of LEGACY_FILES) {
|
|
4308
|
-
const dest =
|
|
4309
|
-
if (
|
|
4310
|
-
|
|
5480
|
+
const dest = path12.join(cwd, rel);
|
|
5481
|
+
if (fs8.existsSync(dest)) {
|
|
5482
|
+
fs8.unlinkSync(dest);
|
|
4311
5483
|
changes.push({ path: rel, action: "removed" });
|
|
4312
5484
|
}
|
|
4313
5485
|
}
|
|
4314
|
-
cleanupEmpty(
|
|
4315
|
-
cleanupEmpty(
|
|
4316
|
-
cleanupEmpty(
|
|
5486
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
|
|
5487
|
+
cleanupEmpty(path12.join(cwd, ".claude/commands"));
|
|
5488
|
+
cleanupEmpty(path12.join(cwd, ".claude/rules"));
|
|
4317
5489
|
return { changes };
|
|
4318
5490
|
}
|
|
4319
5491
|
};
|
|
4320
5492
|
function cleanupEmpty(dir) {
|
|
4321
5493
|
try {
|
|
4322
|
-
const entries =
|
|
4323
|
-
if (entries.length === 0)
|
|
5494
|
+
const entries = fs8.readdirSync(dir);
|
|
5495
|
+
if (entries.length === 0) fs8.rmdirSync(dir);
|
|
4324
5496
|
} catch {
|
|
4325
5497
|
}
|
|
4326
5498
|
}
|
|
@@ -4489,6 +5661,8 @@ async function run(opts) {
|
|
|
4489
5661
|
return runScaffold(cwd, positional.slice(1), flags, writer);
|
|
4490
5662
|
case "rename":
|
|
4491
5663
|
return runRename(cwd, positional.slice(1), flags, writer);
|
|
5664
|
+
case "states":
|
|
5665
|
+
return runStates(cwd, positional.slice(1), writer);
|
|
4492
5666
|
case "ai": {
|
|
4493
5667
|
const result = await runAiCommand({
|
|
4494
5668
|
cwd,
|
|
@@ -4517,6 +5691,7 @@ function helpText2() {
|
|
|
4517
5691
|
" scan [flags] Run the scanner pipeline",
|
|
4518
5692
|
" scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
|
|
4519
5693
|
" rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
|
|
5694
|
+
" states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
|
|
4520
5695
|
" ai <install|uninstall|providers> Manage AI assistant integrations",
|
|
4521
5696
|
"",
|
|
4522
5697
|
"Flags:",
|
|
@@ -4524,15 +5699,15 @@ function helpText2() {
|
|
|
4524
5699
|
" --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
|
|
4525
5700
|
" --audit Equivalent to --check --lint (read-only)",
|
|
4526
5701
|
" --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
|
|
4527
|
-
" --update-baseline Regenerate the
|
|
5702
|
+
" --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
|
|
4528
5703
|
" --json Emit JSON diagnostics on stdout",
|
|
4529
5704
|
" --force (scaffold) overwrite existing spec",
|
|
4530
5705
|
""
|
|
4531
5706
|
].join("\n");
|
|
4532
5707
|
}
|
|
4533
5708
|
function runInit(cwd, w) {
|
|
4534
|
-
const configPath =
|
|
4535
|
-
if (
|
|
5709
|
+
const configPath = path13.join(cwd, CONFIG_FILENAME);
|
|
5710
|
+
if (fs9.existsSync(configPath)) {
|
|
4536
5711
|
w.err(`.uidex.json already exists at ${configPath}`);
|
|
4537
5712
|
return w.result(1);
|
|
4538
5713
|
}
|
|
@@ -4541,16 +5716,16 @@ function runInit(cwd, w) {
|
|
|
4541
5716
|
sources: [{ rootDir: "src" }],
|
|
4542
5717
|
output: "src/uidex.gen.ts"
|
|
4543
5718
|
};
|
|
4544
|
-
|
|
5719
|
+
fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
4545
5720
|
w.out(`Created ${configPath}`);
|
|
4546
|
-
const gitignorePath =
|
|
5721
|
+
const gitignorePath = path13.join(cwd, ".gitignore");
|
|
4547
5722
|
const entry = "*.gen.*";
|
|
4548
|
-
if (
|
|
4549
|
-
const existing =
|
|
5723
|
+
if (fs9.existsSync(gitignorePath)) {
|
|
5724
|
+
const existing = fs9.readFileSync(gitignorePath, "utf8");
|
|
4550
5725
|
const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
|
|
4551
5726
|
if (!hasEntry) {
|
|
4552
5727
|
const needsNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
4553
|
-
|
|
5728
|
+
fs9.appendFileSync(
|
|
4554
5729
|
gitignorePath,
|
|
4555
5730
|
`${needsNewline ? "\n" : ""}${entry}
|
|
4556
5731
|
`,
|
|
@@ -4559,7 +5734,7 @@ function runInit(cwd, w) {
|
|
|
4559
5734
|
w.out(`Appended ${entry} to ${gitignorePath}`);
|
|
4560
5735
|
}
|
|
4561
5736
|
} else {
|
|
4562
|
-
|
|
5737
|
+
fs9.writeFileSync(gitignorePath, `${entry}
|
|
4563
5738
|
`, "utf8");
|
|
4564
5739
|
w.out(`Created ${gitignorePath} with ${entry}`);
|
|
4565
5740
|
}
|
|
@@ -4598,20 +5773,25 @@ function runScanCommand(cwd, flags, w) {
|
|
|
4598
5773
|
if (updateBaseline) {
|
|
4599
5774
|
for (const r of results) {
|
|
4600
5775
|
const manifest = r.capturedStates ?? { captured: [] };
|
|
4601
|
-
const
|
|
4602
|
-
const
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
5776
|
+
const acknowledge = {};
|
|
5777
|
+
const merge = (from) => {
|
|
5778
|
+
for (const [route, scopes] of Object.entries(from)) {
|
|
5779
|
+
acknowledge[route] = { ...acknowledge[route], ...scopes };
|
|
5780
|
+
}
|
|
4606
5781
|
};
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
const
|
|
4610
|
-
|
|
5782
|
+
merge(computePageBacklog(r.registry, manifest));
|
|
5783
|
+
merge(computeMatrixBacklog(r.registry, manifest));
|
|
5784
|
+
const baseline = { version: 2, acknowledge };
|
|
5785
|
+
const p2 = coverageBaselinePath(r);
|
|
5786
|
+
fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
|
|
5787
|
+
const entries = Object.values(acknowledge);
|
|
5788
|
+
const pages = entries.filter((s) => s["*"]).length;
|
|
5789
|
+
const kindGaps = entries.reduce(
|
|
5790
|
+
(n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
|
|
4611
5791
|
0
|
|
4612
5792
|
);
|
|
4613
5793
|
w.out(
|
|
4614
|
-
`Wrote ${p2} \u2014 ${
|
|
5794
|
+
`Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
|
|
4615
5795
|
);
|
|
4616
5796
|
}
|
|
4617
5797
|
return w.result(0);
|
|
@@ -4684,7 +5864,7 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
4684
5864
|
for (const r of results) {
|
|
4685
5865
|
const entity = r.registry.get(scaffoldKind, id);
|
|
4686
5866
|
if (!entity) continue;
|
|
4687
|
-
const outDir =
|
|
5867
|
+
const outDir = path13.resolve(r.configDir, "e2e");
|
|
4688
5868
|
const result = scaffoldSpec({
|
|
4689
5869
|
registry: r.registry,
|
|
4690
5870
|
kind: scaffoldKind,
|
|
@@ -4705,6 +5885,53 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
4705
5885
|
return w.result(1);
|
|
4706
5886
|
}
|
|
4707
5887
|
var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
|
|
5888
|
+
function runStates(cwd, args, w) {
|
|
5889
|
+
const [sub, explicitPartial] = args;
|
|
5890
|
+
if (sub !== "merge") {
|
|
5891
|
+
w.err("Usage: uidex states merge [partial-manifest]");
|
|
5892
|
+
return w.result(1);
|
|
5893
|
+
}
|
|
5894
|
+
const configs = discover({ cwd });
|
|
5895
|
+
if (configs.length === 0) {
|
|
5896
|
+
w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
|
|
5897
|
+
return w.result(1);
|
|
5898
|
+
}
|
|
5899
|
+
let merged = 0;
|
|
5900
|
+
for (const { configDir, config } of configs) {
|
|
5901
|
+
const rel = config.statesManifest ?? "uidex-states.json";
|
|
5902
|
+
const target = path13.resolve(configDir, rel);
|
|
5903
|
+
const partial = explicitPartial ? path13.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
|
|
5904
|
+
const committedRaw = readJson(target);
|
|
5905
|
+
const partialRaw = readJson(partial);
|
|
5906
|
+
if (!partialRaw) continue;
|
|
5907
|
+
if (!committedRaw) {
|
|
5908
|
+
w.err(`No committed manifest at ${rel}; nothing to merge into`);
|
|
5909
|
+
return w.result(1);
|
|
5910
|
+
}
|
|
5911
|
+
const result = mergeStates(committedRaw, partialRaw);
|
|
5912
|
+
fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
|
|
5913
|
+
merged++;
|
|
5914
|
+
w.out(
|
|
5915
|
+
`Merged ${path13.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
|
|
5916
|
+
);
|
|
5917
|
+
for (const k of result.added) w.out(` + ${k}`);
|
|
5918
|
+
for (const k of result.updated) w.out(` ~ ${k}`);
|
|
5919
|
+
}
|
|
5920
|
+
if (merged === 0) {
|
|
5921
|
+
w.out("No partial manifest found; nothing to merge.");
|
|
5922
|
+
}
|
|
5923
|
+
return w.result(0);
|
|
5924
|
+
}
|
|
5925
|
+
function readJson(p2) {
|
|
5926
|
+
try {
|
|
5927
|
+
const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
|
|
5928
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
5929
|
+
const m = parsed;
|
|
5930
|
+
return Array.isArray(m.captured) ? m : null;
|
|
5931
|
+
} catch {
|
|
5932
|
+
return null;
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
4708
5935
|
function runRename(cwd, args, flags, w) {
|
|
4709
5936
|
const [kind, oldId, newId] = args;
|
|
4710
5937
|
if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
|
|
@@ -4753,35 +5980,47 @@ function createWriter() {
|
|
|
4753
5980
|
}
|
|
4754
5981
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4755
5982
|
0 && (module.exports = {
|
|
5983
|
+
ACK_ALL,
|
|
5984
|
+
ACK_SCOPES,
|
|
4756
5985
|
CONFIG_FILENAME,
|
|
4757
5986
|
ConfigError,
|
|
4758
5987
|
DEFAULT_CONVENTIONS,
|
|
5988
|
+
DIAGNOSTIC_CODES,
|
|
4759
5989
|
applyFixes,
|
|
4760
5990
|
audit,
|
|
5991
|
+
capturedElsewhere,
|
|
5992
|
+
checkComponentCoverage,
|
|
4761
5993
|
checkPageCoverage,
|
|
4762
5994
|
checkStateCoverage,
|
|
4763
5995
|
checkStateMatrix,
|
|
4764
5996
|
compileRoute,
|
|
4765
|
-
|
|
4766
|
-
|
|
5997
|
+
computeMatrixBacklog,
|
|
5998
|
+
computePageBacklog,
|
|
5999
|
+
coverageBaselinePath,
|
|
4767
6000
|
detectRoutes,
|
|
4768
6001
|
discover,
|
|
4769
6002
|
emit,
|
|
6003
|
+
entityAcknowledge,
|
|
4770
6004
|
extract,
|
|
4771
6005
|
extractUidexExports,
|
|
6006
|
+
findWorkspaceRoot,
|
|
4772
6007
|
globToRegExp,
|
|
4773
|
-
|
|
6008
|
+
loadCoverageBaseline,
|
|
4774
6009
|
matchUrlToRoute,
|
|
4775
|
-
|
|
6010
|
+
mergeStates,
|
|
4776
6011
|
parseConfig,
|
|
6012
|
+
parseCoverageBaseline,
|
|
4777
6013
|
pathToId,
|
|
4778
6014
|
renameEntity,
|
|
4779
6015
|
resolve,
|
|
4780
6016
|
resolveGitContext,
|
|
6017
|
+
resolveWorkspace,
|
|
6018
|
+
routeAcknowledge,
|
|
4781
6019
|
runCli,
|
|
4782
6020
|
runScan,
|
|
4783
6021
|
scaffoldSpec,
|
|
4784
6022
|
scaffoldWidgetSpec,
|
|
6023
|
+
serializeCoverageBaseline,
|
|
4785
6024
|
validateConfig,
|
|
4786
6025
|
walk,
|
|
4787
6026
|
writeScanResult
|