@chrisdudek/yg 5.0.0-alpha.2 → 5.0.0-alpha.4
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/ast.d.ts +7 -2
- package/dist/ast.js +7 -2
- package/dist/bin.js +582 -187
- package/dist/structure.d.ts +8 -0
- package/dist/structure.js +111 -49
- package/graph-schemas/yg-architecture.yaml +1 -1
- package/graph-schemas/yg-config.yaml +6 -0
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -162,9 +162,23 @@ var init_graph = __esm({
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
// src/utils/mapping-path.ts
|
|
165
|
+
import { minimatch } from "minimatch";
|
|
165
166
|
function normalizeMappingPath(p2) {
|
|
166
167
|
return p2.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
167
168
|
}
|
|
169
|
+
function isGlobPattern(entry) {
|
|
170
|
+
return entry.includes("*");
|
|
171
|
+
}
|
|
172
|
+
function globMatch(file, pattern) {
|
|
173
|
+
return minimatch(file, pattern, { dot: true });
|
|
174
|
+
}
|
|
175
|
+
function mappingEntryMatchesFile(entry, file) {
|
|
176
|
+
const e = normalizeMappingPath(entry);
|
|
177
|
+
const f = normalizeMappingPath(file);
|
|
178
|
+
if (e === "") return false;
|
|
179
|
+
if (isGlobPattern(e)) return globMatch(f, e);
|
|
180
|
+
return f === e || f.startsWith(e + "/");
|
|
181
|
+
}
|
|
168
182
|
var init_mapping_path = __esm({
|
|
169
183
|
"src/utils/mapping-path.ts"() {
|
|
170
184
|
"use strict";
|
|
@@ -570,9 +584,25 @@ async function collectFiles(dir, projectRoot, stack) {
|
|
|
570
584
|
}
|
|
571
585
|
return results;
|
|
572
586
|
}
|
|
587
|
+
function excludeNestedGraphSubtrees(relPaths) {
|
|
588
|
+
const seg = `/${YGGDRASIL_DIRNAME}/`;
|
|
589
|
+
const nestedRoots = /* @__PURE__ */ new Set();
|
|
590
|
+
for (const p2 of relPaths) {
|
|
591
|
+
const idx = p2.indexOf(seg);
|
|
592
|
+
if (idx > 0) nestedRoots.add(p2.slice(0, idx));
|
|
593
|
+
}
|
|
594
|
+
if (nestedRoots.size === 0) return relPaths;
|
|
595
|
+
return relPaths.filter((p2) => {
|
|
596
|
+
for (const root of nestedRoots) {
|
|
597
|
+
if (p2 === root || p2.startsWith(root + "/")) return false;
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
573
602
|
async function walkRepoFiles(projectRoot) {
|
|
574
603
|
const stack = await loadRootGitignoreStack(projectRoot);
|
|
575
|
-
|
|
604
|
+
const files = await collectFiles(projectRoot, projectRoot, stack);
|
|
605
|
+
return excludeNestedGraphSubtrees(files);
|
|
576
606
|
}
|
|
577
607
|
var require2, ignoreFactory, YGGDRASIL_DIRNAME;
|
|
578
608
|
var init_repo_scanner = __esm({
|
|
@@ -659,6 +689,11 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
|
|
|
659
689
|
const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
|
|
660
690
|
const allFiles = [];
|
|
661
691
|
for (const tf of trackedFiles) {
|
|
692
|
+
if (isGlobPattern(tf.path)) {
|
|
693
|
+
const entries = await expandGlobEntry(projectRoot, tf.path, gitignoreStack);
|
|
694
|
+
for (const entry of entries) allFiles.push(entry);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
662
697
|
const absPath = path16.join(projectRoot, tf.path);
|
|
663
698
|
try {
|
|
664
699
|
const st = await stat5(absPath);
|
|
@@ -681,7 +716,7 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
|
|
|
681
716
|
continue;
|
|
682
717
|
}
|
|
683
718
|
}
|
|
684
|
-
const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) =>
|
|
719
|
+
const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => mappingEntryMatchesFile(prefix, entry.relPath))) : allFiles;
|
|
685
720
|
const dirty = [];
|
|
686
721
|
for (const entry of filtered) {
|
|
687
722
|
const storedMtime = storedFileData?.mtimes[entry.relPath];
|
|
@@ -741,26 +776,50 @@ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, optio
|
|
|
741
776
|
result.push(...fileStats);
|
|
742
777
|
return result;
|
|
743
778
|
}
|
|
779
|
+
async function expandGlobEntry(projectRoot, glob, gitignoreStack) {
|
|
780
|
+
const segments = glob.split("/");
|
|
781
|
+
const firstGlobIdx = segments.findIndex((s) => isGlobPattern(s));
|
|
782
|
+
const baseSegments = firstGlobIdx > 0 ? segments.slice(0, firstGlobIdx) : [];
|
|
783
|
+
const baseDir = baseSegments.length > 0 ? path16.join(projectRoot, ...baseSegments) : projectRoot;
|
|
784
|
+
try {
|
|
785
|
+
const dirEntries = await collectDirectoryFilePaths(baseDir, projectRoot, {
|
|
786
|
+
projectRoot,
|
|
787
|
+
gitignoreStack
|
|
788
|
+
});
|
|
789
|
+
return dirEntries.filter((entry) => globMatch(entry.relPath, glob)).map((entry) => ({
|
|
790
|
+
relPath: toPosixPath(entry.relPath),
|
|
791
|
+
absPath: entry.absPath,
|
|
792
|
+
mtimeMs: entry.mtimeMs
|
|
793
|
+
}));
|
|
794
|
+
} catch {
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
744
798
|
async function expandMappingPaths(projectRoot, mappingPaths) {
|
|
745
799
|
const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
|
|
746
800
|
const result = [];
|
|
747
801
|
for (const mp of mappingPaths) {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
802
|
+
if (isGlobPattern(mp)) {
|
|
803
|
+
const entries = await expandGlobEntry(projectRoot, mp, gitignoreStack);
|
|
804
|
+
for (const entry of entries) result.push(entry.relPath);
|
|
805
|
+
} else {
|
|
806
|
+
const absPath = path16.join(projectRoot, mp);
|
|
807
|
+
try {
|
|
808
|
+
const st = await stat5(absPath);
|
|
809
|
+
if (st.isDirectory()) {
|
|
810
|
+
const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
|
|
811
|
+
projectRoot,
|
|
812
|
+
gitignoreStack
|
|
813
|
+
});
|
|
814
|
+
for (const entry of dirEntries) {
|
|
815
|
+
result.push(toPosixPath(path16.join(mp, entry.relPath)));
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
result.push(toPosixPath(mp));
|
|
758
819
|
}
|
|
759
|
-
}
|
|
760
|
-
|
|
820
|
+
} catch {
|
|
821
|
+
continue;
|
|
761
822
|
}
|
|
762
|
-
} catch {
|
|
763
|
-
continue;
|
|
764
823
|
}
|
|
765
824
|
}
|
|
766
825
|
return result;
|
|
@@ -770,6 +829,7 @@ var init_hash = __esm({
|
|
|
770
829
|
"src/io/hash.ts"() {
|
|
771
830
|
"use strict";
|
|
772
831
|
init_posix();
|
|
832
|
+
init_mapping_path();
|
|
773
833
|
init_repo_scanner();
|
|
774
834
|
require3 = createRequire2(import.meta.url);
|
|
775
835
|
ignoreFactory2 = require3("ignore");
|
|
@@ -1031,10 +1091,10 @@ function computeEffectiveAspectStatuses(node, graph) {
|
|
|
1031
1091
|
for (const a of graph.aspects) idToAspect.set(a.id, a);
|
|
1032
1092
|
let changed = true;
|
|
1033
1093
|
let iterations = 0;
|
|
1034
|
-
const maxIterations = graph.aspects.length +
|
|
1094
|
+
const maxIterations = graph.aspects.length + 2;
|
|
1035
1095
|
while (changed) {
|
|
1036
1096
|
if (++iterations > maxIterations) {
|
|
1037
|
-
throw new ImpliesCycleError(
|
|
1097
|
+
throw new ImpliesCycleError("implies fix-point exceeded its iteration bound (internal invariant violated)");
|
|
1038
1098
|
}
|
|
1039
1099
|
changed = false;
|
|
1040
1100
|
const currentIds = [...result.keys()];
|
|
@@ -1242,8 +1302,7 @@ function collectTrackedFiles(node, graph, baseline) {
|
|
|
1242
1302
|
}
|
|
1243
1303
|
const allAspectIds = computeEffectiveAspects(node, graph);
|
|
1244
1304
|
const mappingPathsList = normalizeMappingPaths(node.meta.mapping);
|
|
1245
|
-
const
|
|
1246
|
-
const isOwnedByMapping = (p2) => mappingPathsSet.has(p2) || mappingPathsList.some((m) => p2.startsWith(m + "/"));
|
|
1305
|
+
const isOwnedByMapping = (p2) => mappingPathsList.some((m) => mappingEntryMatchesFile(m, p2));
|
|
1247
1306
|
for (const aspectId of allAspectIds) {
|
|
1248
1307
|
const aspect = graph.aspects.find((a) => a.id === aspectId);
|
|
1249
1308
|
if (!aspect) continue;
|
|
@@ -1347,6 +1406,7 @@ var init_files = __esm({
|
|
|
1347
1406
|
"src/core/graph/files.ts"() {
|
|
1348
1407
|
"use strict";
|
|
1349
1408
|
init_paths();
|
|
1409
|
+
init_mapping_path();
|
|
1350
1410
|
init_traversal();
|
|
1351
1411
|
init_aspects();
|
|
1352
1412
|
init_tier_selection();
|
|
@@ -3090,9 +3150,8 @@ function isAllowed(p2, set) {
|
|
|
3090
3150
|
if (p2 === "") return false;
|
|
3091
3151
|
if (set.has(p2)) return true;
|
|
3092
3152
|
for (const a of set) {
|
|
3093
|
-
if (a === p2) return true;
|
|
3094
3153
|
if (a.startsWith(p2 + "/")) return true;
|
|
3095
|
-
if (
|
|
3154
|
+
if (mappingEntryMatchesFile(a, p2)) return true;
|
|
3096
3155
|
}
|
|
3097
3156
|
return false;
|
|
3098
3157
|
}
|
|
@@ -3184,13 +3243,7 @@ var init_ctx_fs = __esm({
|
|
|
3184
3243
|
function isPathInMapping(candidate, mapping) {
|
|
3185
3244
|
const c = normalizeMappingPath(candidate);
|
|
3186
3245
|
if (c === "") return false;
|
|
3187
|
-
|
|
3188
|
-
const n = normalizeMappingPath(raw);
|
|
3189
|
-
if (n === "") continue;
|
|
3190
|
-
if (c === n) return true;
|
|
3191
|
-
if (c.startsWith(n + "/")) return true;
|
|
3192
|
-
}
|
|
3193
|
-
return false;
|
|
3246
|
+
return mapping.some((raw) => mappingEntryMatchesFile(raw, c));
|
|
3194
3247
|
}
|
|
3195
3248
|
var init_expand_mapping_sync = __esm({
|
|
3196
3249
|
"src/structure/expand-mapping-sync.ts"() {
|
|
@@ -3231,15 +3284,16 @@ function computeAllowedNodePaths(currentPath, graph) {
|
|
|
3231
3284
|
return allowed;
|
|
3232
3285
|
}
|
|
3233
3286
|
function createCtxGraph(params) {
|
|
3234
|
-
const { currentNodePath, graph, projectRoot, touchedFiles } = params;
|
|
3287
|
+
const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode } = params;
|
|
3235
3288
|
const allowed = computeAllowedNodePaths(currentNodePath, graph);
|
|
3236
3289
|
function assertAllowed(id) {
|
|
3237
3290
|
if (!allowed.has(id)) throw new UndeclaredGraphReadError(id);
|
|
3238
3291
|
}
|
|
3239
3292
|
function toPublicNode(m) {
|
|
3240
3293
|
const files = [];
|
|
3241
|
-
|
|
3242
|
-
|
|
3294
|
+
const preExpanded = expandedFilesByNode?.get(m.path);
|
|
3295
|
+
const candidatePaths = preExpanded ?? (m.meta.mapping ?? []).map(normalizeMappingPath);
|
|
3296
|
+
for (const p2 of candidatePaths) {
|
|
3243
3297
|
if (!p2) continue;
|
|
3244
3298
|
const abs = path29.resolve(projectRoot, p2);
|
|
3245
3299
|
try {
|
|
@@ -3338,10 +3392,14 @@ import path30 from "path";
|
|
|
3338
3392
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
3339
3393
|
import { existsSync as existsSync5 } from "fs";
|
|
3340
3394
|
import { createRequire as createRequire3 } from "module";
|
|
3341
|
-
|
|
3342
|
-
if (
|
|
3343
|
-
|
|
3344
|
-
|
|
3395
|
+
function init() {
|
|
3396
|
+
if (initPromise === null) {
|
|
3397
|
+
initPromise = Parser.init();
|
|
3398
|
+
initPromise.catch(() => {
|
|
3399
|
+
initPromise = null;
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
return initPromise;
|
|
3345
3403
|
}
|
|
3346
3404
|
function resolveWasm(filename, pkg2) {
|
|
3347
3405
|
for (const dir of GRAMMAR_DIRS) {
|
|
@@ -3364,12 +3422,16 @@ async function getParser(extension) {
|
|
|
3364
3422
|
throw new Error(`no parser for extension '${extension}'`);
|
|
3365
3423
|
}
|
|
3366
3424
|
const cacheKey = info.wasmFile;
|
|
3367
|
-
let
|
|
3368
|
-
if (
|
|
3425
|
+
let langP = langCache.get(cacheKey);
|
|
3426
|
+
if (langP === void 0) {
|
|
3369
3427
|
const wasmPath = resolveWasm(info.wasmFile, info.wasmPackage);
|
|
3370
|
-
|
|
3371
|
-
langCache.set(cacheKey,
|
|
3428
|
+
langP = Language.load(wasmPath);
|
|
3429
|
+
langCache.set(cacheKey, langP);
|
|
3430
|
+
langP.catch(() => {
|
|
3431
|
+
if (langCache.get(cacheKey) === langP) langCache.delete(cacheKey);
|
|
3432
|
+
});
|
|
3372
3433
|
}
|
|
3434
|
+
const lang = await langP;
|
|
3373
3435
|
const parser = new Parser();
|
|
3374
3436
|
parser.setLanguage(lang);
|
|
3375
3437
|
return parser;
|
|
@@ -3383,7 +3445,7 @@ async function parseFile(filePath, content14) {
|
|
|
3383
3445
|
}
|
|
3384
3446
|
return tree;
|
|
3385
3447
|
}
|
|
3386
|
-
var _require, __filename2, __dirname2, GRAMMAR_DIRS,
|
|
3448
|
+
var _require, __filename2, __dirname2, GRAMMAR_DIRS, initPromise, langCache;
|
|
3387
3449
|
var init_parser = __esm({
|
|
3388
3450
|
"src/ast/parser.ts"() {
|
|
3389
3451
|
"use strict";
|
|
@@ -3395,7 +3457,7 @@ var init_parser = __esm({
|
|
|
3395
3457
|
path30.resolve(__dirname2, "grammars"),
|
|
3396
3458
|
path30.resolve(__dirname2, "..", "grammars")
|
|
3397
3459
|
];
|
|
3398
|
-
|
|
3460
|
+
initPromise = null;
|
|
3399
3461
|
langCache = /* @__PURE__ */ new Map();
|
|
3400
3462
|
}
|
|
3401
3463
|
});
|
|
@@ -3624,15 +3686,23 @@ function parseMarker(commentText, line, file) {
|
|
|
3624
3686
|
if (m) return makeMarker("single", m, line, file);
|
|
3625
3687
|
return null;
|
|
3626
3688
|
}
|
|
3627
|
-
function collectSuppressions(tree, file, totalLines) {
|
|
3628
|
-
|
|
3629
|
-
return [];
|
|
3630
|
-
}
|
|
3631
|
-
const comments = findComments({ path: file, ast: tree });
|
|
3689
|
+
function collectSuppressions(tree, file, totalLines, content14) {
|
|
3690
|
+
const hasGrammar = getLanguageForExtension(extname3(file)) !== null;
|
|
3632
3691
|
const markers = [];
|
|
3633
|
-
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3692
|
+
if (hasGrammar && tree) {
|
|
3693
|
+
const comments = findComments({ path: file, ast: tree });
|
|
3694
|
+
for (const c of comments) {
|
|
3695
|
+
const m = parseMarker(c.text, c.startPosition.row + 1, file);
|
|
3696
|
+
if (m) markers.push(m);
|
|
3697
|
+
}
|
|
3698
|
+
} else if (content14 !== void 0) {
|
|
3699
|
+
const lines = content14.split("\n");
|
|
3700
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3701
|
+
const m = parseMarker(lines[i], i + 1, file);
|
|
3702
|
+
if (m) markers.push(m);
|
|
3703
|
+
}
|
|
3704
|
+
} else {
|
|
3705
|
+
return [];
|
|
3636
3706
|
}
|
|
3637
3707
|
markers.sort((a, b) => a.line - b.line);
|
|
3638
3708
|
const ranges = [];
|
|
@@ -3730,8 +3800,8 @@ var init_suppress = __esm({
|
|
|
3730
3800
|
}
|
|
3731
3801
|
code = "SUPPRESS_MARKER_MISSING_REASON";
|
|
3732
3802
|
};
|
|
3733
|
-
RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)
|
|
3734
|
-
RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)
|
|
3803
|
+
RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
|
|
3804
|
+
RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
|
|
3735
3805
|
RE_ENABLE = /\byg-suppress-enable\(\s*([^)]+?)\s*\)/;
|
|
3736
3806
|
}
|
|
3737
3807
|
});
|
|
@@ -3862,7 +3932,12 @@ async function runStructureAspect(params) {
|
|
|
3862
3932
|
const checkFn = mod.check;
|
|
3863
3933
|
const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
|
|
3864
3934
|
const ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles });
|
|
3865
|
-
const
|
|
3935
|
+
const expandedFilesByNode = /* @__PURE__ */ new Map();
|
|
3936
|
+
for (const id of computeAllowedNodePaths(nodePath, graph)) {
|
|
3937
|
+
const m = graph.nodes.get(id);
|
|
3938
|
+
if (m) expandedFilesByNode.set(id, await enumerateMappedFilesAsync(m.meta.mapping ?? [], projectRoot));
|
|
3939
|
+
}
|
|
3940
|
+
const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles, expandedFilesByNode });
|
|
3866
3941
|
const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache });
|
|
3867
3942
|
const ownFiles = await buildOwnFiles(node, projectRoot, touchedFiles);
|
|
3868
3943
|
await prewarmupAstCache({ astCache, projectRoot, files: ownFiles });
|
|
@@ -3976,12 +4051,22 @@ ${err.stack ?? ""}`,
|
|
|
3976
4051
|
}
|
|
3977
4052
|
violations.push(vv);
|
|
3978
4053
|
}
|
|
4054
|
+
const contentByPath = /* @__PURE__ */ new Map();
|
|
4055
|
+
for (const f of [...ownFiles, ...astInputSet]) {
|
|
4056
|
+
contentByPath.set(normalizeMappingPath(f.path), f.content);
|
|
4057
|
+
}
|
|
3979
4058
|
const rangesByFile = /* @__PURE__ */ new Map();
|
|
3980
4059
|
function rangesFor(filePath) {
|
|
3981
4060
|
const existing = rangesByFile.get(filePath);
|
|
3982
4061
|
if (existing !== void 0) return existing;
|
|
3983
4062
|
const cached = astCache.get(filePath);
|
|
3984
|
-
|
|
4063
|
+
let ranges;
|
|
4064
|
+
if (cached) {
|
|
4065
|
+
ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
|
|
4066
|
+
} else {
|
|
4067
|
+
const content14 = contentByPath.get(filePath);
|
|
4068
|
+
ranges = content14 !== void 0 ? collectSuppressions(void 0, filePath, content14.split("\n").length, content14) : null;
|
|
4069
|
+
}
|
|
3985
4070
|
rangesByFile.set(filePath, ranges);
|
|
3986
4071
|
return ranges;
|
|
3987
4072
|
}
|
|
@@ -4575,7 +4660,7 @@ The CLI (\`yg\`) reads and validates \u2014 it never modifies files. You create
|
|
|
4575
4660
|
.drift-state/ \u2190 generated by CLI; never edit manually
|
|
4576
4661
|
\`\`\`
|
|
4577
4662
|
|
|
4578
|
-
**Nodes** \u2014 components. \`model/<path>/yg-node.yaml\`. Nodes nest by directory \u2014 children inherit parent aspects. Schema: \`schemas/yg-node.yaml\`.
|
|
4663
|
+
**Nodes** \u2014 components. \`model/<path>/yg-node.yaml\`. Nodes nest by directory \u2014 children inherit parent aspects. Schema: \`schemas/yg-node.yaml\`. Node \`mapping:\` entries and architecture \`when.path\` both accept minimatch glob patterns \u2014 \`*\` matches within a single path segment, \`**\` matches across segments (e.g. \`src/db/*Repository.cs\` maps only repository files in that directory; \`src/**/*.ts\` maps all TypeScript files under src).
|
|
4579
4664
|
|
|
4580
4665
|
**Aspects** \u2014 enforceable rules. \`aspects/<id>/yg-aspect.yaml\` + zero or one rule source files. The reviewer kind is inferred from which rule source is present: \`content.md\` \u2192 LLM reviewer; \`check.mjs\` \u2192 deterministic reviewer; neither file but \`implies:\` declared \u2192 aggregating aspect (a named bundle with no own reviewer). The \`reviewer:\` block in \`yg-aspect.yaml\` is optional \u2014 kind is inferred automatically. If present, an explicit \`reviewer.type\` must agree with the inferred kind. LLM aspects may set \`reviewer.tier:\` to pick a named tier from \`yg-config.yaml\` (otherwise the configured default tier is used). An aspect can declare \`implies: [other-aspect]\` \u2014 implied aspects are included recursively (must be acyclic). LLM aspects may declare \`references:\` \u2014 supporting files (lookup tables, catalogues) included in the reviewer prompt and exposed to the agent under \`read:\`. Schema: \`schemas/yg-aspect.yaml\`. Aspects also carry a \`status:\` field (default \`enforced\`) \u2014 three levels \`draft / advisory / enforced\` control whether the reviewer runs and whether violations block.
|
|
4581
4666
|
|
|
@@ -4968,7 +5053,7 @@ context.
|
|
|
4968
5053
|
|
|
4969
5054
|
**Flow** \u2014 when you see a sequence of steps toward a business goal. Not code call sequences \u2014 real-world processes. "User places an order" = flow. "Handler calls service" = relation between nodes. Read \`schemas/yg-flow.yaml\` and \`yg knowledge read flows\` before creating.
|
|
4970
5055
|
|
|
4971
|
-
**Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node's mapped source (plus any aspect reference files) exceeds the per-node character budget (\`quality.max_node_chars\`, default 40000) or it covers >3 distinct workflows, split into children \u2014 \`yg check\` enforces the budget as an \`oversized-node\` error. Why: the reviewer
|
|
5056
|
+
**Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node's mapped source (plus any aspect reference files) exceeds the per-node character budget (\`quality.max_node_chars\`, default 40000) or it covers >3 distinct workflows, split into children \u2014 \`yg check\` enforces the budget as an \`oversized-node\` error. Why: the LLM reviewer sends ALL of a node's files in one prompt, so an oversized context dilutes focus and risks window truncation that falsely rejects unchanged code. The budget therefore applies ONLY to nodes an LLM reviewer actually reads \u2014 those with at least one non-draft LLM aspect; deterministic-only and aspect-less nodes carry no budget (a \`check.mjs\` reads files programmatically, with no context window). Keep LLM-reviewed nodes well under the budget. Binary files (images, fonts, archives, etc.) count 0 toward the budget automatically. A node mapping a single unsplittable generated **text** artifact (e.g. a large lockfile) can opt out with \`sizeExempt: { reason: "..." }\`. Read \`schemas/yg-node.yaml\` before creating.
|
|
4972
5057
|
|
|
4973
5058
|
**Port / relation** \u2014 when a critical aspect must cross a node boundary, or when a new typed dependency is needed. Bare relations do NOT propagate aspects; ports do. Six relation types exist (\`calls\`, \`uses\`, \`extends\`, \`implements\`, \`emits\`, \`listens\`); event relations must be paired. Deep dive: \`yg knowledge read ports-and-relations\`.
|
|
4974
5059
|
|
|
@@ -5677,6 +5762,43 @@ var DEFAULT_QUALITY = {
|
|
|
5677
5762
|
max_direct_relations: 10,
|
|
5678
5763
|
max_node_chars: 4e4
|
|
5679
5764
|
};
|
|
5765
|
+
var DEFAULT_COVERAGE = { required: ["/"], excluded: [] };
|
|
5766
|
+
function parseStringArray(raw, field, filename) {
|
|
5767
|
+
if (raw === void 0) return [];
|
|
5768
|
+
if (!Array.isArray(raw) || raw.some((x) => typeof x !== "string")) {
|
|
5769
|
+
throw new ConfigParseError({
|
|
5770
|
+
what: `${filename}: ${field} must be a list of strings (got ${JSON.stringify(raw)}).`,
|
|
5771
|
+
why: "Coverage roots are repo-relative path prefixes; a non-list value cannot be matched against files.",
|
|
5772
|
+
next: `Set ${field} to a YAML list, e.g.
|
|
5773
|
+
${field.split(".").pop()}:
|
|
5774
|
+
- services/`
|
|
5775
|
+
}, "config-invalid");
|
|
5776
|
+
}
|
|
5777
|
+
return raw;
|
|
5778
|
+
}
|
|
5779
|
+
function parseCoverage(raw, filename) {
|
|
5780
|
+
if (raw === void 0) return DEFAULT_COVERAGE;
|
|
5781
|
+
if (typeof raw !== "object" || Array.isArray(raw) || raw === null) {
|
|
5782
|
+
throw new ConfigParseError({
|
|
5783
|
+
what: `${filename}: coverage must be a mapping`,
|
|
5784
|
+
why: "coverage holds the required/excluded root lists",
|
|
5785
|
+
next: 'replace with `coverage: { required: ["/"], excluded: [] }`'
|
|
5786
|
+
}, "config-invalid");
|
|
5787
|
+
}
|
|
5788
|
+
const cov = raw;
|
|
5789
|
+
const required = cov.required === void 0 ? ["/"] : parseStringArray(cov.required, "coverage.required", filename);
|
|
5790
|
+
const excluded = parseStringArray(cov.excluded, "coverage.excluded", filename);
|
|
5791
|
+
for (const root of [...required, ...excluded]) {
|
|
5792
|
+
if (root.split("/").includes("..")) {
|
|
5793
|
+
throw new ConfigParseError({
|
|
5794
|
+
what: `${filename}: coverage root '${root}' contains a '..' segment.`,
|
|
5795
|
+
why: "'..' is not a valid repo-relative prefix and will never match any git-tracked path, silently mis-scoping coverage enforcement.",
|
|
5796
|
+
next: 'Use a repo-relative path prefix without any ".." segments (e.g. - services/ instead of - services/../other/).'
|
|
5797
|
+
}, "config-invalid");
|
|
5798
|
+
}
|
|
5799
|
+
}
|
|
5800
|
+
return { required, excluded };
|
|
5801
|
+
}
|
|
5680
5802
|
function parseMaxNodeChars(raw, filename) {
|
|
5681
5803
|
if (raw === void 0) return DEFAULT_QUALITY.max_node_chars ?? 4e4;
|
|
5682
5804
|
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
|
|
@@ -5688,6 +5810,17 @@ function parseMaxNodeChars(raw, filename) {
|
|
|
5688
5810
|
}
|
|
5689
5811
|
return raw;
|
|
5690
5812
|
}
|
|
5813
|
+
function parseMaxDirectRelations(raw, filename) {
|
|
5814
|
+
if (raw === void 0) return DEFAULT_QUALITY.max_direct_relations ?? 10;
|
|
5815
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
|
|
5816
|
+
throw new ConfigParseError({
|
|
5817
|
+
what: `${filename}: quality.max_direct_relations must be a positive integer (got ${JSON.stringify(raw)}).`,
|
|
5818
|
+
why: "It is the per-node relation-count budget; a zero, negative, or fractional value makes the threshold nonsensical.",
|
|
5819
|
+
next: "Set quality.max_direct_relations to a positive integer (default 10), or remove it to use the default."
|
|
5820
|
+
}, "config-invalid");
|
|
5821
|
+
}
|
|
5822
|
+
return raw;
|
|
5823
|
+
}
|
|
5691
5824
|
var PROVIDER_DEFAULTS = {
|
|
5692
5825
|
"claude-code": { model: "haiku" },
|
|
5693
5826
|
"codex": { model: "o4-mini" },
|
|
@@ -5715,7 +5848,7 @@ async function parseConfig(filePath) {
|
|
|
5715
5848
|
}
|
|
5716
5849
|
const qualityMap = qualityRaw;
|
|
5717
5850
|
const quality = qualityMap ? {
|
|
5718
|
-
max_direct_relations:
|
|
5851
|
+
max_direct_relations: parseMaxDirectRelations(qualityMap.max_direct_relations, filename),
|
|
5719
5852
|
max_node_chars: parseMaxNodeChars(qualityMap.max_node_chars, filename)
|
|
5720
5853
|
} : DEFAULT_QUALITY;
|
|
5721
5854
|
let reviewer;
|
|
@@ -5754,7 +5887,8 @@ async function parseConfig(filePath) {
|
|
|
5754
5887
|
quality,
|
|
5755
5888
|
reviewer,
|
|
5756
5889
|
parallel,
|
|
5757
|
-
debug
|
|
5890
|
+
debug,
|
|
5891
|
+
coverage: parseCoverage(raw.coverage, filename)
|
|
5758
5892
|
};
|
|
5759
5893
|
}
|
|
5760
5894
|
function parseReviewer(raw, filename) {
|
|
@@ -5891,6 +6025,13 @@ function parseTier(name, raw, filename) {
|
|
|
5891
6025
|
next: "add `model: <model-name>` under config:"
|
|
5892
6026
|
}, "config-tier-config-missing");
|
|
5893
6027
|
}
|
|
6028
|
+
if (t.provider === "openai-compatible" && (typeof c.endpoint !== "string" || !c.endpoint.trim())) {
|
|
6029
|
+
throw new ConfigParseError({
|
|
6030
|
+
what: `${filename}: tier '${name}' (provider 'openai-compatible') is missing config.endpoint`,
|
|
6031
|
+
why: `'openai-compatible' has no default host \u2014 without an explicit endpoint it silently falls back to the public OpenAI API (api.openai.com).`,
|
|
6032
|
+
next: "add `endpoint: <url>` under config: pointing at your compatible server."
|
|
6033
|
+
}, "config-tier-endpoint-missing");
|
|
6034
|
+
}
|
|
5894
6035
|
const allowed = /* @__PURE__ */ new Set(["provider", "consensus", "config", "references"]);
|
|
5895
6036
|
for (const k of Object.keys(t)) {
|
|
5896
6037
|
if (!allowed.has(k)) {
|
|
@@ -6314,6 +6455,10 @@ function parseRelations(raw, filePath) {
|
|
|
6314
6455
|
);
|
|
6315
6456
|
}
|
|
6316
6457
|
rel.consumes = consumesArr;
|
|
6458
|
+
} else if (obj.consumes !== void 0) {
|
|
6459
|
+
throw new Error(
|
|
6460
|
+
`yg-node.yaml at ${filePath}: relations[${index}].consumes must be an array of string port names (got ${Array.isArray(obj.consumes) ? "array" : typeof obj.consumes}). A scalar value is silently ignored, so the consumed port's required aspects would not be enforced. Use consumes: [<port-name>].`
|
|
6461
|
+
);
|
|
6317
6462
|
}
|
|
6318
6463
|
if (typeof obj.event_name === "string" && obj.event_name.trim()) {
|
|
6319
6464
|
rel.event_name = obj.event_name.trim();
|
|
@@ -6872,13 +7017,13 @@ function parseReviewer2(raw, aspectId, files) {
|
|
|
6872
7017
|
next: "add `type: llm` or `type: deterministic` under reviewer:"
|
|
6873
7018
|
}
|
|
6874
7019
|
});
|
|
6875
|
-
} else if (obj.type !== "llm" && obj.type !== "deterministic") {
|
|
7020
|
+
} else if (obj.type !== "llm" && obj.type !== "deterministic" && obj.type !== "aggregate") {
|
|
6876
7021
|
errors.push({
|
|
6877
7022
|
code: "aspect-reviewer-type-invalid",
|
|
6878
7023
|
messageData: {
|
|
6879
7024
|
what: `aspect '${aspectId}' has invalid reviewer.type: '${String(obj.type)}'`,
|
|
6880
|
-
why: 'only "llm"
|
|
6881
|
-
next: "change to type: llm or type:
|
|
7025
|
+
why: 'only "llm", "deterministic", or "aggregate" are valid',
|
|
7026
|
+
next: "change to type: llm, type: deterministic, or type: aggregate"
|
|
6882
7027
|
}
|
|
6883
7028
|
});
|
|
6884
7029
|
} else {
|
|
@@ -9366,7 +9511,7 @@ init_posix();
|
|
|
9366
9511
|
import path21 from "path";
|
|
9367
9512
|
|
|
9368
9513
|
// src/core/file-when-evaluator.ts
|
|
9369
|
-
|
|
9514
|
+
init_mapping_path();
|
|
9370
9515
|
var YGGDRASIL_PREFIX = ".yggdrasil/";
|
|
9371
9516
|
function safeRegexTest(re, str) {
|
|
9372
9517
|
const HEAD_LIMIT = 256 * 1024;
|
|
@@ -9445,7 +9590,7 @@ async function evaluateAtomic2(predicate, ctx) {
|
|
|
9445
9590
|
);
|
|
9446
9591
|
}
|
|
9447
9592
|
if (predicate.path !== void 0) {
|
|
9448
|
-
const matches =
|
|
9593
|
+
const matches = globMatch(ctx.repoRelPath, predicate.path);
|
|
9449
9594
|
return {
|
|
9450
9595
|
result: matches,
|
|
9451
9596
|
trace: { kind: "atom-path", pattern: predicate.path, result: matches }
|
|
@@ -9488,7 +9633,15 @@ async function evaluateAtomic2(predicate, ctx) {
|
|
|
9488
9633
|
}
|
|
9489
9634
|
};
|
|
9490
9635
|
}
|
|
9491
|
-
|
|
9636
|
+
let regex;
|
|
9637
|
+
try {
|
|
9638
|
+
regex = new RegExp(predicate.content);
|
|
9639
|
+
} catch {
|
|
9640
|
+
return {
|
|
9641
|
+
result: false,
|
|
9642
|
+
trace: { kind: "atom-content", pattern: predicate.content, result: false, detail: "invalid content regex" }
|
|
9643
|
+
};
|
|
9644
|
+
}
|
|
9492
9645
|
const { match: matches } = safeRegexTest(regex, fileContent.content);
|
|
9493
9646
|
return {
|
|
9494
9647
|
result: matches,
|
|
@@ -9549,6 +9702,9 @@ function renderNode(node, indent, lines) {
|
|
|
9549
9702
|
}
|
|
9550
9703
|
|
|
9551
9704
|
// src/core/checks/architecture.ts
|
|
9705
|
+
init_hash();
|
|
9706
|
+
init_mapping_path();
|
|
9707
|
+
init_posix();
|
|
9552
9708
|
function checkTypeUnknownParent(graph) {
|
|
9553
9709
|
const issues = [];
|
|
9554
9710
|
const knownTypes = new Set(Object.keys(graph.architecture.node_types));
|
|
@@ -9706,7 +9862,15 @@ async function checkTypeWhenMismatch(graph, cache) {
|
|
|
9706
9862
|
const typeDef = graph.architecture.node_types[node.meta.type];
|
|
9707
9863
|
if (typeDef === void 0 || typeDef.when === void 0) continue;
|
|
9708
9864
|
const mapping = node.meta.mapping ?? [];
|
|
9709
|
-
|
|
9865
|
+
const pathsToCheck = [];
|
|
9866
|
+
for (const entry of mapping) {
|
|
9867
|
+
if (isGlobPattern(entry)) {
|
|
9868
|
+
pathsToCheck.push(...(await expandMappingPaths(projectRoot, [entry])).map(toPosixPath));
|
|
9869
|
+
} else {
|
|
9870
|
+
pathsToCheck.push(entry);
|
|
9871
|
+
}
|
|
9872
|
+
}
|
|
9873
|
+
for (const relPath of pathsToCheck) {
|
|
9710
9874
|
const absPath = path21.join(projectRoot, relPath);
|
|
9711
9875
|
const result = await evaluateFileWhen(typeDef.when, {
|
|
9712
9876
|
absPath,
|
|
@@ -10205,6 +10369,23 @@ function checkWhenReferences(graph) {
|
|
|
10205
10369
|
})
|
|
10206
10370
|
});
|
|
10207
10371
|
}
|
|
10372
|
+
} else if (match.consumes_port !== void 0) {
|
|
10373
|
+
const port = match.consumes_port;
|
|
10374
|
+
const known = [...graph.nodes.values()].some(
|
|
10375
|
+
(n) => n.meta.ports !== void 0 && port in n.meta.ports
|
|
10376
|
+
);
|
|
10377
|
+
if (!known) {
|
|
10378
|
+
issues.push({
|
|
10379
|
+
severity: "error",
|
|
10380
|
+
code: "when-unknown-port",
|
|
10381
|
+
rule: "when-unknown-port",
|
|
10382
|
+
...issueMsg({
|
|
10383
|
+
what: `Port '${port}' in when at ${ctx}/${relType}.consumes_port is not declared on any node.`,
|
|
10384
|
+
why: "The predicate references a port that no node defines, so it can never match.",
|
|
10385
|
+
next: `Fix the port name, or declare it under ports: on the node(s) this relation targets.`
|
|
10386
|
+
})
|
|
10387
|
+
});
|
|
10388
|
+
}
|
|
10208
10389
|
}
|
|
10209
10390
|
}
|
|
10210
10391
|
};
|
|
@@ -10596,7 +10777,7 @@ function checkAspectStatusDowngrade(graph) {
|
|
|
10596
10777
|
for (const source of sources) {
|
|
10597
10778
|
if (!sourceIsExplicit(source, node, aspectId, graph)) continue;
|
|
10598
10779
|
const otherDeclared = sources.filter((s) => s !== source).map((s) => s.declared);
|
|
10599
|
-
const anchor = otherDeclared
|
|
10780
|
+
const anchor = [...otherDeclared, aspectDefault].reduce(
|
|
10600
10781
|
(acc, cur) => STATUS_ORDER[cur] > STATUS_ORDER[acc] ? cur : acc,
|
|
10601
10782
|
"draft"
|
|
10602
10783
|
);
|
|
@@ -10626,6 +10807,7 @@ function checkAspectStatusDowngrade(graph) {
|
|
|
10626
10807
|
// src/core/checks/mapping.ts
|
|
10627
10808
|
init_paths();
|
|
10628
10809
|
init_hash();
|
|
10810
|
+
init_mapping_path();
|
|
10629
10811
|
init_graph_fs();
|
|
10630
10812
|
init_repo_scanner();
|
|
10631
10813
|
init_aspects();
|
|
@@ -10673,12 +10855,6 @@ async function checkStrictBackwardCoverage(graph, cache) {
|
|
|
10673
10855
|
);
|
|
10674
10856
|
if (strictTypes.length === 0) return { issues: [], unreadable: [] };
|
|
10675
10857
|
const projectRoot = path23.dirname(graph.rootPath);
|
|
10676
|
-
const fileToOwner = /* @__PURE__ */ new Map();
|
|
10677
|
-
for (const [nodePath, node] of graph.nodes) {
|
|
10678
|
-
for (const relPath of node.meta.mapping ?? []) {
|
|
10679
|
-
if (!fileToOwner.has(relPath)) fileToOwner.set(relPath, { nodePath, nodeType: node.meta.type });
|
|
10680
|
-
}
|
|
10681
|
-
}
|
|
10682
10858
|
const repoFiles = await walkRepoFiles(projectRoot);
|
|
10683
10859
|
const issues = [];
|
|
10684
10860
|
const unreadable = [];
|
|
@@ -10741,7 +10917,14 @@ Run: yg impact --type ${sorted[j]}`
|
|
|
10741
10917
|
}
|
|
10742
10918
|
if (matchingTypes.length === 0) continue;
|
|
10743
10919
|
const { typeId, trace } = matchingTypes[0];
|
|
10744
|
-
|
|
10920
|
+
let owner;
|
|
10921
|
+
for (const [nodePath, node] of graph.nodes) {
|
|
10922
|
+
const entries = node.meta.mapping ?? [];
|
|
10923
|
+
if (entries.some((entry) => mappingEntryMatchesFile(entry, relPath))) {
|
|
10924
|
+
owner = { nodePath, nodeType: node.meta.type };
|
|
10925
|
+
break;
|
|
10926
|
+
}
|
|
10927
|
+
}
|
|
10745
10928
|
if (owner === void 0) {
|
|
10746
10929
|
issues.push({
|
|
10747
10930
|
severity: "error",
|
|
@@ -10786,7 +10969,7 @@ function arePathsOverlapping(pathA, pathB) {
|
|
|
10786
10969
|
function isAncestorNode(possibleAncestor, possibleDescendant) {
|
|
10787
10970
|
return possibleDescendant.startsWith(possibleAncestor + "/");
|
|
10788
10971
|
}
|
|
10789
|
-
function checkMappingOverlap(graph) {
|
|
10972
|
+
async function checkMappingOverlap(graph) {
|
|
10790
10973
|
const issues = [];
|
|
10791
10974
|
const ownership = [];
|
|
10792
10975
|
for (const [nodePath, node] of graph.nodes) {
|
|
@@ -10832,6 +11015,46 @@ function checkMappingOverlap(graph) {
|
|
|
10832
11015
|
});
|
|
10833
11016
|
}
|
|
10834
11017
|
}
|
|
11018
|
+
const anyGlob = [...graph.nodes.values()].some(
|
|
11019
|
+
(n) => (n.meta.mapping ?? []).some((e) => isGlobPattern(e))
|
|
11020
|
+
);
|
|
11021
|
+
if (anyGlob) {
|
|
11022
|
+
const projectRoot = path23.dirname(graph.rootPath);
|
|
11023
|
+
const repoFiles = await walkRepoFiles(projectRoot);
|
|
11024
|
+
const reported = /* @__PURE__ */ new Set();
|
|
11025
|
+
for (const rawRel of repoFiles) {
|
|
11026
|
+
const relPath = normalizePathForCompare(rawRel);
|
|
11027
|
+
const owners = [];
|
|
11028
|
+
let viaGlob = false;
|
|
11029
|
+
for (const [nodePath, node] of graph.nodes) {
|
|
11030
|
+
let matched = false;
|
|
11031
|
+
for (const entry of node.meta.mapping ?? []) {
|
|
11032
|
+
if (!mappingEntryMatchesFile(entry, relPath)) continue;
|
|
11033
|
+
matched = true;
|
|
11034
|
+
if (isGlobPattern(entry)) viaGlob = true;
|
|
11035
|
+
}
|
|
11036
|
+
if (matched) owners.push(nodePath);
|
|
11037
|
+
}
|
|
11038
|
+
if (owners.length < 2 || !viaGlob || reported.has(relPath)) continue;
|
|
11039
|
+
const leaves = owners.filter(
|
|
11040
|
+
(o) => !owners.some((other) => other !== o && isAncestorNode(o, other))
|
|
11041
|
+
);
|
|
11042
|
+
if (leaves.length < 2) continue;
|
|
11043
|
+
reported.add(relPath);
|
|
11044
|
+
issues.push({
|
|
11045
|
+
severity: "error",
|
|
11046
|
+
code: "overlapping-mapping",
|
|
11047
|
+
rule: "overlapping-mapping",
|
|
11048
|
+
...issueMsg({
|
|
11049
|
+
what: `File '${relPath}' is owned by multiple non-hierarchical nodes:
|
|
11050
|
+
${leaves.map((n) => " " + n).join("\n")}`,
|
|
11051
|
+
why: `Each source file must have exactly one owner node. A glob mapping in one node resolves to a file also claimed by another node.`,
|
|
11052
|
+
next: `Narrow the glob, or remove the file from one node's mapping and model the dependency via a relation.`
|
|
11053
|
+
}),
|
|
11054
|
+
nodePath: leaves[0]
|
|
11055
|
+
});
|
|
11056
|
+
}
|
|
11057
|
+
}
|
|
10835
11058
|
return issues;
|
|
10836
11059
|
}
|
|
10837
11060
|
async function checkMappingPathsExist(graph) {
|
|
@@ -10840,21 +11063,38 @@ async function checkMappingPathsExist(graph) {
|
|
|
10840
11063
|
for (const [nodePath, node] of graph.nodes) {
|
|
10841
11064
|
const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizePathForCompare);
|
|
10842
11065
|
for (const mp of mappingPaths) {
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
}
|
|
11066
|
+
if (isGlobPattern(mp)) {
|
|
11067
|
+
const matched = await expandMappingPaths(projectRoot, [mp]);
|
|
11068
|
+
if (matched.length === 0) {
|
|
11069
|
+
issues.push({
|
|
11070
|
+
severity: "error",
|
|
11071
|
+
code: "mapping-path-missing",
|
|
11072
|
+
rule: "mapping-path-missing",
|
|
11073
|
+
...issueMsg({
|
|
11074
|
+
what: `Glob '${mp}' matches no files on disk.`,
|
|
11075
|
+
why: `Node maps a glob pattern that currently resolves to no files \u2014 possibly all matching files were deleted or the pattern is wrong.`,
|
|
11076
|
+
next: `Update mapping in yg-node.yaml: fix the glob or remove the entry.`
|
|
11077
|
+
}),
|
|
11078
|
+
nodePath
|
|
11079
|
+
});
|
|
11080
|
+
}
|
|
11081
|
+
} else {
|
|
11082
|
+
const absPath = path23.join(projectRoot, mp);
|
|
11083
|
+
try {
|
|
11084
|
+
await fileAccess(absPath);
|
|
11085
|
+
} catch {
|
|
11086
|
+
issues.push({
|
|
11087
|
+
severity: "error",
|
|
11088
|
+
code: "mapping-path-missing",
|
|
11089
|
+
rule: "mapping-path-missing",
|
|
11090
|
+
...issueMsg({
|
|
11091
|
+
what: `Mapping path '${mp}' does not exist on disk.`,
|
|
11092
|
+
why: `Node maps a file that was deleted or moved.`,
|
|
11093
|
+
next: `Update mapping in yg-node.yaml: fix the path or remove the entry.`
|
|
11094
|
+
}),
|
|
11095
|
+
nodePath
|
|
11096
|
+
});
|
|
11097
|
+
}
|
|
10858
11098
|
}
|
|
10859
11099
|
}
|
|
10860
11100
|
}
|
|
@@ -10924,6 +11164,7 @@ async function checkOversizedNodes(graph, cache) {
|
|
|
10924
11164
|
refsByAspect.set(aspect.id, aspect.references.map((r) => r.path));
|
|
10925
11165
|
}
|
|
10926
11166
|
}
|
|
11167
|
+
const aspectById = new Map(graph.aspects.map((a) => [a.id, a]));
|
|
10927
11168
|
async function charsOf(repoRelPath) {
|
|
10928
11169
|
if (BINARY_EXTENSIONS.has(path23.extname(repoRelPath).toLowerCase())) return 0;
|
|
10929
11170
|
const abs = path23.resolve(projectRoot, repoRelPath);
|
|
@@ -10943,13 +11184,24 @@ async function checkOversizedNodes(graph, cache) {
|
|
|
10943
11184
|
const mappingPaths = normalizeMappingPaths(node.meta.mapping);
|
|
10944
11185
|
if (mappingPaths.length === 0) continue;
|
|
10945
11186
|
const sourceFiles = await expandMappingPaths(projectRoot, mappingPaths);
|
|
10946
|
-
const refPaths = /* @__PURE__ */ new Set();
|
|
10947
11187
|
let effectiveAspects;
|
|
10948
11188
|
try {
|
|
10949
11189
|
effectiveAspects = computeEffectiveAspects(node, graph);
|
|
10950
11190
|
} catch {
|
|
10951
11191
|
effectiveAspects = /* @__PURE__ */ new Set();
|
|
10952
11192
|
}
|
|
11193
|
+
let statuses;
|
|
11194
|
+
try {
|
|
11195
|
+
statuses = computeEffectiveAspectStatuses(node, graph);
|
|
11196
|
+
} catch {
|
|
11197
|
+
statuses = /* @__PURE__ */ new Map();
|
|
11198
|
+
}
|
|
11199
|
+
const isLlmReviewed = [...effectiveAspects].some((id) => {
|
|
11200
|
+
const def = aspectById.get(id);
|
|
11201
|
+
return def?.reviewer.type === "llm" && (statuses.get(id) ?? "enforced") !== "draft";
|
|
11202
|
+
});
|
|
11203
|
+
if (!isLlmReviewed) continue;
|
|
11204
|
+
const refPaths = /* @__PURE__ */ new Set();
|
|
10953
11205
|
for (const aspectId of effectiveAspects) {
|
|
10954
11206
|
for (const rp of refsByAspect.get(aspectId) ?? []) refPaths.add(rp);
|
|
10955
11207
|
}
|
|
@@ -11387,7 +11639,7 @@ async function validate(graph, scope = "all") {
|
|
|
11387
11639
|
issues.push(...checkSchemas(graph));
|
|
11388
11640
|
issues.push(...checkRelationTargets(graph));
|
|
11389
11641
|
issues.push(...checkNoCycles(graph));
|
|
11390
|
-
issues.push(...checkMappingOverlap(graph));
|
|
11642
|
+
issues.push(...await checkMappingOverlap(graph));
|
|
11391
11643
|
issues.push(...checkMappingEscapesRepo(graph));
|
|
11392
11644
|
issues.push(...await checkMappingPathsExist(graph));
|
|
11393
11645
|
issues.push(...checkBrokenFlowRefs(graph));
|
|
@@ -11459,6 +11711,7 @@ init_debug_log();
|
|
|
11459
11711
|
init_message_builder();
|
|
11460
11712
|
init_paths();
|
|
11461
11713
|
init_posix();
|
|
11714
|
+
init_mapping_path();
|
|
11462
11715
|
function normalizeForMatch(inputPath) {
|
|
11463
11716
|
return toPosixPath(inputPath.trim());
|
|
11464
11717
|
}
|
|
@@ -11468,17 +11721,25 @@ function findOwner(graph, projectRoot, rawPath) {
|
|
|
11468
11721
|
for (const [nodePath, node] of graph.nodes) {
|
|
11469
11722
|
const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizeForMatch).filter((mappingPath) => mappingPath.length > 0);
|
|
11470
11723
|
for (const mappingPath of mappingPaths) {
|
|
11471
|
-
if (
|
|
11472
|
-
|
|
11473
|
-
|
|
11474
|
-
|
|
11475
|
-
|
|
11476
|
-
|
|
11724
|
+
if (isGlobPattern(mappingPath)) {
|
|
11725
|
+
if (mappingEntryMatchesFile(mappingPath, file)) {
|
|
11726
|
+
if (!best || mappingPath.length > best.mappingPath.length) {
|
|
11727
|
+
best = { nodePath, mappingPath, exact: true };
|
|
11728
|
+
}
|
|
11729
|
+
}
|
|
11730
|
+
} else {
|
|
11731
|
+
if (file === mappingPath) {
|
|
11732
|
+
return { file, nodePath, mappingPath, direct: true };
|
|
11733
|
+
}
|
|
11734
|
+
if (file.startsWith(mappingPath + "/")) {
|
|
11735
|
+
if (!best || mappingPath.length > best.mappingPath.length) {
|
|
11736
|
+
best = { nodePath, mappingPath, exact: false };
|
|
11737
|
+
}
|
|
11477
11738
|
}
|
|
11478
11739
|
}
|
|
11479
11740
|
}
|
|
11480
11741
|
}
|
|
11481
|
-
return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath, direct:
|
|
11742
|
+
return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath, direct: best.exact } : { file, nodePath: null };
|
|
11482
11743
|
}
|
|
11483
11744
|
function registerOwnerCommand(program2) {
|
|
11484
11745
|
program2.command("owner").description("Find which graph node owns a source file").requiredOption("--file <path>", "File path (relative to repository root)").action(async (options) => {
|
|
@@ -11498,11 +11759,21 @@ function registerOwnerCommand(program2) {
|
|
|
11498
11759
|
exists = false;
|
|
11499
11760
|
}
|
|
11500
11761
|
if (exists) {
|
|
11501
|
-
process.stdout.write(
|
|
11502
|
-
|
|
11762
|
+
process.stdout.write(
|
|
11763
|
+
buildIssueMessage({
|
|
11764
|
+
what: `${result.file} -> no graph coverage`,
|
|
11765
|
+
why: "This file exists but no graph node maps it, so its code is not verified against any aspect.",
|
|
11766
|
+
next: `Add '${result.file}' to a node's mapping in yg-node.yaml, or create a node for it.`
|
|
11767
|
+
}) + "\n"
|
|
11768
|
+
);
|
|
11503
11769
|
} else {
|
|
11504
|
-
process.stdout.write(
|
|
11505
|
-
|
|
11770
|
+
process.stdout.write(
|
|
11771
|
+
buildIssueMessage({
|
|
11772
|
+
what: `${result.file} -> no graph coverage (file not found)`,
|
|
11773
|
+
why: "This path does not exist on disk and is not mapped by any graph node.",
|
|
11774
|
+
next: `Check the path for typos; once the file exists, add it to a node's mapping in yg-node.yaml.`
|
|
11775
|
+
}) + "\n"
|
|
11776
|
+
);
|
|
11506
11777
|
}
|
|
11507
11778
|
} else {
|
|
11508
11779
|
process.stdout.write(`${result.file} -> ${result.nodePath}
|
|
@@ -11730,6 +12001,13 @@ var STRUCTURAL_CODES = /* @__PURE__ */ new Set([
|
|
|
11730
12001
|
"when-unknown-type",
|
|
11731
12002
|
"when-unknown-node",
|
|
11732
12003
|
"when-unknown-port",
|
|
12004
|
+
// Port-contract codes — blocking architecture-gate errors (documented in the
|
|
12005
|
+
// ports-and-relations knowledge topic); belong in the single-source structural set.
|
|
12006
|
+
"port-missing-consumes",
|
|
12007
|
+
"port-undefined",
|
|
12008
|
+
"port-missing-aspect",
|
|
12009
|
+
"consumes-without-ports",
|
|
12010
|
+
"relation-target-forbidden",
|
|
11733
12011
|
"aspect-unexpected-rule-source",
|
|
11734
12012
|
"aspect-missing-rule-source",
|
|
11735
12013
|
"aspect-empty",
|
|
@@ -11757,6 +12035,90 @@ var COMPLETENESS_CODES = /* @__PURE__ */ new Set(["description-missing"]);
|
|
|
11757
12035
|
// src/core/check.ts
|
|
11758
12036
|
init_log_format();
|
|
11759
12037
|
init_posix();
|
|
12038
|
+
init_repo_scanner();
|
|
12039
|
+
init_mapping_path();
|
|
12040
|
+
|
|
12041
|
+
// src/core/check-coverage-tiers.ts
|
|
12042
|
+
init_posix();
|
|
12043
|
+
init_mapping_path();
|
|
12044
|
+
function normalizeRoot(root) {
|
|
12045
|
+
return toPosixPath(root.trim()).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/");
|
|
12046
|
+
}
|
|
12047
|
+
function matchesRoot(file, normRoot) {
|
|
12048
|
+
return normRoot === "" || mappingEntryMatchesFile(normRoot, file);
|
|
12049
|
+
}
|
|
12050
|
+
function partitionByCoverageTier(uncovered, coverage) {
|
|
12051
|
+
const req = coverage.required.map(normalizeRoot);
|
|
12052
|
+
const exc = coverage.excluded.map(normalizeRoot);
|
|
12053
|
+
const required = [];
|
|
12054
|
+
const middle = [];
|
|
12055
|
+
for (const f of uncovered) {
|
|
12056
|
+
let best = { len: -1, tier: "middle" };
|
|
12057
|
+
for (const r of req) if (matchesRoot(f, r) && r.length > best.len) best = { len: r.length, tier: "required" };
|
|
12058
|
+
for (const r of exc) if (matchesRoot(f, r) && r.length >= best.len) best = { len: r.length, tier: "excluded" };
|
|
12059
|
+
if (best.tier === "required") required.push(f);
|
|
12060
|
+
else if (best.tier === "middle") middle.push(f);
|
|
12061
|
+
}
|
|
12062
|
+
return { required, middle };
|
|
12063
|
+
}
|
|
12064
|
+
function buildCoverageIssue(uncoveredFiles, totalGitFiles) {
|
|
12065
|
+
if (uncoveredFiles.length === 0) return null;
|
|
12066
|
+
const sampleSize = 5;
|
|
12067
|
+
const sample = uncoveredFiles.slice(0, sampleSize);
|
|
12068
|
+
const remaining = uncoveredFiles.length - sample.length;
|
|
12069
|
+
const coveragePct = totalGitFiles > 0 ? (totalGitFiles - uncoveredFiles.length) / totalGitFiles * 100 : 100;
|
|
12070
|
+
let coverageMd;
|
|
12071
|
+
if (uncoveredFiles.length <= sampleSize) {
|
|
12072
|
+
coverageMd = {
|
|
12073
|
+
what: `${uncoveredFiles.length} source file${uncoveredFiles.length === 1 ? "" : "s"} not covered by any node.
|
|
12074
|
+
${sample.map((f) => " " + f).join("\n")}`,
|
|
12075
|
+
why: "Files without graph coverage cannot be modified under the protocol.",
|
|
12076
|
+
next: `Check ownership candidates: yg context --file <path>
|
|
12077
|
+
Then: add to existing node mapping, or create a new node.`
|
|
12078
|
+
};
|
|
12079
|
+
} else {
|
|
12080
|
+
const guidance = coveragePct < 50 ? "Establish coverage: create nodes for active areas first, expand coverage incrementally." : "Add to an existing node mapping, or create a new node.";
|
|
12081
|
+
coverageMd = {
|
|
12082
|
+
what: `${uncoveredFiles.length} source files have no graph coverage.
|
|
12083
|
+
Examples:
|
|
12084
|
+
${sample.map((f) => " " + f).join("\n")}
|
|
12085
|
+
... and ${remaining} more`,
|
|
12086
|
+
why: "Files without graph coverage cannot be modified under the protocol.",
|
|
12087
|
+
next: `${guidance}
|
|
12088
|
+
Check ownership candidates: yg context --file <path>`
|
|
12089
|
+
};
|
|
12090
|
+
}
|
|
12091
|
+
return {
|
|
12092
|
+
severity: "error",
|
|
12093
|
+
code: "unmapped-files",
|
|
12094
|
+
rule: "unmapped-file",
|
|
12095
|
+
messageData: coverageMd,
|
|
12096
|
+
uncoveredFiles,
|
|
12097
|
+
uncoveredCount: uncoveredFiles.length
|
|
12098
|
+
};
|
|
12099
|
+
}
|
|
12100
|
+
function buildCoverageAdvisoryIssue(uncoveredFiles) {
|
|
12101
|
+
if (uncoveredFiles.length === 0) return null;
|
|
12102
|
+
const sample = uncoveredFiles.slice(0, 5);
|
|
12103
|
+
const remaining = uncoveredFiles.length - sample.length;
|
|
12104
|
+
const body = uncoveredFiles.length <= 5 ? sample.map((f) => " " + f).join("\n") : `${sample.map((f) => " " + f).join("\n")}
|
|
12105
|
+
... and ${remaining} more`;
|
|
12106
|
+
return {
|
|
12107
|
+
severity: "warning",
|
|
12108
|
+
code: "uncovered-advisory",
|
|
12109
|
+
rule: "uncovered-advisory",
|
|
12110
|
+
messageData: {
|
|
12111
|
+
what: `${uncoveredFiles.length} tracked file${uncoveredFiles.length === 1 ? "" : "s"} outside any required coverage root.
|
|
12112
|
+
${body}`,
|
|
12113
|
+
why: "Not under a coverage.required root \u2014 visible but non-blocking. Bring an area under graph coverage to enforce it.",
|
|
12114
|
+
next: "Map these files to a node, or add their root to coverage.required to make this an error."
|
|
12115
|
+
},
|
|
12116
|
+
uncoveredFiles,
|
|
12117
|
+
uncoveredCount: uncoveredFiles.length
|
|
12118
|
+
};
|
|
12119
|
+
}
|
|
12120
|
+
|
|
12121
|
+
// src/core/check.ts
|
|
11760
12122
|
async function classifyDrift(graph) {
|
|
11761
12123
|
const projectRoot = path34.dirname(graph.rootPath);
|
|
11762
12124
|
const issues = [];
|
|
@@ -12012,59 +12374,17 @@ function scanUncoveredFiles(graph, gitTrackedFiles) {
|
|
|
12012
12374
|
const projectRoot = path34.dirname(graph.rootPath);
|
|
12013
12375
|
const yggPrefix = toPosixPath(path34.relative(projectRoot, graph.rootPath));
|
|
12014
12376
|
const uncovered = [];
|
|
12015
|
-
|
|
12377
|
+
const tracked = excludeNestedGraphSubtrees(gitTrackedFiles);
|
|
12378
|
+
for (const file of tracked) {
|
|
12016
12379
|
const normalized = toPosixPath(file.trim());
|
|
12017
12380
|
if (normalized.startsWith(yggPrefix + "/") || normalized === yggPrefix) continue;
|
|
12018
|
-
|
|
12019
|
-
for (const rawMp of allMappings) {
|
|
12020
|
-
const mp = toPosixPath(rawMp);
|
|
12021
|
-
if (normalized === mp || normalized.startsWith(mp + "/")) {
|
|
12022
|
-
covered = true;
|
|
12023
|
-
break;
|
|
12024
|
-
}
|
|
12025
|
-
}
|
|
12381
|
+
const covered = allMappings.some((mp) => mappingEntryMatchesFile(mp, normalized));
|
|
12026
12382
|
if (!covered) {
|
|
12027
12383
|
uncovered.push(normalized);
|
|
12028
12384
|
}
|
|
12029
12385
|
}
|
|
12030
12386
|
return uncovered.sort();
|
|
12031
12387
|
}
|
|
12032
|
-
function buildCoverageIssue(uncoveredFiles, totalGitFiles) {
|
|
12033
|
-
if (uncoveredFiles.length === 0) return null;
|
|
12034
|
-
const sampleSize = 5;
|
|
12035
|
-
const sample = uncoveredFiles.slice(0, sampleSize);
|
|
12036
|
-
const remaining = uncoveredFiles.length - sample.length;
|
|
12037
|
-
const coveragePct = totalGitFiles > 0 ? (totalGitFiles - uncoveredFiles.length) / totalGitFiles * 100 : 100;
|
|
12038
|
-
let coverageMd;
|
|
12039
|
-
if (uncoveredFiles.length <= sampleSize) {
|
|
12040
|
-
coverageMd = {
|
|
12041
|
-
what: `${uncoveredFiles.length} source file${uncoveredFiles.length === 1 ? "" : "s"} not covered by any node.
|
|
12042
|
-
${sample.map((f) => " " + f).join("\n")}`,
|
|
12043
|
-
why: "Files without graph coverage cannot be modified under the protocol.",
|
|
12044
|
-
next: `Check ownership candidates: yg context --file <path>
|
|
12045
|
-
Then: add to existing node mapping, or create a new node.`
|
|
12046
|
-
};
|
|
12047
|
-
} else {
|
|
12048
|
-
const guidance = coveragePct < 50 ? "Establish coverage: create nodes for active areas first, expand coverage incrementally." : "Add to an existing node mapping, or create a new node.";
|
|
12049
|
-
coverageMd = {
|
|
12050
|
-
what: `${uncoveredFiles.length} source files have no graph coverage.
|
|
12051
|
-
Examples:
|
|
12052
|
-
${sample.map((f) => " " + f).join("\n")}
|
|
12053
|
-
... and ${remaining} more`,
|
|
12054
|
-
why: "Files without graph coverage cannot be modified under the protocol.",
|
|
12055
|
-
next: `${guidance}
|
|
12056
|
-
Check ownership candidates: yg context --file <path>`
|
|
12057
|
-
};
|
|
12058
|
-
}
|
|
12059
|
-
return {
|
|
12060
|
-
severity: "error",
|
|
12061
|
-
code: "unmapped-files",
|
|
12062
|
-
rule: "unmapped-file",
|
|
12063
|
-
messageData: coverageMd,
|
|
12064
|
-
uncoveredFiles,
|
|
12065
|
-
uncoveredCount: uncoveredFiles.length
|
|
12066
|
-
};
|
|
12067
|
-
}
|
|
12068
12388
|
async function detectOrphanedDriftState(graph) {
|
|
12069
12389
|
const driftState = await readDriftState(graph.rootPath);
|
|
12070
12390
|
const validNodePaths = new Set(graph.nodes.keys());
|
|
@@ -12074,20 +12394,25 @@ async function runCheck(graph, gitTrackedFiles) {
|
|
|
12074
12394
|
const validation = await validate(graph);
|
|
12075
12395
|
const validationIssues = validation.issues.filter((vi) => vi.code).map((vi) => ({ ...vi, code: vi.code }));
|
|
12076
12396
|
const driftIssues = await classifyDrift(graph);
|
|
12077
|
-
let
|
|
12397
|
+
let coverageIssues = [];
|
|
12078
12398
|
let coveredFiles = 0;
|
|
12079
12399
|
let totalFiles = 0;
|
|
12080
12400
|
if (gitTrackedFiles !== null) {
|
|
12081
12401
|
const projectRoot = path34.dirname(graph.rootPath);
|
|
12082
12402
|
const yggPrefix = toPosixPath(path34.relative(projectRoot, graph.rootPath));
|
|
12083
|
-
const sourceFiles = gitTrackedFiles.filter((f) => {
|
|
12403
|
+
const sourceFiles = excludeNestedGraphSubtrees(gitTrackedFiles).filter((f) => {
|
|
12084
12404
|
const normalized = toPosixPath(f.trim());
|
|
12085
12405
|
return !normalized.startsWith(yggPrefix + "/") && normalized !== yggPrefix;
|
|
12086
12406
|
});
|
|
12087
12407
|
totalFiles = sourceFiles.length;
|
|
12088
12408
|
const uncovered = scanUncoveredFiles(graph, gitTrackedFiles);
|
|
12089
|
-
|
|
12090
|
-
|
|
12409
|
+
const coverage = graph.config.coverage ?? DEFAULT_COVERAGE;
|
|
12410
|
+
const tiers = partitionByCoverageTier(uncovered, coverage);
|
|
12411
|
+
coveredFiles = totalFiles - (tiers.required.length + tiers.middle.length);
|
|
12412
|
+
coverageIssues = [
|
|
12413
|
+
buildCoverageIssue(tiers.required, totalFiles),
|
|
12414
|
+
buildCoverageAdvisoryIssue(tiers.middle)
|
|
12415
|
+
].filter((x) => x !== null);
|
|
12091
12416
|
}
|
|
12092
12417
|
const orphanedPaths = await detectOrphanedDriftState(graph);
|
|
12093
12418
|
await garbageCollectDriftState(
|
|
@@ -12116,7 +12441,7 @@ async function runCheck(graph, gitTrackedFiles) {
|
|
|
12116
12441
|
const allIssues = [
|
|
12117
12442
|
...driftIssues,
|
|
12118
12443
|
...validationIssues,
|
|
12119
|
-
...
|
|
12444
|
+
...coverageIssues,
|
|
12120
12445
|
...orphanWarnings
|
|
12121
12446
|
];
|
|
12122
12447
|
const nodeTypeCounts = /* @__PURE__ */ new Map();
|
|
@@ -12219,10 +12544,15 @@ function getChildMappingExclusions2(graph, nodePath) {
|
|
|
12219
12544
|
}
|
|
12220
12545
|
async function allPathsMissing(projectRoot, mappingPaths) {
|
|
12221
12546
|
for (const mp of mappingPaths) {
|
|
12222
|
-
|
|
12223
|
-
await
|
|
12224
|
-
return false;
|
|
12225
|
-
}
|
|
12547
|
+
if (isGlobPattern(mp)) {
|
|
12548
|
+
const matched = await expandMappingPaths(projectRoot, [mp]);
|
|
12549
|
+
if (matched.length > 0) return false;
|
|
12550
|
+
} else {
|
|
12551
|
+
try {
|
|
12552
|
+
await fileAccess(path34.join(projectRoot, mp));
|
|
12553
|
+
return false;
|
|
12554
|
+
} catch {
|
|
12555
|
+
}
|
|
12226
12556
|
}
|
|
12227
12557
|
}
|
|
12228
12558
|
return true;
|
|
@@ -12331,7 +12661,7 @@ function computeSuggestedNext(issues, graph) {
|
|
|
12331
12661
|
addRemaining(coverageErrors.length > 0 ? coverageErrors[0].uncoveredCount ?? 0 : 0, "files need coverage");
|
|
12332
12662
|
const then = remaining.length > 0 ? `
|
|
12333
12663
|
Then: ${remaining.join(", ")}` : "";
|
|
12334
|
-
return `Fix ${first.code} in ${first.nodePath ?? ".yggdrasil
|
|
12664
|
+
return `Fix ${first.code} in ${first.nodePath ?? ".yggdrasil"}
|
|
12335
12665
|
1 of ${structuralErrors.length} structural error${structuralErrors.length === 1 ? "" : "s"}${then}`;
|
|
12336
12666
|
}
|
|
12337
12667
|
if (coverageErrors.length > 0) {
|
|
@@ -14017,10 +14347,16 @@ function renderErrorSection(errors) {
|
|
|
14017
14347
|
}
|
|
14018
14348
|
function renderWarningSection(warnings) {
|
|
14019
14349
|
const lines = [chalk8.yellow(`Warnings (${warnings.length}):`)];
|
|
14020
|
-
|
|
14350
|
+
const coverage = warnings.filter((i) => i.code === "uncovered-advisory");
|
|
14351
|
+
const rest = warnings.filter((i) => i.code !== "uncovered-advisory");
|
|
14352
|
+
for (const issue of sortByNodePath(rest)) {
|
|
14021
14353
|
lines.push("");
|
|
14022
14354
|
renderIssueBlock(issue, lines, "warning");
|
|
14023
14355
|
}
|
|
14356
|
+
for (const issue of coverage) {
|
|
14357
|
+
lines.push("");
|
|
14358
|
+
renderUnmappedBlock(issue, lines, "uncovered");
|
|
14359
|
+
}
|
|
14024
14360
|
return lines.join("\n");
|
|
14025
14361
|
}
|
|
14026
14362
|
function renderIssueBlock(issue, lines, mode) {
|
|
@@ -14038,14 +14374,12 @@ function renderIssueBlock(issue, lines, mode) {
|
|
|
14038
14374
|
lines.push(` Fix: ${md.next}${fixSuffix}`);
|
|
14039
14375
|
}
|
|
14040
14376
|
}
|
|
14041
|
-
function renderUnmappedBlock(issue, lines) {
|
|
14377
|
+
function renderUnmappedBlock(issue, lines, label = "unmapped") {
|
|
14042
14378
|
const md = issue.messageData;
|
|
14043
14379
|
const files = issue.uncoveredFiles ?? [];
|
|
14044
|
-
const whatFirstLine = md.what.split("\n")[0];
|
|
14045
|
-
const countMatch = whatFirstLine.match(/^(\d[\d,]*)/);
|
|
14046
14380
|
const count = issue.uncoveredCount ?? files.length;
|
|
14047
|
-
const countLabel =
|
|
14048
|
-
lines.push(`
|
|
14381
|
+
const countLabel = String(count);
|
|
14382
|
+
lines.push(` ${label} (${countLabel})`);
|
|
14049
14383
|
const shown = files.slice(0, 10);
|
|
14050
14384
|
for (const f of shown) {
|
|
14051
14385
|
lines.push(` ${f}`);
|
|
@@ -14132,6 +14466,7 @@ init_loader_hook();
|
|
|
14132
14466
|
init_parser();
|
|
14133
14467
|
init_suppress();
|
|
14134
14468
|
init_validate_check_module();
|
|
14469
|
+
init_language_registry();
|
|
14135
14470
|
import path37 from "path";
|
|
14136
14471
|
import { readFile as readFile20 } from "fs/promises";
|
|
14137
14472
|
import { pathToFileURL as pathToFileURL3 } from "url";
|
|
@@ -14179,18 +14514,15 @@ async function runAstAspect(params) {
|
|
|
14179
14514
|
continue;
|
|
14180
14515
|
}
|
|
14181
14516
|
const content14 = await readFile20(path37.resolve(params.projectRoot, f.path), "utf-8");
|
|
14517
|
+
if (getLanguageForExtension(path37.extname(f.path).toLowerCase()) === null) {
|
|
14518
|
+
sourceFiles.push({ path: f.path, content: content14, ast: void 0 });
|
|
14519
|
+
continue;
|
|
14520
|
+
}
|
|
14182
14521
|
let ast;
|
|
14183
14522
|
try {
|
|
14184
14523
|
ast = await parseFile(f.path, content14);
|
|
14185
14524
|
} catch (e) {
|
|
14186
14525
|
const msg = e.message ?? String(e);
|
|
14187
|
-
if (msg.startsWith("no parser for extension")) {
|
|
14188
|
-
throw new AstRunnerError("AST_NO_PARSER_FOR_EXTENSION", {
|
|
14189
|
-
what: msg + ` (file: ${f.path})`,
|
|
14190
|
-
why: `v1 supports only .ts/.tsx/.js/.mjs/.cjs/.jsx.`,
|
|
14191
|
-
next: `Remove ${f.path} from the node's mapping.`
|
|
14192
|
-
});
|
|
14193
|
-
}
|
|
14194
14526
|
throw new AstRunnerError("AST_GRAMMAR_LOAD_FAILED", {
|
|
14195
14527
|
what: `Failed to load tree-sitter grammar for ${f.path}: ${msg}`,
|
|
14196
14528
|
why: `The bundled WASM grammar could not be loaded.`,
|
|
@@ -14211,7 +14543,7 @@ async function runAstAspect(params) {
|
|
|
14211
14543
|
const rangesPerFile = /* @__PURE__ */ new Map();
|
|
14212
14544
|
for (const f of sourceFiles) {
|
|
14213
14545
|
const totalLines = f.content.split("\n").length;
|
|
14214
|
-
rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines));
|
|
14546
|
+
rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines, f.content));
|
|
14215
14547
|
}
|
|
14216
14548
|
const ctx = { files: sourceFiles };
|
|
14217
14549
|
let raw;
|
|
@@ -15493,6 +15825,20 @@ predicate satisfaction fraction, or edge-case messages for files inside
|
|
|
15493
15825
|
Run this whenever you add or modify a type's \`when\` predicate and want
|
|
15494
15826
|
to verify that existing files are classified as expected.
|
|
15495
15827
|
|
|
15828
|
+
## Glob patterns in mapping and when.path
|
|
15829
|
+
|
|
15830
|
+
Both node \`mapping:\` entries and architecture \`when.path\` predicates accept
|
|
15831
|
+
minimatch glob patterns. \`*\` matches any characters within a single path
|
|
15832
|
+
segment (does not cross \`/\`); \`**\` matches across path segments.
|
|
15833
|
+
|
|
15834
|
+
Examples:
|
|
15835
|
+
- \`src/db/*Repository.cs\` \u2014 owns only files matching \`*Repository.cs\` directly
|
|
15836
|
+
inside \`src/db/\`, not subdirectory files or non-matching files like \`Helper.cs\`.
|
|
15837
|
+
- \`src/**/*.ts\` \u2014 owns all \`.ts\` files anywhere under \`src/\` at any depth.
|
|
15838
|
+
|
|
15839
|
+
Plain (non-glob) entries remain unchanged: an exact file path or a directory
|
|
15840
|
+
prefix (e.g. \`src/handlers\`) covers that file or all files beneath it.
|
|
15841
|
+
|
|
15496
15842
|
## Aspect status in architecture default aspects
|
|
15497
15843
|
|
|
15498
15844
|
Architecture-level default aspects (channel 3) may declare \`status:\` to
|
|
@@ -15896,7 +16242,11 @@ var content4 = `# Writing deterministic aspects
|
|
|
15896
16242
|
A deterministic aspect declares \`reviewer: { type: deterministic }\` and ships
|
|
15897
16243
|
a \`check.mjs\` file. The check runs locally at zero LLM cost and returns a
|
|
15898
16244
|
\`Violation[]\`. Deterministic aspects do not use reviewer tiers \u2014
|
|
15899
|
-
\`reviewer.tier:\` is rejected together with \`type: deterministic\`.
|
|
16245
|
+
\`reviewer.tier:\` is rejected together with \`type: deterministic\`. Because the
|
|
16246
|
+
check reads files programmatically (no LLM prompt), the per-node character budget
|
|
16247
|
+
(\`quality.max_node_chars\` / \`oversized-node\`) does NOT apply to a node whose
|
|
16248
|
+
only effective aspects are deterministic \u2014 such a node may map an arbitrarily
|
|
16249
|
+
large area.
|
|
15900
16250
|
|
|
15901
16251
|
There are two ways to scope a deterministic aspect:
|
|
15902
16252
|
|
|
@@ -16009,7 +16359,7 @@ arrives in the one \`check.mjs\` invocation.
|
|
|
16009
16359
|
| \`walk(node, visitor)\` | \`(node, (n) => boolean|void) => void\` | DFS traversal; visitor returning \`false\` skips descent into that subtree |
|
|
16010
16360
|
| \`report(file, node, message)\` | \`(file, TreeNode, string) => Violation\` | Build a \`{ file, line, column, message }\` \u2014 \`line\` 1-based, \`column\` 0-based |
|
|
16011
16361
|
| \`inFile(file, pattern)\` | \`(file, { glob } | { regex } | { contains }) => boolean\` | Path filter (discriminated object form) |
|
|
16012
|
-
| \`findComments(target)\` | \`(file
|
|
16362
|
+
| \`findComments(target)\` | \`(file) => TreeNode[]\` | Returns comment nodes within a file (language derived from its path) |
|
|
16013
16363
|
| \`closest(node, types)\` | \`(TreeNode, string[]) => TreeNode | null\` | Nearest ancestor whose \`type\` is in \`types\` |
|
|
16014
16364
|
|
|
16015
16365
|
## tree-sitter node API
|
|
@@ -16268,7 +16618,7 @@ parsed AST trees via \`ctx.parseAst\`:
|
|
|
16268
16618
|
| \`closest(node, types)\` | \`(TreeNode, string[]) => TreeNode | null\` | Nearest ancestor of one of the given types |
|
|
16269
16619
|
| \`report(file, node, message)\` | \`(file, TreeNode, string) => Violation\` | Build \`{ file, line, column, message }\` \u2014 line 1-based, column 0-based |
|
|
16270
16620
|
| \`inFile(file, pattern)\` | \`(file, { glob } | { regex } | { contains }) => boolean\` | Path filter |
|
|
16271
|
-
| \`findComments(target)\` | \`(file
|
|
16621
|
+
| \`findComments(target)\` | \`(file) => TreeNode[]\` | Returns comment nodes |
|
|
16272
16622
|
|
|
16273
16623
|
These helpers are optional \u2014 most graph-aware checks work purely with \`ctx.graph\`
|
|
16274
16624
|
and \`ctx.fs\` without parsing AST trees at all.
|
|
@@ -16773,6 +17123,21 @@ For generated files where the entire file is exempt, place the marker at
|
|
|
16773
17123
|
the file level (outside any function or class). At file level, the
|
|
16774
17124
|
contextual scope is the whole file.
|
|
16775
17125
|
|
|
17126
|
+
## Language support
|
|
17127
|
+
|
|
17128
|
+
Markers are recognized in any source language, using whichever comment syntax
|
|
17129
|
+
the language provides \u2014 \`//\` and \`/* */\` (C-family), \`#\` (shell, Python),
|
|
17130
|
+
\`--\` (SQL), and so on. The marker token \`yg-suppress(...)\` is what is matched,
|
|
17131
|
+
not a specific comment style.
|
|
17132
|
+
|
|
17133
|
+
For a file whose extension has a registered grammar, markers are read from the
|
|
17134
|
+
file's comments, so a \`yg-suppress(...)\` that merely appears inside a string
|
|
17135
|
+
literal is NOT treated as a marker. For a file whose extension has no registered
|
|
17136
|
+
grammar (e.g. \`.sql\`, \`.md\`, \`.sh\`), there is no parse tree, so markers are
|
|
17137
|
+
found by scanning the raw lines \u2014 which is what lets a content-only deterministic
|
|
17138
|
+
check waive a violation in such a file. (In that raw-scan mode a marker token
|
|
17139
|
+
sitting inside a string literal would also match, so keep markers in comments.)
|
|
17140
|
+
|
|
16776
17141
|
## Reason text
|
|
16777
17142
|
|
|
16778
17143
|
The reason text after the aspect-id is permanent. Future maintainers and
|
|
@@ -17039,14 +17404,19 @@ reviewer:
|
|
|
17039
17404
|
config:
|
|
17040
17405
|
model: qwen3 # Model identifier for this provider
|
|
17041
17406
|
temperature: 0 # Sampling temperature (0 = deterministic)
|
|
17042
|
-
endpoint: http://localhost:11434 # Required for ollama
|
|
17407
|
+
endpoint: http://localhost:11434 # Required for openai-compatible (no default host); ollama defaults to localhost:11434
|
|
17043
17408
|
# references: # optional caps on aspect reference files
|
|
17044
17409
|
# max_bytes_per_file: 65536 # default: 64 KiB per reference file
|
|
17045
17410
|
# max_total_bytes_per_aspect: 262144 # default: 256 KiB total per aspect
|
|
17046
17411
|
|
|
17412
|
+
coverage: # Optional \u2014 controls which files must be mapped
|
|
17413
|
+
required: # Unmapped files under these roots are a blocking error
|
|
17414
|
+
- "/" # Default: whole repo (previous always-map-everything behavior)
|
|
17415
|
+
excluded: [] # Files under these roots are silently ignored
|
|
17416
|
+
|
|
17047
17417
|
quality:
|
|
17048
17418
|
max_direct_relations: 10 # Max out-edges per node before high-fan-out warning
|
|
17049
|
-
max_node_chars: 40000 # Per-node
|
|
17419
|
+
max_node_chars: 40000 # Per-node char budget (source + aspect refs); oversized-node error \u2014 LLM-reviewed nodes only
|
|
17050
17420
|
|
|
17051
17421
|
parallel: 1 # Concurrent aspect verifications across nodes (default: 1)
|
|
17052
17422
|
|
|
@@ -17113,7 +17483,7 @@ Provider-specific options passed to the LLM client:
|
|
|
17113
17483
|
|---|---|---|
|
|
17114
17484
|
| \`model\` | string | Required. Provider-specific model identifier. |
|
|
17115
17485
|
| \`temperature\` | number | Defaults to 0. Higher = more varied responses. |
|
|
17116
|
-
| \`endpoint\` | string | Required for \`
|
|
17486
|
+
| \`endpoint\` | string | Required for \`openai-compatible\` (no default host \u2014 else falls back to api.openai.com); \`ollama\` defaults to http://localhost:11434. |
|
|
17117
17487
|
| \`timeout\` | number | Timeout in seconds. Default 300. Applies to CLI providers only \u2014 non-CLI/API providers ignore it. |
|
|
17118
17488
|
|
|
17119
17489
|
API keys do NOT live here \u2014 they belong in \`yg-secrets.yaml\` (api_key only).
|
|
@@ -17179,6 +17549,26 @@ the provider's standard \`*_API_KEY\`) as a fallback when not present in
|
|
|
17179
17549
|
\`yg-config.yaml\` itself must never contain credentials. Commit it to the
|
|
17180
17550
|
repository \u2014 it is safe to share.
|
|
17181
17551
|
|
|
17552
|
+
## Coverage config
|
|
17553
|
+
|
|
17554
|
+
\`\`\`yaml
|
|
17555
|
+
coverage:
|
|
17556
|
+
required:
|
|
17557
|
+
- src/ # unmapped files under src/ are a blocking error
|
|
17558
|
+
excluded:
|
|
17559
|
+
- vendor/ # silently ignored
|
|
17560
|
+
- "**/*.generated.ts" # glob: drop generated files anywhere
|
|
17561
|
+
\`\`\`
|
|
17562
|
+
|
|
17563
|
+
Controls which git-tracked files must be mapped to a node.
|
|
17564
|
+
|
|
17565
|
+
- \`required\` \u2014 roots where unmapped files are a blocking \`unmapped-files\` error. Default: \`["/"]\` (whole repo \u2014 the previous always-map-everything behavior). An explicit empty list \`[]\` means require nothing: every uncovered file (outside \`excluded\`/nested) becomes a non-blocking \`uncovered-advisory\` warning and nothing blocks (pure-advisory adoption). Empty only counts when written explicitly; omitting the \`coverage\` block keeps the \`["/"]\` default.
|
|
17566
|
+
- \`excluded\` \u2014 roots that are silently ignored. Default: \`[]\`.
|
|
17567
|
+
- Roots accept the same forms as a node \`mapping:\` entry: an exact file, a directory prefix (e.g. \`src/\` covers everything beneath it), or a glob (\`*\` within a segment, \`**\` across) \u2014 so \`excluded: ["**/*.generated.ts"]\` drops generated files anywhere and \`required: ["services/*/api/**"]\` scopes the blocking tier to a pattern. \`/\` still means the whole repo.
|
|
17568
|
+
- Files that match neither a required nor an excluded root produce a non-blocking \`uncovered-advisory\` warning.
|
|
17569
|
+
- Subtrees containing their own nested \`.yggdrasil/\` are auto-skipped by all repo-walking checks (they are governed by their own graph).
|
|
17570
|
+
- Longest-match wins (by normalized root/pattern length); on a tie between required and excluded, excluded wins.
|
|
17571
|
+
|
|
17182
17572
|
## Quality thresholds
|
|
17183
17573
|
|
|
17184
17574
|
\`\`\`yaml
|
|
@@ -17190,6 +17580,9 @@ quality:
|
|
|
17190
17580
|
\`max_direct_relations\` fires a warning when exceeded. \`max_node_chars\` is a
|
|
17191
17581
|
blocking error: a node whose mapped source plus aspect reference files exceed it
|
|
17192
17582
|
(binary files do not count) must be split into children to stay under the budget.
|
|
17583
|
+
The budget applies only to nodes an LLM reviewer actually reads \u2014 those with at
|
|
17584
|
+
least one non-draft LLM aspect; deterministic-only and aspect-less nodes are not
|
|
17585
|
+
bounded (a check.mjs reads files programmatically, with no context window).
|
|
17193
17586
|
For a node mapping a single unsplittable generated or binary artifact (a lockfile,
|
|
17194
17587
|
an append-only changelog, an image), opt out per-node with
|
|
17195
17588
|
\`sizeExempt: { reason: "<why it cannot be split>" }\`.
|
|
@@ -17256,8 +17649,9 @@ Unified gate \u2014 runs all validators in sequence.
|
|
|
17256
17649
|
yg check
|
|
17257
17650
|
\`\`\`
|
|
17258
17651
|
|
|
17259
|
-
Detects: drift (source + cascade), validation errors, coverage gaps
|
|
17260
|
-
\`unmapped-files
|
|
17652
|
+
Detects: drift (source + cascade), validation errors, coverage gaps
|
|
17653
|
+
(\`unmapped-files\` errors under required roots, \`uncovered-advisory\` warnings
|
|
17654
|
+
outside them), type-when mismatches, strict orphans/misplaced files.
|
|
17261
17655
|
|
|
17262
17656
|
Exit 0 = clean. Exit 1 = errors found. CI blocks on exit 1.
|
|
17263
17657
|
|
|
@@ -17668,8 +18062,10 @@ ports: # map keyed by port name (NOT a list)
|
|
|
17668
18062
|
\`\`\`
|
|
17669
18063
|
|
|
17670
18064
|
Every aspect id listed in a port's \`aspects\` must be defined under
|
|
17671
|
-
\`aspects
|
|
17672
|
-
\`
|
|
18065
|
+
\`aspects/\`. An undefined id is caught unconditionally by the
|
|
18066
|
+
reference-integrity check (code \`aspect-undefined\`); when the port is
|
|
18067
|
+
actually consumed, the missing aspect additionally surfaces as
|
|
18068
|
+
\`port-missing-aspect\`.
|
|
17673
18069
|
|
|
17674
18070
|
A consumer references the port via the relation's \`consumes\`. In
|
|
17675
18071
|
\`yg-node.yaml\`, \`relations:\` is a flat list and each entry carries its own
|
|
@@ -17714,12 +18110,11 @@ a blocking error on the missing port contract, surfacing the gap.
|
|
|
17714
18110
|
|
|
17715
18111
|
If a target node declares ports and the consumer's relation does NOT
|
|
17716
18112
|
declare \`consumes\`, \`yg check\` emits a blocking error (code
|
|
17717
|
-
\`port-missing-consumes\`) that fails the architecture gate
|
|
17718
|
-
|
|
17719
|
-
|
|
17720
|
-
|
|
17721
|
-
|
|
17722
|
-
\`\`\`
|
|
18113
|
+
\`port-missing-consumes\`) that fails the architecture gate. Like every
|
|
18114
|
+
diagnostic, it is rendered in the what/why/next form: it names the
|
|
18115
|
+
relation, explains that the target's port-required aspects won't be
|
|
18116
|
+
verified without a \`consumes\` declaration, and tells you to add
|
|
18117
|
+
\`consumes: [<port-names>]\` to the relation.
|
|
17723
18118
|
|
|
17724
18119
|
There is no "accept the gap" mechanism. Resolve it one of two ways:
|
|
17725
18120
|
declare which port(s) you consume on the relation, or remove the ports
|