@ui-manifest-json/core 0.1.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BrainRidge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @ui-manifest-json/core
2
+
3
+ The shared `UiManifest` schema — as TypeScript types — plus the framework-agnostic helpers every
4
+ extractor builds on.
5
+
6
+ **You don't usually install this directly.** [`@ui-manifest-json/angular`][ng] and
7
+ [`@ui-manifest-json/react`][react] depend on it. Install it on its own when you're *consuming*
8
+ manifests and want the types, or writing an extractor for another framework.
9
+
10
+ ```bash
11
+ npm install @ui-manifest-json/core
12
+ ```
13
+
14
+ ## What's in it
15
+
16
+ ```ts
17
+ import type { UiManifest, RouteNode, ComponentNode, DomNode } from '@ui-manifest-json/core';
18
+
19
+ const manifest: UiManifest = JSON.parse(await readFile('ui-manifest.json', 'utf8'));
20
+ ```
21
+
22
+ The full shape is documented field by field in [docs/schema.md][schema]. The types are the source of
23
+ truth; that page is the tour.
24
+
25
+ Three runtime helpers ship alongside the types:
26
+
27
+ | Export | What it does |
28
+ |---|---|
29
+ | `resolveRouteDependencyTree` | Splices each component's template into its parent's where the child's tag appears, recursively, with component-boundary and cycle markers. Framework-agnostic: you supply a `matchFn` that decides which tag maps to which component. |
30
+ | `resolveFullPaths` | Walks a nested route tree and annotates each node with the full path a URL must have to reach it, `baseHref` applied. |
31
+ | `collectRepoProvenance` / `generatorProvenance` | Reads the commit, remote, branch, dirty state and app root out of the git working tree, and the build id out of CI. Best-effort: outside a git tree every field is simply absent, which means the manifest is unpinned rather than that anything failed. |
32
+
33
+ ## Schema version
34
+
35
+ `SCHEMA_VERSION` is `"2.0"`. A manifest carries it as `schemaVersion`, and it is the one field to
36
+ check before trusting the rest — v2 made `app.baseHref` and `app.routerMode` required, and without
37
+ those a route path cannot be matched against a real URL at all.
38
+
39
+ ## License
40
+
41
+ MIT — see [LICENSE](./LICENSE).
42
+
43
+ [ng]: https://www.npmjs.com/package/@ui-manifest-json/angular
44
+ [react]: https://www.npmjs.com/package/@ui-manifest-json/react
45
+ [schema]: https://github.com/BrainRidge/ui-manifest/blob/main/docs/schema.md
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolve every route's `fullPath` from the nested route tree.
3
+ *
4
+ * Framework-agnostic on purpose: Angular's `children`/`loadChildren` and React Router's nested
5
+ * `<Route>`/`children` produce the same `RouteNode` tree, so the join rule is the same and lives
6
+ * once. Both extractors call this after their own parsing.
7
+ */
8
+ import type { RouteNode } from './types/route.js';
9
+ /**
10
+ * Annotate `routes` (in place, recursively) with `fullPath`.
11
+ *
12
+ * `baseHref` is prepended so the result is what a URL actually looks like, not what the route
13
+ * config says — those differ for every app not served from the root, and the difference is
14
+ * invisible until a consumer tries to match a real URL and matches nothing.
15
+ *
16
+ * A wildcard gets no `fullPath` at all rather than an empty or synthesised one: it is a fallback,
17
+ * not a screen, and a consumer walking `fullPath` should skip it without having to know the
18
+ * convention.
19
+ *
20
+ * Note a wildcard's CHILDREN are still resolved. A `**` with children is unusual but legal, and the
21
+ * children are reachable even though the parent segment is not a location.
22
+ */
23
+ export declare function resolveFullPaths(routes: RouteNode[], baseHref?: string): RouteNode[];
24
+ //# sourceMappingURL=full-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"full-path.d.ts","sourceRoot":"","sources":["../src/full-path.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAYlD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,QAAQ,SAAM,GAAG,SAAS,EAAE,CAYjF"}
@@ -0,0 +1,38 @@
1
+ /** Route paths that match anything and therefore identify nothing. */
2
+ const WILDCARDS = new Set(['**', '*']);
3
+ function join(parent, segment) {
4
+ const left = parent.replace(/\/+$/, '');
5
+ const right = segment.replace(/^\/+/, '').replace(/\/+$/, '');
6
+ if (!right)
7
+ return left || '/';
8
+ return `${left}/${right}`;
9
+ }
10
+ /**
11
+ * Annotate `routes` (in place, recursively) with `fullPath`.
12
+ *
13
+ * `baseHref` is prepended so the result is what a URL actually looks like, not what the route
14
+ * config says — those differ for every app not served from the root, and the difference is
15
+ * invisible until a consumer tries to match a real URL and matches nothing.
16
+ *
17
+ * A wildcard gets no `fullPath` at all rather than an empty or synthesised one: it is a fallback,
18
+ * not a screen, and a consumer walking `fullPath` should skip it without having to know the
19
+ * convention.
20
+ *
21
+ * Note a wildcard's CHILDREN are still resolved. A `**` with children is unusual but legal, and the
22
+ * children are reachable even though the parent segment is not a location.
23
+ */
24
+ export function resolveFullPaths(routes, baseHref = '/') {
25
+ const base = `/${baseHref.replace(/^\/+/, '').replace(/\/+$/, '')}`.replace(/^\/$/, '');
26
+ const walk = (nodes, parent) => {
27
+ for (const node of nodes) {
28
+ const segment = node.path ?? '';
29
+ const here = WILDCARDS.has(segment.trim()) ? parent : join(parent, segment);
30
+ if (!WILDCARDS.has(segment.trim()))
31
+ node.fullPath = here || '/';
32
+ if (node.children?.length)
33
+ walk(node.children, here);
34
+ }
35
+ };
36
+ walk(routes, base);
37
+ return routes;
38
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  export * from './types/dom.js';
2
+ export * from './types/source.js';
3
+ export * from './types/uncapturable.js';
2
4
  export * from './types/component.js';
3
5
  export * from './types/route.js';
6
+ export * from './types/provenance.js';
4
7
  export * from './types/dependency-graph.js';
5
8
  export * from './types/manifest.js';
9
+ export { resolveFullPaths } from './full-path.js';
10
+ export { collectRepoProvenance, generatorProvenance } from './provenance.js';
11
+ export type { CollectProvenanceOptions } from './provenance.js';
12
+ export { collapseDom, enrichDom, controlTypeFor, selectorCandidatesFor, testidOf, TESTID_ATTRS } from './semantics.js';
6
13
  export { resolveRouteDependencyTree } from './resolve-tree.js';
7
14
  export type { MatchFn } from './resolve-tree.js';
8
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC7E,YAAY,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAE,qBAAqB,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACvH,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  export * from './types/dom.js';
2
+ export * from './types/source.js';
3
+ export * from './types/uncapturable.js';
2
4
  export * from './types/component.js';
3
5
  export * from './types/route.js';
6
+ export * from './types/provenance.js';
4
7
  export * from './types/dependency-graph.js';
5
8
  export * from './types/manifest.js';
9
+ export { resolveFullPaths } from './full-path.js';
10
+ export { collectRepoProvenance, generatorProvenance } from './provenance.js';
11
+ export { collapseDom, enrichDom, controlTypeFor, selectorCandidatesFor, testidOf, TESTID_ATTRS } from './semantics.js';
6
12
  export { resolveRouteDependencyTree } from './resolve-tree.js';
@@ -0,0 +1,17 @@
1
+ import type { GeneratorProvenance, RepoProvenance } from './types/provenance.js';
2
+ export interface CollectProvenanceOptions {
3
+ /**
4
+ * Where the extractor is scanning. Becomes `appRoot`, relative to the repository root — and it
5
+ * is also the directory git is asked FROM, which matters more than it looks: `--dir` can point
6
+ * at a checkout that is not the one the command was run in, and asking git about the current
7
+ * directory would then pin the manifest to a completely unrelated repository's HEAD. That is the
8
+ * worst failure available here, because the result looks exactly like a correct pin.
9
+ */
10
+ targetDir: string;
11
+ /** Only a fallback origin for `appRoot` when the target is not in a git tree at all. */
12
+ cwd: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ }
15
+ export declare function collectRepoProvenance(options: CollectProvenanceOptions): RepoProvenance;
16
+ export declare function generatorProvenance(name: string, version: string, passes: string[], env?: NodeJS.ProcessEnv): GeneratorProvenance;
17
+ //# sourceMappingURL=provenance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provenance.d.ts","sourceRoot":"","sources":["../src/provenance.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAoDjF,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,cAAc,CAsCvF;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EAAE,EAChB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,mBAAmB,CAGrB"}
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Collect `RepoProvenance` from the git working tree, and `buildId` from CI.
3
+ *
4
+ * Every field is best-effort and every failure is silent-but-absent. This runs against arbitrary
5
+ * checkouts — a tarball with no `.git`, a shallow CI clone, a machine with no `git` on PATH — and
6
+ * none of those is an error: they mean the manifest is unpinned, which is a fact about the output
7
+ * rather than a failure to produce it. The one thing never done here is substituting a plausible
8
+ * value for a missing one: a branch name in place of a commit would look like a pin and move.
9
+ *
10
+ * `git` is invoked directly rather than via a dependency. It is one process per field, already on
11
+ * every machine that has a checkout to read, and the alternative is a dependency in a package whose
12
+ * whole appeal is that it has almost none.
13
+ */
14
+ import { execFileSync } from 'node:child_process';
15
+ import { realpathSync } from 'node:fs';
16
+ import { relative } from 'node:path';
17
+ /**
18
+ * Resolve symlinks, or return the path unchanged.
19
+ *
20
+ * `git rev-parse --show-toplevel` reports a REAL path, so on any platform where the checkout sits
21
+ * under a symlink the two disagree and `relative()` produces an escape-hatch path full of `..`.
22
+ * macOS makes this the common case rather than an edge one — `/tmp` and `/var` are both symlinks
23
+ * into `/private` — so a manifest generated in a temp checkout would report an `appRoot` naming
24
+ * the developer's filesystem instead of a subtree of the repo.
25
+ */
26
+ function realpath(path) {
27
+ try {
28
+ return realpathSync(path);
29
+ }
30
+ catch {
31
+ return path; // the directory may not exist yet; a non-resolvable path is not a failure here
32
+ }
33
+ }
34
+ /** Run one git command, or return undefined. Never throws, never prints. */
35
+ function git(args, cwd) {
36
+ try {
37
+ const out = execFileSync('git', args, {
38
+ cwd,
39
+ encoding: 'utf8',
40
+ stdio: ['ignore', 'pipe', 'ignore'],
41
+ timeout: 5000,
42
+ });
43
+ const value = out.trim();
44
+ return value || undefined;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ /**
51
+ * The build identifier of the CI run, if this is one.
52
+ *
53
+ * Ordered by specificity, not popularity: a run id identifies one execution, where a build number
54
+ * can repeat across re-runs. Absent locally, which is correct — a developer's laptop has no build.
55
+ */
56
+ function detectBuildId(env) {
57
+ return (env.GITHUB_RUN_ID ??
58
+ env.BUILD_BUILDID ?? // Azure Pipelines
59
+ env.CI_PIPELINE_ID ?? // GitLab
60
+ env.BUILDKITE_BUILD_ID ??
61
+ env.CIRCLE_WORKFLOW_ID ??
62
+ undefined);
63
+ }
64
+ export function collectRepoProvenance(options) {
65
+ const cwd = realpath(options.cwd);
66
+ const targetDir = realpath(options.targetDir);
67
+ // Every git question is asked from the SCANNED tree, not from the process's cwd — see
68
+ // `targetDir` above.
69
+ const root = git(['rev-parse', '--show-toplevel'], targetDir);
70
+ if (!root) {
71
+ // Not a git working tree. `appRoot` is still worth reporting relative to cwd — a consumer
72
+ // knowing which directory was scanned is useful even when it cannot know which commit.
73
+ const appRoot = relative(cwd, targetDir) || '.';
74
+ return { appRoot };
75
+ }
76
+ const commit = git(['rev-parse', 'HEAD'], targetDir);
77
+ // `--porcelain` is empty for a clean tree. Only meaningful once we know we have a commit:
78
+ // "dirty" without one says nothing a consumer can use.
79
+ const status = commit ? git(['status', '--porcelain'], targetDir) : undefined;
80
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], targetDir);
81
+ const provenance = {
82
+ remoteUrl: git(['remote', 'get-url', 'origin'], targetDir),
83
+ commit,
84
+ // Committer date, ISO 8601 strict. Not the author date: two commits can share an author date
85
+ // after a rebase, and what orders two manifests is when the code landed.
86
+ commitTime: commit ? git(['show', '-s', '--format=%cI', 'HEAD'], targetDir) : undefined,
87
+ // A detached HEAD reports "HEAD", which is not a branch name and should not be recorded as one.
88
+ branch: branch && branch !== 'HEAD' ? branch : undefined,
89
+ dirty: commit ? Boolean(status) : undefined,
90
+ appRoot: relative(root, targetDir) || '.',
91
+ };
92
+ // Absent, not null/empty: a consumer testing `if (provenance.commit)` should not have to also
93
+ // test for the empty string, and JSON with explicit nulls everywhere reads as though something
94
+ // failed rather than as though it was never available.
95
+ for (const key of Object.keys(provenance)) {
96
+ if (provenance[key] === undefined)
97
+ delete provenance[key];
98
+ }
99
+ return provenance;
100
+ }
101
+ export function generatorProvenance(name, version, passes, env = process.env) {
102
+ const buildId = detectBuildId(env);
103
+ return buildId ? { name, version, buildId, passes } : { name, version, passes };
104
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The pass that turns a raw parse tree into something a consumer can JOIN against a live DOM.
3
+ *
4
+ * Framework-agnostic on purpose: both extractors produce the same `DomNode` union, and every rule
5
+ * here reads only that union. Angular's parser and React's differ entirely in how they find a
6
+ * conditional; by the time a tree reaches this module the conditional is a `TemplateNode` either
7
+ * way.
8
+ *
9
+ * Three things happen here, and each removes work a consumer would otherwise have to do — wrongly,
10
+ * and once per consumer:
11
+ *
12
+ * **Static text is folded into its element.** A `<button>Sign In</button>` parses as an element
13
+ * with a text child. Keyed on its own, that button has no handle at all: no id, no name, no
14
+ * testid — which describes most buttons in most apps. Folding the text up is what makes it
15
+ * addressable. An interpolation is deliberately NOT folded: a key built from `{{ user.name }}`
16
+ * changes with the test data.
17
+ *
18
+ * **Ancestry is denormalized.** Every structural branch between the root and an element is copied
19
+ * onto that element as `conditionChain`. This is what lets the tree drop presentational wrappers
20
+ * without losing anything, and it is what turns "why is this field not on the page?" from a
21
+ * repository search into a field read.
22
+ *
23
+ * **Handles are enumerated, and their absence is reported.** `tokenStability: "none"` on a control
24
+ * means the source offers no stable way to address it. With a file and a line attached, that is
25
+ * the single most actionable line this whole format produces: *add a `data-testid` here*.
26
+ */
27
+ import type { ControlType, DomNode, ElementNode, SelectorCandidate } from './types/dom.js';
28
+ /**
29
+ * Drop presentational wrappers, splicing their children into their place.
30
+ *
31
+ * Safe only because `conditionChain` is denormalized onto every element: nothing downstream needs
32
+ * the ancestors this removes. Run BEFORE enrichment so text folding and uniqueness both see the
33
+ * final tree. Returns how many nodes went, because a consumer must be able to tell that the tree
34
+ * it is reading is not the DOM.
35
+ */
36
+ export declare function collapseDom(nodes: DomNode[]): {
37
+ dom: DomNode[];
38
+ collapsed: number;
39
+ };
40
+ /** The attribute names a test-id may be spelled with, in the order a consumer should prefer. */
41
+ export declare const TESTID_ATTRS: readonly ["data-testid", "data-test-id", "data-test", "data-qa", "data-cy"];
42
+ export declare function testidOf(attrs: Record<string, string>): string | undefined;
43
+ /**
44
+ * What a person can DO with this element.
45
+ *
46
+ * Driven off the tag and `type` rather than the role attribute, because the role is usually
47
+ * absent and the tag almost never is. `<input type="submit">` is a BUTTON — it is spelled as an
48
+ * input but it submits, and treating it as a textbox is how a submit control ends up expected to
49
+ * accept typing.
50
+ */
51
+ export declare function controlTypeFor(el: string, attrs: Record<string, string>): ControlType | undefined;
52
+ /** Every handle the SOURCE offers for this element, best first. Audit material, not a locator. */
53
+ export declare function selectorCandidatesFor(node: ElementNode, staticText?: string): SelectorCandidate[];
54
+ /**
55
+ * Walk a parsed tree and enrich every element in place.
56
+ *
57
+ * Returns every element it visited, in template order, so the caller can run whole-template passes
58
+ * (uniqueness) without walking again. Order is template order because two elements sharing a
59
+ * handle should collide in a stable order rather than whichever the walk happened to reach first.
60
+ */
61
+ export declare function enrichDom(nodes: DomNode[]): ElementNode[];
62
+ //# sourceMappingURL=semantics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantics.d.ts","sourceRoot":"","sources":["../src/semantics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EACK,WAAW,EAAE,OAAO,EAAE,WAAW,EAAE,iBAAiB,EACpE,MAAM,gBAAgB,CAAC;AAoCxB;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG;IAAE,GAAG,EAAE,OAAO,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CA4BnF;AAED,gGAAgG;AAChG,eAAO,MAAM,YAAY,6EAA8E,CAAC;AAExG,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,SAAS,CAM1E;AAKD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,WAAW,GAAG,SAAS,CAwBjG;AAUD,kGAAkG;AAClG,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,WAAW,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAejG;AA2FD;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,WAAW,EAAE,CAqEzD"}
@@ -0,0 +1,325 @@
1
+ /** Attributes that make an element addressable, and so make it worth keeping. */
2
+ const MEANINGFUL_ATTRS = [
3
+ 'id', 'name', 'placeholder', 'role', 'title', 'href', 'src', 'type', 'value', 'for',
4
+ 'routerLink', 'formControlName', 'formGroupName', 'formArrayName',
5
+ ];
6
+ /**
7
+ * A node that exists only to position other nodes.
8
+ *
9
+ * The rule is narrow on purpose. A `div` holding nothing but a class and OTHER ELEMENTS is
10
+ * layout; a `div` holding text is the text's element, and dropping it would leave the text with
11
+ * nothing to hang on — including, decisively, nothing for a `conditionChain` to be attached to.
12
+ * `<div class="error-message">{{ errorMessage }}</div>` under an `*ngIf` is the whole answer to
13
+ * "why is this not on the page?", and it looks exactly like a wrapper until you notice it wraps
14
+ * no element.
15
+ */
16
+ function isPresentationalWrapper(node) {
17
+ const tag = node.el.toLowerCase();
18
+ if (tag !== 'div' && tag !== 'span')
19
+ return false;
20
+ if (node.el.includes('-'))
21
+ return false;
22
+ if (node.events.length || node.props.length)
23
+ return false;
24
+ if (node.refs?.length)
25
+ return false;
26
+ for (const key of Object.keys(node.attrs)) {
27
+ if (key === 'class' || key === 'style')
28
+ continue;
29
+ if (key.startsWith('aria-') || key.startsWith('data-'))
30
+ return false;
31
+ if (MEANINGFUL_ATTRS.includes(key))
32
+ return false;
33
+ return false;
34
+ }
35
+ // It must wrap at least one ELEMENT, and carry no text of its own.
36
+ const hasElementChild = node.children.some(c => c.type === 'element' || c.type === 'template');
37
+ const hasOwnText = node.children.some(c => c.type === 'text' || c.type === 'interpolation');
38
+ return hasElementChild && !hasOwnText;
39
+ }
40
+ /**
41
+ * Drop presentational wrappers, splicing their children into their place.
42
+ *
43
+ * Safe only because `conditionChain` is denormalized onto every element: nothing downstream needs
44
+ * the ancestors this removes. Run BEFORE enrichment so text folding and uniqueness both see the
45
+ * final tree. Returns how many nodes went, because a consumer must be able to tell that the tree
46
+ * it is reading is not the DOM.
47
+ */
48
+ export function collapseDom(nodes) {
49
+ let collapsed = 0;
50
+ const rewrite = (list) => {
51
+ const out = [];
52
+ for (const node of list) {
53
+ if (node.type === 'template') {
54
+ node.children = rewrite(node.children);
55
+ for (const branch of node.branches ?? [])
56
+ branch.children = rewrite(branch.children);
57
+ out.push(node);
58
+ continue;
59
+ }
60
+ if (node.type !== 'element') {
61
+ out.push(node);
62
+ continue;
63
+ }
64
+ node.children = rewrite(node.children);
65
+ if (isPresentationalWrapper(node)) {
66
+ collapsed += 1;
67
+ out.push(...node.children);
68
+ continue;
69
+ }
70
+ out.push(node);
71
+ }
72
+ return out;
73
+ };
74
+ return { dom: rewrite(nodes), collapsed };
75
+ }
76
+ /** The attribute names a test-id may be spelled with, in the order a consumer should prefer. */
77
+ export const TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy'];
78
+ export function testidOf(attrs) {
79
+ for (const name of TESTID_ATTRS) {
80
+ const value = attrs[name];
81
+ if (typeof value === 'string' && value.trim())
82
+ return value.trim();
83
+ }
84
+ return undefined;
85
+ }
86
+ /** Tags that are interactive by nature, so an element bearing one is a control even with no role. */
87
+ const CONTROL_TAGS = new Set(['input', 'select', 'textarea', 'button', 'a', 'option']);
88
+ /**
89
+ * What a person can DO with this element.
90
+ *
91
+ * Driven off the tag and `type` rather than the role attribute, because the role is usually
92
+ * absent and the tag almost never is. `<input type="submit">` is a BUTTON — it is spelled as an
93
+ * input but it submits, and treating it as a textbox is how a submit control ends up expected to
94
+ * accept typing.
95
+ */
96
+ export function controlTypeFor(el, attrs) {
97
+ const tag = el.toLowerCase();
98
+ const type = (attrs.type ?? '').toLowerCase();
99
+ if (tag === 'a')
100
+ return 'link';
101
+ if (tag === 'button')
102
+ return 'button';
103
+ if (tag === 'textarea')
104
+ return 'textbox';
105
+ if (tag === 'select')
106
+ return attrs.multiple !== undefined ? 'listbox' : 'combobox';
107
+ if (tag === 'input') {
108
+ if (type === 'checkbox')
109
+ return 'checkbox';
110
+ if (type === 'radio')
111
+ return 'radio';
112
+ if (type === 'file')
113
+ return 'fileinput';
114
+ if (type === 'range')
115
+ return 'slider';
116
+ if (type === 'date' || type === 'datetime-local' || type === 'month' || type === 'week')
117
+ return 'datepicker';
118
+ // submit/button/reset/image are buttons wearing an input's tag.
119
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image')
120
+ return 'button';
121
+ return 'textbox';
122
+ }
123
+ const role = (attrs.role ?? '').toLowerCase();
124
+ if (role === 'button' || role === 'link' || role === 'checkbox' || role === 'radio'
125
+ || role === 'combobox' || role === 'listbox' || role === 'textbox' || role === 'tab'
126
+ || role === 'grid' || role === 'slider') {
127
+ return role;
128
+ }
129
+ return undefined;
130
+ }
131
+ function isControl(node) {
132
+ const tag = node.el.toLowerCase();
133
+ if (CONTROL_TAGS.has(tag))
134
+ return true;
135
+ if (node.attrs.role)
136
+ return true;
137
+ // A custom element wired to a click is a control however it is spelled.
138
+ return node.events.some(e => e.kind !== 'twoWayWriteback');
139
+ }
140
+ /** Every handle the SOURCE offers for this element, best first. Audit material, not a locator. */
141
+ export function selectorCandidatesFor(node, staticText) {
142
+ const out = [];
143
+ const { attrs } = node;
144
+ const tag = node.el.toLowerCase();
145
+ const push = (by, value) => out.push({ by, value, unique: false, uniqueScope: 'template' });
146
+ const testid = testidOf(attrs);
147
+ if (testid)
148
+ push('testid', `[data-testid="${testid}"]`);
149
+ if (attrs.id)
150
+ push('id', `#${attrs.id}`);
151
+ if (attrs.name)
152
+ push('name', `${tag}[name="${attrs.name}"]`);
153
+ if (attrs['aria-label'])
154
+ push('aria', `${tag}[aria-label="${attrs['aria-label']}"]`);
155
+ if (attrs.placeholder)
156
+ push('placeholder', `${tag}[placeholder="${attrs.placeholder}"]`);
157
+ if (staticText)
158
+ push('text', staticText);
159
+ return out;
160
+ }
161
+ /**
162
+ * Mark which candidates are unique WITHIN THIS TEMPLATE.
163
+ *
164
+ * Scoped to the template and labelled as such, because that is the only claim a static pass can
165
+ * honestly make: one page is composed of a shell plus a route component, so a value unique in its
166
+ * own file can still collide once rendered. Saying `uniqueScope: "template"` is the difference
167
+ * between a weaker claim and a wrong one.
168
+ */
169
+ function markUniqueness(elements) {
170
+ const counts = new Map();
171
+ for (const el of elements) {
172
+ for (const c of el.selectorCandidates ?? []) {
173
+ counts.set(`${c.by} ${c.value}`, (counts.get(`${c.by} ${c.value}`) ?? 0) + 1);
174
+ }
175
+ }
176
+ for (const el of elements) {
177
+ for (const c of el.selectorCandidates ?? []) {
178
+ c.unique = counts.get(`${c.by} ${c.value}`) === 1;
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * Does this element carry a stable handle, an expression-derived one, or none at all?
184
+ *
185
+ * `none` is the interesting answer and the reason this field exists: reported with a file and a
186
+ * line it becomes an actionable finding, where the same element simply missing from a join reads
187
+ * as noise.
188
+ */
189
+ function tokenStabilityFor(node, staticText) {
190
+ const { attrs } = node;
191
+ if (testidOf(attrs) || attrs.id || attrs.name || attrs['aria-label'] || attrs.placeholder || staticText) {
192
+ return 'static';
193
+ }
194
+ const dynamic = node.props.find(p => p.name === 'id' || p.name === 'attr.id' || p.name === 'name' || p.name === 'attr.name'
195
+ || TESTID_ATTRS.some(t => p.name === t || p.name === `attr.${t}`));
196
+ if (dynamic)
197
+ return 'dynamic';
198
+ return isControl(node) ? 'none' : undefined;
199
+ }
200
+ /** The literal prefix of a dynamic handle, e.g. `[attr.id]="'tx-' + tx.id"`. */
201
+ function tokenTemplateFor(node) {
202
+ const dynamic = node.props.find(p => p.name === 'id' || p.name === 'attr.id'
203
+ || TESTID_ATTRS.some(t => p.name === t || p.name === `attr.${t}`));
204
+ if (!dynamic)
205
+ return undefined;
206
+ const literal = /^\s*['"]([^'"]+)['"]\s*\+/.exec(dynamic.expr);
207
+ return literal ? `${literal[1]}{{*}}` : undefined;
208
+ }
209
+ /**
210
+ * An accessible name, and ONLY when the source actually declares one.
211
+ *
212
+ * Deliberately not falling back to `staticText`, which is the obvious version and is wrong. A
213
+ * consumer keys an element on the best handle it has, and an accessible name outranks raw text —
214
+ * so synthesising one from a caption turns `<button>Sign In</button>` into an `aria:Sign In` key,
215
+ * while a browser recording the same button (it has no `aria-label`) produces a `text:Sign In`
216
+ * one. The two never join, and they fail to join on exactly the elements whose only handle is
217
+ * their caption. `staticText` carries the caption; let the consumer decide what it is worth.
218
+ */
219
+ function accessibleNameFor(node) {
220
+ return node.attrs['aria-label'] || node.attrs.title || undefined;
221
+ }
222
+ function isTruthyAttr(value) {
223
+ return value !== undefined && value !== 'false';
224
+ }
225
+ /** The gate a `TemplateNode` represents, as one link in a chain. */
226
+ function linkFor(node, branch) {
227
+ return {
228
+ directive: node.structural,
229
+ expr: node.condition ?? '',
230
+ ...(branch ? { branch } : {}),
231
+ ...(node.source ? { source: node.source } : {}),
232
+ };
233
+ }
234
+ /** `@for`'s condition is written `item of items; track id` — split it back apart. */
235
+ function repeatPartsOf(condition) {
236
+ if (!condition)
237
+ return {};
238
+ const match = /^\s*(\S+)\s+of\s+([^;]+?)\s*(?:;\s*track\s+(.+))?\s*$/.exec(condition);
239
+ if (!match)
240
+ return { over: condition.trim() || undefined };
241
+ return { varName: match[1], over: match[2]?.trim(), trackBy: match[3]?.trim() };
242
+ }
243
+ /**
244
+ * Walk a parsed tree and enrich every element in place.
245
+ *
246
+ * Returns every element it visited, in template order, so the caller can run whole-template passes
247
+ * (uniqueness) without walking again. Order is template order because two elements sharing a
248
+ * handle should collide in a stable order rather than whichever the walk happened to reach first.
249
+ */
250
+ export function enrichDom(nodes) {
251
+ const seen = [];
252
+ const walk = (list, chain, repeat) => {
253
+ for (const node of list) {
254
+ if (node.type === 'template') {
255
+ const t = node;
256
+ const isFor = t.structural === '@for' || t.structural === '*ngFor' || t.structural === '.map()';
257
+ const parts = isFor ? repeatPartsOf(t.condition) : {};
258
+ const nextRepeat = isFor ? { ...parts, on: true } : repeat;
259
+ // The primary branch's children hang off `children`; every other branch off `branches`.
260
+ // `branches[0]` IS `children` for an @if/@switch, so walking both would visit the primary
261
+ // twice and give its elements a duplicated chain link.
262
+ walk(t.children, [...chain, linkFor(t)], nextRepeat);
263
+ for (const branch of (t.branches ?? []).slice(1)) {
264
+ walk(branch.children, [...chain, linkFor(t, branch.label)], nextRepeat);
265
+ }
266
+ continue;
267
+ }
268
+ if (node.type !== 'element')
269
+ continue;
270
+ const el = node;
271
+ const staticParts = [];
272
+ let hasDynamicText = false;
273
+ for (const child of el.children) {
274
+ if (child.type === 'text' && child.value.trim())
275
+ staticParts.push(child.value.trim());
276
+ if (child.type === 'interpolation')
277
+ hasDynamicText = true;
278
+ }
279
+ const staticText = staticParts.join(' ').trim() || undefined;
280
+ if (staticText)
281
+ el.staticText = staticText;
282
+ if (hasDynamicText)
283
+ el.hasDynamicText = true;
284
+ const controlType = controlTypeFor(el.el, el.attrs);
285
+ if (controlType)
286
+ el.controlType = controlType;
287
+ const accessibleName = accessibleNameFor(el);
288
+ if (accessibleName)
289
+ el.accessibleName = accessibleName;
290
+ if (el.attrs.role)
291
+ el.role = el.attrs.role;
292
+ if (isTruthyAttr(el.attrs.required))
293
+ el.required = true;
294
+ if (el.el.includes('-'))
295
+ el.sourceRepresentation = el.el;
296
+ const stability = tokenStabilityFor(el, staticText);
297
+ if (stability)
298
+ el.tokenStability = stability;
299
+ const template = stability === 'dynamic' ? tokenTemplateFor(el) : undefined;
300
+ if (template)
301
+ el.tokenTemplate = template;
302
+ const candidates = selectorCandidatesFor(el, staticText);
303
+ if (candidates.length)
304
+ el.selectorCandidates = candidates;
305
+ if (chain.length) {
306
+ el.conditional = true;
307
+ el.conditionChain = chain;
308
+ }
309
+ if (repeat.on) {
310
+ el.repeated = true;
311
+ if (repeat.over)
312
+ el.repeatOver = repeat.over;
313
+ if (repeat.varName)
314
+ el.repeatVar = repeat.varName;
315
+ if (repeat.trackBy)
316
+ el.repeatTrackBy = repeat.trackBy;
317
+ }
318
+ seen.push(el);
319
+ walk(el.children, chain, repeat);
320
+ }
321
+ };
322
+ walk(nodes, [], { on: false });
323
+ markUniqueness(seen);
324
+ return seen;
325
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DomNode } from './dom.js';
2
+ import type { SourcePointer } from './source.js';
2
3
  export type PropertyBindingKind = 'decorator' | 'signal';
3
4
  /** An Angular @Input()/@Output() or input()/output() signal. */
4
5
  export interface PropertyBinding {
@@ -21,6 +22,15 @@ export interface ComponentNode {
21
22
  className: string;
22
23
  /** Repo-relative path to the file the component is defined in. */
23
24
  filePath: string;
25
+ /** The class declaration, with lines. `filePath` says which file; this says where in it. */
26
+ source?: SourcePointer;
27
+ /** The TEMPLATE, which for an external `templateUrl` is a different file entirely — and is
28
+ * where every element's own pointer lands. Without this a consumer reading a component's
29
+ * location gets the `.ts`, and every element in it points somewhere else. */
30
+ template?: {
31
+ source: SourcePointer;
32
+ inline: boolean;
33
+ };
24
34
  /** Angular only. */
25
35
  selector?: string;
26
36
  /** Angular only. */
@@ -1 +1 @@
1
- {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../../src/types/component.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAExC,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAEzD,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,EAAE,mBAAmB,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAE9D,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oBAAoB;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,oDAAoD;IACpD,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,oDAAoD;IACpD,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,oDAAoD;IACpD,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,iFAAiF;IACjF,GAAG,CAAC,EAAE,OAAO,EAAE,CAAC;CACjB"}
1
+ {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../../src/types/component.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAEzD,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,EAAE,mBAAmB,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAE9D,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,4FAA4F;IAC5F,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB;;kFAE8E;IAC9E,QAAQ,CAAC,EAAE;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;IACtD,oBAAoB;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oBAAoB;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,oDAAoD;IACpD,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,oDAAoD;IACpD,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,oDAAoD;IACpD,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,iFAAiF;IACjF,GAAG,CAAC,EAAE,OAAO,EAAE,CAAC;CACjB"}
@@ -9,6 +9,7 @@
9
9
  * control-flow detection) rather than a grammar built for the purpose. Best-effort — the
10
10
  * underlying JS could always route around the pattern in a way that isn't detected.
11
11
  */
12
+ import type { SourcePointer } from './source.js';
12
13
  export type Extraction = 'compiler' | 'heuristic';
13
14
  export interface BaseNode {
14
15
  extraction: Extraction;
@@ -17,6 +18,17 @@ export interface BaseNode {
17
18
  export interface BoundExpr {
18
19
  name: string;
19
20
  expr: string;
21
+ /**
22
+ * What kind of binding this is.
23
+ *
24
+ * `[(ngModel)]` desugars into a property AND an event, and in v2 the write-back was
25
+ * indistinguishable from a real handler — so a consumer counting handlers got 20 where the app
26
+ * had 7, and every two-way-bound input looked interactive twice. `twoWayWriteback` marks the
27
+ * generated half.
28
+ */
29
+ kind?: 'dom' | 'output' | 'twoWay' | 'twoWayWriteback';
30
+ /** Where the handler METHOD is declared — the `.ts` file, not the template. */
31
+ handler?: SourcePointer;
20
32
  }
21
33
  /**
22
34
  * `Child` is generic so the resolved dependency-graph tree (packages/core/src/types/
@@ -36,7 +48,61 @@ export interface ElementNode<Child = DomNode> extends BaseNode {
36
48
  /** Template reference variables (#ref). Angular only. */
37
49
  refs?: string[];
38
50
  children: Child[];
51
+ /** Where this element is written. */
52
+ source?: SourcePointer;
53
+ /** STATIC child text only, folded from `TextNode` children and trimmed. An interpolation is
54
+ * never text: a key built from one changes with the test data. */
55
+ staticText?: string;
56
+ /** True when any child was an interpolation — so "no staticText" can be told apart from
57
+ * "the text is dynamic". */
58
+ hasDynamicText?: boolean;
59
+ /** `static`: a stable handle exists. `dynamic`: a token-bearing attribute is an expression
60
+ * (`[attr.id]="'tx-' + tx.id"`), and `tokenTemplate` carries the literal prefix. `none`: this
61
+ * control has no stable handle at all — which is the most actionable thing this file says. */
62
+ tokenStability?: 'static' | 'dynamic' | 'none';
63
+ tokenTemplate?: string;
64
+ controlType?: ControlType;
65
+ /** The tag as WRITTEN, when it differs from what renders (`mat-select` -> a listbox). */
66
+ sourceRepresentation?: string;
67
+ role?: string;
68
+ accessibleName?: string;
69
+ required?: boolean;
70
+ /** True when any ancestor is a structural branch. */
71
+ conditional?: boolean;
72
+ /** Every gate between the template root and this element, outermost first. */
73
+ conditionChain?: ConditionLink[];
74
+ repeated?: boolean;
75
+ repeatOver?: string;
76
+ repeatVar?: string;
77
+ repeatTrackBy?: string;
78
+ /**
79
+ * Handles the SOURCE offers for this element — audit material, never a locator to drive.
80
+ *
81
+ * `unique` computed within one template is a weaker claim than a live DOM's uniqueness: the
82
+ * shell and the route component both render into one page, so a token unique in its own file
83
+ * can still collide once composed. `uniqueScope` says which claim is being made.
84
+ */
85
+ selectorCandidates?: SelectorCandidate[];
39
86
  }
87
+ /** One structural gate, as written in the template. */
88
+ export interface ConditionLink {
89
+ /** `*ngIf`, `@if`, `@for`, `@switch`, `@defer`, ... */
90
+ directive: string;
91
+ /** The guiding expression. Free text with no grammar — treat it as data. */
92
+ expr: string;
93
+ /** Which branch of a multi-branch construct this element sits in (`if`, `else if`, `else`,
94
+ * a `@switch` case label, `empty`, `placeholder`). */
95
+ branch?: string;
96
+ source?: SourcePointer;
97
+ }
98
+ export interface SelectorCandidate {
99
+ by: 'testid' | 'id' | 'name' | 'aria' | 'role' | 'placeholder' | 'text' | 'css';
100
+ value: string;
101
+ unique: boolean;
102
+ uniqueScope: 'template' | 'route';
103
+ }
104
+ /** What a person can DO with this element, independent of how it is spelled. */
105
+ export type ControlType = 'textbox' | 'combobox' | 'listbox' | 'checkbox' | 'radio' | 'button' | 'link' | 'datepicker' | 'fileinput' | 'slider' | 'grid' | 'tab' | 'other';
40
106
  export interface TextNode extends BaseNode {
41
107
  type: 'text';
42
108
  value: string;
@@ -61,6 +127,7 @@ export interface TemplateNode<Child = DomNode> extends BaseNode {
61
127
  branches?: TemplateBranch<Child>[];
62
128
  /** The primary/consequent branch's children, kept so every DomNode has a `children` array. */
63
129
  children: Child[];
130
+ source?: SourcePointer;
64
131
  }
65
132
  export type DomNode = ElementNode | TextNode | InterpolationNode | TemplateNode;
66
133
  //# sourceMappingURL=dom.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../src/types/dom.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,WAAW,CAAC;AAElD,MAAM,WAAW,QAAQ;IACvB,UAAU,EAAE,UAAU,CAAC;CACxB;AAED,mHAAmH;AACnH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW,CAAC,KAAK,GAAG,OAAO,CAAE,SAAQ,QAAQ;IAC5D,IAAI,EAAE,SAAS,CAAC;IAChB,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAC;IACX,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,wEAAwE;IACxE,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,qDAAqD;IACrD,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,KAAK,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,QAAS,SAAQ,QAAQ;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,IAAI,EAAE,eAAe,CAAC;IACtB,8FAA8F;IAC9F,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,cAAc,GACtB,OAAO,GACP,QAAQ,GACR,KAAK,GACL,MAAM,GACN,SAAS,GACT,QAAQ,GACR,SAAS,GACT,IAAI,GACJ,QAAQ,CAAC;AAEb,MAAM,WAAW,cAAc,CAAC,KAAK,GAAG,OAAO;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,YAAY,CAAC,KAAK,GAAG,OAAO,CAAE,SAAQ,QAAQ;IAC7D,IAAI,EAAE,UAAU,CAAC;IACjB,UAAU,EAAE,cAAc,CAAC;IAC3B,iGAAiG;IACjG,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC;IACnC,8FAA8F;IAC9F,QAAQ,EAAE,KAAK,EAAE,CAAC;CACnB;AAED,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG,QAAQ,GAAG,iBAAiB,GAAG,YAAY,CAAC"}
1
+ {"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../src/types/dom.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,WAAW,CAAC;AAElD,MAAM,WAAW,QAAQ;IACvB,UAAU,EAAE,UAAU,CAAC;CACxB;AAED,mHAAmH;AACnH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,iBAAiB,CAAC;IACvD,+EAA+E;IAC/E,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW,CAAC,KAAK,GAAG,OAAO,CAAE,SAAQ,QAAQ;IAC5D,IAAI,EAAE,SAAS,CAAC;IAChB,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAC;IACX,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,wEAAwE;IACxE,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,qDAAqD;IACrD,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,KAAK,EAAE,CAAC;IAElB,qCAAqC;IACrC,MAAM,CAAC,EAAE,aAAa,CAAC;IASvB;uEACmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;iCAC6B;IAC7B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;mGAE+F;IAC/F,cAAc,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;IAC/C,aAAa,CAAC,EAAE,MAAM,CAAC;IAGvB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,yFAAyF;IACzF,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IAOnB,qDAAqD;IACrD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,aAAa,EAAE,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,iBAAiB,EAAE,CAAC;CAC1C;AAED,uDAAuD;AACvD,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,MAAM,GAAG,KAAK,CAAC;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC;CACnC;AAED,gFAAgF;AAChF,MAAM,MAAM,WAAW,GACnB,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAC7E,YAAY,GAAG,WAAW,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;AAErE,MAAM,WAAW,QAAS,SAAQ,QAAQ;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,IAAI,EAAE,eAAe,CAAC;IACtB,8FAA8F;IAC9F,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,cAAc,GACtB,OAAO,GACP,QAAQ,GACR,KAAK,GACL,MAAM,GACN,SAAS,GACT,QAAQ,GACR,SAAS,GACT,IAAI,GACJ,QAAQ,CAAC;AAEb,MAAM,WAAW,cAAc,CAAC,KAAK,GAAG,OAAO;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,YAAY,CAAC,KAAK,GAAG,OAAO,CAAE,SAAQ,QAAQ;IAC7D,IAAI,EAAE,UAAU,CAAC;IACjB,UAAU,EAAE,cAAc,CAAC;IAC3B,iGAAiG;IACjG,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC;IACnC,8FAA8F;IAC9F,QAAQ,EAAE,KAAK,EAAE,CAAC;IAClB,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG,QAAQ,GAAG,iBAAiB,GAAG,YAAY,CAAC"}
@@ -1,16 +1,95 @@
1
1
  import type { ComponentNode } from './component.js';
2
2
  import type { RouteNode } from './route.js';
3
3
  import type { RouteDependencyTree } from './dependency-graph.js';
4
- export declare const SCHEMA_VERSION = "1.0";
4
+ import type { AppIdentity, Coverage, CoverageScope, GeneratorProvenance, Provenance, RepoProvenance } from './provenance.js';
5
+ import type { Uncapturable } from './uncapturable.js';
6
+ import type { SourcePointer } from './source.js';
7
+ /**
8
+ * Bumped to "2.0" for the `app` block, whose two fields are REQUIRED — see `AppIdentity`. A
9
+ * consumer needs exactly one field to test to know whether the routes it is about to read can be
10
+ * matched against real URLs at all, and that field is this one.
11
+ */
12
+ export declare const SCHEMA_VERSION = "2.0";
5
13
  export type Framework = 'angular' | 'react';
6
14
  export interface UiManifest {
7
15
  schemaVersion: typeof SCHEMA_VERSION;
8
16
  framework: Framework;
17
+ /** Where the app is served from. Required: without it every route in this file is unmatchable
18
+ * against a real URL, silently. See {@link AppIdentity}. */
19
+ app: AppIdentity;
20
+ /** Which commit, which extractor, which passes. Non-diffable — ignore it when diffing, the same
21
+ * as `generatedAt`.
22
+ *
23
+ * Kept alongside the top-level `repo`/`generator` below rather than replaced by them: a
24
+ * consumer written against the first 2.0 release reads this block, and breaking it would make
25
+ * a field addition into a coordinated release. The two are the same objects. */
26
+ provenance: Provenance;
27
+ /**
28
+ * The same `provenance.repo` / `provenance.generator`, lifted to the top level.
29
+ *
30
+ * Both spellings exist on purpose. Nesting keeps the non-diffable data in one block a
31
+ * `jq 'del(.provenance)'` can drop whole; lifting is what consumers that require these fields
32
+ * read. `repo.remoteUrl` and `repo.appRoot` are optional on the nested copy (they are absent
33
+ * outside a git tree, which is information) and REQUIRED here — outside a git tree this pair is
34
+ * simply not emitted, so "present but hollow" never occurs.
35
+ */
36
+ repo?: RepoProvenance & {
37
+ remoteUrl: string;
38
+ appRoot: string;
39
+ };
40
+ generator?: GeneratorProvenance & {
41
+ generatedAt: string;
42
+ };
43
+ /** Whether a missing route means "deleted" or "not looked at". See {@link Coverage}. */
44
+ coverage: Coverage;
45
+ coverageScope?: CoverageScope;
9
46
  /** ISO timestamp of generation. Not diff-relevant on its own — consumers diffing two
10
47
  * manifests should ignore this field, since it changes on every run even with no UI change. */
11
48
  generatedAt: string;
49
+ /**
50
+ * Whether presentational nodes were dropped.
51
+ *
52
+ * `"semantic"` means the tree is NOT the DOM: a wrapper carrying nothing but a class, with no
53
+ * events, no props and no handle, is folded away and its static text folded into its parent.
54
+ * That is a large reduction with no loss to any join, precisely because `conditionChain` is
55
+ * denormalized onto each element — nothing downstream needs the ancestors that were dropped.
56
+ */
57
+ nodePolicy?: 'semantic' | 'verbatim';
58
+ /** How many nodes `nodePolicy` removed, so a consumer knows the tree is not the DOM. */
59
+ collapsedNodeCount?: number;
12
60
  routes: RouteNode[];
61
+ /**
62
+ * Wildcard routes.
63
+ *
64
+ * Kept out of `routes[]` because a `**` matches every URL and so identifies none: given a page
65
+ * key it would fold every unmatched screen onto one node, which is worse than a miss.
66
+ */
67
+ fallbacks?: {
68
+ pattern: string;
69
+ redirectTo?: string;
70
+ source?: SourcePointer;
71
+ }[];
13
72
  components: ComponentNode[];
73
+ /**
74
+ * Which components render under each route — the shell above the router outlet included.
75
+ *
76
+ * References, never DOM: the elements live in `components[]` and are resolved once. Without
77
+ * this a consumer keying elements by page attributes the shell's navigation to no page at
78
+ * all, and "where is the sign-out button on this screen" answers "this screen has none".
79
+ */
80
+ routeTrees?: {
81
+ routePath: string;
82
+ rootComponent: string;
83
+ nodes: {
84
+ component: string;
85
+ via?: SourcePointer;
86
+ conditional: boolean;
87
+ repeated: boolean;
88
+ children: unknown[];
89
+ }[];
90
+ }[];
91
+ /** What the extractor could not statically resolve. See {@link Uncapturable}. */
92
+ uncapturable?: Uncapturable[];
14
93
  /** Present only when the extractor was run with dependency-graph resolution enabled. */
15
94
  dependencyGraph?: RouteDependencyTree[];
16
95
  /** Soft-failure notices, e.g. "routing pattern unresolved for src/App.tsx". Never used in
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/types/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAEjE,eAAO,MAAM,cAAc,QAAQ,CAAC;AAEpC,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE5C,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,OAAO,cAAc,CAAC;IACrC,SAAS,EAAE,SAAS,CAAC;IACrB;oGACgG;IAChG,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,wFAAwF;IACxF,eAAe,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACxC;mGAC+F;IAC/F,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/types/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,KAAK,EACV,WAAW,EAAE,QAAQ,EAAE,aAAa,EAAE,mBAAmB,EAAE,UAAU,EAAE,cAAc,EACtF,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;GAIG;AACH,eAAO,MAAM,cAAc,QAAQ,CAAC;AAEpC,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE5C,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,OAAO,cAAc,CAAC;IACrC,SAAS,EAAE,SAAS,CAAC;IACrB;iEAC6D;IAC7D,GAAG,EAAE,WAAW,CAAC;IACjB;;;;;qFAKiF;IACjF,UAAU,EAAE,UAAU,CAAC;IACvB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,cAAc,GAAG;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/D,SAAS,CAAC,EAAE,mBAAmB,GAAG;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1D,wFAAwF;IACxF,QAAQ,EAAE,QAAQ,CAAC;IACnB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;oGACgG;IAChG,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,UAAU,GAAG,UAAU,CAAC;IACrC,wFAAwF;IACxF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,aAAa,CAAA;KAAE,EAAE,CAAC;IAC/E,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE;QACX,SAAS,EAAE,MAAM,CAAC;QAClB,aAAa,EAAE,MAAM,CAAC;QACtB,KAAK,EAAE;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,GAAG,CAAC,EAAE,aAAa,CAAC;YAAC,WAAW,EAAE,OAAO,CAAC;YAAC,QAAQ,EAAE,OAAO,CAAC;YAChF,QAAQ,EAAE,OAAO,EAAE,CAAA;SAAE,EAAE,CAAC;KAClC,EAAE,CAAC;IACJ,iFAAiF;IACjF,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,wFAAwF;IACxF,eAAe,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACxC;mGAC+F;IAC/F,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB"}
@@ -1 +1,6 @@
1
- export const SCHEMA_VERSION = '1.0';
1
+ /**
2
+ * Bumped to "2.0" for the `app` block, whose two fields are REQUIRED — see `AppIdentity`. A
3
+ * consumer needs exactly one field to test to know whether the routes it is about to read can be
4
+ * matched against real URLs at all, and that field is this one.
5
+ */
6
+ export const SCHEMA_VERSION = '2.0';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Where a manifest came from: which app, which commit, which extractor run.
3
+ *
4
+ * v1 could answer "what does this UI contain" but not "which build is this" — so two manifests
5
+ * could only ever be compared against each other, never against a deployed thing. Everything here
6
+ * exists to make a manifest self-locating.
7
+ *
8
+ * **This whole block is non-diffable**, in the same sense `generatedAt` already is: `commit` and
9
+ * `buildId` change on every run even when the UI does not. `jq 'del(.generatedAt, .provenance)'`
10
+ * restores a document whose every remaining field changes only when the UI's structure does. That
11
+ * is why it is a segregated top-level block rather than fields sprinkled through `routes` and
12
+ * `components`.
13
+ */
14
+ /** How the app's router turns a route path into a URL. */
15
+ export type RouterMode = 'path' | 'hash';
16
+ /**
17
+ * Where the app is served from, which is what decides whether a route path matches a real URL.
18
+ *
19
+ * Both fields are REQUIRED, and that is deliberate. Omitting either does not produce an error in
20
+ * any consumer — it produces a silent, total miss. An app served under `/portal/` renders
21
+ * `/portal/dashboard`; a manifest that says `dashboard` matches nothing, on every route, and looks
22
+ * exactly like a manifest for an app that simply has little in it. `useHash: true` fails the same
23
+ * way. A field whose absence is indistinguishable from a wrong answer has to be required.
24
+ */
25
+ export interface AppIdentity {
26
+ /**
27
+ * The app's base path, from Angular's `<base href>` / `APP_BASE_HREF`, or React Router's
28
+ * `basename`. `"/"` when the app is served from the root — which is the common case, and is a
29
+ * real answer rather than a default standing in for "unknown".
30
+ */
31
+ baseHref: string;
32
+ /** `"hash"` for `useHash: true` / `HashRouter`, which puts the entire route after a `#`. */
33
+ routerMode: RouterMode;
34
+ /** How `baseHref`/`routerMode` were established. `"detected"` means read out of the source;
35
+ * `"configured"` means the caller supplied them; `"default"` means neither, and the values are
36
+ * the conventional ones — which a consumer should treat as a weaker claim. */
37
+ confidence: 'detected' | 'configured' | 'default';
38
+ }
39
+ /** The commit the source was in when the manifest was generated. */
40
+ export interface RepoProvenance {
41
+ /** `origin`'s URL, as git reports it. Absent outside a git working tree. */
42
+ remoteUrl?: string;
43
+ /** Full commit sha. Absent outside a git working tree, and deliberately NOT defaulted to a
44
+ * branch name: a branch moves, so a manifest pinned to one is not pinned at all. Its absence
45
+ * marks the output as unpinned, which is information a consumer can act on. */
46
+ commit?: string;
47
+ /** Committer timestamp, ISO 8601. From git, never from a clock: it is what orders two manifests
48
+ * that arrive out of sequence, and a generation timestamp would order them by when they were
49
+ * uploaded rather than by which describes newer code. */
50
+ commitTime?: string;
51
+ branch?: string;
52
+ /** True when the working tree had uncommitted changes — so `commit` describes *most* of what was
53
+ * extracted, not all of it. Silently reporting a commit for a dirty tree is how a manifest comes
54
+ * to describe code that was never committed anywhere. */
55
+ dirty?: boolean;
56
+ /** The scanned directory, relative to the repository root. Every `filePath` in the manifest is
57
+ * relative to this, so a consumer can reconstruct a repo-relative path without guessing. */
58
+ appRoot?: string;
59
+ }
60
+ /** Which extractor produced this, and what it actually ran. */
61
+ export interface GeneratorProvenance {
62
+ name: string;
63
+ version: string;
64
+ /** The CI run that produced it, when one did (`GITHUB_RUN_ID` and friends). */
65
+ buildId?: string;
66
+ /**
67
+ * Analysis passes that ran, e.g. `["routes", "components", "dom", "dependency-graph"]`.
68
+ *
69
+ * The point is negative information: a consumer must be able to tell "this component has no DOM
70
+ * tree" from "the DOM pass never ran". Without it, every optional field is ambiguous between
71
+ * "absent" and "not looked for", and a consumer either re-runs unnecessarily or trusts a gap.
72
+ */
73
+ passes: string[];
74
+ }
75
+ export interface Provenance {
76
+ repo: RepoProvenance;
77
+ generator: GeneratorProvenance;
78
+ }
79
+ /**
80
+ * Whether this manifest describes the whole app or a named part of it.
81
+ *
82
+ * Load-bearing for anything that merges manifests over time. Given only a manifest that lacks a
83
+ * route, a consumer cannot tell whether the route was DELETED or merely not covered by this run —
84
+ * and guessing "not covered" means a deleted route lives forever, while guessing "deleted" throws
85
+ * away a real one. `"full"` is a claim that absence means deletion; `"partial"` is a claim that it
86
+ * does not, and `coverageScope` says what was actually looked at.
87
+ */
88
+ export type Coverage = 'full' | 'partial';
89
+ export interface CoverageScope {
90
+ routes?: string[];
91
+ paths?: string[];
92
+ }
93
+ //# sourceMappingURL=provenance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provenance.d.ts","sourceRoot":"","sources":["../../src/types/provenance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0DAA0D;AAC1D,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAEzC;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,4FAA4F;IAC5F,UAAU,EAAE,UAAU,CAAC;IACvB;;mFAE+E;IAC/E,UAAU,EAAE,UAAU,GAAG,YAAY,GAAG,SAAS,CAAC;CACnD;AAED,oEAAoE;AACpE,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;oFAEgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;8DAE0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;8DAE0D;IAC1D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;iGAC6F;IAC7F,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,+DAA+D;AAC/D,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,EAAE,mBAAmB,CAAC;CAChC;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,CAAC;AAE1C,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Where a manifest came from: which app, which commit, which extractor run.
3
+ *
4
+ * v1 could answer "what does this UI contain" but not "which build is this" — so two manifests
5
+ * could only ever be compared against each other, never against a deployed thing. Everything here
6
+ * exists to make a manifest self-locating.
7
+ *
8
+ * **This whole block is non-diffable**, in the same sense `generatedAt` already is: `commit` and
9
+ * `buildId` change on every run even when the UI does not. `jq 'del(.generatedAt, .provenance)'`
10
+ * restores a document whose every remaining field changes only when the UI's structure does. That
11
+ * is why it is a segregated top-level block rather than fields sprinkled through `routes` and
12
+ * `components`.
13
+ */
14
+ export {};
@@ -1,18 +1,57 @@
1
+ import type { SourcePointer } from './source.js';
2
+ /**
3
+ * One guard on a route.
4
+ *
5
+ * v2 emitted a bare name. A name cannot be opened, so "what gates this route?" was answerable
6
+ * only as far as "something called authGuard" — and the follow-up, which is the one that matters
7
+ * when a test cannot reach a screen, needed a repository search to answer.
8
+ */
9
+ export interface RouteGuard {
10
+ name: string;
11
+ kind: 'function' | 'class';
12
+ source?: SourcePointer;
13
+ }
1
14
  export interface RouteGuards {
2
- canActivate?: string[];
3
- canDeactivate?: string[];
15
+ canActivate?: RouteGuard[];
16
+ canActivateChild?: RouteGuard[];
17
+ canDeactivate?: RouteGuard[];
18
+ canMatch?: RouteGuard[];
4
19
  }
5
20
  export type ReactRoutingPattern = 'jsx-routes' | 'router-config' | 'file-based';
6
21
  export interface RouteNode {
22
+ /** This route's own segment, exactly as written in the source. */
7
23
  path: string;
24
+ /**
25
+ * The full path a URL must have to reach this route: every ancestor's `path` joined with this
26
+ * one, `baseHref` applied, and leading/trailing slashes normalised.
27
+ *
28
+ * Derivable by a consumer from the tree — but doing it here means one implementation instead of
29
+ * one per consumer, and it removes a specific way to get it wrong: two `''` children under
30
+ * different parents are the same `path` and different `fullPath`s, so a consumer keying on
31
+ * `path` silently collides them.
32
+ *
33
+ * Absent for a route that cannot BE a URL — a bare `**` wildcard matches every path and so
34
+ * identifies none, and giving it a `fullPath` would invite a consumer to treat it as a screen.
35
+ */
36
+ fullPath?: string;
8
37
  /** The resolved lazy-loaded target, e.g. Angular's loadComponent or a React <Route element>. */
9
38
  component?: {
10
39
  module: string;
11
40
  export: string;
41
+ source?: SourcePointer;
42
+ };
43
+ loadComponent?: {
44
+ module: string;
45
+ export: string;
46
+ source?: SourcePointer;
12
47
  };
13
48
  redirectTo?: string;
14
49
  pathMatch?: string;
15
50
  guards?: RouteGuards;
51
+ /** Query parameters this route reads. */
52
+ queryParamKeys?: string[];
53
+ /** Where the route object literal is written. */
54
+ source?: SourcePointer;
16
55
  children?: RouteNode[];
17
56
  /** React only, set at the tree root: which detection strategy produced this tree. */
18
57
  routingPattern?: ReactRoutingPattern;
@@ -1 +1 @@
1
- {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/types/route.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,MAAM,mBAAmB,GAAG,YAAY,GAAG,eAAe,GAAG,YAAY,CAAC;AAEhF,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,gGAAgG;IAChG,SAAS,CAAC,EAAE;QACV,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,SAAS,EAAE,CAAC;IACvB,qFAAqF;IACrF,cAAc,CAAC,EAAE,mBAAmB,CAAC;CACtC"}
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/types/route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;IAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,gBAAgB,CAAC,EAAE,UAAU,EAAE,CAAC;IAChC,aAAa,CAAC,EAAE,UAAU,EAAE,CAAC;IAC7B,QAAQ,CAAC,EAAE,UAAU,EAAE,CAAC;CACzB;AAED,MAAM,MAAM,mBAAmB,GAAG,YAAY,GAAG,eAAe,GAAG,YAAY,CAAC;AAEhF,MAAM,WAAW,SAAS;IACxB,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gGAAgG;IAChG,SAAS,CAAC,EAAE;QACV,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,aAAa,CAAC;KACxB,CAAC;IACF,aAAa,CAAC,EAAE;QACd,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,aAAa,CAAC;KACxB,CAAC;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,yCAAyC;IACzC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,iDAAiD;IACjD,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,QAAQ,CAAC,EAAE,SAAS,EAAE,CAAC;IACvB,qFAAqF;IACrF,cAAc,CAAC,EAAE,mBAAmB,CAAC;CACtC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where in the repository a thing is declared.
3
+ *
4
+ * v2 emitted routes, components and elements but never said *where they came from*, so every
5
+ * "where is this declared?" answer a consumer could give was "somewhere in this file" — and for a
6
+ * 400-line template that is not an answer, it is a re-read. Line numbers are the single biggest
7
+ * thing this block adds, and they cost nothing to produce: Angular's `parseTemplate()` and the
8
+ * TypeScript AST both carry positions on every node already.
9
+ *
10
+ * `blobOid` is the quietly valuable field. A git blob sha survives a rename, so a pointer stays
11
+ * resolvable after a refactor that a path alone would strand. Absent until the extractor is asked
12
+ * to shell out to git per file, which it is not today.
13
+ */
14
+ export interface SourcePointer {
15
+ /** Repo-relative, forward slashes, no `..` segment. Relative to the repository root — NOT to
16
+ * `repo.appRoot` — so a consumer can hand it to a repository API without re-joining anything. */
17
+ path: string;
18
+ /** A class, method or guard name. An identifier, never free text. */
19
+ symbol?: string;
20
+ /** 1-based, like an editor shows and unlike every parser that produces it. */
21
+ startLine?: number;
22
+ endLine?: number;
23
+ blobOid?: string;
24
+ }
25
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../../src/types/source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,aAAa;IAC5B;sGACkG;IAClG,IAAI,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * What the extractor could NOT statically resolve.
3
+ *
4
+ * This is not optional bookkeeping. When a consumer sees an element at runtime that no manifest
5
+ * declares, there are two causes — the manifest is stale, or the app injects that DOM — and they
6
+ * need opposite actions. This list is the only thing that tells them apart. Without it, every such
7
+ * finding is undiagnosable and a manifest covering 70% of an app looks complete.
8
+ *
9
+ * `diagnostics[]` is still emitted alongside and still carries the same notices as free text; this
10
+ * is the same information given a shape a consumer can branch on.
11
+ */
12
+ export type UncapturableKind = 'dynamicComponentOutlet' | 'innerHTML' | 'runtimeRoute' | 'unresolvedLazyChunk' | 'thirdPartyWebComponent' | 'iframe' | 'dynamicSelector' | 'unresolvedApiUrl' | 'templateParseError' | 'unsupportedTemplateNode';
13
+ export interface Uncapturable {
14
+ kind: UncapturableKind;
15
+ /** The component (or route) whose extraction is incomplete because of this. */
16
+ affects?: string;
17
+ /** Free text, for a person. */
18
+ detail?: string;
19
+ source?: import('./source.js').SourcePointer;
20
+ }
21
+ //# sourceMappingURL=uncapturable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uncapturable.d.ts","sourceRoot":"","sources":["../../src/types/uncapturable.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,gBAAgB,GACxB,wBAAwB,GACxB,WAAW,GACX,cAAc,GACd,qBAAqB,GACrB,wBAAwB,GACxB,QAAQ,GACR,iBAAiB,GACjB,kBAAkB,GAClB,oBAAoB,GACpB,yBAAyB,CAAC;AAE9B,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,gBAAgB,CAAC;IACvB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+BAA+B;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,aAAa,EAAE,aAAa,CAAC;CAC9C"}
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,9 +1,25 @@
1
1
  {
2
2
  "name": "@ui-manifest-json/core",
3
- "version": "0.1.0",
4
- "description": "Shared JSON schema types and dependency-tree resolver for ui-manifest extractors.",
3
+ "version": "0.3.0",
4
+ "description": "Shared JSON schema types and framework-agnostic helpers for ui-manifest extractors.",
5
+ "keywords": [
6
+ "ui-manifest",
7
+ "schema",
8
+ "types",
9
+ "ast",
10
+ "static-analysis"
11
+ ],
5
12
  "type": "module",
6
13
  "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/BrainRidge/ui-manifest.git",
17
+ "directory": "packages/core"
18
+ },
19
+ "homepage": "https://github.com/BrainRidge/ui-manifest/tree/main/packages/core#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/BrainRidge/ui-manifest/issues"
22
+ },
7
23
  "engines": {
8
24
  "node": ">=20.6.0"
9
25
  },