@intentius/chant 0.0.1

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 (271) hide show
  1. package/README.md +365 -0
  2. package/package.json +22 -0
  3. package/src/attrref.test.ts +148 -0
  4. package/src/attrref.ts +50 -0
  5. package/src/barrel.test.ts +157 -0
  6. package/src/barrel.ts +101 -0
  7. package/src/bench.test.ts +227 -0
  8. package/src/build.test.ts +437 -0
  9. package/src/build.ts +425 -0
  10. package/src/builder.test.ts +312 -0
  11. package/src/builder.ts +56 -0
  12. package/src/child-project.ts +44 -0
  13. package/src/cli/commands/__fixtures__/init-lexicon-output/README.md +26 -0
  14. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +14 -0
  15. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/package.json +16 -0
  16. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/index.mdx +8 -0
  17. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +7 -0
  18. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/tsconfig.json +10 -0
  19. package/src/cli/commands/__fixtures__/init-lexicon-output/examples/getting-started/.gitkeep +0 -0
  20. package/src/cli/commands/__fixtures__/init-lexicon-output/justfile +26 -0
  21. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +29 -0
  22. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/docs.ts +25 -0
  23. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +8 -0
  24. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +74 -0
  25. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/naming.ts +33 -0
  26. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/package.ts +25 -0
  27. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/rollback.ts +45 -0
  28. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +11 -0
  29. package/src/cli/commands/__fixtures__/init-lexicon-output/src/generated/.gitkeep +0 -0
  30. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +10 -0
  31. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +10 -0
  32. package/src/cli/commands/__fixtures__/init-lexicon-output/src/index.ts +9 -0
  33. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/rules/index.ts +1 -0
  34. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/rules/sample.ts +18 -0
  35. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lsp/completions.ts +14 -0
  36. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lsp/hover.ts +14 -0
  37. package/src/cli/commands/__fixtures__/init-lexicon-output/src/plugin.ts +110 -0
  38. package/src/cli/commands/__fixtures__/init-lexicon-output/src/serializer.ts +24 -0
  39. package/src/cli/commands/__fixtures__/init-lexicon-output/src/spec/fetch.ts +21 -0
  40. package/src/cli/commands/__fixtures__/init-lexicon-output/src/spec/parse.ts +25 -0
  41. package/src/cli/commands/__fixtures__/init-lexicon-output/src/validate-cli.ts +4 -0
  42. package/src/cli/commands/__fixtures__/init-lexicon-output/src/validate.ts +24 -0
  43. package/src/cli/commands/__fixtures__/init-lexicon-output/tsconfig.json +10 -0
  44. package/src/cli/commands/__fixtures__/sample-rule.ts +11 -0
  45. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +222 -0
  46. package/src/cli/commands/build.test.ts +149 -0
  47. package/src/cli/commands/build.ts +344 -0
  48. package/src/cli/commands/diff.test.ts +148 -0
  49. package/src/cli/commands/diff.ts +221 -0
  50. package/src/cli/commands/doctor.test.ts +239 -0
  51. package/src/cli/commands/doctor.ts +224 -0
  52. package/src/cli/commands/import.test.ts +379 -0
  53. package/src/cli/commands/import.ts +335 -0
  54. package/src/cli/commands/init-lexicon.test.ts +297 -0
  55. package/src/cli/commands/init-lexicon.ts +993 -0
  56. package/src/cli/commands/init.test.ts +317 -0
  57. package/src/cli/commands/init.ts +505 -0
  58. package/src/cli/commands/licenses.ts +165 -0
  59. package/src/cli/commands/lint.test.ts +332 -0
  60. package/src/cli/commands/lint.ts +408 -0
  61. package/src/cli/commands/list.test.ts +100 -0
  62. package/src/cli/commands/list.ts +108 -0
  63. package/src/cli/commands/update.test.ts +38 -0
  64. package/src/cli/commands/update.ts +207 -0
  65. package/src/cli/conflict-check.test.ts +255 -0
  66. package/src/cli/conflict-check.ts +89 -0
  67. package/src/cli/debug.ts +8 -0
  68. package/src/cli/format.test.ts +140 -0
  69. package/src/cli/format.ts +133 -0
  70. package/src/cli/handlers/build.ts +58 -0
  71. package/src/cli/handlers/dev.ts +38 -0
  72. package/src/cli/handlers/init.ts +46 -0
  73. package/src/cli/handlers/lint.ts +36 -0
  74. package/src/cli/handlers/misc.ts +57 -0
  75. package/src/cli/handlers/serve.ts +26 -0
  76. package/src/cli/index.ts +3 -0
  77. package/src/cli/lsp/capabilities.ts +46 -0
  78. package/src/cli/lsp/diagnostics.ts +52 -0
  79. package/src/cli/lsp/server.test.ts +618 -0
  80. package/src/cli/lsp/server.ts +393 -0
  81. package/src/cli/main.test.ts +257 -0
  82. package/src/cli/main.ts +224 -0
  83. package/src/cli/mcp/resources/context.ts +59 -0
  84. package/src/cli/mcp/server.test.ts +747 -0
  85. package/src/cli/mcp/server.ts +402 -0
  86. package/src/cli/mcp/tools/build.ts +117 -0
  87. package/src/cli/mcp/tools/import.ts +48 -0
  88. package/src/cli/mcp/tools/lint.ts +45 -0
  89. package/src/cli/plugins.test.ts +31 -0
  90. package/src/cli/plugins.ts +94 -0
  91. package/src/cli/registry.ts +73 -0
  92. package/src/cli/reporters/stylish.test.ts +282 -0
  93. package/src/cli/reporters/stylish.ts +186 -0
  94. package/src/cli/watch.test.ts +81 -0
  95. package/src/cli/watch.ts +101 -0
  96. package/src/codegen/case.test.ts +30 -0
  97. package/src/codegen/case.ts +11 -0
  98. package/src/codegen/coverage.ts +167 -0
  99. package/src/codegen/docs.ts +634 -0
  100. package/src/codegen/fetch.test.ts +119 -0
  101. package/src/codegen/fetch.ts +261 -0
  102. package/src/codegen/generate-registry.test.ts +118 -0
  103. package/src/codegen/generate-registry.ts +107 -0
  104. package/src/codegen/generate-runtime-index.test.ts +81 -0
  105. package/src/codegen/generate-runtime-index.ts +99 -0
  106. package/src/codegen/generate-typescript.test.ts +146 -0
  107. package/src/codegen/generate-typescript.ts +161 -0
  108. package/src/codegen/generate.ts +206 -0
  109. package/src/codegen/json-patch.test.ts +113 -0
  110. package/src/codegen/json-patch.ts +151 -0
  111. package/src/codegen/json-schema.test.ts +196 -0
  112. package/src/codegen/json-schema.ts +209 -0
  113. package/src/codegen/naming.ts +201 -0
  114. package/src/codegen/package.ts +161 -0
  115. package/src/codegen/rollback.test.ts +92 -0
  116. package/src/codegen/rollback.ts +115 -0
  117. package/src/codegen/topo-sort.test.ts +69 -0
  118. package/src/codegen/topo-sort.ts +46 -0
  119. package/src/codegen/typecheck.test.ts +37 -0
  120. package/src/codegen/typecheck.ts +74 -0
  121. package/src/codegen/validate.test.ts +86 -0
  122. package/src/codegen/validate.ts +143 -0
  123. package/src/composite.test.ts +426 -0
  124. package/src/composite.ts +243 -0
  125. package/src/config.test.ts +91 -0
  126. package/src/config.ts +87 -0
  127. package/src/declarable.test.ts +160 -0
  128. package/src/declarable.ts +47 -0
  129. package/src/detectLexicon.test.ts +236 -0
  130. package/src/detectLexicon.ts +37 -0
  131. package/src/discovery/cache.test.ts +78 -0
  132. package/src/discovery/cache.ts +86 -0
  133. package/src/discovery/collect.test.ts +269 -0
  134. package/src/discovery/collect.ts +51 -0
  135. package/src/discovery/cycles.test.ts +238 -0
  136. package/src/discovery/cycles.ts +107 -0
  137. package/src/discovery/files.test.ts +154 -0
  138. package/src/discovery/files.ts +61 -0
  139. package/src/discovery/graph.test.ts +476 -0
  140. package/src/discovery/graph.ts +150 -0
  141. package/src/discovery/import.test.ts +199 -0
  142. package/src/discovery/import.ts +20 -0
  143. package/src/discovery/index.test.ts +272 -0
  144. package/src/discovery/index.ts +132 -0
  145. package/src/discovery/resolve.test.ts +267 -0
  146. package/src/discovery/resolve.ts +54 -0
  147. package/src/errors.test.ts +138 -0
  148. package/src/errors.ts +86 -0
  149. package/src/import/base-parser.test.ts +67 -0
  150. package/src/import/base-parser.ts +48 -0
  151. package/src/import/generator.ts +21 -0
  152. package/src/import/ir-utils.test.ts +103 -0
  153. package/src/import/ir-utils.ts +87 -0
  154. package/src/import/parser.ts +41 -0
  155. package/src/index.ts +60 -0
  156. package/src/intrinsic-interpolation.test.ts +91 -0
  157. package/src/intrinsic-interpolation.ts +89 -0
  158. package/src/intrinsic.test.ts +69 -0
  159. package/src/intrinsic.ts +43 -0
  160. package/src/lexicon-integrity.test.ts +94 -0
  161. package/src/lexicon-integrity.ts +69 -0
  162. package/src/lexicon-manifest.test.ts +101 -0
  163. package/src/lexicon-manifest.ts +71 -0
  164. package/src/lexicon-output.test.ts +182 -0
  165. package/src/lexicon-output.ts +82 -0
  166. package/src/lexicon-schema.test.ts +239 -0
  167. package/src/lexicon-schema.ts +144 -0
  168. package/src/lexicon.ts +212 -0
  169. package/src/lint/config-overrides.test.ts +254 -0
  170. package/src/lint/config.test.ts +644 -0
  171. package/src/lint/config.ts +375 -0
  172. package/src/lint/declarative.test.ts +256 -0
  173. package/src/lint/declarative.ts +187 -0
  174. package/src/lint/engine.test.ts +465 -0
  175. package/src/lint/engine.ts +172 -0
  176. package/src/lint/named-checks.test.ts +37 -0
  177. package/src/lint/named-checks.ts +33 -0
  178. package/src/lint/parser.test.ts +129 -0
  179. package/src/lint/parser.ts +42 -0
  180. package/src/lint/post-synth.test.ts +113 -0
  181. package/src/lint/post-synth.ts +76 -0
  182. package/src/lint/presets/relaxed.json +19 -0
  183. package/src/lint/presets/strict.json +19 -0
  184. package/src/lint/rule-loader.test.ts +67 -0
  185. package/src/lint/rule-loader.ts +67 -0
  186. package/src/lint/rule-options.test.ts +141 -0
  187. package/src/lint/rule.test.ts +196 -0
  188. package/src/lint/rule.ts +98 -0
  189. package/src/lint/rules/barrel-import-style.test.ts +80 -0
  190. package/src/lint/rules/barrel-import-style.ts +59 -0
  191. package/src/lint/rules/composite-scope.ts +55 -0
  192. package/src/lint/rules/cor017-composite-name-match.test.ts +107 -0
  193. package/src/lint/rules/cor017-composite-name-match.ts +108 -0
  194. package/src/lint/rules/cor018-composite-prefer-lexicon-type.test.ts +172 -0
  195. package/src/lint/rules/cor018-composite-prefer-lexicon-type.ts +167 -0
  196. package/src/lint/rules/declarable-naming-convention.test.ts +69 -0
  197. package/src/lint/rules/declarable-naming-convention.ts +70 -0
  198. package/src/lint/rules/enforce-barrel-import.test.ts +169 -0
  199. package/src/lint/rules/enforce-barrel-import.ts +81 -0
  200. package/src/lint/rules/enforce-barrel-ref.test.ts +114 -0
  201. package/src/lint/rules/enforce-barrel-ref.ts +75 -0
  202. package/src/lint/rules/evl001-non-literal-expression.test.ts +158 -0
  203. package/src/lint/rules/evl001-non-literal-expression.ts +149 -0
  204. package/src/lint/rules/evl002-control-flow-resource.test.ts +110 -0
  205. package/src/lint/rules/evl002-control-flow-resource.ts +61 -0
  206. package/src/lint/rules/evl003-dynamic-property-access.test.ts +63 -0
  207. package/src/lint/rules/evl003-dynamic-property-access.ts +41 -0
  208. package/src/lint/rules/evl004-spread-non-const.test.ts +130 -0
  209. package/src/lint/rules/evl004-spread-non-const.ts +111 -0
  210. package/src/lint/rules/evl005-resource-block-body.test.ts +59 -0
  211. package/src/lint/rules/evl005-resource-block-body.ts +49 -0
  212. package/src/lint/rules/evl006-barrel-usage.test.ts +63 -0
  213. package/src/lint/rules/evl006-barrel-usage.ts +95 -0
  214. package/src/lint/rules/evl007-invalid-siblings.test.ts +87 -0
  215. package/src/lint/rules/evl007-invalid-siblings.ts +139 -0
  216. package/src/lint/rules/evl008-unresolvable-barrel-ref.test.ts +118 -0
  217. package/src/lint/rules/evl008-unresolvable-barrel-ref.ts +140 -0
  218. package/src/lint/rules/evl009-composite-no-constant.test.ts +162 -0
  219. package/src/lint/rules/evl009-composite-no-constant.ts +171 -0
  220. package/src/lint/rules/evl010-composite-no-transform.test.ts +121 -0
  221. package/src/lint/rules/evl010-composite-no-transform.ts +69 -0
  222. package/src/lint/rules/export-required.test.ts +213 -0
  223. package/src/lint/rules/export-required.ts +158 -0
  224. package/src/lint/rules/file-declarable-limit.test.ts +148 -0
  225. package/src/lint/rules/file-declarable-limit.ts +96 -0
  226. package/src/lint/rules/flat-declarations.test.ts +210 -0
  227. package/src/lint/rules/flat-declarations.ts +70 -0
  228. package/src/lint/rules/index.ts +99 -0
  229. package/src/lint/rules/no-cyclic-declarable-ref.test.ts +135 -0
  230. package/src/lint/rules/no-cyclic-declarable-ref.ts +178 -0
  231. package/src/lint/rules/no-redundant-type-import.test.ts +129 -0
  232. package/src/lint/rules/no-redundant-type-import.ts +85 -0
  233. package/src/lint/rules/no-redundant-value-cast.test.ts +51 -0
  234. package/src/lint/rules/no-redundant-value-cast.ts +46 -0
  235. package/src/lint/rules/no-string-ref.test.ts +100 -0
  236. package/src/lint/rules/no-string-ref.ts +66 -0
  237. package/src/lint/rules/no-unused-declarable-import.test.ts +74 -0
  238. package/src/lint/rules/no-unused-declarable-import.ts +103 -0
  239. package/src/lint/rules/no-unused-declarable.test.ts +134 -0
  240. package/src/lint/rules/no-unused-declarable.ts +118 -0
  241. package/src/lint/rules/prefer-namespace-import.test.ts +102 -0
  242. package/src/lint/rules/prefer-namespace-import.ts +63 -0
  243. package/src/lint/rules/single-concern-file.test.ts +156 -0
  244. package/src/lint/rules/single-concern-file.ts +98 -0
  245. package/src/lint/rules/stale-barrel-types.ts +60 -0
  246. package/src/lint/selectors.test.ts +113 -0
  247. package/src/lint/selectors.ts +188 -0
  248. package/src/lsp/lexicon-providers.ts +191 -0
  249. package/src/lsp/types.ts +79 -0
  250. package/src/mcp/types.ts +22 -0
  251. package/src/project/scan.test.ts +178 -0
  252. package/src/project/scan.ts +182 -0
  253. package/src/project/sync.test.ts +87 -0
  254. package/src/project/sync.ts +46 -0
  255. package/src/project-validation.test.ts +64 -0
  256. package/src/project-validation.ts +79 -0
  257. package/src/pseudo-parameter.test.ts +39 -0
  258. package/src/pseudo-parameter.ts +47 -0
  259. package/src/runtime.ts +68 -0
  260. package/src/serializer-walker.test.ts +124 -0
  261. package/src/serializer-walker.ts +83 -0
  262. package/src/serializer.ts +42 -0
  263. package/src/sort.test.ts +290 -0
  264. package/src/sort.ts +58 -0
  265. package/src/stack-output.ts +82 -0
  266. package/src/types.test.ts +307 -0
  267. package/src/types.ts +46 -0
  268. package/src/utils.test.ts +195 -0
  269. package/src/utils.ts +46 -0
  270. package/src/validation.test.ts +308 -0
  271. package/src/validation.ts +50 -0
@@ -0,0 +1,238 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { detectCycles, normalizeCycleKey } from "./cycles";
3
+
4
+ describe("detectCycles", () => {
5
+ test("returns empty array for empty graph", () => {
6
+ const graph = {};
7
+ const cycles = detectCycles(graph);
8
+ expect(cycles).toEqual([]);
9
+ });
10
+
11
+ test("returns empty array for single node with no edges", () => {
12
+ const graph = {
13
+ A: [],
14
+ };
15
+ const cycles = detectCycles(graph);
16
+ expect(cycles).toEqual([]);
17
+ });
18
+
19
+ test("returns empty array for acyclic graph with multiple nodes", () => {
20
+ const graph = {
21
+ A: ["B", "C"],
22
+ B: ["D"],
23
+ C: ["D"],
24
+ D: [],
25
+ };
26
+ const cycles = detectCycles(graph);
27
+ expect(cycles).toEqual([]);
28
+ });
29
+
30
+ test("returns empty array for linear chain", () => {
31
+ const graph = {
32
+ A: ["B"],
33
+ B: ["C"],
34
+ C: ["D"],
35
+ D: [],
36
+ };
37
+ const cycles = detectCycles(graph);
38
+ expect(cycles).toEqual([]);
39
+ });
40
+
41
+ test("detects simple self-loop", () => {
42
+ const graph = {
43
+ A: ["A"],
44
+ };
45
+ const cycles = detectCycles(graph);
46
+ expect(cycles.length).toBe(1);
47
+ expect(cycles[0]).toEqual(["A"]);
48
+ });
49
+
50
+ test("detects simple two-node cycle", () => {
51
+ const graph = {
52
+ A: ["B"],
53
+ B: ["A"],
54
+ };
55
+ const cycles = detectCycles(graph);
56
+ expect(cycles.length).toBe(1);
57
+ expect(cycles[0]).toEqual(["A", "B"]);
58
+ });
59
+
60
+ test("detects three-node cycle", () => {
61
+ const graph = {
62
+ A: ["B"],
63
+ B: ["C"],
64
+ C: ["A"],
65
+ };
66
+ const cycles = detectCycles(graph);
67
+ expect(cycles.length).toBe(1);
68
+ expect(cycles[0]).toEqual(["A", "B", "C"]);
69
+ });
70
+
71
+ test("detects longer cycle", () => {
72
+ const graph = {
73
+ A: ["B"],
74
+ B: ["C"],
75
+ C: ["D"],
76
+ D: ["E"],
77
+ E: ["A"],
78
+ };
79
+ const cycles = detectCycles(graph);
80
+ expect(cycles.length).toBe(1);
81
+ expect(cycles[0]).toEqual(["A", "B", "C", "D", "E"]);
82
+ });
83
+
84
+ test("detects cycle with branching paths", () => {
85
+ const graph = {
86
+ A: ["B", "C"],
87
+ B: ["D"],
88
+ C: ["D"],
89
+ D: ["A"],
90
+ };
91
+ const cycles = detectCycles(graph);
92
+ expect(cycles.length).toBe(1);
93
+ // The cycle should be detected from one of the paths
94
+ expect(cycles[0]).toContain("A");
95
+ expect(cycles[0]).toContain("D");
96
+ });
97
+
98
+ test("detects multiple independent cycles", () => {
99
+ const graph = {
100
+ A: ["B"],
101
+ B: ["A"],
102
+ C: ["D"],
103
+ D: ["C"],
104
+ };
105
+ const cycles = detectCycles(graph);
106
+ expect(cycles.length).toBe(2);
107
+ });
108
+
109
+ test("detects cycle in complex graph with acyclic parts", () => {
110
+ const graph = {
111
+ A: ["B"],
112
+ B: ["C"],
113
+ C: ["D"],
114
+ D: ["B", "E"],
115
+ E: [],
116
+ };
117
+ const cycles = detectCycles(graph);
118
+ expect(cycles.length).toBe(1);
119
+ expect(cycles[0]).toEqual(["B", "C", "D"]);
120
+ });
121
+
122
+ test("handles graph with nodes that have no outgoing edges", () => {
123
+ const graph = {
124
+ A: ["B"],
125
+ B: ["C"],
126
+ C: ["A"],
127
+ D: [],
128
+ E: ["D"],
129
+ };
130
+ const cycles = detectCycles(graph);
131
+ expect(cycles.length).toBe(1);
132
+ expect(cycles[0]).toEqual(["A", "B", "C"]);
133
+ });
134
+
135
+ test("handles disconnected components with one containing cycle", () => {
136
+ const graph = {
137
+ A: ["B"],
138
+ B: ["C"],
139
+ C: [],
140
+ D: ["E"],
141
+ E: ["D"],
142
+ };
143
+ const cycles = detectCycles(graph);
144
+ expect(cycles.length).toBe(1);
145
+ expect(cycles[0]).toEqual(["D", "E"]);
146
+ });
147
+
148
+ test("detects nested cycles", () => {
149
+ const graph = {
150
+ A: ["B"],
151
+ B: ["C"],
152
+ C: ["A", "D"],
153
+ D: ["E"],
154
+ E: ["D"],
155
+ };
156
+ const cycles = detectCycles(graph);
157
+ // Should detect at least one cycle
158
+ expect(cycles.length).toBeGreaterThanOrEqual(1);
159
+ });
160
+
161
+ test("handles graph with multiple edges from same node", () => {
162
+ const graph = {
163
+ A: ["B", "C", "D"],
164
+ B: ["E"],
165
+ C: ["E"],
166
+ D: ["E"],
167
+ E: ["A"],
168
+ };
169
+ const cycles = detectCycles(graph);
170
+ expect(cycles.length).toBe(1);
171
+ expect(cycles[0][0]).toBe("A");
172
+ expect(cycles[0][cycles[0].length - 1]).toBe("E");
173
+ });
174
+
175
+ test("handles nodes referenced as neighbors but not defined as keys", () => {
176
+ const graph = {
177
+ A: ["B"],
178
+ B: ["C"],
179
+ // C is referenced but not defined
180
+ };
181
+ const cycles = detectCycles(graph);
182
+ expect(cycles).toEqual([]);
183
+ });
184
+
185
+ test("preserves node IDs correctly in cycle path", () => {
186
+ const graph = {
187
+ node1: ["node2"],
188
+ node2: ["node3"],
189
+ node3: ["node1"],
190
+ };
191
+ const cycles = detectCycles(graph);
192
+ expect(cycles.length).toBe(1);
193
+ expect(cycles[0]).toEqual(["node1", "node2", "node3"]);
194
+ });
195
+
196
+ test("handles graph with numeric-like string node IDs", () => {
197
+ const graph = {
198
+ "1": ["2"],
199
+ "2": ["3"],
200
+ "3": ["1"],
201
+ };
202
+ const cycles = detectCycles(graph);
203
+ expect(cycles.length).toBe(1);
204
+ expect(cycles[0]).toEqual(["1", "2", "3"]);
205
+ });
206
+
207
+ test("accepts Map<string, Set<string>> input", () => {
208
+ const graph = new Map<string, Set<string>>([
209
+ ["A", new Set(["B"])],
210
+ ["B", new Set(["C"])],
211
+ ["C", new Set(["A"])],
212
+ ]);
213
+ const cycles = detectCycles(graph);
214
+ expect(cycles.length).toBe(1);
215
+ expect(cycles[0]).toEqual(["A", "B", "C"]);
216
+ });
217
+
218
+ test("deduplicates equivalent cycles from different starting nodes", () => {
219
+ // Both A->B->C->A and B->C->A->B are the same cycle
220
+ const graph = new Map<string, Set<string>>([
221
+ ["A", new Set(["B"])],
222
+ ["B", new Set(["C"])],
223
+ ["C", new Set(["A"])],
224
+ ]);
225
+ const cycles = detectCycles(graph);
226
+ expect(cycles.length).toBe(1);
227
+ });
228
+ });
229
+
230
+ describe("normalizeCycleKey", () => {
231
+ test("rotates to lexicographically smallest node", () => {
232
+ expect(normalizeCycleKey(["C", "A", "B", "C"])).toBe("A,B,C");
233
+ });
234
+
235
+ test("handles single-node cycle", () => {
236
+ expect(normalizeCycleKey(["A", "A"])).toBe("A");
237
+ });
238
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Cycle detection using DFS with three-color marking (white/gray/black).
3
+ *
4
+ * Accepts either `Map<string, Set<string>>` or `Record<string, string[]>` as input.
5
+ * Returns deduplicated cycles, each represented as an array of node IDs.
6
+ */
7
+
8
+ type GraphInput = Map<string, Set<string>> | Record<string, string[]>;
9
+
10
+ /**
11
+ * Normalize graph input to Map<string, Set<string>>.
12
+ */
13
+ function normalizeGraph(graph: GraphInput): Map<string, Set<string>> {
14
+ if (graph instanceof Map) return graph;
15
+ const result = new Map<string, Set<string>>();
16
+ for (const [node, neighbors] of Object.entries(graph)) {
17
+ result.set(node, new Set(neighbors));
18
+ }
19
+ return result;
20
+ }
21
+
22
+ /**
23
+ * Detects cycles in a directed graph using DFS with three-color marking.
24
+ *
25
+ * @param graph - Adjacency list: node → neighbors. Accepts Map<string, Set<string>>
26
+ * or Record<string, string[]>.
27
+ * @returns Array of deduplicated cycles. Each cycle is a node ID array
28
+ * (e.g. ["A", "B", "C"]). Self-loops return ["A"].
29
+ */
30
+ export function detectCycles(graph: GraphInput): string[][] {
31
+ const g = normalizeGraph(graph);
32
+
33
+ const WHITE = 0; // Not visited
34
+ const GRAY = 1; // In current DFS path
35
+ const BLACK = 2; // Fully explored
36
+
37
+ const color = new Map<string, number>();
38
+ const parent = new Map<string, string>();
39
+ const cycles: string[][] = [];
40
+ const reportedCycles = new Set<string>();
41
+
42
+ for (const node of g.keys()) {
43
+ color.set(node, WHITE);
44
+ }
45
+
46
+ function dfs(node: string): void {
47
+ color.set(node, GRAY);
48
+
49
+ const neighbors = g.get(node) ?? new Set();
50
+ for (const neighbor of neighbors) {
51
+ const c = color.get(neighbor);
52
+ if (c === undefined || c === WHITE) {
53
+ if (c === undefined) {
54
+ // Node referenced but not defined as a key — treat as fully explored
55
+ continue;
56
+ }
57
+ parent.set(neighbor, node);
58
+ dfs(neighbor);
59
+ } else if (c === GRAY) {
60
+ // Found a cycle — reconstruct it
61
+ const cycle: string[] = [neighbor];
62
+ let current = node;
63
+ while (current !== neighbor) {
64
+ cycle.push(current);
65
+ current = parent.get(current)!;
66
+ }
67
+ cycle.push(neighbor);
68
+ cycle.reverse();
69
+
70
+ // Normalize to avoid duplicate cycle reports
71
+ const key = normalizeCycleKey(cycle);
72
+ if (!reportedCycles.has(key)) {
73
+ reportedCycles.add(key);
74
+ // Strip the trailing repeated node for a clean cycle representation
75
+ cycles.push(cycle.slice(0, -1));
76
+ }
77
+ }
78
+ }
79
+
80
+ color.set(node, BLACK);
81
+ }
82
+
83
+ for (const node of g.keys()) {
84
+ if (color.get(node) === WHITE) {
85
+ dfs(node);
86
+ }
87
+ }
88
+
89
+ return cycles;
90
+ }
91
+
92
+ /**
93
+ * Normalize a cycle for deduplication.
94
+ * Rotate to start with the lexicographically smallest node.
95
+ */
96
+ export function normalizeCycleKey(cycle: string[]): string {
97
+ // Remove the repeated last element for rotation
98
+ const nodes = cycle.slice(0, -1);
99
+ let minIdx = 0;
100
+ for (let i = 1; i < nodes.length; i++) {
101
+ if (nodes[i] < nodes[minIdx]) {
102
+ minIdx = i;
103
+ }
104
+ }
105
+ const rotated = [...nodes.slice(minIdx), ...nodes.slice(0, minIdx)];
106
+ return rotated.join(",");
107
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { findInfraFiles } from "./files";
3
+ import { withTestDir } from "@intentius/chant-test-utils";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ describe("findInfraFiles", () => {
8
+ test("returns empty array for empty directory", async () => {
9
+ await withTestDir(async (testDir) => {
10
+ const files = await findInfraFiles(testDir);
11
+ expect(files).toEqual([]);
12
+ });
13
+ });
14
+
15
+ test("finds .ts files in root directory", async () => {
16
+ await withTestDir(async (testDir) => {
17
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
18
+ await writeFile(join(testDir, "config.ts"), "export const config = {};");
19
+
20
+ const files = await findInfraFiles(testDir);
21
+ expect(files).toHaveLength(2);
22
+ expect(files.some((f) => f.endsWith("app.ts"))).toBe(true);
23
+ expect(files.some((f) => f.endsWith("config.ts"))).toBe(true);
24
+ });
25
+ });
26
+
27
+ test.each([
28
+ { extension: ".test.ts", pattern: /app\.test\.ts$/ },
29
+ { extension: ".spec.ts", pattern: /app\.spec\.ts$/ },
30
+ ])("excludes $extension files", async ({ extension, pattern }) => {
31
+ await withTestDir(async (testDir) => {
32
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
33
+ await writeFile(join(testDir, `app${extension}`), "test();");
34
+
35
+ const files = await findInfraFiles(testDir);
36
+ expect(files).toHaveLength(1);
37
+ expect(files[0]).toMatch(/app\.ts$/);
38
+ expect(files[0]).not.toMatch(pattern);
39
+ });
40
+ });
41
+
42
+ test("finds .ts files recursively", async () => {
43
+ await withTestDir(async (testDir) => {
44
+ const subDir = join(testDir, "src", "lib");
45
+ await mkdir(subDir, { recursive: true });
46
+ await writeFile(join(testDir, "root.ts"), "export const root = {};");
47
+ await writeFile(join(testDir, "src", "app.ts"), "export const app = {};");
48
+ await writeFile(
49
+ join(subDir, "utils.ts"),
50
+ "export const utils = {};"
51
+ );
52
+
53
+ const files = await findInfraFiles(testDir);
54
+ expect(files).toHaveLength(3);
55
+ expect(files.some((f) => f.endsWith("root.ts"))).toBe(true);
56
+ expect(files.some((f) => f.endsWith("app.ts"))).toBe(true);
57
+ expect(files.some((f) => f.endsWith("utils.ts"))).toBe(true);
58
+ });
59
+ });
60
+
61
+ test("excludes node_modules directory", async () => {
62
+ await withTestDir(async (testDir) => {
63
+ const nodeModulesDir = join(testDir, "node_modules", "some-package");
64
+ await mkdir(nodeModulesDir, { recursive: true });
65
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
66
+ await writeFile(
67
+ join(nodeModulesDir, "index.ts"),
68
+ "export const lib = {};"
69
+ );
70
+
71
+ const files = await findInfraFiles(testDir);
72
+ expect(files).toHaveLength(1);
73
+ expect(files[0]).toMatch(/app\.ts$/);
74
+ expect(files.some((f) => f.includes("node_modules"))).toBe(false);
75
+ });
76
+ });
77
+
78
+ test("excludes nested node_modules directories", async () => {
79
+ await withTestDir(async (testDir) => {
80
+ const srcDir = join(testDir, "src");
81
+ const nodeModulesDir = join(srcDir, "node_modules");
82
+ await mkdir(nodeModulesDir, { recursive: true });
83
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
84
+ await writeFile(join(srcDir, "lib.ts"), "export const lib = {};");
85
+ await writeFile(
86
+ join(nodeModulesDir, "package.ts"),
87
+ "export const pkg = {};"
88
+ );
89
+
90
+ const files = await findInfraFiles(testDir);
91
+ expect(files).toHaveLength(2);
92
+ expect(files.some((f) => f.endsWith("app.ts"))).toBe(true);
93
+ expect(files.some((f) => f.endsWith("lib.ts"))).toBe(true);
94
+ expect(files.some((f) => f.includes("node_modules"))).toBe(false);
95
+ });
96
+ });
97
+
98
+ test("ignores non-.ts files", async () => {
99
+ await withTestDir(async (testDir) => {
100
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
101
+ await writeFile(join(testDir, "readme.md"), "# README");
102
+ await writeFile(join(testDir, "config.json"), "{}");
103
+ await writeFile(join(testDir, "script.js"), "console.log();");
104
+
105
+ const files = await findInfraFiles(testDir);
106
+ expect(files).toHaveLength(1);
107
+ expect(files[0]).toMatch(/app\.ts$/);
108
+ });
109
+ });
110
+
111
+ test("handles mixed file types and test files", async () => {
112
+ await withTestDir(async (testDir) => {
113
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
114
+ await writeFile(join(testDir, "app.test.ts"), "test();");
115
+ await writeFile(join(testDir, "app.spec.ts"), "test();");
116
+ await writeFile(join(testDir, "config.ts"), "export const config = {};");
117
+ await writeFile(join(testDir, "readme.md"), "# README");
118
+
119
+ const files = await findInfraFiles(testDir);
120
+ expect(files).toHaveLength(2);
121
+ expect(files.some((f) => f.endsWith("app.ts"))).toBe(true);
122
+ expect(files.some((f) => f.endsWith("config.ts"))).toBe(true);
123
+ });
124
+ });
125
+
126
+ test("returns full paths to files", async () => {
127
+ await withTestDir(async (testDir) => {
128
+ await writeFile(join(testDir, "app.ts"), "export const app = {};");
129
+
130
+ const files = await findInfraFiles(testDir);
131
+ expect(files).toHaveLength(1);
132
+ expect(files[0]).toBe(join(testDir, "app.ts"));
133
+ });
134
+ });
135
+
136
+ test("handles directories with no .ts files", async () => {
137
+ await withTestDir(async (testDir) => {
138
+ await mkdir(join(testDir, "docs"), { recursive: true });
139
+ await writeFile(join(testDir, "docs", "readme.md"), "# README");
140
+ await writeFile(join(testDir, "package.json"), "{}");
141
+
142
+ const files = await findInfraFiles(testDir);
143
+ expect(files).toEqual([]);
144
+ });
145
+ });
146
+
147
+ test("handles non-existent directory gracefully", async () => {
148
+ await withTestDir(async (testDir) => {
149
+ const nonExistentPath = join(testDir, "does-not-exist");
150
+ const files = await findInfraFiles(nonExistentPath);
151
+ expect(files).toEqual([]);
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,61 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+
5
+ /**
6
+ * Recursively find all TypeScript infrastructure files in a directory
7
+ * @param path - The directory path to search
8
+ * @returns Array of file paths to .ts files (excluding test files)
9
+ */
10
+ export async function findInfraFiles(path: string): Promise<string[]> {
11
+ const files: string[] = [];
12
+ let sourceRoot: string | null = null;
13
+
14
+ async function scanDirectory(dir: string): Promise<void> {
15
+ let entries;
16
+
17
+ try {
18
+ entries = await readdir(dir, { withFileTypes: true });
19
+ } catch (error) {
20
+ // Skip directories we can't read
21
+ return;
22
+ }
23
+
24
+ for (const entry of entries) {
25
+ const fullPath = join(dir, entry.name);
26
+
27
+ // Skip node_modules
28
+ if (entry.isDirectory() && entry.name === "node_modules") {
29
+ continue;
30
+ }
31
+
32
+ if (entry.isDirectory()) {
33
+ // Child project boundary — a directory with its own barrel file is a
34
+ // separate scope, but only if we've already found the project's own
35
+ // source root (the first barrel directory). The project's own src/
36
+ // with _.ts is the source root, not a child project.
37
+ const barrelPath = join(fullPath, "_.ts");
38
+ if (existsSync(barrelPath)) {
39
+ if (sourceRoot === null) {
40
+ sourceRoot = fullPath;
41
+ } else {
42
+ continue;
43
+ }
44
+ }
45
+ await scanDirectory(fullPath);
46
+ } else if (entry.isFile()) {
47
+ // Include only .ts files, exclude test files
48
+ if (
49
+ entry.name.endsWith(".ts") &&
50
+ !entry.name.endsWith(".test.ts") &&
51
+ !entry.name.endsWith(".spec.ts")
52
+ ) {
53
+ files.push(fullPath);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ await scanDirectory(path);
60
+ return files;
61
+ }