prd-to-flutter 0.1.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 +149 -0
- package/bin/p2f.mjs +18 -0
- package/dist/cli.js +203 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/clean.js +35 -0
- package/dist/commands/clean.js.map +1 -0
- package/dist/commands/doctor.js +148 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/generate.js +120 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.js +46 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/paths.js +58 -0
- package/dist/commands/paths.js.map +1 -0
- package/dist/commands/remove.js +23 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/screenshots-audit.js +39 -0
- package/dist/commands/screenshots-audit.js.map +1 -0
- package/dist/commands/skills-install.js +21 -0
- package/dist/commands/skills-install.js.map +1 -0
- package/dist/commands/sync.js +84 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/update.js +93 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/core/existing-page-detector.js +463 -0
- package/dist/core/existing-page-detector.js.map +1 -0
- package/dist/core/fail-fast.js +50 -0
- package/dist/core/fail-fast.js.map +1 -0
- package/dist/core/feature-coverage-builder.js +667 -0
- package/dist/core/feature-coverage-builder.js.map +1 -0
- package/dist/core/flutter-project-scanner.js +393 -0
- package/dist/core/flutter-project-scanner.js.map +1 -0
- package/dist/core/implementation-plan-writer.js +190 -0
- package/dist/core/implementation-plan-writer.js.map +1 -0
- package/dist/core/logger.js +39 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/package-info.js +33 -0
- package/dist/core/package-info.js.map +1 -0
- package/dist/core/paths.js +37 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/playwright-capture.js +539 -0
- package/dist/core/playwright-capture.js.map +1 -0
- package/dist/core/playwright-cli.js +26 -0
- package/dist/core/playwright-cli.js.map +1 -0
- package/dist/core/playwright-install.js +40 -0
- package/dist/core/playwright-install.js.map +1 -0
- package/dist/core/prd-clone.js +131 -0
- package/dist/core/prd-clone.js.map +1 -0
- package/dist/core/prd-install.js +108 -0
- package/dist/core/prd-install.js.map +1 -0
- package/dist/core/profile.js +149 -0
- package/dist/core/profile.js.map +1 -0
- package/dist/core/project-doc-reader.js +252 -0
- package/dist/core/project-doc-reader.js.map +1 -0
- package/dist/core/report-writer.js +90 -0
- package/dist/core/report-writer.js.map +1 -0
- package/dist/core/route-name.js +60 -0
- package/dist/core/route-name.js.map +1 -0
- package/dist/core/run-context.js +160 -0
- package/dist/core/run-context.js.map +1 -0
- package/dist/core/screenshot-auditor.js +405 -0
- package/dist/core/screenshot-auditor.js.map +1 -0
- package/dist/core/screenshot-exploration-plan-writer.js +200 -0
- package/dist/core/screenshot-exploration-plan-writer.js.map +1 -0
- package/dist/core/semantic-model-builder.js +922 -0
- package/dist/core/semantic-model-builder.js.map +1 -0
- package/dist/core/skill-install.js +78 -0
- package/dist/core/skill-install.js.map +1 -0
- package/dist/core/stage-stub.js +24 -0
- package/dist/core/stage-stub.js.map +1 -0
- package/dist/core/task-index-writer.js +149 -0
- package/dist/core/task-index-writer.js.map +1 -0
- package/dist/core/update-checker.js +155 -0
- package/dist/core/update-checker.js.map +1 -0
- package/dist/core/vue-page-locator.js +748 -0
- package/dist/core/vue-page-locator.js.map +1 -0
- package/dist/core/vue-project-reader.js +116 -0
- package/dist/core/vue-project-reader.js.map +1 -0
- package/docs/artifacts-and-agent.md +203 -0
- package/docs/development.md +118 -0
- package/docs/usage.md +246 -0
- package/package.json +50 -0
- package/skills/p2f/SKILL.md +303 -0
- package/skills/p2f/references/page-layout-patterns.md +120 -0
- package/skills/p2f/references/youfi-flutter-guidelines.md +71 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, join, relative } from 'node:path';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { markComplete } from './run-context.js';
|
|
5
|
+
const MAX_SOURCE_BYTES = 120_000;
|
|
6
|
+
const MAX_DOC_BYTES = 8_000;
|
|
7
|
+
const MAX_DOC_FILES = 10;
|
|
8
|
+
const MAX_SOURCE_INTERACTIONS_TO_QUEUE = 16;
|
|
9
|
+
export function buildFeatureCoverage(ctx, page, vueDocs) {
|
|
10
|
+
const analysisDir = join(ctx.pageDir, 'analysis');
|
|
11
|
+
mkdirSync(analysisDir, { recursive: true });
|
|
12
|
+
const jsonPath = join(analysisDir, 'feature-coverage.json');
|
|
13
|
+
const markdownPath = join(analysisDir, 'feature-coverage.md');
|
|
14
|
+
const scratch = {
|
|
15
|
+
features: [],
|
|
16
|
+
states: [],
|
|
17
|
+
interactions: [],
|
|
18
|
+
sourceFiles: [],
|
|
19
|
+
docFiles: [],
|
|
20
|
+
};
|
|
21
|
+
for (const file of page.relatedFiles) {
|
|
22
|
+
analyzeVueSourceFile(scratch, vueDocs.root, file);
|
|
23
|
+
}
|
|
24
|
+
for (const doc of collectProductDocs(vueDocs.root, page)) {
|
|
25
|
+
analyzeProductDoc(scratch, vueDocs.root, doc.path, doc.score, doc.matchedTerms);
|
|
26
|
+
}
|
|
27
|
+
const coverage = {
|
|
28
|
+
pageKey: ctx.pageKey,
|
|
29
|
+
pageName: ctx.options.pageName,
|
|
30
|
+
generatedAt: new Date().toISOString(),
|
|
31
|
+
jsonPath,
|
|
32
|
+
markdownPath,
|
|
33
|
+
sourceFiles: scratch.sourceFiles,
|
|
34
|
+
docFiles: scratch.docFiles,
|
|
35
|
+
features: dedupeFeatures(scratch.features),
|
|
36
|
+
expectedStates: dedupeStates(scratch.states),
|
|
37
|
+
expectedInteractions: dedupeInteractions(scratch.interactions),
|
|
38
|
+
runtimeCoverage: {
|
|
39
|
+
coveredByDom: [],
|
|
40
|
+
queuedForAgentExploration: [],
|
|
41
|
+
notQueued: [],
|
|
42
|
+
discoveredOnlyByRuntime: [],
|
|
43
|
+
notes: [
|
|
44
|
+
'运行态覆盖会在 build-semantic-model 合并 DOM 候选与源码/文档预期后更新。',
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
writeFeatureCoverageFiles(ctx, coverage);
|
|
49
|
+
logger.success(`Feature coverage → features=${coverage.features.length} states=${coverage.expectedStates.length} interactions=${coverage.expectedInteractions.length}`);
|
|
50
|
+
markComplete(ctx, 'build-feature-coverage');
|
|
51
|
+
return coverage;
|
|
52
|
+
}
|
|
53
|
+
export function mergeFeatureCoverageIntoSemanticModel(ctx, model, coverage) {
|
|
54
|
+
const existing = new Set(model.outline.candidateInteractions
|
|
55
|
+
.map((c) => normalizeLabel(c.label ?? ''))
|
|
56
|
+
.filter(Boolean));
|
|
57
|
+
const queued = [];
|
|
58
|
+
const notQueued = [];
|
|
59
|
+
const covered = [];
|
|
60
|
+
const expectedLabels = new Set(coverage.expectedInteractions
|
|
61
|
+
.map((item) => normalizeLabel(item.label))
|
|
62
|
+
.filter(Boolean));
|
|
63
|
+
const runtimeOnly = model.outline.candidateInteractions
|
|
64
|
+
.map((item) => item.label)
|
|
65
|
+
.filter((label) => Boolean(label))
|
|
66
|
+
.filter((label) => !expectedLabels.has(normalizeLabel(label)));
|
|
67
|
+
for (const item of coverage.expectedInteractions) {
|
|
68
|
+
const normalized = normalizeLabel(item.label);
|
|
69
|
+
if (!normalized) {
|
|
70
|
+
item.runtimeStatus = 'not-queued';
|
|
71
|
+
notQueued.push(item);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (existing.has(normalized)) {
|
|
75
|
+
item.runtimeStatus = 'covered-by-dom';
|
|
76
|
+
covered.push(item);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!isSafeExplorationLabel(item.label) || queued.length >= MAX_SOURCE_INTERACTIONS_TO_QUEUE) {
|
|
80
|
+
item.runtimeStatus = 'not-queued';
|
|
81
|
+
notQueued.push(item);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
item.runtimeStatus = 'queued-for-agent-exploration';
|
|
85
|
+
queued.push(item);
|
|
86
|
+
existing.add(normalized);
|
|
87
|
+
model.outline.candidateInteractions.push(toCandidateInteraction(item, queued.length));
|
|
88
|
+
}
|
|
89
|
+
coverage.runtimeCoverage = {
|
|
90
|
+
coveredByDom: covered.map((i) => i.label),
|
|
91
|
+
queuedForAgentExploration: queued.map((i) => i.label),
|
|
92
|
+
notQueued: notQueued.map((i) => i.label),
|
|
93
|
+
discoveredOnlyByRuntime: dedupeBy(runtimeOnly, normalizeLabel),
|
|
94
|
+
notes: [
|
|
95
|
+
queued.length
|
|
96
|
+
? `已为 agent live-page 探索追加 ${queued.length} 个源码/文档派生交互。`
|
|
97
|
+
: '没有追加源码/文档派生交互。',
|
|
98
|
+
'源码/文档派生候选只是线索;agent 必须检查 live page,并探索这些标签之外的可见控件。',
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
writeFeatureCoverageFiles(ctx, coverage);
|
|
102
|
+
writeFileSync(model.candidateInteractionsPath, JSON.stringify({
|
|
103
|
+
pageKey: model.pageKey,
|
|
104
|
+
candidates: model.outline.candidateInteractions,
|
|
105
|
+
sourceCoverage: {
|
|
106
|
+
queuedForAgentExploration: coverage.runtimeCoverage.queuedForAgentExploration,
|
|
107
|
+
coveredByDom: coverage.runtimeCoverage.coveredByDom,
|
|
108
|
+
},
|
|
109
|
+
}, null, 2) + '\n', 'utf8');
|
|
110
|
+
logger.success(`Feature coverage merged → queued=${queued.length} coveredByDom=${covered.length}`);
|
|
111
|
+
markComplete(ctx, 'merge-feature-coverage');
|
|
112
|
+
return queued.length;
|
|
113
|
+
}
|
|
114
|
+
function analyzeVueSourceFile(scratch, root, file) {
|
|
115
|
+
if (!existsSync(file) || !statSync(file).isFile())
|
|
116
|
+
return;
|
|
117
|
+
const rel = relative(root, file);
|
|
118
|
+
const body = readLimited(file, MAX_SOURCE_BYTES);
|
|
119
|
+
const ext = extname(file).toLowerCase();
|
|
120
|
+
const isVue = ext === '.vue';
|
|
121
|
+
const template = isVue ? extractTemplate(body) : '';
|
|
122
|
+
const interactionSource = template;
|
|
123
|
+
const components = interactionSource ? extractComponents(interactionSource) : [];
|
|
124
|
+
const labels = interactionSource ? extractLabels(interactionSource).slice(0, 80) : [];
|
|
125
|
+
const handlers = interactionSource ? extractEventHandlers(interactionSource) : [];
|
|
126
|
+
scratch.sourceFiles.push({
|
|
127
|
+
path: rel,
|
|
128
|
+
components,
|
|
129
|
+
labels,
|
|
130
|
+
eventHandlers: handlers.map((h) => `${h.event}:${h.handler}`).slice(0, 80),
|
|
131
|
+
});
|
|
132
|
+
if (!isVue)
|
|
133
|
+
return;
|
|
134
|
+
for (const component of components) {
|
|
135
|
+
const feature = featureFromComponent(component);
|
|
136
|
+
if (feature) {
|
|
137
|
+
scratch.features.push({
|
|
138
|
+
id: '',
|
|
139
|
+
kind: feature.kind,
|
|
140
|
+
label: feature.label,
|
|
141
|
+
source: 'vue-source',
|
|
142
|
+
sourceFile: rel,
|
|
143
|
+
evidence: `<${component}>`,
|
|
144
|
+
confidence: 0.72,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const state of extractSourceStates(body)) {
|
|
149
|
+
scratch.states.push({
|
|
150
|
+
id: '',
|
|
151
|
+
...state,
|
|
152
|
+
source: 'vue-source',
|
|
153
|
+
sourceFile: rel,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
for (const tab of extractTabLabels(interactionSource)) {
|
|
157
|
+
scratch.features.push({
|
|
158
|
+
id: '',
|
|
159
|
+
kind: 'tab',
|
|
160
|
+
label: tab,
|
|
161
|
+
source: 'vue-source',
|
|
162
|
+
sourceFile: rel,
|
|
163
|
+
evidence: `tab label: ${tab}`,
|
|
164
|
+
confidence: 0.66,
|
|
165
|
+
});
|
|
166
|
+
scratch.interactions.push({
|
|
167
|
+
id: '',
|
|
168
|
+
kind: 'tab-change',
|
|
169
|
+
label: tab,
|
|
170
|
+
source: 'vue-source',
|
|
171
|
+
sourceFile: rel,
|
|
172
|
+
evidence: `tab label: ${tab}`,
|
|
173
|
+
confidence: 0.58,
|
|
174
|
+
selectorStrategy: 'text',
|
|
175
|
+
runtimeStatus: 'pending',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const event of handlers) {
|
|
179
|
+
const label = pickEventLabel(event.snippet, event.handler);
|
|
180
|
+
if (!label)
|
|
181
|
+
continue;
|
|
182
|
+
const kind = inferInteractionKind({
|
|
183
|
+
label,
|
|
184
|
+
evidence: event.snippet,
|
|
185
|
+
event: event.event,
|
|
186
|
+
handler: event.handler,
|
|
187
|
+
});
|
|
188
|
+
scratch.interactions.push({
|
|
189
|
+
id: '',
|
|
190
|
+
kind,
|
|
191
|
+
label,
|
|
192
|
+
source: 'vue-source',
|
|
193
|
+
sourceFile: rel,
|
|
194
|
+
evidence: `${event.event}: ${event.handler}`,
|
|
195
|
+
confidence: event.event === 'click' ? 0.6 : 0.54,
|
|
196
|
+
selectorStrategy: kind === 'input-focus' ? 'placeholder' : 'text',
|
|
197
|
+
runtimeStatus: 'pending',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function analyzeProductDoc(scratch, root, file, score, matchedTerms) {
|
|
202
|
+
const rel = relative(root, file);
|
|
203
|
+
const body = readLimited(file, MAX_DOC_BYTES);
|
|
204
|
+
const headings = body
|
|
205
|
+
.split(/\r?\n/)
|
|
206
|
+
.map((line) => /^#{1,4}\s+(.+)$/.exec(line)?.[1]?.trim())
|
|
207
|
+
.filter((line) => Boolean(line))
|
|
208
|
+
.slice(0, 30);
|
|
209
|
+
scratch.docFiles.push({ path: rel, score, matchedTerms, headings });
|
|
210
|
+
const interestingLines = body
|
|
211
|
+
.split(/\r?\n/)
|
|
212
|
+
.map((line) => line.trim())
|
|
213
|
+
.filter((line) => line.length >= 3 && line.length <= 180)
|
|
214
|
+
.filter((line) => /点击|按钮|切换|筛选|选择|搜索|输入|弹窗|下拉|列表|空态|加载|错误|Tab|tab|filter|select|search|dialog|modal|empty|loading/i.test(line))
|
|
215
|
+
.slice(0, 80);
|
|
216
|
+
for (const line of [...headings, ...interestingLines]) {
|
|
217
|
+
const kind = inferFeatureKind(line);
|
|
218
|
+
if (kind !== 'unknown') {
|
|
219
|
+
scratch.features.push({
|
|
220
|
+
id: '',
|
|
221
|
+
kind,
|
|
222
|
+
label: cleanLabel(line).slice(0, 80),
|
|
223
|
+
source: 'product-doc',
|
|
224
|
+
sourceFile: rel,
|
|
225
|
+
evidence: line,
|
|
226
|
+
confidence: Math.min(0.72, 0.42 + score / 10),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const state = stateFromText(line);
|
|
230
|
+
if (state) {
|
|
231
|
+
scratch.states.push({
|
|
232
|
+
id: '',
|
|
233
|
+
...state,
|
|
234
|
+
source: 'product-doc',
|
|
235
|
+
sourceFile: rel,
|
|
236
|
+
evidence: line,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const label = extractActionLabelFromDocLine(line);
|
|
240
|
+
if (label) {
|
|
241
|
+
scratch.interactions.push({
|
|
242
|
+
id: '',
|
|
243
|
+
kind: inferInteractionKind({ label, evidence: line }),
|
|
244
|
+
label,
|
|
245
|
+
source: 'product-doc',
|
|
246
|
+
sourceFile: rel,
|
|
247
|
+
evidence: line,
|
|
248
|
+
confidence: Math.min(0.58, 0.36 + score / 12),
|
|
249
|
+
selectorStrategy: /输入|搜索|placeholder/i.test(line) ? 'placeholder' : 'text',
|
|
250
|
+
runtimeStatus: 'pending',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function collectProductDocs(root, page) {
|
|
256
|
+
if (page.productDocs.length) {
|
|
257
|
+
return page.productDocs.map((doc, index) => ({
|
|
258
|
+
path: doc.source,
|
|
259
|
+
score: 100 - index,
|
|
260
|
+
matchedTerms: doc.notesRef
|
|
261
|
+
? [doc.notesRef]
|
|
262
|
+
: [page.routeInfo.label, page.routeInfo.screenId, basename(page.entryFile, extname(page.entryFile))]
|
|
263
|
+
.filter((value) => Boolean(value)),
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
const files = [];
|
|
267
|
+
walk(root, files);
|
|
268
|
+
const strongTerms = dedupeBy([
|
|
269
|
+
page.routeInfo.label,
|
|
270
|
+
page.routeInfo.name,
|
|
271
|
+
page.routeInfo.path,
|
|
272
|
+
page.routeInfo.screenId,
|
|
273
|
+
basename(page.entryFile, extname(page.entryFile)),
|
|
274
|
+
].filter((v) => Boolean(v)), (term) => term.toLowerCase());
|
|
275
|
+
const weakTerms = dedupeBy([page.routeInfo.module].filter((v) => Boolean(v)), (term) => term.toLowerCase());
|
|
276
|
+
return files
|
|
277
|
+
.map((path) => {
|
|
278
|
+
const rel = relative(root, path);
|
|
279
|
+
const body = readLimited(path, MAX_DOC_BYTES).toLowerCase();
|
|
280
|
+
const lowerRel = rel.toLowerCase();
|
|
281
|
+
const strongMatches = strongTerms.filter((term) => {
|
|
282
|
+
const t = term.toLowerCase();
|
|
283
|
+
return body.includes(t) || lowerRel.includes(t);
|
|
284
|
+
});
|
|
285
|
+
const weakMatches = weakTerms.filter((term) => {
|
|
286
|
+
const t = term.toLowerCase();
|
|
287
|
+
return body.includes(t) || lowerRel.includes(t);
|
|
288
|
+
});
|
|
289
|
+
const matchedTerms = [...strongMatches, ...weakMatches];
|
|
290
|
+
const nameScore = /readme|docs?|prd|product|spec|需求|产品|功能|文档/i.test(rel) ? 2 : 0;
|
|
291
|
+
const score = strongMatches.length * 4 + weakMatches.length + nameScore;
|
|
292
|
+
return { path, score, matchedTerms };
|
|
293
|
+
})
|
|
294
|
+
.filter((item) => item.matchedTerms.some((term) => strongTerms.includes(term)))
|
|
295
|
+
.sort((a, b) => b.score - a.score)
|
|
296
|
+
.slice(0, MAX_DOC_FILES);
|
|
297
|
+
}
|
|
298
|
+
function walk(dir, out, depth = 0) {
|
|
299
|
+
if (depth > 5)
|
|
300
|
+
return;
|
|
301
|
+
let entries;
|
|
302
|
+
try {
|
|
303
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (entry.name.startsWith('.'))
|
|
310
|
+
continue;
|
|
311
|
+
if (['node_modules', 'dist', 'build', 'coverage', 'public', 'assets', 'images'].includes(entry.name))
|
|
312
|
+
continue;
|
|
313
|
+
const path = join(dir, entry.name);
|
|
314
|
+
if (entry.isDirectory()) {
|
|
315
|
+
walk(path, out, depth + 1);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!entry.isFile())
|
|
319
|
+
continue;
|
|
320
|
+
if (!['.md', '.mdx', '.txt'].includes(extname(entry.name).toLowerCase()))
|
|
321
|
+
continue;
|
|
322
|
+
out.push(path);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function writeFeatureCoverageFiles(ctx, coverage) {
|
|
326
|
+
writeFileSync(coverage.jsonPath, JSON.stringify(coverage, null, 2) + '\n', 'utf8');
|
|
327
|
+
writeFileSync(coverage.markdownPath, renderMarkdown(ctx, coverage), 'utf8');
|
|
328
|
+
}
|
|
329
|
+
function renderMarkdown(ctx, coverage) {
|
|
330
|
+
const lines = [];
|
|
331
|
+
lines.push(`# 功能覆盖清单 - ${coverage.pageName}`);
|
|
332
|
+
lines.push('');
|
|
333
|
+
lines.push('该文件是基于源码/文档生成的 agent 截图探索清单,本身不能证明视觉状态已覆盖。');
|
|
334
|
+
lines.push('');
|
|
335
|
+
lines.push(`- 页面目录名:\`${coverage.pageKey}\``);
|
|
336
|
+
lines.push(`- 已分析源码文件数:${coverage.sourceFiles.length}`);
|
|
337
|
+
lines.push(`- 匹配到的产品文档数:${coverage.docFiles.length}`);
|
|
338
|
+
lines.push(`- 功能数:${coverage.features.length}`);
|
|
339
|
+
lines.push(`- 预期状态数:${coverage.expectedStates.length}`);
|
|
340
|
+
lines.push(`- 预期交互数:${coverage.expectedInteractions.length}`);
|
|
341
|
+
lines.push(`- 已加入 agent 探索队列:${coverage.runtimeCoverage.queuedForAgentExploration.length}`);
|
|
342
|
+
lines.push('');
|
|
343
|
+
lines.push('## 运行态覆盖');
|
|
344
|
+
lines.push('');
|
|
345
|
+
lines.push(`- 已由 DOM 候选覆盖:${coverage.runtimeCoverage.coveredByDom.join(', ') || '(无)'}`);
|
|
346
|
+
lines.push(`- 已加入 agent 探索队列:${coverage.runtimeCoverage.queuedForAgentExploration.join(', ') || '(无)'}`);
|
|
347
|
+
lines.push(`- 仅源码/文档存在或未入队:${coverage.runtimeCoverage.notQueued.join(', ') || '(无)'}`);
|
|
348
|
+
lines.push(`- 仅运行态发现:${coverage.runtimeCoverage.discoveredOnlyByRuntime.join(', ') || '(无)'}`);
|
|
349
|
+
for (const note of coverage.runtimeCoverage.notes)
|
|
350
|
+
lines.push(`- 备注:${note}`);
|
|
351
|
+
lines.push('');
|
|
352
|
+
lines.push('## 预期交互');
|
|
353
|
+
lines.push('');
|
|
354
|
+
if (coverage.expectedInteractions.length === 0) {
|
|
355
|
+
lines.push('- (无)');
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
for (const item of coverage.expectedInteractions.slice(0, 80)) {
|
|
359
|
+
lines.push(`- [${item.runtimeStatus}] ${item.kind} · ${item.label} · ${item.sourceFile} · ${item.evidence}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push('## 预期状态');
|
|
364
|
+
lines.push('');
|
|
365
|
+
if (coverage.expectedStates.length === 0) {
|
|
366
|
+
lines.push('- (无)');
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
for (const state of coverage.expectedStates.slice(0, 80)) {
|
|
370
|
+
lines.push(`- ${state.kind} · ${state.label} · ${state.sourceFile}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
lines.push('');
|
|
374
|
+
lines.push('## 功能清单');
|
|
375
|
+
lines.push('');
|
|
376
|
+
if (coverage.features.length === 0) {
|
|
377
|
+
lines.push('- (无)');
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
for (const feature of coverage.features.slice(0, 120)) {
|
|
381
|
+
lines.push(`- ${feature.kind} · ${feature.label} · ${feature.sourceFile}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
lines.push('');
|
|
385
|
+
lines.push('## 源码文件');
|
|
386
|
+
lines.push('');
|
|
387
|
+
for (const file of coverage.sourceFiles.slice(0, 60)) {
|
|
388
|
+
lines.push(`- \`${file.path}\` · 组件=${file.components.slice(0, 16).join(', ') || '(无)'}`);
|
|
389
|
+
}
|
|
390
|
+
lines.push('');
|
|
391
|
+
if (coverage.docFiles.length) {
|
|
392
|
+
lines.push('## 产品文档');
|
|
393
|
+
lines.push('');
|
|
394
|
+
for (const file of coverage.docFiles) {
|
|
395
|
+
lines.push(`- \`${file.path}\` · 分数=${file.score} · 命中=${file.matchedTerms.join(', ') || '文件名'}`);
|
|
396
|
+
}
|
|
397
|
+
lines.push('');
|
|
398
|
+
}
|
|
399
|
+
lines.push('## 使用方式');
|
|
400
|
+
lines.push('');
|
|
401
|
+
lines.push(`- agent 截图探索前先检查 \`${relative(ctx.taskDir, coverage.markdownPath)}\`。`);
|
|
402
|
+
lines.push('- 如果这里列出了某个功能或状态,agent 应尝试在 live prototype 中到达它,或记录无法到达的原因。');
|
|
403
|
+
lines.push('');
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
|
406
|
+
function toCandidateInteraction(item, seq) {
|
|
407
|
+
const encoded = encodeURIComponent(item.label);
|
|
408
|
+
return {
|
|
409
|
+
id: `src-${seq}`,
|
|
410
|
+
kind: item.kind,
|
|
411
|
+
selector: item.selectorStrategy === 'placeholder' ? `placeholder=${encoded}` : `text=${encoded}`,
|
|
412
|
+
label: item.label,
|
|
413
|
+
description: `源码/文档预期交互:${item.evidence}`,
|
|
414
|
+
confidence: item.confidence,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function extractTemplate(body) {
|
|
418
|
+
return /<template[^>]*>([\s\S]*?)<\/template>/i.exec(body)?.[1] ?? '';
|
|
419
|
+
}
|
|
420
|
+
function extractComponents(body) {
|
|
421
|
+
const out = new Set();
|
|
422
|
+
const re = /<([A-Za-z][\w.-]*)(?=[\s>/])/g;
|
|
423
|
+
let m;
|
|
424
|
+
while ((m = re.exec(body)) !== null) {
|
|
425
|
+
const tag = m[1];
|
|
426
|
+
if (/^(div|span|template|script|style|p|ul|li|section|main|header|footer|img|svg|path|button|input|circle|text|rect|line|g|polyline|polygon|defs|clipPath|linearGradient|stop)$/i.test(tag)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
out.add(tag);
|
|
430
|
+
}
|
|
431
|
+
return [...out].slice(0, 80);
|
|
432
|
+
}
|
|
433
|
+
function featureFromComponent(component) {
|
|
434
|
+
const c = component.toLowerCase();
|
|
435
|
+
if (/tab|segment|switch/.test(c))
|
|
436
|
+
return { kind: 'tab', label: component };
|
|
437
|
+
if (/popup|dialog|modal|sheet|drawer/.test(c))
|
|
438
|
+
return { kind: 'dialog', label: component };
|
|
439
|
+
if (/picker|dropdown|select|calendar|date|filter/.test(c))
|
|
440
|
+
return { kind: 'picker', label: component };
|
|
441
|
+
if (/field|input|search|textarea|form/.test(c))
|
|
442
|
+
return { kind: 'form', label: component };
|
|
443
|
+
if (/list|cell|table|row|item/.test(c))
|
|
444
|
+
return { kind: 'list', label: component };
|
|
445
|
+
if (/chart|echart|kline|graph/.test(c))
|
|
446
|
+
return { kind: 'chart', label: component };
|
|
447
|
+
if (/collapse|expand|accordion/.test(c))
|
|
448
|
+
return { kind: 'expandable', label: component };
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
function extractLabels(body) {
|
|
452
|
+
const labels = [];
|
|
453
|
+
const attr = /(?:label|title|placeholder|name|text|button-text|confirm-button-text|cancel-button-text)\s*=\s*(['"])([^'"<>]{1,80})\1/g;
|
|
454
|
+
const boundAttr = /:(?:label|title|placeholder|name|text)\s*=\s*(['"])['"`]([^'"`<>]{1,80})['"`]\1/g;
|
|
455
|
+
const textNode = />\s*([^<>{}\n][^<>{}\n]{0,80})\s*</g;
|
|
456
|
+
for (const re of [attr, boundAttr, textNode]) {
|
|
457
|
+
let m;
|
|
458
|
+
while ((m = re.exec(body)) !== null) {
|
|
459
|
+
const label = cleanLabel(m[2] ?? m[1] ?? '');
|
|
460
|
+
if (isHumanLabel(label))
|
|
461
|
+
labels.push(label);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return unique(labels);
|
|
465
|
+
}
|
|
466
|
+
function extractTabLabels(body) {
|
|
467
|
+
const labels = [];
|
|
468
|
+
const tabTag = /<(?:van-tab|[A-Za-z][\w.-]*(?:Tab|Segment|Switch)[\w.-]*)\b[^>]*>/g;
|
|
469
|
+
let m;
|
|
470
|
+
while ((m = tabTag.exec(body)) !== null) {
|
|
471
|
+
labels.push(...extractLabels(m[0]));
|
|
472
|
+
}
|
|
473
|
+
return unique(labels).slice(0, 40);
|
|
474
|
+
}
|
|
475
|
+
function extractEventHandlers(body) {
|
|
476
|
+
const out = [];
|
|
477
|
+
const tagRe = /<([A-Za-z][\w.-]*)([^>]*?(?:@|v-on:)(?:click|change|select|confirm|cancel|focus|submit|input|open|close)[^>]*)>([\s\S]{0,240}?)(?:<\/\1>)?/g;
|
|
478
|
+
let m;
|
|
479
|
+
while ((m = tagRe.exec(body)) !== null) {
|
|
480
|
+
const snippet = m[0].slice(0, 500);
|
|
481
|
+
const eventRe = /(?:@|v-on:)(click|change|select|confirm|cancel|focus|submit|input|open|close)(?:\.[\w.-]+)?\s*=\s*(['"])(.*?)\2/g;
|
|
482
|
+
let e;
|
|
483
|
+
while ((e = eventRe.exec(snippet)) !== null) {
|
|
484
|
+
out.push({
|
|
485
|
+
event: e[1],
|
|
486
|
+
handler: cleanHandler(e[3]),
|
|
487
|
+
snippet: compact(snippet),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return out.slice(0, 120);
|
|
492
|
+
}
|
|
493
|
+
function extractSourceStates(body) {
|
|
494
|
+
const defs = [
|
|
495
|
+
{ kind: 'loading', re: /loading|加载中|骨架屏|skeleton/i, label: 'loading' },
|
|
496
|
+
{ kind: 'empty', re: /empty|noData|no-data|暂无|无数据|空状态/i, label: 'empty' },
|
|
497
|
+
{ kind: 'error', re: /error|failed|异常|错误|失败/i, label: 'error' },
|
|
498
|
+
{ kind: 'disabled', re: /disabled|禁用|不可用/i, label: 'disabled' },
|
|
499
|
+
{ kind: 'auth', re: /login|auth|permission|登录|权限|认证/i, label: 'auth' },
|
|
500
|
+
{ kind: 'expanded', re: /expanded|展开|showMore|moreVisible/i, label: 'expanded' },
|
|
501
|
+
{ kind: 'collapsed', re: /collapsed|收起|fold/i, label: 'collapsed' },
|
|
502
|
+
{ kind: 'success', re: /success|成功|完成/i, label: 'success' },
|
|
503
|
+
{ kind: 'failure', re: /fail|失败|拒绝/i, label: 'failure' },
|
|
504
|
+
];
|
|
505
|
+
const out = [];
|
|
506
|
+
for (const def of defs) {
|
|
507
|
+
const m = def.re.exec(body);
|
|
508
|
+
if (!m)
|
|
509
|
+
continue;
|
|
510
|
+
out.push({
|
|
511
|
+
kind: def.kind,
|
|
512
|
+
label: def.label,
|
|
513
|
+
evidence: compact(body.slice(Math.max(0, m.index - 80), m.index + 120)),
|
|
514
|
+
confidence: 0.58,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
function pickEventLabel(snippet, handler) {
|
|
520
|
+
const labels = extractLabels(snippet);
|
|
521
|
+
if (labels.length)
|
|
522
|
+
return labels[0];
|
|
523
|
+
void handler;
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
function extractActionLabelFromDocLine(line) {
|
|
527
|
+
const quoted = /[`"“”']([^`"“”']{1,40})[`"“”']/.exec(line);
|
|
528
|
+
if (quoted && isHumanLabel(quoted[1]))
|
|
529
|
+
return cleanLabel(quoted[1]);
|
|
530
|
+
const button = /([\u4e00-\u9fa5A-Za-z0-9 /\-]{1,24})(?:按钮|入口|tab|Tab|筛选项|选项)/.exec(line);
|
|
531
|
+
if (button && isHumanLabel(button[1]))
|
|
532
|
+
return cleanLabel(button[1]);
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
function inferInteractionKind(input) {
|
|
536
|
+
const text = `${input.label} ${input.evidence} ${input.event ?? ''} ${input.handler ?? ''}`.toLowerCase();
|
|
537
|
+
if (/tab|segment|switch|切换|标签/.test(text))
|
|
538
|
+
return 'tab-change';
|
|
539
|
+
if (/input|focus|search|输入|搜索/.test(text))
|
|
540
|
+
return 'input-focus';
|
|
541
|
+
if (/picker|dropdown|select|filter|calendar|date|筛选|选择|下拉|排序/.test(text))
|
|
542
|
+
return 'dropdown';
|
|
543
|
+
if (/expand|collapse|more|展开|收起|更多/.test(text))
|
|
544
|
+
return 'expand-toggle';
|
|
545
|
+
if (/route|router|navigate|href|detail|go|to|详情|跳转|导航/.test(text))
|
|
546
|
+
return 'nav-link';
|
|
547
|
+
return 'dialog-trigger';
|
|
548
|
+
}
|
|
549
|
+
function inferFeatureKind(text) {
|
|
550
|
+
if (/tab|切换|标签|分段/i.test(text))
|
|
551
|
+
return 'tab';
|
|
552
|
+
if (/筛选|过滤|filter/i.test(text))
|
|
553
|
+
return 'filter';
|
|
554
|
+
if (/输入|搜索|表单|input|search|form/i.test(text))
|
|
555
|
+
return 'form';
|
|
556
|
+
if (/列表|明细|记录|list|table/i.test(text))
|
|
557
|
+
return 'list';
|
|
558
|
+
if (/空态|暂无|无数据|empty/i.test(text))
|
|
559
|
+
return 'empty-state';
|
|
560
|
+
if (/加载|loading|skeleton/i.test(text))
|
|
561
|
+
return 'loading-state';
|
|
562
|
+
if (/错误|异常|error/i.test(text))
|
|
563
|
+
return 'error-state';
|
|
564
|
+
if (/弹窗|浮层|抽屉|dialog|modal|popup|sheet/i.test(text))
|
|
565
|
+
return 'dialog';
|
|
566
|
+
if (/选择|下拉|picker|select|dropdown/i.test(text))
|
|
567
|
+
return 'picker';
|
|
568
|
+
if (/图表|chart|kline/i.test(text))
|
|
569
|
+
return 'chart';
|
|
570
|
+
if (/展开|收起|更多|expand|collapse/i.test(text))
|
|
571
|
+
return 'expandable';
|
|
572
|
+
if (/跳转|导航|入口|route|link/i.test(text))
|
|
573
|
+
return 'navigation';
|
|
574
|
+
return 'unknown';
|
|
575
|
+
}
|
|
576
|
+
function stateFromText(line) {
|
|
577
|
+
const map = [
|
|
578
|
+
{ kind: 'loading', re: /加载|loading|skeleton/i, label: 'loading' },
|
|
579
|
+
{ kind: 'empty', re: /空态|暂无|无数据|empty/i, label: 'empty' },
|
|
580
|
+
{ kind: 'error', re: /错误|异常|error/i, label: 'error' },
|
|
581
|
+
{ kind: 'disabled', re: /禁用|disabled/i, label: 'disabled' },
|
|
582
|
+
{ kind: 'auth', re: /登录|权限|auth|login/i, label: 'auth' },
|
|
583
|
+
];
|
|
584
|
+
const hit = map.find((item) => item.re.test(line));
|
|
585
|
+
if (!hit)
|
|
586
|
+
return null;
|
|
587
|
+
return {
|
|
588
|
+
kind: hit.kind,
|
|
589
|
+
label: hit.label,
|
|
590
|
+
evidence: line,
|
|
591
|
+
confidence: 0.5,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function dedupeFeatures(items) {
|
|
595
|
+
return dedupeBy(items, (i) => `${i.kind}:${normalizeLabel(i.label)}:${i.sourceFile}`)
|
|
596
|
+
.map((item, idx) => ({ ...item, id: `f${idx + 1}` }))
|
|
597
|
+
.slice(0, 160);
|
|
598
|
+
}
|
|
599
|
+
function dedupeStates(items) {
|
|
600
|
+
return dedupeBy(items, (i) => `${i.kind}:${normalizeLabel(i.label)}:${i.sourceFile}`)
|
|
601
|
+
.map((item, idx) => ({ ...item, id: `s${idx + 1}` }))
|
|
602
|
+
.slice(0, 80);
|
|
603
|
+
}
|
|
604
|
+
function dedupeInteractions(items) {
|
|
605
|
+
return dedupeBy(items, (i) => `${i.kind}:${normalizeLabel(i.label)}`)
|
|
606
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
607
|
+
.map((item, idx) => ({ ...item, id: `i${idx + 1}` }))
|
|
608
|
+
.slice(0, 80);
|
|
609
|
+
}
|
|
610
|
+
function dedupeBy(items, keyOf) {
|
|
611
|
+
const out = [];
|
|
612
|
+
const seen = new Set();
|
|
613
|
+
for (const item of items) {
|
|
614
|
+
const key = keyOf(item);
|
|
615
|
+
if (!key || seen.has(key))
|
|
616
|
+
continue;
|
|
617
|
+
seen.add(key);
|
|
618
|
+
out.push(item);
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
function readLimited(path, maxBytes) {
|
|
623
|
+
const buf = readFileSync(path);
|
|
624
|
+
return buf.byteLength > maxBytes ? buf.subarray(0, maxBytes).toString('utf8') : buf.toString('utf8');
|
|
625
|
+
}
|
|
626
|
+
function cleanHandler(value) {
|
|
627
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
628
|
+
}
|
|
629
|
+
function cleanLabel(value) {
|
|
630
|
+
return value
|
|
631
|
+
.replace(/ /g, ' ')
|
|
632
|
+
.replace(/\s+/g, ' ')
|
|
633
|
+
.replace(/^[-*•\d.、\s]+/, '')
|
|
634
|
+
.trim();
|
|
635
|
+
}
|
|
636
|
+
function compact(value) {
|
|
637
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
638
|
+
}
|
|
639
|
+
function isHumanLabel(value) {
|
|
640
|
+
const label = cleanLabel(value);
|
|
641
|
+
if (label.length < 1 || label.length > 60)
|
|
642
|
+
return false;
|
|
643
|
+
if (/^[{}[\]().,:;|/\\]+$/.test(label))
|
|
644
|
+
return false;
|
|
645
|
+
if (/^\w+\.\w+/.test(label))
|
|
646
|
+
return false;
|
|
647
|
+
if (/^(true|false|null|undefined|class|style|src|href|key)$/i.test(label))
|
|
648
|
+
return false;
|
|
649
|
+
return /[\u4e00-\u9fa5A-Za-z0-9]/.test(label);
|
|
650
|
+
}
|
|
651
|
+
function normalizeLabel(value) {
|
|
652
|
+
return cleanLabel(value).toLowerCase();
|
|
653
|
+
}
|
|
654
|
+
function isSafeExplorationLabel(value) {
|
|
655
|
+
const label = cleanLabel(value);
|
|
656
|
+
if (!isHumanLabel(label))
|
|
657
|
+
return false;
|
|
658
|
+
if (label.includes('{{') || label.includes('}}'))
|
|
659
|
+
return false;
|
|
660
|
+
if (/^\/|https?:|\.vue$|\.js$|\.ts$/i.test(label))
|
|
661
|
+
return false;
|
|
662
|
+
return label.length <= 40;
|
|
663
|
+
}
|
|
664
|
+
function unique(values) {
|
|
665
|
+
return dedupeBy(values.map(cleanLabel).filter(isHumanLabel), (v) => normalizeLabel(v));
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=feature-coverage-builder.js.map
|