Skip to content

Commit aab5b4d

Browse files
authored
Merge pull request #151 from Lykhoyda/fix/issue-106-flow-skeleton-bundling
fix(gh-106): bundle .rn-agent/actions + skeleton in experience export/import
2 parents d2e26f8 + 6444fc8 commit aab5b4d

10 files changed

Lines changed: 1524 additions & 7 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.37",
12+
"version": "0.44.38",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.37",
3+
"version": "0.44.38",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,52 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.38] — 2026-05-13
8+
9+
### Added (GH #106 — flow + skeleton bundling in experience export/import)
10+
11+
- **`exportExperience()` now bundles `.rn-agent/actions/*.yaml` flows and
12+
`.rn-agent/skeleton.yaml`** alongside heuristics + failure stats —
13+
matching what the `rn-agent-export` / `rn-agent-import` command docs
14+
have advertised since D1204. Until now the underlying script handled
15+
only heuristics, so a teammate exporting + importing got the muscle
16+
memory metadata but not the actual reusable actions that ARE the L3
17+
corpus.
18+
- New `src/experience/flow-bundle.ts` exposes pure anonymize/restore
19+
helpers: rewrites `appId:` between `com.example.<slug>` (export) and
20+
the local project's bundleId (import), truncates author-prose comment
21+
lines longer than 200 chars while preserving M7 fields verbatim, and
22+
extracts `${VAR}` placeholders so the importer can surface them.
23+
- **Placeholder manifest comments** (Codex review A, HIGH conf): on
24+
import, if a flow contains `${UPPER_CASE_VARS}`, prepend a
25+
`# placeholders: VAR1, VAR2 — supply via -e KEY=VALUE on replay` line
26+
above the M7 header. Codex's call: don't suffix every placeholder flow
27+
with `.needs-review.yaml` (that punishes correctly-authored flows);
28+
don't go silent (violates spirit of acceptance criterion); use a
29+
grep-able comment instead.
30+
- **`appId:` rewrite is line-wise** (Codex review B, HIGH conf), so
31+
legitimate multi-line top sections (a `# shared across envs` comment
32+
above `appId:`) round-trip cleanly. Hard-fails only when zero `appId:`
33+
lines exist.
34+
- **Conflict semantics**: an imported flow whose `id` already exists
35+
locally lands at `<id>.imported.yaml` so the user can diff and merge
36+
manually. Same pattern for skeleton (`skeleton.imported.yaml`).
37+
- **Status forced to `experimental`** on import — keeps imported flows
38+
from claiming `active` before a local replay proves they work.
39+
- **Sidecars are not bundled.** Per-developer runtime state (`runHistory`,
40+
`repairHistory`, `stats`) is exactly what shouldn't travel; on import
41+
the local `loadOrInitSidecar` seeds a fresh sidecar on first replay.
42+
- **`--no-flows` / `--no-skeleton` opt-outs** on both `ExportOptions`
43+
and `ImportOptions` (default both true).
44+
- **Defense in depth**: import-side flow `id` is re-validated against
45+
`^[A-Za-z0-9_-]+$` so a hand-crafted bundle with a path-traversal id
46+
can't escape `.rn-agent/actions/`. Malformed flows are skipped with a
47+
one-line stderr log.
48+
- 29 new tests — 18 pure-helper tests on `flow-bundle.ts` + 11
49+
integration tests with real temp dirs covering export, import,
50+
opt-outs, conflict-rename, malformed-bundle defense in depth, and
51+
no-app.json fallback. Suite: 1312 → 1341 passing.
52+
753
## [0.44.37] — 2026-05-12
854

955
### Added (GH #91 acceptance #3 closeout — per-project verification config)
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// GH #106 / D1204 follow-up — flow + skeleton bundling helpers for the
2+
// experience export/import pipeline. The detector / existing exporter in
3+
// sharing.ts already handles heuristics + failure stats; this module adds
4+
// the missing pieces so the bundle actually carries the .rn-agent/actions/
5+
// corpus and the skeleton.yaml lookup table.
6+
//
7+
// Pure functions only — no I/O. Caller (sharing.ts) walks the filesystem
8+
// and feeds raw YAML text in. Returns transformed YAML text. Keeps the
9+
// I/O surface auditable and the helpers testable without temp dirs.
10+
//
11+
// Design calls (Codex pre-implementation review, both HIGH conf):
12+
// - `${VAR}` placeholders → prepend "# placeholders: VAR1, VAR2" comment
13+
// above the M7 header on restore. Do NOT suffix with .needs-review.yaml
14+
// — that punishes correctly-authored flows. (Codex A)
15+
// - `appId:` rewrite is line-wise, not whole-string. Preserves multi-line
16+
// top sections (a human-edited "# shared across envs" comment above
17+
// appId is legitimate per saveAction's topSection contract). Hard-fail
18+
// only when zero `appId:` lines exist; warn on multiple. (Codex B)
19+
const APPID_LINE_RE = /^appId:\s*(.+)$/;
20+
const PLACEHOLDER_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
21+
const M7_HEADER_LINE_RE = /^#\s*(id|intent|tags|mutates|status|requires|produces|preconditions|placeholders):/i;
22+
const STATUS_LINE_RE = /^(#\s*status:\s*).+$/;
23+
const MAX_PROSE_LINE_LENGTH = 200; // bytes per prose comment before truncation
24+
/** Anonymize a project's appId into a stable export slug (lowercase, no dots). */
25+
export function sanitizeAppIdSlug(appId) {
26+
if (!appId || typeof appId !== 'string')
27+
return 'app';
28+
const slug = appId
29+
.toLowerCase()
30+
.replace(/^com\./, '')
31+
.replace(/[^a-z0-9]+/g, '-')
32+
.replace(/^-+|-+$/g, '')
33+
.slice(0, 64);
34+
return slug || 'app';
35+
}
36+
function splitFlowYaml(text) {
37+
const lines = text.split('\n');
38+
const sepIdx = lines.findIndex((l) => l.trim() === '---');
39+
if (sepIdx < 0) {
40+
return { topSection: [], m7HeaderAndProse: [], body: lines, hasSeparator: false };
41+
}
42+
const topSection = lines.slice(0, sepIdx);
43+
const afterSep = lines.slice(sepIdx + 1);
44+
const headerAndProse = [];
45+
const body = [];
46+
let stillHeader = true;
47+
for (const line of afterSep) {
48+
if (stillHeader && (line.startsWith('#') || line.trim() === '')) {
49+
headerAndProse.push(line);
50+
}
51+
else {
52+
stillHeader = false;
53+
body.push(line);
54+
}
55+
}
56+
return { topSection, m7HeaderAndProse: headerAndProse, body, hasSeparator: true };
57+
}
58+
function joinFlowYaml(parts) {
59+
const out = [];
60+
if (parts.hasSeparator) {
61+
for (const l of parts.topSection)
62+
out.push(l);
63+
out.push('---');
64+
}
65+
for (const l of parts.m7HeaderAndProse)
66+
out.push(l);
67+
for (const l of parts.body)
68+
out.push(l);
69+
return out.join('\n');
70+
}
71+
function rewriteAppIdLine(topSection, newAppId) {
72+
let foundIdx = -1;
73+
for (let i = 0; i < topSection.length; i++) {
74+
if (APPID_LINE_RE.test(topSection[i])) {
75+
foundIdx = i;
76+
break;
77+
}
78+
}
79+
if (foundIdx < 0) {
80+
throw new Error('FlowBundleError: top section missing appId: line');
81+
}
82+
// Codex B: replace the first match; if there are duplicates, the second
83+
// is left alone. Maestro itself honors the first appId, so this matches
84+
// runtime semantics. A warning here would just spam logs since the
85+
// export side runs through every project's flows.
86+
const next = [...topSection];
87+
next[foundIdx] = `appId: ${newAppId}`;
88+
return next;
89+
}
90+
/**
91+
* Anonymize a flow YAML for export:
92+
* - rewrite `appId:` to `com.example.<slug>`
93+
* - truncate any prose comment line longer than 200 chars in the
94+
* header-and-prose section (preserves M7 fields verbatim)
95+
* - leave body verbatim (testIDs are semantic; ${VAR} placeholders stay)
96+
*
97+
* Throws FlowBundleError when the YAML has no `appId:` line — surfaces
98+
* malformed input rather than silently producing a broken export.
99+
*/
100+
export function anonymizeFlowYaml(text) {
101+
const parts = splitFlowYaml(text);
102+
if (!parts.hasSeparator) {
103+
throw new Error('FlowBundleError: flow YAML missing --- separator');
104+
}
105+
const sourceMatch = parts.topSection
106+
.map((l) => l.match(APPID_LINE_RE))
107+
.find((m) => m !== null);
108+
if (!sourceMatch) {
109+
throw new Error('FlowBundleError: top section missing appId: line');
110+
}
111+
const slug = sanitizeAppIdSlug(sourceMatch[1].trim());
112+
const newTop = rewriteAppIdLine(parts.topSection, `com.example.${slug}`);
113+
// Multi-review fix (Codex 92 + Gemini 95): strip any `# placeholders:`
114+
// line on export so a bundle round-trip (A→B→C) doesn't accumulate
115+
// copies. The manifest is a local-import-only annotation; the bundle
116+
// itself should never carry it.
117+
const newHeader = parts.m7HeaderAndProse
118+
.filter((line) => !/^#\s*placeholders:/i.test(line))
119+
.map((line) => {
120+
if (!line.startsWith('#'))
121+
return line;
122+
if (M7_HEADER_LINE_RE.test(line))
123+
return line; // keep M7 fields verbatim
124+
if (line.length <= MAX_PROSE_LINE_LENGTH + 2)
125+
return line; // 2 == '# ' prefix
126+
return line.slice(0, MAX_PROSE_LINE_LENGTH + 2 - 3) + '...';
127+
});
128+
return joinFlowYaml({ ...parts, topSection: newTop, m7HeaderAndProse: newHeader });
129+
}
130+
/** Read the first uppercase-only `${VAR}` placeholder names in the YAML body. */
131+
export function extractPlaceholders(text) {
132+
const seen = new Set();
133+
const iter = text.matchAll(PLACEHOLDER_RE);
134+
for (const m of iter) {
135+
seen.add(m[1]);
136+
}
137+
return [...seen].sort();
138+
}
139+
/** Pull the `id` from a flow's M7 header. Returns null when missing. */
140+
export function extractActionId(text) {
141+
const parts = splitFlowYaml(text);
142+
for (const line of parts.m7HeaderAndProse) {
143+
const m = line.match(/^#\s*id:\s*(.+)$/);
144+
if (m) {
145+
const id = m[1].trim();
146+
if (/^[A-Za-z0-9_-]+$/.test(id))
147+
return id;
148+
}
149+
}
150+
return null;
151+
}
152+
/**
153+
* Restore an anonymized flow YAML for import:
154+
* - rewrite `appId:` from `com.example.<slug>` to the local project's
155+
* actual appId
156+
* - force `# status: <anything>` to `# status: experimental` so the
157+
* imported flow can't claim active status before a local replay
158+
* proves it works
159+
* - if `${VAR}` placeholders exist, prepend a `# placeholders: …`
160+
* comment so the user sees what `-e KEY=VAL` args to supply at replay
161+
*/
162+
export function restoreFlowYaml(text, localAppId) {
163+
const parts = splitFlowYaml(text);
164+
if (!parts.hasSeparator) {
165+
throw new Error('FlowBundleError: flow YAML missing --- separator');
166+
}
167+
const newTop = rewriteAppIdLine(parts.topSection, localAppId);
168+
// Multi-review fix (Codex 92 + Gemini 95): drop any existing
169+
// `# placeholders:` line BEFORE we decide to prepend a fresh one, so a
170+
// file imported multiple times (manual re-import, or a renamed
171+
// `.imported.yaml` that was promoted to the canonical name) doesn't
172+
// stack duplicate manifest comments.
173+
const newHeader = parts.m7HeaderAndProse
174+
.filter((line) => !/^#\s*placeholders:/i.test(line))
175+
.map((line) => {
176+
const m = line.match(STATUS_LINE_RE);
177+
if (m)
178+
return `${m[1]}experimental`;
179+
return line;
180+
});
181+
const placeholders = extractPlaceholders(parts.body.join('\n'));
182+
let header = newHeader;
183+
if (placeholders.length > 0) {
184+
const manifest = `# placeholders: ${placeholders.join(', ')} — supply via -e KEY=VALUE on replay`;
185+
header = [manifest, ...newHeader];
186+
}
187+
return joinFlowYaml({ ...parts, topSection: newTop, m7HeaderAndProse: header });
188+
}
189+
// ── skeleton helpers ────────────────────────────────────────────────────
190+
//
191+
// Skeleton uses a structured YAML where `appId:` lives at top-level (not
192+
// inside a topSection-before-`---` block). Same rewrite logic via a
193+
// line-wise scan over the full document.
194+
function rewriteSkeletonAppId(text, newAppId) {
195+
const lines = text.split('\n');
196+
let foundIdx = -1;
197+
for (let i = 0; i < lines.length; i++) {
198+
if (APPID_LINE_RE.test(lines[i])) {
199+
foundIdx = i;
200+
break;
201+
}
202+
}
203+
if (foundIdx < 0) {
204+
throw new Error('FlowBundleError: skeleton missing appId: field');
205+
}
206+
lines[foundIdx] = `appId: ${newAppId}`;
207+
return lines.join('\n');
208+
}
209+
export function anonymizeSkeleton(text) {
210+
const lines = text.split('\n');
211+
const m = lines.map((l) => l.match(APPID_LINE_RE)).find((x) => x !== null);
212+
if (!m)
213+
throw new Error('FlowBundleError: skeleton missing appId: field');
214+
const slug = sanitizeAppIdSlug(m[1].trim());
215+
return rewriteSkeletonAppId(text, `com.example.${slug}`);
216+
}
217+
export function restoreSkeleton(text, localAppId) {
218+
return rewriteSkeletonAppId(text, localAppId);
219+
}

0 commit comments

Comments
 (0)