qfai 1.6.4 → 1.6.5
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/dist/cli/index.cjs +1833 -278
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.mjs +1824 -269
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.cjs +1596 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.mjs +1596 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.cjs
CHANGED
|
@@ -442,6 +442,48 @@ function normalizeUiux(raw, configPath, issues) {
|
|
|
442
442
|
issues.push(configIssue(configPath, "uiux.htmlMockTimeout \u306F\u6B63\u306E\u6570\u5024\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002"));
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
|
+
if (raw.qualityProfile !== void 0) {
|
|
446
|
+
if (typeof raw.qualityProfile === "string" && ["strict", "high", "default"].includes(raw.qualityProfile)) {
|
|
447
|
+
result.qualityProfile = raw.qualityProfile;
|
|
448
|
+
} else {
|
|
449
|
+
issues.push(
|
|
450
|
+
configIssue(
|
|
451
|
+
configPath,
|
|
452
|
+
"uiux.qualityProfile \u306F strict|high|default \u306E\u3044\u305A\u308C\u304B\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002"
|
|
453
|
+
)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (raw.requireResearchSummary !== void 0) {
|
|
458
|
+
if (typeof raw.requireResearchSummary === "boolean") {
|
|
459
|
+
result.requireResearchSummary = raw.requireResearchSummary;
|
|
460
|
+
} else {
|
|
461
|
+
issues.push(
|
|
462
|
+
configIssue(configPath, "uiux.requireResearchSummary \u306F\u30D6\u30FC\u30EB\u5024\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002")
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (raw.competitive_refs_min !== void 0) {
|
|
467
|
+
if (typeof raw.competitive_refs_min === "number" && Number.isFinite(raw.competitive_refs_min) && raw.competitive_refs_min >= 0) {
|
|
468
|
+
result.competitive_refs_min = raw.competitive_refs_min;
|
|
469
|
+
} else {
|
|
470
|
+
issues.push(
|
|
471
|
+
configIssue(configPath, "uiux.competitive_refs_min \u306F0\u4EE5\u4E0A\u306E\u6570\u5024\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002")
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (raw.warning_as_error_override !== void 0) {
|
|
476
|
+
if (Array.isArray(raw.warning_as_error_override) && raw.warning_as_error_override.every((v) => typeof v === "string")) {
|
|
477
|
+
result.warning_as_error_override = raw.warning_as_error_override;
|
|
478
|
+
} else {
|
|
479
|
+
issues.push(
|
|
480
|
+
configIssue(
|
|
481
|
+
configPath,
|
|
482
|
+
"uiux.warning_as_error_override \u306F\u6587\u5B57\u5217\u914D\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002"
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
445
487
|
return Object.keys(result).length > 0 ? result : void 0;
|
|
446
488
|
}
|
|
447
489
|
function configIssue(file, message) {
|
|
@@ -1522,8 +1564,8 @@ var import_promises7 = require("fs/promises");
|
|
|
1522
1564
|
var import_node_path8 = __toESM(require("path"), 1);
|
|
1523
1565
|
var import_node_url2 = require("url");
|
|
1524
1566
|
async function resolveToolVersion() {
|
|
1525
|
-
if ("1.6.
|
|
1526
|
-
return "1.6.
|
|
1567
|
+
if ("1.6.5".length > 0) {
|
|
1568
|
+
return "1.6.5";
|
|
1527
1569
|
}
|
|
1528
1570
|
try {
|
|
1529
1571
|
const packagePath = resolvePackageJsonPath();
|
|
@@ -4059,8 +4101,8 @@ function extractRouteHintsFromEvidence(evidence) {
|
|
|
4059
4101
|
}
|
|
4060
4102
|
|
|
4061
4103
|
// src/cli/commands/report.ts
|
|
4062
|
-
var
|
|
4063
|
-
var
|
|
4104
|
+
var import_promises55 = require("fs/promises");
|
|
4105
|
+
var import_node_path58 = __toESM(require("path"), 1);
|
|
4064
4106
|
|
|
4065
4107
|
// src/core/normalize.ts
|
|
4066
4108
|
function normalizeIssuePaths(root, issues) {
|
|
@@ -4155,8 +4197,8 @@ async function createPhaseGuardResult(phase, blockedIssue) {
|
|
|
4155
4197
|
}
|
|
4156
4198
|
|
|
4157
4199
|
// src/core/report.ts
|
|
4158
|
-
var
|
|
4159
|
-
var
|
|
4200
|
+
var import_promises53 = require("fs/promises");
|
|
4201
|
+
var import_node_path56 = __toESM(require("path"), 1);
|
|
4160
4202
|
|
|
4161
4203
|
// src/core/contractIndex.ts
|
|
4162
4204
|
var import_promises15 = require("fs/promises");
|
|
@@ -12356,7 +12398,7 @@ function collectLayer(layer, layerName, target, errors) {
|
|
|
12356
12398
|
}
|
|
12357
12399
|
function flattenTokens(obj, prefix, target, errors) {
|
|
12358
12400
|
for (const [key, value] of Object.entries(obj)) {
|
|
12359
|
-
const
|
|
12401
|
+
const path61 = `${prefix}.${key}`;
|
|
12360
12402
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
12361
12403
|
const record2 = value;
|
|
12362
12404
|
if ("$value" in record2) {
|
|
@@ -12372,9 +12414,9 @@ function flattenTokens(obj, prefix, target, errors) {
|
|
|
12372
12414
|
if (typeof record2.platform === "string") {
|
|
12373
12415
|
token.platform = record2.platform;
|
|
12374
12416
|
}
|
|
12375
|
-
target.set(
|
|
12417
|
+
target.set(path61, token);
|
|
12376
12418
|
} else {
|
|
12377
|
-
flattenTokens(record2,
|
|
12419
|
+
flattenTokens(record2, path61, target, errors);
|
|
12378
12420
|
}
|
|
12379
12421
|
}
|
|
12380
12422
|
}
|
|
@@ -12384,44 +12426,44 @@ function resolveAllReferences(result) {
|
|
|
12384
12426
|
for (const [key, val] of result.primitives) allTokens.set(key, val);
|
|
12385
12427
|
for (const [key, val] of result.semantics) allTokens.set(key, val);
|
|
12386
12428
|
for (const [key, val] of result.components) allTokens.set(key, val);
|
|
12387
|
-
for (const [
|
|
12388
|
-
resolveTokenRef(
|
|
12429
|
+
for (const [path61] of allTokens) {
|
|
12430
|
+
resolveTokenRef(path61, allTokens, /* @__PURE__ */ new Set(), 0, result);
|
|
12389
12431
|
}
|
|
12390
12432
|
}
|
|
12391
|
-
function resolveTokenRef(
|
|
12392
|
-
if (result.resolved.has(
|
|
12393
|
-
return result.resolved.get(
|
|
12433
|
+
function resolveTokenRef(path61, allTokens, visited, depth, result) {
|
|
12434
|
+
if (result.resolved.has(path61)) {
|
|
12435
|
+
return result.resolved.get(path61);
|
|
12394
12436
|
}
|
|
12395
12437
|
if (depth > MAX_RESOLVE_DEPTH) {
|
|
12396
12438
|
result.errors.push({
|
|
12397
|
-
message: `Max reference depth exceeded at: ${
|
|
12398
|
-
path:
|
|
12439
|
+
message: `Max reference depth exceeded at: ${path61}`,
|
|
12440
|
+
path: path61
|
|
12399
12441
|
});
|
|
12400
12442
|
return void 0;
|
|
12401
12443
|
}
|
|
12402
|
-
if (visited.has(
|
|
12444
|
+
if (visited.has(path61)) {
|
|
12403
12445
|
result.errors.push({
|
|
12404
|
-
message: `Circular reference detected: ${
|
|
12405
|
-
path:
|
|
12446
|
+
message: `Circular reference detected: ${path61}`,
|
|
12447
|
+
path: path61
|
|
12406
12448
|
});
|
|
12407
12449
|
return void 0;
|
|
12408
12450
|
}
|
|
12409
|
-
const token = allTokens.get(
|
|
12451
|
+
const token = allTokens.get(path61);
|
|
12410
12452
|
if (!token) {
|
|
12411
12453
|
return void 0;
|
|
12412
12454
|
}
|
|
12413
12455
|
if (typeof token.$value !== "string") {
|
|
12414
12456
|
const rawValue2 = stringifyTokenValue(token.$value);
|
|
12415
|
-
result.resolved.set(
|
|
12457
|
+
result.resolved.set(path61, rawValue2);
|
|
12416
12458
|
return rawValue2;
|
|
12417
12459
|
}
|
|
12418
12460
|
const rawValue = stringifyTokenValue(token.$value);
|
|
12419
12461
|
const refs = [...rawValue.matchAll(REF_PATTERN)];
|
|
12420
12462
|
if (refs.length === 0) {
|
|
12421
|
-
result.resolved.set(
|
|
12463
|
+
result.resolved.set(path61, rawValue);
|
|
12422
12464
|
return rawValue;
|
|
12423
12465
|
}
|
|
12424
|
-
visited.add(
|
|
12466
|
+
visited.add(path61);
|
|
12425
12467
|
let resolved = rawValue;
|
|
12426
12468
|
for (const ref of refs) {
|
|
12427
12469
|
const refPath = ref[1];
|
|
@@ -12429,8 +12471,8 @@ function resolveTokenRef(path58, allTokens, visited, depth, result) {
|
|
|
12429
12471
|
const refToken = allTokens.get(refPath);
|
|
12430
12472
|
if (!refToken) {
|
|
12431
12473
|
result.errors.push({
|
|
12432
|
-
message: `Unresolved token reference: {${refPath}} at ${
|
|
12433
|
-
path:
|
|
12474
|
+
message: `Unresolved token reference: {${refPath}} at ${path61}`,
|
|
12475
|
+
path: path61
|
|
12434
12476
|
});
|
|
12435
12477
|
continue;
|
|
12436
12478
|
}
|
|
@@ -12439,7 +12481,7 @@ function resolveTokenRef(path58, allTokens, visited, depth, result) {
|
|
|
12439
12481
|
resolved = resolved.split(`{${refPath}}`).join(refValue);
|
|
12440
12482
|
}
|
|
12441
12483
|
}
|
|
12442
|
-
result.resolved.set(
|
|
12484
|
+
result.resolved.set(path61, resolved);
|
|
12443
12485
|
return resolved;
|
|
12444
12486
|
}
|
|
12445
12487
|
function stringifyTokenValue(value) {
|
|
@@ -12677,8 +12719,8 @@ var import_node_path45 = __toESM(require("path"), 1);
|
|
|
12677
12719
|
var import_fast_glob3 = __toESM(require("fast-glob"), 1);
|
|
12678
12720
|
|
|
12679
12721
|
// src/core/uiux/contrastRatio.ts
|
|
12680
|
-
function computeContrastRatio(
|
|
12681
|
-
const fgLum = relativeLuminance(
|
|
12722
|
+
function computeContrastRatio(fg11, bg) {
|
|
12723
|
+
const fgLum = relativeLuminance(fg11);
|
|
12682
12724
|
const bgLum = relativeLuminance(bg);
|
|
12683
12725
|
if (fgLum === null || bgLum === null) return null;
|
|
12684
12726
|
const lighter = Math.max(fgLum, bgLum);
|
|
@@ -14618,216 +14660,1729 @@ async function collectTestCaseIds(specDir) {
|
|
|
14618
14660
|
return { knownTcIds, unitComponentTcIds };
|
|
14619
14661
|
}
|
|
14620
14662
|
|
|
14621
|
-
// src/core/
|
|
14622
|
-
var
|
|
14623
|
-
|
|
14624
|
-
|
|
14625
|
-
|
|
14626
|
-
|
|
14627
|
-
|
|
14628
|
-
|
|
14629
|
-
|
|
14630
|
-
|
|
14631
|
-
|
|
14632
|
-
|
|
14633
|
-
|
|
14634
|
-
|
|
14635
|
-
|
|
14636
|
-
|
|
14637
|
-
|
|
14638
|
-
|
|
14639
|
-
|
|
14640
|
-
|
|
14641
|
-
|
|
14642
|
-
|
|
14643
|
-
|
|
14644
|
-
|
|
14645
|
-
|
|
14646
|
-
|
|
14647
|
-
category: "compatibility",
|
|
14648
|
-
message: `UI/UX validation exceeded budget (${UIUX_VALIDATION_BUDGET_MS}ms). All validators were executed.`,
|
|
14649
|
-
rule: "uiux.performanceBudget"
|
|
14650
|
-
});
|
|
14663
|
+
// src/core/validators/ddpValidation.ts
|
|
14664
|
+
var import_promises50 = require("fs/promises");
|
|
14665
|
+
var import_node_path53 = __toESM(require("path"), 1);
|
|
14666
|
+
var import_node_url3 = require("url");
|
|
14667
|
+
var import_fast_glob8 = __toESM(require("fast-glob"), 1);
|
|
14668
|
+
var DDP_HEADING_RE = /^#{1,3}\s+Design\s+Direction\s+Pack/im;
|
|
14669
|
+
var DDP_REQUIRED_FIELDS = [
|
|
14670
|
+
"visual_thesis",
|
|
14671
|
+
"content_plan",
|
|
14672
|
+
"interaction_thesis",
|
|
14673
|
+
"anti_goals",
|
|
14674
|
+
"cta_hierarchy"
|
|
14675
|
+
];
|
|
14676
|
+
var DDP_THEME_FIELDS = ["theme", "mood", "taste", "material", "energy", "visual_anchor"];
|
|
14677
|
+
var UI_BEARING_KEYWORDS_RE = /\b(screen|ui|interface|mock|layout|design)\b/i;
|
|
14678
|
+
var FIGMA_URL_RE = /https?:\/\/(?:www\.)?figma\.com\/[^\s)]+/i;
|
|
14679
|
+
var _bannedPatterns = null;
|
|
14680
|
+
async function loadBannedPatterns() {
|
|
14681
|
+
if (_bannedPatterns !== null) return _bannedPatterns;
|
|
14682
|
+
try {
|
|
14683
|
+
const thisDir = import_node_path53.default.dirname((0, import_node_url3.fileURLToPath)(__filename));
|
|
14684
|
+
const filePath = import_node_path53.default.join(thisDir, "ddpBannedPatterns.txt");
|
|
14685
|
+
const content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
14686
|
+
_bannedPatterns = content.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
14687
|
+
} catch {
|
|
14688
|
+
_bannedPatterns = [];
|
|
14651
14689
|
}
|
|
14652
|
-
|
|
14653
|
-
...configIssues,
|
|
14654
|
-
...await validateRepositoryHygiene(root, config),
|
|
14655
|
-
...await validateSkillsIntegrity(root, config),
|
|
14656
|
-
...await validateAssistantAssets(root, config),
|
|
14657
|
-
...await validateDiscussionMermaid(root),
|
|
14658
|
-
...await validateMermaidEnforcement(root),
|
|
14659
|
-
...await validateSpecPacks(root, config),
|
|
14660
|
-
...await validateDiscussionPackReadiness(root, config),
|
|
14661
|
-
...await validateDiscussionVisuals(root),
|
|
14662
|
-
...await validateLegacyStatusDir(root),
|
|
14663
|
-
...await validateStatusInSpecs(root, config),
|
|
14664
|
-
...await validateDensityHints(root, config),
|
|
14665
|
-
...await validateReviewArtifacts(root),
|
|
14666
|
-
...await validatePrototypingEvidence(root, config),
|
|
14667
|
-
...await validateSpecSplitByCapability(root, config),
|
|
14668
|
-
...await validateLayeredTraceability(root, config),
|
|
14669
|
-
...await validateOrphanProhibition(root, config),
|
|
14670
|
-
...await validateLayerCoverage(root, config),
|
|
14671
|
-
...atddCodeTraceabilityIssues,
|
|
14672
|
-
...await validateContractReferences(root, config),
|
|
14673
|
-
...await validateTraceability(root, config, phase),
|
|
14674
|
-
...await validateDefinedIds(root, config),
|
|
14675
|
-
...await validateContracts(root, config),
|
|
14676
|
-
...await validateTddList(root, config),
|
|
14677
|
-
...uiuxIssues
|
|
14678
|
-
];
|
|
14679
|
-
const { issues, waivers } = await applyWaivers(root, findings);
|
|
14680
|
-
const specsRoot = resolvePath(root, config, "specsDir");
|
|
14681
|
-
const scenarioFiles = await collectScenarioFiles(specsRoot);
|
|
14682
|
-
const scIds = await collectScIdsFromScenarioFiles(scenarioFiles);
|
|
14683
|
-
const { refs: scTestRefs, scan: testFiles } = await collectScTestReferences(
|
|
14684
|
-
root,
|
|
14685
|
-
config.validation.traceability.testFileGlobs,
|
|
14686
|
-
config.validation.traceability.testFileExcludeGlobs
|
|
14687
|
-
);
|
|
14688
|
-
const scCoverage = buildScCoverage(scIds, scTestRefs);
|
|
14689
|
-
const toolVersion = await resolveToolVersion();
|
|
14690
|
-
return {
|
|
14691
|
-
toolVersion,
|
|
14692
|
-
phase,
|
|
14693
|
-
issues,
|
|
14694
|
-
counts: countIssues(issues),
|
|
14695
|
-
traceability: {
|
|
14696
|
-
sc: scCoverage,
|
|
14697
|
-
testFiles
|
|
14698
|
-
},
|
|
14699
|
-
waivers
|
|
14700
|
-
};
|
|
14690
|
+
return _bannedPatterns;
|
|
14701
14691
|
}
|
|
14702
|
-
function
|
|
14703
|
-
|
|
14704
|
-
|
|
14705
|
-
|
|
14706
|
-
|
|
14692
|
+
async function validateDdpFields(root, config) {
|
|
14693
|
+
const issues = [];
|
|
14694
|
+
const pattern = import_node_path53.default.posix.join(root.replace(/\\/g, "/"), config.paths.discussionDir, "**/*.md");
|
|
14695
|
+
const files = await (0, import_fast_glob8.default)(pattern, { absolute: true });
|
|
14696
|
+
let ddpFound = false;
|
|
14697
|
+
let isUiBearing = false;
|
|
14698
|
+
for (const filePath of files) {
|
|
14699
|
+
const basename = import_node_path53.default.basename(filePath);
|
|
14700
|
+
if (basename === "03_Story-Workshop.md") {
|
|
14701
|
+
try {
|
|
14702
|
+
const storyContent = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
14703
|
+
if (UI_BEARING_KEYWORDS_RE.test(storyContent)) {
|
|
14704
|
+
isUiBearing = true;
|
|
14705
|
+
}
|
|
14706
|
+
} catch {
|
|
14707
14707
|
}
|
|
14708
|
-
acc[issue2.severity] += 1;
|
|
14709
|
-
return acc;
|
|
14710
|
-
},
|
|
14711
|
-
{ info: 0, warning: 0, error: 0 }
|
|
14712
|
-
);
|
|
14713
|
-
}
|
|
14714
|
-
|
|
14715
|
-
// src/core/report.ts
|
|
14716
|
-
var REPORT_GUARDRAILS_MAX = 20;
|
|
14717
|
-
var REPORT_TEST_STRATEGY_SAMPLE_LIMIT = 20;
|
|
14718
|
-
var SC_TAG_RE4 = /^SC-\d{4}-\d{4}$/;
|
|
14719
|
-
async function createReportData(root, validation, configResult) {
|
|
14720
|
-
const resolvedRoot = import_node_path53.default.resolve(root);
|
|
14721
|
-
const resolved = configResult ?? await loadConfig(resolvedRoot);
|
|
14722
|
-
const config = resolved.config;
|
|
14723
|
-
const configPath = resolved.configPath;
|
|
14724
|
-
const specsRoot = resolvePath(resolvedRoot, config, "specsDir");
|
|
14725
|
-
const contractsRoot = resolvePath(resolvedRoot, config, "contractsDir");
|
|
14726
|
-
const apiRoot = import_node_path53.default.join(contractsRoot, "api");
|
|
14727
|
-
const uiRoot = import_node_path53.default.join(contractsRoot, "ui");
|
|
14728
|
-
const dbRoot = import_node_path53.default.join(contractsRoot, "db");
|
|
14729
|
-
const srcRoot = resolvePath(resolvedRoot, config, "srcDir");
|
|
14730
|
-
const testsRoot = resolvePath(resolvedRoot, config, "testsDir");
|
|
14731
|
-
const specEntries = await collectSpecEntries(specsRoot);
|
|
14732
|
-
const specFiles = await collectSpecFiles(specsRoot);
|
|
14733
|
-
const scenarioFiles = await collectScenarioFiles(specsRoot);
|
|
14734
|
-
const scenarioCount = await countScenarios(scenarioFiles);
|
|
14735
|
-
const testStrategy = await collectTestStrategy(
|
|
14736
|
-
scenarioFiles,
|
|
14737
|
-
resolvedRoot,
|
|
14738
|
-
config,
|
|
14739
|
-
REPORT_TEST_STRATEGY_SAMPLE_LIMIT
|
|
14740
|
-
);
|
|
14741
|
-
const {
|
|
14742
|
-
api: apiFiles,
|
|
14743
|
-
ui: uiFiles,
|
|
14744
|
-
db: dbFiles,
|
|
14745
|
-
thema: themaFiles
|
|
14746
|
-
} = await collectContractFiles(uiRoot, apiRoot, dbRoot);
|
|
14747
|
-
const contractIndex = await buildContractIndex(resolvedRoot, config);
|
|
14748
|
-
const contractIdList = Array.from(contractIndex.ids);
|
|
14749
|
-
const specContractRefs = await collectSpecContractRefs(specFiles, contractIdList);
|
|
14750
|
-
const referencedContracts = /* @__PURE__ */ new Set();
|
|
14751
|
-
for (const entry of specContractRefs.specToContracts.values()) {
|
|
14752
|
-
entry.ids.forEach((id) => referencedContracts.add(id));
|
|
14753
|
-
}
|
|
14754
|
-
const referencedContractCount = contractIdList.filter((id) => referencedContracts.has(id)).length;
|
|
14755
|
-
const orphanContractCount = contractIdList.filter((id) => !referencedContracts.has(id)).length;
|
|
14756
|
-
const contractIdToSpecsRecord = mapToSortedRecord(specContractRefs.idToSpecs);
|
|
14757
|
-
const specToContractsRecord = mapToSpecContractRecord(specContractRefs.specToContracts);
|
|
14758
|
-
const idsByPrefix = await collectIds([
|
|
14759
|
-
...specFiles,
|
|
14760
|
-
...scenarioFiles,
|
|
14761
|
-
...apiFiles,
|
|
14762
|
-
...uiFiles,
|
|
14763
|
-
...dbFiles,
|
|
14764
|
-
...themaFiles
|
|
14765
|
-
]);
|
|
14766
|
-
const upstreamIds = await collectUpstreamIds([...specFiles, ...scenarioFiles]);
|
|
14767
|
-
const traceability = await evaluateTraceability(upstreamIds, srcRoot, testsRoot);
|
|
14768
|
-
const resolvedValidationRaw = validation ?? await validateProject(resolvedRoot, resolved);
|
|
14769
|
-
const normalizedValidation = normalizeValidationResult(resolvedRoot, resolvedValidationRaw);
|
|
14770
|
-
const scCoverage = normalizedValidation.traceability.sc;
|
|
14771
|
-
const testFiles = normalizedValidation.traceability.testFiles;
|
|
14772
|
-
const scSources = await collectScIdSourcesFromScenarioFiles(scenarioFiles);
|
|
14773
|
-
const scSourceRecord = mapToSortedRecord(normalizeScSources(resolvedRoot, scSources));
|
|
14774
|
-
const guardrailsLoad = await loadDecisionGuardrails(resolvedRoot, {
|
|
14775
|
-
specsRoot
|
|
14776
|
-
});
|
|
14777
|
-
const guardrailsAll = sortDecisionGuardrails(normalizeDecisionGuardrails(guardrailsLoad.entries));
|
|
14778
|
-
const guardrailsDisplay = guardrailsAll.slice(0, REPORT_GUARDRAILS_MAX);
|
|
14779
|
-
const guardrailsByType = { nonGoal: 0, notNow: 0, tradeOff: 0 };
|
|
14780
|
-
for (const item of guardrailsAll) {
|
|
14781
|
-
if (item.type === "non-goal") {
|
|
14782
|
-
guardrailsByType.nonGoal += 1;
|
|
14783
|
-
} else if (item.type === "not-now") {
|
|
14784
|
-
guardrailsByType.notNow += 1;
|
|
14785
|
-
} else {
|
|
14786
|
-
guardrailsByType.tradeOff += 1;
|
|
14787
14708
|
}
|
|
14788
14709
|
}
|
|
14789
|
-
const
|
|
14790
|
-
|
|
14791
|
-
|
|
14792
|
-
|
|
14793
|
-
|
|
14794
|
-
|
|
14795
|
-
const warning = {
|
|
14796
|
-
file: item.file ? toRelativePath(resolvedRoot, item.file) : "(unknown)",
|
|
14797
|
-
suspectedMismatch: item.message,
|
|
14798
|
-
refs: item.refs ?? []
|
|
14799
|
-
};
|
|
14800
|
-
if (item.suggested_action) {
|
|
14801
|
-
warning.suggestion = item.suggested_action;
|
|
14710
|
+
for (const filePath of files) {
|
|
14711
|
+
let content;
|
|
14712
|
+
try {
|
|
14713
|
+
content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
14714
|
+
} catch {
|
|
14715
|
+
continue;
|
|
14802
14716
|
}
|
|
14803
|
-
|
|
14804
|
-
|
|
14805
|
-
|
|
14806
|
-
const
|
|
14807
|
-
|
|
14808
|
-
|
|
14809
|
-
|
|
14810
|
-
|
|
14811
|
-
|
|
14812
|
-
|
|
14813
|
-
|
|
14717
|
+
if (!DDP_HEADING_RE.test(content)) continue;
|
|
14718
|
+
ddpFound = true;
|
|
14719
|
+
const rel = import_node_path53.default.relative(root, filePath).replace(/\\/g, "/");
|
|
14720
|
+
const section = extractDdpSection(content);
|
|
14721
|
+
if (!section) continue;
|
|
14722
|
+
for (const field of DDP_REQUIRED_FIELDS) {
|
|
14723
|
+
if (!hasNonEmptyField(section, field)) {
|
|
14724
|
+
issues.push(
|
|
14725
|
+
issue(
|
|
14726
|
+
"QFAI-DDP-001",
|
|
14727
|
+
`DDP required field missing: ${field}`,
|
|
14728
|
+
"error",
|
|
14729
|
+
rel,
|
|
14730
|
+
`ddp.requiredField.${field}`
|
|
14731
|
+
)
|
|
14732
|
+
);
|
|
14733
|
+
}
|
|
14814
14734
|
}
|
|
14815
|
-
|
|
14816
|
-
|
|
14735
|
+
const visualThesisValue = getFieldValue(section, "visual_thesis");
|
|
14736
|
+
if (visualThesisValue) {
|
|
14737
|
+
const trimmed = visualThesisValue.trim();
|
|
14738
|
+
if (/^-\s/.test(trimmed) && !/[.!?]/.test(trimmed)) {
|
|
14739
|
+
issues.push(
|
|
14740
|
+
issue(
|
|
14741
|
+
"QFAI-DDP-002",
|
|
14742
|
+
"Visual thesis should be a single descriptive sentence, not bullet points",
|
|
14743
|
+
"warning",
|
|
14744
|
+
rel,
|
|
14745
|
+
"ddp.visualThesisFormat"
|
|
14746
|
+
)
|
|
14747
|
+
);
|
|
14748
|
+
}
|
|
14817
14749
|
}
|
|
14818
|
-
|
|
14819
|
-
|
|
14820
|
-
|
|
14821
|
-
|
|
14822
|
-
|
|
14823
|
-
|
|
14824
|
-
|
|
14825
|
-
|
|
14826
|
-
|
|
14827
|
-
|
|
14828
|
-
|
|
14829
|
-
|
|
14830
|
-
|
|
14750
|
+
const themeBlock = extractNestedBlock(section, "theme");
|
|
14751
|
+
if (themeBlock !== null) {
|
|
14752
|
+
for (const tf of DDP_THEME_FIELDS) {
|
|
14753
|
+
if (!hasSubField(themeBlock, tf)) {
|
|
14754
|
+
issues.push(
|
|
14755
|
+
issue(
|
|
14756
|
+
"QFAI-DDP-003",
|
|
14757
|
+
`DDP theme field missing: ${tf}`,
|
|
14758
|
+
"error",
|
|
14759
|
+
rel,
|
|
14760
|
+
`ddp.themeField.${tf}`
|
|
14761
|
+
)
|
|
14762
|
+
);
|
|
14763
|
+
}
|
|
14764
|
+
}
|
|
14765
|
+
}
|
|
14766
|
+
const ctaBlock = extractNestedBlock(section, "cta_hierarchy");
|
|
14767
|
+
if (ctaBlock !== null) {
|
|
14768
|
+
if (!hasSubField(ctaBlock, "primary")) {
|
|
14769
|
+
issues.push(
|
|
14770
|
+
issue(
|
|
14771
|
+
"QFAI-DDP-004",
|
|
14772
|
+
"CTA hierarchy must have at least one primary CTA",
|
|
14773
|
+
"error",
|
|
14774
|
+
rel,
|
|
14775
|
+
"ddp.ctaPrimaryMissing"
|
|
14776
|
+
)
|
|
14777
|
+
);
|
|
14778
|
+
}
|
|
14779
|
+
if (!hasSubField(ctaBlock, "placement")) {
|
|
14780
|
+
issues.push(
|
|
14781
|
+
issue(
|
|
14782
|
+
"QFAI-DDP-005",
|
|
14783
|
+
"CTA placement not specified",
|
|
14784
|
+
"warning",
|
|
14785
|
+
rel,
|
|
14786
|
+
"ddp.ctaPlacementMissing"
|
|
14787
|
+
)
|
|
14788
|
+
);
|
|
14789
|
+
}
|
|
14790
|
+
} else {
|
|
14791
|
+
const ctaInline = getFieldValue(section, "cta_hierarchy");
|
|
14792
|
+
if (ctaInline && !ctaInline.toLowerCase().includes("primary")) {
|
|
14793
|
+
issues.push(
|
|
14794
|
+
issue(
|
|
14795
|
+
"QFAI-DDP-004",
|
|
14796
|
+
"CTA hierarchy must have at least one primary CTA",
|
|
14797
|
+
"error",
|
|
14798
|
+
rel,
|
|
14799
|
+
"ddp.ctaPrimaryMissing"
|
|
14800
|
+
)
|
|
14801
|
+
);
|
|
14802
|
+
}
|
|
14803
|
+
}
|
|
14804
|
+
const contentPlanItems = collectListItems(section, "content_plan");
|
|
14805
|
+
if (contentPlanItems.length > 0 && contentPlanItems.length < 2) {
|
|
14806
|
+
issues.push(
|
|
14807
|
+
issue(
|
|
14808
|
+
"QFAI-DDP-007",
|
|
14809
|
+
"Content plan should have at least 2 sections",
|
|
14810
|
+
"warning",
|
|
14811
|
+
rel,
|
|
14812
|
+
"ddp.contentPlanSections"
|
|
14813
|
+
)
|
|
14814
|
+
);
|
|
14815
|
+
}
|
|
14816
|
+
const interactionItems = collectListItems(section, "interaction_thesis");
|
|
14817
|
+
if (interactionItems.length > 0 && interactionItems.length < 2) {
|
|
14818
|
+
issues.push(
|
|
14819
|
+
issue(
|
|
14820
|
+
"QFAI-DDP-008",
|
|
14821
|
+
"Interaction thesis should contain 2-3 motion principles",
|
|
14822
|
+
"warning",
|
|
14823
|
+
rel,
|
|
14824
|
+
"ddp.interactionThesisPrinciples"
|
|
14825
|
+
)
|
|
14826
|
+
);
|
|
14827
|
+
}
|
|
14828
|
+
const antiGoalItems = collectListItems(section, "anti_goals");
|
|
14829
|
+
const bannedPatterns = await loadBannedPatterns();
|
|
14830
|
+
const hasBannedPattern = antiGoalItems.some(
|
|
14831
|
+
(item) => bannedPatterns.some((bp) => item.toLowerCase().includes(bp.toLowerCase()))
|
|
14832
|
+
);
|
|
14833
|
+
if (hasNonEmptyField(section, "anti_goals") && antiGoalItems.length > 0 && !hasBannedPattern) {
|
|
14834
|
+
issues.push(
|
|
14835
|
+
issue(
|
|
14836
|
+
"QFAI-DDP-009",
|
|
14837
|
+
"DDP anti-goals should contain at least one banned generic pattern",
|
|
14838
|
+
"warning",
|
|
14839
|
+
rel,
|
|
14840
|
+
"ddp.antiGoalsBannedPattern"
|
|
14841
|
+
)
|
|
14842
|
+
);
|
|
14843
|
+
}
|
|
14844
|
+
if (FIGMA_URL_RE.test(section)) {
|
|
14845
|
+
issues.push(
|
|
14846
|
+
issue(
|
|
14847
|
+
"QFAI-DDP-010",
|
|
14848
|
+
"DDP contains hard reference to external tool (Figma URL); DDP should be tool-independent",
|
|
14849
|
+
"error",
|
|
14850
|
+
rel,
|
|
14851
|
+
"ddp.externalToolDependency"
|
|
14852
|
+
)
|
|
14853
|
+
);
|
|
14854
|
+
}
|
|
14855
|
+
}
|
|
14856
|
+
if (isUiBearing && !ddpFound) {
|
|
14857
|
+
issues.push(
|
|
14858
|
+
issue(
|
|
14859
|
+
"QFAI-DDP-006",
|
|
14860
|
+
"UI-bearing artifact detected but no Design Direction Pack found",
|
|
14861
|
+
"error",
|
|
14862
|
+
void 0,
|
|
14863
|
+
"ddp.uiBearingNoDdp"
|
|
14864
|
+
)
|
|
14865
|
+
);
|
|
14866
|
+
}
|
|
14867
|
+
await validateResearchTraceability(root, config, issues);
|
|
14868
|
+
await validateStoryWorkshopTemplates(root, config, issues, files);
|
|
14869
|
+
validateQualityProfile(config, issues);
|
|
14870
|
+
await validateOptionComparison(root, config, issues);
|
|
14871
|
+
await validateCompetitiveRefs(root, config, issues);
|
|
14872
|
+
return issues;
|
|
14873
|
+
}
|
|
14874
|
+
var LIST_TEMPLATE_REQUIRED_FIELDS = [
|
|
14875
|
+
"screen_type",
|
|
14876
|
+
"items_source",
|
|
14877
|
+
"empty_state",
|
|
14878
|
+
"sort_filter_controls",
|
|
14879
|
+
"pagination_or_infinite_scroll",
|
|
14880
|
+
"primary_cta"
|
|
14881
|
+
];
|
|
14882
|
+
var FORM_TEMPLATE_REQUIRED_FIELDS = [
|
|
14883
|
+
"screen_type",
|
|
14884
|
+
"fields",
|
|
14885
|
+
"required_fields_count",
|
|
14886
|
+
"submit_cta",
|
|
14887
|
+
"validation_feedback",
|
|
14888
|
+
"cancel_or_back_action"
|
|
14889
|
+
];
|
|
14890
|
+
var ANTI_PATTERN_CHECKS = [
|
|
14891
|
+
{
|
|
14892
|
+
pattern: /dual\s+primary\s+CTA|two\s+primary\s+CTA|2\s+primary\s+CTA/i,
|
|
14893
|
+
id: "dual-primary-cta",
|
|
14894
|
+
message: "Anti-pattern detected: dual primary CTA",
|
|
14895
|
+
guidance: "A screen should have exactly one primary CTA to avoid user confusion."
|
|
14896
|
+
},
|
|
14897
|
+
{
|
|
14898
|
+
pattern: /required.fields.*(?:[89]|\d{2,})|required_fields_count\s*:\s*(?:[89]|\d{2,})/i,
|
|
14899
|
+
id: "too-many-required-fields",
|
|
14900
|
+
message: "Anti-pattern detected: form has more than 7 required fields",
|
|
14901
|
+
guidance: "Keep required fields to 7 or fewer to reduce form abandonment."
|
|
14902
|
+
},
|
|
14903
|
+
{
|
|
14904
|
+
pattern: /empty.state(?:(?!action|cta|button|link).)*$/im,
|
|
14905
|
+
id: "empty-state-no-action",
|
|
14906
|
+
message: "Anti-pattern detected: empty state without action",
|
|
14907
|
+
guidance: "Empty states should include an actionable CTA to guide users forward."
|
|
14908
|
+
}
|
|
14909
|
+
];
|
|
14910
|
+
async function validateResearchTraceability(root, config, issues) {
|
|
14911
|
+
const requireResearchSummary = config.uiux?.requireResearchSummary === true;
|
|
14912
|
+
const discussionPattern = import_node_path53.default.posix.join(
|
|
14913
|
+
root.replace(/\\/g, "/"),
|
|
14914
|
+
config.paths.discussionDir,
|
|
14915
|
+
"**/*.md"
|
|
14916
|
+
);
|
|
14917
|
+
const discussionFiles = await (0, import_fast_glob8.default)(discussionPattern, { absolute: true });
|
|
14918
|
+
let hasResearchSummary = false;
|
|
14919
|
+
for (const filePath of discussionFiles) {
|
|
14920
|
+
try {
|
|
14921
|
+
const content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
14922
|
+
if (/research_summary\s*:/m.test(content)) {
|
|
14923
|
+
hasResearchSummary = true;
|
|
14924
|
+
break;
|
|
14925
|
+
}
|
|
14926
|
+
} catch {
|
|
14927
|
+
}
|
|
14928
|
+
}
|
|
14929
|
+
if (requireResearchSummary && !hasResearchSummary) {
|
|
14930
|
+
issues.push(
|
|
14931
|
+
issue(
|
|
14932
|
+
"QFAI-DDP-012",
|
|
14933
|
+
"Research summary required by config (requireResearchSummary=true) but not found in discussion pack",
|
|
14934
|
+
"error",
|
|
14935
|
+
void 0,
|
|
14936
|
+
"ddp.researchSummaryRequired"
|
|
14937
|
+
)
|
|
14938
|
+
);
|
|
14939
|
+
}
|
|
14940
|
+
const contractPattern = import_node_path53.default.posix.join(
|
|
14941
|
+
root.replace(/\\/g, "/"),
|
|
14942
|
+
config.paths.contractsDir,
|
|
14943
|
+
"design",
|
|
14944
|
+
"*.yaml"
|
|
14945
|
+
);
|
|
14946
|
+
const contractFiles = await (0, import_fast_glob8.default)(contractPattern, { absolute: true });
|
|
14947
|
+
for (const filePath of contractFiles) {
|
|
14948
|
+
let content;
|
|
14949
|
+
try {
|
|
14950
|
+
content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
14951
|
+
} catch {
|
|
14952
|
+
continue;
|
|
14953
|
+
}
|
|
14954
|
+
const rel = import_node_path53.default.relative(root, filePath).replace(/\\/g, "/");
|
|
14955
|
+
const ruleBlocks = content.split(/(?=^-\s+(?:rule|id)\s*:)/m).filter((b) => b.trim());
|
|
14956
|
+
for (const block of ruleBlocks) {
|
|
14957
|
+
const idMatch = /(?:^|\n)\s*(?:-\s+)?id\s*:\s*(\S+)/m.exec(block);
|
|
14958
|
+
const ruleId = idMatch?.[1] ?? "unknown";
|
|
14959
|
+
if (!/source_research\s*:/m.test(block) && /(?:rule|id)\s*:/m.test(block)) {
|
|
14960
|
+
issues.push(
|
|
14961
|
+
issue(
|
|
14962
|
+
"QFAI-DDP-011",
|
|
14963
|
+
`Design contract rule '${ruleId}' missing source_research traceability field`,
|
|
14964
|
+
"warning",
|
|
14965
|
+
rel,
|
|
14966
|
+
`ddp.researchTraceability.${ruleId}`
|
|
14967
|
+
)
|
|
14968
|
+
);
|
|
14969
|
+
}
|
|
14970
|
+
}
|
|
14971
|
+
}
|
|
14972
|
+
}
|
|
14973
|
+
async function validateStoryWorkshopTemplates(root, config, issues, discussionFiles) {
|
|
14974
|
+
for (const filePath of discussionFiles) {
|
|
14975
|
+
let content;
|
|
14976
|
+
try {
|
|
14977
|
+
content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
14978
|
+
} catch {
|
|
14979
|
+
continue;
|
|
14980
|
+
}
|
|
14981
|
+
const rel = import_node_path53.default.relative(root, filePath).replace(/\\/g, "/");
|
|
14982
|
+
if (/screen_type\s*:\s*list/im.test(content)) {
|
|
14983
|
+
for (const field of LIST_TEMPLATE_REQUIRED_FIELDS) {
|
|
14984
|
+
if (!hasNonEmptyField(content, field)) {
|
|
14985
|
+
issues.push(
|
|
14986
|
+
issue(
|
|
14987
|
+
"QFAI-DDP-013",
|
|
14988
|
+
`List template missing required field: ${field}`,
|
|
14989
|
+
"error",
|
|
14990
|
+
rel,
|
|
14991
|
+
`ddp.listTemplate.${field}`
|
|
14992
|
+
)
|
|
14993
|
+
);
|
|
14994
|
+
}
|
|
14995
|
+
}
|
|
14996
|
+
if (hasFieldKey(content, "density_rationale") && !hasNonEmptyField(content, "density_rationale")) {
|
|
14997
|
+
issues.push(
|
|
14998
|
+
issue(
|
|
14999
|
+
"QFAI-DDP-013",
|
|
15000
|
+
"List template density_rationale is empty",
|
|
15001
|
+
"error",
|
|
15002
|
+
rel,
|
|
15003
|
+
"ddp.listTemplate.density_rationale"
|
|
15004
|
+
)
|
|
15005
|
+
);
|
|
15006
|
+
}
|
|
15007
|
+
}
|
|
15008
|
+
if (/screen_type\s*:\s*form/im.test(content)) {
|
|
15009
|
+
for (const field of FORM_TEMPLATE_REQUIRED_FIELDS) {
|
|
15010
|
+
if (!hasNonEmptyField(content, field)) {
|
|
15011
|
+
issues.push(
|
|
15012
|
+
issue(
|
|
15013
|
+
"QFAI-DDP-013",
|
|
15014
|
+
`Form template missing required field: ${field}`,
|
|
15015
|
+
"error",
|
|
15016
|
+
rel,
|
|
15017
|
+
`ddp.formTemplate.${field}`
|
|
15018
|
+
)
|
|
15019
|
+
);
|
|
15020
|
+
}
|
|
15021
|
+
}
|
|
15022
|
+
const statesBlock = extractNestedBlock(content, "states");
|
|
15023
|
+
if (statesBlock !== null && !hasSubField(statesBlock, "error")) {
|
|
15024
|
+
issues.push(
|
|
15025
|
+
issue(
|
|
15026
|
+
"QFAI-DDP-013",
|
|
15027
|
+
"Form template states.error is missing",
|
|
15028
|
+
"error",
|
|
15029
|
+
rel,
|
|
15030
|
+
"ddp.formTemplate.states.error"
|
|
15031
|
+
)
|
|
15032
|
+
);
|
|
15033
|
+
}
|
|
15034
|
+
}
|
|
15035
|
+
detectAntiPatterns(content, rel, issues);
|
|
15036
|
+
}
|
|
15037
|
+
}
|
|
15038
|
+
function detectAntiPatterns(content, rel, issues) {
|
|
15039
|
+
for (const check of ANTI_PATTERN_CHECKS) {
|
|
15040
|
+
if (check.pattern.test(content)) {
|
|
15041
|
+
issues.push(
|
|
15042
|
+
issue(
|
|
15043
|
+
"QFAI-DDP-014",
|
|
15044
|
+
check.message,
|
|
15045
|
+
"error",
|
|
15046
|
+
rel,
|
|
15047
|
+
`ddp.antiPattern.${check.id}`,
|
|
15048
|
+
void 0,
|
|
15049
|
+
"compatibility",
|
|
15050
|
+
check.guidance
|
|
15051
|
+
)
|
|
15052
|
+
);
|
|
15053
|
+
}
|
|
15054
|
+
}
|
|
15055
|
+
}
|
|
15056
|
+
function validateQualityProfile(config, issues) {
|
|
15057
|
+
const profile = config.uiux?.qualityProfile;
|
|
15058
|
+
if (profile === "strict") {
|
|
15059
|
+
issues.push(
|
|
15060
|
+
issue(
|
|
15061
|
+
"QFAI-DDP-015",
|
|
15062
|
+
"Quality profile is set to 'strict': all DDP warnings are enforced as errors",
|
|
15063
|
+
"info",
|
|
15064
|
+
void 0,
|
|
15065
|
+
"ddp.qualityProfile.strict"
|
|
15066
|
+
)
|
|
15067
|
+
);
|
|
15068
|
+
} else if (profile === "high") {
|
|
15069
|
+
issues.push(
|
|
15070
|
+
issue(
|
|
15071
|
+
"QFAI-DDP-015",
|
|
15072
|
+
"Quality profile is set to 'high': all DDP warnings are shown",
|
|
15073
|
+
"info",
|
|
15074
|
+
void 0,
|
|
15075
|
+
"ddp.qualityProfile.high"
|
|
15076
|
+
)
|
|
15077
|
+
);
|
|
15078
|
+
}
|
|
15079
|
+
}
|
|
15080
|
+
async function validateOptionComparison(root, config, issues) {
|
|
15081
|
+
const contractPattern = import_node_path53.default.posix.join(
|
|
15082
|
+
root.replace(/\\/g, "/"),
|
|
15083
|
+
config.paths.contractsDir,
|
|
15084
|
+
"design",
|
|
15085
|
+
"*.yaml"
|
|
15086
|
+
);
|
|
15087
|
+
const contractFiles = await (0, import_fast_glob8.default)(contractPattern, { absolute: true });
|
|
15088
|
+
for (const filePath of contractFiles) {
|
|
15089
|
+
let content;
|
|
15090
|
+
try {
|
|
15091
|
+
content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
15092
|
+
} catch {
|
|
15093
|
+
continue;
|
|
15094
|
+
}
|
|
15095
|
+
const rel = import_node_path53.default.relative(root, filePath).replace(/\\/g, "/");
|
|
15096
|
+
const optionMatches = content.match(/(?:^|\n)\s*-\s+option\s*:/gm) ?? [];
|
|
15097
|
+
const numberedOptions = content.match(/(?:^|\n)\s*option_\d+\s*:/gm) ?? [];
|
|
15098
|
+
const totalOptions = optionMatches.length + numberedOptions.length;
|
|
15099
|
+
if (totalOptions === 1) {
|
|
15100
|
+
issues.push(
|
|
15101
|
+
issue(
|
|
15102
|
+
"QFAI-DDP-016",
|
|
15103
|
+
"Design contract has only 1 option; at least 2 options are required for comparison",
|
|
15104
|
+
"error",
|
|
15105
|
+
rel,
|
|
15106
|
+
"ddp.optionComparison.insufficient"
|
|
15107
|
+
)
|
|
15108
|
+
);
|
|
15109
|
+
} else if (totalOptions >= 2) {
|
|
15110
|
+
const lines = content.split(/\r?\n/);
|
|
15111
|
+
for (const line of lines) {
|
|
15112
|
+
const prosMatch = /^\s*pros\s*:\s*(.*)$/m.exec(line);
|
|
15113
|
+
if (prosMatch && prosMatch[1]?.trim() === "") {
|
|
15114
|
+
issues.push(
|
|
15115
|
+
issue(
|
|
15116
|
+
"QFAI-DDP-016",
|
|
15117
|
+
"Design option has empty pros field",
|
|
15118
|
+
"warning",
|
|
15119
|
+
rel,
|
|
15120
|
+
"ddp.optionComparison.emptyPros"
|
|
15121
|
+
)
|
|
15122
|
+
);
|
|
15123
|
+
}
|
|
15124
|
+
}
|
|
15125
|
+
}
|
|
15126
|
+
}
|
|
15127
|
+
}
|
|
15128
|
+
async function validateCompetitiveRefs(root, config, issues) {
|
|
15129
|
+
const minRefs = config.uiux?.competitive_refs_min ?? 3;
|
|
15130
|
+
const contractPattern = import_node_path53.default.posix.join(
|
|
15131
|
+
root.replace(/\\/g, "/"),
|
|
15132
|
+
config.paths.contractsDir,
|
|
15133
|
+
"design",
|
|
15134
|
+
"*.yaml"
|
|
15135
|
+
);
|
|
15136
|
+
const contractFiles = await (0, import_fast_glob8.default)(contractPattern, { absolute: true });
|
|
15137
|
+
for (const filePath of contractFiles) {
|
|
15138
|
+
let content;
|
|
15139
|
+
try {
|
|
15140
|
+
content = await (0, import_promises50.readFile)(filePath, "utf-8");
|
|
15141
|
+
} catch {
|
|
15142
|
+
continue;
|
|
15143
|
+
}
|
|
15144
|
+
const rel = import_node_path53.default.relative(root, filePath).replace(/\\/g, "/");
|
|
15145
|
+
const refsBlock = extractNestedBlock(content, "competitive_refs");
|
|
15146
|
+
if (refsBlock !== null) {
|
|
15147
|
+
const refItems = refsBlock.split(/\n/).filter((l) => /^\s*-\s/.test(l));
|
|
15148
|
+
if (refItems.length < minRefs) {
|
|
15149
|
+
issues.push(
|
|
15150
|
+
issue(
|
|
15151
|
+
"QFAI-DDP-017",
|
|
15152
|
+
`Competitive references insufficient: found ${refItems.length}, minimum ${minRefs} required`,
|
|
15153
|
+
"error",
|
|
15154
|
+
rel,
|
|
15155
|
+
"ddp.competitiveRefs.insufficient"
|
|
15156
|
+
)
|
|
15157
|
+
);
|
|
15158
|
+
}
|
|
15159
|
+
} else {
|
|
15160
|
+
const inlineRefs = getFieldValue(content, "competitive_refs");
|
|
15161
|
+
if (inlineRefs !== null) {
|
|
15162
|
+
if (1 < minRefs) {
|
|
15163
|
+
issues.push(
|
|
15164
|
+
issue(
|
|
15165
|
+
"QFAI-DDP-017",
|
|
15166
|
+
`Competitive references insufficient: found 1, minimum ${minRefs} required`,
|
|
15167
|
+
"error",
|
|
15168
|
+
rel,
|
|
15169
|
+
"ddp.competitiveRefs.insufficient"
|
|
15170
|
+
)
|
|
15171
|
+
);
|
|
15172
|
+
}
|
|
15173
|
+
}
|
|
15174
|
+
}
|
|
15175
|
+
if (/competitive_refs/m.test(content) && !/translation_policy\s*:/m.test(content)) {
|
|
15176
|
+
issues.push(
|
|
15177
|
+
issue(
|
|
15178
|
+
"QFAI-DDP-018",
|
|
15179
|
+
"Translation policy missing in design contract with competitive references",
|
|
15180
|
+
"warning",
|
|
15181
|
+
rel,
|
|
15182
|
+
"ddp.translationPolicy.missing"
|
|
15183
|
+
)
|
|
15184
|
+
);
|
|
15185
|
+
}
|
|
15186
|
+
}
|
|
15187
|
+
}
|
|
15188
|
+
function extractDdpSection(content) {
|
|
15189
|
+
const heading = DDP_HEADING_RE.exec(content);
|
|
15190
|
+
if (!heading) return null;
|
|
15191
|
+
const start = heading.index + heading[0].length;
|
|
15192
|
+
const remainder = content.slice(start);
|
|
15193
|
+
const nextHeadingOffset = remainder.search(/^#{1,3}\s+/m);
|
|
15194
|
+
const section = nextHeadingOffset === -1 ? remainder : remainder.slice(0, nextHeadingOffset);
|
|
15195
|
+
return section.trim();
|
|
15196
|
+
}
|
|
15197
|
+
function hasFieldKey(section, field) {
|
|
15198
|
+
const re = new RegExp(`^${field}\\s*:`, "m");
|
|
15199
|
+
return re.test(section);
|
|
15200
|
+
}
|
|
15201
|
+
function hasNonEmptyField(section, field) {
|
|
15202
|
+
const re = new RegExp(`^${field}\\s*:(.*)`, "m");
|
|
15203
|
+
const match = re.exec(section);
|
|
15204
|
+
if (!match) return false;
|
|
15205
|
+
const inlineValue = (match[1] ?? "").trim();
|
|
15206
|
+
if (inlineValue) return true;
|
|
15207
|
+
const matchEnd = match.index + match[0].length;
|
|
15208
|
+
const remainder = section.slice(matchEnd);
|
|
15209
|
+
const lines = remainder.split(/\r?\n/);
|
|
15210
|
+
for (const line of lines) {
|
|
15211
|
+
const trimmed = line.trim();
|
|
15212
|
+
if (!trimmed) continue;
|
|
15213
|
+
if (/^\s+/.test(line) && trimmed.length > 0) return true;
|
|
15214
|
+
break;
|
|
15215
|
+
}
|
|
15216
|
+
return false;
|
|
15217
|
+
}
|
|
15218
|
+
function getFieldValue(section, field) {
|
|
15219
|
+
const re = new RegExp(`^${field}\\s*:(.*)`, "m");
|
|
15220
|
+
const match = re.exec(section);
|
|
15221
|
+
if (!match) return null;
|
|
15222
|
+
const inlineValue = (match[1] ?? "").trim();
|
|
15223
|
+
if (inlineValue) return inlineValue;
|
|
15224
|
+
const matchEnd = match.index + match[0].length;
|
|
15225
|
+
const remainder = section.slice(matchEnd);
|
|
15226
|
+
const lines = remainder.split(/\r?\n/);
|
|
15227
|
+
const blockLines = [];
|
|
15228
|
+
for (const line of lines) {
|
|
15229
|
+
const trimmed = line.trim();
|
|
15230
|
+
if (!trimmed && blockLines.length === 0) continue;
|
|
15231
|
+
if (/^\s+/.test(line) && trimmed.length > 0) {
|
|
15232
|
+
blockLines.push(trimmed);
|
|
15233
|
+
} else if (blockLines.length > 0 || !trimmed && blockLines.length === 0) {
|
|
15234
|
+
if (!trimmed && blockLines.length === 0) continue;
|
|
15235
|
+
break;
|
|
15236
|
+
} else {
|
|
15237
|
+
break;
|
|
15238
|
+
}
|
|
15239
|
+
}
|
|
15240
|
+
return blockLines.length > 0 ? blockLines.join("\n") : null;
|
|
15241
|
+
}
|
|
15242
|
+
function extractNestedBlock(section, field) {
|
|
15243
|
+
const re = new RegExp(`^${field}\\s*:(.*)`, "m");
|
|
15244
|
+
const match = re.exec(section);
|
|
15245
|
+
if (!match) return null;
|
|
15246
|
+
const inlineValue = (match[1] ?? "").trim();
|
|
15247
|
+
if (inlineValue) return null;
|
|
15248
|
+
const matchEnd = match.index + match[0].length;
|
|
15249
|
+
const remainder = section.slice(matchEnd);
|
|
15250
|
+
const lines = remainder.split(/\r?\n/);
|
|
15251
|
+
const blockLines = [];
|
|
15252
|
+
for (const line of lines) {
|
|
15253
|
+
const trimmed = line.trim();
|
|
15254
|
+
if (!trimmed && blockLines.length === 0) continue;
|
|
15255
|
+
if (/^\s+/.test(line) && trimmed.length > 0) {
|
|
15256
|
+
blockLines.push(line);
|
|
15257
|
+
} else if (blockLines.length > 0) {
|
|
15258
|
+
break;
|
|
15259
|
+
} else if (trimmed) {
|
|
15260
|
+
break;
|
|
15261
|
+
}
|
|
15262
|
+
}
|
|
15263
|
+
return blockLines.length > 0 ? blockLines.join("\n") : null;
|
|
15264
|
+
}
|
|
15265
|
+
function hasSubField(block, subField) {
|
|
15266
|
+
const re = new RegExp(`^\\s*${subField}\\s*:\\s*\\S`, "m");
|
|
15267
|
+
return re.test(block);
|
|
15268
|
+
}
|
|
15269
|
+
function collectListItems(section, field) {
|
|
15270
|
+
const re = new RegExp(`^${field}\\s*:(.*)`, "m");
|
|
15271
|
+
const match = re.exec(section);
|
|
15272
|
+
if (!match) return [];
|
|
15273
|
+
const matchEnd = match.index + match[0].length;
|
|
15274
|
+
const remainder = section.slice(matchEnd);
|
|
15275
|
+
const lines = remainder.split(/\r?\n/);
|
|
15276
|
+
const items = [];
|
|
15277
|
+
for (const line of lines) {
|
|
15278
|
+
const trimmed = line.trim();
|
|
15279
|
+
if (!trimmed && items.length === 0) continue;
|
|
15280
|
+
if (/^\s+-\s/.test(line)) {
|
|
15281
|
+
items.push(trimmed.replace(/^-\s*/, ""));
|
|
15282
|
+
} else if (/^\s+\S/.test(line) && items.length > 0) {
|
|
15283
|
+
continue;
|
|
15284
|
+
} else if (trimmed && !/^\s/.test(line)) {
|
|
15285
|
+
break;
|
|
15286
|
+
} else if (!trimmed && items.length > 0) {
|
|
15287
|
+
break;
|
|
15288
|
+
}
|
|
15289
|
+
}
|
|
15290
|
+
return items;
|
|
15291
|
+
}
|
|
15292
|
+
|
|
15293
|
+
// src/core/validators/navigationFlow.ts
|
|
15294
|
+
var import_promises51 = require("fs/promises");
|
|
15295
|
+
var NODE_DEF_RE = /([A-Za-z_][\w-]*)\s*(?:\[.*?\]|\(.*?\)|\{.*?\})/g;
|
|
15296
|
+
var EDGE_RE = /([A-Za-z_][\w-]*)(?:\s*(?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\}))?(?:::\w+)?\s*(?:--+>|==+>|-.->|~~>)\s*(?:\|"?([^"|]*)"?\|)?\s*([A-Za-z_][\w-]*)(?:\s*(?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\}))?(?:::\w+)?/g;
|
|
15297
|
+
var SUBGRAPH_RE = /^\s*subgraph\s+([\w-]+)/gm;
|
|
15298
|
+
var FLOWCHART_DECL_RE = /^\s*(?:flowchart|graph)\s+(TD|TB|BT|RL|LR)\s*$/m;
|
|
15299
|
+
var MODERN_FLOWCHART_RE = /^\s*flowchart\s+(TD|TB|BT|RL|LR)\s*$/m;
|
|
15300
|
+
var TERMINAL_TEXT_RE = /\b(?:end|terminal|finish|complete|done|終了|完了)\b/i;
|
|
15301
|
+
var ERROR_NODE_CLASS_RE = /:::\s*error\b/;
|
|
15302
|
+
var ERROR_PREFIX_RE = /^err-/;
|
|
15303
|
+
function parseFlowchart(content) {
|
|
15304
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
15305
|
+
const edges = [];
|
|
15306
|
+
const subgraphs = [];
|
|
15307
|
+
for (const match of content.matchAll(SUBGRAPH_RE)) {
|
|
15308
|
+
if (match[1]) subgraphs.push(match[1]);
|
|
15309
|
+
}
|
|
15310
|
+
const lines = content.split("\n");
|
|
15311
|
+
for (const line of lines) {
|
|
15312
|
+
const trimmed = line.trim();
|
|
15313
|
+
if (trimmed.startsWith("%%") || trimmed.startsWith("subgraph") || trimmed === "end" || FLOWCHART_DECL_RE.test(trimmed)) {
|
|
15314
|
+
continue;
|
|
15315
|
+
}
|
|
15316
|
+
for (const match of line.matchAll(NODE_DEF_RE)) {
|
|
15317
|
+
const id = match[1] ?? "";
|
|
15318
|
+
const fullMatch = match[0];
|
|
15319
|
+
const bracketContent = fullMatch.match(/\[([^\]]*)\]/)?.[1] ?? fullMatch.match(/\(([^)]*)\)/)?.[1] ?? fullMatch.match(/\{([^}]*)\}/)?.[1] ?? id;
|
|
15320
|
+
const displayText = bracketContent.replace(/^["']|["']$/g, "");
|
|
15321
|
+
const isError = ERROR_PREFIX_RE.test(id) || ERROR_NODE_CLASS_RE.test(line);
|
|
15322
|
+
const isTerminal = TERMINAL_TEXT_RE.test(displayText);
|
|
15323
|
+
if (!nodes.has(id)) {
|
|
15324
|
+
nodes.set(id, { id, displayText, isTerminal, isError });
|
|
15325
|
+
}
|
|
15326
|
+
}
|
|
15327
|
+
}
|
|
15328
|
+
for (const match of content.matchAll(EDGE_RE)) {
|
|
15329
|
+
const from = match[1] ?? "";
|
|
15330
|
+
const label = match[2] !== void 0 && match[2] !== "" ? match[2] : null;
|
|
15331
|
+
const to = match[3] ?? "";
|
|
15332
|
+
edges.push({ from, to, label });
|
|
15333
|
+
if (!nodes.has(from)) {
|
|
15334
|
+
nodes.set(from, {
|
|
15335
|
+
id: from,
|
|
15336
|
+
displayText: from,
|
|
15337
|
+
isTerminal: false,
|
|
15338
|
+
isError: ERROR_PREFIX_RE.test(from)
|
|
15339
|
+
});
|
|
15340
|
+
}
|
|
15341
|
+
if (!nodes.has(to)) {
|
|
15342
|
+
nodes.set(to, {
|
|
15343
|
+
id: to,
|
|
15344
|
+
displayText: to,
|
|
15345
|
+
isTerminal: false,
|
|
15346
|
+
isError: ERROR_PREFIX_RE.test(to)
|
|
15347
|
+
});
|
|
15348
|
+
}
|
|
15349
|
+
}
|
|
15350
|
+
return { nodes, edges, subgraphs, raw: content };
|
|
15351
|
+
}
|
|
15352
|
+
function hasFlowchartDeclaration(content) {
|
|
15353
|
+
return FLOWCHART_DECL_RE.test(content);
|
|
15354
|
+
}
|
|
15355
|
+
function checkMermaidSyntax(content, file) {
|
|
15356
|
+
const issues = [];
|
|
15357
|
+
if (hasFlowchartDeclaration(content) && !MODERN_FLOWCHART_RE.test(content)) {
|
|
15358
|
+
issues.push(
|
|
15359
|
+
issue(
|
|
15360
|
+
"QFAI-NAV-001",
|
|
15361
|
+
`Mermaid block uses legacy "graph" keyword. Use "flowchart" instead.`,
|
|
15362
|
+
"error",
|
|
15363
|
+
file,
|
|
15364
|
+
"navigationFlow.mermaid.syntax"
|
|
15365
|
+
)
|
|
15366
|
+
);
|
|
15367
|
+
return issues;
|
|
15368
|
+
}
|
|
15369
|
+
if (!hasFlowchartDeclaration(content)) {
|
|
15370
|
+
issues.push(
|
|
15371
|
+
issue(
|
|
15372
|
+
"QFAI-NAV-001",
|
|
15373
|
+
`Mermaid block does not contain a valid flowchart declaration.`,
|
|
15374
|
+
"error",
|
|
15375
|
+
file,
|
|
15376
|
+
"navigationFlow.mermaid.syntax"
|
|
15377
|
+
)
|
|
15378
|
+
);
|
|
15379
|
+
return issues;
|
|
15380
|
+
}
|
|
15381
|
+
const subgraphOpens = (content.match(/^\s*subgraph\b/gm) ?? []).length;
|
|
15382
|
+
const subgraphCloses = (content.match(/^\s*end\b/gm) ?? []).length;
|
|
15383
|
+
if (subgraphOpens !== subgraphCloses) {
|
|
15384
|
+
issues.push(
|
|
15385
|
+
issue(
|
|
15386
|
+
"QFAI-NAV-001",
|
|
15387
|
+
`Mermaid flowchart has mismatched subgraph/end blocks (${subgraphOpens} subgraph vs ${subgraphCloses} end).`,
|
|
15388
|
+
"error",
|
|
15389
|
+
file,
|
|
15390
|
+
"navigationFlow.mermaid.syntax"
|
|
15391
|
+
)
|
|
15392
|
+
);
|
|
15393
|
+
}
|
|
15394
|
+
return issues;
|
|
15395
|
+
}
|
|
15396
|
+
function checkUnlabeledEdges(parsed, file) {
|
|
15397
|
+
const issues = [];
|
|
15398
|
+
for (const edge of parsed.edges) {
|
|
15399
|
+
if (edge.label === null) {
|
|
15400
|
+
issues.push(
|
|
15401
|
+
issue(
|
|
15402
|
+
"QFAI-NAV-002",
|
|
15403
|
+
`Edge ${edge.from} \u2192 ${edge.to} has no label. All edges must be labeled.`,
|
|
15404
|
+
"warning",
|
|
15405
|
+
file,
|
|
15406
|
+
"navigationFlow.edge.label"
|
|
15407
|
+
)
|
|
15408
|
+
);
|
|
15409
|
+
}
|
|
15410
|
+
}
|
|
15411
|
+
return issues;
|
|
15412
|
+
}
|
|
15413
|
+
function checkDeadEndNodes(parsed, file) {
|
|
15414
|
+
const issues = [];
|
|
15415
|
+
const outgoingCount = /* @__PURE__ */ new Map();
|
|
15416
|
+
for (const [id] of parsed.nodes) {
|
|
15417
|
+
outgoingCount.set(id, 0);
|
|
15418
|
+
}
|
|
15419
|
+
for (const edge of parsed.edges) {
|
|
15420
|
+
outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
|
|
15421
|
+
}
|
|
15422
|
+
for (const [id, count] of outgoingCount) {
|
|
15423
|
+
if (count === 0) {
|
|
15424
|
+
const node = parsed.nodes.get(id);
|
|
15425
|
+
if (node && !node.isTerminal) {
|
|
15426
|
+
issues.push(
|
|
15427
|
+
issue(
|
|
15428
|
+
"QFAI-NAV-003",
|
|
15429
|
+
`Node "${id}" has no outgoing edges and is not marked as terminal.`,
|
|
15430
|
+
"warning",
|
|
15431
|
+
file,
|
|
15432
|
+
"navigationFlow.node.deadEnd"
|
|
15433
|
+
)
|
|
15434
|
+
);
|
|
15435
|
+
}
|
|
15436
|
+
}
|
|
15437
|
+
}
|
|
15438
|
+
return issues;
|
|
15439
|
+
}
|
|
15440
|
+
function checkReachability(parsed, file) {
|
|
15441
|
+
const issues = [];
|
|
15442
|
+
if (parsed.nodes.size === 0) return issues;
|
|
15443
|
+
const firstEdge = parsed.edges[0];
|
|
15444
|
+
const entryId = firstEdge?.from ?? parsed.nodes.keys().next().value;
|
|
15445
|
+
if (!entryId) return issues;
|
|
15446
|
+
const visited = /* @__PURE__ */ new Set();
|
|
15447
|
+
const queue = [entryId];
|
|
15448
|
+
visited.add(entryId);
|
|
15449
|
+
while (queue.length > 0) {
|
|
15450
|
+
const current = queue.shift() ?? "";
|
|
15451
|
+
for (const edge of parsed.edges) {
|
|
15452
|
+
if (edge.from === current && !visited.has(edge.to)) {
|
|
15453
|
+
visited.add(edge.to);
|
|
15454
|
+
queue.push(edge.to);
|
|
15455
|
+
}
|
|
15456
|
+
}
|
|
15457
|
+
}
|
|
15458
|
+
for (const [id] of parsed.nodes) {
|
|
15459
|
+
if (!visited.has(id)) {
|
|
15460
|
+
issues.push(
|
|
15461
|
+
issue(
|
|
15462
|
+
"QFAI-NAV-004",
|
|
15463
|
+
`Node "${id}" is unreachable from entry point "${entryId}".`,
|
|
15464
|
+
"error",
|
|
15465
|
+
file,
|
|
15466
|
+
"navigationFlow.node.unreachable"
|
|
15467
|
+
)
|
|
15468
|
+
);
|
|
15469
|
+
}
|
|
15470
|
+
}
|
|
15471
|
+
return issues;
|
|
15472
|
+
}
|
|
15473
|
+
function checkErrorRecovery(parsed, file) {
|
|
15474
|
+
const issues = [];
|
|
15475
|
+
for (const [id, node] of parsed.nodes) {
|
|
15476
|
+
if (!node.isError) continue;
|
|
15477
|
+
const hasOutgoing = parsed.edges.some((e) => e.from === id);
|
|
15478
|
+
if (!hasOutgoing) {
|
|
15479
|
+
issues.push(
|
|
15480
|
+
issue(
|
|
15481
|
+
"QFAI-NAV-005",
|
|
15482
|
+
`Error node "${id}" has no outgoing edge to recover to normal flow.`,
|
|
15483
|
+
"error",
|
|
15484
|
+
file,
|
|
15485
|
+
"navigationFlow.errorNode.recovery"
|
|
15486
|
+
)
|
|
15487
|
+
);
|
|
15488
|
+
}
|
|
15489
|
+
}
|
|
15490
|
+
return issues;
|
|
15491
|
+
}
|
|
15492
|
+
function checkViewportDiffs(content, parsed, file) {
|
|
15493
|
+
const issues = [];
|
|
15494
|
+
const hasViewportSubgraph = parsed.subgraphs.some(
|
|
15495
|
+
(s) => /desktop|mobile|tablet|responsive/i.test(s)
|
|
15496
|
+
);
|
|
15497
|
+
const hasViewportAnnotation = /%%\s*(?:desktop|mobile|tablet|viewport|responsive)/i.test(content) || /\bdesktop\b.*\bmobile\b/i.test(content) || /\bmobile\b.*\bdesktop\b/i.test(content);
|
|
15498
|
+
const hasSharedDeclaration = /\b(?:shared|common)\b/i.test(content);
|
|
15499
|
+
if (!hasViewportSubgraph && !hasViewportAnnotation && !hasSharedDeclaration) {
|
|
15500
|
+
issues.push(
|
|
15501
|
+
issue(
|
|
15502
|
+
"QFAI-NAV-006",
|
|
15503
|
+
`Flowchart does not document viewport differences. Use subgraphs for desktop/mobile or annotate as "shared".`,
|
|
15504
|
+
"warning",
|
|
15505
|
+
file,
|
|
15506
|
+
"navigationFlow.viewport.diff"
|
|
15507
|
+
)
|
|
15508
|
+
);
|
|
15509
|
+
}
|
|
15510
|
+
return issues;
|
|
15511
|
+
}
|
|
15512
|
+
function checkImplementationAlignment(specContent, parsed, file) {
|
|
15513
|
+
const issues = [];
|
|
15514
|
+
const screenListMatch = specContent.match(/## Screen List\s*\n((?:.*\n)*?)(?=## |$)/);
|
|
15515
|
+
if (!screenListMatch) {
|
|
15516
|
+
return issues;
|
|
15517
|
+
}
|
|
15518
|
+
const screenListBlock = screenListMatch[1] ?? "";
|
|
15519
|
+
const screenNames = [];
|
|
15520
|
+
for (const line of screenListBlock.split("\n")) {
|
|
15521
|
+
const match = line.match(/^\s*[-*]\s+(.+)/);
|
|
15522
|
+
if (match?.[1]) {
|
|
15523
|
+
screenNames.push(match[1].trim());
|
|
15524
|
+
}
|
|
15525
|
+
}
|
|
15526
|
+
if (screenNames.length === 0) return issues;
|
|
15527
|
+
const flowNodeTexts = /* @__PURE__ */ new Set();
|
|
15528
|
+
for (const [, node] of parsed.nodes) {
|
|
15529
|
+
flowNodeTexts.add(node.displayText.toLowerCase());
|
|
15530
|
+
}
|
|
15531
|
+
for (const screen of screenNames) {
|
|
15532
|
+
const screenLower = screen.toLowerCase();
|
|
15533
|
+
const found = [...flowNodeTexts].some(
|
|
15534
|
+
(t) => t.includes(screenLower) || screenLower.includes(t)
|
|
15535
|
+
);
|
|
15536
|
+
if (!found) {
|
|
15537
|
+
issues.push(
|
|
15538
|
+
issue(
|
|
15539
|
+
"QFAI-NAV-007",
|
|
15540
|
+
`Screen "${screen}" in Screen List is not found in flowchart nodes.`,
|
|
15541
|
+
"warning",
|
|
15542
|
+
file,
|
|
15543
|
+
"navigationFlow.alignment.mismatch"
|
|
15544
|
+
)
|
|
15545
|
+
);
|
|
15546
|
+
}
|
|
15547
|
+
}
|
|
15548
|
+
const screenNamesLower = screenNames.map((s) => s.toLowerCase());
|
|
15549
|
+
for (const [, node] of parsed.nodes) {
|
|
15550
|
+
if (node.isTerminal || node.isError) continue;
|
|
15551
|
+
const textLower = node.displayText.toLowerCase();
|
|
15552
|
+
const found = screenNamesLower.some((s) => s.includes(textLower) || textLower.includes(s));
|
|
15553
|
+
if (!found) {
|
|
15554
|
+
issues.push(
|
|
15555
|
+
issue(
|
|
15556
|
+
"QFAI-NAV-007",
|
|
15557
|
+
`Flowchart node "${node.displayText}" has no matching entry in Screen List.`,
|
|
15558
|
+
"warning",
|
|
15559
|
+
file,
|
|
15560
|
+
"navigationFlow.alignment.mismatch"
|
|
15561
|
+
)
|
|
15562
|
+
);
|
|
15563
|
+
}
|
|
15564
|
+
}
|
|
15565
|
+
return issues;
|
|
15566
|
+
}
|
|
15567
|
+
async function validateNavigationFlow(root, config) {
|
|
15568
|
+
const specsDir = resolvePath(root, config, "specsDir");
|
|
15569
|
+
const allFiles = await collectFiles(specsDir, { extensions: [".md"] });
|
|
15570
|
+
const specFiles = allFiles.filter((f) => /[\\/]spec-\d{4}[\\/]/.test(f));
|
|
15571
|
+
const issues = [];
|
|
15572
|
+
for (const file of specFiles) {
|
|
15573
|
+
const content = await (0, import_promises51.readFile)(file, "utf-8");
|
|
15574
|
+
const blocks = extractFencedCodeBlocks(content);
|
|
15575
|
+
const mermaidBlocks = blocks.filter((b) => b.language === "mermaid");
|
|
15576
|
+
const _flowchartBlocks = mermaidBlocks.filter((b) => hasFlowchartDeclaration(b.content));
|
|
15577
|
+
for (const block of mermaidBlocks) {
|
|
15578
|
+
if (!hasFlowchartDeclaration(block.content)) {
|
|
15579
|
+
continue;
|
|
15580
|
+
}
|
|
15581
|
+
issues.push(...checkMermaidSyntax(block.content, file));
|
|
15582
|
+
const parsed = parseFlowchart(block.content);
|
|
15583
|
+
issues.push(...checkUnlabeledEdges(parsed, file));
|
|
15584
|
+
issues.push(...checkDeadEndNodes(parsed, file));
|
|
15585
|
+
issues.push(...checkReachability(parsed, file));
|
|
15586
|
+
issues.push(...checkErrorRecovery(parsed, file));
|
|
15587
|
+
issues.push(...checkViewportDiffs(block.content, parsed, file));
|
|
15588
|
+
issues.push(...checkImplementationAlignment(content, parsed, file));
|
|
15589
|
+
}
|
|
15590
|
+
}
|
|
15591
|
+
return issues;
|
|
15592
|
+
}
|
|
15593
|
+
|
|
15594
|
+
// src/core/validators/renderCritique.ts
|
|
15595
|
+
var import_node_path54 = __toESM(require("path"), 1);
|
|
15596
|
+
var import_fast_glob9 = __toESM(require("fast-glob"), 1);
|
|
15597
|
+
var RENDERED_KEYWORDS_RE = /\b(rendered|screenshot|html\b|preview|visual\s*review)/i;
|
|
15598
|
+
var DDP_REFERENCE_RE = /\b(ddp|design\s*direction\s*pack)\b/i;
|
|
15599
|
+
var READ_ORDER_RE = /DDP[\s\S]{0,40}Design\s*Token[\s\S]{0,40}UI\s*Contract[\s\S]{0,40}HTML\s*Mock[\s\S]{0,40}Flow/i;
|
|
15600
|
+
var DESKTOP_RE = /\b(desktop|1024\s*px|1280\s*px|1440\s*px|viewport\s*[≥>=]+\s*1024)\b/i;
|
|
15601
|
+
var MOBILE_RE = /\b(mobile|480\s*px|375\s*px|390\s*px|viewport\s*[≤<=]+\s*480)\b/i;
|
|
15602
|
+
var EVIDENCE_DATE_RE = /\bdate\s*:/i;
|
|
15603
|
+
var EVIDENCE_VIEWPORT_RE = /\bviewport\s*:/i;
|
|
15604
|
+
var EVIDENCE_VERDICT_RE = /\bverdict\s*:\s*(PASS|REVISE)\b/i;
|
|
15605
|
+
var EVIDENCE_FINDINGS_RE = /\bfindings\s*:/i;
|
|
15606
|
+
var RUBRIC_RE = /\b(rubric|evaluation\s*criteria|scoring\s*guide)\b/i;
|
|
15607
|
+
var TASK_FIDELITY_SECTION_RE = /\btaskFidelity\b/i;
|
|
15608
|
+
var STEP_COUNT_RE = /\bstep_count\s*:\s*(\d+)/i;
|
|
15609
|
+
var CTA_VISIBILITY_RE = /\bcta_visibility\s*:/i;
|
|
15610
|
+
var FOUR_STATE_CHECK_RE = /\bfour_state_check\s*:/i;
|
|
15611
|
+
var MAX_PRIMARY_STEPS_RE = /\bmax_primary_steps\s*:\s*(\d+)/i;
|
|
15612
|
+
async function validateRenderCritique(root, config) {
|
|
15613
|
+
const issues = [];
|
|
15614
|
+
const discussionDir = import_node_path54.default.join(root, config.paths.discussionDir).replace(/\\/g, "/");
|
|
15615
|
+
const discussionFiles = await (0, import_fast_glob9.default)(import_node_path54.default.posix.join(discussionDir, "**/*.md"), { absolute: true });
|
|
15616
|
+
let hasDdp = false;
|
|
15617
|
+
for (const df of discussionFiles) {
|
|
15618
|
+
const content = await readSafe(df);
|
|
15619
|
+
if (/^#{1,3}\s+Design\s+Direction\s+Pack/im.test(content)) {
|
|
15620
|
+
hasDdp = true;
|
|
15621
|
+
break;
|
|
15622
|
+
}
|
|
15623
|
+
}
|
|
15624
|
+
if (!hasDdp) return issues;
|
|
15625
|
+
const skillsDir = import_node_path54.default.join(root, config.paths.skillsDir).replace(/\\/g, "/");
|
|
15626
|
+
const evidenceDir = import_node_path54.default.join(root, ".qfai", "evidence").replace(/\\/g, "/");
|
|
15627
|
+
const skillPromptPattern = import_node_path54.default.posix.join(skillsDir, "qfai-{prototyping,implement}*/SKILL.md");
|
|
15628
|
+
const skillFiles = await (0, import_fast_glob9.default)(skillPromptPattern, { dot: true });
|
|
15629
|
+
const evidencePattern = import_node_path54.default.posix.join(evidenceDir, "{prototyping*,critique-*}.md");
|
|
15630
|
+
const evidenceFiles = await (0, import_fast_glob9.default)(evidencePattern, { dot: true });
|
|
15631
|
+
for (const sf of skillFiles) {
|
|
15632
|
+
const content = await readSafe(sf);
|
|
15633
|
+
if (content.length > 0 && !RENDERED_KEYWORDS_RE.test(content)) {
|
|
15634
|
+
issues.push(
|
|
15635
|
+
issue(
|
|
15636
|
+
"QFAI-CRIT-001",
|
|
15637
|
+
`Skill prompt does not mention rendered/screenshot/HTML review: ${import_node_path54.default.relative(root, sf)}`,
|
|
15638
|
+
"error",
|
|
15639
|
+
sf,
|
|
15640
|
+
"renderCritique.codeOnly",
|
|
15641
|
+
void 0,
|
|
15642
|
+
"change",
|
|
15643
|
+
"Add rendered output review requirement (e.g. 'screenshot', 'rendered', 'HTML') to the skill prompt."
|
|
15644
|
+
)
|
|
15645
|
+
);
|
|
15646
|
+
}
|
|
15647
|
+
}
|
|
15648
|
+
for (const sf of skillFiles) {
|
|
15649
|
+
const content = await readSafe(sf);
|
|
15650
|
+
if (content.length > 0 && !DDP_REFERENCE_RE.test(content)) {
|
|
15651
|
+
issues.push(
|
|
15652
|
+
issue(
|
|
15653
|
+
"QFAI-CRIT-002",
|
|
15654
|
+
`Downstream skill prompt missing DDP reference: ${import_node_path54.default.relative(root, sf)}`,
|
|
15655
|
+
"error",
|
|
15656
|
+
sf,
|
|
15657
|
+
"renderCritique.ddpMissing",
|
|
15658
|
+
void 0,
|
|
15659
|
+
"change",
|
|
15660
|
+
"Add DDP (Design Direction Pack) reference to the downstream skill prompt."
|
|
15661
|
+
)
|
|
15662
|
+
);
|
|
15663
|
+
}
|
|
15664
|
+
}
|
|
15665
|
+
const allEvidenceContent = await collectContent(evidenceFiles);
|
|
15666
|
+
if (evidenceFiles.length > 0 && !DESKTOP_RE.test(allEvidenceContent)) {
|
|
15667
|
+
issues.push(
|
|
15668
|
+
issue(
|
|
15669
|
+
"QFAI-CRIT-003",
|
|
15670
|
+
"No desktop viewport critique found in evidence files (viewport >= 1024px required).",
|
|
15671
|
+
"error",
|
|
15672
|
+
evidenceDir,
|
|
15673
|
+
"renderCritique.desktopMissing",
|
|
15674
|
+
void 0,
|
|
15675
|
+
"change",
|
|
15676
|
+
"Add desktop viewport critique (>= 1024px) to evidence files."
|
|
15677
|
+
)
|
|
15678
|
+
);
|
|
15679
|
+
}
|
|
15680
|
+
if (evidenceFiles.length > 0 && !MOBILE_RE.test(allEvidenceContent)) {
|
|
15681
|
+
issues.push(
|
|
15682
|
+
issue(
|
|
15683
|
+
"QFAI-CRIT-004",
|
|
15684
|
+
"No mobile viewport critique found in evidence files (viewport <= 480px required).",
|
|
15685
|
+
"error",
|
|
15686
|
+
evidenceDir,
|
|
15687
|
+
"renderCritique.mobileMissing",
|
|
15688
|
+
void 0,
|
|
15689
|
+
"change",
|
|
15690
|
+
"Add mobile viewport critique (<= 480px) to evidence files."
|
|
15691
|
+
)
|
|
15692
|
+
);
|
|
15693
|
+
}
|
|
15694
|
+
for (const sf of skillFiles) {
|
|
15695
|
+
const content = await readSafe(sf);
|
|
15696
|
+
if (content.length > 0 && !READ_ORDER_RE.test(content)) {
|
|
15697
|
+
issues.push(
|
|
15698
|
+
issue(
|
|
15699
|
+
"QFAI-CRIT-005",
|
|
15700
|
+
`Read order not specified (DDP \u2192 Design Token \u2192 UI Contract \u2192 HTML Mock \u2192 Flow): ${import_node_path54.default.relative(root, sf)}`,
|
|
15701
|
+
"error",
|
|
15702
|
+
sf,
|
|
15703
|
+
"renderCritique.readOrder",
|
|
15704
|
+
void 0,
|
|
15705
|
+
"change",
|
|
15706
|
+
"Specify the read order: DDP \u2192 Design Token \u2192 UI Contract \u2192 HTML Mock \u2192 Flow."
|
|
15707
|
+
)
|
|
15708
|
+
);
|
|
15709
|
+
}
|
|
15710
|
+
}
|
|
15711
|
+
for (const ef of evidenceFiles) {
|
|
15712
|
+
const content = await readSafe(ef);
|
|
15713
|
+
if (content.length === 0) continue;
|
|
15714
|
+
const hasDate = EVIDENCE_DATE_RE.test(content);
|
|
15715
|
+
const hasViewport = EVIDENCE_VIEWPORT_RE.test(content);
|
|
15716
|
+
const hasVerdict = EVIDENCE_VERDICT_RE.test(content);
|
|
15717
|
+
const hasFindings = EVIDENCE_FINDINGS_RE.test(content);
|
|
15718
|
+
if (!hasDate || !hasViewport || !hasVerdict || !hasFindings) {
|
|
15719
|
+
const missing = [];
|
|
15720
|
+
if (!hasDate) missing.push("date");
|
|
15721
|
+
if (!hasViewport) missing.push("viewport");
|
|
15722
|
+
if (!hasVerdict) missing.push("verdict");
|
|
15723
|
+
if (!hasFindings) missing.push("findings");
|
|
15724
|
+
issues.push(
|
|
15725
|
+
issue(
|
|
15726
|
+
"QFAI-CRIT-006",
|
|
15727
|
+
`Critique evidence incomplete (missing: ${missing.join(", ")}): ${import_node_path54.default.relative(root, ef)}`,
|
|
15728
|
+
"error",
|
|
15729
|
+
ef,
|
|
15730
|
+
"renderCritique.incompleteEvidence",
|
|
15731
|
+
void 0,
|
|
15732
|
+
"change",
|
|
15733
|
+
"Ensure evidence includes date, viewport, verdict (PASS/REVISE), and findings."
|
|
15734
|
+
)
|
|
15735
|
+
);
|
|
15736
|
+
}
|
|
15737
|
+
}
|
|
15738
|
+
if (evidenceFiles.length > 0 && !RUBRIC_RE.test(allEvidenceContent)) {
|
|
15739
|
+
issues.push(
|
|
15740
|
+
issue(
|
|
15741
|
+
"QFAI-CRIT-007",
|
|
15742
|
+
"Rubric / evaluation criteria not documented in evidence files.",
|
|
15743
|
+
"warning",
|
|
15744
|
+
evidenceDir,
|
|
15745
|
+
"renderCritique.rubricMissing",
|
|
15746
|
+
void 0,
|
|
15747
|
+
"change",
|
|
15748
|
+
"Document the rubric or evaluation criteria in evidence files for reproducibility."
|
|
15749
|
+
)
|
|
15750
|
+
);
|
|
15751
|
+
}
|
|
15752
|
+
if (evidenceFiles.length > 0) {
|
|
15753
|
+
const desktopPass = hasViewportPass(allEvidenceContent, "desktop");
|
|
15754
|
+
const mobilePass = hasViewportPass(allEvidenceContent, "mobile");
|
|
15755
|
+
if (!desktopPass || !mobilePass) {
|
|
15756
|
+
issues.push(
|
|
15757
|
+
issue(
|
|
15758
|
+
"QFAI-CRIT-008",
|
|
15759
|
+
`Iterative loop not completed: ${!desktopPass ? "desktop" : ""}${!desktopPass && !mobilePass ? " and " : ""}${!mobilePass ? "mobile" : ""} viewport not PASS.`,
|
|
15760
|
+
"error",
|
|
15761
|
+
evidenceDir,
|
|
15762
|
+
"renderCritique.loopNotCompleted",
|
|
15763
|
+
void 0,
|
|
15764
|
+
"change",
|
|
15765
|
+
"Both desktop and mobile viewports must have verdict: PASS for the loop to complete."
|
|
15766
|
+
)
|
|
15767
|
+
);
|
|
15768
|
+
}
|
|
15769
|
+
}
|
|
15770
|
+
if (evidenceFiles.length > 0 && !TASK_FIDELITY_SECTION_RE.test(allEvidenceContent)) {
|
|
15771
|
+
issues.push(
|
|
15772
|
+
issue(
|
|
15773
|
+
"QFAI-CRIT-009",
|
|
15774
|
+
"taskFidelity evaluation not recorded in evidence files.",
|
|
15775
|
+
"error",
|
|
15776
|
+
evidenceDir,
|
|
15777
|
+
"renderCritique.taskFidelityMissing",
|
|
15778
|
+
void 0,
|
|
15779
|
+
"change",
|
|
15780
|
+
"Add taskFidelity section with step_count, cta_visibility, and four_state_check."
|
|
15781
|
+
)
|
|
15782
|
+
);
|
|
15783
|
+
}
|
|
15784
|
+
if (evidenceFiles.length > 0 && TASK_FIDELITY_SECTION_RE.test(allEvidenceContent)) {
|
|
15785
|
+
const stepCountMatch = STEP_COUNT_RE.exec(allEvidenceContent);
|
|
15786
|
+
const maxStepsMatch = MAX_PRIMARY_STEPS_RE.exec(allEvidenceContent);
|
|
15787
|
+
const hasCta = CTA_VISIBILITY_RE.test(allEvidenceContent);
|
|
15788
|
+
const hasFourState = FOUR_STATE_CHECK_RE.test(allEvidenceContent);
|
|
15789
|
+
if (!hasCta || !hasFourState) {
|
|
15790
|
+
issues.push(
|
|
15791
|
+
issue(
|
|
15792
|
+
"QFAI-CRIT-009",
|
|
15793
|
+
`taskFidelity section incomplete (missing: ${[!hasCta ? "cta_visibility" : "", !hasFourState ? "four_state_check" : ""].filter(Boolean).join(", ")}).`,
|
|
15794
|
+
"error",
|
|
15795
|
+
evidenceDir,
|
|
15796
|
+
"renderCritique.taskFidelityMissing",
|
|
15797
|
+
void 0,
|
|
15798
|
+
"change",
|
|
15799
|
+
"Add missing taskFidelity fields: step_count, cta_visibility, four_state_check."
|
|
15800
|
+
)
|
|
15801
|
+
);
|
|
15802
|
+
}
|
|
15803
|
+
if (stepCountMatch && maxStepsMatch) {
|
|
15804
|
+
const stepCount = parseInt(stepCountMatch[1] ?? "0", 10);
|
|
15805
|
+
const maxSteps = parseInt(maxStepsMatch[1] ?? "0", 10);
|
|
15806
|
+
if (stepCount > maxSteps) {
|
|
15807
|
+
issues.push(
|
|
15808
|
+
issue(
|
|
15809
|
+
"QFAI-CRIT-010",
|
|
15810
|
+
`taskFidelity FAIL: step_count (${stepCount}) exceeds max_primary_steps (${maxSteps}).`,
|
|
15811
|
+
"error",
|
|
15812
|
+
evidenceDir,
|
|
15813
|
+
"renderCritique.taskFidelityFail",
|
|
15814
|
+
void 0,
|
|
15815
|
+
"change",
|
|
15816
|
+
"Reduce step_count to be within max_primary_steps, or revise the flow."
|
|
15817
|
+
)
|
|
15818
|
+
);
|
|
15819
|
+
}
|
|
15820
|
+
}
|
|
15821
|
+
}
|
|
15822
|
+
return issues;
|
|
15823
|
+
}
|
|
15824
|
+
function hasViewportPass(content, viewport) {
|
|
15825
|
+
const vpRe = viewport === "desktop" ? DESKTOP_RE : MOBILE_RE;
|
|
15826
|
+
const sections = content.split(/(?=^#{1,3}\s)/m);
|
|
15827
|
+
for (const section of sections) {
|
|
15828
|
+
if (vpRe.test(section) && /verdict\s*:\s*PASS/i.test(section)) {
|
|
15829
|
+
return true;
|
|
15830
|
+
}
|
|
15831
|
+
}
|
|
15832
|
+
return false;
|
|
15833
|
+
}
|
|
15834
|
+
async function collectContent(files) {
|
|
15835
|
+
const contents = [];
|
|
15836
|
+
for (const f of files) {
|
|
15837
|
+
contents.push(await readSafe(f));
|
|
15838
|
+
}
|
|
15839
|
+
return contents.join("\n---\n");
|
|
15840
|
+
}
|
|
15841
|
+
|
|
15842
|
+
// src/core/validators/designFidelity.ts
|
|
15843
|
+
var import_promises52 = require("fs/promises");
|
|
15844
|
+
var import_node_path55 = __toESM(require("path"), 1);
|
|
15845
|
+
var import_fast_glob10 = __toESM(require("fast-glob"), 1);
|
|
15846
|
+
var SCORECARD_HEADING_RE = /^#{1,3}\s+Fidelity\s+Scorecard/im;
|
|
15847
|
+
var BASE_DIMENSIONS = ["hierarchy", "clarity", "accessibility", "responsive"];
|
|
15848
|
+
var TASK_FIDELITY_REQUIRED_FIELDS = [
|
|
15849
|
+
"step_count",
|
|
15850
|
+
"cta_visibility",
|
|
15851
|
+
"empty_state",
|
|
15852
|
+
"error_state",
|
|
15853
|
+
"primary_flow_clicks"
|
|
15854
|
+
];
|
|
15855
|
+
var PASS_THRESHOLD = 70;
|
|
15856
|
+
var ESCALATED_CODES = [
|
|
15857
|
+
"dual_primary_cta",
|
|
15858
|
+
"empty_state_without_action",
|
|
15859
|
+
"error_without_recovery",
|
|
15860
|
+
"placeholder_or_lorem",
|
|
15861
|
+
"cta_visibility_fail",
|
|
15862
|
+
"scorecard_dimension_missing"
|
|
15863
|
+
];
|
|
15864
|
+
async function validateDesignFidelity(root, config) {
|
|
15865
|
+
const issues = [];
|
|
15866
|
+
const evidenceDirs = [".qfai/evidence", ".qfai/review"];
|
|
15867
|
+
const allFiles = [];
|
|
15868
|
+
for (const dir of evidenceDirs) {
|
|
15869
|
+
const pattern = import_node_path55.default.posix.join(root.replace(/\\/g, "/"), dir, "**/*.md");
|
|
15870
|
+
const files = await (0, import_fast_glob10.default)(pattern, { absolute: true });
|
|
15871
|
+
allFiles.push(...files);
|
|
15872
|
+
}
|
|
15873
|
+
for (const filePath of allFiles) {
|
|
15874
|
+
let content;
|
|
15875
|
+
try {
|
|
15876
|
+
content = await (0, import_promises52.readFile)(filePath, "utf-8");
|
|
15877
|
+
} catch {
|
|
15878
|
+
continue;
|
|
15879
|
+
}
|
|
15880
|
+
if (!SCORECARD_HEADING_RE.test(content)) continue;
|
|
15881
|
+
const rel = import_node_path55.default.relative(root, filePath).replace(/\\/g, "/");
|
|
15882
|
+
const section = extractScorecardSection(content);
|
|
15883
|
+
if (!section) continue;
|
|
15884
|
+
const dimensions = parseDimensions(section);
|
|
15885
|
+
const _verdict = parseVerdict(section);
|
|
15886
|
+
for (const dim of BASE_DIMENSIONS) {
|
|
15887
|
+
if (!dimensions.has(dim)) {
|
|
15888
|
+
issues.push(
|
|
15889
|
+
issue(
|
|
15890
|
+
"QFAI-FID-001",
|
|
15891
|
+
`Fidelity scorecard missing dimension: ${dim}`,
|
|
15892
|
+
"error",
|
|
15893
|
+
rel,
|
|
15894
|
+
`fidelity.dimension.${dim}`
|
|
15895
|
+
)
|
|
15896
|
+
);
|
|
15897
|
+
} else {
|
|
15898
|
+
const d = dimensions.get(dim);
|
|
15899
|
+
if (d && (d.score === null || d.comment === null)) {
|
|
15900
|
+
issues.push(
|
|
15901
|
+
issue(
|
|
15902
|
+
"QFAI-FID-002",
|
|
15903
|
+
`Fidelity dimension '${dim}' missing ${d.score === null ? "score" : "prose comment"}`,
|
|
15904
|
+
"error",
|
|
15905
|
+
rel,
|
|
15906
|
+
`fidelity.scoreOrProse.${dim}`
|
|
15907
|
+
)
|
|
15908
|
+
);
|
|
15909
|
+
}
|
|
15910
|
+
}
|
|
15911
|
+
}
|
|
15912
|
+
const scores = [];
|
|
15913
|
+
for (const dim of BASE_DIMENSIONS) {
|
|
15914
|
+
const d = dimensions.get(dim);
|
|
15915
|
+
if (d?.score !== null && d?.score !== void 0) {
|
|
15916
|
+
scores.push(d.score);
|
|
15917
|
+
if (d.score < PASS_THRESHOLD) {
|
|
15918
|
+
issues.push(
|
|
15919
|
+
issue(
|
|
15920
|
+
"QFAI-FID-003",
|
|
15921
|
+
`Dimension '${dim}' score ${d.score} is below threshold ${PASS_THRESHOLD}`,
|
|
15922
|
+
"error",
|
|
15923
|
+
rel,
|
|
15924
|
+
`fidelity.threshold.${dim}`
|
|
15925
|
+
)
|
|
15926
|
+
);
|
|
15927
|
+
}
|
|
15928
|
+
}
|
|
15929
|
+
}
|
|
15930
|
+
const taskFid = dimensions.get("taskFidelity");
|
|
15931
|
+
if (taskFid?.score !== null && taskFid?.score !== void 0) {
|
|
15932
|
+
scores.push(taskFid.score);
|
|
15933
|
+
if (taskFid.score < PASS_THRESHOLD) {
|
|
15934
|
+
issues.push(
|
|
15935
|
+
issue(
|
|
15936
|
+
"QFAI-FID-003",
|
|
15937
|
+
`Dimension 'taskFidelity' score ${taskFid.score} is below threshold ${PASS_THRESHOLD}`,
|
|
15938
|
+
"error",
|
|
15939
|
+
rel,
|
|
15940
|
+
"fidelity.threshold.taskFidelity"
|
|
15941
|
+
)
|
|
15942
|
+
);
|
|
15943
|
+
}
|
|
15944
|
+
}
|
|
15945
|
+
if (scores.length > 0) {
|
|
15946
|
+
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
15947
|
+
if (avg < PASS_THRESHOLD) {
|
|
15948
|
+
issues.push(
|
|
15949
|
+
issue(
|
|
15950
|
+
"QFAI-FID-003",
|
|
15951
|
+
`Overall average score ${avg.toFixed(1)} is below threshold ${PASS_THRESHOLD}`,
|
|
15952
|
+
"error",
|
|
15953
|
+
rel,
|
|
15954
|
+
"fidelity.threshold.average"
|
|
15955
|
+
)
|
|
15956
|
+
);
|
|
15957
|
+
}
|
|
15958
|
+
}
|
|
15959
|
+
for (const dim of BASE_DIMENSIONS) {
|
|
15960
|
+
const d = dimensions.get(dim);
|
|
15961
|
+
if (d?.score !== null && d?.score !== void 0 && d.score < PASS_THRESHOLD) {
|
|
15962
|
+
if (!d.improvement || !d.alternative) {
|
|
15963
|
+
issues.push(
|
|
15964
|
+
issue(
|
|
15965
|
+
"QFAI-FID-004",
|
|
15966
|
+
`FAIL dimension '${dim}' missing ${!d.improvement ? "improvement instructions" : "alternative suggestion"}`,
|
|
15967
|
+
"error",
|
|
15968
|
+
rel,
|
|
15969
|
+
`fidelity.improvement.${dim}`
|
|
15970
|
+
)
|
|
15971
|
+
);
|
|
15972
|
+
}
|
|
15973
|
+
}
|
|
15974
|
+
}
|
|
15975
|
+
const responsive = dimensions.get("responsive");
|
|
15976
|
+
if (responsive) {
|
|
15977
|
+
if (!responsive.desktop || !responsive.mobile) {
|
|
15978
|
+
issues.push(
|
|
15979
|
+
issue(
|
|
15980
|
+
"QFAI-FID-005",
|
|
15981
|
+
`Responsive dimension missing ${!responsive.desktop ? "desktop" : "mobile"} viewport results`,
|
|
15982
|
+
"error",
|
|
15983
|
+
rel,
|
|
15984
|
+
"fidelity.viewport.responsive"
|
|
15985
|
+
)
|
|
15986
|
+
);
|
|
15987
|
+
}
|
|
15988
|
+
}
|
|
15989
|
+
const delta = parseDelta(section);
|
|
15990
|
+
if (delta.hasBreaking) {
|
|
15991
|
+
if (!delta.content || !delta.impact || !delta.migration) {
|
|
15992
|
+
const missing = [];
|
|
15993
|
+
if (!delta.content) missing.push("content");
|
|
15994
|
+
if (!delta.impact) missing.push("impact");
|
|
15995
|
+
if (!delta.migration) missing.push("migration steps");
|
|
15996
|
+
issues.push(
|
|
15997
|
+
issue(
|
|
15998
|
+
"QFAI-FID-006",
|
|
15999
|
+
`Breaking delta missing: ${missing.join(", ")}`,
|
|
16000
|
+
"error",
|
|
16001
|
+
rel,
|
|
16002
|
+
"fidelity.breakingDelta"
|
|
16003
|
+
)
|
|
16004
|
+
);
|
|
16005
|
+
}
|
|
16006
|
+
}
|
|
16007
|
+
if (!hasRubric(section)) {
|
|
16008
|
+
issues.push(
|
|
16009
|
+
issue(
|
|
16010
|
+
"QFAI-FID-007",
|
|
16011
|
+
"Scorecard rubric not documented; results are not reproducible",
|
|
16012
|
+
"error",
|
|
16013
|
+
rel,
|
|
16014
|
+
"fidelity.rubricMissing"
|
|
16015
|
+
)
|
|
16016
|
+
);
|
|
16017
|
+
}
|
|
16018
|
+
const requiresTaskFidelity = hasTaskFidelityRequirement(content);
|
|
16019
|
+
if (requiresTaskFidelity) {
|
|
16020
|
+
if (!dimensions.has("taskFidelity")) {
|
|
16021
|
+
issues.push(
|
|
16022
|
+
issue(
|
|
16023
|
+
"QFAI-FID-008",
|
|
16024
|
+
"Spec requires taskFidelity dimension but it is not present in scorecard",
|
|
16025
|
+
"error",
|
|
16026
|
+
rel,
|
|
16027
|
+
"fidelity.taskFidelity.missing"
|
|
16028
|
+
)
|
|
16029
|
+
);
|
|
16030
|
+
} else {
|
|
16031
|
+
const tf = dimensions.get("taskFidelity");
|
|
16032
|
+
for (const field of TASK_FIDELITY_REQUIRED_FIELDS) {
|
|
16033
|
+
if (tf && !tf.fields.has(field)) {
|
|
16034
|
+
issues.push(
|
|
16035
|
+
issue(
|
|
16036
|
+
"QFAI-FID-009",
|
|
16037
|
+
`taskFidelity missing required field: ${field}`,
|
|
16038
|
+
"error",
|
|
16039
|
+
rel,
|
|
16040
|
+
`fidelity.taskFidelity.field.${field}`
|
|
16041
|
+
)
|
|
16042
|
+
);
|
|
16043
|
+
}
|
|
16044
|
+
}
|
|
16045
|
+
}
|
|
16046
|
+
}
|
|
16047
|
+
const overrides = config.uiux?.warning_as_error_override ?? [];
|
|
16048
|
+
for (const code of ESCALATED_CODES) {
|
|
16049
|
+
if (hasAntiPatternMention(section, code)) {
|
|
16050
|
+
const isOverridden = overrides.includes(code);
|
|
16051
|
+
const severity = isOverridden ? "warning" : "error";
|
|
16052
|
+
const issueCode = isOverridden ? "QFAI-FID-011" : "QFAI-FID-010";
|
|
16053
|
+
const suffix = isOverridden ? " (overridden to warning by config)" : "";
|
|
16054
|
+
issues.push(
|
|
16055
|
+
issue(
|
|
16056
|
+
issueCode,
|
|
16057
|
+
`Escalated validation: ${code}${suffix}`,
|
|
16058
|
+
severity,
|
|
16059
|
+
rel,
|
|
16060
|
+
`fidelity.escalation.${code}`
|
|
16061
|
+
)
|
|
16062
|
+
);
|
|
16063
|
+
}
|
|
16064
|
+
}
|
|
16065
|
+
}
|
|
16066
|
+
return issues;
|
|
16067
|
+
}
|
|
16068
|
+
function extractScorecardSection(content) {
|
|
16069
|
+
const heading = SCORECARD_HEADING_RE.exec(content);
|
|
16070
|
+
if (!heading) return null;
|
|
16071
|
+
const start = heading.index + heading[0].length;
|
|
16072
|
+
const remainder = content.slice(start);
|
|
16073
|
+
const nextHeadingOffset = remainder.search(/^#{1,3}\s+/m);
|
|
16074
|
+
const section = nextHeadingOffset === -1 ? remainder : remainder.slice(0, nextHeadingOffset);
|
|
16075
|
+
return section.trim();
|
|
16076
|
+
}
|
|
16077
|
+
function parseDimensions(section) {
|
|
16078
|
+
const dims = /* @__PURE__ */ new Map();
|
|
16079
|
+
const lines = section.split(/\r?\n/);
|
|
16080
|
+
let currentDim = null;
|
|
16081
|
+
let currentInfo = null;
|
|
16082
|
+
for (const line of lines) {
|
|
16083
|
+
const dimMatch = /^(\w+)\s*:(.*)$/.exec(line);
|
|
16084
|
+
if (dimMatch && !line.startsWith(" ") && !line.startsWith(" ")) {
|
|
16085
|
+
if (currentDim && currentInfo) {
|
|
16086
|
+
dims.set(currentDim, currentInfo);
|
|
16087
|
+
}
|
|
16088
|
+
const name = dimMatch[1] ?? "";
|
|
16089
|
+
const inlineValue = (dimMatch[2] ?? "").trim();
|
|
16090
|
+
if (["verdict", "rubric", "delta", "breaking_delta"].includes(name)) {
|
|
16091
|
+
currentDim = null;
|
|
16092
|
+
currentInfo = null;
|
|
16093
|
+
continue;
|
|
16094
|
+
}
|
|
16095
|
+
currentDim = name;
|
|
16096
|
+
currentInfo = {
|
|
16097
|
+
score: null,
|
|
16098
|
+
comment: null,
|
|
16099
|
+
improvement: null,
|
|
16100
|
+
alternative: null,
|
|
16101
|
+
desktop: null,
|
|
16102
|
+
mobile: null,
|
|
16103
|
+
fields: /* @__PURE__ */ new Map()
|
|
16104
|
+
};
|
|
16105
|
+
if (inlineValue && /^\d+$/.test(inlineValue)) {
|
|
16106
|
+
currentInfo.score = parseInt(inlineValue, 10);
|
|
16107
|
+
}
|
|
16108
|
+
continue;
|
|
16109
|
+
}
|
|
16110
|
+
if (currentDim && currentInfo && /^\s+/.test(line)) {
|
|
16111
|
+
const subMatch = /^\s+(\w+)\s*:\s*(.*)$/.exec(line);
|
|
16112
|
+
if (subMatch) {
|
|
16113
|
+
const key = subMatch[1] ?? "";
|
|
16114
|
+
const value = (subMatch[2] ?? "").trim();
|
|
16115
|
+
switch (key) {
|
|
16116
|
+
case "score":
|
|
16117
|
+
currentInfo.score = value ? parseInt(value, 10) : null;
|
|
16118
|
+
break;
|
|
16119
|
+
case "comment":
|
|
16120
|
+
currentInfo.comment = value || null;
|
|
16121
|
+
break;
|
|
16122
|
+
case "improvement":
|
|
16123
|
+
currentInfo.improvement = value || null;
|
|
16124
|
+
break;
|
|
16125
|
+
case "alternative":
|
|
16126
|
+
currentInfo.alternative = value || null;
|
|
16127
|
+
break;
|
|
16128
|
+
case "desktop":
|
|
16129
|
+
currentInfo.desktop = value || null;
|
|
16130
|
+
break;
|
|
16131
|
+
case "mobile":
|
|
16132
|
+
currentInfo.mobile = value || null;
|
|
16133
|
+
break;
|
|
16134
|
+
default:
|
|
16135
|
+
currentInfo.fields.set(key, value);
|
|
16136
|
+
break;
|
|
16137
|
+
}
|
|
16138
|
+
}
|
|
16139
|
+
}
|
|
16140
|
+
}
|
|
16141
|
+
if (currentDim && currentInfo) {
|
|
16142
|
+
dims.set(currentDim, currentInfo);
|
|
16143
|
+
}
|
|
16144
|
+
return dims;
|
|
16145
|
+
}
|
|
16146
|
+
function parseVerdict(section) {
|
|
16147
|
+
const match = /^verdict\s*:\s*(\S+)/m.exec(section);
|
|
16148
|
+
return match?.[1] ?? null;
|
|
16149
|
+
}
|
|
16150
|
+
function parseDelta(section) {
|
|
16151
|
+
const hasBreaking = /breaking_delta\s*:/m.test(section) || /delta\s*:/m.test(section) && /breaking/im.test(section);
|
|
16152
|
+
if (!hasBreaking) {
|
|
16153
|
+
return { hasBreaking: false, content: false, impact: false, migration: false };
|
|
16154
|
+
}
|
|
16155
|
+
return {
|
|
16156
|
+
hasBreaking: true,
|
|
16157
|
+
content: /content\s*:/m.test(section),
|
|
16158
|
+
impact: /impact\s*:/m.test(section),
|
|
16159
|
+
migration: /migration\s*:/m.test(section)
|
|
16160
|
+
};
|
|
16161
|
+
}
|
|
16162
|
+
function hasRubric(section) {
|
|
16163
|
+
return /rubric\s*:/m.test(section);
|
|
16164
|
+
}
|
|
16165
|
+
function hasTaskFidelityRequirement(content) {
|
|
16166
|
+
return /taskFidelity\s*:\s*required/im.test(content) || /require.*taskFidelity/im.test(content);
|
|
16167
|
+
}
|
|
16168
|
+
function hasAntiPatternMention(section, code) {
|
|
16169
|
+
return new RegExp(`\\b${code.replace(/_/g, "[_ ]")}\\b`, "i").test(section);
|
|
16170
|
+
}
|
|
16171
|
+
|
|
16172
|
+
// src/core/validate.ts
|
|
16173
|
+
var UIUX_VALIDATION_BUDGET_MS = 2e3;
|
|
16174
|
+
async function validateProject(root, configResult, options = {}) {
|
|
16175
|
+
const resolved = configResult ?? await loadConfig(root);
|
|
16176
|
+
const { config, issues: configIssues } = resolved;
|
|
16177
|
+
const phase = options.phase ?? "full";
|
|
16178
|
+
const atddCodeTraceabilityIssues = phase === "refinement" ? [] : await validateAtddCodeTraceability(root, config);
|
|
16179
|
+
const uiuxStart = performance.now();
|
|
16180
|
+
const platformResult = await detectPlatform(root, config, options.platform);
|
|
16181
|
+
const platform = platformResult.platform;
|
|
16182
|
+
const uiuxValidators = [
|
|
16183
|
+
() => validateDesignToken(root, config),
|
|
16184
|
+
() => validateHtmlMock(root, platform, config),
|
|
16185
|
+
() => validateMermaidScreenFlow(root, config),
|
|
16186
|
+
() => validateBpApDb(root, config),
|
|
16187
|
+
() => validateUiDefinitionConsistency(root, config),
|
|
16188
|
+
() => validateResearchSummary(root, config),
|
|
16189
|
+
() => validateAgentDefinition(root, config)
|
|
16190
|
+
];
|
|
16191
|
+
const uiuxIssueGroups = await Promise.all(uiuxValidators.map((validator) => validator()));
|
|
16192
|
+
const uiuxIssues = [...platformResult.issues, ...uiuxIssueGroups.flat()];
|
|
16193
|
+
const uiuxElapsed = performance.now() - uiuxStart;
|
|
16194
|
+
if (uiuxElapsed > UIUX_VALIDATION_BUDGET_MS) {
|
|
16195
|
+
uiuxIssues.push({
|
|
16196
|
+
code: "QFAI-UIUX-PERF",
|
|
16197
|
+
severity: "warning",
|
|
16198
|
+
category: "compatibility",
|
|
16199
|
+
message: `UI/UX validation exceeded budget (${UIUX_VALIDATION_BUDGET_MS}ms). All validators were executed.`,
|
|
16200
|
+
rule: "uiux.performanceBudget"
|
|
16201
|
+
});
|
|
16202
|
+
}
|
|
16203
|
+
const findings = [
|
|
16204
|
+
...configIssues,
|
|
16205
|
+
...await validateRepositoryHygiene(root, config),
|
|
16206
|
+
...await validateSkillsIntegrity(root, config),
|
|
16207
|
+
...await validateAssistantAssets(root, config),
|
|
16208
|
+
...await validateDiscussionMermaid(root),
|
|
16209
|
+
...await validateMermaidEnforcement(root),
|
|
16210
|
+
...await validateSpecPacks(root, config),
|
|
16211
|
+
...await validateDiscussionPackReadiness(root, config),
|
|
16212
|
+
...await validateDiscussionVisuals(root),
|
|
16213
|
+
...await validateLegacyStatusDir(root),
|
|
16214
|
+
...await validateStatusInSpecs(root, config),
|
|
16215
|
+
...await validateDensityHints(root, config),
|
|
16216
|
+
...await validateReviewArtifacts(root),
|
|
16217
|
+
...await validatePrototypingEvidence(root, config),
|
|
16218
|
+
...await validateSpecSplitByCapability(root, config),
|
|
16219
|
+
...await validateLayeredTraceability(root, config),
|
|
16220
|
+
...await validateOrphanProhibition(root, config),
|
|
16221
|
+
...await validateLayerCoverage(root, config),
|
|
16222
|
+
...atddCodeTraceabilityIssues,
|
|
16223
|
+
...await validateContractReferences(root, config),
|
|
16224
|
+
...await validateTraceability(root, config, phase),
|
|
16225
|
+
...await validateDefinedIds(root, config),
|
|
16226
|
+
...await validateContracts(root, config),
|
|
16227
|
+
...await validateTddList(root, config),
|
|
16228
|
+
...await validateDdpFields(root, config),
|
|
16229
|
+
...await validateNavigationFlow(root, config),
|
|
16230
|
+
...await validateRenderCritique(root, config),
|
|
16231
|
+
...await validateDesignFidelity(root, config),
|
|
16232
|
+
...uiuxIssues
|
|
16233
|
+
];
|
|
16234
|
+
const { issues, waivers } = await applyWaivers(root, findings);
|
|
16235
|
+
const specsRoot = resolvePath(root, config, "specsDir");
|
|
16236
|
+
const scenarioFiles = await collectScenarioFiles(specsRoot);
|
|
16237
|
+
const scIds = await collectScIdsFromScenarioFiles(scenarioFiles);
|
|
16238
|
+
const { refs: scTestRefs, scan: testFiles } = await collectScTestReferences(
|
|
16239
|
+
root,
|
|
16240
|
+
config.validation.traceability.testFileGlobs,
|
|
16241
|
+
config.validation.traceability.testFileExcludeGlobs
|
|
16242
|
+
);
|
|
16243
|
+
const scCoverage = buildScCoverage(scIds, scTestRefs);
|
|
16244
|
+
const toolVersion = await resolveToolVersion();
|
|
16245
|
+
return {
|
|
16246
|
+
toolVersion,
|
|
16247
|
+
phase,
|
|
16248
|
+
issues,
|
|
16249
|
+
counts: countIssues(issues),
|
|
16250
|
+
traceability: {
|
|
16251
|
+
sc: scCoverage,
|
|
16252
|
+
testFiles
|
|
16253
|
+
},
|
|
16254
|
+
waivers
|
|
16255
|
+
};
|
|
16256
|
+
}
|
|
16257
|
+
function countIssues(issues) {
|
|
16258
|
+
return issues.reduce(
|
|
16259
|
+
(acc, issue2) => {
|
|
16260
|
+
if (issue2.suppressed) {
|
|
16261
|
+
return acc;
|
|
16262
|
+
}
|
|
16263
|
+
acc[issue2.severity] += 1;
|
|
16264
|
+
return acc;
|
|
16265
|
+
},
|
|
16266
|
+
{ info: 0, warning: 0, error: 0 }
|
|
16267
|
+
);
|
|
16268
|
+
}
|
|
16269
|
+
|
|
16270
|
+
// src/core/report.ts
|
|
16271
|
+
var REPORT_GUARDRAILS_MAX = 20;
|
|
16272
|
+
var REPORT_TEST_STRATEGY_SAMPLE_LIMIT = 20;
|
|
16273
|
+
var SC_TAG_RE4 = /^SC-\d{4}-\d{4}$/;
|
|
16274
|
+
async function createReportData(root, validation, configResult) {
|
|
16275
|
+
const resolvedRoot = import_node_path56.default.resolve(root);
|
|
16276
|
+
const resolved = configResult ?? await loadConfig(resolvedRoot);
|
|
16277
|
+
const config = resolved.config;
|
|
16278
|
+
const configPath = resolved.configPath;
|
|
16279
|
+
const specsRoot = resolvePath(resolvedRoot, config, "specsDir");
|
|
16280
|
+
const contractsRoot = resolvePath(resolvedRoot, config, "contractsDir");
|
|
16281
|
+
const apiRoot = import_node_path56.default.join(contractsRoot, "api");
|
|
16282
|
+
const uiRoot = import_node_path56.default.join(contractsRoot, "ui");
|
|
16283
|
+
const dbRoot = import_node_path56.default.join(contractsRoot, "db");
|
|
16284
|
+
const srcRoot = resolvePath(resolvedRoot, config, "srcDir");
|
|
16285
|
+
const testsRoot = resolvePath(resolvedRoot, config, "testsDir");
|
|
16286
|
+
const specEntries = await collectSpecEntries(specsRoot);
|
|
16287
|
+
const specFiles = await collectSpecFiles(specsRoot);
|
|
16288
|
+
const scenarioFiles = await collectScenarioFiles(specsRoot);
|
|
16289
|
+
const scenarioCount = await countScenarios(scenarioFiles);
|
|
16290
|
+
const testStrategy = await collectTestStrategy(
|
|
16291
|
+
scenarioFiles,
|
|
16292
|
+
resolvedRoot,
|
|
16293
|
+
config,
|
|
16294
|
+
REPORT_TEST_STRATEGY_SAMPLE_LIMIT
|
|
16295
|
+
);
|
|
16296
|
+
const {
|
|
16297
|
+
api: apiFiles,
|
|
16298
|
+
ui: uiFiles,
|
|
16299
|
+
db: dbFiles,
|
|
16300
|
+
thema: themaFiles
|
|
16301
|
+
} = await collectContractFiles(uiRoot, apiRoot, dbRoot);
|
|
16302
|
+
const contractIndex = await buildContractIndex(resolvedRoot, config);
|
|
16303
|
+
const contractIdList = Array.from(contractIndex.ids);
|
|
16304
|
+
const specContractRefs = await collectSpecContractRefs(specFiles, contractIdList);
|
|
16305
|
+
const referencedContracts = /* @__PURE__ */ new Set();
|
|
16306
|
+
for (const entry of specContractRefs.specToContracts.values()) {
|
|
16307
|
+
entry.ids.forEach((id) => referencedContracts.add(id));
|
|
16308
|
+
}
|
|
16309
|
+
const referencedContractCount = contractIdList.filter((id) => referencedContracts.has(id)).length;
|
|
16310
|
+
const orphanContractCount = contractIdList.filter((id) => !referencedContracts.has(id)).length;
|
|
16311
|
+
const contractIdToSpecsRecord = mapToSortedRecord(specContractRefs.idToSpecs);
|
|
16312
|
+
const specToContractsRecord = mapToSpecContractRecord(specContractRefs.specToContracts);
|
|
16313
|
+
const idsByPrefix = await collectIds([
|
|
16314
|
+
...specFiles,
|
|
16315
|
+
...scenarioFiles,
|
|
16316
|
+
...apiFiles,
|
|
16317
|
+
...uiFiles,
|
|
16318
|
+
...dbFiles,
|
|
16319
|
+
...themaFiles
|
|
16320
|
+
]);
|
|
16321
|
+
const upstreamIds = await collectUpstreamIds([...specFiles, ...scenarioFiles]);
|
|
16322
|
+
const traceability = await evaluateTraceability(upstreamIds, srcRoot, testsRoot);
|
|
16323
|
+
const resolvedValidationRaw = validation ?? await validateProject(resolvedRoot, resolved);
|
|
16324
|
+
const normalizedValidation = normalizeValidationResult(resolvedRoot, resolvedValidationRaw);
|
|
16325
|
+
const scCoverage = normalizedValidation.traceability.sc;
|
|
16326
|
+
const testFiles = normalizedValidation.traceability.testFiles;
|
|
16327
|
+
const scSources = await collectScIdSourcesFromScenarioFiles(scenarioFiles);
|
|
16328
|
+
const scSourceRecord = mapToSortedRecord(normalizeScSources(resolvedRoot, scSources));
|
|
16329
|
+
const guardrailsLoad = await loadDecisionGuardrails(resolvedRoot, {
|
|
16330
|
+
specsRoot
|
|
16331
|
+
});
|
|
16332
|
+
const guardrailsAll = sortDecisionGuardrails(normalizeDecisionGuardrails(guardrailsLoad.entries));
|
|
16333
|
+
const guardrailsDisplay = guardrailsAll.slice(0, REPORT_GUARDRAILS_MAX);
|
|
16334
|
+
const guardrailsByType = { nonGoal: 0, notNow: 0, tradeOff: 0 };
|
|
16335
|
+
for (const item of guardrailsAll) {
|
|
16336
|
+
if (item.type === "non-goal") {
|
|
16337
|
+
guardrailsByType.nonGoal += 1;
|
|
16338
|
+
} else if (item.type === "not-now") {
|
|
16339
|
+
guardrailsByType.notNow += 1;
|
|
16340
|
+
} else {
|
|
16341
|
+
guardrailsByType.tradeOff += 1;
|
|
16342
|
+
}
|
|
16343
|
+
}
|
|
16344
|
+
const guardrailsErrors = guardrailsLoad.errors.map((item) => ({
|
|
16345
|
+
path: toRelativePath(resolvedRoot, item.path),
|
|
16346
|
+
message: item.message
|
|
16347
|
+
}));
|
|
16348
|
+
const changeTypeSummary = await collectChangeTypeSummary(specsRoot);
|
|
16349
|
+
const ctypeWarnings = normalizedValidation.issues.filter((item) => item.code === "QFAI-CTYPE-002").map((item) => {
|
|
16350
|
+
const warning = {
|
|
16351
|
+
file: item.file ? toRelativePath(resolvedRoot, item.file) : "(unknown)",
|
|
16352
|
+
suspectedMismatch: item.message,
|
|
16353
|
+
refs: item.refs ?? []
|
|
16354
|
+
};
|
|
16355
|
+
if (item.suggested_action) {
|
|
16356
|
+
warning.suggestion = item.suggested_action;
|
|
16357
|
+
}
|
|
16358
|
+
return warning;
|
|
16359
|
+
});
|
|
16360
|
+
const toReportRuleFinding = (item) => {
|
|
16361
|
+
const finding = {
|
|
16362
|
+
code: item.code,
|
|
16363
|
+
severity: item.severity,
|
|
16364
|
+
message: item.message,
|
|
16365
|
+
refs: item.refs ?? []
|
|
16366
|
+
};
|
|
16367
|
+
if (item.file) {
|
|
16368
|
+
finding.file = toRelativePath(resolvedRoot, item.file);
|
|
16369
|
+
}
|
|
16370
|
+
if (item.suggested_action) {
|
|
16371
|
+
finding.suggestion = item.suggested_action;
|
|
16372
|
+
}
|
|
16373
|
+
return finding;
|
|
16374
|
+
};
|
|
16375
|
+
const compatFindings = normalizedValidation.issues.filter((item) => /^QFAI-COMPAT-\d+$/.test(item.code)).map((item) => toReportRuleFinding(item));
|
|
16376
|
+
const scopeMismatches = normalizedValidation.issues.filter((item) => item.code === "QFAI-SCOPE-001" || item.code === "QFAI-SCOPE-002").map((item) => toReportRuleFinding(item));
|
|
16377
|
+
const verificationFindings = normalizedValidation.issues.filter((item) => /^QFAI-VFY-\d+$/.test(item.code)).map((item) => toReportRuleFinding(item));
|
|
16378
|
+
const missingDeltaUpdateIssues = normalizedValidation.issues.filter(
|
|
16379
|
+
(item) => item.code === "QFAI-CTYPE-003"
|
|
16380
|
+
).length;
|
|
16381
|
+
const waiverState = normalizedValidation.waivers ?? {
|
|
16382
|
+
active: [],
|
|
16383
|
+
suppressed: {
|
|
16384
|
+
total: 0,
|
|
16385
|
+
byWaiver: {},
|
|
14831
16386
|
byRule: {}
|
|
14832
16387
|
}
|
|
14833
16388
|
};
|
|
@@ -15606,7 +17161,7 @@ async function collectChangeTypeSummary(specsRoot) {
|
|
|
15606
17161
|
};
|
|
15607
17162
|
const deltaFiles = await collectDeltaFiles(specsRoot);
|
|
15608
17163
|
for (const deltaFile of deltaFiles) {
|
|
15609
|
-
const text = await (0,
|
|
17164
|
+
const text = await (0, import_promises53.readFile)(deltaFile, "utf-8");
|
|
15610
17165
|
const parsed = parseDeltaV1(text);
|
|
15611
17166
|
for (const entry of parsed.entries) {
|
|
15612
17167
|
if (!entry.meta) {
|
|
@@ -15643,7 +17198,7 @@ async function collectSpecContractRefs(specFiles, contractIdList) {
|
|
|
15643
17198
|
idToSpecs.set(contractId, /* @__PURE__ */ new Set());
|
|
15644
17199
|
}
|
|
15645
17200
|
for (const file of specFiles) {
|
|
15646
|
-
const text = await (0,
|
|
17201
|
+
const text = await (0, import_promises53.readFile)(file, "utf-8");
|
|
15647
17202
|
const parsed = parseSpec(text, file);
|
|
15648
17203
|
const specKey = parsed.specId;
|
|
15649
17204
|
if (!specKey) {
|
|
@@ -15680,7 +17235,7 @@ async function collectIds(files) {
|
|
|
15680
17235
|
result[prefix] = /* @__PURE__ */ new Set();
|
|
15681
17236
|
}
|
|
15682
17237
|
for (const file of files) {
|
|
15683
|
-
const text = await (0,
|
|
17238
|
+
const text = await (0, import_promises53.readFile)(file, "utf-8");
|
|
15684
17239
|
for (const prefix of ID_PREFIXES) {
|
|
15685
17240
|
const ids = extractIds(text, prefix);
|
|
15686
17241
|
ids.forEach((id) => result[prefix].add(id));
|
|
@@ -15695,7 +17250,7 @@ async function collectIds(files) {
|
|
|
15695
17250
|
async function collectUpstreamIds(files) {
|
|
15696
17251
|
const ids = /* @__PURE__ */ new Set();
|
|
15697
17252
|
for (const file of files) {
|
|
15698
|
-
const text = await (0,
|
|
17253
|
+
const text = await (0, import_promises53.readFile)(file, "utf-8");
|
|
15699
17254
|
extractAllIds(text).forEach((id) => ids.add(id));
|
|
15700
17255
|
}
|
|
15701
17256
|
return ids;
|
|
@@ -15716,7 +17271,7 @@ async function evaluateTraceability(upstreamIds, srcRoot, testsRoot) {
|
|
|
15716
17271
|
}
|
|
15717
17272
|
const pattern = buildIdPattern(Array.from(upstreamIds));
|
|
15718
17273
|
for (const file of targetFiles) {
|
|
15719
|
-
const text = await (0,
|
|
17274
|
+
const text = await (0, import_promises53.readFile)(file, "utf-8");
|
|
15720
17275
|
if (pattern.test(text)) {
|
|
15721
17276
|
return true;
|
|
15722
17277
|
}
|
|
@@ -15835,7 +17390,7 @@ function normalizeScSources(root, sources) {
|
|
|
15835
17390
|
async function countScenarios(scenarioFiles) {
|
|
15836
17391
|
let total = 0;
|
|
15837
17392
|
for (const file of scenarioFiles) {
|
|
15838
|
-
const text = await (0,
|
|
17393
|
+
const text = await (0, import_promises53.readFile)(file, "utf-8");
|
|
15839
17394
|
const { document, errors } = parseScenarioDocument(text, file);
|
|
15840
17395
|
if (!document || errors.length > 0) {
|
|
15841
17396
|
continue;
|
|
@@ -15866,7 +17421,7 @@ async function collectTestStrategy(scenarioFiles, root, config, limit) {
|
|
|
15866
17421
|
let totalScenarios = 0;
|
|
15867
17422
|
let e2eCount = 0;
|
|
15868
17423
|
for (const file of scenarioFiles) {
|
|
15869
|
-
const text = await (0,
|
|
17424
|
+
const text = await (0, import_promises53.readFile)(file, "utf-8");
|
|
15870
17425
|
const { document, errors } = parseScenarioDocument(text, file);
|
|
15871
17426
|
if (!document || errors.length > 0) {
|
|
15872
17427
|
continue;
|
|
@@ -15954,10 +17509,10 @@ function buildHotspots(issues) {
|
|
|
15954
17509
|
async function collectTddCoverage(entries) {
|
|
15955
17510
|
const specs = [];
|
|
15956
17511
|
for (const entry of entries) {
|
|
15957
|
-
const testCasesPath =
|
|
17512
|
+
const testCasesPath = import_node_path56.default.join(entry.dir, "06_Test-Cases.md");
|
|
15958
17513
|
let tcContent;
|
|
15959
17514
|
try {
|
|
15960
|
-
tcContent = await (0,
|
|
17515
|
+
tcContent = await (0, import_promises53.readFile)(testCasesPath, "utf-8");
|
|
15961
17516
|
} catch {
|
|
15962
17517
|
continue;
|
|
15963
17518
|
}
|
|
@@ -15989,10 +17544,10 @@ async function collectTddCoverage(entries) {
|
|
|
15989
17544
|
});
|
|
15990
17545
|
continue;
|
|
15991
17546
|
}
|
|
15992
|
-
const tddListPath =
|
|
17547
|
+
const tddListPath = import_node_path56.default.join(entry.dir, "tdd", "test-list.md");
|
|
15993
17548
|
let tddContent;
|
|
15994
17549
|
try {
|
|
15995
|
-
tddContent = await (0,
|
|
17550
|
+
tddContent = await (0, import_promises53.readFile)(tddListPath, "utf-8");
|
|
15996
17551
|
} catch {
|
|
15997
17552
|
specs.push({
|
|
15998
17553
|
specNumber: entry.specNumber,
|
|
@@ -16075,8 +17630,8 @@ async function collectTddCoverage(entries) {
|
|
|
16075
17630
|
}
|
|
16076
17631
|
|
|
16077
17632
|
// src/core/specPackReport.ts
|
|
16078
|
-
var
|
|
16079
|
-
var
|
|
17633
|
+
var import_promises54 = require("fs/promises");
|
|
17634
|
+
var import_node_path57 = __toESM(require("path"), 1);
|
|
16080
17635
|
var REQUIRED_LEDGER_COLUMNS = [
|
|
16081
17636
|
"trace_id",
|
|
16082
17637
|
"obj_id",
|
|
@@ -16094,9 +17649,9 @@ async function writeSpecPackReports(root, config) {
|
|
|
16094
17649
|
const entries = await collectSpecEntries(specsRoot);
|
|
16095
17650
|
const contractIndex = await buildContractIndex(root, config);
|
|
16096
17651
|
for (const entry of entries) {
|
|
16097
|
-
const specName =
|
|
16098
|
-
const outputDir =
|
|
16099
|
-
await (0,
|
|
17652
|
+
const specName = import_node_path57.default.basename(entry.dir);
|
|
17653
|
+
const outputDir = import_node_path57.default.join(outRoot, specName);
|
|
17654
|
+
await (0, import_promises54.mkdir)(outputDir, { recursive: true });
|
|
16100
17655
|
const [acText, tcText, exText, ledgerText] = await Promise.all([
|
|
16101
17656
|
readSafe12(entry.acceptanceCriteriaPath),
|
|
16102
17657
|
readSafe12(entry.testCasesPath),
|
|
@@ -16121,14 +17676,14 @@ async function writeSpecPackReports(root, config) {
|
|
|
16121
17676
|
contractIds: contractIndex.ids
|
|
16122
17677
|
});
|
|
16123
17678
|
const graph = buildTraceabilityGraph(ledgerRows);
|
|
16124
|
-
await (0,
|
|
16125
|
-
|
|
17679
|
+
await (0, import_promises54.writeFile)(
|
|
17680
|
+
import_node_path57.default.join(outputDir, "coverage.md"),
|
|
16126
17681
|
`${formatCoverageMarkdown(specName, coverage)}
|
|
16127
17682
|
`,
|
|
16128
17683
|
"utf-8"
|
|
16129
17684
|
);
|
|
16130
|
-
await (0,
|
|
16131
|
-
|
|
17685
|
+
await (0, import_promises54.writeFile)(
|
|
17686
|
+
import_node_path57.default.join(outputDir, "traceability-graph.json"),
|
|
16132
17687
|
`${JSON.stringify(graph, null, 2)}
|
|
16133
17688
|
`,
|
|
16134
17689
|
"utf-8"
|
|
@@ -16303,7 +17858,7 @@ function getCell(row, indexByColumn, column) {
|
|
|
16303
17858
|
}
|
|
16304
17859
|
async function readSafe12(filePath) {
|
|
16305
17860
|
try {
|
|
16306
|
-
return await (0,
|
|
17861
|
+
return await (0, import_promises54.readFile)(filePath, "utf-8");
|
|
16307
17862
|
} catch {
|
|
16308
17863
|
return "";
|
|
16309
17864
|
}
|
|
@@ -16321,7 +17876,7 @@ function warnIfTruncated(scan, context) {
|
|
|
16321
17876
|
|
|
16322
17877
|
// src/cli/commands/report.ts
|
|
16323
17878
|
async function runReport(options) {
|
|
16324
|
-
const root =
|
|
17879
|
+
const root = import_node_path58.default.resolve(options.root);
|
|
16325
17880
|
const configResult = await loadConfig(root);
|
|
16326
17881
|
let validation;
|
|
16327
17882
|
let blockedByPhaseGuard = false;
|
|
@@ -16337,7 +17892,7 @@ async function runReport(options) {
|
|
|
16337
17892
|
validation = normalized;
|
|
16338
17893
|
} else {
|
|
16339
17894
|
const input = options.inputPath ?? configResult.config.output.validateJsonPath;
|
|
16340
|
-
const inputPath =
|
|
17895
|
+
const inputPath = import_node_path58.default.isAbsolute(input) ? input : import_node_path58.default.resolve(root, input);
|
|
16341
17896
|
try {
|
|
16342
17897
|
validation = await readValidationResult(inputPath);
|
|
16343
17898
|
} catch (err) {
|
|
@@ -16364,11 +17919,11 @@ async function runReport(options) {
|
|
|
16364
17919
|
warnIfTruncated(data.traceability.testFiles, "report");
|
|
16365
17920
|
const output = options.format === "json" ? formatReportJson(data) : options.baseUrl ? formatReportMarkdown(data, { baseUrl: options.baseUrl }) : formatReportMarkdown(data);
|
|
16366
17921
|
const outRoot = resolvePath(root, configResult.config, "outDir");
|
|
16367
|
-
const defaultOut = options.format === "json" ?
|
|
17922
|
+
const defaultOut = options.format === "json" ? import_node_path58.default.join(outRoot, "report.json") : import_node_path58.default.join(outRoot, "report.md");
|
|
16368
17923
|
const out = options.outPath ?? defaultOut;
|
|
16369
|
-
const outPath =
|
|
16370
|
-
await (0,
|
|
16371
|
-
await (0,
|
|
17924
|
+
const outPath = import_node_path58.default.isAbsolute(out) ? out : import_node_path58.default.resolve(root, out);
|
|
17925
|
+
await (0, import_promises55.mkdir)(import_node_path58.default.dirname(outPath), { recursive: true });
|
|
17926
|
+
await (0, import_promises55.writeFile)(outPath, `${output}
|
|
16372
17927
|
`, "utf-8");
|
|
16373
17928
|
await writeSpecPackReports(root, configResult.config);
|
|
16374
17929
|
if (blockedByPhaseGuard) {
|
|
@@ -16383,7 +17938,7 @@ async function runReport(options) {
|
|
|
16383
17938
|
info(`wrote report: ${outPath}`);
|
|
16384
17939
|
}
|
|
16385
17940
|
async function readValidationResult(inputPath) {
|
|
16386
|
-
const raw = await (0,
|
|
17941
|
+
const raw = await (0, import_promises55.readFile)(inputPath, "utf-8");
|
|
16387
17942
|
const parsed = JSON.parse(raw);
|
|
16388
17943
|
if (!isValidationResult(parsed)) {
|
|
16389
17944
|
throw new Error(`validate.json \u306E\u5F62\u5F0F\u304C\u4E0D\u6B63\u3067\u3059: ${inputPath}`);
|
|
@@ -16443,23 +17998,23 @@ function isMissingFileError2(error2) {
|
|
|
16443
17998
|
return record2.code === "ENOENT";
|
|
16444
17999
|
}
|
|
16445
18000
|
async function writeValidationResult(root, outputPath, result) {
|
|
16446
|
-
const abs =
|
|
16447
|
-
await (0,
|
|
16448
|
-
await (0,
|
|
18001
|
+
const abs = import_node_path58.default.isAbsolute(outputPath) ? outputPath : import_node_path58.default.resolve(root, outputPath);
|
|
18002
|
+
await (0, import_promises55.mkdir)(import_node_path58.default.dirname(abs), { recursive: true });
|
|
18003
|
+
await (0, import_promises55.writeFile)(abs, `${JSON.stringify(result, null, 2)}
|
|
16449
18004
|
`, "utf-8");
|
|
16450
18005
|
}
|
|
16451
18006
|
|
|
16452
18007
|
// src/cli/commands/validate.ts
|
|
16453
|
-
var
|
|
16454
|
-
var
|
|
18008
|
+
var import_promises57 = require("fs/promises");
|
|
18009
|
+
var import_node_path60 = __toESM(require("path"), 1);
|
|
16455
18010
|
|
|
16456
18011
|
// src/core/runLog.ts
|
|
16457
|
-
var
|
|
16458
|
-
var
|
|
18012
|
+
var import_promises56 = require("fs/promises");
|
|
18013
|
+
var import_node_path59 = __toESM(require("path"), 1);
|
|
16459
18014
|
async function writeValidateRunLog(input) {
|
|
16460
|
-
const root =
|
|
18015
|
+
const root = import_node_path59.default.resolve(input.root);
|
|
16461
18016
|
const outDir = resolvePath(root, input.config, "outDir");
|
|
16462
|
-
await (0,
|
|
18017
|
+
await (0, import_promises56.mkdir)(outDir, { recursive: true });
|
|
16463
18018
|
const { runId, reportDir } = await allocateRunReportDir(outDir, input.startedAt);
|
|
16464
18019
|
const relativeSpecsRoot = toRelativePath(root, resolvePath(root, input.config, "specsDir"));
|
|
16465
18020
|
const latestDiscussion = await findLatestPack(
|
|
@@ -16504,10 +18059,10 @@ async function writeValidateRunLog(input) {
|
|
|
16504
18059
|
errors,
|
|
16505
18060
|
warnings
|
|
16506
18061
|
});
|
|
16507
|
-
await writeJson(
|
|
16508
|
-
await writeJson(
|
|
16509
|
-
await writeJson(
|
|
16510
|
-
await (0,
|
|
18062
|
+
await writeJson(import_node_path59.default.join(reportDir, "run.json"), runJson);
|
|
18063
|
+
await writeJson(import_node_path59.default.join(reportDir, "validator.json"), validatorJson);
|
|
18064
|
+
await writeJson(import_node_path59.default.join(reportDir, "traceability.json"), traceabilityJson);
|
|
18065
|
+
await (0, import_promises56.writeFile)(import_node_path59.default.join(reportDir, "summary.md"), `${summaryMd}
|
|
16511
18066
|
`, "utf-8");
|
|
16512
18067
|
return {
|
|
16513
18068
|
runId,
|
|
@@ -16515,7 +18070,7 @@ async function writeValidateRunLog(input) {
|
|
|
16515
18070
|
};
|
|
16516
18071
|
}
|
|
16517
18072
|
async function writeJson(filePath, value) {
|
|
16518
|
-
await (0,
|
|
18073
|
+
await (0, import_promises56.writeFile)(filePath, `${JSON.stringify(value, null, 2)}
|
|
16519
18074
|
`, "utf-8");
|
|
16520
18075
|
}
|
|
16521
18076
|
function resolveStatus(result, override) {
|
|
@@ -16631,9 +18186,9 @@ async function allocateRunReportDir(outDir, startedAt) {
|
|
|
16631
18186
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
16632
18187
|
const candidateDate = new Date(startedAt.getTime() + attempt);
|
|
16633
18188
|
const runId = `run-${formatTimestamp17(candidateDate)}`;
|
|
16634
|
-
const reportDir =
|
|
18189
|
+
const reportDir = import_node_path59.default.join(outDir, runId);
|
|
16635
18190
|
try {
|
|
16636
|
-
await (0,
|
|
18191
|
+
await (0, import_promises56.mkdir)(reportDir);
|
|
16637
18192
|
return { runId, reportDir };
|
|
16638
18193
|
} catch (error2) {
|
|
16639
18194
|
if (isAlreadyExistsError(error2)) {
|
|
@@ -16662,7 +18217,7 @@ function shouldFail(result, failOn) {
|
|
|
16662
18217
|
// src/cli/commands/validate.ts
|
|
16663
18218
|
async function runValidate(options) {
|
|
16664
18219
|
const startedAt = /* @__PURE__ */ new Date();
|
|
16665
|
-
const root =
|
|
18220
|
+
const root = import_node_path60.default.resolve(options.root);
|
|
16666
18221
|
const configResult = await loadConfig(root);
|
|
16667
18222
|
const blockedIssue = buildCiRefinementIssue(options.phase);
|
|
16668
18223
|
const blockedByPhaseGuard = blockedIssue !== null;
|
|
@@ -16818,12 +18373,12 @@ function issueKey(issue2) {
|
|
|
16818
18373
|
}
|
|
16819
18374
|
async function emitJson(result, root, jsonPath) {
|
|
16820
18375
|
const abs = resolveJsonPath(root, jsonPath);
|
|
16821
|
-
await (0,
|
|
16822
|
-
await (0,
|
|
18376
|
+
await (0, import_promises57.mkdir)(import_node_path60.default.dirname(abs), { recursive: true });
|
|
18377
|
+
await (0, import_promises57.writeFile)(abs, `${JSON.stringify(result, null, 2)}
|
|
16823
18378
|
`, "utf-8");
|
|
16824
18379
|
}
|
|
16825
18380
|
function resolveJsonPath(root, jsonPath) {
|
|
16826
|
-
return
|
|
18381
|
+
return import_node_path60.default.isAbsolute(jsonPath) ? jsonPath : import_node_path60.default.resolve(root, jsonPath);
|
|
16827
18382
|
}
|
|
16828
18383
|
var GITHUB_ANNOTATION_LIMIT = 100;
|
|
16829
18384
|
var ISSUE_EXPECTED_BY_CODE = {
|