@pruddiman/hem 0.0.1-beta-5671db0
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/LICENSE +21 -0
- package/dist/agents/arbiter-agent.d.ts +72 -0
- package/dist/agents/arbiter-agent.js +149 -0
- package/dist/agents/architecture-agent.d.ts +148 -0
- package/dist/agents/architecture-agent.js +459 -0
- package/dist/agents/base-agent.d.ts +44 -0
- package/dist/agents/base-agent.js +57 -0
- package/dist/agents/crossref-agent.d.ts +140 -0
- package/dist/agents/crossref-agent.js +560 -0
- package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
- package/dist/agents/crossref-arbiter-agent.js +147 -0
- package/dist/agents/documentation-agent.d.ts +55 -0
- package/dist/agents/documentation-agent.js +159 -0
- package/dist/agents/exploration-agent.d.ts +58 -0
- package/dist/agents/exploration-agent.js +102 -0
- package/dist/agents/grouping-agent.d.ts +167 -0
- package/dist/agents/grouping-agent.js +557 -0
- package/dist/agents/index-agent.d.ts +86 -0
- package/dist/agents/index-agent.js +360 -0
- package/dist/agents/organization-agent.d.ts +144 -0
- package/dist/agents/organization-agent.js +607 -0
- package/dist/auth.d.ts +372 -0
- package/dist/auth.js +1072 -0
- package/dist/broadcast-mcp.d.ts +21 -0
- package/dist/broadcast-mcp.js +59 -0
- package/dist/changelog.d.ts +85 -0
- package/dist/changelog.js +223 -0
- package/dist/decision-queue.d.ts +173 -0
- package/dist/decision-queue.js +265 -0
- package/dist/diff-scope.d.ts +24 -0
- package/dist/diff-scope.js +28 -0
- package/dist/discovery.d.ts +54 -0
- package/dist/discovery.js +405 -0
- package/dist/grouping.d.ts +37 -0
- package/dist/grouping.js +343 -0
- package/dist/helpers/format.d.ts +5 -0
- package/dist/helpers/format.js +13 -0
- package/dist/helpers/index.d.ts +11 -0
- package/dist/helpers/index.js +11 -0
- package/dist/helpers/parsing.d.ts +52 -0
- package/dist/helpers/parsing.js +128 -0
- package/dist/helpers/paths.d.ts +41 -0
- package/dist/helpers/paths.js +67 -0
- package/dist/helpers/strings.d.ts +45 -0
- package/dist/helpers/strings.js +97 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +1087 -0
- package/dist/merge-utils.d.ts +22 -0
- package/dist/merge-utils.js +34 -0
- package/dist/orchestrator.d.ts +194 -0
- package/dist/orchestrator.js +1169 -0
- package/dist/output.d.ts +106 -0
- package/dist/output.js +243 -0
- package/dist/progress.d.ts +228 -0
- package/dist/progress.js +644 -0
- package/dist/providers/copilot.d.ts +247 -0
- package/dist/providers/copilot.js +598 -0
- package/dist/providers/index.d.ts +15 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/opencode.d.ts +156 -0
- package/dist/providers/opencode.js +416 -0
- package/dist/providers/types.d.ts +156 -0
- package/dist/providers/types.js +16 -0
- package/dist/resources.d.ts +76 -0
- package/dist/resources.js +151 -0
- package/dist/search-index.d.ts +71 -0
- package/dist/search-index.js +187 -0
- package/dist/search-mcp.d.ts +25 -0
- package/dist/search-mcp.js +100 -0
- package/dist/server-utils.d.ts +56 -0
- package/dist/server-utils.js +135 -0
- package/dist/session.d.ts +227 -0
- package/dist/session.js +370 -0
- package/dist/types.d.ts +272 -0
- package/dist/types.js +5 -0
- package/dist/worktree.d.ts +82 -0
- package/dist/worktree.js +187 -0
- package/package.json +45 -0
package/dist/grouping.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File grouping module for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Analyses discovered source files and groups them by feature vertical
|
|
5
|
+
* (e.g., "user", "order") or architectural layer (e.g., controllers,
|
|
6
|
+
* services). Each file appears in at most one group; feature grouping
|
|
7
|
+
* takes priority over layer grouping.
|
|
8
|
+
*
|
|
9
|
+
* Reference: FR-003, data-model.md lines 93-108.
|
|
10
|
+
*/
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { toKebabCase } from "./helpers/strings.js";
|
|
13
|
+
// ── Layer detection ─────────────────────────────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Maps well-known file name suffixes to their architectural layer label.
|
|
16
|
+
* Order matters: first match wins.
|
|
17
|
+
*/
|
|
18
|
+
const LAYER_SUFFIXES = [
|
|
19
|
+
{ suffix: ".controller", label: "Controllers" },
|
|
20
|
+
{ suffix: ".controllers", label: "Controllers" },
|
|
21
|
+
{ suffix: ".service", label: "Services" },
|
|
22
|
+
{ suffix: ".services", label: "Services" },
|
|
23
|
+
{ suffix: ".repository", label: "Repositories" },
|
|
24
|
+
{ suffix: ".repositories", label: "Repositories" },
|
|
25
|
+
{ suffix: ".repo", label: "Repositories" },
|
|
26
|
+
{ suffix: ".model", label: "Models" },
|
|
27
|
+
{ suffix: ".models", label: "Models" },
|
|
28
|
+
{ suffix: ".entity", label: "Models" },
|
|
29
|
+
{ suffix: ".middleware", label: "Middleware" },
|
|
30
|
+
{ suffix: ".guard", label: "Guards" },
|
|
31
|
+
{ suffix: ".pipe", label: "Pipes" },
|
|
32
|
+
{ suffix: ".interceptor", label: "Interceptors" },
|
|
33
|
+
{ suffix: ".filter", label: "Filters" },
|
|
34
|
+
{ suffix: ".decorator", label: "Decorators" },
|
|
35
|
+
{ suffix: ".dto", label: "DTOs" },
|
|
36
|
+
{ suffix: ".schema", label: "Schemas" },
|
|
37
|
+
{ suffix: ".migration", label: "Migrations" },
|
|
38
|
+
{ suffix: ".seed", label: "Seeds" },
|
|
39
|
+
{ suffix: ".test", label: "Tests" },
|
|
40
|
+
{ suffix: ".spec", label: "Tests" },
|
|
41
|
+
{ suffix: ".util", label: "Utilities" },
|
|
42
|
+
{ suffix: ".utils", label: "Utilities" },
|
|
43
|
+
{ suffix: ".helper", label: "Utilities" },
|
|
44
|
+
{ suffix: ".helpers", label: "Utilities" },
|
|
45
|
+
{ suffix: ".config", label: "Configuration" },
|
|
46
|
+
{ suffix: ".route", label: "Routes" },
|
|
47
|
+
{ suffix: ".routes", label: "Routes" },
|
|
48
|
+
{ suffix: ".router", label: "Routes" },
|
|
49
|
+
{ suffix: ".component", label: "Components" },
|
|
50
|
+
{ suffix: ".hook", label: "Hooks" },
|
|
51
|
+
{ suffix: ".context", label: "Contexts" },
|
|
52
|
+
{ suffix: ".provider", label: "Providers" },
|
|
53
|
+
{ suffix: ".resolver", label: "Resolvers" },
|
|
54
|
+
{ suffix: ".module", label: "Modules" },
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Well-known directory names that indicate an architectural layer.
|
|
58
|
+
*/
|
|
59
|
+
const LAYER_DIRECTORIES = new Map([
|
|
60
|
+
["controllers", "Controllers"],
|
|
61
|
+
["controller", "Controllers"],
|
|
62
|
+
["services", "Services"],
|
|
63
|
+
["service", "Services"],
|
|
64
|
+
["repositories", "Repositories"],
|
|
65
|
+
["repository", "Repositories"],
|
|
66
|
+
["repos", "Repositories"],
|
|
67
|
+
["models", "Models"],
|
|
68
|
+
["model", "Models"],
|
|
69
|
+
["entities", "Models"],
|
|
70
|
+
["entity", "Models"],
|
|
71
|
+
["middleware", "Middleware"],
|
|
72
|
+
["middlewares", "Middleware"],
|
|
73
|
+
["guards", "Guards"],
|
|
74
|
+
["pipes", "Pipes"],
|
|
75
|
+
["interceptors", "Interceptors"],
|
|
76
|
+
["filters", "Filters"],
|
|
77
|
+
["decorators", "Decorators"],
|
|
78
|
+
["dtos", "DTOs"],
|
|
79
|
+
["dto", "DTOs"],
|
|
80
|
+
["schemas", "Schemas"],
|
|
81
|
+
["migrations", "Migrations"],
|
|
82
|
+
["seeds", "Seeds"],
|
|
83
|
+
["tests", "Tests"],
|
|
84
|
+
["test", "Tests"],
|
|
85
|
+
["__tests__", "Tests"],
|
|
86
|
+
["spec", "Tests"],
|
|
87
|
+
["utils", "Utilities"],
|
|
88
|
+
["util", "Utilities"],
|
|
89
|
+
["helpers", "Utilities"],
|
|
90
|
+
["lib", "Utilities"],
|
|
91
|
+
["config", "Configuration"],
|
|
92
|
+
["configs", "Configuration"],
|
|
93
|
+
["routes", "Routes"],
|
|
94
|
+
["router", "Routes"],
|
|
95
|
+
["routers", "Routes"],
|
|
96
|
+
["components", "Components"],
|
|
97
|
+
["hooks", "Hooks"],
|
|
98
|
+
["contexts", "Contexts"],
|
|
99
|
+
["providers", "Providers"],
|
|
100
|
+
["resolvers", "Resolvers"],
|
|
101
|
+
["modules", "Modules"],
|
|
102
|
+
]);
|
|
103
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
104
|
+
/**
|
|
105
|
+
* Finds the common parent directory for a set of file paths.
|
|
106
|
+
*
|
|
107
|
+
* @param files - Array of FileInfo objects.
|
|
108
|
+
* @returns The common parent directory (relative path), or `"."` for root.
|
|
109
|
+
*/
|
|
110
|
+
export function commonDirectory(files) {
|
|
111
|
+
if (files.length === 0)
|
|
112
|
+
return ".";
|
|
113
|
+
const first = files[0];
|
|
114
|
+
if (files.length === 1)
|
|
115
|
+
return dirname(first.path);
|
|
116
|
+
const dirs = files.map((f) => dirname(f.path).split("/"));
|
|
117
|
+
const minLen = Math.min(...dirs.map((d) => d.length));
|
|
118
|
+
const common = [];
|
|
119
|
+
const head = dirs[0];
|
|
120
|
+
for (let i = 0; i < minLen; i++) {
|
|
121
|
+
const segment = head[i];
|
|
122
|
+
if (dirs.every((d) => d[i] === segment)) {
|
|
123
|
+
common.push(segment);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return common.length === 0 ? "." : common.join("/");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Extracts the file stem (name without the final extension).
|
|
133
|
+
* For multi-part suffixes like `user.controller.ts`, the stem is `user`.
|
|
134
|
+
*/
|
|
135
|
+
function fileStem(relativePath) {
|
|
136
|
+
const base = relativePath.split("/").pop() ?? "";
|
|
137
|
+
const dotIndex = base.indexOf(".");
|
|
138
|
+
return dotIndex === -1 ? base : base.substring(0, dotIndex);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Returns the "name" portion of the file without known layer suffixes
|
|
142
|
+
* and without the actual file extension.
|
|
143
|
+
* E.g. `user.controller.ts` → the layer suffix is `.controller` → stem `user`.
|
|
144
|
+
* `helpers.ts` → no layer suffix → stem `helpers`.
|
|
145
|
+
*/
|
|
146
|
+
function fileNameWithoutExtension(relativePath) {
|
|
147
|
+
const base = relativePath.split("/").pop() ?? "";
|
|
148
|
+
// Remove the last extension (.ts, .js, etc.)
|
|
149
|
+
const lastDot = base.lastIndexOf(".");
|
|
150
|
+
return lastDot === -1 ? base : base.substring(0, lastDot);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Detects the architectural layer for a file by checking its name
|
|
154
|
+
* against known suffixes.
|
|
155
|
+
*
|
|
156
|
+
* @returns The layer label (e.g., "Controllers") or `undefined`.
|
|
157
|
+
*/
|
|
158
|
+
function detectLayer(relativePath) {
|
|
159
|
+
const nameNoExt = fileNameWithoutExtension(relativePath).toLowerCase();
|
|
160
|
+
for (const { suffix, label } of LAYER_SUFFIXES) {
|
|
161
|
+
if (nameNoExt.endsWith(suffix)) {
|
|
162
|
+
return label;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Detects the architectural layer for a file by checking its containing
|
|
169
|
+
* directory name against known layer directories.
|
|
170
|
+
*
|
|
171
|
+
* @returns The layer label or `undefined`.
|
|
172
|
+
*/
|
|
173
|
+
function detectLayerByDirectory(relativePath) {
|
|
174
|
+
const segments = dirname(relativePath).split("/");
|
|
175
|
+
// Check from innermost to outermost directory
|
|
176
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
177
|
+
const segment = segments[i];
|
|
178
|
+
if (!segment)
|
|
179
|
+
continue;
|
|
180
|
+
const label = LAYER_DIRECTORIES.get(segment.toLowerCase());
|
|
181
|
+
if (label)
|
|
182
|
+
return label;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Extracts a feature name from a file's path by looking at directory
|
|
188
|
+
* segments. Returns the most specific (deepest) non-layer directory
|
|
189
|
+
* segment, or `undefined` if the file is at root level or only in
|
|
190
|
+
* layer directories.
|
|
191
|
+
*/
|
|
192
|
+
function extractFeatureName(relativePath) {
|
|
193
|
+
const dir = dirname(relativePath);
|
|
194
|
+
if (dir === ".")
|
|
195
|
+
return undefined;
|
|
196
|
+
const segments = dir.split("/");
|
|
197
|
+
// Walk from deepest to shallowest, find first segment that is NOT
|
|
198
|
+
// a known layer directory
|
|
199
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
200
|
+
const original = segments[i];
|
|
201
|
+
if (!original)
|
|
202
|
+
continue;
|
|
203
|
+
if (!LAYER_DIRECTORIES.has(original.toLowerCase())) {
|
|
204
|
+
return original; // preserve original casing
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Capitalises the first letter and replaces hyphens/underscores with
|
|
211
|
+
* spaces for display labels. E.g., `user-auth` → `User Auth`.
|
|
212
|
+
*/
|
|
213
|
+
function toDisplayLabel(name) {
|
|
214
|
+
return name
|
|
215
|
+
.replace(/[-_]/g, " ")
|
|
216
|
+
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
217
|
+
}
|
|
218
|
+
// ── Main ────────────────────────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* Groups discovered files by feature vertical or architectural layer.
|
|
221
|
+
*
|
|
222
|
+
* Grouping strategy:
|
|
223
|
+
* 1. Filter out binary files.
|
|
224
|
+
* 2. Attempt to assign each file to a **feature vertical** group
|
|
225
|
+
* based on its directory structure (e.g., files under `user/` →
|
|
226
|
+
* "User" feature group).
|
|
227
|
+
* 3. Files not assigned to a feature group are checked for
|
|
228
|
+
* **architectural layer** membership based on file name suffixes
|
|
229
|
+
* (e.g., `.controller.ts` → "Controllers" layer) or containing
|
|
230
|
+
* directory (e.g., `services/` → "Services").
|
|
231
|
+
* 4. Remaining files go into a catch-all "Other" group.
|
|
232
|
+
* 5. Each file appears in at most one group.
|
|
233
|
+
*
|
|
234
|
+
* @param files - Discovered files (may include binary files).
|
|
235
|
+
* @returns Array of `FileGroup` objects.
|
|
236
|
+
*/
|
|
237
|
+
export function groupFiles(files) {
|
|
238
|
+
// Step 1: filter to non-binary files only
|
|
239
|
+
const textFiles = files.filter((f) => !f.isBinary);
|
|
240
|
+
if (textFiles.length === 0)
|
|
241
|
+
return [];
|
|
242
|
+
// Step 2: Build feature and layer buckets
|
|
243
|
+
const featureBuckets = new Map();
|
|
244
|
+
const layerBuckets = new Map();
|
|
245
|
+
const ungrouped = [];
|
|
246
|
+
const assigned = new Set(); // track by relative path
|
|
247
|
+
// First pass: try to assign every file to a feature vertical
|
|
248
|
+
for (const file of textFiles) {
|
|
249
|
+
const feature = extractFeatureName(file.path);
|
|
250
|
+
if (feature) {
|
|
251
|
+
const key = feature.toLowerCase();
|
|
252
|
+
if (!featureBuckets.has(key)) {
|
|
253
|
+
featureBuckets.set(key, []);
|
|
254
|
+
}
|
|
255
|
+
featureBuckets.get(key).push(file);
|
|
256
|
+
assigned.add(file.path);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Promote single-file features back to unassigned — features with
|
|
260
|
+
// only one file aren't meaningful groups by themselves. They'll get
|
|
261
|
+
// a chance to be grouped by layer instead.
|
|
262
|
+
for (const [key, bucket] of featureBuckets) {
|
|
263
|
+
if (bucket.length < 2) {
|
|
264
|
+
for (const file of bucket) {
|
|
265
|
+
assigned.delete(file.path);
|
|
266
|
+
}
|
|
267
|
+
featureBuckets.delete(key);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Second pass: unassigned files → try layer grouping
|
|
271
|
+
for (const file of textFiles) {
|
|
272
|
+
if (assigned.has(file.path))
|
|
273
|
+
continue;
|
|
274
|
+
const layer = detectLayer(file.path) ?? detectLayerByDirectory(file.path);
|
|
275
|
+
if (layer) {
|
|
276
|
+
if (!layerBuckets.has(layer)) {
|
|
277
|
+
layerBuckets.set(layer, []);
|
|
278
|
+
}
|
|
279
|
+
layerBuckets.get(layer).push(file);
|
|
280
|
+
assigned.add(file.path);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Promote single-file layers back to ungrouped
|
|
284
|
+
for (const [key, bucket] of layerBuckets) {
|
|
285
|
+
if (bucket.length < 2) {
|
|
286
|
+
for (const file of bucket) {
|
|
287
|
+
assigned.delete(file.path);
|
|
288
|
+
}
|
|
289
|
+
layerBuckets.delete(key);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Third pass: remaining → "Other"
|
|
293
|
+
for (const file of textFiles) {
|
|
294
|
+
if (!assigned.has(file.path)) {
|
|
295
|
+
ungrouped.push(file);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Step 3: Convert buckets to FileGroup objects
|
|
299
|
+
const groups = [];
|
|
300
|
+
// Feature (vertical) groups
|
|
301
|
+
for (const [key, bucket] of featureBuckets) {
|
|
302
|
+
const displayLabel = toDisplayLabel(key);
|
|
303
|
+
groups.push({
|
|
304
|
+
id: toKebabCase(displayLabel) + "-feature",
|
|
305
|
+
label: displayLabel,
|
|
306
|
+
type: "vertical",
|
|
307
|
+
files: bucket.sort((a, b) => a.path.localeCompare(b.path)),
|
|
308
|
+
directory: commonDirectory(bucket),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// Layer (horizontal) groups
|
|
312
|
+
for (const [label, bucket] of layerBuckets) {
|
|
313
|
+
groups.push({
|
|
314
|
+
id: toKebabCase(label) + "-layer",
|
|
315
|
+
label,
|
|
316
|
+
type: "horizontal",
|
|
317
|
+
files: bucket.sort((a, b) => a.path.localeCompare(b.path)),
|
|
318
|
+
directory: commonDirectory(bucket),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Catch-all "Other" group
|
|
322
|
+
if (ungrouped.length > 0) {
|
|
323
|
+
groups.push({
|
|
324
|
+
id: "other",
|
|
325
|
+
label: "Other",
|
|
326
|
+
type: "vertical",
|
|
327
|
+
files: ungrouped.sort((a, b) => a.path.localeCompare(b.path)),
|
|
328
|
+
directory: commonDirectory(ungrouped),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
// Sort groups: verticals first, then horizontals, alphabetically within
|
|
332
|
+
groups.sort((a, b) => {
|
|
333
|
+
if (a.type !== b.type)
|
|
334
|
+
return a.type === "vertical" ? -1 : 1;
|
|
335
|
+
// "Other" always last within its type
|
|
336
|
+
if (a.id === "other")
|
|
337
|
+
return 1;
|
|
338
|
+
if (b.id === "other")
|
|
339
|
+
return -1;
|
|
340
|
+
return a.label.localeCompare(b.label);
|
|
341
|
+
});
|
|
342
|
+
return groups;
|
|
343
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format elapsed time as human-readable string.
|
|
3
|
+
* @internal
|
|
4
|
+
*/
|
|
5
|
+
export function formatElapsed(startMs, endMs) {
|
|
6
|
+
const totalSeconds = Math.round((endMs - startMs) / 1000);
|
|
7
|
+
if (totalSeconds < 60) {
|
|
8
|
+
return `${totalSeconds}s`;
|
|
9
|
+
}
|
|
10
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
11
|
+
const seconds = totalSeconds % 60;
|
|
12
|
+
return `${minutes}m ${seconds}s`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel file for src/helpers — re-exports all public symbols from
|
|
3
|
+
* the helper modules for convenient single-point imports.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { toKebabCase, formatElapsed } from "./helpers/index.js";
|
|
7
|
+
*/
|
|
8
|
+
export * from "./strings.js";
|
|
9
|
+
export * from "./paths.js";
|
|
10
|
+
export * from "./parsing.js";
|
|
11
|
+
export * from "./format.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel file for src/helpers — re-exports all public symbols from
|
|
3
|
+
* the helper modules for convenient single-point imports.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { toKebabCase, formatElapsed } from "./helpers/index.js";
|
|
7
|
+
*/
|
|
8
|
+
export * from "./strings.js";
|
|
9
|
+
export * from "./paths.js";
|
|
10
|
+
export * from "./parsing.js";
|
|
11
|
+
export * from "./format.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text extraction and parsing utilities for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for extracting structured content (Markdown, JSON,
|
|
5
|
+
* file lists, concepts) from LLM response text.
|
|
6
|
+
*/
|
|
7
|
+
import type { MessagePart } from "../session.js";
|
|
8
|
+
/**
|
|
9
|
+
* Extracts the Markdown content from the OpenCode prompt response.
|
|
10
|
+
*
|
|
11
|
+
* Concatenates all text parts from the response, stripping any wrapping
|
|
12
|
+
* code fences if the LLM returned the content inside a fenced block.
|
|
13
|
+
*
|
|
14
|
+
* @param parts - The response parts from `promptAndWait()`.
|
|
15
|
+
* @returns The extracted Markdown string.
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractMarkdown(parts: Array<MessagePart>): string;
|
|
18
|
+
/**
|
|
19
|
+
* Extracts a JSON string from an LLM response that may contain preamble text.
|
|
20
|
+
*
|
|
21
|
+
* LLMs often prepend commentary (e.g., "Now I have all the information I need.")
|
|
22
|
+
* before the actual JSON payload. This function tries three strategies in order:
|
|
23
|
+
*
|
|
24
|
+
* 1. Fenced code block — extracts content from `` ```json ... ``` `` fences.
|
|
25
|
+
* 2. First brace / bracket — locates the first `{` or `[` and the last matching
|
|
26
|
+
* `}` or `]` to isolate the JSON object/array from surrounding text.
|
|
27
|
+
* 3. Entire string — falls back to returning the full input (for responses that
|
|
28
|
+
* are already pure JSON).
|
|
29
|
+
*
|
|
30
|
+
* @param response - The raw LLM response string.
|
|
31
|
+
* @returns The extracted JSON substring (not yet parsed).
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractJSON(response: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Extracts `relatedFiles` from generated Markdown content.
|
|
36
|
+
*
|
|
37
|
+
* Looks for the "Source Files" or "Related Files" section and extracts file
|
|
38
|
+
* paths from backtick-quoted entries (e.g., `` `src/user/controller.ts` ``).
|
|
39
|
+
*
|
|
40
|
+
* @param content - The generated Markdown content.
|
|
41
|
+
* @returns Array of relative file paths found in the Source Files section.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseRelatedFiles(content: string): string[];
|
|
44
|
+
/**
|
|
45
|
+
* Extracts key concept names from section content.
|
|
46
|
+
*
|
|
47
|
+
* Looks for backtick-quoted identifiers as a heuristic for concepts.
|
|
48
|
+
*
|
|
49
|
+
* @param content - The section body text.
|
|
50
|
+
* @returns Array of unique concept strings.
|
|
51
|
+
*/
|
|
52
|
+
export declare function extractConcepts(content: string): string[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text extraction and parsing utilities for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for extracting structured content (Markdown, JSON,
|
|
5
|
+
* file lists, concepts) from LLM response text.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Extracts the Markdown content from the OpenCode prompt response.
|
|
9
|
+
*
|
|
10
|
+
* Concatenates all text parts from the response, stripping any wrapping
|
|
11
|
+
* code fences if the LLM returned the content inside a fenced block.
|
|
12
|
+
*
|
|
13
|
+
* @param parts - The response parts from `promptAndWait()`.
|
|
14
|
+
* @returns The extracted Markdown string.
|
|
15
|
+
*/
|
|
16
|
+
export function extractMarkdown(parts) {
|
|
17
|
+
const textParts = parts.filter((p) => p.type === "text" && typeof p.text === "string");
|
|
18
|
+
let content = textParts.map((p) => p.text).join("\n");
|
|
19
|
+
// Strip wrapping ```markdown ... ``` fences if present
|
|
20
|
+
const fencePattern = /^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/;
|
|
21
|
+
const match = content.match(fencePattern);
|
|
22
|
+
if (match) {
|
|
23
|
+
content = match[1];
|
|
24
|
+
}
|
|
25
|
+
// Strip preamble text before the first Markdown heading.
|
|
26
|
+
// LLMs sometimes produce commentary like "Now I have a thorough
|
|
27
|
+
// understanding of the codebase..." before the actual document.
|
|
28
|
+
const h1Match = content.search(/^# .+/m);
|
|
29
|
+
if (h1Match > 0) {
|
|
30
|
+
content = content.slice(h1Match);
|
|
31
|
+
}
|
|
32
|
+
return content.trim();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Extracts a JSON string from an LLM response that may contain preamble text.
|
|
36
|
+
*
|
|
37
|
+
* LLMs often prepend commentary (e.g., "Now I have all the information I need.")
|
|
38
|
+
* before the actual JSON payload. This function tries three strategies in order:
|
|
39
|
+
*
|
|
40
|
+
* 1. Fenced code block — extracts content from `` ```json ... ``` `` fences.
|
|
41
|
+
* 2. First brace / bracket — locates the first `{` or `[` and the last matching
|
|
42
|
+
* `}` or `]` to isolate the JSON object/array from surrounding text.
|
|
43
|
+
* 3. Entire string — falls back to returning the full input (for responses that
|
|
44
|
+
* are already pure JSON).
|
|
45
|
+
*
|
|
46
|
+
* @param response - The raw LLM response string.
|
|
47
|
+
* @returns The extracted JSON substring (not yet parsed).
|
|
48
|
+
*/
|
|
49
|
+
export function extractJSON(response) {
|
|
50
|
+
// Strategy 1: fenced ```json block
|
|
51
|
+
const fencePattern = /```(?:json)?\s*\n([\s\S]*?)\n```/;
|
|
52
|
+
const fenceMatch = response.match(fencePattern);
|
|
53
|
+
if (fenceMatch) {
|
|
54
|
+
return fenceMatch[1];
|
|
55
|
+
}
|
|
56
|
+
// Strategy 2: locate first { or [ and matching last } or ]
|
|
57
|
+
const braceIdx = response.indexOf("{");
|
|
58
|
+
const bracketIdx = response.indexOf("[");
|
|
59
|
+
let startChar = null;
|
|
60
|
+
let startIdx = -1;
|
|
61
|
+
if (braceIdx >= 0 && (bracketIdx < 0 || braceIdx < bracketIdx)) {
|
|
62
|
+
startChar = "{";
|
|
63
|
+
startIdx = braceIdx;
|
|
64
|
+
}
|
|
65
|
+
else if (bracketIdx >= 0) {
|
|
66
|
+
startChar = "[";
|
|
67
|
+
startIdx = bracketIdx;
|
|
68
|
+
}
|
|
69
|
+
if (startChar !== null && startIdx >= 0) {
|
|
70
|
+
const endChar = startChar === "{" ? "}" : "]";
|
|
71
|
+
const endIdx = response.lastIndexOf(endChar);
|
|
72
|
+
if (endIdx > startIdx) {
|
|
73
|
+
return response.slice(startIdx, endIdx + 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Strategy 3: return full string (caller's JSON.parse will validate)
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Extracts `relatedFiles` from generated Markdown content.
|
|
81
|
+
*
|
|
82
|
+
* Looks for the "Source Files" or "Related Files" section and extracts file
|
|
83
|
+
* paths from backtick-quoted entries (e.g., `` `src/user/controller.ts` ``).
|
|
84
|
+
*
|
|
85
|
+
* @param content - The generated Markdown content.
|
|
86
|
+
* @returns Array of relative file paths found in the Source Files section.
|
|
87
|
+
*/
|
|
88
|
+
export function parseRelatedFiles(content) {
|
|
89
|
+
const files = [];
|
|
90
|
+
const lines = content.split("\n");
|
|
91
|
+
let inSection = false;
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
// Detect start of Source Files / Related Files section
|
|
94
|
+
if (/^##\s+(Source|Related) Files/i.test(line)) {
|
|
95
|
+
inSection = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// End section at next heading
|
|
99
|
+
if (inSection && /^##\s+/.test(line)) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
// Extract file paths from backtick-quoted list items
|
|
103
|
+
if (inSection) {
|
|
104
|
+
const match = line.match(/`([^`]+)`/);
|
|
105
|
+
if (match) {
|
|
106
|
+
files.push(match[1]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return files;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Extracts key concept names from section content.
|
|
114
|
+
*
|
|
115
|
+
* Looks for backtick-quoted identifiers as a heuristic for concepts.
|
|
116
|
+
*
|
|
117
|
+
* @param content - The section body text.
|
|
118
|
+
* @returns Array of unique concept strings.
|
|
119
|
+
*/
|
|
120
|
+
export function extractConcepts(content) {
|
|
121
|
+
const matches = content.match(/`([^`]+)`/g);
|
|
122
|
+
if (!matches)
|
|
123
|
+
return [];
|
|
124
|
+
const unique = new Set(matches
|
|
125
|
+
.map((m) => m.slice(1, -1))
|
|
126
|
+
.filter((c) => c.length > 0 && !/^\s|\s$/.test(c) && !c.includes(" ")));
|
|
127
|
+
return Array.from(unique);
|
|
128
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path validation and resolution utilities for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for checking path containment, resolving output
|
|
5
|
+
* paths within a destination directory, and validating source/destination
|
|
6
|
+
* relationships.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Checks whether `child` is within (or equal to) `parent` after
|
|
10
|
+
* resolving both to absolute paths.
|
|
11
|
+
*
|
|
12
|
+
* Uses a trailing-separator prefix check to prevent false positives
|
|
13
|
+
* where `/foo/bar` would incorrectly match `/foo/barbaz`.
|
|
14
|
+
*
|
|
15
|
+
* @param child - The path to test.
|
|
16
|
+
* @param parent - The potential ancestor path.
|
|
17
|
+
* @returns `true` if `child` is the same as or a descendant of `parent`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isPathWithin(child: string, parent: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the full output path for a file within the destination directory.
|
|
22
|
+
*
|
|
23
|
+
* Safety: Rejects any `relativePath` that would escape the destination
|
|
24
|
+
* directory (e.g., paths containing `..` segments that resolve outside).
|
|
25
|
+
*
|
|
26
|
+
* @param destinationPath - Path to the destination directory.
|
|
27
|
+
* @param relativePath - Relative path for the output file.
|
|
28
|
+
* @returns The resolved absolute output path.
|
|
29
|
+
* @throws If the resolved path falls outside the destination directory.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveOutputPath(destinationPath: string, relativePath: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Validates the relationship between destination and source paths.
|
|
34
|
+
*
|
|
35
|
+
* When the destination directory is inside the source directory, logs
|
|
36
|
+
* a note that destination files will be excluded from scanning.
|
|
37
|
+
*
|
|
38
|
+
* @param destinationPath - Path to the destination directory.
|
|
39
|
+
* @param sourcePath - Path to the source directory.
|
|
40
|
+
*/
|
|
41
|
+
export declare function validateDestinationPath(destinationPath: string, sourcePath: string): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path validation and resolution utilities for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for checking path containment, resolving output
|
|
5
|
+
* paths within a destination directory, and validating source/destination
|
|
6
|
+
* relationships.
|
|
7
|
+
*/
|
|
8
|
+
import { resolve, normalize, relative } from "node:path";
|
|
9
|
+
/**
|
|
10
|
+
* Checks whether `child` is within (or equal to) `parent` after
|
|
11
|
+
* resolving both to absolute paths.
|
|
12
|
+
*
|
|
13
|
+
* Uses a trailing-separator prefix check to prevent false positives
|
|
14
|
+
* where `/foo/bar` would incorrectly match `/foo/barbaz`.
|
|
15
|
+
*
|
|
16
|
+
* @param child - The path to test.
|
|
17
|
+
* @param parent - The potential ancestor path.
|
|
18
|
+
* @returns `true` if `child` is the same as or a descendant of `parent`.
|
|
19
|
+
*/
|
|
20
|
+
export function isPathWithin(child, parent) {
|
|
21
|
+
const absoluteChild = resolve(child);
|
|
22
|
+
const absoluteParent = resolve(parent);
|
|
23
|
+
if (absoluteChild === absoluteParent) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const parentPrefix = absoluteParent.endsWith("/")
|
|
27
|
+
? absoluteParent
|
|
28
|
+
: absoluteParent + "/";
|
|
29
|
+
return absoluteChild.startsWith(parentPrefix);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the full output path for a file within the destination directory.
|
|
33
|
+
*
|
|
34
|
+
* Safety: Rejects any `relativePath` that would escape the destination
|
|
35
|
+
* directory (e.g., paths containing `..` segments that resolve outside).
|
|
36
|
+
*
|
|
37
|
+
* @param destinationPath - Path to the destination directory.
|
|
38
|
+
* @param relativePath - Relative path for the output file.
|
|
39
|
+
* @returns The resolved absolute output path.
|
|
40
|
+
* @throws If the resolved path falls outside the destination directory.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveOutputPath(destinationPath, relativePath) {
|
|
43
|
+
const absoluteDestination = resolve(destinationPath);
|
|
44
|
+
const resolved = resolve(absoluteDestination, normalize(relativePath));
|
|
45
|
+
const rel = relative(absoluteDestination, resolved);
|
|
46
|
+
if (rel.startsWith("..") || resolve(absoluteDestination, rel) !== resolved) {
|
|
47
|
+
throw new Error(`Output path "${relativePath}" escapes the destination directory "${absoluteDestination}".`);
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validates the relationship between destination and source paths.
|
|
53
|
+
*
|
|
54
|
+
* When the destination directory is inside the source directory, logs
|
|
55
|
+
* a note that destination files will be excluded from scanning.
|
|
56
|
+
*
|
|
57
|
+
* @param destinationPath - Path to the destination directory.
|
|
58
|
+
* @param sourcePath - Path to the source directory.
|
|
59
|
+
*/
|
|
60
|
+
export function validateDestinationPath(destinationPath, sourcePath) {
|
|
61
|
+
const absoluteDestination = resolve(destinationPath);
|
|
62
|
+
const absoluteSource = resolve(sourcePath);
|
|
63
|
+
if (isPathWithin(absoluteDestination, absoluteSource)) {
|
|
64
|
+
console.log(`Note: Destination "${absoluteDestination}" is inside source "${absoluteSource}". ` +
|
|
65
|
+
`Destination files will be excluded from scanning.`);
|
|
66
|
+
}
|
|
67
|
+
}
|