@toolbaux/guardian 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/dist/adapters/csharp-adapter.js +149 -0
  4. package/dist/adapters/go-adapter.js +96 -0
  5. package/dist/adapters/index.js +16 -0
  6. package/dist/adapters/java-adapter.js +122 -0
  7. package/dist/adapters/python-adapter.js +183 -0
  8. package/dist/adapters/runner.js +69 -0
  9. package/dist/adapters/types.js +1 -0
  10. package/dist/adapters/typescript-adapter.js +179 -0
  11. package/dist/benchmarking/framework.js +91 -0
  12. package/dist/cli.js +343 -0
  13. package/dist/commands/analyze-depth.js +43 -0
  14. package/dist/commands/api-spec-extractor.js +52 -0
  15. package/dist/commands/breaking-change-analyzer.js +334 -0
  16. package/dist/commands/config-compliance.js +219 -0
  17. package/dist/commands/constraints.js +221 -0
  18. package/dist/commands/context.js +101 -0
  19. package/dist/commands/data-flow-tracer.js +291 -0
  20. package/dist/commands/dependency-impact-analyzer.js +27 -0
  21. package/dist/commands/diff.js +146 -0
  22. package/dist/commands/discrepancy.js +71 -0
  23. package/dist/commands/doc-generate.js +163 -0
  24. package/dist/commands/doc-html.js +120 -0
  25. package/dist/commands/drift.js +88 -0
  26. package/dist/commands/extract.js +16 -0
  27. package/dist/commands/feature-context.js +116 -0
  28. package/dist/commands/generate.js +339 -0
  29. package/dist/commands/guard.js +182 -0
  30. package/dist/commands/init.js +209 -0
  31. package/dist/commands/intel.js +20 -0
  32. package/dist/commands/license-dependency-auditor.js +33 -0
  33. package/dist/commands/performance-hotspot-profiler.js +42 -0
  34. package/dist/commands/search.js +314 -0
  35. package/dist/commands/security-boundary-auditor.js +359 -0
  36. package/dist/commands/simulate.js +294 -0
  37. package/dist/commands/summary.js +27 -0
  38. package/dist/commands/test-coverage-mapper.js +264 -0
  39. package/dist/commands/verify-drift.js +62 -0
  40. package/dist/config.js +441 -0
  41. package/dist/extract/ai-context-hints.js +107 -0
  42. package/dist/extract/analyzers/backend.js +1704 -0
  43. package/dist/extract/analyzers/depth.js +264 -0
  44. package/dist/extract/analyzers/frontend.js +2221 -0
  45. package/dist/extract/api-usage-tracker.js +19 -0
  46. package/dist/extract/cache.js +53 -0
  47. package/dist/extract/codebase-intel.js +190 -0
  48. package/dist/extract/compress.js +452 -0
  49. package/dist/extract/context-block.js +356 -0
  50. package/dist/extract/contracts.js +183 -0
  51. package/dist/extract/discrepancies.js +233 -0
  52. package/dist/extract/docs-loader.js +110 -0
  53. package/dist/extract/docs.js +2379 -0
  54. package/dist/extract/drift.js +1578 -0
  55. package/dist/extract/duplicates.js +435 -0
  56. package/dist/extract/feature-arcs.js +138 -0
  57. package/dist/extract/graph.js +76 -0
  58. package/dist/extract/html-doc.js +1409 -0
  59. package/dist/extract/ignore.js +45 -0
  60. package/dist/extract/index.js +455 -0
  61. package/dist/extract/llm-client.js +159 -0
  62. package/dist/extract/pattern-registry.js +141 -0
  63. package/dist/extract/product-doc.js +497 -0
  64. package/dist/extract/python.js +1202 -0
  65. package/dist/extract/runtime.js +193 -0
  66. package/dist/extract/schema-evolution-validator.js +35 -0
  67. package/dist/extract/test-gap-analyzer.js +20 -0
  68. package/dist/extract/tests.js +74 -0
  69. package/dist/extract/types.js +1 -0
  70. package/dist/extract/validate-backend.js +30 -0
  71. package/dist/extract/writer.js +11 -0
  72. package/dist/output-layout.js +37 -0
  73. package/dist/project-discovery.js +309 -0
  74. package/dist/schema/architecture.js +350 -0
  75. package/dist/schema/feature-spec.js +89 -0
  76. package/dist/schema/index.js +8 -0
  77. package/dist/schema/ux.js +46 -0
  78. package/package.json +75 -0
@@ -0,0 +1,435 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import Parser from "tree-sitter";
5
+ import Python from "tree-sitter-python";
6
+ export async function findDuplicateFunctions(params) {
7
+ const { files, baseRoot, fileContents } = params;
8
+ const fingerprints = [];
9
+ for (const file of files) {
10
+ const ext = path.extname(file).toLowerCase();
11
+ const language = ext === ".py" ? "py" : "ts";
12
+ const content = fileContents.get(file);
13
+ if (typeof content !== "string" || content.length === 0) {
14
+ continue;
15
+ }
16
+ if (language === "py") {
17
+ fingerprints.push(...extractPythonFunctions(file, content));
18
+ }
19
+ else if (isTsLike(ext)) {
20
+ const lang = ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs"
21
+ ? "js"
22
+ : "ts";
23
+ fingerprints.push(...extractTsFunctions(file, content, lang));
24
+ }
25
+ }
26
+ const duplicates = groupByHash(fingerprints);
27
+ const similar = [
28
+ ...findSimilarByCallPattern(fingerprints),
29
+ ...findSimilarByAstStructure(fingerprints)
30
+ ]
31
+ .sort((a, b) => b.similarity - a.similarity)
32
+ .slice(0, 50);
33
+ return {
34
+ duplicateFunctions: duplicates,
35
+ similarFunctions: similar
36
+ };
37
+ }
38
+ function groupByHash(fingerprints) {
39
+ const grouped = new Map();
40
+ for (const fingerprint of fingerprints) {
41
+ const entry = grouped.get(fingerprint.hash) ?? [];
42
+ entry.push(fingerprint);
43
+ grouped.set(fingerprint.hash, entry);
44
+ }
45
+ const groups = [];
46
+ for (const [hash, entries] of grouped.entries()) {
47
+ if (entries.length < 2) {
48
+ continue;
49
+ }
50
+ const size = Math.max(...entries.map((entry) => entry.size));
51
+ groups.push({
52
+ hash,
53
+ size,
54
+ functions: entries.map((entry) => ({
55
+ id: entry.id,
56
+ name: entry.name,
57
+ file: entry.file,
58
+ language: entry.language,
59
+ size: entry.size
60
+ }))
61
+ });
62
+ }
63
+ groups.sort((a, b) => b.size - a.size || b.functions.length - a.functions.length);
64
+ return groups;
65
+ }
66
+ function findSimilarByCallPattern(fingerprints) {
67
+ const bySize = new Map();
68
+ for (const fp of fingerprints) {
69
+ const callSize = fp.calls.length;
70
+ if (callSize === 0) {
71
+ continue;
72
+ }
73
+ const entry = bySize.get(callSize) ?? [];
74
+ entry.push(fp);
75
+ bySize.set(callSize, entry);
76
+ }
77
+ const pairs = [];
78
+ const sizes = Array.from(bySize.keys()).sort((a, b) => a - b);
79
+ const threshold = 0.8;
80
+ const maxPairs = 50;
81
+ for (const size of sizes) {
82
+ const bucket = bySize.get(size) ?? [];
83
+ for (let i = 0; i < bucket.length; i += 1) {
84
+ for (let j = i + 1; j < bucket.length; j += 1) {
85
+ const sim = jaccard(bucket[i].calls, bucket[j].calls);
86
+ if (sim >= threshold) {
87
+ pairs.push({
88
+ similarity: round(sim, 3),
89
+ basis: "call_pattern",
90
+ functions: [
91
+ pickSummary(bucket[i]),
92
+ pickSummary(bucket[j])
93
+ ]
94
+ });
95
+ if (pairs.length >= maxPairs) {
96
+ return pairs;
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ return pairs;
103
+ }
104
+ function findSimilarByAstStructure(fingerprints) {
105
+ const minSize = 12;
106
+ const bucketSize = 10;
107
+ const buckets = new Map();
108
+ for (const fp of fingerprints) {
109
+ if (fp.size < minSize) {
110
+ continue;
111
+ }
112
+ const key = Math.floor(fp.size / bucketSize);
113
+ const entry = buckets.get(key) ?? [];
114
+ entry.push(fp);
115
+ buckets.set(key, entry);
116
+ }
117
+ const pairs = [];
118
+ const seen = new Set();
119
+ const threshold = 0.8;
120
+ const maxPairs = 50;
121
+ const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
122
+ for (const key of keys) {
123
+ const group = buckets.get(key) ?? [];
124
+ const next = buckets.get(key + 1) ?? [];
125
+ for (let i = 0; i < group.length; i += 1) {
126
+ const base = group[i];
127
+ const candidates = [...group.slice(i + 1), ...next];
128
+ for (const candidate of candidates) {
129
+ if (base.hash === candidate.hash) {
130
+ continue;
131
+ }
132
+ const pairKey = base.id < candidate.id ? `${base.id}::${candidate.id}` : `${candidate.id}::${base.id}`;
133
+ if (seen.has(pairKey)) {
134
+ continue;
135
+ }
136
+ seen.add(pairKey);
137
+ const sim = jaccard(base.kindSet, candidate.kindSet);
138
+ if (sim >= threshold) {
139
+ pairs.push({
140
+ similarity: round(sim, 3),
141
+ basis: "ast_structure",
142
+ functions: [
143
+ pickSummary(base),
144
+ pickSummary(candidate)
145
+ ]
146
+ });
147
+ if (pairs.length >= maxPairs) {
148
+ return pairs;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ return pairs;
155
+ }
156
+ function pickSummary(fp) {
157
+ return {
158
+ id: fp.id,
159
+ name: fp.name,
160
+ file: fp.file,
161
+ language: fp.language
162
+ };
163
+ }
164
+ function jaccard(a, b) {
165
+ const setA = new Set(a);
166
+ const setB = new Set(b);
167
+ if (setA.size === 0 || setB.size === 0) {
168
+ return 0;
169
+ }
170
+ let intersection = 0;
171
+ for (const item of setA) {
172
+ if (setB.has(item)) {
173
+ intersection += 1;
174
+ }
175
+ }
176
+ const union = setA.size + setB.size - intersection;
177
+ if (union === 0) {
178
+ return 0;
179
+ }
180
+ return intersection / union;
181
+ }
182
+ function extractTsFunctions(file, source, language) {
183
+ const kind = inferScriptKind(file);
184
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, kind);
185
+ const functions = [];
186
+ const visit = (node) => {
187
+ if (ts.isFunctionDeclaration(node) && node.name && node.body) {
188
+ functions.push(buildTsFingerprint(file, node.name.text, node.body, language));
189
+ }
190
+ if (ts.isMethodDeclaration(node) && node.name && node.body) {
191
+ const name = ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)
192
+ ? node.name.text
193
+ : "method";
194
+ const className = ts.isClassDeclaration(node.parent) && node.parent.name
195
+ ? node.parent.name.text
196
+ : "Class";
197
+ functions.push(buildTsFingerprint(file, `${className}.${name}`, node.body, language));
198
+ }
199
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
200
+ const initializer = node.initializer;
201
+ if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
202
+ if (initializer.body) {
203
+ functions.push(buildTsFingerprint(file, node.name.text, initializer.body, language));
204
+ }
205
+ }
206
+ }
207
+ ts.forEachChild(node, visit);
208
+ };
209
+ visit(sourceFile);
210
+ return functions;
211
+ }
212
+ function buildTsFingerprint(file, name, body, language) {
213
+ const tokens = [];
214
+ const calls = [];
215
+ const walk = (node) => {
216
+ tokens.push(normalizeTsKind(node));
217
+ if (ts.isCallExpression(node)) {
218
+ const callee = resolveTsCallee(node.expression);
219
+ if (callee) {
220
+ calls.push(callee);
221
+ }
222
+ }
223
+ ts.forEachChild(node, walk);
224
+ };
225
+ walk(body);
226
+ const payload = tokens.join("|");
227
+ const hash = crypto.createHash("sha1").update(payload).digest("hex");
228
+ const kindSet = Array.from(new Set(tokens));
229
+ const relative = file;
230
+ return {
231
+ id: `${relative}#${name}`,
232
+ name,
233
+ file: relative,
234
+ language,
235
+ size: tokens.length,
236
+ hash,
237
+ calls: Array.from(new Set(calls)),
238
+ kindSet
239
+ };
240
+ }
241
+ function normalizeTsKind(node) {
242
+ switch (node.kind) {
243
+ case ts.SyntaxKind.Identifier:
244
+ return "Identifier";
245
+ case ts.SyntaxKind.StringLiteral:
246
+ case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
247
+ case ts.SyntaxKind.NumericLiteral:
248
+ case ts.SyntaxKind.BigIntLiteral:
249
+ case ts.SyntaxKind.RegularExpressionLiteral:
250
+ return "Literal";
251
+ default:
252
+ return ts.SyntaxKind[node.kind] || "Node";
253
+ }
254
+ }
255
+ function resolveTsCallee(expr) {
256
+ if (ts.isIdentifier(expr)) {
257
+ return expr.text;
258
+ }
259
+ if (ts.isPropertyAccessExpression(expr)) {
260
+ const base = resolveTsCallee(expr.expression);
261
+ if (base) {
262
+ return `${base}.${expr.name.text}`;
263
+ }
264
+ return expr.name.text;
265
+ }
266
+ if (ts.isElementAccessExpression(expr)) {
267
+ const base = resolveTsCallee(expr.expression);
268
+ if (base) {
269
+ return `${base}[]`;
270
+ }
271
+ }
272
+ if (expr.kind === ts.SyntaxKind.ThisKeyword) {
273
+ return "this";
274
+ }
275
+ return null;
276
+ }
277
+ function extractPythonFunctions(file, source) {
278
+ const parser = new Parser();
279
+ parser.setLanguage(Python);
280
+ let tree;
281
+ try {
282
+ tree = parser.parse(source);
283
+ }
284
+ catch {
285
+ return [];
286
+ }
287
+ const root = tree.rootNode;
288
+ const functions = [];
289
+ walk(root, (node) => {
290
+ if (node.type === "function_definition") {
291
+ if (!isTopLevel(node)) {
292
+ return;
293
+ }
294
+ const nameNode = node.childForFieldName("name");
295
+ const bodyNode = node.childForFieldName("body");
296
+ if (!nameNode || !bodyNode) {
297
+ return;
298
+ }
299
+ const name = nodeText(nameNode, source);
300
+ functions.push(buildPythonFingerprint(file, name, bodyNode, source));
301
+ }
302
+ if (node.type === "class_definition") {
303
+ if (!isTopLevel(node)) {
304
+ return;
305
+ }
306
+ const classNameNode = node.childForFieldName("name");
307
+ const bodyNode = node.childForFieldName("body");
308
+ if (!classNameNode || !bodyNode) {
309
+ return;
310
+ }
311
+ const className = nodeText(classNameNode, source);
312
+ for (const child of bodyNode.namedChildren) {
313
+ if (child.type !== "function_definition") {
314
+ continue;
315
+ }
316
+ const methodNameNode = child.childForFieldName("name");
317
+ const methodBody = child.childForFieldName("body");
318
+ if (!methodNameNode || !methodBody) {
319
+ continue;
320
+ }
321
+ const methodName = nodeText(methodNameNode, source);
322
+ functions.push(buildPythonFingerprint(file, `${className}.${methodName}`, methodBody, source));
323
+ }
324
+ }
325
+ });
326
+ return functions;
327
+ }
328
+ function buildPythonFingerprint(file, name, body, source) {
329
+ const tokens = [];
330
+ const calls = [];
331
+ walk(body, (node) => {
332
+ tokens.push(normalizePythonNode(node, source));
333
+ if (node.type === "call") {
334
+ const callee = node.childForFieldName("function");
335
+ if (callee) {
336
+ const callName = exprName(callee, source);
337
+ if (callName) {
338
+ calls.push(callName);
339
+ }
340
+ }
341
+ }
342
+ });
343
+ const hash = crypto.createHash("sha1").update(tokens.join("|")).digest("hex");
344
+ const kindSet = Array.from(new Set(tokens));
345
+ return {
346
+ id: `${file}#${name}`,
347
+ name,
348
+ file,
349
+ language: "py",
350
+ size: tokens.length,
351
+ hash,
352
+ calls: Array.from(new Set(calls)),
353
+ kindSet
354
+ };
355
+ }
356
+ function normalizePythonNode(node, source) {
357
+ if (node.type === "identifier") {
358
+ return "id";
359
+ }
360
+ if (node.type === "string" ||
361
+ node.type === "integer" ||
362
+ node.type === "float" ||
363
+ node.type === "true" ||
364
+ node.type === "false" ||
365
+ node.type === "none") {
366
+ return "lit";
367
+ }
368
+ if (node.type === "attribute") {
369
+ return "attr";
370
+ }
371
+ return node.type;
372
+ }
373
+ function isTopLevel(node) {
374
+ return node.parent?.type === "module";
375
+ }
376
+ function walk(node, visit) {
377
+ visit(node);
378
+ for (const child of node.namedChildren) {
379
+ walk(child, visit);
380
+ }
381
+ }
382
+ function nodeText(node, source) {
383
+ return source.slice(node.startIndex, node.endIndex);
384
+ }
385
+ function exprName(node, source) {
386
+ if (node.type === "identifier") {
387
+ return nodeText(node, source);
388
+ }
389
+ if (node.type === "attribute") {
390
+ const objectNode = node.childForFieldName("object");
391
+ const attrNode = node.childForFieldName("attribute");
392
+ const base = objectNode ? exprName(objectNode, source) : null;
393
+ const attr = attrNode ? nodeText(attrNode, source) : null;
394
+ if (base && attr) {
395
+ return `${base}.${attr}`;
396
+ }
397
+ return attr ?? base;
398
+ }
399
+ return null;
400
+ }
401
+ function isTsLike(ext) {
402
+ return [
403
+ ".ts",
404
+ ".tsx",
405
+ ".js",
406
+ ".jsx",
407
+ ".mjs",
408
+ ".cjs",
409
+ ".mts",
410
+ ".cts"
411
+ ].includes(ext);
412
+ }
413
+ function inferScriptKind(filePath) {
414
+ const ext = path.extname(filePath).toLowerCase();
415
+ if (ext === ".tsx") {
416
+ return ts.ScriptKind.TSX;
417
+ }
418
+ if (ext === ".jsx") {
419
+ return ts.ScriptKind.JSX;
420
+ }
421
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
422
+ return ts.ScriptKind.JS;
423
+ }
424
+ if (ext === ".mts") {
425
+ return ts.ScriptKind.TS;
426
+ }
427
+ if (ext === ".cts") {
428
+ return ts.ScriptKind.TS;
429
+ }
430
+ return ts.ScriptKind.TS;
431
+ }
432
+ function round(value, precision) {
433
+ const factor = 10 ** precision;
434
+ return Math.round(value * factor) / factor;
435
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Feature Arcs — builds a timeline of how feature areas evolve across sprints.
3
+ *
4
+ * Analogous to thread evolution in the book workflow (T8 evolving across 7 books):
5
+ * tracks which endpoints, models, and service calls each feature area added per sprint.
6
+ *
7
+ * Input: directory of feature spec YAML files
8
+ * Output: feature-arcs.json
9
+ *
10
+ * Structure:
11
+ * {
12
+ * "auth": {
13
+ * "sprint_1": { endpoints: [...], models: [...], maps_to: [...] },
14
+ * "sprint_4": { ... },
15
+ * },
16
+ * "billing": { ... }
17
+ * }
18
+ */
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import yaml from "js-yaml";
22
+ import { parseFeatureSpec } from "../schema/feature-spec.js";
23
+ /**
24
+ * Load all feature spec YAML files from a directory and build the arc timeline.
25
+ */
26
+ export async function buildFeatureArcs(featureSpecsDir) {
27
+ const specs = await loadAllFeatureSpecs(featureSpecsDir);
28
+ const arcMap = new Map();
29
+ const untaggedFeatures = [];
30
+ const untaggedEndpoints = new Set();
31
+ const untaggedModels = new Set();
32
+ for (const spec of specs) {
33
+ const sprintKey = spec.sprint != null ? `sprint_${spec.sprint}` : "unsprinted";
34
+ const tags = spec.tags.length > 0 ? spec.tags : null;
35
+ if (!tags) {
36
+ untaggedFeatures.push(spec.feature);
37
+ spec.affected_endpoints.forEach((e) => untaggedEndpoints.add(e));
38
+ spec.affected_models.forEach((m) => untaggedModels.add(m));
39
+ continue;
40
+ }
41
+ for (const tag of tags) {
42
+ if (!arcMap.has(tag)) {
43
+ arcMap.set(tag, { tag, sprints: {}, total_endpoints: 0, total_models: 0 });
44
+ }
45
+ const arc = arcMap.get(tag);
46
+ if (!arc.sprints[sprintKey]) {
47
+ arc.sprints[sprintKey] = { endpoints: [], models: [], maps_to: [], features: [] };
48
+ }
49
+ const sprint = arc.sprints[sprintKey];
50
+ sprint.features.push(spec.feature);
51
+ spec.affected_endpoints.forEach((e) => {
52
+ if (!sprint.endpoints.includes(e))
53
+ sprint.endpoints.push(e);
54
+ });
55
+ spec.affected_models.forEach((m) => {
56
+ if (!sprint.models.includes(m))
57
+ sprint.models.push(m);
58
+ });
59
+ if (spec.maps_to && !sprint.maps_to.includes(spec.maps_to)) {
60
+ sprint.maps_to.push(spec.maps_to);
61
+ }
62
+ }
63
+ }
64
+ // Compute totals per arc (deduplicated across sprints)
65
+ for (const arc of arcMap.values()) {
66
+ const allEndpoints = new Set();
67
+ const allModels = new Set();
68
+ for (const sprint of Object.values(arc.sprints)) {
69
+ sprint.endpoints.forEach((e) => allEndpoints.add(e));
70
+ sprint.models.forEach((m) => allModels.add(m));
71
+ }
72
+ arc.total_endpoints = allEndpoints.size;
73
+ arc.total_models = allModels.size;
74
+ }
75
+ // Sort arcs alphabetically; sort sprint keys numerically
76
+ const arcs = {};
77
+ for (const [tag, arc] of Array.from(arcMap.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
78
+ arcs[tag] = {
79
+ ...arc,
80
+ sprints: sortSprintKeys(arc.sprints),
81
+ };
82
+ }
83
+ return {
84
+ generated_at: new Date().toISOString(),
85
+ arcs,
86
+ untagged: {
87
+ features: untaggedFeatures,
88
+ endpoints: Array.from(untaggedEndpoints),
89
+ models: Array.from(untaggedModels),
90
+ },
91
+ };
92
+ }
93
+ function sortSprintKeys(sprints) {
94
+ const sorted = {};
95
+ const keys = Object.keys(sprints).sort((a, b) => {
96
+ const na = parseInt(a.replace(/\D/g, ""), 10);
97
+ const nb = parseInt(b.replace(/\D/g, ""), 10);
98
+ if (!isNaN(na) && !isNaN(nb))
99
+ return na - nb;
100
+ return a.localeCompare(b);
101
+ });
102
+ for (const k of keys)
103
+ sorted[k] = sprints[k];
104
+ return sorted;
105
+ }
106
+ async function loadAllFeatureSpecs(dir) {
107
+ let entries;
108
+ try {
109
+ const dirEntries = await fs.readdir(dir);
110
+ entries = dirEntries
111
+ .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
112
+ .map((f) => path.join(dir, f));
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ const specs = [];
118
+ for (const entry of entries) {
119
+ try {
120
+ const raw = await fs.readFile(entry, "utf8");
121
+ const parsed = yaml.load(raw);
122
+ const spec = parseFeatureSpec(parsed);
123
+ specs.push(spec);
124
+ }
125
+ catch {
126
+ // Skip malformed specs silently
127
+ }
128
+ }
129
+ return specs;
130
+ }
131
+ /**
132
+ * Write feature arcs to disk.
133
+ */
134
+ export async function writeFeatureArcs(featureSpecsDir, outputPath) {
135
+ const arcs = await buildFeatureArcs(featureSpecsDir);
136
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
137
+ await fs.writeFile(outputPath, JSON.stringify(arcs, null, 2), "utf8");
138
+ }
@@ -0,0 +1,76 @@
1
+ export function ensureNode(graph, node) {
2
+ if (!graph.has(node)) {
3
+ graph.set(node, new Set());
4
+ }
5
+ }
6
+ export function addEdge(graph, from, to) {
7
+ ensureNode(graph, from);
8
+ ensureNode(graph, to);
9
+ graph.get(from)?.add(to);
10
+ }
11
+ export function inboundCounts(graph, nodes) {
12
+ const counts = new Map();
13
+ for (const node of nodes) {
14
+ counts.set(node, 0);
15
+ }
16
+ for (const [from, neighbors] of graph) {
17
+ if (!counts.has(from)) {
18
+ counts.set(from, 0);
19
+ }
20
+ for (const neighbor of neighbors) {
21
+ counts.set(neighbor, (counts.get(neighbor) ?? 0) + 1);
22
+ }
23
+ }
24
+ return counts;
25
+ }
26
+ function normalizeCycle(cycle) {
27
+ if (cycle.length === 0) {
28
+ return cycle;
29
+ }
30
+ let minIndex = 0;
31
+ for (let i = 1; i < cycle.length; i += 1) {
32
+ if (cycle[i] < cycle[minIndex]) {
33
+ minIndex = i;
34
+ }
35
+ }
36
+ return [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
37
+ }
38
+ function cycleKey(cycle) {
39
+ return normalizeCycle(cycle).join("->");
40
+ }
41
+ export function findCycles(graph) {
42
+ const nodes = Array.from(graph.keys()).sort((a, b) => a.localeCompare(b));
43
+ const visited = new Set();
44
+ const onStack = new Set();
45
+ const stack = [];
46
+ const cycles = new Map();
47
+ function dfs(node) {
48
+ visited.add(node);
49
+ stack.push(node);
50
+ onStack.add(node);
51
+ const neighbors = Array.from(graph.get(node) ?? []).sort((a, b) => a.localeCompare(b));
52
+ for (const neighbor of neighbors) {
53
+ if (!visited.has(neighbor)) {
54
+ dfs(neighbor);
55
+ }
56
+ else if (onStack.has(neighbor)) {
57
+ const startIndex = stack.indexOf(neighbor);
58
+ if (startIndex >= 0) {
59
+ const cycle = stack.slice(startIndex);
60
+ const key = cycleKey(cycle);
61
+ if (!cycles.has(key)) {
62
+ cycles.set(key, normalizeCycle(cycle));
63
+ }
64
+ }
65
+ }
66
+ }
67
+ stack.pop();
68
+ onStack.delete(node);
69
+ }
70
+ for (const node of nodes) {
71
+ if (!visited.has(node)) {
72
+ dfs(node);
73
+ }
74
+ }
75
+ return Array.from(cycles.values()).sort((a, b) => a.join("->").localeCompare(b.join("->")));
76
+ }