@juzser/blacksmith 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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,966 @@
1
+ /**
2
+ * The compile-time module graph that file-level path claims cannot see.
3
+ *
4
+ * A path claim answers "did two tasks touch the same file". It cannot answer
5
+ * "did task A change a signature that task B's file imports", because that
6
+ * coupling does not live inside either file — it lives on the edge between
7
+ * them. This module reads those edges: which file imports which symbol from
8
+ * which file, and which file exports what.
9
+ *
10
+ * It ships its own scanner instead of calling a compiler, and that is a
11
+ * decision rather than an oversight. `typescript@7` is the native rewrite and
12
+ * exposes no compiler API: its package `exports` map offers `./unstable/sync`
13
+ * (a client that spawns a native language server) and `./unstable/ast`, both
14
+ * under the package's own word for "this will change". `ts.createSourceFile`
15
+ * is not there to call. Pinning the claim gate to that surface would trade a
16
+ * small scanner for a large moving dependency.
17
+ *
18
+ * The trade the scanner makes in return: it is a lexer, not a type checker, so
19
+ * some shapes are beyond it. Every one of them is *named* rather than quietly
20
+ * dropped — `unanalyzed`, `unresolved`, `unreadExports`, `opaqueImports`. A
21
+ * hole an operator can see is a different thing from a graph that claims to be
22
+ * complete and is not.
23
+ */
24
+ import { readdirSync, readFileSync } from 'node:fs';
25
+ import { dirname, extname, join } from 'node:path/posix';
26
+ // ---------------------------------------------------------------------------
27
+ // Lexical helpers
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Punctuation after which a `/` opens a regex rather than dividing. Without
31
+ * this, `const r = /['"]/;` would open a fake string and swallow the rest of
32
+ * the file.
33
+ */
34
+ const REGEX_ALLOWED_AFTER_PUNCT = new Set([
35
+ '(',
36
+ ',',
37
+ '=',
38
+ ':',
39
+ '[',
40
+ '!',
41
+ '&',
42
+ '|',
43
+ '?',
44
+ '{',
45
+ '}',
46
+ ';',
47
+ '+',
48
+ '-',
49
+ '*',
50
+ '%',
51
+ '~',
52
+ '^',
53
+ '<',
54
+ '>',
55
+ ]);
56
+ const REGEX_ALLOWED_AFTER_WORD = new Set([
57
+ 'return',
58
+ 'typeof',
59
+ 'instanceof',
60
+ 'in',
61
+ 'of',
62
+ 'new',
63
+ 'delete',
64
+ 'void',
65
+ 'case',
66
+ 'do',
67
+ 'else',
68
+ 'yield',
69
+ 'await',
70
+ ]);
71
+ const EXPORT_MODIFIERS = new Set(['declare', 'abstract', 'async']);
72
+ const NAMED_DECLARATION_KEYWORDS = new Set([
73
+ 'function',
74
+ 'class',
75
+ 'interface',
76
+ 'type',
77
+ 'enum',
78
+ 'namespace',
79
+ 'module',
80
+ ]);
81
+ function isIdentStart(ch) {
82
+ return /[A-Za-z_$]/.test(ch);
83
+ }
84
+ function isIdentPart(ch) {
85
+ return /[A-Za-z0-9_$]/.test(ch);
86
+ }
87
+ function readIdent(text, at) {
88
+ if (at >= text.length || !isIdentStart(text.charAt(at)))
89
+ return '';
90
+ let end = at;
91
+ while (end < text.length && isIdentPart(text.charAt(end)))
92
+ end += 1;
93
+ return text.slice(at, end);
94
+ }
95
+ function skipSpace(text, at) {
96
+ let i = at;
97
+ while (i < text.length && /\s/.test(text.charAt(i)))
98
+ i += 1;
99
+ return i;
100
+ }
101
+ function leadingWord(text) {
102
+ return readIdent(text.trimStart(), 0);
103
+ }
104
+ function startsRegex(prevToken) {
105
+ if (prevToken === '')
106
+ return true;
107
+ return REGEX_ALLOWED_AFTER_PUNCT.has(prevToken) || REGEX_ALLOWED_AFTER_WORD.has(prevToken);
108
+ }
109
+ function scanQuoted(source, start, quote) {
110
+ let i = start + 1;
111
+ while (i < source.length) {
112
+ const ch = source.charAt(i);
113
+ if (ch === '\\') {
114
+ i += 2;
115
+ continue;
116
+ }
117
+ if (ch === quote)
118
+ return { end: i, closed: true };
119
+ if (ch === '\n')
120
+ return { end: i, closed: false };
121
+ i += 1;
122
+ }
123
+ return { end: source.length, closed: false };
124
+ }
125
+ /** Index of the closing `/`, or null when this `/` was division after all. */
126
+ function scanRegex(source, start) {
127
+ let i = start + 1;
128
+ let inClass = false;
129
+ while (i < source.length) {
130
+ const ch = source.charAt(i);
131
+ if (ch === '\\') {
132
+ i += 2;
133
+ continue;
134
+ }
135
+ if (ch === '\n')
136
+ return null;
137
+ if (inClass) {
138
+ if (ch === ']')
139
+ inClass = false;
140
+ i += 1;
141
+ continue;
142
+ }
143
+ if (ch === '[') {
144
+ inClass = true;
145
+ i += 1;
146
+ continue;
147
+ }
148
+ if (ch === '/')
149
+ return i;
150
+ i += 1;
151
+ }
152
+ return null;
153
+ }
154
+ /**
155
+ * Blank out comment and literal interiors *in place*: same length, same line
156
+ * breaks, quote delimiters kept. That means every offset into the result still
157
+ * slices the original, and a masked specifier is recognisable as an empty
158
+ * quoted run.
159
+ */
160
+ export function maskLiterals(source) {
161
+ const out = source.split('');
162
+ let unterminated = false;
163
+ const blank = (from, to) => {
164
+ for (let k = from; k < to && k < out.length; k += 1) {
165
+ if (out[k] !== '\n')
166
+ out[k] = ' ';
167
+ }
168
+ };
169
+ // Each open `${` remembers the brace depth it interrupted, so its matching
170
+ // `}` can be told apart from a plain block close.
171
+ const templateFrames = [];
172
+ let inTemplate = false;
173
+ let braceDepth = 0;
174
+ let prevToken = '';
175
+ let i = 0;
176
+ while (i < source.length) {
177
+ const ch = source.charAt(i);
178
+ if (inTemplate) {
179
+ if (ch === '\\') {
180
+ blank(i, i + 2);
181
+ i += 2;
182
+ continue;
183
+ }
184
+ if (ch === '`') {
185
+ inTemplate = false;
186
+ prevToken = '`';
187
+ i += 1;
188
+ continue;
189
+ }
190
+ if (ch === '$' && source.charAt(i + 1) === '{') {
191
+ templateFrames.push(braceDepth);
192
+ inTemplate = false;
193
+ prevToken = '{';
194
+ i += 2;
195
+ continue;
196
+ }
197
+ blank(i, i + 1);
198
+ i += 1;
199
+ continue;
200
+ }
201
+ const next = source.charAt(i + 1);
202
+ if (ch === '/' && next === '/') {
203
+ let end = source.indexOf('\n', i);
204
+ if (end === -1)
205
+ end = source.length;
206
+ blank(i, end);
207
+ i = end;
208
+ continue;
209
+ }
210
+ if (ch === '/' && next === '*') {
211
+ const end = source.indexOf('*/', i + 2);
212
+ if (end === -1) {
213
+ blank(i, source.length);
214
+ unterminated = true;
215
+ break;
216
+ }
217
+ blank(i, end + 2);
218
+ i = end + 2;
219
+ continue;
220
+ }
221
+ if (ch === '"' || ch === "'") {
222
+ const quoted = scanQuoted(source, i, ch);
223
+ blank(i + 1, quoted.end);
224
+ if (!quoted.closed)
225
+ unterminated = true;
226
+ i = quoted.closed ? quoted.end + 1 : quoted.end;
227
+ prevToken = ch;
228
+ continue;
229
+ }
230
+ if (ch === '`') {
231
+ inTemplate = true;
232
+ i += 1;
233
+ continue;
234
+ }
235
+ if (ch === '/' && startsRegex(prevToken)) {
236
+ const close = scanRegex(source, i);
237
+ if (close !== null) {
238
+ blank(i + 1, close);
239
+ i = close + 1;
240
+ // A closed regex is a value, so the next `/` divides.
241
+ prevToken = 'regex';
242
+ continue;
243
+ }
244
+ // Fall through: this `/` divides.
245
+ }
246
+ if (ch === '{') {
247
+ braceDepth += 1;
248
+ prevToken = ch;
249
+ i += 1;
250
+ continue;
251
+ }
252
+ if (ch === '}') {
253
+ const frame = templateFrames[templateFrames.length - 1];
254
+ if (frame !== undefined && braceDepth === frame) {
255
+ templateFrames.pop();
256
+ inTemplate = true;
257
+ i += 1;
258
+ continue;
259
+ }
260
+ braceDepth -= 1;
261
+ prevToken = ch;
262
+ i += 1;
263
+ continue;
264
+ }
265
+ if (isIdentStart(ch)) {
266
+ const word = readIdent(source, i);
267
+ prevToken = word;
268
+ i += word.length;
269
+ continue;
270
+ }
271
+ if (!/\s/.test(ch))
272
+ prevToken = ch;
273
+ i += 1;
274
+ }
275
+ if (inTemplate)
276
+ unterminated = true;
277
+ return { masked: out.join(''), unterminated };
278
+ }
279
+ /** Words that prove a `{` opens a declaration body, not a named import list. */
280
+ const DECLARATION_WORDS = new Set([
281
+ 'function',
282
+ 'class',
283
+ 'interface',
284
+ 'enum',
285
+ 'namespace',
286
+ 'module',
287
+ 'const',
288
+ 'let',
289
+ 'var',
290
+ 'declare',
291
+ 'abstract',
292
+ 'async',
293
+ 'default',
294
+ ]);
295
+ /**
296
+ * Where the clause's own named list opens, or -1. A named list is reachable
297
+ * from the keyword through nothing but names, commas, `*` and whitespace —
298
+ * `import d, { a } from` qualifies, `export function f() {` does not.
299
+ */
300
+ function findListBrace(masked, at) {
301
+ let i = at;
302
+ while (i < masked.length) {
303
+ const ch = masked.charAt(i);
304
+ if (ch === '{')
305
+ return i;
306
+ if (ch === ',' || ch === '*' || /\s/.test(ch)) {
307
+ i += 1;
308
+ continue;
309
+ }
310
+ if (isIdentStart(ch)) {
311
+ const word = readIdent(masked, i);
312
+ if (DECLARATION_WORDS.has(word))
313
+ return -1;
314
+ i += word.length;
315
+ continue;
316
+ }
317
+ return -1;
318
+ }
319
+ return -1;
320
+ }
321
+ /**
322
+ * A statement clause ends at the first depth-0 `;` or `{` — unless that `{` is
323
+ * the clause's own named import/export list, which is exactly what tells
324
+ * `import { a } from` apart from `export function f() {`. A depth-0 newline
325
+ * also ends it, unless the next token is `from`.
326
+ */
327
+ function readClause(masked, start, keyword) {
328
+ let i = start + keyword.length;
329
+ const listBrace = findListBrace(masked, i);
330
+ let depth = 0;
331
+ let consumedList = false;
332
+ while (i < masked.length) {
333
+ const ch = masked.charAt(i);
334
+ if (ch === '(' || ch === '[') {
335
+ depth += 1;
336
+ i += 1;
337
+ continue;
338
+ }
339
+ if (ch === ')' || ch === ']') {
340
+ depth -= 1;
341
+ i += 1;
342
+ continue;
343
+ }
344
+ if (ch === '{') {
345
+ if (depth === 0 && !consumedList && i === listBrace) {
346
+ consumedList = true;
347
+ depth += 1;
348
+ i += 1;
349
+ continue;
350
+ }
351
+ if (depth === 0)
352
+ return { textEnd: i, end: i, stop: '{' };
353
+ depth += 1;
354
+ i += 1;
355
+ continue;
356
+ }
357
+ if (ch === '}') {
358
+ depth -= 1;
359
+ i += 1;
360
+ continue;
361
+ }
362
+ if (ch === ';' && depth === 0)
363
+ return { textEnd: i, end: i + 1, stop: ';' };
364
+ if (ch === '\n' && depth === 0 && readIdent(masked, skipSpace(masked, i)) !== 'from') {
365
+ return { textEnd: i, end: i, stop: 'nl' };
366
+ }
367
+ i += 1;
368
+ }
369
+ return { textEnd: masked.length, end: masked.length, stop: 'eof' };
370
+ }
371
+ function splitTopLevel(text, sep) {
372
+ const parts = [];
373
+ let depth = 0;
374
+ let last = 0;
375
+ for (let i = 0; i < text.length; i += 1) {
376
+ const ch = text.charAt(i);
377
+ if (ch === '(' || ch === '[' || ch === '{')
378
+ depth += 1;
379
+ else if (ch === ')' || ch === ']' || ch === '}')
380
+ depth -= 1;
381
+ else if (ch === sep && depth === 0) {
382
+ parts.push(text.slice(last, i));
383
+ last = i + 1;
384
+ }
385
+ }
386
+ parts.push(text.slice(last));
387
+ return parts;
388
+ }
389
+ /** `a as b` -> the name the target exports and the name it lands under. */
390
+ function renamedEntry(entry) {
391
+ let text = entry.trim();
392
+ if (text === '')
393
+ return null;
394
+ const withoutType = text.replace(/^type\s+/, '');
395
+ text = withoutType;
396
+ const asAt = text.search(/\sas\s/);
397
+ if (asAt === -1)
398
+ return { local: text, exported: text };
399
+ const local = text.slice(0, asAt).trim();
400
+ const exported = text.slice(asAt + 4).trim();
401
+ if (local === '')
402
+ return null;
403
+ return { local, exported: exported === '' ? local : exported };
404
+ }
405
+ function importedName(entry) {
406
+ const parsed = renamedEntry(entry);
407
+ return parsed === null ? null : parsed.local;
408
+ }
409
+ function parseImportHead(head) {
410
+ let rest = head.trim();
411
+ let typeOnly = false;
412
+ if (/^type\b/.test(rest)) {
413
+ typeOnly = true;
414
+ rest = rest.slice(4).trim();
415
+ }
416
+ const names = [];
417
+ for (const part of splitTopLevel(rest, ',')) {
418
+ const clause = part.trim();
419
+ if (clause === '')
420
+ continue;
421
+ if (clause.startsWith('{')) {
422
+ const inner = clause.slice(1, clause.lastIndexOf('}'));
423
+ for (const entry of splitTopLevel(inner, ',')) {
424
+ const name = importedName(entry);
425
+ if (name !== null)
426
+ names.push(name);
427
+ }
428
+ continue;
429
+ }
430
+ names.push(clause.startsWith('*') ? '*' : 'default');
431
+ }
432
+ return { names, typeOnly };
433
+ }
434
+ function parseExportFromHead(head) {
435
+ let rest = head.trim();
436
+ let typeOnly = false;
437
+ if (/^type\b/.test(rest)) {
438
+ typeOnly = true;
439
+ rest = rest.slice(4).trim();
440
+ }
441
+ if (rest.startsWith('*')) {
442
+ const after = rest.slice(1).trim();
443
+ if (/^as\b/.test(after)) {
444
+ const alias = after.slice(2).trim();
445
+ return { importNames: ['*'], exportNames: alias === '' ? ['*'] : [alias], typeOnly };
446
+ }
447
+ return { importNames: ['*'], exportNames: ['*'], typeOnly };
448
+ }
449
+ if (rest.startsWith('{')) {
450
+ const inner = rest.slice(1, rest.lastIndexOf('}'));
451
+ const importNames = [];
452
+ const exportNames = [];
453
+ for (const entry of splitTopLevel(inner, ',')) {
454
+ const parsed = renamedEntry(entry);
455
+ if (parsed === null)
456
+ continue;
457
+ importNames.push(parsed.local);
458
+ exportNames.push(parsed.exported);
459
+ }
460
+ return { importNames, exportNames, typeOnly };
461
+ }
462
+ return { importNames: [], exportNames: [], typeOnly };
463
+ }
464
+ function unreadLabel(clauseText, stoppedAtBrace) {
465
+ return `${clauseText.trim()}${stoppedAtBrace ? ' {' : ''}`;
466
+ }
467
+ function parseExportDeclaration(head, clauseText, stoppedAtBrace, facts) {
468
+ let rest = head.trim();
469
+ let word = leadingWord(rest);
470
+ while (EXPORT_MODIFIERS.has(word)) {
471
+ rest = rest.slice(rest.indexOf(word) + word.length).trim();
472
+ word = leadingWord(rest);
473
+ }
474
+ if (word === 'default') {
475
+ facts.exports.push('default');
476
+ return;
477
+ }
478
+ if (word === 'const' || word === 'let' || word === 'var') {
479
+ const names = [];
480
+ for (const part of splitTopLevel(rest.slice(rest.indexOf(word) + word.length), ',')) {
481
+ const name = leadingWord(part);
482
+ if (name !== '')
483
+ names.push(name);
484
+ }
485
+ if (names.length > 0) {
486
+ facts.exports.push(...names);
487
+ return;
488
+ }
489
+ facts.unreadExports.push(unreadLabel(clauseText, stoppedAtBrace));
490
+ return;
491
+ }
492
+ if (NAMED_DECLARATION_KEYWORDS.has(word)) {
493
+ let after = rest.slice(rest.indexOf(word) + word.length).trim();
494
+ if (after.startsWith('*'))
495
+ after = after.slice(1).trim();
496
+ const name = leadingWord(after);
497
+ if (name !== '') {
498
+ facts.exports.push(name);
499
+ return;
500
+ }
501
+ }
502
+ facts.unreadExports.push(unreadLabel(clauseText, stoppedAtBrace));
503
+ }
504
+ /** The depth-0 `from '...'` of a clause, if it has one. */
505
+ function findFromSpecifier(masked, from, to) {
506
+ let depth = 0;
507
+ let i = from;
508
+ while (i < to) {
509
+ const ch = masked.charAt(i);
510
+ if (ch === '(' || ch === '[' || ch === '{') {
511
+ depth += 1;
512
+ i += 1;
513
+ continue;
514
+ }
515
+ if (ch === ')' || ch === ']' || ch === '}') {
516
+ depth -= 1;
517
+ i += 1;
518
+ continue;
519
+ }
520
+ if (isIdentStart(ch)) {
521
+ const word = readIdent(masked, i);
522
+ if (depth === 0 && word === 'from') {
523
+ const quoteStart = skipSpace(masked, i + 4);
524
+ const quote = masked.charAt(quoteStart);
525
+ if (quoteStart < to && (quote === "'" || quote === '"')) {
526
+ const quoteEnd = masked.indexOf(quote, quoteStart + 1);
527
+ if (quoteEnd !== -1 && quoteEnd < to)
528
+ return { fromAt: i, quoteStart, quoteEnd };
529
+ }
530
+ }
531
+ i += word.length;
532
+ continue;
533
+ }
534
+ i += 1;
535
+ }
536
+ return null;
537
+ }
538
+ /** Whitespace is formatting, not signature: collapse it before comparing. */
539
+ function normalizeSignature(text) {
540
+ return text.trim().replace(/\s+/g, ' ');
541
+ }
542
+ /**
543
+ * Record the declaration text against every export the clause introduced.
544
+ * Wrapping is what keeps this honest: the clause parsers below push export
545
+ * names from six different shapes, and one of them growing a seventh should
546
+ * not be able to silently drop its signature.
547
+ */
548
+ function readClauseFacts(source, masked, start, clause, keyword, facts) {
549
+ const before = facts.exports.length;
550
+ readClauseExports(source, masked, start, clause, keyword, facts);
551
+ const signature = normalizeSignature(source.slice(start, clause.textEnd));
552
+ for (let i = before; i < facts.exports.length; i += 1) {
553
+ const name = facts.exports[i];
554
+ if (name !== undefined)
555
+ facts.exportSignatures.set(name, signature);
556
+ }
557
+ }
558
+ function readClauseExports(source, masked, start, clause, keyword, facts) {
559
+ const clauseText = source.slice(start, clause.textEnd);
560
+ const stoppedAtBrace = clause.stop === '{';
561
+ const headStart = start + keyword.length;
562
+ const spec = findFromSpecifier(masked, headStart, clause.textEnd);
563
+ if (spec !== null) {
564
+ const specifier = source.slice(spec.quoteStart + 1, spec.quoteEnd);
565
+ const head = masked.slice(headStart, spec.fromAt);
566
+ if (keyword === 'import') {
567
+ const { names, typeOnly } = parseImportHead(head);
568
+ facts.imports.push({ specifier, resolved: null, names, typeOnly, dynamic: false });
569
+ return;
570
+ }
571
+ const { importNames, exportNames, typeOnly } = parseExportFromHead(head);
572
+ facts.imports.push({ specifier, resolved: null, names: importNames, typeOnly, dynamic: false });
573
+ facts.exports.push(...exportNames);
574
+ return;
575
+ }
576
+ if (keyword === 'import') {
577
+ // A side-effect import carries no symbol, but it is still an edge.
578
+ let quoteStart = skipSpace(masked, headStart);
579
+ if (readIdent(masked, quoteStart) === 'type')
580
+ quoteStart = skipSpace(masked, quoteStart + 4);
581
+ const quote = masked.charAt(quoteStart);
582
+ if (quote === "'" || quote === '"') {
583
+ const quoteEnd = masked.indexOf(quote, quoteStart + 1);
584
+ if (quoteEnd !== -1 && quoteEnd < clause.textEnd) {
585
+ facts.imports.push({
586
+ specifier: source.slice(quoteStart + 1, quoteEnd),
587
+ resolved: null,
588
+ names: [],
589
+ typeOnly: false,
590
+ dynamic: false,
591
+ });
592
+ return;
593
+ }
594
+ }
595
+ facts.opaqueImports.push(clauseText.trim());
596
+ return;
597
+ }
598
+ const head = masked.slice(headStart, clause.textEnd);
599
+ const listHead = head.trim().replace(/^type\s+/, '');
600
+ if (listHead.startsWith('{')) {
601
+ for (const entry of splitTopLevel(listHead.slice(1, listHead.lastIndexOf('}')), ',')) {
602
+ const parsed = renamedEntry(entry);
603
+ if (parsed !== null)
604
+ facts.exports.push(parsed.exported);
605
+ }
606
+ return;
607
+ }
608
+ parseExportDeclaration(head, clauseText, stoppedAtBrace, facts);
609
+ }
610
+ function readStatementClauses(source, masked, facts) {
611
+ let depth = 0;
612
+ let i = 0;
613
+ let prevChar = '';
614
+ // Start of file is a statement boundary; so is a line break, because ASI.
615
+ let sawNewline = true;
616
+ while (i < masked.length) {
617
+ const ch = masked.charAt(i);
618
+ if (ch === '\n') {
619
+ sawNewline = true;
620
+ i += 1;
621
+ continue;
622
+ }
623
+ if (/\s/.test(ch)) {
624
+ i += 1;
625
+ continue;
626
+ }
627
+ if (ch === '(' || ch === '[' || ch === '{') {
628
+ depth += 1;
629
+ prevChar = ch;
630
+ sawNewline = false;
631
+ i += 1;
632
+ continue;
633
+ }
634
+ if (ch === ')' || ch === ']' || ch === '}') {
635
+ depth -= 1;
636
+ prevChar = ch;
637
+ sawNewline = false;
638
+ i += 1;
639
+ continue;
640
+ }
641
+ if (isIdentStart(ch)) {
642
+ const word = readIdent(masked, i);
643
+ const atBoundary = sawNewline || prevChar === '' || prevChar === ';' || prevChar === '{' || prevChar === '}';
644
+ const after = masked.charAt(skipSpace(masked, i + word.length));
645
+ const isDynamicOrMeta = word === 'import' && (after === '(' || after === '.');
646
+ if (depth === 0 &&
647
+ atBoundary &&
648
+ !isDynamicOrMeta &&
649
+ (word === 'import' || word === 'export')) {
650
+ const clause = readClause(masked, i, word);
651
+ readClauseFacts(source, masked, i, clause, word, facts);
652
+ i = clause.end;
653
+ prevChar = ';';
654
+ sawNewline = false;
655
+ continue;
656
+ }
657
+ i += word.length;
658
+ prevChar = 'a';
659
+ sawNewline = false;
660
+ continue;
661
+ }
662
+ prevChar = ch;
663
+ sawNewline = false;
664
+ i += 1;
665
+ }
666
+ }
667
+ function matchParen(masked, open) {
668
+ let depth = 0;
669
+ for (let i = open; i < masked.length; i += 1) {
670
+ const ch = masked.charAt(i);
671
+ if (ch === '(')
672
+ depth += 1;
673
+ else if (ch === ')') {
674
+ depth -= 1;
675
+ if (depth === 0)
676
+ return i;
677
+ }
678
+ }
679
+ return -1;
680
+ }
681
+ function readDynamicImports(source, masked, facts) {
682
+ const pattern = /\bimport\s*\(/g;
683
+ let match = pattern.exec(masked);
684
+ while (match !== null) {
685
+ const open = match.index + match[0].length - 1;
686
+ const close = matchParen(masked, open);
687
+ const inner = close === -1 ? masked.length : close;
688
+ const quoteStart = skipSpace(masked, open + 1);
689
+ const quote = masked.charAt(quoteStart);
690
+ const quoteEnd = quote === "'" || quote === '"' ? masked.indexOf(quote, quoteStart + 1) : -1;
691
+ if (quoteEnd !== -1 && quoteEnd < inner) {
692
+ facts.imports.push({
693
+ specifier: source.slice(quoteStart + 1, quoteEnd),
694
+ resolved: null,
695
+ names: ['*'],
696
+ typeOnly: false,
697
+ dynamic: true,
698
+ });
699
+ }
700
+ else {
701
+ facts.opaqueImports.push(`import(${source.slice(open + 1, inner).trim()})`);
702
+ }
703
+ pattern.lastIndex = close === -1 ? masked.length : close + 1;
704
+ match = pattern.exec(masked);
705
+ }
706
+ }
707
+ /** Read one file's imports and exports. Resolution is the graph's job, not this one's. */
708
+ export function parseModuleFacts(source, file) {
709
+ const facts = {
710
+ file,
711
+ exports: [],
712
+ imports: [],
713
+ exportSignatures: new Map(),
714
+ unreadExports: [],
715
+ opaqueImports: [],
716
+ unterminated: false,
717
+ };
718
+ const { masked, unterminated } = maskLiterals(source);
719
+ if (unterminated) {
720
+ facts.unterminated = true;
721
+ return facts;
722
+ }
723
+ readStatementClauses(source, masked, facts);
724
+ readDynamicImports(source, masked, facts);
725
+ return facts;
726
+ }
727
+ // ---------------------------------------------------------------------------
728
+ // Resolution
729
+ // ---------------------------------------------------------------------------
730
+ /** NodeNext writes `.js` and means the TypeScript source that compiles to it. */
731
+ const SOURCE_FOR_EMITTED = new Map([
732
+ ['.js', ['.ts', '.tsx', '.js']],
733
+ ['.jsx', ['.tsx', '.jsx']],
734
+ ['.mjs', ['.mts', '.mjs']],
735
+ ['.cjs', ['.cts', '.cjs']],
736
+ ]);
737
+ /** The conventional emitted/authored pair, used to follow `dist/x.js` to `src/x.ts`. */
738
+ const BUILD_DIRECTORY = 'dist';
739
+ const SOURCE_DIRECTORY = 'src';
740
+ /**
741
+ * Extensions a relative import can carry that hold no compile-time symbols. An
742
+ * import of a stylesheet is a bundler instruction, not a module edge, so its
743
+ * absence from the graph is not a hole worth reporting.
744
+ */
745
+ const SYMBOL_FREE_EXTENSIONS = new Set([
746
+ '.css',
747
+ '.scss',
748
+ '.sass',
749
+ '.less',
750
+ '.styl',
751
+ '.json',
752
+ '.json5',
753
+ '.yaml',
754
+ '.yml',
755
+ '.toml',
756
+ '.svg',
757
+ '.png',
758
+ '.jpg',
759
+ '.jpeg',
760
+ '.gif',
761
+ '.webp',
762
+ '.avif',
763
+ '.ico',
764
+ '.woff',
765
+ '.woff2',
766
+ '.ttf',
767
+ '.otf',
768
+ '.eot',
769
+ '.mp3',
770
+ '.mp4',
771
+ '.webm',
772
+ '.wav',
773
+ '.ogg',
774
+ '.txt',
775
+ '.md',
776
+ '.html',
777
+ '.wasm',
778
+ ]);
779
+ const AUTHORED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'];
780
+ const EXTENSIONLESS_CANDIDATES = [
781
+ '.ts',
782
+ '.tsx',
783
+ '.mts',
784
+ '.cts',
785
+ '.js',
786
+ '.jsx',
787
+ '.mjs',
788
+ '.cjs',
789
+ ];
790
+ const INDEX_NAMES = [
791
+ 'index.ts',
792
+ 'index.tsx',
793
+ 'index.mts',
794
+ 'index.cts',
795
+ 'index.js',
796
+ 'index.jsx',
797
+ ];
798
+ /**
799
+ * Map a specifier onto a repo-relative file, or null. A bare specifier resolves
800
+ * to null by design: a package is not this repo, so it is out of scope rather
801
+ * than a failure. A *relative* specifier that resolves to null is a hole, and
802
+ * the graph records it as one.
803
+ */
804
+ function candidatesFor(base) {
805
+ const candidates = [];
806
+ const ext = extname(base);
807
+ const emitted = SOURCE_FOR_EMITTED.get(ext);
808
+ if (emitted !== undefined) {
809
+ const stem = base.slice(0, base.length - ext.length);
810
+ for (const swapped of emitted)
811
+ candidates.push(stem + swapped);
812
+ }
813
+ else if (AUTHORED_EXTENSIONS.includes(ext)) {
814
+ candidates.push(base);
815
+ }
816
+ else {
817
+ candidates.push(base);
818
+ for (const suffix of EXTENSIONLESS_CANDIDATES)
819
+ candidates.push(base + suffix);
820
+ }
821
+ for (const index of INDEX_NAMES)
822
+ candidates.push(`${base}/${index}`);
823
+ return candidates;
824
+ }
825
+ export function resolveSpecifier(fromFile, specifier, inScope) {
826
+ if (!specifier.startsWith('.'))
827
+ return null;
828
+ const base = join(dirname(fromFile), specifier);
829
+ for (const candidate of candidatesFor(base)) {
830
+ if (inScope.has(candidate))
831
+ return candidate;
832
+ }
833
+ // Build output stands in for the source that emits it. A module importing
834
+ // `../dist/paths.js` is coupled to `../src/paths.ts` just as tightly as if it
835
+ // had said so: change that signature and this importer breaks. The compiled
836
+ // copy is not in scope — it is generated — so follow the edge to the source.
837
+ if (base.includes(`/${BUILD_DIRECTORY}/`)) {
838
+ const asSource = base.replace(`/${BUILD_DIRECTORY}/`, `/${SOURCE_DIRECTORY}/`);
839
+ for (const candidate of candidatesFor(asSource)) {
840
+ if (inScope.has(candidate))
841
+ return candidate;
842
+ }
843
+ }
844
+ return null;
845
+ }
846
+ // ---------------------------------------------------------------------------
847
+ // The graph
848
+ // ---------------------------------------------------------------------------
849
+ /**
850
+ * Build the graph from an in-memory corpus. Taking sources rather than a
851
+ * directory keeps this pure and testable; `collectSources` is the half that
852
+ * touches disk.
853
+ */
854
+ export function buildSymbolGraph(sources) {
855
+ const inScope = new Set(sources.keys());
856
+ const modules = new Map();
857
+ const dependents = new Map();
858
+ const unanalyzed = [];
859
+ const unresolved = [];
860
+ const seenUnresolved = new Set();
861
+ const ordered = [...sources.keys()].sort();
862
+ for (const file of ordered) {
863
+ const source = sources.get(file);
864
+ if (source === undefined)
865
+ continue;
866
+ const facts = parseModuleFacts(source, file);
867
+ if (facts.unterminated) {
868
+ unanalyzed.push(file);
869
+ continue;
870
+ }
871
+ for (const edge of facts.imports) {
872
+ edge.resolved = resolveSpecifier(file, edge.specifier, inScope);
873
+ if (edge.resolved === null) {
874
+ if (edge.specifier.startsWith('.') &&
875
+ !SYMBOL_FREE_EXTENSIONS.has(extname(edge.specifier))) {
876
+ const key = `${file}\u0000${edge.specifier}`;
877
+ if (!seenUnresolved.has(key)) {
878
+ seenUnresolved.add(key);
879
+ unresolved.push({ from: file, specifier: edge.specifier });
880
+ }
881
+ }
882
+ continue;
883
+ }
884
+ const list = dependents.get(edge.resolved) ?? [];
885
+ list.push({
886
+ from: file,
887
+ names: edge.names,
888
+ typeOnly: edge.typeOnly,
889
+ specifier: edge.specifier,
890
+ dynamic: edge.dynamic,
891
+ });
892
+ dependents.set(edge.resolved, list);
893
+ }
894
+ modules.set(file, facts);
895
+ }
896
+ return { modules, dependents, unanalyzed, unresolved };
897
+ }
898
+ // ---------------------------------------------------------------------------
899
+ // Reading a corpus off disk
900
+ // ---------------------------------------------------------------------------
901
+ export const DEFAULT_SOURCE_EXTENSIONS = [
902
+ '.ts',
903
+ '.tsx',
904
+ '.mts',
905
+ '.cts',
906
+ '.js',
907
+ '.jsx',
908
+ '.mjs',
909
+ '.cjs',
910
+ ];
911
+ /** Directories whose contents are never the repo's own authored source. */
912
+ export const DEFAULT_SKIPPED_DIRECTORIES = [
913
+ '.git',
914
+ 'node_modules',
915
+ 'dist',
916
+ 'build',
917
+ 'coverage',
918
+ 'workspaces',
919
+ 'state',
920
+ '.venv',
921
+ 'venv',
922
+ '__pycache__',
923
+ '.next',
924
+ '.turbo',
925
+ ];
926
+ function readEntries(absolute) {
927
+ try {
928
+ return readdirSync(absolute, { withFileTypes: true });
929
+ }
930
+ catch {
931
+ // A directory that cannot be listed is a directory this graph cannot speak for.
932
+ return [];
933
+ }
934
+ }
935
+ /** Read every authored source under `rootDir`, keyed by repo-relative path. */
936
+ export function collectSources(rootDir, options = {}) {
937
+ const extensions = options.extensions ?? DEFAULT_SOURCE_EXTENSIONS;
938
+ const skipped = new Set(options.skipDirectories ?? DEFAULT_SKIPPED_DIRECTORIES);
939
+ const sources = new Map();
940
+ const walk = (relativeDir) => {
941
+ const absolute = relativeDir === '' ? rootDir : `${rootDir}/${relativeDir}`;
942
+ for (const entry of readEntries(absolute)) {
943
+ const relative = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
944
+ if (entry.isSymbolicLink())
945
+ continue;
946
+ if (entry.isDirectory()) {
947
+ if (skipped.has(entry.name))
948
+ continue;
949
+ walk(relative);
950
+ continue;
951
+ }
952
+ if (!entry.isFile())
953
+ continue;
954
+ if (!extensions.some((suffix) => entry.name.endsWith(suffix)))
955
+ continue;
956
+ try {
957
+ sources.set(relative, readFileSync(`${rootDir}/${relative}`, 'utf8'));
958
+ }
959
+ catch {
960
+ // A file that cannot be read is not a file this graph can speak for.
961
+ }
962
+ }
963
+ };
964
+ walk('');
965
+ return sources;
966
+ }