@llm4ts/flow 0.13.4 → 0.14.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.
package/src/Survey.ts CHANGED
@@ -66,25 +66,59 @@ export const closureFor = (
66
66
  return walk([program], new Set([program]), [])
67
67
  }
68
68
 
69
- const unitName = (path: string): string => {
69
+ /**
70
+ * A source file's unit name: its basename without the extension. The graph
71
+ * keys nodes by it, and `resolveUnit` folds edge targets onto it.
72
+ */
73
+ export const unitName = (path: string): string => {
70
74
  const base = path.split("/").at(-1) ?? path
71
75
  const dot = base.lastIndexOf(".")
72
76
  return dot < 0 ? base : base.slice(0, dot)
73
77
  }
74
78
 
79
+ /**
80
+ * The unit a captured reference points at. COBOL rules capture the bare unit
81
+ * name (`CALL 'FEECALC'`), but web estates reference units by PATH —
82
+ * `<jsp:include page="header.jsp">`, `page="/WEB-INF/fragments/footer.jsp"`
83
+ * — so a raw capture would never equal a node name, every fragment would show
84
+ * zero incoming edges, and the inventory would flag the most-included files
85
+ * in the estate as retire candidates. Unknown references stay as captured:
86
+ * an edge to a unit the estate does not contain is itself a finding.
87
+ */
88
+ export const resolveUnit = (reference: string, known: ReadonlySet<string>): string => {
89
+ if (known.has(reference)) {
90
+ return reference
91
+ }
92
+ const folded = unitName(reference)
93
+ return known.has(folded) ? folded : reference
94
+ }
95
+
75
96
  const matches = (regex: string, contents: string): ReadonlyArray<string> => {
76
97
  const expression = new RegExp(regex, "g")
77
98
  return [...contents.matchAll(expression)].map((match) => match[1] ?? match[0])
78
99
  }
79
100
 
101
+ export interface SurveyGraphOptions {
102
+ /** Regex over repo-relative paths to leave out even when `sources` matches. */
103
+ readonly exclude?: string
104
+ }
105
+
80
106
  export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
81
107
  workspace: WorkspaceShape,
82
108
  sources: string,
83
109
  units: ReadonlyArray<CoverageRule>,
84
- edgeRules: ReadonlyArray<CoverageRule>
110
+ edgeRules: ReadonlyArray<CoverageRule>,
111
+ options: SurveyGraphOptions = {}
85
112
  ): Effect.fn.Return<SurveyGraph, WorkspaceError> {
86
- const sourcePattern = new RegExp(sources)
87
- const paths = (yield* workspace.discover()).filter((path) => sourcePattern.test(path)).sort()
113
+ // The source regex narrows discovery itself, so the workspace's result cap
114
+ // counts candidate units rather than every jar, image, and generated file
115
+ // sharing the tree with them.
116
+ const paths = [
117
+ ...(yield* workspace.discover("**/*", {
118
+ matching: new RegExp(sources),
119
+ ...(options.exclude === undefined ? {} : { excluding: new RegExp(options.exclude) })
120
+ }))
121
+ ].sort()
88
122
  const nodes: Array<SurveyNode> = []
89
123
  const contents = new Map<string, string>()
90
124
  for (const path of paths) {
@@ -101,6 +135,7 @@ export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
101
135
  })
102
136
  )
103
137
  }
138
+ const known = new Set(nodes.map((node) => node.name))
104
139
  const edges: Array<SurveyEdge> = []
105
140
  for (const rule of edgeRules) {
106
141
  const filePattern = new RegExp(rule.files)
@@ -108,7 +143,7 @@ export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
108
143
  for (const target of new Set(matches(rule.unit, contents.get(path) ?? ""))) {
109
144
  const edge = SurveyEdge.make({
110
145
  from: unitName(path),
111
- to: target,
146
+ to: resolveUnit(target, known),
112
147
  kind: rule.name
113
148
  })
114
149
  if (
@@ -179,3 +214,100 @@ export const renderSurveyInventory = (graph: SurveyGraph): string => {
179
214
  ""
180
215
  ].join("\n")
181
216
  }
217
+
218
+ /** The `graph.json` artifact: the graph as it is read back by later phases. */
219
+ export const renderSurveyGraphJson = (graph: SurveyGraph): string =>
220
+ JSON.stringify(graph, undefined, 2)
221
+
222
+ /**
223
+ * What a pack contributes to the survey's two reasoning prompts. The frame
224
+ * around it — the JSON contracts, the evidence rule, the wave discipline — is
225
+ * stack-neutral and lives here; everything that names a technology comes
226
+ * from the pack: the edge rules its graph was built from, and the optional
227
+ * `prompts/survey-refine.md` / `prompts/survey-triage.md` sidecars describing
228
+ * where THAT stack hides the links regexes miss and how to weigh its units.
229
+ */
230
+ export interface SurveyPromptContext {
231
+ /** The pack's `## Survey:` edge rules — the graph's provenance, by name. */
232
+ readonly rules: ReadonlyArray<CoverageRule>
233
+ /** The pack's stack-specific guidance, or undefined for the neutral default. */
234
+ readonly guidance: string | undefined
235
+ }
236
+
237
+ const ruleNames = (context: SurveyPromptContext): string =>
238
+ context.rules.length === 0 ? "none" : context.rules.map((rule) => rule.name).join(", ")
239
+
240
+ const defaultRefineGuidance = [
241
+ "Regexes miss links the source establishes indirectly: invocations whose target is held",
242
+ "in a variable or configuration entry, wiring declared in descriptors instead of code,",
243
+ "fragments pulled in by inclusion or templating, and units only a build or scheduler",
244
+ "step names."
245
+ ].join("\n")
246
+
247
+ const defaultTriageGuidance = [
248
+ "Weigh each unit by what depends on it and what it depends on: shared units many others",
249
+ "reference are migrated early or wrapped; units nothing references are retire candidates",
250
+ "unless an entry point outside the graph (scheduler, external caller, deployment",
251
+ "descriptor) reaches them."
252
+ ].join("\n")
253
+
254
+ export const surveyRefinePrompt = (graph: SurveyGraph, context: SurveyPromptContext): string =>
255
+ [
256
+ "You are refining the dependency graph of a legacy estate. The graph below was built",
257
+ "deterministically — one node per source file (named by its file name without the",
258
+ `extension), one edge per regex match of the pack's survey rules (${ruleNames(context)}).`,
259
+ context.guidance ?? defaultRefineGuidance,
260
+ 'You have read-only access to the estate — read the sources (each node\'s "path" names its',
261
+ "file) and find the dependency edges the regexes missed. Prioritise the suspicious shapes:",
262
+ "units with fewer outgoing edges than the source suggests, units nothing references, units",
263
+ "with degree 0.",
264
+ "",
265
+ "Produce:",
266
+ '- "edges": ONLY links the graph does not already have, and ONLY between the units listed',
267
+ ' below (use the exact unit names). Each edge: "from" (the referencing unit), "to" (the',
268
+ ' referenced unit), "kind" (how the link is made — a short kebab-case label), and',
269
+ ' "evidence" (file, line, and the statement that establishes the link — no evidence, no',
270
+ " edge). References to external systems, platform services, or third-party libraries are",
271
+ ' NOT edges; put them in "notes".',
272
+ '- "notes": references you could not resolve to a unit — indirect targets whose value you',
273
+ " could not trace, external systems. Empty if none.",
274
+ "",
275
+ `Units: ${graph.nodes
276
+ .map((node) => node.name)
277
+ .sort()
278
+ .join(", ")}`,
279
+ "",
280
+ "Graph (JSON):",
281
+ renderSurveyGraphJson(graph)
282
+ ].join("\n")
283
+
284
+ export const surveyTriagePrompt = (
285
+ graph: SurveyGraph,
286
+ inventory: string,
287
+ context: SurveyPromptContext
288
+ ): string =>
289
+ [
290
+ "You are triaging a legacy estate for modernization. Below are its inventory and",
291
+ `dependency graph (regex-derived from the source by the pack's survey rules — ${ruleNames(context)} —`,
292
+ "plus `llm-…` edges the graph-refine step grounded in the source with evidence — trust them).",
293
+ context.guidance ?? defaultTriageGuidance,
294
+ "",
295
+ "Produce:",
296
+ '- "triage": for EVERY unit in the inventory, a disposition:',
297
+ ' - "rewrite": actively used business logic or user-facing behaviour to modernize;',
298
+ ' - "retire": unreferenced/dead — candidate for decommissioning, with the evidence;',
299
+ ' - "wrap": keep on the legacy platform and front with an API (shared units other',
300
+ " estates still call, or units out of this modernization's scope).",
301
+ " Rationale in one sentence, grounded in the graph (degrees, callers, size).",
302
+ '- "waves": dependency-coherent migration slices for the REWRITE units: a wave\'s units',
303
+ " should depend only on already-migrated or same-wave units where possible; leaves and",
304
+ " low-fan-in units first; name each wave (wave-1, wave-2, …) and give the ordering rationale.",
305
+ '- "notes": anything the graph could not resolve — indirect references, cycles worth a',
306
+ " human look. Empty if none.",
307
+ "",
308
+ "Inventory:",
309
+ inventory,
310
+ "",
311
+ "Graph (JSON):",
312
+ renderSurveyGraphJson(graph)
313
+ ].join("\n")
package/src/Workspace.ts CHANGED
@@ -17,13 +17,34 @@ export interface WorkspaceLimits {
17
17
  readonly maxWriteBytes: number
18
18
  readonly maxResults: number
19
19
  readonly maxDepth: number
20
+ /**
21
+ * Directory names discovery never descends into, at any depth. Version
22
+ * control internals and dependency/build output are never estate sources,
23
+ * yet on a real repository they hold the overwhelming majority of files —
24
+ * walking them is slow and used to spend the result cap before the first
25
+ * source was seen. Absent means no pruning (the previous behaviour).
26
+ */
27
+ readonly excludeDirs?: ReadonlyArray<string>
20
28
  }
21
29
 
30
+ /** The directories `defaultWorkspaceLimits` prunes from discovery. */
31
+ export const defaultExcludedDirectories: ReadonlyArray<string> = Object.freeze([
32
+ ".git",
33
+ ".hg",
34
+ ".svn",
35
+ "node_modules",
36
+ "target",
37
+ "build",
38
+ "dist",
39
+ "out"
40
+ ])
41
+
22
42
  export const defaultWorkspaceLimits: WorkspaceLimits = Object.freeze({
23
43
  maxReadBytes: 1_048_576,
24
44
  maxWriteBytes: 1_048_576,
25
45
  maxResults: 1_000,
26
- maxDepth: 32
46
+ maxDepth: 32,
47
+ excludeDirs: defaultExcludedDirectories
27
48
  })
28
49
 
29
50
  /**
@@ -32,30 +53,105 @@ export const defaultWorkspaceLimits: WorkspaceLimits = Object.freeze({
32
53
  * and generated exports, so the default 1 MiB read cap — sized for
33
54
  * spec-and-plan repositories — would fail an inventory on its first big
34
55
  * file. 8 MiB accommodates real estates while still refusing runaway blobs.
56
+ * The same reasoning sizes the discovery cap: an estate is by nature large,
57
+ * and 1 000 results — sized for spec-and-plan repositories — is fewer files
58
+ * than a mid-sized J2EE application ships in `src/` alone.
35
59
  */
36
60
  export const legacySourceWorkspaceLimits: WorkspaceLimits = Object.freeze({
37
61
  ...defaultWorkspaceLimits,
38
- maxReadBytes: 8_388_608
62
+ maxReadBytes: 8_388_608,
63
+ maxResults: 20_000
39
64
  })
40
65
 
66
+ const positiveInteger = (raw: string | undefined): number | undefined => {
67
+ const text = raw?.trim()
68
+ if (text === undefined || text.length === 0 || !/^\d+$/.test(text)) {
69
+ return undefined
70
+ }
71
+ const parsed = Number.parseInt(text, 10)
72
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined
73
+ }
74
+
75
+ const directoryList = (raw: string | undefined): ReadonlyArray<string> | undefined => {
76
+ const text = raw?.trim()
77
+ if (text === undefined || text.length === 0) {
78
+ return undefined
79
+ }
80
+ const names = text
81
+ .split(",")
82
+ .map((name) => name.trim())
83
+ .filter((name) => name.length > 0 && !name.includes("/") && !name.includes("\\"))
84
+ return names.length === 0 ? undefined : names
85
+ }
86
+
41
87
  /**
42
- * `defaults` with the per-file read cap overridden by `LLM4TS_MAX_READ_BYTES`
43
- * when it holds a positive integer; anything else leaves the defaults
44
- * untouched. The escape hatch for estates whose sources exceed even the
45
- * legacy-source cap.
88
+ * `defaults` with the caps overridden from the environment when the values
89
+ * are well-formed; anything else leaves that default untouched:
90
+ *
91
+ * - `LLM4TS_MAX_READ_BYTES` (positive integer): the per-file read cap, the
92
+ * escape hatch for estates whose sources exceed even the legacy-source cap.
93
+ * - `LLM4TS_MAX_DISCOVER_RESULTS` (positive integer): the discovery result
94
+ * cap, for estates larger than `legacySourceWorkspaceLimits` allows for.
95
+ * - `LLM4TS_EXCLUDE_DIRS` (comma-separated directory names): replaces the
96
+ * pruned-directory list — `.git,node_modules,generated` — for estates
97
+ * whose vendored or generated trees sit under names the default list
98
+ * does not know.
46
99
  */
47
100
  export const workspaceLimitsFromEnv = (
48
101
  environment: Readonly<Record<string, string | undefined>>,
49
102
  defaults: WorkspaceLimits = defaultWorkspaceLimits
50
103
  ): WorkspaceLimits => {
51
- const raw = environment.LLM4TS_MAX_READ_BYTES?.trim()
52
- if (raw === undefined || raw.length === 0 || !/^\d+$/.test(raw)) {
104
+ const maxReadBytes = positiveInteger(environment.LLM4TS_MAX_READ_BYTES)
105
+ const maxResults = positiveInteger(environment.LLM4TS_MAX_DISCOVER_RESULTS)
106
+ const excludeDirs = directoryList(environment.LLM4TS_EXCLUDE_DIRS)
107
+ if (maxReadBytes === undefined && maxResults === undefined && excludeDirs === undefined) {
53
108
  return defaults
54
109
  }
55
- const parsed = Number.parseInt(raw, 10)
56
- return Number.isSafeInteger(parsed) && parsed > 0
57
- ? { ...defaults, maxReadBytes: parsed }
58
- : defaults
110
+ return {
111
+ ...defaults,
112
+ ...(maxReadBytes === undefined ? {} : { maxReadBytes }),
113
+ ...(maxResults === undefined ? {} : { maxResults }),
114
+ ...(excludeDirs === undefined ? {} : { excludeDirs })
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Narrows a discovery beyond its glob: only paths `matching` the first regex
120
+ * and not `excluding` the second are returned AND counted against
121
+ * `maxResults`, so a cap sized for source units is not spent on the jars,
122
+ * images, and generated files that share the tree. Pass regexes without the
123
+ * `g` flag — a global regex carries `lastIndex` state across `test` calls.
124
+ */
125
+ export interface DiscoverOptions {
126
+ readonly matching?: RegExp
127
+ readonly excluding?: RegExp
128
+ }
129
+
130
+ export const discoverAccepts = (path: string, options: DiscoverOptions): boolean =>
131
+ (options.matching?.test(path) ?? true) && !(options.excluding?.test(path) ?? false)
132
+
133
+ /** Whether a repo-relative path lies under a pruned directory. */
134
+ export const isExcludedPath = (path: string, limits: WorkspaceLimits): boolean => {
135
+ const excluded = limits.excludeDirs
136
+ if (excluded === undefined || excluded.length === 0) {
137
+ return false
138
+ }
139
+ const directories = path.split("/").slice(0, -1)
140
+ return directories.some((segment) => excluded.includes(segment))
141
+ }
142
+
143
+ /**
144
+ * The advice a flow gives when discovery overflows `limits.maxResults`: the
145
+ * three knobs that narrow or raise it, in the order a user should try them.
146
+ */
147
+ export const discoveryOverflowAdvice = (limits: WorkspaceLimits): string => {
148
+ const pruned = limits.excludeDirs ?? []
149
+ return (
150
+ `discovery stopped at ${limits.maxResults} matching files; ` +
151
+ "narrow the pack's `sources:` regex or add an `exclude:` regex, prune more directories " +
152
+ `with LLM4TS_EXCLUDE_DIRS=<names> (pruned now: ${pruned.length === 0 ? "none" : pruned.join(",")}), ` +
153
+ "or raise the cap with LLM4TS_MAX_DISCOVER_RESULTS=<count>"
154
+ )
59
155
  }
60
156
 
61
157
  export interface WorkspaceShape {
@@ -64,7 +160,10 @@ export interface WorkspaceShape {
64
160
  readonly read: (path: string) => Effect.Effect<string, WorkspaceError>
65
161
  readonly write: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
66
162
  readonly append: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
67
- readonly discover: (pattern?: string) => Effect.Effect<ReadonlyArray<string>, WorkspaceError>
163
+ readonly discover: (
164
+ pattern?: string,
165
+ options?: DiscoverOptions
166
+ ) => Effect.Effect<ReadonlyArray<string>, WorkspaceError>
68
167
  readonly search: (
69
168
  query: string,
70
169
  pattern?: string
@@ -210,13 +309,19 @@ export const makeMemoryWorkspace = (
210
309
  )
211
310
  }
212
311
 
213
- const discover = (pattern = "**/*"): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
312
+ const discover = (
313
+ pattern = "**/*",
314
+ options: DiscoverOptions = {}
315
+ ): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
214
316
  Effect.gen(function* () {
215
317
  const normalizedPattern = pattern.replaceAll("\\", "/")
216
318
  const matcher = normalizedPattern === "**/*" ? /.*/ : globRegex(normalizedPattern)
217
319
  const files = yield* Ref.get(state)
218
320
  const results: Array<string> = []
219
321
  for (const path of Object.keys(files).sort(comparePaths)) {
322
+ if (isExcludedPath(path, limits)) {
323
+ continue
324
+ }
220
325
  const depth = path.split("/").length - 1
221
326
  if (depth > limits.maxDepth) {
222
327
  return yield* WorkspaceLimitError.make({
@@ -225,7 +330,7 @@ export const makeMemoryWorkspace = (
225
330
  actual: depth
226
331
  })
227
332
  }
228
- if (matcher.test(path)) {
333
+ if (matcher.test(path) && discoverAccepts(path, options)) {
229
334
  results.push(path)
230
335
  if (results.length > limits.maxResults) {
231
336
  return yield* WorkspaceLimitError.make({