@descryy/runtime-identity-grounding 0.2.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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What identity grounding is allowed to say about one on-screen element.
3
+ *
4
+ * The whole package answers a single question — *which component produced
5
+ * this DOM node, and where is that written* — and this file fixes the
6
+ * vocabulary of the answer so that "we could not tell" is a first-class,
7
+ * named value rather than a null a reader has to interpret.
8
+ *
9
+ * ## No new event type, no new node type
10
+ *
11
+ * `RuntimeEventType` (`@descryy/runtime-contracts`) gains nothing here, and
12
+ * neither does `descry-core`'s frozen 15/15 graph vocabulary. This follows
13
+ * `@descryy/runtime-browser`'s `dom-evidence.ts` precedent exactly: a DOM
14
+ * fact is naturally *"what did the element this action touched look like"*,
15
+ * not an independent stream, so it rides on the `Evidence.payload` of the
16
+ * action evidence a caller is already emitting. An `ElementIdentity` is a
17
+ * plain, JSON-serialisable object for exactly that reason.
18
+ *
19
+ * The graph side is the same discipline one layer over: this package
20
+ * resolves an element to a **file, a symbol name and a line**, which is
21
+ * precisely `resolveSymbolNode`'s (`@descryy/runtime-graph-correlator`) input.
22
+ * It does not resolve to a node id itself — that resolver already refuses
23
+ * ambiguity, checks the line against the node's range and knows about
24
+ * repo-relative versus absolute path languages, and a second copy of that
25
+ * reasoning here would be a copy that drifts. See `graph-join.ts`.
26
+ */
27
+ import type { CapabilityStatus, SourceLocation } from "@descryy/runtime-contracts";
28
+ /**
29
+ * Every way this can decline to name a component, as a closed set.
30
+ *
31
+ * A refusal is not an error and is never thrown — same contract
32
+ * `CollectorStartResult` states for a collector that cannot start: absence
33
+ * has to be representable data the caller can act on.
34
+ */
35
+ export declare const IDENTITY_REFUSALS: readonly ["probe-not-installed", "element-not-found", "not-react", "react-debug-fields-absent", "no-component-fiber", "anonymous-component"];
36
+ export type IdentityRefusal = (typeof IDENTITY_REFUSALS)[number];
37
+ /**
38
+ * How a source location was obtained. The two values are the two React
39
+ * majors' genuinely different internals, not two implementations of one
40
+ * idea — see `identity-grounding.ts` for the measurements.
41
+ */
42
+ export declare const GROUNDING_MECHANISMS: readonly ["react-debug-source", "react-owner-stack"];
43
+ export type GroundingMechanism = (typeof GROUNDING_MECHANISMS)[number];
44
+ export declare const COMPONENT_KINDS: readonly ["function", "class", "forward-ref", "memo"];
45
+ export type ComponentKind = (typeof COMPONENT_KINDS)[number];
46
+ export declare const COMPONENT_NAME_SOURCES: readonly ["displayName", "function-name", "forward-ref-render-name"];
47
+ export type ComponentNameSource = (typeof COMPONENT_NAME_SOURCES)[number];
48
+ export interface ResolvedComponent {
49
+ readonly name: string;
50
+ /** Which property the name actually came from — a `displayName` a developer set and a minifier-surviving `function.name` are not equally trustworthy, and a reader should be able to tell them apart. */
51
+ readonly nameSource: ComponentNameSource;
52
+ readonly kind: ComponentKind;
53
+ }
54
+ /**
55
+ * Which fiber field the component was read from.
56
+ *
57
+ * `_debugOwner` is React's own record of **which component's render created
58
+ * this element**, and it is the one that pairs with the source location:
59
+ * `_debugSource` and `_debugStack` both describe the *creation* site. The
60
+ * `return` chain answers a different question — which component this
61
+ * element ended up rendered *inside* — and the two differ whenever an
62
+ * element is created in one component and passed down as `children` to
63
+ * another. The fixture app renders exactly that case on purpose.
64
+ */
65
+ export declare const COMPONENT_SOURCES: readonly ["debug-owner", "return-chain"];
66
+ export type ComponentSource = (typeof COMPONENT_SOURCES)[number];
67
+ interface ElementIdentityBase {
68
+ readonly selector: string;
69
+ /**
70
+ * How many `parentElement` hops from the queried element to the node that
71
+ * actually carried a React fiber. `0` is the element itself. Greater than
72
+ * zero means the queried node was not created by React at all — the
73
+ * inside of a `dangerouslySetInnerHTML` block, say — and the answer
74
+ * describes its nearest React-rendered ancestor. Stated, never folded
75
+ * into the answer silently.
76
+ */
77
+ readonly fiberDistance: number;
78
+ }
79
+ export interface ElementIdentityGrounded extends ElementIdentityBase {
80
+ readonly outcome: "grounded";
81
+ /** The component whose render created this element. */
82
+ readonly component: ResolvedComponent;
83
+ readonly componentSource: ComponentSource;
84
+ /** The nearest component in the `return` chain — where the element was rendered, as opposed to where it was created. Null when there is none above the host fiber. */
85
+ readonly renderedBy: ResolvedComponent | null;
86
+ readonly sourceLocation: SourceLocation;
87
+ /**
88
+ * True when `sourceLocation.file` names authored source. False when it
89
+ * names compiled output because nothing could map it back — a React 19
90
+ * owner stack with no source map beside the bundle. The location is still
91
+ * real and still worth reporting; it just is not the file a developer
92
+ * wrote, and `capability` degrades accordingly.
93
+ */
94
+ readonly authoredSource: boolean;
95
+ readonly mechanism: GroundingMechanism;
96
+ readonly capability: CapabilityStatus;
97
+ }
98
+ export interface ElementIdentityComponentOnly extends ElementIdentityBase {
99
+ readonly outcome: "component-only";
100
+ /** The component is named and trustworthy — this is a development build — but no source location could be produced for it. */
101
+ readonly component: ResolvedComponent;
102
+ readonly componentSource: ComponentSource;
103
+ readonly renderedBy: ResolvedComponent | null;
104
+ readonly capability: CapabilityStatus;
105
+ readonly reason: string;
106
+ }
107
+ export interface ElementIdentityRefused extends ElementIdentityBase {
108
+ readonly outcome: "refused";
109
+ readonly refusal: IdentityRefusal;
110
+ readonly reason: string;
111
+ readonly capability: CapabilityStatus;
112
+ /**
113
+ * The running page's own `type.name` for the fiber, when there was a
114
+ * fiber at all.
115
+ *
116
+ * **Not a component name**, and deliberately not called one. On the
117
+ * `react-debug-fields-absent` path this is a minified identifier, kept so
118
+ * a human debugging a refusal can see what was actually there. Anything
119
+ * that treats this as an authored name has reintroduced exactly the guess
120
+ * the refusal exists to prevent.
121
+ */
122
+ readonly observedTypeName: string | null;
123
+ }
124
+ export type ElementIdentity = ElementIdentityGrounded | ElementIdentityComponentOnly | ElementIdentityRefused;
125
+ /**
126
+ * The capability level this answer reports, in `CollectorCapabilities`'s own
127
+ * three-value vocabulary (`@descryy/runtime-contracts`) rather than a fourth
128
+ * one invented here.
129
+ *
130
+ * The mapping, stated once so it cannot drift between call sites:
131
+ *
132
+ * | outcome | availability | why |
133
+ * | --- | --- | --- |
134
+ * | `grounded`, authored source, not stale | `available` | component and authored file/line, both trustworthy |
135
+ * | `grounded`, compiled or stale location | `degraded` | a real location that is not the authored one, or one whose freshness failed a check |
136
+ * | `component-only` | `degraded` | the component is named; no location at all |
137
+ * | `refused` | `unavailable` | nothing was named |
138
+ *
139
+ * `unavailable` is this repo's existing word for what a refusal is; there is
140
+ * no `refused` member of `CapabilityAvailability` and adding one would fork
141
+ * the capability model for one package.
142
+ */
143
+ export declare function capabilityFor(availability: "available"): CapabilityStatus;
144
+ export declare function capabilityFor(availability: "degraded" | "unavailable", reason: string): CapabilityStatus;
145
+ export {};
146
+ //# sourceMappingURL=element-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"element-identity.d.ts","sourceRoot":"","sources":["../src/element-identity.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * What identity grounding is allowed to say about one on-screen element.
3
+ *
4
+ * The whole package answers a single question — *which component produced
5
+ * this DOM node, and where is that written* — and this file fixes the
6
+ * vocabulary of the answer so that "we could not tell" is a first-class,
7
+ * named value rather than a null a reader has to interpret.
8
+ *
9
+ * ## No new event type, no new node type
10
+ *
11
+ * `RuntimeEventType` (`@descryy/runtime-contracts`) gains nothing here, and
12
+ * neither does `descry-core`'s frozen 15/15 graph vocabulary. This follows
13
+ * `@descryy/runtime-browser`'s `dom-evidence.ts` precedent exactly: a DOM
14
+ * fact is naturally *"what did the element this action touched look like"*,
15
+ * not an independent stream, so it rides on the `Evidence.payload` of the
16
+ * action evidence a caller is already emitting. An `ElementIdentity` is a
17
+ * plain, JSON-serialisable object for exactly that reason.
18
+ *
19
+ * The graph side is the same discipline one layer over: this package
20
+ * resolves an element to a **file, a symbol name and a line**, which is
21
+ * precisely `resolveSymbolNode`'s (`@descryy/runtime-graph-correlator`) input.
22
+ * It does not resolve to a node id itself — that resolver already refuses
23
+ * ambiguity, checks the line against the node's range and knows about
24
+ * repo-relative versus absolute path languages, and a second copy of that
25
+ * reasoning here would be a copy that drifts. See `graph-join.ts`.
26
+ */
27
+ /**
28
+ * Every way this can decline to name a component, as a closed set.
29
+ *
30
+ * A refusal is not an error and is never thrown — same contract
31
+ * `CollectorStartResult` states for a collector that cannot start: absence
32
+ * has to be representable data the caller can act on.
33
+ */
34
+ export const IDENTITY_REFUSALS = [
35
+ /** `install()` was never called, or was called after the document had already loaded — `page.addInitScript` does not reach a document that is already there. */
36
+ "probe-not-installed",
37
+ /** The selector matched nothing in the page. */
38
+ "element-not-found",
39
+ /** Neither the element nor any ancestor carries a React fiber key. Not a React tree — or not one this React version tags on the host node. */
40
+ "not-react",
41
+ /**
42
+ * A fiber was found and it carries no `_debug*` field of any kind.
43
+ *
44
+ * **This is the production-build refusal, and it is the one that must
45
+ * never soften into a guess.** React strips `_debugSource` /
46
+ * `_debugOwner` / `_debugStack` from fibers outside a development build,
47
+ * and a production bundle is additionally minified, so `type.name` on the
48
+ * fiber is whatever the minifier left behind — `U2`, `ap` — not the
49
+ * authored component name. Measured directly against React 18.3.1 and
50
+ * 19.2.8 production bundles: zero `_debug*` keys, and component names
51
+ * mangled in both.
52
+ *
53
+ * So there are two independent losses here, not one: no source location,
54
+ * *and* no trustworthy name. Reporting the mangled name as the component
55
+ * would be a confident wrong answer, which is worse than this refusal.
56
+ * The observed `type.name` still travels on
57
+ * `ElementIdentityRefused.observedTypeName`, labelled as what it is.
58
+ *
59
+ * The second, far rarer reading of the same observation — a React version
60
+ * that stopped exposing `_debug*` in development too — cannot be
61
+ * distinguished from here, which is why this is named for the evidence
62
+ * (the fields are absent) rather than for the conclusion (it is a
63
+ * production build). The reason string carries both readings.
64
+ */
65
+ "react-debug-fields-absent",
66
+ /** A host fiber with no component above it at all — an element rendered directly by the root, with no `_debugOwner` and no component in its `return` chain. */
67
+ "no-component-fiber",
68
+ /** A component fiber was found and it has no resolvable name (an anonymous function or arrow with no `displayName`). Refused rather than reported as a nameless component: `resolveSymbolNode` has nothing to look up, and neither does a reader. */
69
+ "anonymous-component",
70
+ ];
71
+ /**
72
+ * How a source location was obtained. The two values are the two React
73
+ * majors' genuinely different internals, not two implementations of one
74
+ * idea — see `identity-grounding.ts` for the measurements.
75
+ */
76
+ export const GROUNDING_MECHANISMS = [
77
+ /** React 18 and earlier, development build: `fiber._debugSource`, which names the authored file and line directly. */
78
+ "react-debug-source",
79
+ /** React 19, development build: `fiber._debugStack`, a captured `Error` whose frames point into *compiled* output and must be mapped back. */
80
+ "react-owner-stack",
81
+ ];
82
+ export const COMPONENT_KINDS = ["function", "class", "forward-ref", "memo"];
83
+ export const COMPONENT_NAME_SOURCES = ["displayName", "function-name", "forward-ref-render-name"];
84
+ /**
85
+ * Which fiber field the component was read from.
86
+ *
87
+ * `_debugOwner` is React's own record of **which component's render created
88
+ * this element**, and it is the one that pairs with the source location:
89
+ * `_debugSource` and `_debugStack` both describe the *creation* site. The
90
+ * `return` chain answers a different question — which component this
91
+ * element ended up rendered *inside* — and the two differ whenever an
92
+ * element is created in one component and passed down as `children` to
93
+ * another. The fixture app renders exactly that case on purpose.
94
+ */
95
+ export const COMPONENT_SOURCES = ["debug-owner", "return-chain"];
96
+ export function capabilityFor(availability, reason) {
97
+ return availability === "available" ? { availability, reason: null } : { availability, reason: reason ?? "" };
98
+ }
99
+ //# sourceMappingURL=element-identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"element-identity.js","sourceRoot":"","sources":["../src/element-identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,gKAAgK;IAChK,qBAAqB;IACrB,gDAAgD;IAChD,mBAAmB;IACnB,8IAA8I;IAC9I,WAAW;IACX;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,2BAA2B;IAC3B,+JAA+J;IAC/J,oBAAoB;IACpB,qPAAqP;IACrP,qBAAqB;CACb,CAAC;AAGX;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,sHAAsH;IACtH,oBAAoB;IACpB,8IAA8I;IAC9I,mBAAmB;CACX,CAAC;AAGX,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,UAAU,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,CAAU,CAAC;AAGrF,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,aAAa,EAAE,eAAe,EAAE,yBAAyB,CAAU,CAAC;AAU3G;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,aAAa,EAAE,cAAc,CAAU,CAAC;AAsF1E,MAAM,UAAU,aAAa,CAAC,YAA8C,EAAE,MAAe;IAC3F,OAAO,YAAY,KAAK,WAAW,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE,EAAE,CAAC;AAChH,CAAC"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The page-side half: reads React's fiber bookkeeping off a real DOM node
3
+ * and returns **raw facts only**.
4
+ *
5
+ * ## Read-only, and why that matters here
6
+ *
7
+ * `frontend-initiator-capture.ts` (`@descryy/runtime-browser`) had to open
8
+ * its own module comment by conceding that it *writes* — it wraps
9
+ * `window.fetch` and adds a header to real outgoing requests, which is a
10
+ * real observable side effect on the application under test. This module
11
+ * has no such concession to make. It defines one function on `window` and
12
+ * reads properties React already put on DOM nodes and on its own fiber
13
+ * objects. Nothing is wrapped, nothing is patched, no application code path
14
+ * changes shape because Descry is attached. That is the same
15
+ * observation-only guarantee `Collector` states for attach mode
16
+ * (`ATTACH_MODE_SCOPE_DISCLOSURE`), and it holds here without needing an
17
+ * exception.
18
+ *
19
+ * ## Injected through the existing seam, not a second one
20
+ *
21
+ * `page.addInitScript` with a string body — exactly the mechanism
22
+ * `frontend-initiator-capture.ts` already uses, for the same reason it
23
+ * gives: the script runs as written in the page's own JS engine and needs
24
+ * no transpilation step of its own. It is a string rather than a typed
25
+ * function because this repo's `tsconfig.base.json` compiles with `lib:
26
+ * ["ES2023"]` and no DOM library at all, so `document` and `window` do not
27
+ * exist as types in this package. Writing the page code as a string is not
28
+ * a shortcut around that; it is the same choice the sibling module made,
29
+ * and it keeps the boundary between "runs here" and "runs there" visible.
30
+ *
31
+ * `addInitScript` does not reach a document that has already loaded. Call
32
+ * `install()` before navigating, like every other collector in
33
+ * `@descryy/runtime-browser` — a page navigated first reports
34
+ * `probe-not-installed`, by name, rather than silently returning nothing.
35
+ *
36
+ * ## Raw facts only — no decisions
37
+ *
38
+ * Everything this returns is a JSON-serialisable transcript of what was on
39
+ * the fiber. Deciding what it *means* (is this a production build? does
40
+ * this owner stack point at authored source?) happens in Node, in
41
+ * `identity-grounding.ts`, where it can be unit-tested without a browser
42
+ * and where the source-map machinery this repo already owns is reachable.
43
+ * The page-side script cannot resolve a source map and must not try.
44
+ */
45
+ import type { Page } from "playwright-core";
46
+ import type { ComponentKind, ComponentNameSource } from "./element-identity.ts";
47
+ /** The single global this installs. One name, one function, no other footprint on the page. */
48
+ export declare const IDENTITY_GROUNDING_BINDING = "__descryIdentityGrounding";
49
+ /**
50
+ * The two key prefixes React has used to hang a fiber off a host DOM node:
51
+ * `__reactFiber$` since React 17, `__reactInternalInstance$` before that.
52
+ * Both are checked because both are cheap to check and a wrong "not React"
53
+ * answer on a React 16 page would be a silent, permanent blind spot.
54
+ *
55
+ * Measured present as `__reactFiber$<random>` on React 18.3.1 and 19.2.8
56
+ * alike; `__reactInternalInstance$` is carried on the documented shape of
57
+ * React 16 and is **not** covered by this repo's fixtures, which is stated
58
+ * here rather than implied by the code.
59
+ */
60
+ export declare const FIBER_KEY_PREFIXES: readonly ["__reactFiber$", "__reactInternalInstance$"];
61
+ export type FiberKeyPrefix = (typeof FIBER_KEY_PREFIXES)[number];
62
+ /** A component's name as the page could read it, or an explicit "there was a component and it has no name". */
63
+ export interface RawComponentDescriptor {
64
+ readonly name: string | null;
65
+ readonly nameSource: ComponentNameSource | null;
66
+ readonly kind: ComponentKind;
67
+ }
68
+ export interface RawDebugSource {
69
+ readonly fileName: string | null;
70
+ readonly lineNumber: number | null;
71
+ readonly columnNumber: number | null;
72
+ }
73
+ export type RawFiberProbe = {
74
+ readonly status: "probe-not-installed";
75
+ } | {
76
+ readonly status: "element-not-found";
77
+ readonly selector: string;
78
+ } | {
79
+ readonly status: "no-fiber";
80
+ readonly selector: string;
81
+ /** The element's own enumerable keys, capped — so a "not React" refusal can be argued with rather than merely believed. */
82
+ readonly ownKeys: readonly string[];
83
+ } | {
84
+ readonly status: "fiber";
85
+ readonly selector: string;
86
+ readonly fiberKeyPrefix: FiberKeyPrefix;
87
+ readonly fiberDistance: number;
88
+ /** `fiber.tag`, verbatim, for audit only. Nothing in this package branches on it — see `describeType` in the injected script for why the type shape is used instead. */
89
+ readonly hostTag: number | null;
90
+ /** `fiber.type` when it is a string, i.e. the host element's tag name. Null for a non-host fiber. */
91
+ readonly hostTypeName: string | null;
92
+ /** Every `_debug*` key present on the host fiber. Empty means a non-development React build — the one fact the production refusal rests on. */
93
+ readonly debugFields: readonly string[];
94
+ /** `fiber._debugOwner`'s type, described. Null when there is no owner, or the owner is not a nameable component (a host root, a context provider). */
95
+ readonly owner: RawComponentDescriptor | null;
96
+ /** The nearest nameable component walking `fiber.return`. Null when there is none. */
97
+ readonly returnChainComponent: RawComponentDescriptor | null;
98
+ /** React 18 and earlier, development: the authored file/line the JSX transform recorded. */
99
+ readonly debugSource: RawDebugSource | null;
100
+ /** React 19, development: `fiber._debugStack.stack`, verbatim. Frames point into compiled output. */
101
+ readonly debugStackText: string | null;
102
+ /** `type.name` off the host fiber's own type when it is not a string — kept for the refusal path, never treated as an authored name. */
103
+ readonly observedTypeName: string | null;
104
+ };
105
+ export interface IdentityGroundingProbe {
106
+ /** Installs the page-side reader into every document this page loads from now on. Call before navigating. */
107
+ install(page: Page): Promise<void>;
108
+ /** Runs the reader against one selector in the page's current document. */
109
+ probe(page: Page, selector: string): Promise<RawFiberProbe>;
110
+ }
111
+ export declare function createIdentityGroundingProbe(): IdentityGroundingProbe;
112
+ //# sourceMappingURL=fiber-probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fiber-probe.d.ts","sourceRoot":"","sources":["../src/fiber-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAEhF,+FAA+F;AAC/F,eAAO,MAAM,0BAA0B,8BAA8B,CAAC;AAEtE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,wDAAyD,CAAC;AACzF,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE,+GAA+G;AAC/G,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAChD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAA;CAAE,GAC1C;IAAE,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACnE;IACE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2HAA2H;IAC3H,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC,GACD;IACE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,wKAAwK;IACxK,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,qGAAqG;IACrG,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,+IAA+I;IAC/I,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,sJAAsJ;IACtJ,QAAQ,CAAC,KAAK,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAC9C,sFAAsF;IACtF,QAAQ,CAAC,oBAAoB,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAC7D,4FAA4F;IAC5F,QAAQ,CAAC,WAAW,EAAE,cAAc,GAAG,IAAI,CAAC;IAC5C,qGAAqG;IACrG,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,wIAAwI;IACxI,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1C,CAAC;AAEN,MAAM,WAAW,sBAAsB;IACrC,6GAA6G;IAC7G,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,2EAA2E;IAC3E,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;CAC7D;AAED,wBAAgB,4BAA4B,IAAI,sBAAsB,CASrE"}
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The page-side half: reads React's fiber bookkeeping off a real DOM node
3
+ * and returns **raw facts only**.
4
+ *
5
+ * ## Read-only, and why that matters here
6
+ *
7
+ * `frontend-initiator-capture.ts` (`@descryy/runtime-browser`) had to open
8
+ * its own module comment by conceding that it *writes* — it wraps
9
+ * `window.fetch` and adds a header to real outgoing requests, which is a
10
+ * real observable side effect on the application under test. This module
11
+ * has no such concession to make. It defines one function on `window` and
12
+ * reads properties React already put on DOM nodes and on its own fiber
13
+ * objects. Nothing is wrapped, nothing is patched, no application code path
14
+ * changes shape because Descry is attached. That is the same
15
+ * observation-only guarantee `Collector` states for attach mode
16
+ * (`ATTACH_MODE_SCOPE_DISCLOSURE`), and it holds here without needing an
17
+ * exception.
18
+ *
19
+ * ## Injected through the existing seam, not a second one
20
+ *
21
+ * `page.addInitScript` with a string body — exactly the mechanism
22
+ * `frontend-initiator-capture.ts` already uses, for the same reason it
23
+ * gives: the script runs as written in the page's own JS engine and needs
24
+ * no transpilation step of its own. It is a string rather than a typed
25
+ * function because this repo's `tsconfig.base.json` compiles with `lib:
26
+ * ["ES2023"]` and no DOM library at all, so `document` and `window` do not
27
+ * exist as types in this package. Writing the page code as a string is not
28
+ * a shortcut around that; it is the same choice the sibling module made,
29
+ * and it keeps the boundary between "runs here" and "runs there" visible.
30
+ *
31
+ * `addInitScript` does not reach a document that has already loaded. Call
32
+ * `install()` before navigating, like every other collector in
33
+ * `@descryy/runtime-browser` — a page navigated first reports
34
+ * `probe-not-installed`, by name, rather than silently returning nothing.
35
+ *
36
+ * ## Raw facts only — no decisions
37
+ *
38
+ * Everything this returns is a JSON-serialisable transcript of what was on
39
+ * the fiber. Deciding what it *means* (is this a production build? does
40
+ * this owner stack point at authored source?) happens in Node, in
41
+ * `identity-grounding.ts`, where it can be unit-tested without a browser
42
+ * and where the source-map machinery this repo already owns is reachable.
43
+ * The page-side script cannot resolve a source map and must not try.
44
+ */
45
+ /** The single global this installs. One name, one function, no other footprint on the page. */
46
+ export const IDENTITY_GROUNDING_BINDING = "__descryIdentityGrounding";
47
+ /**
48
+ * The two key prefixes React has used to hang a fiber off a host DOM node:
49
+ * `__reactFiber$` since React 17, `__reactInternalInstance$` before that.
50
+ * Both are checked because both are cheap to check and a wrong "not React"
51
+ * answer on a React 16 page would be a silent, permanent blind spot.
52
+ *
53
+ * Measured present as `__reactFiber$<random>` on React 18.3.1 and 19.2.8
54
+ * alike; `__reactInternalInstance$` is carried on the documented shape of
55
+ * React 16 and is **not** covered by this repo's fixtures, which is stated
56
+ * here rather than implied by the code.
57
+ */
58
+ export const FIBER_KEY_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
59
+ export function createIdentityGroundingProbe() {
60
+ return {
61
+ async install(page) {
62
+ await page.addInitScript({ content: probeScriptSource() });
63
+ },
64
+ probe(page, selector) {
65
+ return page.evaluate(probeCallSource(selector));
66
+ },
67
+ };
68
+ }
69
+ /**
70
+ * The call side, also a string — and a **self-invoking** one, with the
71
+ * selector serialised into it rather than passed as an `evaluate` argument.
72
+ *
73
+ * Measured, not stylistic: Playwright's string form of `page.evaluate`
74
+ * evaluates the text as an expression, and passing a separate `arg`
75
+ * alongside a string body returned `undefined` here rather than calling the
76
+ * function expression with it. A self-invoking expression has one
77
+ * unambiguous meaning in both directions, and `JSON.stringify` on the
78
+ * selector is the same escaping this file already uses for the binding
79
+ * name, so a selector containing quotes cannot break out of the string.
80
+ *
81
+ * The `probe-not-installed` branch lives here rather than in the injected
82
+ * script for the obvious reason that a script which never ran cannot report
83
+ * that it never ran. This is the only place that distinction can be drawn,
84
+ * and drawing it is what keeps "Descry was not attached to this document"
85
+ * from being reported as "this page is not React".
86
+ */
87
+ function probeCallSource(selector) {
88
+ return `(() => {
89
+ const grounding = window[${JSON.stringify(IDENTITY_GROUNDING_BINDING)}];
90
+ if (grounding === undefined || typeof grounding.probe !== "function") {
91
+ return { status: "probe-not-installed" };
92
+ }
93
+ return grounding.probe(${JSON.stringify(selector)});
94
+ })()`;
95
+ }
96
+ /** Cap on the key list carried by a `no-fiber` result. A `<div>` has few own keys; a pathological one should not become an unbounded payload. */
97
+ const MAX_REPORTED_OWN_KEYS = 32;
98
+ /**
99
+ * The injected reader.
100
+ *
101
+ * ## Why component detection reads the *type shape* and not `fiber.tag`
102
+ *
103
+ * The obvious implementation is a set of numeric fiber tags —
104
+ * `0 = FunctionComponent`, `1 = ClassComponent`, `11 = ForwardRef` and so
105
+ * on. That table is a private React implementation detail with no
106
+ * compatibility guarantee across majors, and this module has to work
107
+ * against at least two majors whose fiber internals already differ in the
108
+ * ways this package exists to handle. A table that is wrong on some future
109
+ * React does not fail loudly — it silently classifies a real component as
110
+ * "not a component" and the answer degrades to a refusal nobody can
111
+ * explain.
112
+ *
113
+ * `fiber.type` is far more stable and is directly observable: a string is a
114
+ * host element, a function is a function or class component, an object with
115
+ * a `render` is a `forwardRef`, an object with a `type` is a `memo`. That
116
+ * is what is used. `tag` is still reported, verbatim, for audit.
117
+ *
118
+ * ## Everything degrades to null, nothing throws
119
+ *
120
+ * A property access that finds an unexpected shape yields `null`, and the
121
+ * whole probe body is wrapped so that a genuinely unexpected page cannot
122
+ * turn a read-only observation into a page error the application would
123
+ * then have to survive.
124
+ */
125
+ function probeScriptSource() {
126
+ return `(() => {
127
+ const FIBER_KEY_PREFIXES = ${JSON.stringify(FIBER_KEY_PREFIXES)};
128
+
129
+ function fiberKeyOf(element) {
130
+ const keys = Object.keys(element);
131
+ for (let i = 0; i < keys.length; i++) {
132
+ for (let p = 0; p < FIBER_KEY_PREFIXES.length; p++) {
133
+ if (keys[i].indexOf(FIBER_KEY_PREFIXES[p]) === 0) {
134
+ return { key: keys[i], prefix: FIBER_KEY_PREFIXES[p] };
135
+ }
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function named(type, kind) {
142
+ if (typeof type.displayName === "string" && type.displayName.length > 0) {
143
+ return { name: type.displayName, nameSource: "displayName", kind: kind };
144
+ }
145
+ if (typeof type.name === "string" && type.name.length > 0) {
146
+ return { name: type.name, nameSource: "function-name", kind: kind };
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function describeType(type) {
152
+ if (type === null || type === undefined) return null;
153
+ // A host element ("div", "button"). Real, but not a component.
154
+ if (typeof type === "string") return null;
155
+ if (typeof type === "function") {
156
+ const kind = type.prototype !== undefined && type.prototype !== null && type.prototype.isReactComponent !== undefined
157
+ ? "class"
158
+ : "function";
159
+ return named(type, kind) || { name: null, nameSource: null, kind: kind };
160
+ }
161
+ if (typeof type === "object") {
162
+ if (typeof type.render === "function") {
163
+ const outer = named(type, "forward-ref");
164
+ if (outer !== null) return outer;
165
+ const inner = named(type.render, "forward-ref");
166
+ if (inner !== null) return { name: inner.name, nameSource: "forward-ref-render-name", kind: "forward-ref" };
167
+ return { name: null, nameSource: null, kind: "forward-ref" };
168
+ }
169
+ if (type.type !== undefined && type.type !== null) {
170
+ const outer = named(type, "memo");
171
+ if (outer !== null) return outer;
172
+ const inner = describeType(type.type);
173
+ if (inner !== null) return { name: inner.name, nameSource: inner.nameSource, kind: "memo" };
174
+ return { name: null, nameSource: null, kind: "memo" };
175
+ }
176
+ // A context provider/consumer, a portal, a fragment: a real fiber,
177
+ // and not something with an authored component name to report.
178
+ return null;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function nearestComponentAbove(fiber) {
184
+ let current = fiber.return;
185
+ let hops = 0;
186
+ while (current !== null && current !== undefined && hops < 200) {
187
+ const described = describeType(current.type);
188
+ if (described !== null) return described;
189
+ current = current.return;
190
+ hops++;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function debugSourceOf(fiber) {
196
+ const source = fiber._debugSource;
197
+ if (source === null || source === undefined || typeof source !== "object") return null;
198
+ return {
199
+ fileName: typeof source.fileName === "string" ? source.fileName : null,
200
+ lineNumber: typeof source.lineNumber === "number" ? source.lineNumber : null,
201
+ columnNumber: typeof source.columnNumber === "number" ? source.columnNumber : null,
202
+ };
203
+ }
204
+
205
+ function debugStackTextOf(fiber) {
206
+ const stack = fiber._debugStack;
207
+ if (stack === null || stack === undefined) return null;
208
+ return typeof stack.stack === "string" ? stack.stack : null;
209
+ }
210
+
211
+ window[${JSON.stringify(IDENTITY_GROUNDING_BINDING)}] = {
212
+ probe: function (selector) {
213
+ let element;
214
+ try {
215
+ element = document.querySelector(selector);
216
+ } catch (_error) {
217
+ // An invalid selector is the caller's mistake, and it is not a
218
+ // statement about the page. Reported as "matched nothing", which is
219
+ // what actually happened here.
220
+ return { status: "element-not-found", selector: selector };
221
+ }
222
+ if (element === null) return { status: "element-not-found", selector: selector };
223
+
224
+ let node = element;
225
+ let distance = 0;
226
+ let located = null;
227
+ while (node !== null && node !== undefined) {
228
+ const found = fiberKeyOf(node);
229
+ if (found !== null) {
230
+ located = { node: node, key: found.key, prefix: found.prefix };
231
+ break;
232
+ }
233
+ node = node.parentElement;
234
+ distance++;
235
+ }
236
+ if (located === null) {
237
+ return {
238
+ status: "no-fiber",
239
+ selector: selector,
240
+ ownKeys: Object.keys(element).slice(0, ${MAX_REPORTED_OWN_KEYS}),
241
+ };
242
+ }
243
+
244
+ const fiber = located.node[located.key];
245
+ const owner = fiber._debugOwner;
246
+ const observedType = fiber.type;
247
+
248
+ return {
249
+ status: "fiber",
250
+ selector: selector,
251
+ fiberKeyPrefix: located.prefix,
252
+ fiberDistance: distance,
253
+ hostTag: typeof fiber.tag === "number" ? fiber.tag : null,
254
+ hostTypeName: typeof observedType === "string" ? observedType : null,
255
+ debugFields: Object.keys(fiber).filter(function (key) { return key.indexOf("_debug") === 0; }),
256
+ owner: owner === null || owner === undefined ? null : describeType(owner.type),
257
+ returnChainComponent: nearestComponentAbove(fiber),
258
+ debugSource: debugSourceOf(fiber),
259
+ debugStackText: debugStackTextOf(fiber),
260
+ observedTypeName:
261
+ observedType !== null && observedType !== undefined && typeof observedType !== "string" && typeof observedType.name === "string"
262
+ ? observedType.name
263
+ : null,
264
+ };
265
+ },
266
+ };
267
+ })();`;
268
+ }
269
+ //# sourceMappingURL=fiber-probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fiber-probe.js","sourceRoot":"","sources":["../src/fiber-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAMH,+FAA+F;AAC/F,MAAM,CAAC,MAAM,0BAA0B,GAAG,2BAA2B,CAAC;AAEtE;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,eAAe,EAAE,0BAA0B,CAAU,CAAC;AAuDzF,MAAM,UAAU,4BAA4B;IAC1C,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,IAAU;YACtB,MAAM,IAAI,CAAC,aAAa,CAAC,EAAE,OAAO,EAAE,iBAAiB,EAAE,EAAE,CAAC,CAAC;QAC7D,CAAC;QACD,KAAK,CAAC,IAAU,EAAE,QAAgB;YAChC,OAAO,IAAI,CAAC,QAAQ,CAAgB,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;QACjE,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,SAAS,eAAe,CAAC,QAAgB;IACvC,OAAO;6BACoB,IAAI,CAAC,SAAS,CAAC,0BAA0B,CAAC;;;;2BAI5C,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC;KAC9C,CAAC;AACN,CAAC;AAED,iJAAiJ;AACjJ,MAAM,qBAAqB,GAAG,EAAE,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,SAAS,iBAAiB;IACxB,OAAO;+BACsB,IAAI,CAAC,SAAS,CAAC,kBAAkB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;WAoFtD,IAAI,CAAC,SAAS,CAAC,0BAA0B,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mDA6BF,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;MA2BlE,CAAC;AACP,CAAC"}