uidex 0.9.0 → 0.10.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 +1190 -335
- 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 +19 -13
- package/dist/headless/index.d.ts +19 -13
- package/dist/headless/index.js +3 -0
- package/dist/headless/index.js.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -13
- package/dist/index.d.ts +19 -13
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/playwright/index.cjs +36 -7
- package/dist/playwright/index.cjs.map +1 -1
- package/dist/playwright/index.js +36 -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 +3 -0
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +19 -13
- package/dist/react/index.d.ts +19 -13
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/scan/index.cjs +1129 -244
- package/dist/scan/index.cjs.map +1 -1
- package/dist/scan/index.d.cts +379 -100
- package/dist/scan/index.d.ts +379 -100
- package/dist/scan/index.js +1109 -240
- package/dist/scan/index.js.map +1 -1
- package/package.json +1 -1
package/dist/scan/index.js
CHANGED
|
@@ -30,7 +30,8 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
|
30
30
|
"audit",
|
|
31
31
|
"conventions",
|
|
32
32
|
"statesManifest",
|
|
33
|
-
"coverageBaseline"
|
|
33
|
+
"coverageBaseline",
|
|
34
|
+
"workspace"
|
|
34
35
|
]);
|
|
35
36
|
var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
|
|
36
37
|
var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -46,19 +47,64 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
|
|
|
46
47
|
"acceptance",
|
|
47
48
|
"states",
|
|
48
49
|
"pageCoverage",
|
|
49
|
-
"stateMatrix"
|
|
50
|
+
"stateMatrix",
|
|
51
|
+
"severity"
|
|
50
52
|
]);
|
|
53
|
+
var DIAGNOSTIC_CODES = [
|
|
54
|
+
"acceptance-uncovered",
|
|
55
|
+
"acknowledged-elsewhere",
|
|
56
|
+
"competing-uidex-export",
|
|
57
|
+
"component-state-uncaptured",
|
|
58
|
+
"core-kind",
|
|
59
|
+
"duplicate-id",
|
|
60
|
+
"dynamic-attr",
|
|
61
|
+
"dynamic-flow-reference",
|
|
62
|
+
"gen-missing",
|
|
63
|
+
"gen-stale",
|
|
64
|
+
"legacy-acknowledge-field",
|
|
65
|
+
"legacy-coverage-baseline",
|
|
66
|
+
"matrix-backlog",
|
|
67
|
+
"missing-element-annotation",
|
|
68
|
+
"missing-state-capture",
|
|
69
|
+
"orphan-state-capture",
|
|
70
|
+
"page-backlog",
|
|
71
|
+
"page-captured",
|
|
72
|
+
"page-no-capture",
|
|
73
|
+
"parse-error",
|
|
74
|
+
"placeholder-reason",
|
|
75
|
+
"prefer-well-known-file",
|
|
76
|
+
"rename",
|
|
77
|
+
"scope-leak",
|
|
78
|
+
"spread-attr",
|
|
79
|
+
"stale-acknowledgment",
|
|
80
|
+
"stale-page-baseline",
|
|
81
|
+
"uidex-export-ambiguous-kind",
|
|
82
|
+
"uidex-export-duplicate-field",
|
|
83
|
+
"uidex-export-duplicate-state",
|
|
84
|
+
"uidex-export-empty-name",
|
|
85
|
+
"uidex-export-invalid-field",
|
|
86
|
+
"uidex-export-invalid-literal",
|
|
87
|
+
"uidex-export-missing-kind",
|
|
88
|
+
"uidex-export-satisfies-mismatch",
|
|
89
|
+
"uidex-export-unknown-field",
|
|
90
|
+
"unknown-reference",
|
|
91
|
+
"widget-id-mismatch",
|
|
92
|
+
"widget-missing-acceptance",
|
|
93
|
+
"widget-missing-dom"
|
|
94
|
+
];
|
|
95
|
+
var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
|
|
96
|
+
var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
|
|
51
97
|
function fail(msg) {
|
|
52
98
|
throw new ConfigError(`Invalid .uidex.json: ${msg}`);
|
|
53
99
|
}
|
|
54
|
-
function assertObject(value,
|
|
100
|
+
function assertObject(value, path13) {
|
|
55
101
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
56
|
-
fail(`${
|
|
102
|
+
fail(`${path13} must be an object`);
|
|
57
103
|
}
|
|
58
104
|
}
|
|
59
|
-
function assertStringArray(value,
|
|
105
|
+
function assertStringArray(value, path13) {
|
|
60
106
|
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
61
|
-
fail(`${
|
|
107
|
+
fail(`${path13} must be a string[]`);
|
|
62
108
|
}
|
|
63
109
|
}
|
|
64
110
|
function validateConfig(raw) {
|
|
@@ -112,11 +158,30 @@ function validateConfig(raw) {
|
|
|
112
158
|
if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
|
|
113
159
|
fail(`"coverageBaseline" must be a string`);
|
|
114
160
|
}
|
|
161
|
+
if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
|
|
162
|
+
fail(`"workspace" must be a path string or false`);
|
|
163
|
+
}
|
|
115
164
|
if (raw.audit !== void 0) {
|
|
116
165
|
assertObject(raw.audit, "audit");
|
|
117
166
|
for (const key of Object.keys(raw.audit)) {
|
|
118
167
|
if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
|
|
119
168
|
const v = raw.audit[key];
|
|
169
|
+
if (key === "severity") {
|
|
170
|
+
assertObject(v, "audit.severity");
|
|
171
|
+
for (const [code, level] of Object.entries(v)) {
|
|
172
|
+
if (!DIAGNOSTIC_CODE_SET.has(code)) {
|
|
173
|
+
fail(
|
|
174
|
+
`unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
|
|
178
|
+
fail(
|
|
179
|
+
`audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
120
185
|
if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
|
|
121
186
|
}
|
|
122
187
|
}
|
|
@@ -159,7 +224,8 @@ function validateConfig(raw) {
|
|
|
159
224
|
audit: raw.audit,
|
|
160
225
|
conventions: raw.conventions,
|
|
161
226
|
statesManifest: raw.statesManifest,
|
|
162
|
-
coverageBaseline: raw.coverageBaseline
|
|
227
|
+
coverageBaseline: raw.coverageBaseline,
|
|
228
|
+
workspace: raw.workspace
|
|
163
229
|
};
|
|
164
230
|
}
|
|
165
231
|
function parseConfig(json) {
|
|
@@ -209,9 +275,11 @@ function tryReadConfig(configPath) {
|
|
|
209
275
|
function discover(options = {}) {
|
|
210
276
|
const cwd = options.cwd ?? process.cwd();
|
|
211
277
|
const maxDepth = options.maxDepth ?? MAX_DEPTH;
|
|
278
|
+
const all = options.all ?? false;
|
|
212
279
|
const rootMatch = tryReadConfig(path.join(cwd, CONFIG_FILENAME));
|
|
213
|
-
if (rootMatch) return [rootMatch];
|
|
280
|
+
if (rootMatch && !all) return [rootMatch];
|
|
214
281
|
const results = [];
|
|
282
|
+
if (rootMatch) results.push(rootMatch);
|
|
215
283
|
const queue = [[cwd, 0]];
|
|
216
284
|
while (queue.length > 0) {
|
|
217
285
|
const [dir, depth] = queue.shift();
|
|
@@ -315,6 +383,26 @@ function toPosix(p2) {
|
|
|
315
383
|
function matchesAny(rel, patterns) {
|
|
316
384
|
return patterns.some((g) => globToRegExp(g).test(rel));
|
|
317
385
|
}
|
|
386
|
+
function createSourceMatcher(sources, options) {
|
|
387
|
+
const { cwd, globalExcludes = [], includeTests = false } = options;
|
|
388
|
+
const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
|
|
389
|
+
const compiled = sources.map((source) => ({
|
|
390
|
+
absRoot: path2.resolve(cwd, source.rootDir),
|
|
391
|
+
includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
|
|
392
|
+
excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
|
|
393
|
+
}));
|
|
394
|
+
return (absFile) => {
|
|
395
|
+
const abs = path2.resolve(absFile);
|
|
396
|
+
return compiled.some(({ absRoot, includes, excludes }) => {
|
|
397
|
+
const rel = toPosix(path2.relative(absRoot, abs));
|
|
398
|
+
if (rel.length === 0 || rel.startsWith("../") || path2.isAbsolute(rel)) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
if (matchesAny(rel, excludes)) return false;
|
|
402
|
+
return matchesAny(rel, includes);
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
}
|
|
318
406
|
function walk(sources, options) {
|
|
319
407
|
const { cwd, globalExcludes = [], includeTests = false } = options;
|
|
320
408
|
const out2 = [];
|
|
@@ -648,6 +736,132 @@ function createRegistry() {
|
|
|
648
736
|
};
|
|
649
737
|
}
|
|
650
738
|
|
|
739
|
+
// src/scanner/scan/acknowledge.ts
|
|
740
|
+
var ACK_ALL = "*";
|
|
741
|
+
var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
|
|
742
|
+
var COMPONENT_ACK_SCOPES = [ACK_ALL];
|
|
743
|
+
var AUTHORABLE_ACK_TYPES = [
|
|
744
|
+
"impossible",
|
|
745
|
+
"backlog"
|
|
746
|
+
];
|
|
747
|
+
var CORE_SET = new Set(CORE_STATE_KINDS);
|
|
748
|
+
function isCoreKind(scope) {
|
|
749
|
+
return CORE_SET.has(scope);
|
|
750
|
+
}
|
|
751
|
+
var PLACEHOLDER_REASON = "TODO: why can this never be captured?";
|
|
752
|
+
function isPlaceholderReason(reason) {
|
|
753
|
+
return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
|
|
754
|
+
}
|
|
755
|
+
function describeAck(a) {
|
|
756
|
+
if (a.type === "captured-elsewhere") {
|
|
757
|
+
return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
|
|
758
|
+
}
|
|
759
|
+
return a.reason ? `${a.type} ("${a.reason}")` : a.type;
|
|
760
|
+
}
|
|
761
|
+
function originHint(a, page) {
|
|
762
|
+
if (a.origin === "baseline") {
|
|
763
|
+
return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
|
|
764
|
+
}
|
|
765
|
+
if (a.origin === "workspace") {
|
|
766
|
+
return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
|
|
767
|
+
}
|
|
768
|
+
const where = page ? ` on page "${page}"` : "";
|
|
769
|
+
return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
|
|
770
|
+
}
|
|
771
|
+
function toResolved(map, origin, into) {
|
|
772
|
+
for (const [scope, ack] of Object.entries(map ?? {})) {
|
|
773
|
+
into.set(scope, { ...ack, scope, origin });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function routeAcknowledge(registry, baseline, route, page) {
|
|
777
|
+
const out2 = /* @__PURE__ */ new Map();
|
|
778
|
+
toResolved(baseline?.acknowledge?.[route], "baseline", out2);
|
|
779
|
+
if (page) {
|
|
780
|
+
toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
|
|
781
|
+
}
|
|
782
|
+
return out2;
|
|
783
|
+
}
|
|
784
|
+
function entityAcknowledge(meta, capturedElsewhereBy) {
|
|
785
|
+
const source = meta?.acknowledge?.[ACK_ALL];
|
|
786
|
+
if (source) return { ...source, scope: ACK_ALL, origin: "source" };
|
|
787
|
+
if (capturedElsewhereBy) {
|
|
788
|
+
return {
|
|
789
|
+
type: "captured-elsewhere",
|
|
790
|
+
by: capturedElsewhereBy,
|
|
791
|
+
scope: ACK_ALL,
|
|
792
|
+
origin: "workspace"
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
function readAck(raw) {
|
|
798
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
799
|
+
const r = raw;
|
|
800
|
+
if (r.type !== "impossible" && r.type !== "backlog") return void 0;
|
|
801
|
+
const out2 = { type: r.type };
|
|
802
|
+
if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
|
|
803
|
+
return out2;
|
|
804
|
+
}
|
|
805
|
+
function parseCoverageBaseline(raw) {
|
|
806
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
807
|
+
const r = raw;
|
|
808
|
+
const ackRaw = r.acknowledge;
|
|
809
|
+
if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
|
|
810
|
+
return null;
|
|
811
|
+
const acknowledge = {};
|
|
812
|
+
for (const [route, scopesRaw] of Object.entries(
|
|
813
|
+
ackRaw
|
|
814
|
+
)) {
|
|
815
|
+
if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
|
|
816
|
+
continue;
|
|
817
|
+
const scopes = {};
|
|
818
|
+
for (const [scope, ackVal] of Object.entries(
|
|
819
|
+
scopesRaw
|
|
820
|
+
)) {
|
|
821
|
+
if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
|
|
822
|
+
const ack = readAck(ackVal);
|
|
823
|
+
if (ack) scopes[scope] = ack;
|
|
824
|
+
}
|
|
825
|
+
if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
|
|
826
|
+
}
|
|
827
|
+
return { version: 2, acknowledge };
|
|
828
|
+
}
|
|
829
|
+
function isLegacyBaseline(raw) {
|
|
830
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
831
|
+
const r = raw;
|
|
832
|
+
if ("acknowledge" in r) return false;
|
|
833
|
+
return "uncapturedPages" in r || "missingKinds" in r;
|
|
834
|
+
}
|
|
835
|
+
function migrateBaseline(legacy) {
|
|
836
|
+
const acknowledge = {};
|
|
837
|
+
const at = (route) => acknowledge[route] ??= {};
|
|
838
|
+
for (const route of legacy.uncapturedPages ?? []) {
|
|
839
|
+
if (typeof route === "string") at(route)[ACK_ALL] = { type: "backlog" };
|
|
840
|
+
}
|
|
841
|
+
for (const [route, kinds] of Object.entries(legacy.missingKinds ?? {})) {
|
|
842
|
+
if (!Array.isArray(kinds)) continue;
|
|
843
|
+
for (const kind of kinds) {
|
|
844
|
+
if (typeof kind === "string" && isCoreKind(kind)) {
|
|
845
|
+
at(route)[kind] = { type: "backlog" };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return { version: 2, acknowledge };
|
|
850
|
+
}
|
|
851
|
+
function serializeCoverageBaseline(baseline) {
|
|
852
|
+
const routes = Object.keys(baseline.acknowledge).sort();
|
|
853
|
+
const ordered = {};
|
|
854
|
+
for (const route of routes) {
|
|
855
|
+
const scopes = baseline.acknowledge[route];
|
|
856
|
+
const sorted = {};
|
|
857
|
+
for (const scope of ACK_SCOPES) {
|
|
858
|
+
if (scopes[scope]) sorted[scope] = scopes[scope];
|
|
859
|
+
}
|
|
860
|
+
ordered[route] = sorted;
|
|
861
|
+
}
|
|
862
|
+
return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
|
|
863
|
+
}
|
|
864
|
+
|
|
651
865
|
// src/scanner/scan/extract-uidex-export.ts
|
|
652
866
|
var KIND_DISCRIMINATORS = [
|
|
653
867
|
"page",
|
|
@@ -666,26 +880,41 @@ var ALLOWED_FIELDS = {
|
|
|
666
880
|
"widgets",
|
|
667
881
|
"acceptance",
|
|
668
882
|
"states",
|
|
883
|
+
"acknowledge",
|
|
669
884
|
"capture",
|
|
670
885
|
"waivers",
|
|
671
886
|
"description"
|
|
672
887
|
]),
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
888
|
+
// A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
|
|
889
|
+
// and a core kind (one cell of its matrix). A feature/widget has no route, so
|
|
890
|
+
// the core-kind matrix is meaningless there and only `"*"` is accepted —
|
|
891
|
+
// giving a component a scoped opt-out instead of forcing you to delete its
|
|
892
|
+
// whole `states` block. `capture` / `waivers` are REMOVED; they stay parseable
|
|
893
|
+
// only so `--fix` can rewrite them into `acknowledge` (see MIGRATION below).
|
|
676
894
|
feature: /* @__PURE__ */ new Set([
|
|
677
895
|
"feature",
|
|
678
896
|
"name",
|
|
679
897
|
"features",
|
|
680
898
|
"acceptance",
|
|
681
899
|
"states",
|
|
900
|
+
"acknowledge",
|
|
901
|
+
"capture",
|
|
682
902
|
"description"
|
|
683
903
|
]),
|
|
684
904
|
primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
|
|
685
|
-
widget: /* @__PURE__ */ new Set([
|
|
905
|
+
widget: /* @__PURE__ */ new Set([
|
|
906
|
+
"widget",
|
|
907
|
+
"name",
|
|
908
|
+
"acceptance",
|
|
909
|
+
"states",
|
|
910
|
+
"acknowledge",
|
|
911
|
+
"capture",
|
|
912
|
+
"description"
|
|
913
|
+
]),
|
|
686
914
|
region: /* @__PURE__ */ new Set(["region", "name", "description"]),
|
|
687
915
|
flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
|
|
688
916
|
};
|
|
917
|
+
var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
|
|
689
918
|
var STATE_FIELDS = /* @__PURE__ */ new Set([
|
|
690
919
|
"name",
|
|
691
920
|
"kind",
|
|
@@ -936,7 +1165,8 @@ function objectLit(node, content, p2, pos, span) {
|
|
|
936
1165
|
key,
|
|
937
1166
|
value,
|
|
938
1167
|
keyPos,
|
|
939
|
-
span: removalSpan(content, prop.start, prop.end)
|
|
1168
|
+
span: removalSpan(content, prop.start, prop.end),
|
|
1169
|
+
propSpan: { start: prop.start, end: prop.end }
|
|
940
1170
|
});
|
|
941
1171
|
}
|
|
942
1172
|
return { kind: "object", entries, pos, span };
|
|
@@ -1084,8 +1314,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1084
1314
|
const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
|
|
1085
1315
|
const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
|
|
1086
1316
|
const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
|
|
1087
|
-
const
|
|
1088
|
-
const
|
|
1317
|
+
const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
|
|
1318
|
+
const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
|
|
1319
|
+
const legacyCapture = stateBearing ? readBooleanField(byKey, "capture") : void 0;
|
|
1320
|
+
const legacyWaivers = kind === "page" ? readWaiversField(byKey) : void 0;
|
|
1089
1321
|
const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
|
|
1090
1322
|
const metadata = {
|
|
1091
1323
|
source: "ts-export",
|
|
@@ -1109,15 +1341,25 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
|
|
|
1109
1341
|
metadata.widgetSpans = widgetsField.spans;
|
|
1110
1342
|
}
|
|
1111
1343
|
if (states?.length) metadata.states = states;
|
|
1112
|
-
if (
|
|
1113
|
-
|
|
1344
|
+
if (acknowledge && Object.keys(acknowledge).length > 0) {
|
|
1345
|
+
metadata.acknowledge = acknowledge;
|
|
1346
|
+
}
|
|
1347
|
+
if (legacyCapture !== void 0) metadata.legacyCapture = legacyCapture;
|
|
1348
|
+
if (legacyWaivers && Object.keys(legacyWaivers).length > 0) {
|
|
1349
|
+
metadata.legacyWaivers = legacyWaivers;
|
|
1350
|
+
}
|
|
1114
1351
|
if (notFlow) metadata.notFlow = true;
|
|
1115
1352
|
if (typeof id === "string" && idValue.kind === "string") {
|
|
1116
1353
|
metadata.idSpan = idValue.span;
|
|
1117
1354
|
}
|
|
1118
1355
|
const fieldSpans = {};
|
|
1119
|
-
|
|
1356
|
+
const fieldPropSpans = {};
|
|
1357
|
+
for (const entry of value.entries) {
|
|
1358
|
+
fieldSpans[entry.key] = entry.span;
|
|
1359
|
+
fieldPropSpans[entry.key] = entry.propSpan;
|
|
1360
|
+
}
|
|
1120
1361
|
metadata.fieldSpans = fieldSpans;
|
|
1362
|
+
metadata.fieldPropSpans = fieldPropSpans;
|
|
1121
1363
|
return metadata;
|
|
1122
1364
|
}
|
|
1123
1365
|
function readIdField(value, kind, fieldName) {
|
|
@@ -1147,6 +1389,80 @@ function readIdField(value, kind, fieldName) {
|
|
|
1147
1389
|
value.pos
|
|
1148
1390
|
);
|
|
1149
1391
|
}
|
|
1392
|
+
function readAcknowledgeField(byKey, isPage) {
|
|
1393
|
+
const entry = byKey.get("acknowledge");
|
|
1394
|
+
if (!entry) return void 0;
|
|
1395
|
+
if (entry.value.kind !== "object") {
|
|
1396
|
+
throw new ExtractError(
|
|
1397
|
+
"uidex-export-invalid-field",
|
|
1398
|
+
'`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
|
|
1399
|
+
entry.value.pos
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
const out2 = {};
|
|
1403
|
+
for (const e of entry.value.entries) {
|
|
1404
|
+
const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
|
|
1405
|
+
if (!scopes.includes(e.key)) {
|
|
1406
|
+
throw new ExtractError(
|
|
1407
|
+
"uidex-export-invalid-field",
|
|
1408
|
+
`acknowledge scope "${e.key}" is not valid here; expected ${scopes.map((s) => `"${s}"`).join(", ")}${isPage ? "" : " (core-kind scopes are page-only \u2014 the state matrix is route-scoped)"}.`,
|
|
1409
|
+
e.keyPos
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
if (e.value.kind !== "object") {
|
|
1413
|
+
throw new ExtractError(
|
|
1414
|
+
"uidex-export-invalid-field",
|
|
1415
|
+
`acknowledge "${e.key}" must be an object \`{ type, reason? }\`.`,
|
|
1416
|
+
e.value.pos
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
const fieldByKey = /* @__PURE__ */ new Map();
|
|
1420
|
+
for (const f of e.value.entries) {
|
|
1421
|
+
if (!ACK_FIELDS.has(f.key)) {
|
|
1422
|
+
throw new ExtractError(
|
|
1423
|
+
"uidex-export-invalid-field",
|
|
1424
|
+
`Unknown field "${f.key}" in an \`acknowledge\` entry. Allowed: ${[...ACK_FIELDS].sort().join(", ")}.`,
|
|
1425
|
+
f.value.pos
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
fieldByKey.set(f.key, f);
|
|
1429
|
+
}
|
|
1430
|
+
const type = readStringField(fieldByKey, "type");
|
|
1431
|
+
if (type === void 0) {
|
|
1432
|
+
throw new ExtractError(
|
|
1433
|
+
"uidex-export-invalid-field",
|
|
1434
|
+
`acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
|
|
1435
|
+
e.value.pos
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
if (type === "captured-elsewhere") {
|
|
1439
|
+
throw new ExtractError(
|
|
1440
|
+
"uidex-export-invalid-field",
|
|
1441
|
+
'`type: "captured-elsewhere"` cannot be authored; the workspace computes it from the sibling app that captured the declaration.',
|
|
1442
|
+
fieldByKey.get("type").value.pos,
|
|
1443
|
+
"Remove the entry \u2014 if a sibling app really captures this, the workspace reports it as `acknowledged-elsewhere` on its own."
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
if (type !== "impossible" && type !== "backlog") {
|
|
1447
|
+
throw new ExtractError(
|
|
1448
|
+
"uidex-export-invalid-field",
|
|
1449
|
+
`acknowledge \`type\` must be ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")} (got "${type}").`,
|
|
1450
|
+
fieldByKey.get("type").value.pos
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
const reason = readStringField(fieldByKey, "reason");
|
|
1454
|
+
if (type === "impossible" && (reason === void 0 || reason.length === 0)) {
|
|
1455
|
+
throw new ExtractError(
|
|
1456
|
+
"uidex-export-invalid-field",
|
|
1457
|
+
`acknowledge "${e.key}" is \`type: "impossible"\` and needs a non-empty \`reason\` (why this can NEVER be rendered/captured).`,
|
|
1458
|
+
e.value.pos,
|
|
1459
|
+
'If it merely has not been captured yet, that is `type: "backlog"` \u2014 which needs no reason.'
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
out2[e.key] = reason ? { type, reason } : { type };
|
|
1463
|
+
}
|
|
1464
|
+
return out2;
|
|
1465
|
+
}
|
|
1150
1466
|
function readWaiversField(byKey) {
|
|
1151
1467
|
const entry = byKey.get("waivers");
|
|
1152
1468
|
if (!entry) return void 0;
|
|
@@ -1995,9 +2311,8 @@ function buildMetaFromExport(exp) {
|
|
|
1995
2311
|
...s.description ? { description: s.description } : {}
|
|
1996
2312
|
}));
|
|
1997
2313
|
}
|
|
1998
|
-
if (exp.
|
|
1999
|
-
|
|
2000
|
-
meta.waivers = exp.waivers;
|
|
2314
|
+
if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
|
|
2315
|
+
meta.acknowledge = exp.acknowledge;
|
|
2001
2316
|
}
|
|
2002
2317
|
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
2003
2318
|
}
|
|
@@ -2342,6 +2657,45 @@ function resolve2(ctx) {
|
|
|
2342
2657
|
};
|
|
2343
2658
|
registry.add(element);
|
|
2344
2659
|
}
|
|
2660
|
+
const ancestorsById = /* @__PURE__ */ new Map();
|
|
2661
|
+
const fileById = /* @__PURE__ */ new Map();
|
|
2662
|
+
for (const a of allAnnotations) {
|
|
2663
|
+
if (!fileById.has(a.id)) fileById.set(a.id, a.file);
|
|
2664
|
+
if (a.ancestors?.length) {
|
|
2665
|
+
const prev = ancestorsById.get(a.id) ?? [];
|
|
2666
|
+
ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
const ownersByFile = /* @__PURE__ */ new Map();
|
|
2670
|
+
const addOwner = (file, id) => {
|
|
2671
|
+
ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
|
|
2672
|
+
};
|
|
2673
|
+
const featureDirs = [];
|
|
2674
|
+
for (const f of registry.list("feature")) {
|
|
2675
|
+
const loc = f.loc?.file;
|
|
2676
|
+
if (!loc) continue;
|
|
2677
|
+
const dir = loc.endsWith("/") ? loc.slice(0, -1) : path4.posix.dirname(loc);
|
|
2678
|
+
featureDirs.push({ dir, id: f.id });
|
|
2679
|
+
}
|
|
2680
|
+
for (const p2 of registry.list("page")) {
|
|
2681
|
+
if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
|
|
2682
|
+
}
|
|
2683
|
+
const ownersFor = (file) => {
|
|
2684
|
+
const out2 = [...ownersByFile.get(file) ?? []];
|
|
2685
|
+
for (const { dir, id } of featureDirs) {
|
|
2686
|
+
if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
|
|
2687
|
+
}
|
|
2688
|
+
return out2;
|
|
2689
|
+
};
|
|
2690
|
+
const expandTouches = (ids) => {
|
|
2691
|
+
const out2 = new Set(ids);
|
|
2692
|
+
for (const id of ids) {
|
|
2693
|
+
for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
|
|
2694
|
+
const file = fileById.get(id);
|
|
2695
|
+
if (file) for (const owner of ownersFor(file)) out2.add(owner);
|
|
2696
|
+
}
|
|
2697
|
+
return [...out2];
|
|
2698
|
+
};
|
|
2345
2699
|
if (ctx.flowFiles && conventions.flows) {
|
|
2346
2700
|
for (const ff of ctx.flowFiles) {
|
|
2347
2701
|
const notFlowExport = (ff.metadata ?? []).find(
|
|
@@ -2351,7 +2705,10 @@ function resolve2(ctx) {
|
|
|
2351
2705
|
const flowExport = (ff.metadata ?? []).find(
|
|
2352
2706
|
(m) => m.kind === "flow" && typeof m.id === "string"
|
|
2353
2707
|
);
|
|
2354
|
-
const derived = flowsFromFacts(ff)
|
|
2708
|
+
const derived = flowsFromFacts(ff).map((f) => ({
|
|
2709
|
+
...f,
|
|
2710
|
+
touches: expandTouches(f.touches)
|
|
2711
|
+
}));
|
|
2355
2712
|
if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
|
|
2356
2713
|
const base = derived[0];
|
|
2357
2714
|
const flow = {
|
|
@@ -2420,7 +2777,195 @@ function dedupe(arr) {
|
|
|
2420
2777
|
}
|
|
2421
2778
|
|
|
2422
2779
|
// src/scanner/scan/audit.ts
|
|
2423
|
-
import * as
|
|
2780
|
+
import * as path6 from "path";
|
|
2781
|
+
|
|
2782
|
+
// src/scanner/scan/component-coverage.ts
|
|
2783
|
+
var COMPOSED_KINDS = ["widget", "feature"];
|
|
2784
|
+
function composedStateEntities(registry, page) {
|
|
2785
|
+
const meta = registry.get("page", page)?.meta;
|
|
2786
|
+
if (!meta) return [];
|
|
2787
|
+
const out2 = [];
|
|
2788
|
+
for (const kind of COMPOSED_KINDS) {
|
|
2789
|
+
const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
|
|
2790
|
+
for (const id of ids) {
|
|
2791
|
+
const e = registry.get(kind, id);
|
|
2792
|
+
if (!e?.meta?.states?.length) continue;
|
|
2793
|
+
if (e.meta.acknowledge?.[ACK_ALL]) continue;
|
|
2794
|
+
out2.push({ kind, id });
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
return out2;
|
|
2798
|
+
}
|
|
2799
|
+
function capturedStatesFor(captured, kind, id) {
|
|
2800
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
2801
|
+
for (const c of captured) {
|
|
2802
|
+
if (c.entity !== id) continue;
|
|
2803
|
+
if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
|
|
2804
|
+
out2.add(c.state);
|
|
2805
|
+
}
|
|
2806
|
+
return out2;
|
|
2807
|
+
}
|
|
2808
|
+
function checkComponentCoverage(registry, manifest) {
|
|
2809
|
+
const routes = registry.list("route");
|
|
2810
|
+
if (routes.length === 0) return [];
|
|
2811
|
+
const captured = manifest.captured ?? [];
|
|
2812
|
+
const diagnostics = [];
|
|
2813
|
+
for (const route of routes) {
|
|
2814
|
+
if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
|
|
2815
|
+
for (const { kind, id } of composedStateEntities(registry, route.page)) {
|
|
2816
|
+
const entity = registry.get(kind, id);
|
|
2817
|
+
const declared = entity.meta.states.map((s) => s.name);
|
|
2818
|
+
const shot = capturedStatesFor(captured, kind, id);
|
|
2819
|
+
const missing = declared.filter((s) => !shot.has(s));
|
|
2820
|
+
if (missing.length === 0) continue;
|
|
2821
|
+
diagnostics.push({
|
|
2822
|
+
code: "component-state-uncaptured",
|
|
2823
|
+
severity: "info",
|
|
2824
|
+
message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
|
|
2825
|
+
file: entity.loc?.file,
|
|
2826
|
+
line: entity.loc?.line,
|
|
2827
|
+
entity: { kind, id },
|
|
2828
|
+
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}".`
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
return diagnostics;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// src/scanner/scan/migrate-acknowledge.ts
|
|
2836
|
+
var CORE = CORE_STATE_KINDS;
|
|
2837
|
+
function orderScopes(scopes) {
|
|
2838
|
+
const rank = (s) => s === ACK_ALL ? -1 : CORE.indexOf(s);
|
|
2839
|
+
return [...scopes].sort((a, b) => rank(a) - rank(b));
|
|
2840
|
+
}
|
|
2841
|
+
function renderKey(scope) {
|
|
2842
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(scope) ? scope : JSON.stringify(scope);
|
|
2843
|
+
}
|
|
2844
|
+
function renderAck(a) {
|
|
2845
|
+
const parts = [`type: ${JSON.stringify(a.type)}`];
|
|
2846
|
+
if (a.reason) parts.push(`reason: ${JSON.stringify(a.reason)}`);
|
|
2847
|
+
return `{ ${parts.join(", ")} }`;
|
|
2848
|
+
}
|
|
2849
|
+
function renderAcknowledge(entries, indent) {
|
|
2850
|
+
const inner = indent + " ";
|
|
2851
|
+
const lines = orderScopes(Object.keys(entries)).map(
|
|
2852
|
+
(scope) => `${inner}${renderKey(scope)}: ${renderAck(entries[scope])},`
|
|
2853
|
+
);
|
|
2854
|
+
return ["acknowledge: {", ...lines, `${indent}}`].join("\n");
|
|
2855
|
+
}
|
|
2856
|
+
function indentAt(content, offset) {
|
|
2857
|
+
const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
|
|
2858
|
+
const m = /^[ \t]*/.exec(content.slice(lineStart, offset));
|
|
2859
|
+
return m ? m[0] : "";
|
|
2860
|
+
}
|
|
2861
|
+
function plannedEntries(m) {
|
|
2862
|
+
const entries = {};
|
|
2863
|
+
if (m.legacyCapture === false) {
|
|
2864
|
+
entries[ACK_ALL] = {
|
|
2865
|
+
type: "impossible",
|
|
2866
|
+
reason: m.description || PLACEHOLDER_REASON
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
for (const kind of CORE) {
|
|
2870
|
+
const reason = m.legacyWaivers?.[kind];
|
|
2871
|
+
if (reason) entries[kind] = { type: "impossible", reason };
|
|
2872
|
+
}
|
|
2873
|
+
return entries;
|
|
2874
|
+
}
|
|
2875
|
+
function migrateLegacyFields(ef) {
|
|
2876
|
+
const out2 = [];
|
|
2877
|
+
const { content, sourcePath, displayPath } = ef.file;
|
|
2878
|
+
for (const m of ef.metadata ?? []) {
|
|
2879
|
+
const legacy = [];
|
|
2880
|
+
if (m.legacyWaivers) legacy.push("waivers");
|
|
2881
|
+
if (m.legacyCapture !== void 0) legacy.push("capture");
|
|
2882
|
+
if (legacy.length === 0) continue;
|
|
2883
|
+
const fields = legacy.join(" and ");
|
|
2884
|
+
const base = {
|
|
2885
|
+
severity: "error",
|
|
2886
|
+
code: "legacy-acknowledge-field",
|
|
2887
|
+
file: displayPath,
|
|
2888
|
+
line: m.loc.line
|
|
2889
|
+
};
|
|
2890
|
+
if (m.acknowledge) {
|
|
2891
|
+
out2.push({
|
|
2892
|
+
...base,
|
|
2893
|
+
message: `\`${fields}\` was replaced by \`acknowledge\`, and this declaration already has an \`acknowledge\` field`,
|
|
2894
|
+
hint: `Merge the remaining \`${fields}\` entries into \`acknowledge\` by hand and delete them; --fix will not merge two maps for you.`
|
|
2895
|
+
});
|
|
2896
|
+
continue;
|
|
2897
|
+
}
|
|
2898
|
+
const entries = plannedEntries(m);
|
|
2899
|
+
const edits = [];
|
|
2900
|
+
const present = legacy.map((f) => ({ f, prop: m.fieldPropSpans?.[f], del: m.fieldSpans?.[f] })).filter((x) => x.prop && x.del).sort((a, b) => a.prop.start - b.prop.start);
|
|
2901
|
+
if (present.length !== legacy.length) {
|
|
2902
|
+
out2.push({
|
|
2903
|
+
...base,
|
|
2904
|
+
message: `\`${fields}\` was replaced by \`acknowledge\`, but the field spans needed to rewrite it are unavailable`,
|
|
2905
|
+
hint: "Rewrite the declaration by hand: `acknowledge: { <scope>: { type, reason? } }`."
|
|
2906
|
+
});
|
|
2907
|
+
continue;
|
|
2908
|
+
}
|
|
2909
|
+
const [anchor, ...rest] = present;
|
|
2910
|
+
if (Object.keys(entries).length === 0) {
|
|
2911
|
+
edits.push({
|
|
2912
|
+
path: sourcePath,
|
|
2913
|
+
start: anchor.del.start,
|
|
2914
|
+
end: anchor.del.end,
|
|
2915
|
+
replacement: ""
|
|
2916
|
+
});
|
|
2917
|
+
} else {
|
|
2918
|
+
edits.push({
|
|
2919
|
+
path: sourcePath,
|
|
2920
|
+
start: anchor.prop.start,
|
|
2921
|
+
end: anchor.prop.end,
|
|
2922
|
+
replacement: renderAcknowledge(
|
|
2923
|
+
entries,
|
|
2924
|
+
indentAt(content, anchor.prop.start)
|
|
2925
|
+
)
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
for (const r of rest) {
|
|
2929
|
+
edits.push({
|
|
2930
|
+
path: sourcePath,
|
|
2931
|
+
start: r.del.start,
|
|
2932
|
+
end: r.del.end,
|
|
2933
|
+
replacement: ""
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
out2.push({
|
|
2937
|
+
...base,
|
|
2938
|
+
message: `\`${fields}\` was replaced by the unified \`acknowledge\` field`,
|
|
2939
|
+
hint: `Run \`uidex scan --fix\` to rewrite it${m.legacyCapture === false && !m.description ? ", then replace the TODO reason it plants \u2014 `capture: false` carried no explanation and `impossible` requires one" : ""}.`,
|
|
2940
|
+
fix: {
|
|
2941
|
+
description: `Rewrite \`${fields}\` as \`acknowledge\``,
|
|
2942
|
+
edits
|
|
2943
|
+
}
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
return out2;
|
|
2947
|
+
}
|
|
2948
|
+
function migrateLegacyBaseline(opts) {
|
|
2949
|
+
if (!isLegacyBaseline(opts.parsed)) return void 0;
|
|
2950
|
+
return {
|
|
2951
|
+
code: "legacy-coverage-baseline",
|
|
2952
|
+
severity: "error",
|
|
2953
|
+
message: `coverage baseline "${opts.displayPath}" uses the removed \`uncapturedPages\` / \`missingKinds\` shape`,
|
|
2954
|
+
file: opts.displayPath,
|
|
2955
|
+
hint: "Run `uidex scan --fix` to rewrite it as `acknowledge` entries of type `backlog` (identical content, new shape \u2014 no capture run needed).",
|
|
2956
|
+
fix: {
|
|
2957
|
+
description: `Rewrite ${opts.displayPath} as an \`acknowledge\` baseline`,
|
|
2958
|
+
edits: [
|
|
2959
|
+
{
|
|
2960
|
+
path: opts.path,
|
|
2961
|
+
start: 0,
|
|
2962
|
+
end: opts.raw.length,
|
|
2963
|
+
replacement: serializeCoverageBaseline(migrateBaseline(opts.parsed))
|
|
2964
|
+
}
|
|
2965
|
+
]
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2424
2969
|
|
|
2425
2970
|
// src/scanner/scan/page-coverage.ts
|
|
2426
2971
|
function compileRoute(pattern) {
|
|
@@ -2443,11 +2988,11 @@ function compileRoute(pattern) {
|
|
|
2443
2988
|
return { test: (url) => re.test(url), specificity };
|
|
2444
2989
|
}
|
|
2445
2990
|
function matchUrlToRoute(url, routePaths) {
|
|
2446
|
-
const
|
|
2991
|
+
const path13 = stripQuery(url);
|
|
2447
2992
|
let best = null;
|
|
2448
2993
|
for (const rp of routePaths) {
|
|
2449
2994
|
const { test, specificity } = compileRoute(rp);
|
|
2450
|
-
if (!test(
|
|
2995
|
+
if (!test(path13)) continue;
|
|
2451
2996
|
if (!best || specificity > best.specificity)
|
|
2452
2997
|
best = { path: rp, specificity };
|
|
2453
2998
|
}
|
|
@@ -2478,29 +3023,49 @@ function checkPageCoverage(registry, manifest, baseline) {
|
|
|
2478
3023
|
if (routes.length === 0) return [];
|
|
2479
3024
|
const routePaths = routes.map((r) => r.path);
|
|
2480
3025
|
const captured = manifest.captured ?? [];
|
|
2481
|
-
const optedOut = /* @__PURE__ */ new Set();
|
|
2482
|
-
for (const r of routes) {
|
|
2483
|
-
const page = registry.get("page", r.page);
|
|
2484
|
-
if (page?.meta?.capture === false) optedOut.add(r.path);
|
|
2485
|
-
}
|
|
2486
3026
|
const covered = /* @__PURE__ */ new Set();
|
|
2487
3027
|
for (const c of captured) {
|
|
2488
3028
|
const route = routeForCapture(c, routes, routePaths);
|
|
2489
3029
|
if (route) covered.add(route);
|
|
2490
3030
|
}
|
|
2491
|
-
const baselineSet = new Set(baseline?.uncapturedPages ?? []);
|
|
2492
3031
|
const diagnostics = [];
|
|
2493
3032
|
for (const r of routes) {
|
|
2494
|
-
|
|
3033
|
+
const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
|
|
3034
|
+
ACK_ALL
|
|
3035
|
+
);
|
|
2495
3036
|
const loc = registry.get("page", r.page)?.loc;
|
|
2496
|
-
if (
|
|
3037
|
+
if (covered.has(r.path)) {
|
|
3038
|
+
if (ack?.type === "impossible") {
|
|
3039
|
+
diagnostics.push({
|
|
3040
|
+
code: "stale-acknowledgment",
|
|
3041
|
+
severity: "warning",
|
|
3042
|
+
message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
|
|
3043
|
+
file: loc?.file,
|
|
3044
|
+
line: loc?.line,
|
|
3045
|
+
entity: { kind: "page", id: r.page },
|
|
3046
|
+
hint: originHint(ack, r.page)
|
|
3047
|
+
});
|
|
3048
|
+
} else if (ack?.type === "backlog") {
|
|
3049
|
+
diagnostics.push({
|
|
3050
|
+
code: "page-captured",
|
|
3051
|
+
severity: "warning",
|
|
3052
|
+
message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
|
|
3053
|
+
file: loc?.file,
|
|
3054
|
+
line: loc?.line,
|
|
3055
|
+
hint: originHint(ack, r.page)
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
continue;
|
|
3059
|
+
}
|
|
3060
|
+
if (ack?.type === "impossible") continue;
|
|
3061
|
+
if (ack?.type === "backlog") {
|
|
2497
3062
|
diagnostics.push({
|
|
2498
3063
|
code: "page-backlog",
|
|
2499
3064
|
severity: "info",
|
|
2500
3065
|
message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
|
|
2501
3066
|
file: loc?.file,
|
|
2502
3067
|
line: loc?.line,
|
|
2503
|
-
hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or
|
|
3068
|
+
hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or \u2014 if it can NEVER be captured \u2014 say so with \`acknowledge: { "*": { type: "impossible", reason: "\u2026" } }\` on page "${r.page}".`
|
|
2504
3069
|
});
|
|
2505
3070
|
continue;
|
|
2506
3071
|
}
|
|
@@ -2511,46 +3076,155 @@ function checkPageCoverage(registry, manifest, baseline) {
|
|
|
2511
3076
|
file: loc?.file,
|
|
2512
3077
|
line: loc?.line,
|
|
2513
3078
|
entity: { kind: "route", id: r.path },
|
|
2514
|
-
hint: `Capture the route in a states spec
|
|
3079
|
+
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).`
|
|
2515
3080
|
});
|
|
2516
3081
|
}
|
|
2517
|
-
for (const b of
|
|
2518
|
-
if (
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
} else if (covered.has(b) || optedOut.has(b)) {
|
|
2526
|
-
diagnostics.push({
|
|
2527
|
-
code: "page-captured",
|
|
2528
|
-
severity: "warning",
|
|
2529
|
-
message: `route "${b}" is now captured but is still in the coverage baseline`,
|
|
2530
|
-
hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
|
|
2531
|
-
});
|
|
2532
|
-
}
|
|
3082
|
+
for (const b of Object.keys(baseline?.acknowledge ?? {})) {
|
|
3083
|
+
if (routePaths.includes(b)) continue;
|
|
3084
|
+
diagnostics.push({
|
|
3085
|
+
code: "stale-page-baseline",
|
|
3086
|
+
severity: "warning",
|
|
3087
|
+
message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
|
|
3088
|
+
hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
|
|
3089
|
+
});
|
|
2533
3090
|
}
|
|
2534
3091
|
return diagnostics;
|
|
2535
3092
|
}
|
|
2536
|
-
function
|
|
2537
|
-
const
|
|
3093
|
+
function computePageBacklog(registry, manifest) {
|
|
3094
|
+
const out2 = {};
|
|
2538
3095
|
for (const d of checkPageCoverage(registry, manifest, {
|
|
2539
|
-
|
|
3096
|
+
version: 2,
|
|
3097
|
+
acknowledge: {}
|
|
2540
3098
|
})) {
|
|
2541
|
-
if (d.code === "page-no-capture" && d.entity)
|
|
3099
|
+
if (d.code === "page-no-capture" && d.entity) {
|
|
3100
|
+
out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
|
|
3101
|
+
}
|
|
2542
3102
|
}
|
|
2543
|
-
return
|
|
3103
|
+
return out2;
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// src/scanner/scan/workspace.ts
|
|
3107
|
+
import * as fs3 from "fs";
|
|
3108
|
+
import * as path5 from "path";
|
|
3109
|
+
var WORKSPACE_MARKERS = [
|
|
3110
|
+
"pnpm-workspace.yaml",
|
|
3111
|
+
"pnpm-workspace.yml",
|
|
3112
|
+
"turbo.json",
|
|
3113
|
+
"lerna.json",
|
|
3114
|
+
"nx.json",
|
|
3115
|
+
".git"
|
|
3116
|
+
];
|
|
3117
|
+
var MAX_ASCENT = 8;
|
|
3118
|
+
var DEFAULT_STATES_MANIFEST = "uidex-states.json";
|
|
3119
|
+
function captureKey(kind, entity) {
|
|
3120
|
+
return `${kind ?? ""}|${entity}`;
|
|
3121
|
+
}
|
|
3122
|
+
function hasMarker(dir) {
|
|
3123
|
+
return WORKSPACE_MARKERS.some((m) => fs3.existsSync(path5.join(dir, m)));
|
|
3124
|
+
}
|
|
3125
|
+
function findWorkspaceRoot(configDir) {
|
|
3126
|
+
let dir = path5.resolve(configDir);
|
|
3127
|
+
for (let i = 0; i < MAX_ASCENT; i++) {
|
|
3128
|
+
const parent = path5.dirname(dir);
|
|
3129
|
+
if (parent === dir) return void 0;
|
|
3130
|
+
dir = parent;
|
|
3131
|
+
if (hasMarker(dir)) return dir;
|
|
3132
|
+
}
|
|
3133
|
+
return void 0;
|
|
3134
|
+
}
|
|
3135
|
+
function captureKeys(manifest) {
|
|
3136
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3137
|
+
for (const c of manifest?.captured ?? []) {
|
|
3138
|
+
out2.add(captureKey(c.kind, c.entity));
|
|
3139
|
+
}
|
|
3140
|
+
return out2;
|
|
3141
|
+
}
|
|
3142
|
+
function readManifest(configDir, config) {
|
|
3143
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
|
|
3144
|
+
let raw;
|
|
3145
|
+
try {
|
|
3146
|
+
raw = fs3.readFileSync(path5.resolve(configDir, rel), "utf8");
|
|
3147
|
+
} catch {
|
|
3148
|
+
return void 0;
|
|
3149
|
+
}
|
|
3150
|
+
try {
|
|
3151
|
+
const parsed = JSON.parse(raw);
|
|
3152
|
+
if (!parsed || !Array.isArray(parsed.captured)) return void 0;
|
|
3153
|
+
const captured = [];
|
|
3154
|
+
for (const entry of parsed.captured) {
|
|
3155
|
+
if (!entry || typeof entry !== "object") continue;
|
|
3156
|
+
const c = entry;
|
|
3157
|
+
if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
|
|
3158
|
+
const rec = { entity: c.entity, state: c.state };
|
|
3159
|
+
if (typeof c.kind === "string") rec.kind = c.kind;
|
|
3160
|
+
captured.push(rec);
|
|
3161
|
+
}
|
|
3162
|
+
return { captured };
|
|
3163
|
+
} catch {
|
|
3164
|
+
return void 0;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
function toApp(dc) {
|
|
3168
|
+
return {
|
|
3169
|
+
name: path5.basename(dc.configDir),
|
|
3170
|
+
configDir: dc.configDir,
|
|
3171
|
+
configPath: dc.configPath,
|
|
3172
|
+
registers: createSourceMatcher(dc.config.sources, {
|
|
3173
|
+
cwd: dc.configDir,
|
|
3174
|
+
globalExcludes: dc.config.exclude
|
|
3175
|
+
}),
|
|
3176
|
+
captured: captureKeys(readManifest(dc.configDir, dc.config))
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
function resolveWorkspace(opts) {
|
|
3180
|
+
const { configDir, config } = opts;
|
|
3181
|
+
if (config.workspace === false) return void 0;
|
|
3182
|
+
const root = typeof config.workspace === "string" ? path5.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
|
|
3183
|
+
if (!root) return void 0;
|
|
3184
|
+
const configs = opts.configs ?? discover({ cwd: root, all: true });
|
|
3185
|
+
const self = path5.resolve(configDir, CONFIG_FILENAME);
|
|
3186
|
+
const siblings = configs.filter((dc) => path5.resolve(dc.configPath) !== self).map(toApp);
|
|
3187
|
+
if (siblings.length === 0) return void 0;
|
|
3188
|
+
return { root, siblings };
|
|
3189
|
+
}
|
|
3190
|
+
function capturedElsewhere(workspace, absFile, kind, id) {
|
|
3191
|
+
if (!workspace || !absFile) return void 0;
|
|
3192
|
+
for (const app of workspace.siblings) {
|
|
3193
|
+
if (!app.registers(absFile)) continue;
|
|
3194
|
+
if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
|
|
3195
|
+
return app.name;
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
return void 0;
|
|
2544
3199
|
}
|
|
2545
3200
|
|
|
2546
3201
|
// src/scanner/scan/state-coverage.ts
|
|
2547
3202
|
var STATE_KINDS2 = ["page", "feature", "widget"];
|
|
2548
|
-
function declaredStateEntities(registry) {
|
|
3203
|
+
function declaredStateEntities(registry, opts, ungated) {
|
|
2549
3204
|
const out2 = [];
|
|
2550
3205
|
for (const kind of STATE_KINDS2) {
|
|
2551
3206
|
for (const e of registry.list(kind)) {
|
|
2552
3207
|
const states = e.meta?.states;
|
|
2553
3208
|
if (!states || states.length === 0) continue;
|
|
3209
|
+
const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
|
|
3210
|
+
const ack = entityAcknowledge(
|
|
3211
|
+
e.meta,
|
|
3212
|
+
capturedElsewhere(opts.workspace, absFile, kind, e.id)
|
|
3213
|
+
);
|
|
3214
|
+
if (ack) {
|
|
3215
|
+
if (ack.origin === "workspace") {
|
|
3216
|
+
ungated.push({
|
|
3217
|
+
code: "acknowledged-elsewhere",
|
|
3218
|
+
severity: "info",
|
|
3219
|
+
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)})`,
|
|
3220
|
+
file: e.loc?.file,
|
|
3221
|
+
line: e.loc?.line,
|
|
3222
|
+
entity: { kind, id: e.id },
|
|
3223
|
+
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.`
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
2554
3228
|
out2.push({
|
|
2555
3229
|
kind,
|
|
2556
3230
|
id: e.id,
|
|
@@ -2561,18 +3235,31 @@ function declaredStateEntities(registry) {
|
|
|
2561
3235
|
}
|
|
2562
3236
|
return out2;
|
|
2563
3237
|
}
|
|
2564
|
-
function
|
|
3238
|
+
function unreliablePageKinds(registry, captured) {
|
|
3239
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
3240
|
+
for (const c of captured) {
|
|
3241
|
+
if (c.kind !== "page") continue;
|
|
3242
|
+
if (registry.get("page", c.entity)) continue;
|
|
3243
|
+
out2.add(c.entity);
|
|
3244
|
+
}
|
|
3245
|
+
return out2;
|
|
3246
|
+
}
|
|
3247
|
+
function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
|
|
2565
3248
|
if (cap.entity !== ent.id) return false;
|
|
2566
|
-
|
|
3249
|
+
if (cap.kind === void 0) return true;
|
|
3250
|
+
if (cap.kind === ent.kind) return true;
|
|
3251
|
+
return cap.kind === "page" && unreliable.has(cap.entity);
|
|
2567
3252
|
}
|
|
2568
|
-
|
|
3253
|
+
var EMPTY = /* @__PURE__ */ new Set();
|
|
3254
|
+
function checkStateCoverage(registry, manifest, opts = {}) {
|
|
2569
3255
|
const diagnostics = [];
|
|
2570
|
-
const declared = declaredStateEntities(registry);
|
|
2571
3256
|
const captured = manifest.captured ?? [];
|
|
3257
|
+
const unreliable = unreliablePageKinds(registry, captured);
|
|
3258
|
+
const declared = declaredStateEntities(registry, opts, diagnostics);
|
|
2572
3259
|
for (const ent of declared) {
|
|
2573
3260
|
for (const state of ent.states) {
|
|
2574
3261
|
const hit = captured.some(
|
|
2575
|
-
(c) => captureMatchesEntity(c, ent) && c.state === state
|
|
3262
|
+
(c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
|
|
2576
3263
|
);
|
|
2577
3264
|
if (hit) continue;
|
|
2578
3265
|
diagnostics.push({
|
|
@@ -2587,7 +3274,9 @@ function checkStateCoverage(registry, manifest) {
|
|
|
2587
3274
|
}
|
|
2588
3275
|
}
|
|
2589
3276
|
for (const cap of captured) {
|
|
2590
|
-
const candidates = declared.filter(
|
|
3277
|
+
const candidates = declared.filter(
|
|
3278
|
+
(ent2) => captureMatchesEntity(cap, ent2, unreliable)
|
|
3279
|
+
);
|
|
2591
3280
|
if (candidates.length === 0) continue;
|
|
2592
3281
|
const declaredSomewhere = candidates.some(
|
|
2593
3282
|
(ent2) => ent2.states.includes(cap.state)
|
|
@@ -2631,36 +3320,35 @@ function coreKindsByRoute(registry, manifest) {
|
|
|
2631
3320
|
}
|
|
2632
3321
|
return byRoute;
|
|
2633
3322
|
}
|
|
2634
|
-
function pageWaivers(registry, routePage) {
|
|
2635
|
-
return registry.get("page", routePage)?.meta?.waivers ?? {};
|
|
2636
|
-
}
|
|
2637
|
-
function isOptedOut(registry, page) {
|
|
2638
|
-
return page ? registry.get("page", page)?.meta?.capture === false : false;
|
|
2639
|
-
}
|
|
2640
3323
|
function checkStateMatrix(registry, manifest, baseline) {
|
|
2641
3324
|
const routes = registry.list("route");
|
|
2642
3325
|
if (routes.length === 0) return [];
|
|
2643
3326
|
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
2644
3327
|
const captured = coreKindsByRoute(registry, manifest);
|
|
2645
|
-
const baselined = baseline?.missingKinds ?? {};
|
|
2646
3328
|
const diagnostics = [];
|
|
2647
3329
|
for (const [route, kinds] of captured) {
|
|
2648
3330
|
const page = pageOf.get(route);
|
|
2649
|
-
|
|
2650
|
-
const waivers = page ? pageWaivers(registry, page) : {};
|
|
3331
|
+
const ack = routeAcknowledge(registry, baseline, route, page);
|
|
2651
3332
|
const loc = page ? registry.get("page", page)?.loc : void 0;
|
|
2652
|
-
const
|
|
3333
|
+
const all = ack.get(ACK_ALL);
|
|
3334
|
+
if (all) {
|
|
3335
|
+
if (all.type === "impossible") {
|
|
3336
|
+
diagnostics.push(staleDiagnostic(all, route, page, loc));
|
|
3337
|
+
}
|
|
3338
|
+
continue;
|
|
3339
|
+
}
|
|
2653
3340
|
for (const kind of CORE_KINDS) {
|
|
2654
3341
|
if (kinds.has(kind)) continue;
|
|
2655
|
-
|
|
2656
|
-
if (
|
|
3342
|
+
const entry = ack.get(kind);
|
|
3343
|
+
if (entry?.type === "impossible") continue;
|
|
3344
|
+
if (entry?.type === "backlog") {
|
|
2657
3345
|
diagnostics.push({
|
|
2658
3346
|
code: "matrix-backlog",
|
|
2659
3347
|
severity: "info",
|
|
2660
|
-
message: `route "${route}" does not render core kind "${kind}" (acknowledged
|
|
3348
|
+
message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
|
|
2661
3349
|
file: loc?.file,
|
|
2662
3350
|
line: loc?.line,
|
|
2663
|
-
hint: `Capture the "${kind}" state for "${route}",
|
|
3351
|
+
hint: `Capture the "${kind}" state for "${route}", or if the route CANNOT render it change the acknowledgment to \`{ type: "impossible", reason: "why it can't" }\`.`
|
|
2664
3352
|
});
|
|
2665
3353
|
continue;
|
|
2666
3354
|
}
|
|
@@ -2671,42 +3359,43 @@ function checkStateMatrix(registry, manifest, baseline) {
|
|
|
2671
3359
|
file: loc?.file,
|
|
2672
3360
|
line: loc?.line,
|
|
2673
3361
|
entity: { kind: "route", id: route },
|
|
2674
|
-
hint: `Capture the "${kind}" state
|
|
3362
|
+
hint: `Capture the "${kind}" state; or if the route CANNOT render it, add \`acknowledge: { ${kind}: { type: "impossible", reason: "why it can't" } }\` to page "${page}"; or record it as debt with \`uidex scan --update-baseline\` (which writes \`{ type: "backlog" }\`, visible in the diff for review).`
|
|
2675
3363
|
});
|
|
2676
3364
|
}
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
if (!page || isOptedOut(registry, page)) continue;
|
|
2681
|
-
const waivers = pageWaivers(registry, page);
|
|
2682
|
-
const loc = registry.get("page", page)?.loc;
|
|
2683
|
-
for (const kind of Object.keys(waivers)) {
|
|
2684
|
-
if (kinds.has(kind)) {
|
|
2685
|
-
diagnostics.push({
|
|
2686
|
-
code: "stale-waiver",
|
|
2687
|
-
severity: "warning",
|
|
2688
|
-
message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
|
|
2689
|
-
file: loc?.file,
|
|
2690
|
-
line: loc?.line,
|
|
2691
|
-
entity: { kind: "page", id: page },
|
|
2692
|
-
hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
|
|
2693
|
-
});
|
|
2694
|
-
}
|
|
3365
|
+
for (const [scope, entry] of ack) {
|
|
3366
|
+
if (scope === ACK_ALL || !kinds.has(scope)) continue;
|
|
3367
|
+
diagnostics.push(staleDiagnostic(entry, route, page, loc));
|
|
2695
3368
|
}
|
|
2696
3369
|
}
|
|
2697
3370
|
return diagnostics;
|
|
2698
3371
|
}
|
|
2699
|
-
function
|
|
3372
|
+
function staleDiagnostic(entry, route, page, loc) {
|
|
3373
|
+
const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
|
|
3374
|
+
return {
|
|
3375
|
+
code: "stale-acknowledgment",
|
|
3376
|
+
severity: "warning",
|
|
3377
|
+
message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
|
|
3378
|
+
file: loc?.file,
|
|
3379
|
+
line: loc?.line,
|
|
3380
|
+
...page ? { entity: { kind: "page", id: page } } : {},
|
|
3381
|
+
hint: originHint(entry, page)
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
function computeMatrixBacklog(registry, manifest) {
|
|
2700
3385
|
const routes = registry.list("route");
|
|
2701
3386
|
const pageOf = new Map(routes.map((r) => [r.path, r.page]));
|
|
2702
3387
|
const captured = coreKindsByRoute(registry, manifest);
|
|
2703
3388
|
const out2 = {};
|
|
2704
3389
|
for (const [route, kinds] of captured) {
|
|
2705
3390
|
const page = pageOf.get(route);
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
const
|
|
2709
|
-
|
|
3391
|
+
const ack = routeAcknowledge(registry, void 0, route, page);
|
|
3392
|
+
if (ack.has(ACK_ALL)) continue;
|
|
3393
|
+
const scopes = {};
|
|
3394
|
+
for (const kind of CORE_KINDS) {
|
|
3395
|
+
if (kinds.has(kind) || ack.has(kind)) continue;
|
|
3396
|
+
scopes[kind] = { type: "backlog" };
|
|
3397
|
+
}
|
|
3398
|
+
if (Object.keys(scopes).length > 0) out2[route] = scopes;
|
|
2710
3399
|
}
|
|
2711
3400
|
return out2;
|
|
2712
3401
|
}
|
|
@@ -2730,6 +3419,23 @@ function audit(opts) {
|
|
|
2730
3419
|
for (const ef of opts.flowExtracted ?? []) {
|
|
2731
3420
|
if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
|
|
2732
3421
|
}
|
|
3422
|
+
for (const ef of extracted) diagnostics.push(...migrateLegacyFields(ef));
|
|
3423
|
+
if (opts.baselineDiagnostic) diagnostics.push(opts.baselineDiagnostic);
|
|
3424
|
+
for (const ef of extracted) {
|
|
3425
|
+
for (const m of ef.metadata ?? []) {
|
|
3426
|
+
for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
|
|
3427
|
+
if (!isPlaceholderReason(ack.reason)) continue;
|
|
3428
|
+
diagnostics.push({
|
|
3429
|
+
code: "placeholder-reason",
|
|
3430
|
+
severity: "warning",
|
|
3431
|
+
message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
|
|
3432
|
+
file: ef.file.displayPath,
|
|
3433
|
+
line: m.loc.line,
|
|
3434
|
+
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).`
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
2733
3439
|
if (check && opts.generated !== void 0) {
|
|
2734
3440
|
const outRel = opts.outputPath ?? config.output;
|
|
2735
3441
|
const fresh = normalizeForCheck(opts.generated);
|
|
@@ -2843,8 +3549,8 @@ function audit(opts) {
|
|
|
2843
3549
|
if (typeof m.id !== "string") continue;
|
|
2844
3550
|
const filePath = ef.file.displayPath;
|
|
2845
3551
|
const wellKnownName = WELL_KNOWN_FILES[m.kind];
|
|
2846
|
-
if (
|
|
2847
|
-
const dir =
|
|
3552
|
+
if (path6.posix.basename(filePath) === wellKnownName) continue;
|
|
3553
|
+
const dir = path6.posix.dirname(filePath);
|
|
2848
3554
|
const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
|
|
2849
3555
|
if (scannedPaths.has(wellKnownPath)) continue;
|
|
2850
3556
|
const kindLabel = m.kind === "page" ? "Page" : "Feature";
|
|
@@ -3058,25 +3764,41 @@ function audit(opts) {
|
|
|
3058
3764
|
}
|
|
3059
3765
|
}
|
|
3060
3766
|
if (lint && statesEnabled && opts.capturedStates) {
|
|
3061
|
-
|
|
3767
|
+
const sourceByDisplay = new Map(
|
|
3768
|
+
files.map((f) => [f.displayPath, f.sourcePath])
|
|
3769
|
+
);
|
|
3770
|
+
diagnostics.push(
|
|
3771
|
+
...checkStateCoverage(registry, opts.capturedStates, {
|
|
3772
|
+
workspace: opts.workspace,
|
|
3773
|
+
resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
|
|
3774
|
+
})
|
|
3775
|
+
);
|
|
3062
3776
|
}
|
|
3063
3777
|
if (lint && pageCoverageEnabled && opts.capturedStates) {
|
|
3064
3778
|
diagnostics.push(
|
|
3065
|
-
...checkPageCoverage(registry, opts.capturedStates, opts.
|
|
3779
|
+
...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
|
|
3066
3780
|
);
|
|
3067
3781
|
}
|
|
3782
|
+
if (lint && statesEnabled && opts.capturedStates) {
|
|
3783
|
+
diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
|
|
3784
|
+
}
|
|
3068
3785
|
if (lint && stateMatrixEnabled && opts.capturedStates) {
|
|
3069
3786
|
diagnostics.push(
|
|
3070
|
-
...checkStateMatrix(registry, opts.capturedStates,
|
|
3071
|
-
missingKinds: opts.pageBaseline?.missingKinds ?? {}
|
|
3072
|
-
})
|
|
3787
|
+
...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
|
|
3073
3788
|
);
|
|
3074
3789
|
}
|
|
3790
|
+
const overrides = config.audit?.severity ?? {};
|
|
3791
|
+
const governed = diagnostics.flatMap((d) => {
|
|
3792
|
+
const level = overrides[d.code];
|
|
3793
|
+
if (level === void 0) return [d];
|
|
3794
|
+
if (level === "off") return [];
|
|
3795
|
+
return [{ ...d, severity: level }];
|
|
3796
|
+
});
|
|
3075
3797
|
const summary = {
|
|
3076
|
-
errors:
|
|
3077
|
-
warnings:
|
|
3798
|
+
errors: governed.filter((d) => d.severity === "error").length,
|
|
3799
|
+
warnings: governed.filter((d) => d.severity === "warning").length
|
|
3078
3800
|
};
|
|
3079
|
-
return { diagnostics, summary };
|
|
3801
|
+
return { diagnostics: governed, summary };
|
|
3080
3802
|
}
|
|
3081
3803
|
function lineOfOffset(content, offset) {
|
|
3082
3804
|
let line = 1;
|
|
@@ -3404,6 +4126,30 @@ ${body}
|
|
|
3404
4126
|
`;
|
|
3405
4127
|
}
|
|
3406
4128
|
var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
|
|
4129
|
+
function stateEntities(list) {
|
|
4130
|
+
return list.map((e) => ({
|
|
4131
|
+
id: e.id,
|
|
4132
|
+
states: (e.meta?.states ?? []).map((s) => s.name)
|
|
4133
|
+
}));
|
|
4134
|
+
}
|
|
4135
|
+
function emitStateMap(groups) {
|
|
4136
|
+
const lines = ["export interface UidexStates {"];
|
|
4137
|
+
for (const g of groups) {
|
|
4138
|
+
const withStates = g.entities.filter((e) => e.states.length > 0);
|
|
4139
|
+
if (withStates.length === 0) {
|
|
4140
|
+
lines.push(` ${g.kind}: never`);
|
|
4141
|
+
continue;
|
|
4142
|
+
}
|
|
4143
|
+
lines.push(` ${g.kind}: {`);
|
|
4144
|
+
for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
4145
|
+
const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
|
|
4146
|
+
lines.push(` ${JSON.stringify(e.id)}: ${states}`);
|
|
4147
|
+
}
|
|
4148
|
+
lines.push(" }");
|
|
4149
|
+
}
|
|
4150
|
+
lines.push("}");
|
|
4151
|
+
return lines.join("\n") + "\n";
|
|
4152
|
+
}
|
|
3407
4153
|
function emitId(name, ids) {
|
|
3408
4154
|
if (OPAQUE_ID_UNIONS.has(name)) {
|
|
3409
4155
|
return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
|
|
@@ -3491,6 +4237,14 @@ function emit(opts) {
|
|
|
3491
4237
|
);
|
|
3492
4238
|
t.push(emitId("StateId", [...stateNames]));
|
|
3493
4239
|
t.push("");
|
|
4240
|
+
t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
|
|
4241
|
+
t.push(
|
|
4242
|
+
emitStateMap([
|
|
4243
|
+
{ kind: "page", entities: stateEntities(pages) },
|
|
4244
|
+
{ kind: "feature", entities: stateEntities(features) },
|
|
4245
|
+
{ kind: "widget", entities: stateEntities(widgets) }
|
|
4246
|
+
])
|
|
4247
|
+
);
|
|
3494
4248
|
t.push("// ---- authoring-surface shape types ----");
|
|
3495
4249
|
t.push("export namespace Uidex {");
|
|
3496
4250
|
t.push(
|
|
@@ -3503,7 +4257,20 @@ function emit(opts) {
|
|
|
3503
4257
|
t.push(" acceptance?: readonly string[]");
|
|
3504
4258
|
t.push(" description?: string");
|
|
3505
4259
|
t.push(" }");
|
|
3506
|
-
t.push(
|
|
4260
|
+
t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
|
|
4261
|
+
t.push(" export interface Impossible {");
|
|
4262
|
+
t.push(' type: "impossible"');
|
|
4263
|
+
t.push(" reason: string");
|
|
4264
|
+
t.push(" }");
|
|
4265
|
+
t.push(" export interface Backlog {");
|
|
4266
|
+
t.push(' type: "backlog"');
|
|
4267
|
+
t.push(" reason?: string");
|
|
4268
|
+
t.push(" }");
|
|
4269
|
+
t.push(" export type Acknowledgment = Impossible | Backlog");
|
|
4270
|
+
t.push(
|
|
4271
|
+
" export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
|
|
4272
|
+
);
|
|
4273
|
+
t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
|
|
3507
4274
|
t.push(" export interface Page {");
|
|
3508
4275
|
t.push(" page: PageId | false");
|
|
3509
4276
|
t.push(" name?: string");
|
|
@@ -3511,8 +4278,7 @@ function emit(opts) {
|
|
|
3511
4278
|
t.push(" widgets?: readonly WidgetId[]");
|
|
3512
4279
|
t.push(" acceptance?: readonly string[]");
|
|
3513
4280
|
t.push(" states?: readonly (string | State)[]");
|
|
3514
|
-
t.push("
|
|
3515
|
-
t.push(" waivers?: Waivers");
|
|
4281
|
+
t.push(" acknowledge?: Acknowledge");
|
|
3516
4282
|
t.push(" description?: string");
|
|
3517
4283
|
t.push(" }");
|
|
3518
4284
|
t.push(" export interface Feature {");
|
|
@@ -3521,6 +4287,7 @@ function emit(opts) {
|
|
|
3521
4287
|
t.push(" features?: readonly FeatureId[]");
|
|
3522
4288
|
t.push(" acceptance?: readonly string[]");
|
|
3523
4289
|
t.push(" states?: readonly (string | State)[]");
|
|
4290
|
+
t.push(" acknowledge?: ComponentAcknowledge");
|
|
3524
4291
|
t.push(" description?: string");
|
|
3525
4292
|
t.push(" }");
|
|
3526
4293
|
t.push(" export interface Primitive {");
|
|
@@ -3533,6 +4300,7 @@ function emit(opts) {
|
|
|
3533
4300
|
t.push(" name?: string");
|
|
3534
4301
|
t.push(" acceptance?: readonly string[]");
|
|
3535
4302
|
t.push(" states?: readonly (string | State)[]");
|
|
4303
|
+
t.push(" acknowledge?: ComponentAcknowledge");
|
|
3536
4304
|
t.push(" description?: string");
|
|
3537
4305
|
t.push(" }");
|
|
3538
4306
|
t.push(" export interface Region {");
|
|
@@ -3628,8 +4396,8 @@ function parseGitHubRef(ref) {
|
|
|
3628
4396
|
}
|
|
3629
4397
|
|
|
3630
4398
|
// src/scanner/scan/scaffold.ts
|
|
3631
|
-
import * as
|
|
3632
|
-
import * as
|
|
4399
|
+
import * as fs4 from "fs";
|
|
4400
|
+
import * as path7 from "path";
|
|
3633
4401
|
function scaffoldWidgetSpec(opts) {
|
|
3634
4402
|
return scaffoldSpec({
|
|
3635
4403
|
registry: opts.registry,
|
|
@@ -3655,8 +4423,8 @@ function scaffoldSpec(opts) {
|
|
|
3655
4423
|
}
|
|
3656
4424
|
const criteria = entity.meta?.acceptance ?? [];
|
|
3657
4425
|
const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
|
|
3658
|
-
const outputPath =
|
|
3659
|
-
if (
|
|
4426
|
+
const outputPath = path7.resolve(outDir, filename);
|
|
4427
|
+
if (fs4.existsSync(outputPath) && !force) {
|
|
3660
4428
|
return {
|
|
3661
4429
|
outputPath,
|
|
3662
4430
|
written: false,
|
|
@@ -3665,8 +4433,8 @@ function scaffoldSpec(opts) {
|
|
|
3665
4433
|
};
|
|
3666
4434
|
}
|
|
3667
4435
|
const content = renderSpec({ id, criteria, fixtureImport });
|
|
3668
|
-
|
|
3669
|
-
|
|
4436
|
+
fs4.mkdirSync(path7.dirname(outputPath), { recursive: true });
|
|
4437
|
+
fs4.writeFileSync(outputPath, content, "utf8");
|
|
3670
4438
|
return { outputPath, written: true, skipped: false };
|
|
3671
4439
|
}
|
|
3672
4440
|
function capitalize(s) {
|
|
@@ -3674,25 +4442,27 @@ function capitalize(s) {
|
|
|
3674
4442
|
}
|
|
3675
4443
|
function renderSpec(args) {
|
|
3676
4444
|
const lines = [];
|
|
3677
|
-
lines.push(
|
|
3678
|
-
`import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
|
|
3679
|
-
);
|
|
4445
|
+
lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
|
|
3680
4446
|
lines.push("");
|
|
3681
4447
|
lines.push(
|
|
3682
4448
|
`test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
|
|
3683
4449
|
);
|
|
3684
|
-
|
|
3685
|
-
lines.push(` test(
|
|
3686
|
-
lines.push(`
|
|
4450
|
+
const stub = (title, body) => {
|
|
4451
|
+
lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
|
|
4452
|
+
for (const l of body) lines.push(` ${l}`);
|
|
4453
|
+
lines.push(` void uidex`);
|
|
3687
4454
|
lines.push(` })`);
|
|
4455
|
+
lines.push("");
|
|
4456
|
+
};
|
|
4457
|
+
if (args.criteria.length === 0) {
|
|
4458
|
+
stub("TODO: add acceptance criteria", [
|
|
4459
|
+
"// Declare `acceptance: [...]` on the entity, then re-run scaffold."
|
|
4460
|
+
]);
|
|
3688
4461
|
} else {
|
|
3689
4462
|
for (const criterion of args.criteria) {
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
lines.push(` expect(true).toBe(true)`);
|
|
3694
|
-
lines.push(` })`);
|
|
3695
|
-
lines.push("");
|
|
4463
|
+
stub(criterion, [
|
|
4464
|
+
"// TODO: drive this criterion, then remove `.fixme` to arm the test."
|
|
4465
|
+
]);
|
|
3696
4466
|
}
|
|
3697
4467
|
}
|
|
3698
4468
|
lines.push("})");
|
|
@@ -3701,47 +4471,40 @@ function renderSpec(args) {
|
|
|
3701
4471
|
}
|
|
3702
4472
|
|
|
3703
4473
|
// src/scanner/scan/pipeline.ts
|
|
3704
|
-
import * as
|
|
3705
|
-
import * as
|
|
3706
|
-
var
|
|
4474
|
+
import * as fs5 from "fs";
|
|
4475
|
+
import * as path8 from "path";
|
|
4476
|
+
var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
|
|
3707
4477
|
var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
|
|
3708
|
-
function
|
|
4478
|
+
function loadCoverageBaseline(configDir, config) {
|
|
3709
4479
|
const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3710
|
-
const abs =
|
|
4480
|
+
const abs = path8.resolve(configDir, rel);
|
|
3711
4481
|
let raw;
|
|
3712
4482
|
try {
|
|
3713
|
-
raw =
|
|
4483
|
+
raw = fs5.readFileSync(abs, "utf8");
|
|
3714
4484
|
} catch {
|
|
3715
|
-
return
|
|
4485
|
+
return {};
|
|
3716
4486
|
}
|
|
4487
|
+
let parsed;
|
|
3717
4488
|
try {
|
|
3718
|
-
|
|
3719
|
-
if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
|
|
3720
|
-
const uncapturedPages = parsed.uncapturedPages.filter(
|
|
3721
|
-
(p2) => typeof p2 === "string"
|
|
3722
|
-
);
|
|
3723
|
-
let missingKinds;
|
|
3724
|
-
const mkRaw = parsed.missingKinds;
|
|
3725
|
-
if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
|
|
3726
|
-
const mk = {};
|
|
3727
|
-
for (const [route, kinds] of Object.entries(mkRaw)) {
|
|
3728
|
-
if (Array.isArray(kinds)) {
|
|
3729
|
-
mk[route] = kinds.filter((k) => typeof k === "string");
|
|
3730
|
-
}
|
|
3731
|
-
}
|
|
3732
|
-
missingKinds = mk;
|
|
3733
|
-
}
|
|
3734
|
-
return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
|
|
4489
|
+
parsed = JSON.parse(raw);
|
|
3735
4490
|
} catch {
|
|
3736
|
-
return
|
|
4491
|
+
return {};
|
|
3737
4492
|
}
|
|
4493
|
+
const legacy = migrateLegacyBaseline({
|
|
4494
|
+
path: abs,
|
|
4495
|
+
raw,
|
|
4496
|
+
parsed,
|
|
4497
|
+
displayPath: rel
|
|
4498
|
+
});
|
|
4499
|
+
if (legacy) return { legacy };
|
|
4500
|
+
return { baseline: parseCoverageBaseline(parsed) ?? void 0 };
|
|
3738
4501
|
}
|
|
3739
4502
|
function loadCapturedStates(configDir, config) {
|
|
3740
|
-
const rel = config.statesManifest ??
|
|
3741
|
-
const abs =
|
|
4503
|
+
const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
|
|
4504
|
+
const abs = path8.resolve(configDir, rel);
|
|
3742
4505
|
let raw;
|
|
3743
4506
|
try {
|
|
3744
|
-
raw =
|
|
4507
|
+
raw = fs5.readFileSync(abs, "utf8");
|
|
3745
4508
|
} catch {
|
|
3746
4509
|
return void 0;
|
|
3747
4510
|
}
|
|
@@ -3760,7 +4523,7 @@ function loadCapturedStates(configDir, config) {
|
|
|
3760
4523
|
if (typeof c.route === "string") rec.route = c.route;
|
|
3761
4524
|
captured.push(rec);
|
|
3762
4525
|
}
|
|
3763
|
-
return { captured };
|
|
4526
|
+
return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
|
|
3764
4527
|
} catch {
|
|
3765
4528
|
return void 0;
|
|
3766
4529
|
}
|
|
@@ -3796,11 +4559,11 @@ function runOne(dc, opts) {
|
|
|
3796
4559
|
gitContext
|
|
3797
4560
|
});
|
|
3798
4561
|
const typesRel = config.output;
|
|
3799
|
-
const typesPath =
|
|
4562
|
+
const typesPath = path8.resolve(configDir, typesRel);
|
|
3800
4563
|
const dataRel = dataOutputPath(config.output);
|
|
3801
|
-
const dataPath =
|
|
4564
|
+
const dataPath = path8.resolve(configDir, dataRel);
|
|
3802
4565
|
const locsRel = locsOutputPath(config.output);
|
|
3803
|
-
const locsPath =
|
|
4566
|
+
const locsPath = path8.resolve(configDir, locsRel);
|
|
3804
4567
|
let typesOnDisk = null;
|
|
3805
4568
|
let dataOnDisk = null;
|
|
3806
4569
|
let locsOnDisk = null;
|
|
@@ -3813,9 +4576,11 @@ function runOne(dc, opts) {
|
|
|
3813
4576
|
(ef) => (ef.diagnostics?.length ?? 0) > 0
|
|
3814
4577
|
);
|
|
3815
4578
|
const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
|
|
3816
|
-
const
|
|
4579
|
+
const loadedBaseline = opts.coverageBaseline ? { baseline: opts.coverageBaseline } : loadCoverageBaseline(configDir, config);
|
|
4580
|
+
const coverageBaseline = loadedBaseline.baseline;
|
|
4581
|
+
const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
|
|
3817
4582
|
let auditResult;
|
|
3818
|
-
if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
|
|
4583
|
+
if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics || loadedBaseline.legacy) {
|
|
3819
4584
|
auditResult = audit({
|
|
3820
4585
|
registry: resolved.registry,
|
|
3821
4586
|
extracted,
|
|
@@ -3838,7 +4603,9 @@ function runOne(dc, opts) {
|
|
|
3838
4603
|
locsOnDisk,
|
|
3839
4604
|
locsOutputPath: locsRel,
|
|
3840
4605
|
capturedStates,
|
|
3841
|
-
|
|
4606
|
+
coverageBaseline,
|
|
4607
|
+
baselineDiagnostic: loadedBaseline.legacy,
|
|
4608
|
+
workspace
|
|
3842
4609
|
});
|
|
3843
4610
|
}
|
|
3844
4611
|
return {
|
|
@@ -3859,15 +4626,15 @@ function runOne(dc, opts) {
|
|
|
3859
4626
|
}
|
|
3860
4627
|
function readOrNull(p2) {
|
|
3861
4628
|
try {
|
|
3862
|
-
return
|
|
4629
|
+
return fs5.readFileSync(p2, "utf8");
|
|
3863
4630
|
} catch {
|
|
3864
4631
|
return null;
|
|
3865
4632
|
}
|
|
3866
4633
|
}
|
|
3867
4634
|
function writeArtifact(artifact) {
|
|
3868
4635
|
if (readOrNull(artifact.outputPath) === artifact.generated) return false;
|
|
3869
|
-
|
|
3870
|
-
|
|
4636
|
+
fs5.mkdirSync(path8.dirname(artifact.outputPath), { recursive: true });
|
|
4637
|
+
fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
|
|
3871
4638
|
return true;
|
|
3872
4639
|
}
|
|
3873
4640
|
function writeScanResult(result) {
|
|
@@ -3876,14 +4643,45 @@ function writeScanResult(result) {
|
|
|
3876
4643
|
const wroteLocs = writeArtifact(result.locs);
|
|
3877
4644
|
return wroteTypes || wroteData || wroteLocs;
|
|
3878
4645
|
}
|
|
3879
|
-
function
|
|
4646
|
+
function coverageBaselinePath(result) {
|
|
3880
4647
|
const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
|
|
3881
|
-
return
|
|
4648
|
+
return path8.resolve(result.configDir, rel);
|
|
4649
|
+
}
|
|
4650
|
+
|
|
4651
|
+
// src/scanner/scan/states-merge.ts
|
|
4652
|
+
function keyOf(c) {
|
|
4653
|
+
return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
|
|
4654
|
+
}
|
|
4655
|
+
function mergeStates(committed, partial) {
|
|
4656
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
4657
|
+
for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
|
|
4658
|
+
const updated = [];
|
|
4659
|
+
const added = [];
|
|
4660
|
+
for (const c of partial.captured ?? []) {
|
|
4661
|
+
const k = keyOf(c);
|
|
4662
|
+
(byKey.has(k) ? updated : added).push(k);
|
|
4663
|
+
byKey.set(k, c);
|
|
4664
|
+
}
|
|
4665
|
+
const captured = [...byKey.values()].sort(
|
|
4666
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
|
|
4667
|
+
);
|
|
4668
|
+
return {
|
|
4669
|
+
manifest: {
|
|
4670
|
+
...committed,
|
|
4671
|
+
captured,
|
|
4672
|
+
// The merged file is only as fresh as its OLDEST evidence: rows carried
|
|
4673
|
+
// over from the committed manifest were not re-verified by this run, so
|
|
4674
|
+
// claiming the partial's newer stamp would defeat the staleness check.
|
|
4675
|
+
...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
|
|
4676
|
+
},
|
|
4677
|
+
updated: updated.sort(),
|
|
4678
|
+
added: added.sort()
|
|
4679
|
+
};
|
|
3882
4680
|
}
|
|
3883
4681
|
|
|
3884
4682
|
// src/scanner/scan/fix.ts
|
|
3885
|
-
import * as
|
|
3886
|
-
import * as
|
|
4683
|
+
import * as fs6 from "fs";
|
|
4684
|
+
import * as path9 from "path";
|
|
3887
4685
|
function applyFixes(diagnostics) {
|
|
3888
4686
|
const entries = [];
|
|
3889
4687
|
for (const d of diagnostics) {
|
|
@@ -3916,7 +4714,7 @@ function applyFixes(diagnostics) {
|
|
|
3916
4714
|
for (const [filePath, edits] of editsByFile) {
|
|
3917
4715
|
let content;
|
|
3918
4716
|
try {
|
|
3919
|
-
content =
|
|
4717
|
+
content = fs6.readFileSync(filePath, "utf8");
|
|
3920
4718
|
} catch {
|
|
3921
4719
|
for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
|
|
3922
4720
|
continue;
|
|
@@ -3936,22 +4734,22 @@ function applyFixes(diagnostics) {
|
|
|
3936
4734
|
const edit = kept[i];
|
|
3937
4735
|
content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
|
|
3938
4736
|
}
|
|
3939
|
-
if (kept.length > 0)
|
|
4737
|
+
if (kept.length > 0) fs6.writeFileSync(filePath, content, "utf8");
|
|
3940
4738
|
}
|
|
3941
4739
|
for (const entry of entries) {
|
|
3942
4740
|
if (entry.skippedReason) continue;
|
|
3943
4741
|
for (const create of entry.createFiles) {
|
|
3944
|
-
if (
|
|
3945
|
-
entry.skippedReason = `${
|
|
4742
|
+
if (fs6.existsSync(create.path)) {
|
|
4743
|
+
entry.skippedReason = `${path9.basename(create.path)} already exists`;
|
|
3946
4744
|
continue;
|
|
3947
4745
|
}
|
|
3948
|
-
|
|
3949
|
-
|
|
4746
|
+
fs6.mkdirSync(path9.dirname(create.path), { recursive: true });
|
|
4747
|
+
fs6.writeFileSync(create.path, create.content, "utf8");
|
|
3950
4748
|
}
|
|
3951
4749
|
if (entry.skippedReason) continue;
|
|
3952
4750
|
for (const del of entry.deleteFiles) {
|
|
3953
4751
|
try {
|
|
3954
|
-
|
|
4752
|
+
fs6.unlinkSync(del);
|
|
3955
4753
|
} catch {
|
|
3956
4754
|
}
|
|
3957
4755
|
}
|
|
@@ -4129,29 +4927,29 @@ function renameEntity(opts) {
|
|
|
4129
4927
|
}
|
|
4130
4928
|
|
|
4131
4929
|
// src/scanner/scan/cli.ts
|
|
4132
|
-
import * as
|
|
4133
|
-
import * as
|
|
4930
|
+
import * as fs9 from "fs";
|
|
4931
|
+
import * as path12 from "path";
|
|
4134
4932
|
|
|
4135
4933
|
// src/scanner/scan/ai/index.ts
|
|
4136
4934
|
import * as p from "@clack/prompts";
|
|
4137
4935
|
|
|
4138
4936
|
// src/scanner/scan/ai/providers/claude.ts
|
|
4139
|
-
import * as
|
|
4140
|
-
import * as
|
|
4937
|
+
import * as fs8 from "fs";
|
|
4938
|
+
import * as path11 from "path";
|
|
4141
4939
|
|
|
4142
4940
|
// src/scanner/scan/ai/templates.ts
|
|
4143
|
-
import * as
|
|
4144
|
-
import * as
|
|
4941
|
+
import * as fs7 from "fs";
|
|
4942
|
+
import * as path10 from "path";
|
|
4145
4943
|
function templatePath(rel) {
|
|
4146
4944
|
const candidates = [
|
|
4147
|
-
|
|
4945
|
+
path10.resolve(__dirname, "../../templates", rel),
|
|
4148
4946
|
// dist/cli/cli.cjs → ../../templates
|
|
4149
|
-
|
|
4947
|
+
path10.resolve(__dirname, "../../../../templates", rel)
|
|
4150
4948
|
// src/scanner/scan/ai → ../../../../templates
|
|
4151
4949
|
];
|
|
4152
4950
|
for (const c of candidates) {
|
|
4153
4951
|
try {
|
|
4154
|
-
|
|
4952
|
+
fs7.accessSync(c, fs7.constants.R_OK);
|
|
4155
4953
|
return c;
|
|
4156
4954
|
} catch {
|
|
4157
4955
|
continue;
|
|
@@ -4163,7 +4961,7 @@ function templatePath(rel) {
|
|
|
4163
4961
|
);
|
|
4164
4962
|
}
|
|
4165
4963
|
function readTemplate(rel) {
|
|
4166
|
-
return
|
|
4964
|
+
return fs7.readFileSync(templatePath(rel), "utf8");
|
|
4167
4965
|
}
|
|
4168
4966
|
|
|
4169
4967
|
// src/scanner/scan/ai/providers/claude.ts
|
|
@@ -4194,8 +4992,8 @@ var claudeProvider = {
|
|
|
4194
4992
|
async install({ cwd, force }) {
|
|
4195
4993
|
const changes = [];
|
|
4196
4994
|
for (const file of SKILL_FILES) {
|
|
4197
|
-
const dest =
|
|
4198
|
-
const exists =
|
|
4995
|
+
const dest = path11.join(cwd, file.dest);
|
|
4996
|
+
const exists = fs8.existsSync(dest);
|
|
4199
4997
|
if (exists && !force) {
|
|
4200
4998
|
changes.push({
|
|
4201
4999
|
path: file.dest,
|
|
@@ -4204,56 +5002,56 @@ var claudeProvider = {
|
|
|
4204
5002
|
});
|
|
4205
5003
|
continue;
|
|
4206
5004
|
}
|
|
4207
|
-
|
|
4208
|
-
|
|
5005
|
+
fs8.mkdirSync(path11.dirname(dest), { recursive: true });
|
|
5006
|
+
fs8.writeFileSync(dest, readTemplate(file.template));
|
|
4209
5007
|
changes.push({
|
|
4210
5008
|
path: file.dest,
|
|
4211
5009
|
action: exists ? "overwritten" : "created"
|
|
4212
5010
|
});
|
|
4213
5011
|
}
|
|
4214
5012
|
for (const rel of LEGACY_FILES) {
|
|
4215
|
-
const dest =
|
|
4216
|
-
if (
|
|
4217
|
-
|
|
5013
|
+
const dest = path11.join(cwd, rel);
|
|
5014
|
+
if (fs8.existsSync(dest)) {
|
|
5015
|
+
fs8.unlinkSync(dest);
|
|
4218
5016
|
changes.push({ path: rel, action: "removed" });
|
|
4219
5017
|
}
|
|
4220
5018
|
}
|
|
4221
|
-
cleanupEmpty(
|
|
4222
|
-
cleanupEmpty(
|
|
4223
|
-
cleanupEmpty(
|
|
5019
|
+
cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
|
|
5020
|
+
cleanupEmpty(path11.join(cwd, ".claude/commands"));
|
|
5021
|
+
cleanupEmpty(path11.join(cwd, ".claude/rules"));
|
|
4224
5022
|
return { changes };
|
|
4225
5023
|
},
|
|
4226
5024
|
async uninstall({ cwd }) {
|
|
4227
5025
|
const changes = [];
|
|
4228
5026
|
for (const file of SKILL_FILES) {
|
|
4229
|
-
const dest =
|
|
4230
|
-
if (!
|
|
5027
|
+
const dest = path11.join(cwd, file.dest);
|
|
5028
|
+
if (!fs8.existsSync(dest)) {
|
|
4231
5029
|
changes.push({ path: file.dest, action: "skipped", reason: "absent" });
|
|
4232
5030
|
continue;
|
|
4233
5031
|
}
|
|
4234
|
-
|
|
5032
|
+
fs8.unlinkSync(dest);
|
|
4235
5033
|
changes.push({ path: file.dest, action: "removed" });
|
|
4236
5034
|
}
|
|
4237
|
-
cleanupEmpty(
|
|
4238
|
-
cleanupEmpty(
|
|
4239
|
-
cleanupEmpty(
|
|
5035
|
+
cleanupEmpty(path11.join(cwd, ".claude/skills/uidex/references"));
|
|
5036
|
+
cleanupEmpty(path11.join(cwd, ".claude/skills/uidex"));
|
|
5037
|
+
cleanupEmpty(path11.join(cwd, ".claude/skills"));
|
|
4240
5038
|
for (const rel of LEGACY_FILES) {
|
|
4241
|
-
const dest =
|
|
4242
|
-
if (
|
|
4243
|
-
|
|
5039
|
+
const dest = path11.join(cwd, rel);
|
|
5040
|
+
if (fs8.existsSync(dest)) {
|
|
5041
|
+
fs8.unlinkSync(dest);
|
|
4244
5042
|
changes.push({ path: rel, action: "removed" });
|
|
4245
5043
|
}
|
|
4246
5044
|
}
|
|
4247
|
-
cleanupEmpty(
|
|
4248
|
-
cleanupEmpty(
|
|
4249
|
-
cleanupEmpty(
|
|
5045
|
+
cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
|
|
5046
|
+
cleanupEmpty(path11.join(cwd, ".claude/commands"));
|
|
5047
|
+
cleanupEmpty(path11.join(cwd, ".claude/rules"));
|
|
4250
5048
|
return { changes };
|
|
4251
5049
|
}
|
|
4252
5050
|
};
|
|
4253
5051
|
function cleanupEmpty(dir) {
|
|
4254
5052
|
try {
|
|
4255
|
-
const entries =
|
|
4256
|
-
if (entries.length === 0)
|
|
5053
|
+
const entries = fs8.readdirSync(dir);
|
|
5054
|
+
if (entries.length === 0) fs8.rmdirSync(dir);
|
|
4257
5055
|
} catch {
|
|
4258
5056
|
}
|
|
4259
5057
|
}
|
|
@@ -4422,6 +5220,8 @@ async function run(opts) {
|
|
|
4422
5220
|
return runScaffold(cwd, positional.slice(1), flags, writer);
|
|
4423
5221
|
case "rename":
|
|
4424
5222
|
return runRename(cwd, positional.slice(1), flags, writer);
|
|
5223
|
+
case "states":
|
|
5224
|
+
return runStates(cwd, positional.slice(1), writer);
|
|
4425
5225
|
case "ai": {
|
|
4426
5226
|
const result = await runAiCommand({
|
|
4427
5227
|
cwd,
|
|
@@ -4450,22 +5250,23 @@ function helpText2() {
|
|
|
4450
5250
|
" scan [flags] Run the scanner pipeline",
|
|
4451
5251
|
" scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
|
|
4452
5252
|
" rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
|
|
5253
|
+
" states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
|
|
4453
5254
|
" ai <install|uninstall|providers> Manage AI assistant integrations",
|
|
4454
5255
|
"",
|
|
4455
5256
|
"Flags:",
|
|
4456
5257
|
" --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
|
|
4457
5258
|
" --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
|
|
4458
5259
|
" --audit Equivalent to --check --lint (read-only)",
|
|
4459
|
-
" --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
|
|
4460
|
-
" --update-baseline Regenerate the
|
|
5260
|
+
" --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names, migrate waivers/capture to acknowledge), then rescan and write",
|
|
5261
|
+
" --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
|
|
4461
5262
|
" --json Emit JSON diagnostics on stdout",
|
|
4462
5263
|
" --force (scaffold) overwrite existing spec",
|
|
4463
5264
|
""
|
|
4464
5265
|
].join("\n");
|
|
4465
5266
|
}
|
|
4466
5267
|
function runInit(cwd, w) {
|
|
4467
|
-
const configPath =
|
|
4468
|
-
if (
|
|
5268
|
+
const configPath = path12.join(cwd, CONFIG_FILENAME);
|
|
5269
|
+
if (fs9.existsSync(configPath)) {
|
|
4469
5270
|
w.err(`.uidex.json already exists at ${configPath}`);
|
|
4470
5271
|
return w.result(1);
|
|
4471
5272
|
}
|
|
@@ -4474,16 +5275,16 @@ function runInit(cwd, w) {
|
|
|
4474
5275
|
sources: [{ rootDir: "src" }],
|
|
4475
5276
|
output: "src/uidex.gen.ts"
|
|
4476
5277
|
};
|
|
4477
|
-
|
|
5278
|
+
fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
4478
5279
|
w.out(`Created ${configPath}`);
|
|
4479
|
-
const gitignorePath =
|
|
5280
|
+
const gitignorePath = path12.join(cwd, ".gitignore");
|
|
4480
5281
|
const entry = "*.gen.*";
|
|
4481
|
-
if (
|
|
4482
|
-
const existing =
|
|
5282
|
+
if (fs9.existsSync(gitignorePath)) {
|
|
5283
|
+
const existing = fs9.readFileSync(gitignorePath, "utf8");
|
|
4483
5284
|
const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
|
|
4484
5285
|
if (!hasEntry) {
|
|
4485
5286
|
const needsNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
4486
|
-
|
|
5287
|
+
fs9.appendFileSync(
|
|
4487
5288
|
gitignorePath,
|
|
4488
5289
|
`${needsNewline ? "\n" : ""}${entry}
|
|
4489
5290
|
`,
|
|
@@ -4492,7 +5293,7 @@ function runInit(cwd, w) {
|
|
|
4492
5293
|
w.out(`Appended ${entry} to ${gitignorePath}`);
|
|
4493
5294
|
}
|
|
4494
5295
|
} else {
|
|
4495
|
-
|
|
5296
|
+
fs9.writeFileSync(gitignorePath, `${entry}
|
|
4496
5297
|
`, "utf8");
|
|
4497
5298
|
w.out(`Created ${gitignorePath} with ${entry}`);
|
|
4498
5299
|
}
|
|
@@ -4531,20 +5332,25 @@ function runScanCommand(cwd, flags, w) {
|
|
|
4531
5332
|
if (updateBaseline) {
|
|
4532
5333
|
for (const r of results) {
|
|
4533
5334
|
const manifest = r.capturedStates ?? { captured: [] };
|
|
4534
|
-
const
|
|
4535
|
-
const
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
5335
|
+
const acknowledge = {};
|
|
5336
|
+
const merge = (from) => {
|
|
5337
|
+
for (const [route, scopes] of Object.entries(from)) {
|
|
5338
|
+
acknowledge[route] = { ...acknowledge[route] ?? {}, ...scopes };
|
|
5339
|
+
}
|
|
4539
5340
|
};
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
const
|
|
4543
|
-
|
|
5341
|
+
merge(computePageBacklog(r.registry, manifest));
|
|
5342
|
+
merge(computeMatrixBacklog(r.registry, manifest));
|
|
5343
|
+
const baseline = { version: 2, acknowledge };
|
|
5344
|
+
const p2 = coverageBaselinePath(r);
|
|
5345
|
+
fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
|
|
5346
|
+
const entries = Object.values(acknowledge);
|
|
5347
|
+
const pages = entries.filter((s) => s["*"]).length;
|
|
5348
|
+
const kindGaps = entries.reduce(
|
|
5349
|
+
(n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
|
|
4544
5350
|
0
|
|
4545
5351
|
);
|
|
4546
5352
|
w.out(
|
|
4547
|
-
`Wrote ${p2} \u2014 ${
|
|
5353
|
+
`Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
|
|
4548
5354
|
);
|
|
4549
5355
|
}
|
|
4550
5356
|
return w.result(0);
|
|
@@ -4617,7 +5423,7 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
4617
5423
|
for (const r of results) {
|
|
4618
5424
|
const entity = r.registry.get(scaffoldKind, id);
|
|
4619
5425
|
if (!entity) continue;
|
|
4620
|
-
const outDir =
|
|
5426
|
+
const outDir = path12.resolve(r.configDir, "e2e");
|
|
4621
5427
|
const result = scaffoldSpec({
|
|
4622
5428
|
registry: r.registry,
|
|
4623
5429
|
kind: scaffoldKind,
|
|
@@ -4638,6 +5444,53 @@ function runScaffold(cwd, args, flags, w) {
|
|
|
4638
5444
|
return w.result(1);
|
|
4639
5445
|
}
|
|
4640
5446
|
var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
|
|
5447
|
+
function runStates(cwd, args, w) {
|
|
5448
|
+
const [sub, explicitPartial] = args;
|
|
5449
|
+
if (sub !== "merge") {
|
|
5450
|
+
w.err("Usage: uidex states merge [partial-manifest]");
|
|
5451
|
+
return w.result(1);
|
|
5452
|
+
}
|
|
5453
|
+
const configs = discover({ cwd });
|
|
5454
|
+
if (configs.length === 0) {
|
|
5455
|
+
w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
|
|
5456
|
+
return w.result(1);
|
|
5457
|
+
}
|
|
5458
|
+
let merged = 0;
|
|
5459
|
+
for (const { configDir, config } of configs) {
|
|
5460
|
+
const rel = config.statesManifest ?? "uidex-states.json";
|
|
5461
|
+
const target = path12.resolve(configDir, rel);
|
|
5462
|
+
const partial = explicitPartial ? path12.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
|
|
5463
|
+
const committedRaw = readJson(target);
|
|
5464
|
+
const partialRaw = readJson(partial);
|
|
5465
|
+
if (!partialRaw) continue;
|
|
5466
|
+
if (!committedRaw) {
|
|
5467
|
+
w.err(`No committed manifest at ${rel}; nothing to merge into`);
|
|
5468
|
+
return w.result(1);
|
|
5469
|
+
}
|
|
5470
|
+
const result = mergeStates(committedRaw, partialRaw);
|
|
5471
|
+
fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
|
|
5472
|
+
merged++;
|
|
5473
|
+
w.out(
|
|
5474
|
+
`Merged ${path12.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
|
|
5475
|
+
);
|
|
5476
|
+
for (const k of result.added) w.out(` + ${k}`);
|
|
5477
|
+
for (const k of result.updated) w.out(` ~ ${k}`);
|
|
5478
|
+
}
|
|
5479
|
+
if (merged === 0) {
|
|
5480
|
+
w.out("No partial manifest found; nothing to merge.");
|
|
5481
|
+
}
|
|
5482
|
+
return w.result(0);
|
|
5483
|
+
}
|
|
5484
|
+
function readJson(p2) {
|
|
5485
|
+
try {
|
|
5486
|
+
const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
|
|
5487
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
5488
|
+
const m = parsed;
|
|
5489
|
+
return Array.isArray(m.captured) ? m : null;
|
|
5490
|
+
} catch {
|
|
5491
|
+
return null;
|
|
5492
|
+
}
|
|
5493
|
+
}
|
|
4641
5494
|
function runRename(cwd, args, flags, w) {
|
|
4642
5495
|
const [kind, oldId, newId] = args;
|
|
4643
5496
|
if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
|
|
@@ -4685,35 +5538,51 @@ function createWriter() {
|
|
|
4685
5538
|
};
|
|
4686
5539
|
}
|
|
4687
5540
|
export {
|
|
5541
|
+
ACK_ALL,
|
|
5542
|
+
ACK_SCOPES,
|
|
4688
5543
|
CONFIG_FILENAME,
|
|
4689
5544
|
ConfigError,
|
|
4690
5545
|
DEFAULT_CONVENTIONS,
|
|
5546
|
+
DIAGNOSTIC_CODES,
|
|
4691
5547
|
applyFixes,
|
|
4692
5548
|
audit,
|
|
5549
|
+
capturedElsewhere,
|
|
5550
|
+
checkComponentCoverage,
|
|
4693
5551
|
checkPageCoverage,
|
|
4694
5552
|
checkStateCoverage,
|
|
4695
5553
|
checkStateMatrix,
|
|
4696
5554
|
compileRoute,
|
|
4697
|
-
|
|
4698
|
-
|
|
5555
|
+
computeMatrixBacklog,
|
|
5556
|
+
computePageBacklog,
|
|
5557
|
+
coverageBaselinePath,
|
|
4699
5558
|
detectRoutes,
|
|
4700
5559
|
discover,
|
|
4701
5560
|
emit,
|
|
5561
|
+
entityAcknowledge,
|
|
4702
5562
|
extract,
|
|
4703
5563
|
extractUidexExports,
|
|
5564
|
+
findWorkspaceRoot,
|
|
4704
5565
|
globToRegExp,
|
|
4705
|
-
|
|
5566
|
+
isLegacyBaseline,
|
|
5567
|
+
loadCoverageBaseline,
|
|
4706
5568
|
matchUrlToRoute,
|
|
4707
|
-
|
|
5569
|
+
mergeStates,
|
|
5570
|
+
migrateBaseline,
|
|
5571
|
+
migrateLegacyBaseline,
|
|
5572
|
+
migrateLegacyFields,
|
|
4708
5573
|
parseConfig,
|
|
5574
|
+
parseCoverageBaseline,
|
|
4709
5575
|
pathToId,
|
|
4710
5576
|
renameEntity,
|
|
4711
5577
|
resolve2 as resolve,
|
|
4712
5578
|
resolveGitContext,
|
|
5579
|
+
resolveWorkspace,
|
|
5580
|
+
routeAcknowledge,
|
|
4713
5581
|
run as runCli,
|
|
4714
5582
|
runScan,
|
|
4715
5583
|
scaffoldSpec,
|
|
4716
5584
|
scaffoldWidgetSpec,
|
|
5585
|
+
serializeCoverageBaseline,
|
|
4717
5586
|
validateConfig,
|
|
4718
5587
|
walk,
|
|
4719
5588
|
writeScanResult
|