@cassiomc1/forgeloop 1.11.0 → 1.12.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/DOCS_INDEX.md +1 -1
- package/ENG/flutter-development-eng.md +2106 -0
- package/GUIDE_ROUTER.md +46 -4
- package/LOOP_SYSTEM_DESIGN.md +10 -6
- package/QUALITY_SCORECARD.md +1 -0
- package/README.md +15 -14
- package/docs/AGENT_PROTOCOL_SUMMARY.md +1 -1
- package/docs/CLI_REFERENCE.md +1 -1
- package/docs/MCP.md +1 -1
- package/docs/PACKAGE_CONTENTS.md +8 -1
- package/docs/RELEASE_CHECKLIST.md +22 -8
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +19 -6
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5 -5
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +12 -2
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +3 -3
- package/package.json +1 -1
- package/schemas/routing-input.schema.json +14 -1
- package/src/commands/route.js +5 -0
- package/src/config/guides.json +4 -0
- package/src/core/project-detection.js +519 -0
- package/src/core/router.js +80 -7
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const PROJECT_EVIDENCE_SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const PROJECT_EVIDENCE_SCOPES = Object.freeze([
|
|
7
|
+
"MATCH",
|
|
8
|
+
"UNSCOPED",
|
|
9
|
+
"NO_MATCH",
|
|
10
|
+
"NONE",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const MAX_MANIFESTS = 256;
|
|
14
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_SOURCE_FILES = 256;
|
|
16
|
+
const MAX_SOURCE_BYTES = 512 * 1024;
|
|
17
|
+
const UNSUPPORTED_YAML_VALUE = Symbol("unsupported-yaml-value");
|
|
18
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
19
|
+
".dart_tool",
|
|
20
|
+
".forgeloop",
|
|
21
|
+
".git",
|
|
22
|
+
".idea",
|
|
23
|
+
".vscode",
|
|
24
|
+
"build",
|
|
25
|
+
"coverage",
|
|
26
|
+
"dist",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"out",
|
|
29
|
+
"vendor",
|
|
30
|
+
]);
|
|
31
|
+
const PLATFORM_DIRECTORIES = Object.freeze([
|
|
32
|
+
"android",
|
|
33
|
+
"ios",
|
|
34
|
+
"linux",
|
|
35
|
+
"macos",
|
|
36
|
+
"web",
|
|
37
|
+
"windows",
|
|
38
|
+
]);
|
|
39
|
+
const SOURCE_DIRECTORIES = Object.freeze([
|
|
40
|
+
"integration_test",
|
|
41
|
+
"lib",
|
|
42
|
+
"test",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function portableRelative(root, absolutePath) {
|
|
46
|
+
const relative = path.relative(root, absolutePath).split(path.sep).join("/");
|
|
47
|
+
return relative === "" ? "." : relative;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function uniqueSorted(values) {
|
|
51
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))]
|
|
52
|
+
.sort((left, right) => left.localeCompare(right));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripYamlComment(value) {
|
|
56
|
+
let quote = null;
|
|
57
|
+
let braceDepth = 0;
|
|
58
|
+
let bracketDepth = 0;
|
|
59
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
60
|
+
const character = value[index];
|
|
61
|
+
if (quote === "\"") {
|
|
62
|
+
if (character === "\\") index += 1;
|
|
63
|
+
else if (character === "\"") quote = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (quote === "'") {
|
|
67
|
+
if (character === "'" && value[index + 1] === "'") index += 1;
|
|
68
|
+
else if (character === "'") quote = null;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (character === "\"" || character === "'") {
|
|
72
|
+
quote = character;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (character === "{") braceDepth += 1;
|
|
76
|
+
else if (character === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
77
|
+
else if (character === "[") bracketDepth += 1;
|
|
78
|
+
else if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
|
79
|
+
else if (character === "#" && braceDepth === 0 && bracketDepth === 0
|
|
80
|
+
&& (index === 0 || /\s/u.test(value[index - 1]))) {
|
|
81
|
+
return value.slice(0, index).trimEnd();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return value.trimEnd();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findTopLevelColon(value) {
|
|
88
|
+
let quote = null;
|
|
89
|
+
let braceDepth = 0;
|
|
90
|
+
let bracketDepth = 0;
|
|
91
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
92
|
+
const character = value[index];
|
|
93
|
+
if (quote === "\"") {
|
|
94
|
+
if (character === "\\") index += 1;
|
|
95
|
+
else if (character === "\"") quote = null;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (quote === "'") {
|
|
99
|
+
if (character === "'" && value[index + 1] === "'") index += 1;
|
|
100
|
+
else if (character === "'") quote = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (character === "\"" || character === "'") {
|
|
104
|
+
quote = character;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (character === "{") braceDepth += 1;
|
|
108
|
+
else if (character === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
109
|
+
else if (character === "[") bracketDepth += 1;
|
|
110
|
+
else if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
|
111
|
+
else if (character === ":" && braceDepth === 0 && bracketDepth === 0) return index;
|
|
112
|
+
}
|
|
113
|
+
return -1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function unquoteYamlScalar(value) {
|
|
117
|
+
const trimmed = value.trim();
|
|
118
|
+
if (trimmed.length < 2) return trimmed;
|
|
119
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(trimmed);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
127
|
+
return trimmed.slice(1, -1).replaceAll("''", "'");
|
|
128
|
+
}
|
|
129
|
+
return trimmed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function splitTopLevel(value, delimiter = ",") {
|
|
133
|
+
const parts = [];
|
|
134
|
+
let start = 0;
|
|
135
|
+
let quote = null;
|
|
136
|
+
let braceDepth = 0;
|
|
137
|
+
let bracketDepth = 0;
|
|
138
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
139
|
+
const character = value[index];
|
|
140
|
+
if (quote === "\"") {
|
|
141
|
+
if (character === "\\") index += 1;
|
|
142
|
+
else if (character === "\"") quote = null;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (quote === "'") {
|
|
146
|
+
if (character === "'" && value[index + 1] === "'") index += 1;
|
|
147
|
+
else if (character === "'") quote = null;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (character === "\"" || character === "'") {
|
|
151
|
+
quote = character;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (character === "{") braceDepth += 1;
|
|
155
|
+
else if (character === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
156
|
+
else if (character === "[") bracketDepth += 1;
|
|
157
|
+
else if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
|
158
|
+
else if (character === delimiter && braceDepth === 0 && bracketDepth === 0) {
|
|
159
|
+
parts.push(value.slice(start, index).trim());
|
|
160
|
+
start = index + 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
parts.push(value.slice(start).trim());
|
|
164
|
+
return parts.filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseInlineMap(value) {
|
|
168
|
+
const trimmed = value.trim();
|
|
169
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
|
170
|
+
const body = trimmed.slice(1, -1).trim();
|
|
171
|
+
if (body === "") return {};
|
|
172
|
+
const result = {};
|
|
173
|
+
for (const pair of splitTopLevel(body)) {
|
|
174
|
+
const separator = findTopLevelColon(pair);
|
|
175
|
+
if (separator <= 0) return null;
|
|
176
|
+
const key = unquoteYamlScalar(pair.slice(0, separator));
|
|
177
|
+
const rawValue = pair.slice(separator + 1).trim();
|
|
178
|
+
if (!key || rawValue === "" || Object.prototype.hasOwnProperty.call(result, key)) return null;
|
|
179
|
+
const parsedValue = parseYamlValue(rawValue);
|
|
180
|
+
if (parsedValue === null) return null;
|
|
181
|
+
result[key] = parsedValue;
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseYamlValue(value) {
|
|
187
|
+
const withoutComment = stripYamlComment(value).trim();
|
|
188
|
+
if (withoutComment === "") return "";
|
|
189
|
+
if (withoutComment.startsWith("{")) {
|
|
190
|
+
return withoutComment.endsWith("}") ? parseInlineMap(withoutComment) : null;
|
|
191
|
+
}
|
|
192
|
+
if (withoutComment.startsWith("[")) return UNSUPPORTED_YAML_VALUE;
|
|
193
|
+
return unquoteYamlScalar(withoutComment);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function containsUnsupportedYamlValue(value) {
|
|
197
|
+
if (value === UNSUPPORTED_YAML_VALUE) return true;
|
|
198
|
+
if (!value || typeof value !== "object") return false;
|
|
199
|
+
return Object.values(value).some(containsUnsupportedYamlValue);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseYamlLine(line) {
|
|
203
|
+
if (/^\t/u.test(line)) return { invalid: true };
|
|
204
|
+
const indent = line.match(/^ */u)?.[0].length ?? 0;
|
|
205
|
+
const content = stripYamlComment(line.slice(indent)).trim();
|
|
206
|
+
if (content === "" || content === "---" || content === "..." || content.startsWith("#")) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (content.startsWith("- ") || content === "-") return { indent, sequence: true };
|
|
210
|
+
const separator = findTopLevelColon(content);
|
|
211
|
+
if (separator <= 0) return null;
|
|
212
|
+
const key = unquoteYamlScalar(content.slice(0, separator));
|
|
213
|
+
if (!key) return null;
|
|
214
|
+
const rawValue = content.slice(separator + 1).trim();
|
|
215
|
+
const value = rawValue === "" ? "" : parseYamlValue(rawValue);
|
|
216
|
+
if (value === null) return { invalid: true };
|
|
217
|
+
return { indent, key, value };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function dependencySdkValue(entry) {
|
|
221
|
+
if (!entry) return null;
|
|
222
|
+
if (entry.value && typeof entry.value === "object" && !Array.isArray(entry.value)) {
|
|
223
|
+
return entry.value.sdk === "flutter" ? "flutter" : null;
|
|
224
|
+
}
|
|
225
|
+
return entry.value === "" ? null : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseYamlLines(lines) {
|
|
229
|
+
const parsedLines = [];
|
|
230
|
+
let valid = true;
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
const parsed = parseYamlLine(line);
|
|
233
|
+
if (parsed?.invalid) valid = false;
|
|
234
|
+
parsedLines.push(parsed);
|
|
235
|
+
}
|
|
236
|
+
return { parsedLines, valid };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function findSectionIndexes(parsedLines, sectionName) {
|
|
240
|
+
const sectionIndexes = [];
|
|
241
|
+
for (let index = 0; index < parsedLines.length; index += 1) {
|
|
242
|
+
const parsed = parsedLines[index];
|
|
243
|
+
if (parsed?.indent === 0 && parsed.key === sectionName) sectionIndexes.push(index);
|
|
244
|
+
}
|
|
245
|
+
return sectionIndexes;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function dependencyEntriesFromInline(value) {
|
|
249
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
250
|
+
return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, { value: entryValue }]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findSectionIndent(parsedLines, sectionIndex) {
|
|
254
|
+
for (let index = sectionIndex + 1; index < parsedLines.length; index += 1) {
|
|
255
|
+
const parsed = parsedLines[index];
|
|
256
|
+
if (!parsed) continue;
|
|
257
|
+
if (parsed.indent === 0) break;
|
|
258
|
+
return parsed.indent;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readNestedDependencyMap(parsedLines, entryIndex, parentIndent) {
|
|
264
|
+
let nestedIndent = null;
|
|
265
|
+
const nested = {};
|
|
266
|
+
for (let index = entryIndex + 1; index < parsedLines.length; index += 1) {
|
|
267
|
+
const parsed = parsedLines[index];
|
|
268
|
+
if (!parsed) continue;
|
|
269
|
+
if (parsed.invalid) return null;
|
|
270
|
+
if (parsed.sequence) {
|
|
271
|
+
if (parsed.indent > parentIndent) return null;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (parsed.indent <= parentIndent) break;
|
|
275
|
+
if (containsUnsupportedYamlValue(parsed.value)) return null;
|
|
276
|
+
nestedIndent ??= parsed.indent;
|
|
277
|
+
if (parsed.indent !== nestedIndent) continue;
|
|
278
|
+
if (Object.prototype.hasOwnProperty.call(nested, parsed.key)) return null;
|
|
279
|
+
nested[parsed.key] = parsed.value;
|
|
280
|
+
}
|
|
281
|
+
return nested;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readBlockDependencyEntries(parsedLines, sectionIndex, sectionIndent) {
|
|
285
|
+
const entries = {};
|
|
286
|
+
for (let index = sectionIndex + 1; index < parsedLines.length; index += 1) {
|
|
287
|
+
const parsed = parsedLines[index];
|
|
288
|
+
if (!parsed) continue;
|
|
289
|
+
if (parsed.invalid) return null;
|
|
290
|
+
if (parsed.indent === 0) break;
|
|
291
|
+
if (parsed.sequence || containsUnsupportedYamlValue(parsed.value)) return null;
|
|
292
|
+
if (parsed.indent !== sectionIndent) continue;
|
|
293
|
+
if (Object.prototype.hasOwnProperty.call(entries, parsed.key)) return null;
|
|
294
|
+
const entry = { value: parsed.value };
|
|
295
|
+
if (parsed.value === "") {
|
|
296
|
+
const nested = readNestedDependencyMap(parsedLines, index, parsed.indent);
|
|
297
|
+
if (nested === null) return null;
|
|
298
|
+
entry.value = nested;
|
|
299
|
+
}
|
|
300
|
+
entries[parsed.key] = entry;
|
|
301
|
+
}
|
|
302
|
+
return entries;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readDependencySection(lines, sectionName) {
|
|
306
|
+
const { parsedLines, valid } = parseYamlLines(lines);
|
|
307
|
+
const sectionIndexes = findSectionIndexes(parsedLines, sectionName);
|
|
308
|
+
if (sectionIndexes.length > 1) return { present: true, valid: false, entries: {} };
|
|
309
|
+
if (sectionIndexes.length === 0) return { present: false, valid, entries: {} };
|
|
310
|
+
|
|
311
|
+
const sectionIndex = sectionIndexes[0];
|
|
312
|
+
const section = parsedLines[sectionIndex];
|
|
313
|
+
const entries = {};
|
|
314
|
+
if (section.value !== "") {
|
|
315
|
+
if (containsUnsupportedYamlValue(section.value)) {
|
|
316
|
+
return { present: true, valid: false, entries: {} };
|
|
317
|
+
}
|
|
318
|
+
const inlineEntries = dependencyEntriesFromInline(section.value);
|
|
319
|
+
if (inlineEntries === null) {
|
|
320
|
+
return { present: true, valid: false, entries: {} };
|
|
321
|
+
}
|
|
322
|
+
return { present: true, valid, entries: inlineEntries };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const sectionIndent = findSectionIndent(parsedLines, sectionIndex);
|
|
326
|
+
if (sectionIndent === null) return { present: true, valid, entries };
|
|
327
|
+
const blockEntries = readBlockDependencyEntries(parsedLines, sectionIndex, sectionIndent);
|
|
328
|
+
if (blockEntries === null) return { present: true, valid: false, entries: {} };
|
|
329
|
+
return { present: true, valid, entries: blockEntries };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function parsePubspec(text) {
|
|
333
|
+
if (typeof text !== "string" || text.length > MAX_MANIFEST_BYTES || /\r(?!\n)/u.test(text)) {
|
|
334
|
+
return { valid: false, primary: false, flutterTest: false, override: false };
|
|
335
|
+
}
|
|
336
|
+
const lines = text.replaceAll("\r\n", "\n").split("\n");
|
|
337
|
+
const dependencies = readDependencySection(lines, "dependencies");
|
|
338
|
+
const devDependencies = readDependencySection(lines, "dev_dependencies");
|
|
339
|
+
const dependencyOverrides = readDependencySection(lines, "dependency_overrides");
|
|
340
|
+
const valid = dependencies.valid && devDependencies.valid && dependencyOverrides.valid;
|
|
341
|
+
const primary = valid && dependencySdkValue(dependencies.entries.flutter) === "flutter";
|
|
342
|
+
const flutterTest = valid && dependencySdkValue(devDependencies.entries.flutter_test) === "flutter";
|
|
343
|
+
const override = valid && dependencySdkValue(dependencyOverrides.entries.flutter) === "flutter";
|
|
344
|
+
return {
|
|
345
|
+
valid,
|
|
346
|
+
primary,
|
|
347
|
+
flutterTest,
|
|
348
|
+
override,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function findPubspecFiles(root) {
|
|
353
|
+
const result = [];
|
|
354
|
+
async function visit(directory) {
|
|
355
|
+
if (result.length >= MAX_MANIFESTS) return;
|
|
356
|
+
let entries;
|
|
357
|
+
try {
|
|
358
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
359
|
+
} catch {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (result.length >= MAX_MANIFESTS) return;
|
|
365
|
+
const absolutePath = path.join(directory, entry.name);
|
|
366
|
+
if (entry.isSymbolicLink()) continue;
|
|
367
|
+
if (entry.isDirectory()) {
|
|
368
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) await visit(absolutePath);
|
|
369
|
+
} else if (entry.isFile() && entry.name === "pubspec.yaml") {
|
|
370
|
+
result.push(absolutePath);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
await visit(root);
|
|
375
|
+
return result.sort((left, right) => portableRelative(root, left).localeCompare(portableRelative(root, right)));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function readBounded(filePath, maxBytes) {
|
|
379
|
+
try {
|
|
380
|
+
const bytes = await readFile(filePath);
|
|
381
|
+
if (bytes.length > maxBytes) return null;
|
|
382
|
+
return bytes.toString("utf8");
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function directDirectoryNames(directory) {
|
|
389
|
+
try {
|
|
390
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
391
|
+
return new Set(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
392
|
+
} catch {
|
|
393
|
+
return new Set();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function findDartFiles(root) {
|
|
398
|
+
const result = [];
|
|
399
|
+
async function visit(directory) {
|
|
400
|
+
if (result.length >= MAX_SOURCE_FILES) return;
|
|
401
|
+
let entries;
|
|
402
|
+
try {
|
|
403
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
404
|
+
} catch {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
408
|
+
for (const entry of entries) {
|
|
409
|
+
if (result.length >= MAX_SOURCE_FILES) return;
|
|
410
|
+
if (entry.isSymbolicLink()) continue;
|
|
411
|
+
const absolutePath = path.join(directory, entry.name);
|
|
412
|
+
if (entry.isDirectory()) {
|
|
413
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) await visit(absolutePath);
|
|
414
|
+
} else if (entry.isFile() && entry.name.endsWith(".dart")) {
|
|
415
|
+
result.push(absolutePath);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
await visit(root);
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function hasFlutterImport(projectRoot) {
|
|
424
|
+
for (const directory of SOURCE_DIRECTORIES) {
|
|
425
|
+
const files = await findDartFiles(path.join(projectRoot, directory));
|
|
426
|
+
for (const filePath of files) {
|
|
427
|
+
const text = await readBounded(filePath, MAX_SOURCE_BYTES);
|
|
428
|
+
if (text && /^\s*(?:import|export)\s+["']package:flutter\//mu.test(text)) return true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function normalizeClaim(value) {
|
|
435
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
436
|
+
const replaced = value.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
437
|
+
if (replaced === "." || replaced === "") return ".";
|
|
438
|
+
if (replaced.startsWith("../") || replaced.startsWith("/")) return null;
|
|
439
|
+
return path.posix.normalize(replaced).replace(/^\.\//u, "").toLowerCase();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function claimMatchesProject(claim, projectRoot, projectRoots) {
|
|
443
|
+
if (claim === ".") return true;
|
|
444
|
+
const root = projectRoot.toLowerCase();
|
|
445
|
+
if (root === ".") {
|
|
446
|
+
const nestedRoots = projectRoots
|
|
447
|
+
.map((candidate) => candidate.toLowerCase())
|
|
448
|
+
.filter((candidate) => candidate !== ".");
|
|
449
|
+
// A root project owns claims outside confirmed nested project boundaries.
|
|
450
|
+
return !nestedRoots.some((candidate) => claim === candidate || claim.startsWith(`${candidate}/`));
|
|
451
|
+
}
|
|
452
|
+
return claim === root || claim.startsWith(`${root}/`) || root.startsWith(`${claim}/`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function inspectProject(root, manifestPath, targetRoot) {
|
|
456
|
+
const projectRootPath = path.dirname(manifestPath);
|
|
457
|
+
const projectRoot = portableRelative(targetRoot, projectRootPath);
|
|
458
|
+
const manifestRelative = portableRelative(targetRoot, manifestPath);
|
|
459
|
+
const manifestText = await readBounded(manifestPath, MAX_MANIFEST_BYTES);
|
|
460
|
+
const parsed = parsePubspec(manifestText ?? "");
|
|
461
|
+
const primarySignals = parsed.primary ? [`${manifestRelative}:dependencies.flutter.sdk`] : [];
|
|
462
|
+
const supportingSignals = [];
|
|
463
|
+
if (parsed.flutterTest) supportingSignals.push(`${manifestRelative}:dev_dependencies.flutter_test.sdk`);
|
|
464
|
+
if (parsed.override) supportingSignals.push(`${manifestRelative}:dependency_overrides.flutter.sdk`);
|
|
465
|
+
|
|
466
|
+
const metadataText = await readBounded(path.join(projectRootPath, ".metadata"), MAX_SOURCE_BYTES);
|
|
467
|
+
if (metadataText && /^project_type:\s*[^\s#]+/mu.test(metadataText)) {
|
|
468
|
+
supportingSignals.push(`${projectRoot === "." ? ".metadata" : `${projectRoot}/.metadata`}:project_type`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const directories = await directDirectoryNames(projectRootPath);
|
|
472
|
+
for (const platform of PLATFORM_DIRECTORIES) {
|
|
473
|
+
if (directories.has(platform)) supportingSignals.push(`${projectRoot === "." ? platform : `${projectRoot}/${platform}`}:directory`);
|
|
474
|
+
}
|
|
475
|
+
if (await hasFlutterImport(projectRootPath)) {
|
|
476
|
+
supportingSignals.push(`${projectRoot === "." ? "source" : `${projectRoot}/source`}:package:flutter`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
root: projectRoot,
|
|
481
|
+
manifest: manifestRelative,
|
|
482
|
+
validManifest: parsed.valid,
|
|
483
|
+
primary: parsed.primary,
|
|
484
|
+
primarySignals,
|
|
485
|
+
supportingSignals,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function detectProjectEvidence(target, { claims = [] } = {}) {
|
|
490
|
+
const targetRoot = path.resolve(target);
|
|
491
|
+
const manifestPaths = await findPubspecFiles(targetRoot);
|
|
492
|
+
if (manifestPaths.length === 0) return null;
|
|
493
|
+
const projects = [];
|
|
494
|
+
for (const manifestPath of manifestPaths) {
|
|
495
|
+
projects.push(await inspectProject(targetRoot, manifestPath, targetRoot));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const normalizedClaims = uniqueSorted((Array.isArray(claims) ? claims : []).map(normalizeClaim).filter(Boolean));
|
|
499
|
+
const hasClaims = Array.isArray(claims) && claims.length > 0;
|
|
500
|
+
const projectRoots = projects.map((project) => project.root);
|
|
501
|
+
const selectedProjects = hasClaims
|
|
502
|
+
? projects.filter((project) => normalizedClaims.some((claim) => claimMatchesProject(
|
|
503
|
+
claim,
|
|
504
|
+
project.root,
|
|
505
|
+
projectRoots,
|
|
506
|
+
)))
|
|
507
|
+
: projects;
|
|
508
|
+
const scope = hasClaims
|
|
509
|
+
? (selectedProjects.length > 0 ? "MATCH" : "NO_MATCH")
|
|
510
|
+
: "UNSCOPED";
|
|
511
|
+
return {
|
|
512
|
+
schemaVersion: PROJECT_EVIDENCE_SCHEMA_VERSION,
|
|
513
|
+
scope,
|
|
514
|
+
frameworks: selectedProjects.some((project) => project.primary) ? ["flutter"] : [],
|
|
515
|
+
projectRoots: uniqueSorted(selectedProjects.map((project) => project.root)),
|
|
516
|
+
primarySignals: uniqueSorted(selectedProjects.flatMap((project) => project.primarySignals)),
|
|
517
|
+
supportingSignals: uniqueSorted(selectedProjects.flatMap((project) => project.supportingSignals)),
|
|
518
|
+
};
|
|
519
|
+
}
|
package/src/core/router.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GUIDE_IDS, PROTOCOL_VERSION } from "./protocol.js";
|
|
2
2
|
import { assertExecutionProfile, resolveExecutionProfile } from "./execution-profile.js";
|
|
3
|
+
import { PROJECT_EVIDENCE_SCOPES } from "./project-detection.js";
|
|
3
4
|
|
|
4
5
|
export const ROUTING_SCHEMA_VERSION = 1;
|
|
5
6
|
|
|
@@ -61,6 +62,8 @@ const SIGNALS = Object.freeze({
|
|
|
61
62
|
platforms: new Set(["web", "mobile", "desktop", "server", "ci", "cross-platform"]),
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
const PROJECT_FRAMEWORKS = new Set(["flutter"]);
|
|
66
|
+
|
|
64
67
|
export const PLATFORM_SEMANTICS = Object.freeze({
|
|
65
68
|
web: Object.freeze({
|
|
66
69
|
mode: "informational-only",
|
|
@@ -121,6 +124,39 @@ const PRIMARY_GUIDES = Object.freeze({
|
|
|
121
124
|
"ui-copy": "design",
|
|
122
125
|
});
|
|
123
126
|
|
|
127
|
+
function hasFlutterWorkContext(workType) {
|
|
128
|
+
return !["documentation", "ui-copy"].includes(workType);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function addFlutterProjectGuides(input, add) {
|
|
132
|
+
const projectEvidence = input.projectEvidence;
|
|
133
|
+
if (!projectEvidence?.frameworks.includes("flutter")
|
|
134
|
+
|| !["MATCH", "UNSCOPED"].includes(projectEvidence.scope)
|
|
135
|
+
|| !hasFlutterWorkContext(input.workType)) return;
|
|
136
|
+
add("flutter", "PROJECT_FLUTTER_SDK_DEPENDENCY");
|
|
137
|
+
add("clean", "PROJECT_FLUTTER_BASELINE");
|
|
138
|
+
add("test", "PROJECT_FLUTTER_BASELINE");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function flutterExclusionReason(projectEvidence, workType) {
|
|
142
|
+
if (!projectEvidence) return "NO_FLUTTER_PROJECT_EVIDENCE";
|
|
143
|
+
if (projectEvidence.scope === "NO_MATCH") return "NO_FLUTTER_SCOPE_MATCH";
|
|
144
|
+
if (!projectEvidence.frameworks.includes("flutter")) return "NO_FLUTTER_PRIMARY_EVIDENCE";
|
|
145
|
+
if (!hasFlutterWorkContext(workType)) return "NO_FLUTTER_EXECUTABLE_WORK";
|
|
146
|
+
return "NO_FLUTTER_PRIMARY_EVIDENCE";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function exclusionReasonForGuide(guide, projectEvidence, workType) {
|
|
150
|
+
if (guide === "security") return "NO_TRUST_BOUNDARY";
|
|
151
|
+
if (guide === "performance") return "NO_MEASURABLE_PERFORMANCE_RISK";
|
|
152
|
+
if (guide === "design" || guide === "accessibility") return "NO_UI_SURFACE";
|
|
153
|
+
if (guide === "premium" || guide === "games") return "NO_PRIMARY_WORK_TYPE";
|
|
154
|
+
if (guide === "taste") return "NO_TASTE_FRONTEND_CONTEXT";
|
|
155
|
+
if (guide === "documentation") return "NO_DOCUMENTATION_SURFACE";
|
|
156
|
+
if (guide === "flutter") return flutterExclusionReason(projectEvidence, workType);
|
|
157
|
+
return "NO_BEHAVIOR_OR_EXECUTABLE_CHANGE";
|
|
158
|
+
}
|
|
159
|
+
|
|
124
160
|
export class RouteInputError extends Error {
|
|
125
161
|
constructor(message) {
|
|
126
162
|
super(message);
|
|
@@ -151,6 +187,42 @@ function normalizeArray(value, name, allowed) {
|
|
|
151
187
|
return [...seen].sort();
|
|
152
188
|
}
|
|
153
189
|
|
|
190
|
+
function normalizeStringList(value, name) {
|
|
191
|
+
if (value === undefined) return [];
|
|
192
|
+
if (!Array.isArray(value)) throw new RouteInputError(`${name} must be an array`);
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
for (const item of value) {
|
|
195
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
196
|
+
throw new RouteInputError(`${name} must contain non-empty strings`);
|
|
197
|
+
}
|
|
198
|
+
if (seen.has(item)) throw new RouteInputError(`Duplicate ${name.slice(0, -1)}: ${item}`);
|
|
199
|
+
seen.add(item);
|
|
200
|
+
}
|
|
201
|
+
return [...seen].sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizeProjectEvidence(value) {
|
|
205
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
206
|
+
throw new RouteInputError("projectEvidence must be an object");
|
|
207
|
+
}
|
|
208
|
+
if (value.schemaVersion !== undefined && value.schemaVersion !== ROUTING_SCHEMA_VERSION) {
|
|
209
|
+
throw new RouteInputError(`Unsupported projectEvidence schema version: ${value.schemaVersion}`);
|
|
210
|
+
}
|
|
211
|
+
const frameworks = normalizeArray(value.frameworks, "frameworks", PROJECT_FRAMEWORKS);
|
|
212
|
+
const scope = value.scope ?? (frameworks.length > 0 ? "UNSCOPED" : "NONE");
|
|
213
|
+
if (!PROJECT_EVIDENCE_SCOPES.includes(scope)) {
|
|
214
|
+
throw new RouteInputError(`Unknown project evidence scope: ${scope}`);
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
schemaVersion: ROUTING_SCHEMA_VERSION,
|
|
218
|
+
scope,
|
|
219
|
+
frameworks,
|
|
220
|
+
projectRoots: normalizeStringList(value.projectRoots, "projectRoots"),
|
|
221
|
+
primarySignals: normalizeStringList(value.primarySignals, "primarySignals"),
|
|
222
|
+
supportingSignals: normalizeStringList(value.supportingSignals, "supportingSignals"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
154
226
|
export function normalizeRouteInput(input = {}) {
|
|
155
227
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
156
228
|
throw new RouteInputError("Route input must be an object");
|
|
@@ -163,6 +235,9 @@ export function normalizeRouteInput(input = {}) {
|
|
|
163
235
|
throw new RouteInputError(`${key} must be boolean`);
|
|
164
236
|
}
|
|
165
237
|
}
|
|
238
|
+
const projectEvidence = input.projectEvidence === undefined
|
|
239
|
+
? undefined
|
|
240
|
+
: normalizeProjectEvidence(input.projectEvidence);
|
|
166
241
|
return {
|
|
167
242
|
schemaVersion: ROUTING_SCHEMA_VERSION,
|
|
168
243
|
workType: input.workType,
|
|
@@ -171,6 +246,7 @@ export function normalizeRouteInput(input = {}) {
|
|
|
171
246
|
platforms: normalizeArray(input.platforms, "platforms", SIGNALS.platforms),
|
|
172
247
|
behaviorChange: input.behaviorChange ?? false,
|
|
173
248
|
executableChange: input.executableChange ?? false,
|
|
249
|
+
...(projectEvidence ? { projectEvidence } : {}),
|
|
174
250
|
};
|
|
175
251
|
}
|
|
176
252
|
|
|
@@ -186,6 +262,9 @@ export function evaluateRoute(input = {}, profileOptions = {}) {
|
|
|
186
262
|
selected.set(guide, reasons);
|
|
187
263
|
}
|
|
188
264
|
|
|
265
|
+
const projectEvidence = normalized.projectEvidence;
|
|
266
|
+
addFlutterProjectGuides(normalized, add);
|
|
267
|
+
|
|
189
268
|
const workReason = reasonForWorkType(normalized.workType);
|
|
190
269
|
for (const guide of WORK_GUIDES[normalized.workType]) add(guide, workReason);
|
|
191
270
|
|
|
@@ -246,13 +325,7 @@ export function evaluateRoute(input = {}, profileOptions = {}) {
|
|
|
246
325
|
|
|
247
326
|
for (const guide of GUIDE_IDS) {
|
|
248
327
|
if (selected.has(guide)) continue;
|
|
249
|
-
|
|
250
|
-
else if (guide === "performance") excluded[guide] = ["NO_MEASURABLE_PERFORMANCE_RISK"];
|
|
251
|
-
else if (guide === "design" || guide === "accessibility") excluded[guide] = ["NO_UI_SURFACE"];
|
|
252
|
-
else if (guide === "premium" || guide === "games") excluded[guide] = ["NO_PRIMARY_WORK_TYPE"];
|
|
253
|
-
else if (guide === "taste") excluded[guide] = ["NO_TASTE_FRONTEND_CONTEXT"];
|
|
254
|
-
else if (guide === "documentation") excluded[guide] = ["NO_DOCUMENTATION_SURFACE"];
|
|
255
|
-
else excluded[guide] = ["NO_BEHAVIOR_OR_EXECUTABLE_CHANGE"];
|
|
328
|
+
excluded[guide] = [exclusionReasonForGuide(guide, projectEvidence, normalized.workType)];
|
|
256
329
|
}
|
|
257
330
|
|
|
258
331
|
const guides = [...selected.keys()];
|