@atlassian/chippy 0.0.0-stage → 0.0.1-beta.2
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 +201 -0
- package/README.md +276 -2
- package/bin/chippy.mjs +92 -0
- package/bin/pack.mjs +204 -0
- package/bin/skill-dir.mjs +15 -0
- package/lib/avalanche.js +2601 -0
- package/lib/avalanche.js.map +7 -0
- package/lib/bootstrap.d.ts +1 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bootstrap.js.map +7 -0
- package/lib/browser.js +15 -0
- package/lib/browser.js.map +7 -0
- package/lib/extension/avalanche.es.js +5535 -0
- package/lib/extension/avalanche.es.js.map +1 -0
- package/lib/extension/chippy-page-bridge.js +1953 -0
- package/lib/extension/inspect-react.js +216 -0
- package/lib/extension/layout-lab.js +1453 -0
- package/lib/extension/panel-dom.js +90 -0
- package/lib/extension/resolve-probes.js +342 -0
- package/lib/mcp-skill/LICENSE +201 -0
- package/lib/mcp-skill/README.md +148 -0
- package/lib/mcp-skill/SKILL.md +335 -0
- package/lib/mcp-skill/browser-scripts/playwright-probes.js +368 -0
- package/lib/mcp-skill/browser-scripts/playwright-session.js +114 -0
- package/lib/mcp-skill/extension/armed-reload-startup.js +83 -0
- package/lib/mcp-skill/extension/avalanche.es.js +5535 -0
- package/lib/mcp-skill/extension/avalanche.es.js.map +1 -0
- package/lib/mcp-skill/extension/avalanche.iife.js +5536 -0
- package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -0
- package/lib/mcp-skill/extension/background.js +325 -0
- package/lib/mcp-skill/extension/chippy-page-bridge.js +1953 -0
- package/lib/mcp-skill/extension/content-script.js +190 -0
- package/lib/mcp-skill/extension/devtools-hook-shim.js +85 -0
- package/lib/mcp-skill/extension/early-bootstrap.js +49 -0
- package/lib/mcp-skill/extension/icon-128.png +0 -0
- package/lib/mcp-skill/extension/icon-16.png +0 -0
- package/lib/mcp-skill/extension/icon-32.png +0 -0
- package/lib/mcp-skill/extension/icon-48.png +0 -0
- package/lib/mcp-skill/extension/inspect-react.js +216 -0
- package/lib/mcp-skill/extension/layout-lab.js +1453 -0
- package/lib/mcp-skill/extension/logo.svg +14 -0
- package/lib/mcp-skill/extension/manifest.json +69 -0
- package/lib/mcp-skill/extension/panel-dom.js +90 -0
- package/lib/mcp-skill/extension/panel-settings.js +173 -0
- package/lib/mcp-skill/extension/popup.html +41 -0
- package/lib/mcp-skill/extension/popup.js +80 -0
- package/lib/mcp-skill/extension/probe-descriptor.js +241 -0
- package/lib/mcp-skill/extension/resolve-probes.js +342 -0
- package/lib/mcp-skill/extension/startup-handoff.js +30 -0
- package/lib/mcp-skill/package.json +1 -0
- package/lib/mcp-skill/references/README.md +56 -0
- package/lib/mcp-skill/references/active-stimulus.md +85 -0
- package/lib/mcp-skill/references/avalanche-detector.md +731 -0
- package/lib/mcp-skill/references/bdd-router.md +310 -0
- package/lib/mcp-skill/references/browser-runtime-api.md +193 -0
- package/lib/mcp-skill/references/command-output-formats.md +1085 -0
- package/lib/mcp-skill/references/command-surface.md +183 -0
- package/lib/mcp-skill/references/cynefin.md +99 -0
- package/lib/mcp-skill/references/debug-hook.md +161 -0
- package/lib/mcp-skill/references/fixing-performance.md +490 -0
- package/lib/mcp-skill/references/hook-metrics.md +199 -0
- package/lib/mcp-skill/references/hooks-inspection.md +61 -0
- package/lib/mcp-skill/references/jira-taxonomy.md +81 -0
- package/lib/mcp-skill/references/mcp.md +122 -0
- package/lib/mcp-skill/references/mode-a-injection.md +102 -0
- package/lib/mcp-skill/references/operating-model.md +128 -0
- package/lib/mcp-skill/references/playwright-cli-setup.md +105 -0
- package/lib/mcp-skill/references/playwright-probes.md +205 -0
- package/lib/mcp-skill/references/procedures.md +545 -0
- package/lib/mcp-skill/references/recipes.txt +114 -0
- package/lib/mcp-skill/references/stability-render.md +428 -0
- package/lib/mcp-skill/references/test-mode-playbook.md +194 -0
- package/lib/mcp-skill/references/troubleshooting.md +59 -0
- package/lib/mcp-skill/scripts/avalanche-analyze.cjs +495 -0
- package/lib/mcp-skill/scripts/build-avalanche-command.js +34 -0
- package/lib/mcp-skill/scripts/build-fiber-state-command.js +19 -0
- package/lib/mcp-skill/scripts/chippy-helper-server.js +1768 -0
- package/lib/mcp-skill/scripts/chippy-mcp.cjs +273 -0
- package/lib/mcp-skill/scripts/chippy.sh +1252 -0
- package/lib/mcp-skill/scripts/format-probe-result.js +34 -0
- package/lib/mcp-skill/scripts/helper-access.js +158 -0
- package/lib/mcp-skill/scripts/helper-proxy.js +87 -0
- package/lib/mcp-skill/scripts/page-socket-trust.js +81 -0
- package/lib/mcp-skill/scripts/probe-annotation-channel.js +116 -0
- package/lib/mcp-skill/scripts/search-recipes.js +248 -0
- package/lib/mcp-skill/snippets/README.md +89 -0
- package/lib/mcp-skill/snippets/count-context-consumers.js +40 -0
- package/lib/mcp-skill/snippets/find-context-providers.js +38 -0
- package/lib/mcp-skill/snippets/find-legacy-context.js +47 -0
- package/lib/mcp-skill/snippets/find-prop.js +83 -0
- package/lib/mcp-skill/snippets/hook-metrics-sample.js +69 -0
- package/lib/mcp-skill/snippets/hook-metrics.js +60 -0
- package/lib/mcp-skill/snippets/hook-tracker.js +198 -0
- package/lib/mcp-skill/snippets/stability-nudge.js +101 -0
- package/lib/package.json +1 -0
- package/lib/plugin.js +119 -0
- package/lib/plugin.js.map +7 -0
- package/lib/storybook/avalanche-panel-model.js +79 -0
- package/lib/storybook/brand.jsx +6 -0
- package/lib/storybook/connection-fallback.jsx +47 -0
- package/lib/storybook/constants.js +15 -0
- package/lib/storybook/context-card.jsx +171 -0
- package/lib/storybook/manager-owner.js +42 -0
- package/lib/storybook/manager.jsx +425 -0
- package/lib/storybook/preset.js +7 -0
- package/lib/storybook/preview.js +298 -0
- package/lib/storybook/probe-annotation-editor.jsx +52 -0
- package/lib/storybook/probe-intent.js +23 -0
- package/lib/storybook/setup.js +104 -0
- package/lib/types/avalanche/commit-metrics-adapter.d.ts +76 -0
- package/lib/types/avalanche/commit-stats-indices.d.ts +41 -0
- package/lib/types/avalanche/commit-surface-ownership.d.ts +9 -0
- package/lib/types/avalanche/detector.d.ts +183 -0
- package/lib/types/avalanche/interaction-capture.d.ts +25 -0
- package/lib/types/avalanche/internals.d.ts +189 -0
- package/lib/types/avalanche/report-serializer.d.ts +283 -0
- package/lib/types/avalanche/reporter.d.ts +610 -0
- package/lib/types/chippy-bridge/fiber-source.d.ts +48 -0
- package/lib/types/chippy-bridge/react-renderer.d.ts +74 -0
- package/lib/types/chippy-bridge/recorded-source.d.ts +44 -0
- package/lib/types/chippy-bridge/source-map.d.ts +30 -0
- package/lib/types/debug-render/core.d.ts +78 -0
- package/lib/types/layout-lab/fiber-boundaries.d.ts +51 -0
- package/lib/types/layout-lab/fiber-walk.d.ts +131 -0
- package/lib/types/layout-lab/marker-context.d.ts +94 -0
- package/lib/types/package/avalanche.d.ts +6 -0
- package/lib/types/package/browser.d.ts +10 -0
- package/lib/types/package/plugin.d.ts +15 -0
- package/lib/types/plugins/atl/commit-metrics-adapter.d.ts +65 -0
- package/lib/types/plugins/atl/metrics-slot.d.ts +37 -0
- package/lib/types/plugins/index.d.ts +49 -0
- package/lib/types/plugins/jira/context-provider.d.ts +36 -0
- package/package.json +106 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createReadStream } = require('node:fs');
|
|
5
|
+
const { createInterface } = require('node:readline');
|
|
6
|
+
|
|
7
|
+
const REQUIRED_FIELDS = ['id', 'q', 'tags', 'a', 'done', 'guard', 'refs'];
|
|
8
|
+
const STOP_WORDS = new Set([
|
|
9
|
+
'a', 'an', 'and', 'are', 'can', 'do', 'does', 'for', 'from', 'how', 'i', 'in',
|
|
10
|
+
'is', 'it', 'my', 'of', 'on', 'or', 'the', 'this', 'to', 'use', 'what', 'when',
|
|
11
|
+
'where', 'which', 'why', 'with',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function usage() {
|
|
15
|
+
process.stderr.write('Usage: chippy.sh recipes <query...> [--limit N]\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
let limit = 5;
|
|
20
|
+
const queryParts = [];
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
23
|
+
const value = argv[index];
|
|
24
|
+
if (value === '--limit') {
|
|
25
|
+
const next = argv[index + 1];
|
|
26
|
+
if (!next || !/^\d+$/.test(next)) {
|
|
27
|
+
throw new Error('--limit requires an integer from 1 to 20');
|
|
28
|
+
}
|
|
29
|
+
limit = Number(next);
|
|
30
|
+
index += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
queryParts.push(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (limit < 1 || limit > 20) {
|
|
37
|
+
throw new Error('--limit must be between 1 and 20');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const query = queryParts.join(' ').trim();
|
|
41
|
+
if (!query) {
|
|
42
|
+
throw new Error('recipes requires a search query');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { limit, query };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeToken(token) {
|
|
49
|
+
let value = token.toLowerCase();
|
|
50
|
+
if (value.length > 4 && value.endsWith('ies')) value = `${value.slice(0, -3)}y`;
|
|
51
|
+
else if (value.length > 4 && value.endsWith('ing')) value = value.slice(0, -3);
|
|
52
|
+
else if (value.length > 4 && value.endsWith('ed')) value = value.slice(0, -2);
|
|
53
|
+
else if (value.length > 3 && value.endsWith('s')) value = value.slice(0, -1);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tokenize(value) {
|
|
58
|
+
return String(value)
|
|
59
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.split(/[^a-z0-9]+/)
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map(normalizeToken)
|
|
64
|
+
.filter((token) => token.length > 1 && !STOP_WORDS.has(token));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseRecord(line, lineNumber) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed || trimmed.startsWith('#')) return null;
|
|
70
|
+
|
|
71
|
+
const fields = {};
|
|
72
|
+
for (const part of trimmed.split(' | ')) {
|
|
73
|
+
const equals = part.indexOf('=');
|
|
74
|
+
if (equals <= 0) throw new Error(`invalid recipe field at line ${lineNumber}`);
|
|
75
|
+
fields[part.slice(0, equals)] = part.slice(equals + 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const missing = REQUIRED_FIELDS.filter((field) => !fields[field]);
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
throw new Error(`recipe at line ${lineNumber} is missing: ${missing.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
return fields;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function forEachRecipe(file, visit) {
|
|
86
|
+
const lines = createInterface({
|
|
87
|
+
input: createReadStream(file, { encoding: 'utf8' }),
|
|
88
|
+
crlfDelay: Infinity,
|
|
89
|
+
});
|
|
90
|
+
let lineNumber = 0;
|
|
91
|
+
for await (const line of lines) {
|
|
92
|
+
lineNumber += 1;
|
|
93
|
+
const record = parseRecord(line, lineNumber);
|
|
94
|
+
if (record) await visit(record);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isEditDistanceOne(left, right) {
|
|
99
|
+
if (left === right || Math.abs(left.length - right.length) > 1) return false;
|
|
100
|
+
let edits = 0;
|
|
101
|
+
let leftIndex = 0;
|
|
102
|
+
let rightIndex = 0;
|
|
103
|
+
while (leftIndex < left.length && rightIndex < right.length) {
|
|
104
|
+
if (left[leftIndex] === right[rightIndex]) {
|
|
105
|
+
leftIndex += 1;
|
|
106
|
+
rightIndex += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
edits += 1;
|
|
110
|
+
if (edits > 1) return false;
|
|
111
|
+
if (left.length > right.length) leftIndex += 1;
|
|
112
|
+
else if (right.length > left.length) rightIndex += 1;
|
|
113
|
+
else {
|
|
114
|
+
leftIndex += 1;
|
|
115
|
+
rightIndex += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return edits + (leftIndex < left.length || rightIndex < right.length ? 1 : 0) === 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matchWeight(queryToken, documentToken) {
|
|
122
|
+
if (queryToken === documentToken) return 1;
|
|
123
|
+
if (
|
|
124
|
+
Math.min(queryToken.length, documentToken.length) >= 4
|
|
125
|
+
&& (queryToken.startsWith(documentToken) || documentToken.startsWith(queryToken))
|
|
126
|
+
) return 0.72;
|
|
127
|
+
if (
|
|
128
|
+
Math.min(queryToken.length, documentToken.length) >= 5
|
|
129
|
+
&& isEditDistanceOne(queryToken, documentToken)
|
|
130
|
+
) return 0.58;
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function indexedTokens(record) {
|
|
135
|
+
const weightedFields = [
|
|
136
|
+
['id', 3],
|
|
137
|
+
['q', 2],
|
|
138
|
+
['tags', 3],
|
|
139
|
+
['a', 1],
|
|
140
|
+
['done', 1],
|
|
141
|
+
['guard', 1],
|
|
142
|
+
['refs', 1],
|
|
143
|
+
];
|
|
144
|
+
const result = [];
|
|
145
|
+
for (const [field, weight] of weightedFields) {
|
|
146
|
+
for (const token of tokenize(record[field])) result.push({ token, weight });
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function termFrequency(queryToken, tokens) {
|
|
152
|
+
let frequency = 0;
|
|
153
|
+
for (const { token, weight } of tokens) {
|
|
154
|
+
frequency += matchWeight(queryToken, token) * weight;
|
|
155
|
+
}
|
|
156
|
+
return frequency;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function insertTop(results, candidate, limit) {
|
|
160
|
+
results.push(candidate);
|
|
161
|
+
results.sort((left, right) => right.score - left.score || left.record.id.localeCompare(right.record.id));
|
|
162
|
+
if (results.length > limit) results.length = limit;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function search(file, query, limit) {
|
|
166
|
+
const queryTokens = [...new Set(tokenize(query))];
|
|
167
|
+
if (queryTokens.length === 0) throw new Error('search query contains no searchable words');
|
|
168
|
+
|
|
169
|
+
let documentCount = 0;
|
|
170
|
+
let totalLength = 0;
|
|
171
|
+
const documentFrequency = new Map(queryTokens.map((token) => [token, 0]));
|
|
172
|
+
|
|
173
|
+
await forEachRecipe(file, (record) => {
|
|
174
|
+
const tokens = indexedTokens(record);
|
|
175
|
+
documentCount += 1;
|
|
176
|
+
totalLength += tokens.length;
|
|
177
|
+
for (const queryToken of queryTokens) {
|
|
178
|
+
if (termFrequency(queryToken, tokens) > 0) {
|
|
179
|
+
documentFrequency.set(queryToken, documentFrequency.get(queryToken) + 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (documentCount === 0) throw new Error('recipe corpus is empty');
|
|
185
|
+
const averageLength = totalLength / documentCount;
|
|
186
|
+
const results = [];
|
|
187
|
+
const k1 = 1.2;
|
|
188
|
+
const b = 0.75;
|
|
189
|
+
const normalizedPhrase = tokenize(query).join(' ');
|
|
190
|
+
|
|
191
|
+
await forEachRecipe(file, (record) => {
|
|
192
|
+
const tokens = indexedTokens(record);
|
|
193
|
+
let score = 0;
|
|
194
|
+
for (const queryToken of queryTokens) {
|
|
195
|
+
const frequency = termFrequency(queryToken, tokens);
|
|
196
|
+
if (frequency === 0) continue;
|
|
197
|
+
const df = documentFrequency.get(queryToken);
|
|
198
|
+
const inverseDocumentFrequency = Math.log(1 + ((documentCount - df + 0.5) / (df + 0.5)));
|
|
199
|
+
const denominator = frequency + k1 * (1 - b + b * (tokens.length / averageLength));
|
|
200
|
+
score += inverseDocumentFrequency * ((frequency * (k1 + 1)) / denominator);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const searchablePhrase = tokenize(`${record.id} ${record.q} ${record.tags}`).join(' ');
|
|
204
|
+
if (normalizedPhrase.length >= 5 && searchablePhrase.includes(normalizedPhrase)) score += 1.5;
|
|
205
|
+
if (score > 0) insertTop(results, { record, score }, limit);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function printResults(query, results) {
|
|
212
|
+
if (results.length === 0) {
|
|
213
|
+
process.stderr.write(`No Chippy recipe matched: ${query}\n`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
results.forEach(({ record, score }, index) => {
|
|
219
|
+
if (index > 0) process.stdout.write('\n');
|
|
220
|
+
process.stdout.write(`recipe: ${record.id}\n`);
|
|
221
|
+
process.stdout.write(`score: ${score.toFixed(3)}\n`);
|
|
222
|
+
process.stdout.write(`question: ${record.q}\n`);
|
|
223
|
+
process.stdout.write(`answer: ${record.a}\n`);
|
|
224
|
+
process.stdout.write(`done: ${record.done}\n`);
|
|
225
|
+
process.stdout.write(`guard: ${record.guard}\n`);
|
|
226
|
+
process.stdout.write(`refs: ${record.refs}\n`);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function main() {
|
|
231
|
+
const [file, ...argv] = process.argv.slice(2);
|
|
232
|
+
if (!file) {
|
|
233
|
+
usage();
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const { query, limit } = parseArgs(argv);
|
|
240
|
+
printResults(query, await search(file, query, limit));
|
|
241
|
+
} catch (error) {
|
|
242
|
+
process.stderr.write(`${error.message}\n`);
|
|
243
|
+
usage();
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
main();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Chippy snippets
|
|
2
|
+
|
|
3
|
+
Snippets are version-controlled page-realm programs executed through `chippy.sh eval-page`. Use them for a reusable inspection question or reproducible procedure that existing commands cannot answer and that does not justify another bridge round trip.
|
|
4
|
+
|
|
5
|
+
Do not add a snippet to walk React fibers from a probe or remembered fiber until `probe-parents`, `explain-component`, `inspect-fiber-parents`, and `find-fibers` have proven the command surface cannot supply the path evidence. `probe-owners` reads debug-build `_debugOwner`, not the `fiber.return` parent chain; do not use it as a production-build route.
|
|
6
|
+
|
|
7
|
+
Most snippets are read-only. Active or stateful snippets declare the target they touch, the state they own, and what becomes ambiguous after execution.
|
|
8
|
+
|
|
9
|
+
## Run a snippet
|
|
10
|
+
|
|
11
|
+
From the Chippy skill directory:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
./scripts/chippy.sh eval-snippets
|
|
15
|
+
./scripts/chippy.sh eval-page <clientId> <snippet-name> [--key value ...]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`snippet-name` resolves to `snippets/<name>.js`. An absolute path or a path relative to the current directory also works.
|
|
19
|
+
|
|
20
|
+
The shell wrapper forwards every `--key value` pair into `ctx.args`, coercing numbers and `true`/`false`. Its page-script timeout is fixed at five seconds; the wrapper does not expose a timeout override.
|
|
21
|
+
|
|
22
|
+
## Choose a packaged snippet
|
|
23
|
+
|
|
24
|
+
| Snippet | Kind | Use it when |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `find-context-providers` | Read-only | List modern `React.createContext` providers; filter with `--name <regex>`. |
|
|
27
|
+
| `find-legacy-context` | Read-only | Find legacy `childContextTypes` / `contextTypes` producers and consumers. |
|
|
28
|
+
| `count-context-consumers` | Read-only | Count consumers for a Context display-name regex with `--name`. |
|
|
29
|
+
| `find-prop` | Read-only | Find fibers by prop name or value when a probe revealed a clue but the owner chain is broad. |
|
|
30
|
+
| `hook-metrics` | Read-only (ATL plugin) | Read the host `__ATL_REACT_METRICS`, including NaN/undefined-safe `setStateCount`. |
|
|
31
|
+
| `hook-metrics-sample` | Active, bounded (ATL plugin) | Measure a labeled before/after `setStateCount` delta, optionally clicking one test-id. |
|
|
32
|
+
| `hook-tracker` | Stateful instrument (ATL plugin) | Install, query, reset, or remove the `onHookUpdate` recomputation tracker. |
|
|
33
|
+
| `stability-nudge` | Active stimulus | Schedule one controlled component render for the stability-render procedure. |
|
|
34
|
+
|
|
35
|
+
Read [hook metrics](../references/hook-metrics.md) before using the metrics snippets. Read [stability render](../references/stability-render.md) before using `stability-nudge`.
|
|
36
|
+
|
|
37
|
+
## Authoring contract
|
|
38
|
+
|
|
39
|
+
Each snippet is the body of an `async function (ctx) { ... }` executed in the connected page. Return a JSON-serializable value.
|
|
40
|
+
|
|
41
|
+
`ctx` provides:
|
|
42
|
+
|
|
43
|
+
| Field | Contract |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `ctx.args` | Object built from `--key value` pairs. |
|
|
46
|
+
| `ctx.log` | Array of human-readable trace strings returned with the result. |
|
|
47
|
+
| `ctx.helpers.getRootFibers()` | Root fibers across all React renderers on the page. |
|
|
48
|
+
| `ctx.helpers.walkAll(visit, opts?)` | Walk every fiber; `opts.limit` defaults to 200,000. |
|
|
49
|
+
| `ctx.helpers.findFibers(predicate, opts?)` | Return matching fibers; `opts.max` defaults to 5,000. |
|
|
50
|
+
| `ctx.helpers.getDisplayName(fiber)` | Best-effort display name. |
|
|
51
|
+
| `ctx.helpers.fiberInfo(fiber)` | Circular-safe view of name, source, owner, and DOM context. |
|
|
52
|
+
| `ctx.helpers.ancestorFibers(fiber, max?)` | Walk the fiber return chain. |
|
|
53
|
+
| `ctx.helpers.devtoolsHook()` | Raw React DevTools global hook when a documented procedure requires it. |
|
|
54
|
+
|
|
55
|
+
Prefer the smallest result that answers the question. Return summaries or bounded matches, not entire fiber trees.
|
|
56
|
+
|
|
57
|
+
## Result envelope
|
|
58
|
+
|
|
59
|
+
`eval-page` returns this shape:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"ok": true,
|
|
64
|
+
"durationMs": 42,
|
|
65
|
+
"log": ["matched 3 fibers"],
|
|
66
|
+
"result": { "count": 3 }
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The helper removes circular references, trims depth, represents functions as strings, and caps arrays at 200 items per level.
|
|
71
|
+
|
|
72
|
+
## Safety and ownership
|
|
73
|
+
|
|
74
|
+
- Do not commit secrets or return sensitive page data.
|
|
75
|
+
- Keep read-only snippets read-only.
|
|
76
|
+
- For an active snippet, document the exact target, stimulus, owned state, pollution boundary, and required cleanup or recapture.
|
|
77
|
+
- Use a current composite fiber for render stimuli. Re-resolve it after state changes or navigation.
|
|
78
|
+
- Use one stimulus per bounded capture. Clear or recapture before repeating it.
|
|
79
|
+
- Promote version-coupled React internals to a tested bridge capability; keep procedure and target resolution in the snippet.
|
|
80
|
+
|
|
81
|
+
`hook-tracker` owns `window.__CHIPPY_HOOK_TRACKER` and the host `onHookUpdate` callback until `--action uninstall`. `stability-nudge` delegates the render to the bundled `fiber-schedule-update` capability and owns no hook or prop state.
|
|
82
|
+
|
|
83
|
+
## Add or revise a snippet
|
|
84
|
+
|
|
85
|
+
1. Name the reusable question or procedure and choose read-only, active, or stateful behavior.
|
|
86
|
+
2. Add one JavaScript file under `snippets/` with a bounded input and result contract.
|
|
87
|
+
3. Document its arguments, ownership, failure shape, and cleanup in this catalog or its owning reference.
|
|
88
|
+
4. Exercise it through `eval-page`, not by calling its body directly.
|
|
89
|
+
5. If the snippet becomes a documented multi-step procedure, link its canonical reference from [the reference guide](../references/README.md).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Count modern Context consumers for a given Context displayName regex.
|
|
2
|
+
// A consumer is any fiber whose dependencies list contains the matching context.
|
|
3
|
+
// Args:
|
|
4
|
+
// --name <regex> REQUIRED. Context displayName regex (case-insensitive).
|
|
5
|
+
// --max <N> cap consumer samples returned (default 100)
|
|
6
|
+
|
|
7
|
+
const { walkAll, fiberInfo } = ctx.helpers;
|
|
8
|
+
if (!ctx.args.name) return { error: '--name <regex> is required' };
|
|
9
|
+
const nameRe = new RegExp(String(ctx.args.name), 'i');
|
|
10
|
+
const max = Number(ctx.args.max) || 100;
|
|
11
|
+
|
|
12
|
+
const consumers = [];
|
|
13
|
+
let scanned = 0;
|
|
14
|
+
let totalConsumers = 0;
|
|
15
|
+
walkAll((f) => {
|
|
16
|
+
scanned++;
|
|
17
|
+
// dependencies.firstContext is a singly-linked list of ContextDependency
|
|
18
|
+
const dep = f?.dependencies;
|
|
19
|
+
if (!dep) return;
|
|
20
|
+
let cur = dep.firstContext;
|
|
21
|
+
while (cur) {
|
|
22
|
+
const c = cur.context;
|
|
23
|
+
const name = c?.displayName || c?._context?.displayName || null;
|
|
24
|
+
if (name && nameRe.test(name)) {
|
|
25
|
+
totalConsumers++;
|
|
26
|
+
if (consumers.length < max) {
|
|
27
|
+
consumers.push({
|
|
28
|
+
contextName: name,
|
|
29
|
+
consumer: fiberInfo(f),
|
|
30
|
+
owner: f._debugOwner ? fiberInfo(f._debugOwner) : null,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
cur = cur.next;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
ctx.log.push('scanned fibers: ' + scanned);
|
|
40
|
+
return { matchedContexts: nameRe.source, totalConsumers, sampleCount: consumers.length, consumers };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// List active modern React Context providers on the page.
|
|
2
|
+
// Optional args:
|
|
3
|
+
// --name <regex> filter by Context displayName / name (case-insensitive)
|
|
4
|
+
// --max <N> cap results (default 500)
|
|
5
|
+
|
|
6
|
+
const { findFibers, fiberInfo, ancestorFibers, getDisplayName } = ctx.helpers;
|
|
7
|
+
const nameRe = ctx.args.name ? new RegExp(String(ctx.args.name), 'i') : null;
|
|
8
|
+
const max = Number(ctx.args.max) || 500;
|
|
9
|
+
|
|
10
|
+
// Provider fibers: tag === 10 in modern React, type._context exists.
|
|
11
|
+
const fibers = findFibers((f) => {
|
|
12
|
+
const t = f?.type;
|
|
13
|
+
if (!t || typeof t !== 'object') return false;
|
|
14
|
+
// ContextProvider: type._context, $$typeof matches React.createContext output
|
|
15
|
+
return !!t._context;
|
|
16
|
+
}, { max: max * 4 });
|
|
17
|
+
|
|
18
|
+
const seen = new Map(); // key = contextId → { name, count, samples[] }
|
|
19
|
+
for (const f of fibers) {
|
|
20
|
+
const ctxObj = f.type._context;
|
|
21
|
+
const name = ctxObj.displayName || ctxObj._currentValue?.displayName || ctxObj?.Provider?.displayName || null;
|
|
22
|
+
if (nameRe && !nameRe.test(name || '')) continue;
|
|
23
|
+
const id = name || ('anon@' + Object.keys(seen).length);
|
|
24
|
+
let rec = seen.get(id);
|
|
25
|
+
if (!rec) { rec = { name, count: 0, samples: [] }; seen.set(id, rec); }
|
|
26
|
+
rec.count++;
|
|
27
|
+
if (rec.samples.length < 3) {
|
|
28
|
+
rec.samples.push({
|
|
29
|
+
provider: fiberInfo(f),
|
|
30
|
+
owner: f._debugOwner ? fiberInfo(f._debugOwner) : null,
|
|
31
|
+
ancestors: ancestorFibers(f, 6).map((a) => a.name),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rows = Array.from(seen.values()).sort((a, b) => b.count - a.count);
|
|
37
|
+
ctx.log.push('matched providers: ' + rows.length);
|
|
38
|
+
return { totalProviders: rows.length, top: rows.slice(0, max) };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Find class components using the legacy React context API.
|
|
2
|
+
// Producer: class with `childContextTypes`
|
|
3
|
+
// Consumer: class with `contextTypes`
|
|
4
|
+
// Args:
|
|
5
|
+
// --name <regex> filter by component displayName / name
|
|
6
|
+
// --kind <producer|consumer|both> default both
|
|
7
|
+
// --max <N> default 500
|
|
8
|
+
|
|
9
|
+
const { findFibers, fiberInfo, ancestorFibers, getDisplayName } = ctx.helpers;
|
|
10
|
+
const nameRe = ctx.args.name ? new RegExp(String(ctx.args.name), 'i') : null;
|
|
11
|
+
const kind = String(ctx.args.kind || 'both');
|
|
12
|
+
const max = Number(ctx.args.max) || 500;
|
|
13
|
+
|
|
14
|
+
function classifies(t) {
|
|
15
|
+
if (!t) return null;
|
|
16
|
+
const isProducer = !!t.childContextTypes && Object.keys(t.childContextTypes).length > 0;
|
|
17
|
+
const isConsumer = !!t.contextTypes && Object.keys(t.contextTypes).length > 0;
|
|
18
|
+
if (!isProducer && !isConsumer) return null;
|
|
19
|
+
return { isProducer, isConsumer };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fibers = findFibers((f) => !!classifies(f?.type), { max: max * 4 });
|
|
23
|
+
const groups = { producers: new Map(), consumers: new Map() };
|
|
24
|
+
for (const f of fibers) {
|
|
25
|
+
const c = classifies(f.type);
|
|
26
|
+
const name = getDisplayName(f) || '<anonymous>';
|
|
27
|
+
if (nameRe && !nameRe.test(name)) continue;
|
|
28
|
+
if (c.isProducer && (kind === 'producer' || kind === 'both')) {
|
|
29
|
+
let rec = groups.producers.get(name);
|
|
30
|
+
if (!rec) { rec = { name, count: 0, contextKeys: Object.keys(f.type.childContextTypes), samples: [] }; groups.producers.set(name, rec); }
|
|
31
|
+
rec.count++;
|
|
32
|
+
if (rec.samples.length < 3) rec.samples.push({ self: fiberInfo(f), owner: f._debugOwner ? fiberInfo(f._debugOwner) : null });
|
|
33
|
+
}
|
|
34
|
+
if (c.isConsumer && (kind === 'consumer' || kind === 'both')) {
|
|
35
|
+
let rec = groups.consumers.get(name);
|
|
36
|
+
if (!rec) { rec = { name, count: 0, contextKeys: Object.keys(f.type.contextTypes), samples: [] }; groups.consumers.set(name, rec); }
|
|
37
|
+
rec.count++;
|
|
38
|
+
if (rec.samples.length < 3) rec.samples.push({ self: fiberInfo(f), owner: f._debugOwner ? fiberInfo(f._debugOwner) : null });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sortRows = (m) => Array.from(m.values()).sort((a, b) => b.count - a.count).slice(0, max);
|
|
43
|
+
return {
|
|
44
|
+
scannedFibers: fibers.length,
|
|
45
|
+
producers: sortRows(groups.producers),
|
|
46
|
+
consumers: sortRows(groups.consumers),
|
|
47
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Find fibers carrying a given prop, optionally with a matching value.
|
|
2
|
+
// Args:
|
|
3
|
+
// --prop <name> REQUIRED. Prop key to look for (exact match).
|
|
4
|
+
// --value <regex> OPTIONAL. Regex matched against String(propValue) (case-insensitive). Objects/functions become tag strings.
|
|
5
|
+
// --name <regex> OPTIONAL. Filter by component displayName regex (case-insensitive).
|
|
6
|
+
// --file <regex> OPTIONAL. Filter by _debugSource.fileName regex (case-insensitive).
|
|
7
|
+
// --max <N> cap result rows (default 200)
|
|
8
|
+
// --groupBy <name|file|none> default 'name'. Groups identical components into one row with samples.
|
|
9
|
+
// --depth <N> depth-trim for sample value (default 2)
|
|
10
|
+
|
|
11
|
+
const { walkAll, fiberInfo, getDisplayName } = ctx.helpers;
|
|
12
|
+
if (!ctx.args.prop) return { error: '--prop <name> is required' };
|
|
13
|
+
const propKey = String(ctx.args.prop);
|
|
14
|
+
const valueRe = ctx.args.value ? new RegExp(String(ctx.args.value), 'i') : null;
|
|
15
|
+
const nameRe = ctx.args.name ? new RegExp(String(ctx.args.name), 'i') : null;
|
|
16
|
+
const fileRe = ctx.args.file ? new RegExp(String(ctx.args.file), 'i') : null;
|
|
17
|
+
const max = Number(ctx.args.max) || 200;
|
|
18
|
+
const groupBy = ['name', 'file', 'none'].includes(String(ctx.args.groupBy)) ? String(ctx.args.groupBy) : 'name';
|
|
19
|
+
const depth = Number.isFinite(Number(ctx.args.depth)) ? Math.max(0, Number(ctx.args.depth) | 0) : 2;
|
|
20
|
+
|
|
21
|
+
function tagOf(v) {
|
|
22
|
+
if (v == null) return String(v);
|
|
23
|
+
const t = typeof v;
|
|
24
|
+
if (t === 'function') return '[Function ' + (v.name || 'anonymous') + ']';
|
|
25
|
+
if (t === 'object') return Array.isArray(v) ? '[Array len=' + v.length + ']' : '[Object]';
|
|
26
|
+
return String(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function trim(value, d) {
|
|
30
|
+
if (d <= 0 || value == null) {
|
|
31
|
+
if (value && typeof value === 'object') return Array.isArray(value) ? '[Array]' : '[Object]';
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) return value.slice(0, 10).map((v) => trim(v, d - 1));
|
|
35
|
+
if (typeof value === 'object') {
|
|
36
|
+
const out = {};
|
|
37
|
+
let n = 0;
|
|
38
|
+
for (const k of Object.keys(value)) {
|
|
39
|
+
if (n++ > 20) { out._truncated = true; break; }
|
|
40
|
+
try { out[k] = trim(value[k], d - 1); } catch (_) { out[k] = '[Unserializable]'; }
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === 'function') return '[Function ' + (value.name || 'anonymous') + ']';
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const groups = new Map();
|
|
49
|
+
let scanned = 0;
|
|
50
|
+
let matched = 0;
|
|
51
|
+
|
|
52
|
+
walkAll((f) => {
|
|
53
|
+
scanned++;
|
|
54
|
+
const props = f?.memoizedProps;
|
|
55
|
+
if (!props || typeof props !== 'object') return;
|
|
56
|
+
if (!Object.prototype.hasOwnProperty.call(props, propKey)) return;
|
|
57
|
+
const name = getDisplayName(f) || '<anonymous>';
|
|
58
|
+
if (nameRe && !nameRe.test(name)) return;
|
|
59
|
+
const file = f?._debugSource?.fileName || f?.type?._debugSource?.fileName || null;
|
|
60
|
+
if (fileRe && !fileRe.test(file || '')) return;
|
|
61
|
+
const v = props[propKey];
|
|
62
|
+
const tag = tagOf(v);
|
|
63
|
+
if (valueRe && !valueRe.test(tag) && !(typeof v === 'string' && valueRe.test(v))) return;
|
|
64
|
+
matched++;
|
|
65
|
+
const key = groupBy === 'name' ? name
|
|
66
|
+
: groupBy === 'file' ? (file || '<no-source>')
|
|
67
|
+
: (name + '|' + (file || '') + '|' + (f?._debugSource?.lineNumber || ''));
|
|
68
|
+
let rec = groups.get(key);
|
|
69
|
+
if (!rec) { rec = { key, name, file, line: f?._debugSource?.lineNumber || null, count: 0, samples: [] }; groups.set(key, rec); }
|
|
70
|
+
rec.count++;
|
|
71
|
+
if (rec.samples.length < 3) {
|
|
72
|
+
rec.samples.push({
|
|
73
|
+
self: fiberInfo(f),
|
|
74
|
+
owner: f._debugOwner ? fiberInfo(f._debugOwner) : null,
|
|
75
|
+
propValueTag: tag,
|
|
76
|
+
propValue: trim(v, depth),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const rows = Array.from(groups.values()).sort((a, b) => b.count - a.count).slice(0, max);
|
|
82
|
+
ctx.log.push('scanned: ' + scanned + ' matched: ' + matched + ' groups: ' + groups.size);
|
|
83
|
+
return { propKey, totalFibers: scanned, matched, groups: groups.size, top: rows };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Capture a labeled before/after setStateCount delta in ONE call.
|
|
2
|
+
//
|
|
3
|
+
// This is the "lighthouse number" pattern as a first-class read: sample the cheap
|
|
4
|
+
// counter, run a bounded action (a click by testId, or just settle), sample again,
|
|
5
|
+
// return the delta. Answers "how many components fired setState during <phase>".
|
|
6
|
+
//
|
|
7
|
+
// METRIC IS OPTIONAL. The react-dom patch that emits __ATL_REACT_METRICS.setStateCount
|
|
8
|
+
// may or may not be present. If it is absent, wiped, or non-finite (NaN), this snippet
|
|
9
|
+
// returns { available:false, reason } and NEVER throws — callers must not rely on the
|
|
10
|
+
// metric being there.
|
|
11
|
+
//
|
|
12
|
+
// Args:
|
|
13
|
+
// --label <text> OPTIONAL. Human label for the phase (default "sample").
|
|
14
|
+
// --click <testId> OPTIONAL. data-testid to click between before/after samples.
|
|
15
|
+
// --clicks <n> OPTIONAL. repeat the click n times (default 1).
|
|
16
|
+
// --settleMs <n> OPTIONAL. ms to wait after the action before the after-sample
|
|
17
|
+
// (default 300). Also used as the whole wait when no --click.
|
|
18
|
+
//
|
|
19
|
+
// Output (available):
|
|
20
|
+
// { available:true, label, before, after, delta, clicked, settleMs }
|
|
21
|
+
// Output (unavailable):
|
|
22
|
+
// { available:false, label, reason }
|
|
23
|
+
|
|
24
|
+
function readSSC() {
|
|
25
|
+
const m = globalThis.__ATL_REACT_METRICS;
|
|
26
|
+
if (m == null) return { ok: false, reason: 'metrics absent (__ATL_REACT_METRICS is ' + String(m) + ')' };
|
|
27
|
+
const v = m.setStateCount;
|
|
28
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
29
|
+
return { ok: false, reason: 'setStateCount is ' + String(v) + ' (patch absent or seeded non-numeric)' };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, value: v };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const label = ctx.args.label ? String(ctx.args.label) : 'sample';
|
|
35
|
+
const settleMs = Number.isFinite(Number(ctx.args.settleMs)) ? Number(ctx.args.settleMs) : 300;
|
|
36
|
+
const clickId = ctx.args.click ? String(ctx.args.click) : null;
|
|
37
|
+
const clicks = Number(ctx.args.clicks) > 0 ? Number(ctx.args.clicks) : 1;
|
|
38
|
+
|
|
39
|
+
const b = readSSC();
|
|
40
|
+
if (!b.ok) {
|
|
41
|
+
ctx.log.push('setStateCount unavailable: ' + b.reason);
|
|
42
|
+
return { available: false, label, reason: b.reason };
|
|
43
|
+
}
|
|
44
|
+
const before = b.value;
|
|
45
|
+
|
|
46
|
+
let clicked = 0;
|
|
47
|
+
if (clickId) {
|
|
48
|
+
const sel = '[data-testid="' + clickId + '"]';
|
|
49
|
+
for (let i = 0; i < clicks; i++) {
|
|
50
|
+
const el = document.querySelector(sel);
|
|
51
|
+
if (el) { el.click(); clicked++; }
|
|
52
|
+
// small yield so React can flush between clicks
|
|
53
|
+
await new Promise((r) => setTimeout(r, Math.min(80, settleMs)));
|
|
54
|
+
}
|
|
55
|
+
if (clicked === 0) ctx.log.push('no element matched ' + sel);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await new Promise((r) => setTimeout(r, settleMs));
|
|
59
|
+
|
|
60
|
+
const a = readSSC();
|
|
61
|
+
if (!a.ok) {
|
|
62
|
+
// metric vanished mid-sample (host wiped it) — report honestly
|
|
63
|
+
return { available: false, label, reason: 'after-sample: ' + a.reason, before };
|
|
64
|
+
}
|
|
65
|
+
const after = a.value;
|
|
66
|
+
|
|
67
|
+
ctx.log.push(label + ': ' + before + ' -> ' + after + ' (delta ' + (after - before) + ')');
|
|
68
|
+
|
|
69
|
+
return { available: true, label, before, after, delta: after - before, clicked, settleMs };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Read the host-owned React metrics object (__ATL_REACT_METRICS) — READ ONLY.
|
|
2
|
+
//
|
|
3
|
+
// Chippy READS this object; it does not create or seed it (the host/app owns it,
|
|
4
|
+
// same way Chippy reads any avalanche counter). The only new hook-related number
|
|
5
|
+
// here is `setStateCount` (useState/useReducer dispatches scheduled). Richer
|
|
6
|
+
// hook-update accounting lives in the separate `hook-tracker` snippet.
|
|
7
|
+
//
|
|
8
|
+
// Args:
|
|
9
|
+
// --keys <csv> OPTIONAL. Comma-separated counter names to return (default: all).
|
|
10
|
+
// --raw OPTIONAL. If present, return every own key regardless of type.
|
|
11
|
+
//
|
|
12
|
+
// Output:
|
|
13
|
+
// { present, wiped, setStateCount, setStateCountValid, counters:{...}, onHookUpdateInstalled }
|
|
14
|
+
//
|
|
15
|
+
// NaN/undefined-safe: the host may seed setStateCount as null (then react-dom's
|
|
16
|
+
// `null++` yields NaN) or wipe the whole object to undefined after hydration.
|
|
17
|
+
// This reader reports those states honestly instead of throwing.
|
|
18
|
+
|
|
19
|
+
const m = globalThis.__ATL_REACT_METRICS;
|
|
20
|
+
|
|
21
|
+
if (m == null) {
|
|
22
|
+
ctx.log.push('__ATL_REACT_METRICS is ' + String(m) + ' (host absent or wiped post-hydration)');
|
|
23
|
+
return {
|
|
24
|
+
present: false,
|
|
25
|
+
wiped: typeof m === 'undefined',
|
|
26
|
+
note: 'Host metrics object not present. It may not exist yet, or the app wiped it after hydration. Reads return no data; use hook-tracker to own the onHookUpdate slot regardless.',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const wantKeys = ctx.args.keys
|
|
31
|
+
? String(ctx.args.keys).split(',').map((s) => s.trim()).filter(Boolean)
|
|
32
|
+
: null;
|
|
33
|
+
const raw = !!ctx.args.raw;
|
|
34
|
+
|
|
35
|
+
const isNumericCounter = (v) => typeof v === 'number';
|
|
36
|
+
|
|
37
|
+
const counters = {};
|
|
38
|
+
for (const k of Object.keys(m)) {
|
|
39
|
+
if (wantKeys && !wantKeys.includes(k)) continue;
|
|
40
|
+
const v = m[k];
|
|
41
|
+
if (raw) { counters[k] = typeof v === 'function' ? '[Function ' + (v.name || 'anonymous') + ']' : v; continue; }
|
|
42
|
+
if (isNumericCounter(v)) counters[k] = v;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ssc = m.setStateCount;
|
|
46
|
+
const setStateCountValid = typeof ssc === 'number' && Number.isFinite(ssc);
|
|
47
|
+
|
|
48
|
+
ctx.log.push('setStateCount=' + String(ssc) + ' valid=' + setStateCountValid + ' onHookUpdate=' + typeof m.onHookUpdate);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
present: true,
|
|
52
|
+
wiped: false,
|
|
53
|
+
setStateCount: ssc,
|
|
54
|
+
setStateCountValid,
|
|
55
|
+
setStateCountNote: setStateCountValid
|
|
56
|
+
? null
|
|
57
|
+
: 'setStateCount is ' + String(ssc) + '. Host likely seeded it as null/undefined and react-dom incremented it to NaN. It is still monotonic-per-run once numeric; re-seed via hook-tracker install if you need a clean baseline.',
|
|
58
|
+
onHookUpdateInstalled: typeof m.onHookUpdate === 'function',
|
|
59
|
+
counters,
|
|
60
|
+
};
|