@atlassian/chippy 0.0.0-stage → 0.0.1-beta.1
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 +188 -2
- package/bin/chippy.mjs +134 -0
- package/bin/pack.mjs +184 -0
- package/lib/avalanche.js +2570 -0
- package/lib/avalanche.js.map +7 -0
- package/lib/bootstrap.d.ts +1 -0
- package/lib/bootstrap.js +51 -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 +5396 -0
- package/lib/extension/avalanche.es.js.map +1 -0
- package/lib/extension/chippy-page-bridge.js +1833 -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 +150 -0
- package/lib/mcp-skill/SKILL.md +330 -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/avalanche.es.js +5396 -0
- package/lib/mcp-skill/extension/avalanche.es.js.map +1 -0
- package/lib/mcp-skill/extension/avalanche.iife.js +5397 -0
- package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -0
- package/lib/mcp-skill/extension/background.js +323 -0
- package/lib/mcp-skill/extension/chippy-page-bridge.js +1833 -0
- package/lib/mcp-skill/extension/content-script.js +167 -0
- package/lib/mcp-skill/extension/devtools-hook-shim.js +68 -0
- package/lib/mcp-skill/extension/early-bootstrap.js +47 -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 +191 -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/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 +1075 -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 +68 -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 +193 -0
- package/lib/mcp-skill/references/troubleshooting.md +54 -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 +1710 -0
- package/lib/mcp-skill/scripts/chippy-mcp.cjs +273 -0
- package/lib/mcp-skill/scripts/chippy.sh +1218 -0
- package/lib/mcp-skill/scripts/format-probe-result.js +34 -0
- package/lib/mcp-skill/scripts/helper-access.js +144 -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.jsx +403 -0
- package/lib/storybook/preset.js +7 -0
- package/lib/storybook/preview.js +263 -0
- package/lib/storybook/probe-annotation-editor.jsx +52 -0
- package/lib/storybook/probe-intent.js +23 -0
- package/lib/storybook/setup.js +93 -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 +37 -0
- package/lib/types/chippy-bridge/react-renderer.d.ts +49 -0
- package/lib/types/chippy-bridge/source-map.d.ts +30 -0
- package/lib/types/debug-render/core.d.ts +72 -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 +104 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
const mode = process.argv[2] || 'text';
|
|
5
|
+
let input = '';
|
|
6
|
+
|
|
7
|
+
process.stdin.on('data', (chunk) => {
|
|
8
|
+
input += chunk;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
process.stdin.on('end', () => {
|
|
12
|
+
const payload = JSON.parse(input);
|
|
13
|
+
if (mode === 'json') {
|
|
14
|
+
process.stdout.write(JSON.stringify(payload, null, 2));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!payload?.ok || !payload?.result?.ok) {
|
|
18
|
+
process.stdout.write(JSON.stringify(payload, null, 2));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const result = payload.result;
|
|
22
|
+
const text = String(result.text || '');
|
|
23
|
+
if (mode === 'complexity') {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
const start = lines.indexOf('complexity:');
|
|
26
|
+
const end = lines.indexOf('structure:');
|
|
27
|
+
if (start >= 0) {
|
|
28
|
+
const until = end > start ? end : lines.length;
|
|
29
|
+
process.stdout.write(lines.slice(start, until).join('\n'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
process.stdout.write(text || JSON.stringify(payload, null, 2));
|
|
34
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Access control for the Chippy helper.
|
|
3
|
+
//
|
|
4
|
+
// The helper can run scripts in connected pages, so its command API must not
|
|
5
|
+
// be reachable from arbitrary websites or other hosts. Three checks apply:
|
|
6
|
+
// 1. The helper binds to loopback by default. A wider bind (for example
|
|
7
|
+
// 0.0.0.0 for a remote dev environment or container) is opt-in.
|
|
8
|
+
// 2. When bound to loopback, the Host header must name a loopback host. This
|
|
9
|
+
// blocks DNS-rebinding pages that resolve their own name to 127.0.0.1.
|
|
10
|
+
// 3. Protected routes require the X-Chippy-Token header. CORS does not allow
|
|
11
|
+
// that header, so a cross-origin page cannot send it.
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
16
|
+
|
|
17
|
+
const TOKEN_HEADER = 'x-chippy-token';
|
|
18
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
19
|
+
|
|
20
|
+
// Routes that pages load without credentials: health, injected bundles, and
|
|
21
|
+
// the page WebSocket.
|
|
22
|
+
const PUBLIC_GET_PATHS = new Set([
|
|
23
|
+
'',
|
|
24
|
+
'/health',
|
|
25
|
+
'/inject.js',
|
|
26
|
+
'/inject.mjs',
|
|
27
|
+
'/avalanche.es.js',
|
|
28
|
+
'/avalanche.iife.js',
|
|
29
|
+
'/avalanche.es.js.map',
|
|
30
|
+
'/avalanche.iife.js.map',
|
|
31
|
+
'/devtools-hook-shim.js',
|
|
32
|
+
'/api/avalanche-arm',
|
|
33
|
+
'/proxy/go',
|
|
34
|
+
'/ws',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Routes the published browser extension calls from its service worker. The
|
|
38
|
+
// extension sends no token, so these stay open to extension origins and
|
|
39
|
+
// non-browser clients on every bind. They list pages and arm capture; they do
|
|
40
|
+
// not run page code.
|
|
41
|
+
const EXTENSION_ROUTES = new Set(['GET /api/pages', 'POST /api/avalanche-arm']);
|
|
42
|
+
|
|
43
|
+
function stateDirectory(env = process.env) {
|
|
44
|
+
return env.CHIPPY_STATE_DIR || path.join(os.homedir(), '.chippy');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveTokenFile(port, env = process.env) {
|
|
48
|
+
return path.join(stateDirectory(env), `helper-${port}.token`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readHelperToken(port, env = process.env) {
|
|
52
|
+
if (env.CHIPPY_HELPER_TOKEN) return env.CHIPPY_HELPER_TOKEN;
|
|
53
|
+
try {
|
|
54
|
+
return fs.readFileSync(resolveTokenFile(port, env), 'utf8').trim() || null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createHelperToken(env = process.env) {
|
|
61
|
+
return env.CHIPPY_HELPER_TOKEN || crypto.randomBytes(32).toString('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Call only after the server is listening, so a helper that lost the port
|
|
65
|
+
// race cannot replace the running helper's token.
|
|
66
|
+
function writeHelperToken(port, token, env = process.env) {
|
|
67
|
+
if (env.CHIPPY_HELPER_TOKEN) return null;
|
|
68
|
+
const file = resolveTokenFile(port, env);
|
|
69
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
70
|
+
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
|
|
71
|
+
fs.chmodSync(file, 0o600);
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLoopbackBind(host) {
|
|
76
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hostnameOf(hostHeader) {
|
|
80
|
+
if (!hostHeader) return '';
|
|
81
|
+
const value = String(hostHeader).toLowerCase();
|
|
82
|
+
if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1);
|
|
83
|
+
return value.split(':')[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function allowedHosts(env = process.env) {
|
|
87
|
+
const extra = (env.CHIPPY_HELPER_ALLOWED_HOSTS || '')
|
|
88
|
+
.split(',')
|
|
89
|
+
.map((h) => h.trim().toLowerCase())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
return new Set([...LOOPBACK_HOSTS, ...extra]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isAllowedHost(hostHeader, env = process.env) {
|
|
95
|
+
return allowedHosts(env).has(hostnameOf(hostHeader));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Returns 'public', 'extension', or 'protected'. `route` is the path after the
|
|
99
|
+
// Chippy prefix, or null for proxied application paths.
|
|
100
|
+
function classifyRoute(method, route) {
|
|
101
|
+
if (route === null) return 'public';
|
|
102
|
+
if (method === 'OPTIONS') return 'public';
|
|
103
|
+
if (method === 'GET' && PUBLIC_GET_PATHS.has(route)) return 'public';
|
|
104
|
+
if (EXTENSION_ROUTES.has(`${method} ${route}`)) return 'extension';
|
|
105
|
+
return 'protected';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isExtensionOrigin(origin) {
|
|
109
|
+
return typeof origin === 'string' && /^(chrome|moz)-extension:\/\//.test(origin);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function tokensMatch(expected, received) {
|
|
113
|
+
if (typeof received !== 'string' || !expected) return false;
|
|
114
|
+
const a = Buffer.from(expected);
|
|
115
|
+
const b = Buffer.from(received);
|
|
116
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Returns null when the request may proceed, otherwise { status, error }.
|
|
120
|
+
function authorizeRequest({ method, route, headers, token, loopback, env = process.env }) {
|
|
121
|
+
if (loopback && !isAllowedHost(headers.host, env)) {
|
|
122
|
+
return { status: 403, error: 'Host header is not a loopback host; set CHIPPY_HELPER_ALLOWED_HOSTS to allow it' };
|
|
123
|
+
}
|
|
124
|
+
const kind = classifyRoute(method, route);
|
|
125
|
+
if (kind === 'public') return null;
|
|
126
|
+
if (tokensMatch(token, headers[TOKEN_HEADER])) return null;
|
|
127
|
+
if (kind === 'extension') {
|
|
128
|
+
const origin = headers.origin;
|
|
129
|
+
if (origin === undefined || isExtensionOrigin(origin)) return null;
|
|
130
|
+
}
|
|
131
|
+
return { status: 401, error: 'Missing or invalid X-Chippy-Token header' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
TOKEN_HEADER,
|
|
136
|
+
authorizeRequest,
|
|
137
|
+
classifyRoute,
|
|
138
|
+
createHelperToken,
|
|
139
|
+
isAllowedHost,
|
|
140
|
+
isLoopbackBind,
|
|
141
|
+
readHelperToken,
|
|
142
|
+
resolveTokenFile,
|
|
143
|
+
writeHelperToken,
|
|
144
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
|
|
3
|
+
const DIRECT_PROBE_IDS = new Set(['1', '2']);
|
|
4
|
+
|
|
5
|
+
function normalizedAnnotation(marker) {
|
|
6
|
+
return {
|
|
7
|
+
intent: marker?.intent ?? null,
|
|
8
|
+
comment: typeof marker?.comment === 'string' ? marker.comment : '',
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function annotationChanged(previousMarker, nextMarker) {
|
|
13
|
+
const previous = normalizedAnnotation(previousMarker);
|
|
14
|
+
const next = normalizedAnnotation(nextMarker);
|
|
15
|
+
return previous.intent !== next.intent || previous.comment !== next.comment;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createProbeAnnotationChannel({ maxEvents = 50, serializeMarker = (marker) => marker } = {}) {
|
|
19
|
+
const queues = new Map();
|
|
20
|
+
const waiters = new Map();
|
|
21
|
+
let sequence = 0;
|
|
22
|
+
|
|
23
|
+
function getQueue(clientId) {
|
|
24
|
+
if (!queues.has(clientId)) queues.set(clientId, []);
|
|
25
|
+
return queues.get(clientId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getWaiters(clientId) {
|
|
29
|
+
if (!waiters.has(clientId)) waiters.set(clientId, []);
|
|
30
|
+
return waiters.get(clientId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deliver(clientId, event) {
|
|
34
|
+
const waiting = getWaiters(clientId);
|
|
35
|
+
while (waiting.length) {
|
|
36
|
+
const waiter = waiting.shift();
|
|
37
|
+
if (waiter.settled) continue;
|
|
38
|
+
waiter.finish(event);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const queue = getQueue(clientId);
|
|
42
|
+
queue.push(event);
|
|
43
|
+
while (queue.length > maxEvents) queue.shift();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function publish({ clientId, at, page, integration, previousSnapshot, snapshot }) {
|
|
47
|
+
const previousMarkers = previousSnapshot?.markers || {};
|
|
48
|
+
const nextMarkers = snapshot?.markers || {};
|
|
49
|
+
const directIds = Object.keys(nextMarkers).filter((id) => DIRECT_PROBE_IDS.has(id));
|
|
50
|
+
const changedProbeIds = directIds.filter((id) => annotationChanged(previousMarkers[id], nextMarkers[id]));
|
|
51
|
+
const annotatedProbeIds = directIds.filter((id) => {
|
|
52
|
+
const annotation = normalizedAnnotation(nextMarkers[id]);
|
|
53
|
+
return annotation.intent !== null || annotation.comment.length > 0;
|
|
54
|
+
});
|
|
55
|
+
const selectedProbeIds = changedProbeIds.length
|
|
56
|
+
? changedProbeIds
|
|
57
|
+
: (annotatedProbeIds.length ? annotatedProbeIds : directIds);
|
|
58
|
+
|
|
59
|
+
const event = {
|
|
60
|
+
eventId: `probe-annotation:${++sequence}`,
|
|
61
|
+
sequence,
|
|
62
|
+
at,
|
|
63
|
+
clientId,
|
|
64
|
+
reason: 'marker-annotation-change',
|
|
65
|
+
page: page || null,
|
|
66
|
+
integration: integration || null,
|
|
67
|
+
changedProbeIds,
|
|
68
|
+
annotations: selectedProbeIds.map((probeId) => ({
|
|
69
|
+
probeId,
|
|
70
|
+
...normalizedAnnotation(nextMarkers[probeId]),
|
|
71
|
+
marker: serializeMarker(nextMarkers[probeId]),
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
deliver(clientId, event);
|
|
75
|
+
return event;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function wait(clientId, timeoutMs) {
|
|
79
|
+
const queue = getQueue(clientId);
|
|
80
|
+
if (queue.length) {
|
|
81
|
+
return { promise: Promise.resolve(queue.shift()), cancel() {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let waiter;
|
|
85
|
+
const promise = new Promise((resolve) => {
|
|
86
|
+
waiter = {
|
|
87
|
+
settled: false,
|
|
88
|
+
timer: null,
|
|
89
|
+
finish(event) {
|
|
90
|
+
if (this.settled) return;
|
|
91
|
+
this.settled = true;
|
|
92
|
+
clearTimeout(this.timer);
|
|
93
|
+
const waiting = waiters.get(clientId);
|
|
94
|
+
const index = waiting?.indexOf(this) ?? -1;
|
|
95
|
+
if (index >= 0) waiting.splice(index, 1);
|
|
96
|
+
if (waiting?.length === 0) waiters.delete(clientId);
|
|
97
|
+
resolve(event);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
waiter.timer = setTimeout(() => waiter.finish(null), timeoutMs);
|
|
101
|
+
getWaiters(clientId).push(waiter);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
promise,
|
|
106
|
+
cancel() {
|
|
107
|
+
if (!waiter || waiter.settled) return;
|
|
108
|
+
waiter.finish(null);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { publish, wait };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { createProbeAnnotationChannel };
|
|
@@ -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 };
|