@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.
@@ -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
+ }
@@ -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
- if (guide === "security") excluded[guide] = ["NO_TRUST_BOUNDARY"];
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()];