@cleya/contributor-react-navigation 0.0.0-stage → 1.0.0-next.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/README.md +136 -2
- package/dist/ast.d.ts +64 -0
- package/dist/blank.d.ts +97 -0
- package/dist/census.d.ts +43 -0
- package/dist/collect.d.ts +113 -0
- package/dist/dialect.d.ts +58 -0
- package/dist/graph-reader.d.ts +55 -0
- package/dist/nav-calls.d.ts +93 -0
- package/dist/payload.d.ts +21 -0
- package/dist/react-navigation.contributor.d.ts +49 -0
- package/dist/react-navigation.index.d.ts +2 -0
- package/dist/react-navigation.index.js +1522 -0
- package/dist/react-navigation.types.d.ts +42 -0
- package/dist/registrations.d.ts +95 -0
- package/dist/route-names.d.ts +135 -0
- package/dist/type-index.d.ts +92 -0
- package/package.json +37 -5
package/README.md
CHANGED
|
@@ -1,3 +1,137 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cleya/contributor-react-navigation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Indexes a React Navigation app so you can ask **which screens exist, what renders
|
|
4
|
+
each one, and which screen can reach which other**.
|
|
5
|
+
|
|
6
|
+
It reads the source, not the running app: no instrumentation, no navigation
|
|
7
|
+
container, nothing to add to the project being indexed.
|
|
8
|
+
|
|
9
|
+
## What it writes
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
(Route)-[:SERVES]->(Screen)-[:RENDERS]->(Symbol)
|
|
13
|
+
(Screen)-[:NAVIGATES_TO]->(Route)
|
|
14
|
+
(Screen)-[:GATED_BY]->(Predicate)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Node | One per |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `Route` | destination name — `"Settings"` |
|
|
20
|
+
| `Screen` | *registration* of a route — the same route registered in two navigators is two screens |
|
|
21
|
+
| `Predicate` | named mount condition — a guard, or a feature flag read in the component |
|
|
22
|
+
| `Symbol` | code symbol rendering the screen. Written by the code indexer, joined here. |
|
|
23
|
+
|
|
24
|
+
`Route` and `Screen` are separate on purpose: a route registered twice keeps two
|
|
25
|
+
positions, two guards and two components, which a single node would flatten.
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun add @cleya/contributor-react-navigation @cleya/region-navigation @cleya/region-trace
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// cleya.config.ts
|
|
35
|
+
import { defineConfig, defineContributorGroup, defineProjectConfig } from "@cleya/config";
|
|
36
|
+
import { tsScipContributor } from "@cleya/contributor-ts-scip";
|
|
37
|
+
import { reactNavigationContributor } from "@cleya/contributor-react-navigation";
|
|
38
|
+
import { regionNavigation } from "@cleya/region-navigation";
|
|
39
|
+
import { regionTrace } from "@cleya/region-trace";
|
|
40
|
+
|
|
41
|
+
export default defineConfig({
|
|
42
|
+
projectSettings: { db: "./.cleya/graph.lbug" },
|
|
43
|
+
projects: [
|
|
44
|
+
defineProjectConfig({
|
|
45
|
+
id: "my-app",
|
|
46
|
+
scope: { root: "/abs/path/to/app", globs: ["src/**/*.ts", "src/**/*.tsx"] },
|
|
47
|
+
contributorGroups: [
|
|
48
|
+
defineContributorGroup({
|
|
49
|
+
region: regionTrace,
|
|
50
|
+
members: [tsScipContributor()],
|
|
51
|
+
}),
|
|
52
|
+
defineContributorGroup({
|
|
53
|
+
region: regionNavigation,
|
|
54
|
+
members: [reactNavigationContributor()],
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cleya index
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Ask it things
|
|
67
|
+
|
|
68
|
+
```cypher
|
|
69
|
+
-- every screen, and what renders it
|
|
70
|
+
MATCH (r:Route)-[:SERVES]->(s:Screen)
|
|
71
|
+
RETURN r.name AS route, s.graph AS navigator, s.component AS component
|
|
72
|
+
ORDER BY route
|
|
73
|
+
|
|
74
|
+
-- where can I get to from the settings screen, and by clicking what
|
|
75
|
+
MATCH (:Route {name: "Settings"})-[:SERVES]->(:Screen)-[n:NAVIGATES_TO]->(target:Route)
|
|
76
|
+
RETURN target.name, n.trigger, n.label, n.file, n.line
|
|
77
|
+
|
|
78
|
+
-- screens nothing navigates to: dead ends, or entry points
|
|
79
|
+
MATCH (r:Route)-[:SERVES]->(s:Screen)
|
|
80
|
+
WHERE NOT (:Screen)-[:NAVIGATES_TO]->(r)
|
|
81
|
+
RETURN r.name
|
|
82
|
+
|
|
83
|
+
-- what does this feature flag gate?
|
|
84
|
+
MATCH (s:Screen)-[:GATED_BY]->(p:Predicate {name: "canUseTravel"})
|
|
85
|
+
RETURN s.route
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Is the graph complete?
|
|
89
|
+
|
|
90
|
+
That is the question this plugin takes seriously, because the honest answer is
|
|
91
|
+
often "no". Every run prints a coverage report:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
[react-navigation] couverture des appels `navigate` : 1503/1711 (88 %)
|
|
95
|
+
208 sans arête : 12+ hors registre · 61 avec motif d'URL · 135 opaques
|
|
96
|
+
cible lisible mais absente du registre — inscription probablement ratée :
|
|
97
|
+
src/pages/Search.tsx:88 ROUTES.SEARCH_ADVANCED
|
|
98
|
+
[react-navigation] 214 routes / 231 screens / 229 rendered / …
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The denominator matters more than the count. A plugin that finds nothing reports
|
|
102
|
+
zero edges, and zero looks exactly like "this app has no navigation" — so the
|
|
103
|
+
report always says how many navigation-shaped sites it *saw*, and gives the
|
|
104
|
+
address of the ones it could not use.
|
|
105
|
+
|
|
106
|
+
If a run comes back `partial`, read the diagnostics before reading the graph.
|
|
107
|
+
|
|
108
|
+
## A project that does not look like the defaults
|
|
109
|
+
|
|
110
|
+
The core is the React Navigation API and needs no configuration. Anything the
|
|
111
|
+
project invented — its own navigation helpers, its own registry keys, screens
|
|
112
|
+
named by URL rather than by name — is a **dialect**, and it is configured:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
reactNavigationContributor({
|
|
116
|
+
// `goToOffer(id)` navigates; group 1 is the route name
|
|
117
|
+
helperCalls: [/goTo(\w+)\s*\(/g],
|
|
118
|
+
// the app navigates by URL, and only the linking config relates the two
|
|
119
|
+
routeAliasFiles: ["navigation/linkingConfig.ts"],
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
## Requirements
|
|
125
|
+
|
|
126
|
+
- Runs **after** a code contributor such as `@cleya/contributor-ts-scip`, in the same
|
|
127
|
+
project. Screens link to the symbols it declares; without them the plugin
|
|
128
|
+
refuses to run rather than write a graph with no components.
|
|
129
|
+
- `typescript` is a peer dependency: route names are resolved by the compiler,
|
|
130
|
+
using the project's own version.
|
|
131
|
+
- No `tsconfig.json` is not fatal — resolution falls back to enums and constant
|
|
132
|
+
paths, and literal route names are still found.
|
|
133
|
+
|
|
134
|
+
## Further
|
|
135
|
+
|
|
136
|
+
- `CLAUDE.md` — architecture, invariants, module map. Lives alongside the
|
|
137
|
+
source, not in the published package.
|
package/dist/ast.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What sets a navigation off, as a reader of the screen would name it.
|
|
3
|
+
*
|
|
4
|
+
* Both fields are `""` when nothing carries the site — a route named at module
|
|
5
|
+
* level, outside any handler or tag. Empty is a fact, not a failure: it says the
|
|
6
|
+
* position is reachable by no interaction, which is what tells a spurious edge
|
|
7
|
+
* from a real one.
|
|
8
|
+
*/
|
|
9
|
+
export interface SiteContext {
|
|
10
|
+
/** The trigger: JSX tag, handler, or declaration carrying the site. */
|
|
11
|
+
trigger: string;
|
|
12
|
+
/** The user-visible label, when the site sits inside a JSX tag. */
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The three syntactic questions a pattern cannot answer, over one parsed file.
|
|
17
|
+
*
|
|
18
|
+
* Every offset passed to these methods must be a position *inside* the file the
|
|
19
|
+
* index was built from; offsets from another file silently describe whatever
|
|
20
|
+
* node happens to sit there. Positions are byte offsets into the original
|
|
21
|
+
* source, not line/column, and not the blanked copy the patterns scan.
|
|
22
|
+
*
|
|
23
|
+
* The index holds its `SourceFile` for its whole lifetime — it is meant to live
|
|
24
|
+
* as long as the extraction of one file and be dropped, not cached across a run.
|
|
25
|
+
*/
|
|
26
|
+
export interface AstIndex {
|
|
27
|
+
/** Is the position inside a type? Then it never runs. */
|
|
28
|
+
typeOnly(offset: number): boolean;
|
|
29
|
+
/** What carries this position, and under which label. */
|
|
30
|
+
context(offset: number, labelProps: string[]): SiteContext;
|
|
31
|
+
/**
|
|
32
|
+
* Identity of the literal carrying this position — its node's start.
|
|
33
|
+
*
|
|
34
|
+
* Two patterns often see the same string from two angles:
|
|
35
|
+
*
|
|
36
|
+
* navigateTo={{ screen: "SignupMethods" }}
|
|
37
|
+
* ^^^^^^^^^^ declarative-link pattern ^^^^^^ config-key pattern
|
|
38
|
+
*
|
|
39
|
+
* When the expression spans two lines, deduplicating by line number takes
|
|
40
|
+
* them for two navigations. The node is the same either way: it is the only
|
|
41
|
+
* identity that does not depend on how the code is laid out.
|
|
42
|
+
*/
|
|
43
|
+
literalAt(offset: number): number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse one file and answer positional questions about it.
|
|
47
|
+
*
|
|
48
|
+
* `path` is used only to pick the parser dialect — `.tsx` enables JSX, anything
|
|
49
|
+
* else does not — so a mislabelled path silently reads `<Tag/>` as a comparison.
|
|
50
|
+
* Parsing never throws: TypeScript recovers from syntax errors and yields a
|
|
51
|
+
* partial tree, so a broken file degrades to wrong answers rather than a crash.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const src = `export type Props = { navigateTo: { screen: "VenueMap" } };`;
|
|
55
|
+
* astIndex("a.ts", src).typeOnly(src.indexOf(`"VenueMap"`)); // true — an annotation
|
|
56
|
+
*
|
|
57
|
+
* const run = `const go = () => ({ screen: "VenueMap" });`;
|
|
58
|
+
* astIndex("a.ts", run).typeOnly(run.indexOf(`"VenueMap"`)); // false — a value
|
|
59
|
+
*
|
|
60
|
+
* const jsx = `const C = () => <Touchable.Link wording="Voir" onPress={() => navigate("Home")} />;`;
|
|
61
|
+
* astIndex("a.tsx", jsx).context(jsx.indexOf(`"Home"`), ["wording"]);
|
|
62
|
+
* // { trigger: "Touchable.Link", label: "Voir" }
|
|
63
|
+
*/
|
|
64
|
+
export declare const astIndex: (path: string, src: string) => AstIndex;
|
package/dist/blank.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blank out comments and string bodies **while preserving offsets**, so that
|
|
3
|
+
* walking braces is safe.
|
|
4
|
+
*
|
|
5
|
+
* The returned copy has exactly the length of the source: an index found on it
|
|
6
|
+
* designates the same character in the original. That is what lets us search
|
|
7
|
+
* for structure on the blanked text and read the value from the source.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* stripNoise('const a = "{";'); // 'const a = ;' — the brace no longer counts
|
|
11
|
+
* stripNoise("x // y"); // 'x ' — the comment becomes blanks
|
|
12
|
+
* stripNoise("x // y").length; // 6 — the length never changes
|
|
13
|
+
*
|
|
14
|
+
* A block comment is blanked the same way, delimiters included.
|
|
15
|
+
*/
|
|
16
|
+
export declare const stripNoise: (src: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Line number of an offset, **zero-based**.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* lineAt("a\nb\nc", 4); // 2 — not 3: the first line is line 0
|
|
22
|
+
*/
|
|
23
|
+
export declare const lineAt: (src: string, index: number) => number;
|
|
24
|
+
/**
|
|
25
|
+
* Brace depth at this offset. Read it on the blanked copy: a brace inside a
|
|
26
|
+
* string would throw the count off.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* depthAt("a{b{c}d}e", 4); // 2
|
|
30
|
+
* depthAt("a{b{c}d}e", 8); // 0 — everything is closed again
|
|
31
|
+
*/
|
|
32
|
+
export declare const depthAt: (blanked: string, index: number) => number;
|
|
33
|
+
/**
|
|
34
|
+
* An attribute's value, and *where it is*.
|
|
35
|
+
*
|
|
36
|
+
* `at` is the index of the expression within the attribute text: the
|
|
37
|
+
* type-based resolver designates a node by its range, not by its text — two
|
|
38
|
+
* attributes may carry the same expression and not name the same thing.
|
|
39
|
+
*/
|
|
40
|
+
export interface Attr {
|
|
41
|
+
literal: string | null;
|
|
42
|
+
expr: string | null;
|
|
43
|
+
at: number | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Value of a **top-level** JSX attribute: `name="X"` → literal `X`,
|
|
47
|
+
* `name={Expr}` → expression `Expr`.
|
|
48
|
+
*
|
|
49
|
+
* Depth is everything:
|
|
50
|
+
*
|
|
51
|
+
* <Tab.Screen name="Search" options={{ tabBarIcon: () => <Icon name="images" /> }} />
|
|
52
|
+
*
|
|
53
|
+
* holds two `name=`. A regex search over the tag returns the last one — the
|
|
54
|
+
* icon's name — and registers one phantom route per tab. So only attributes at
|
|
55
|
+
* depth zero within the tag are accepted.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const attrs = ` name="Search" options={{ tabBarIcon: () => <Icon name="images" /> }}`;
|
|
59
|
+
* topLevelAttr(attrs, stripNoise(attrs), "name").literal; // "Search", not "images"
|
|
60
|
+
* topLevelAttr(" name={SCREENS.HOME}", …, "name"); // { literal: null, expr: "SCREENS.HOME", at: 7 }
|
|
61
|
+
* topLevelAttr(" component={X}", …, "name"); // { literal: null, expr: null, at: null }
|
|
62
|
+
*/
|
|
63
|
+
export declare const topLevelAttr: (attrsSrc: string, attrsBlanked: string, attr: string) => Attr;
|
|
64
|
+
/**
|
|
65
|
+
* Bounds of `const <symbol> = …`, anchored on the `=`.
|
|
66
|
+
*
|
|
67
|
+
* Anchoring on `=` is not a detail: a type annotation such as
|
|
68
|
+
* `const rootScreens: RouteConfig[] = [...]` would otherwise capture its own
|
|
69
|
+
* brackets.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* spanOf(stripNoise("const a = { x: 1 };"), "a"); // [10, 18]
|
|
73
|
+
* spanOf(stripNoise("const a = 1;"), "a"); // null — neither object nor array
|
|
74
|
+
* spanOf(stripNoise("const b = {};"), "a"); // null — no such symbol
|
|
75
|
+
*/
|
|
76
|
+
export declare const spanOf: (blanked: string, symbol: string) => [number, number] | null;
|
|
77
|
+
/** One object entry found at depth 1, with its four bounds. */
|
|
78
|
+
export interface ObjectKey {
|
|
79
|
+
key: string;
|
|
80
|
+
at: number;
|
|
81
|
+
keyAt: number;
|
|
82
|
+
valueAt: number;
|
|
83
|
+
valueEnd: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* An object's direct keys, literal and computed alike.
|
|
87
|
+
*
|
|
88
|
+
* `at` locates the entry — that is the line we report; `keyAt` locates the
|
|
89
|
+
* key *expression*, which is not the same thing once it is computed:
|
|
90
|
+
* `[SCREENS.X]` starts one character further along, and that is the bound the
|
|
91
|
+
* checker expects.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* const src = stripNoise("const r = { Home: A, [SCREENS.X]: B };");
|
|
95
|
+
* keysAtDepth1(src, src.indexOf("{")).map((k) => k.key); // ["Home", "SCREENS.X"]
|
|
96
|
+
*/
|
|
97
|
+
export declare const keysAtDepth1: (blanked: string, objStart: number) => ObjectKey[];
|
package/dist/census.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the contributor saw and could not use.
|
|
3
|
+
*
|
|
4
|
+
* An axis that never matches returns zero, and zero looks like "this project
|
|
5
|
+
* has none". That is the costliest mistake for whoever is discovering a
|
|
6
|
+
* codebase: the graph looks complete when it is amputated. The census gives the
|
|
7
|
+
* **denominator** — the sites shaped like a navigation that produced nothing —
|
|
8
|
+
* along with the means to go and look: file, line, expression.
|
|
9
|
+
*/
|
|
10
|
+
export interface Census {
|
|
11
|
+
jsxTags: number;
|
|
12
|
+
jsxMissed: string[];
|
|
13
|
+
callsSeen: number;
|
|
14
|
+
callsResolved: number;
|
|
15
|
+
/** Target readable but unknown to the registry: a registration probably missed. */
|
|
16
|
+
unknown: string[];
|
|
17
|
+
/** No literal, but the type carries a URL pattern: recoverable by pattern join. */
|
|
18
|
+
patterned: string[];
|
|
19
|
+
patternedCount: number;
|
|
20
|
+
/** Opaque: irreducible, but it has to be counted. */
|
|
21
|
+
dynamic: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An empty census, every counter at zero.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const census = newCensus();
|
|
28
|
+
* census.callsSeen; // 0
|
|
29
|
+
* census.jsxMissed; // [] — samples accumulate through `sample`
|
|
30
|
+
*/
|
|
31
|
+
export declare const newCensus: () => Census;
|
|
32
|
+
/**
|
|
33
|
+
* Record an example, up to a fixed cap.
|
|
34
|
+
*
|
|
35
|
+
* Silently ignores anything past the cap — the caller keeps the real magnitude
|
|
36
|
+
* in its own counter, so a sample list is an address book, not a measure.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const into: string[] = [];
|
|
40
|
+
* for (let i = 0; i < 10; i++) sample(into, `line ${i}`);
|
|
41
|
+
* into.length; // 5 — capped, and the first five are the ones kept
|
|
42
|
+
*/
|
|
43
|
+
export declare const sample: (into: string[], line: string) => void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { SourceModel } from "@cleya/config";
|
|
2
|
+
import type { Domain } from "@cleya/domain";
|
|
3
|
+
import { type Census } from "./census";
|
|
4
|
+
import { type Counters, type Dialect } from "./dialect";
|
|
5
|
+
import type { GraphReader } from "./graph-reader";
|
|
6
|
+
import { type Registration } from "./registrations";
|
|
7
|
+
/** What `collect` needs. No connection, no Cypher. */
|
|
8
|
+
export interface CollectDeps {
|
|
9
|
+
source: SourceModel;
|
|
10
|
+
projectRoot: string;
|
|
11
|
+
graph: GraphReader;
|
|
12
|
+
/**
|
|
13
|
+
* First segment of every id built, per the recipes on
|
|
14
|
+
* `Domain.Region.Navigation`, so the model is ready to load as-is.
|
|
15
|
+
*/
|
|
16
|
+
workspace: string;
|
|
17
|
+
}
|
|
18
|
+
/** One screen registration, resolved and ready to write. */
|
|
19
|
+
export interface ScreenRecord {
|
|
20
|
+
/** `"$workspace|$graph|$route"` */
|
|
21
|
+
id: string;
|
|
22
|
+
route: string;
|
|
23
|
+
graph: string;
|
|
24
|
+
file: string;
|
|
25
|
+
line: number;
|
|
26
|
+
shape: Registration["shape"];
|
|
27
|
+
guard: string;
|
|
28
|
+
hasOptions: boolean;
|
|
29
|
+
/** Component symbol id, or `""` when none could be resolved. */
|
|
30
|
+
component: string;
|
|
31
|
+
}
|
|
32
|
+
/** One navigation edge, already attributed, deduplicated and weighted. */
|
|
33
|
+
export interface NavEdge {
|
|
34
|
+
screen: string;
|
|
35
|
+
route: string;
|
|
36
|
+
kind: Domain.Region.Navigation.Kind;
|
|
37
|
+
via: Domain.Region.Navigation.Via;
|
|
38
|
+
file: string;
|
|
39
|
+
line: number;
|
|
40
|
+
trigger: string;
|
|
41
|
+
label: string;
|
|
42
|
+
own: boolean;
|
|
43
|
+
owners: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Everything the extraction learned but could not turn into an edge, plus the
|
|
47
|
+
* counts a run is judged by. Feeds the log and the diagnostics, never the graph.
|
|
48
|
+
*/
|
|
49
|
+
export interface CollectReport {
|
|
50
|
+
counters: Counters;
|
|
51
|
+
census: Census;
|
|
52
|
+
duplicates: string[];
|
|
53
|
+
unlinked: string[];
|
|
54
|
+
mounts: string[];
|
|
55
|
+
ambiguous: string[];
|
|
56
|
+
multi: string[];
|
|
57
|
+
orphanCalls: number;
|
|
58
|
+
borrowed: number;
|
|
59
|
+
factoryFiles: number;
|
|
60
|
+
callSites: number;
|
|
61
|
+
rendered: number;
|
|
62
|
+
gates: number;
|
|
63
|
+
typeIndex: {
|
|
64
|
+
sourceFiles: number;
|
|
65
|
+
buildMs: number;
|
|
66
|
+
} | null;
|
|
67
|
+
adjacencySize: number;
|
|
68
|
+
}
|
|
69
|
+
/** The whole result of an extraction, before anything is written. */
|
|
70
|
+
export interface NavigationModel {
|
|
71
|
+
/** `id` is `"$workspace|$name"`. */
|
|
72
|
+
routes: Array<{
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
registrations: number;
|
|
76
|
+
}>;
|
|
77
|
+
screens: ScreenRecord[];
|
|
78
|
+
renders: Array<{
|
|
79
|
+
screen: string;
|
|
80
|
+
symbol: string;
|
|
81
|
+
}>;
|
|
82
|
+
/** `id` is `"$workspace|$kind|$name"`. */
|
|
83
|
+
predicates: Array<{
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
kind: string;
|
|
87
|
+
}>;
|
|
88
|
+
gates: Array<{
|
|
89
|
+
screen: string;
|
|
90
|
+
predicate: string;
|
|
91
|
+
}>;
|
|
92
|
+
edges: NavEdge[];
|
|
93
|
+
report: CollectReport;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read the project and work out its navigation graph.
|
|
97
|
+
*
|
|
98
|
+
* Four passes: read every in-scope file, find the registrations, find the call
|
|
99
|
+
* sites, then attribute each call site to the screens whose component closure
|
|
100
|
+
* reaches it. Nothing is written — the result is a value, which is what makes
|
|
101
|
+
* every decision here assertable without a database.
|
|
102
|
+
*
|
|
103
|
+
* Degrades rather than fails when the project offers no usable TypeScript
|
|
104
|
+
* program: `createTypeIndex` returns `null` and resolution falls back to the
|
|
105
|
+
* name tables, so literal route names are still found.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* const model = await collect({ source, projectRoot, graph, workspace: "app" }, dialect);
|
|
109
|
+
* model.routes.map((r) => r.name); // ["Home", "Away"]
|
|
110
|
+
* model.edges.length; // navigations attributed to a screen
|
|
111
|
+
* model.report.census.callsSeen; // denominator: navigate() calls, edge or not
|
|
112
|
+
*/
|
|
113
|
+
export declare const collect: (deps: CollectDeps, d: Dialect) => Promise<NavigationModel>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNavigationDialect } from "./react-navigation.types";
|
|
2
|
+
/**
|
|
3
|
+
* Defaults.
|
|
4
|
+
*
|
|
5
|
+
* Empty where no universal convention exists — `helperCalls` and `predicates`.
|
|
6
|
+
* A wrong default is worse than a missing one: it manufactures a plausible
|
|
7
|
+
* graph, whereas an empty axis shows up in the match counts. The other axes
|
|
8
|
+
* have a default because React Navigation itself names them.
|
|
9
|
+
*/
|
|
10
|
+
declare const DEFAULTS: {
|
|
11
|
+
helperCalls: RegExp[];
|
|
12
|
+
configKeys: string[];
|
|
13
|
+
labelProps: string[];
|
|
14
|
+
registryEntry: {
|
|
15
|
+
name: string;
|
|
16
|
+
component: string;
|
|
17
|
+
};
|
|
18
|
+
guardKey: string;
|
|
19
|
+
predicates: Array<{
|
|
20
|
+
kind: string;
|
|
21
|
+
pattern: RegExp;
|
|
22
|
+
}>;
|
|
23
|
+
exclude: string[];
|
|
24
|
+
routeAliasFiles: string[];
|
|
25
|
+
closureDepth: number;
|
|
26
|
+
graphLabel: (path: string) => string;
|
|
27
|
+
};
|
|
28
|
+
/** A dialect with every axis resolved, ready for the extraction to read. */
|
|
29
|
+
export type Dialect = typeof DEFAULTS & {
|
|
30
|
+
/** Axes set explicitly: never matching one is an error, not a choice. */
|
|
31
|
+
configured: Set<string>;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a partial dialect into a complete one, validating its patterns.
|
|
35
|
+
*
|
|
36
|
+
* Throws when a supplied pattern is not global or captures nothing — at
|
|
37
|
+
* configuration load time, not midway through an indexing run.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* resolveDialect({}).configured.size; // 0 — everything defaulted
|
|
41
|
+
* resolveDialect({ closureDepth: 3 }).closureDepth; // 3
|
|
42
|
+
* resolveDialect({ helperCalls: [/nav/g] }); // throws — no capture group
|
|
43
|
+
*/
|
|
44
|
+
export declare const resolveDialect: (dialect?: ReactNavigationDialect) => Dialect;
|
|
45
|
+
/** Match count per axis: a pattern that never fires has to be visible. */
|
|
46
|
+
export type Counters = Record<string, number>;
|
|
47
|
+
/**
|
|
48
|
+
* Increment an axis counter.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const counters: Counters = {};
|
|
52
|
+
* bump(counters, "types"); // { types: 1 }
|
|
53
|
+
* bump(counters, "types", 5); // { types: 6 } — an absent axis starts at 0
|
|
54
|
+
*/
|
|
55
|
+
export declare const bump: (counters: Counters, axis: string, n?: number) => void;
|
|
56
|
+
/** Recognises a file mounting a navigator: `createXNavigator(` or `<`. */
|
|
57
|
+
export declare const FACTORY: RegExp;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Domain } from "@cleya/domain";
|
|
2
|
+
/** A symbol reachable from a navigator file, as a component candidate. */
|
|
3
|
+
export type SymbolRow = {
|
|
4
|
+
file: string;
|
|
5
|
+
name: string;
|
|
6
|
+
symbolId: string;
|
|
7
|
+
};
|
|
8
|
+
/** A top-level declaration of a page file, with its kind. */
|
|
9
|
+
export type DeclaredRow = SymbolRow & {
|
|
10
|
+
type: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Everything the extraction reads from the code graph, and nothing more.
|
|
14
|
+
*
|
|
15
|
+
* Four reads, all scoped to one workspace. They are a port rather than a
|
|
16
|
+
* client because `collect` is where the contributor's judgement lives, and
|
|
17
|
+
* judgement that issues Cypher can only be tested against a database. A fake
|
|
18
|
+
* here is four array literals.
|
|
19
|
+
*
|
|
20
|
+
* This is not a general graph API: each method exists because one specific step
|
|
21
|
+
* of the extraction needs exactly that shape.
|
|
22
|
+
*/
|
|
23
|
+
export interface GraphReader {
|
|
24
|
+
/**
|
|
25
|
+
* `USES` adjacency for the workspace, source symbol id -> target symbol ids.
|
|
26
|
+
*
|
|
27
|
+
* Loaded whole rather than walked lazily: the closure visits it repeatedly
|
|
28
|
+
* and per-query round trips dominated the run.
|
|
29
|
+
*/
|
|
30
|
+
adjacency(): Promise<Map<string, string[]>>;
|
|
31
|
+
/**
|
|
32
|
+
* Symbols declared by these files, plus the symbols those declarations use.
|
|
33
|
+
*
|
|
34
|
+
* Both halves matter: a navigator may declare its screens itself, or import
|
|
35
|
+
* them. Matching by name over this set is what avoids re-implementing module
|
|
36
|
+
* resolution.
|
|
37
|
+
*/
|
|
38
|
+
componentCandidates(files: string[]): Promise<SymbolRow[]>;
|
|
39
|
+
/** Every indexed file path of the workspace, for module-specifier resolution. */
|
|
40
|
+
filePaths(): Promise<string[]>;
|
|
41
|
+
/** Top-level declarations of these files, to find a page's main symbol. */
|
|
42
|
+
declaredSymbols(files: string[]): Promise<DeclaredRow[]>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The production reader: one Cypher query per method over the trace region,
|
|
46
|
+
* scoped to the files `workspace` indexed. A symbol used across workspaces
|
|
47
|
+
* (an `external|…` one) still appears as a `USES` target.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const reader = cypherReader(ctx.db, ctx.workspace);
|
|
51
|
+
* (await reader.adjacency()).size; // symbols with at least one USES edge
|
|
52
|
+
* await reader.componentCandidates(["Navigator.tsx"]); // [{ file, name, symbolId }, …]
|
|
53
|
+
* await reader.componentCandidates([]); // [] — no query issued
|
|
54
|
+
*/
|
|
55
|
+
export declare const cypherReader: (db: Domain.Db.ReadClient, workspace: string) => GraphReader;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Domain } from "@cleya/domain";
|
|
2
|
+
import { type Census } from "./census";
|
|
3
|
+
import { type Counters, type Dialect } from "./dialect";
|
|
4
|
+
import { type Classify, type Resolve } from "./route-names";
|
|
5
|
+
/** One navigation call site: who navigates, to which route, under which label. */
|
|
6
|
+
export interface CallSite {
|
|
7
|
+
route: string;
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
kind: Domain.Region.Navigation.Kind;
|
|
11
|
+
via: Domain.Region.Navigation.Via;
|
|
12
|
+
/** Trigger id: link component, config symbol, or enclosing function. */
|
|
13
|
+
trigger: string;
|
|
14
|
+
/** User-visible label, when the trigger carries one. */
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Call patterns: the core first, then the dialect.
|
|
19
|
+
*
|
|
20
|
+
* The core is the React Navigation API, identical everywhere. The last two axes
|
|
21
|
+
* — config keys and project helpers — depend entirely on the project's
|
|
22
|
+
* conventions: they carry 94 % of the edges on one repository and zero
|
|
23
|
+
* elsewhere without configuration.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* callPatterns(resolveDialect({})).length; // core patterns only
|
|
27
|
+
* callPatterns(resolveDialect({ helperCalls: [/go\((\w+)\)/g] }));
|
|
28
|
+
* // …plus one entry whose axis is "helperCalls", so a silent helper is visible
|
|
29
|
+
*/
|
|
30
|
+
export declare const callPatterns: (d: Dialect) => Array<{
|
|
31
|
+
re: RegExp;
|
|
32
|
+
kind: Domain.Region.Navigation.Kind;
|
|
33
|
+
via: Domain.Region.Navigation.Via;
|
|
34
|
+
axis: string;
|
|
35
|
+
ref?: true;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Past this many variants, a union of patterns no longer discriminates.
|
|
39
|
+
*
|
|
40
|
+
* A conditional navigation aims at two or three targets; a union carrying
|
|
41
|
+
* dozens is the project's sum type, arrived at through an `as Route`.
|
|
42
|
+
*/
|
|
43
|
+
export declare const PATTERN_LIMIT = 8;
|
|
44
|
+
/**
|
|
45
|
+
* Targets named somewhere other than right after the call.
|
|
46
|
+
*
|
|
47
|
+
* The `callPatterns` patterns require the literal glued to `navigate(`. Two
|
|
48
|
+
* forms escape them, and both are real navigations:
|
|
49
|
+
*
|
|
50
|
+
* reset({ routes: [{ name: "Bookings" }] }) the stack rebuilt in one go
|
|
51
|
+
* navigate(cond ? "A" : "B") the target chosen at runtime
|
|
52
|
+
*
|
|
53
|
+
* The argument list is delimited by balancing parens, then two precise patterns
|
|
54
|
+
* are applied inside it. Precise, and not "every string in the region": a
|
|
55
|
+
* region overruns as soon as a paren sits inside a string, and the sweep then
|
|
56
|
+
* brings back neighbouring `case "Profile":`. The graph filters unknown names,
|
|
57
|
+
* but it cannot tell a real route name quoted in the wrong place from a target.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* spreadTargets(src, blanked, new Set(["Home", "Away"]));
|
|
61
|
+
* // navigate(x ? "Home" : "Away") → two entries, one per branch
|
|
62
|
+
*/
|
|
63
|
+
export declare const spreadTargets: (src: string, blanked: string, routeNames: Set<string>) => Array<{
|
|
64
|
+
route: string;
|
|
65
|
+
index: number;
|
|
66
|
+
kind: Domain.Region.Navigation.Kind;
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Every navigation call site in one file, plus what the census learned from it.
|
|
70
|
+
*
|
|
71
|
+
* Mutates `counters` and `census` rather than returning them: both aggregate
|
|
72
|
+
* across the whole run, and threading them back through every file would make
|
|
73
|
+
* the caller reassemble what it already owns.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* const sites = callSitesIn(src, stripNoise(src), "a.tsx", routeNames, dialect,
|
|
77
|
+
* resolve, counters, census, classify);
|
|
78
|
+
* sites.map((s) => s.route); // ["Home"] — `navigate("Home")`
|
|
79
|
+
* // A literal inside a type annotation yields nothing: it never runs.
|
|
80
|
+
* // A ternary yields two sites, one per branch.
|
|
81
|
+
*/
|
|
82
|
+
export declare const callSitesIn: (src: string, blanked: string, path: string, routeNames: Set<string>, d: Dialect, resolve: Resolve, counters: Counters, census: Census, classify: Classify) => CallSite[];
|
|
83
|
+
/**
|
|
84
|
+
* Symbol ids are `{workspace}|{scope}|{path}|{name}` — the file is segment 2.
|
|
85
|
+
*
|
|
86
|
+
* Returns `""` rather than throwing on a malformed id: the ids come from the
|
|
87
|
+
* code graph, and one bad row must not abort a run.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* fileOfSymbolId("ws|internal|src/Home.tsx|Home"); // "src/Home.tsx"
|
|
91
|
+
* fileOfSymbolId("not-an-id"); // "" — no segment 2
|
|
92
|
+
*/
|
|
93
|
+
export declare const fileOfSymbolId: (id: string) => string;
|