nomarmy 0.1.0-alpha.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/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- package/scripts/verify-install.sh +68 -0
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
// nomArmy v1.3: deterministic repository / environment scanner.
|
|
2
|
+
//
|
|
3
|
+
// `scanRepository(repoDir, options)` inspects a repository and returns the
|
|
4
|
+
// normalized evidence package defined by `lib/evidence.mjs`.
|
|
5
|
+
//
|
|
6
|
+
// HARD CONSTRAINTS (these are security properties, not style):
|
|
7
|
+
// * READ ONLY. This module opens files. It never spawns a process, never
|
|
8
|
+
// runs docker/npm/make, never resolves network anything. A scanner that
|
|
9
|
+
// executes a command it discovered is a remote-code-execution bug.
|
|
10
|
+
// * NAMES ONLY. Environment variables are recorded by name. A value from a
|
|
11
|
+
// `.env.example`, a compose `environment:` block or a Dockerfile `ENV`
|
|
12
|
+
// never enters the output.
|
|
13
|
+
// * BOUNDED. Depth, entry count, file size, per-category findings and string
|
|
14
|
+
// lengths are all capped. No whole file is ever inlined.
|
|
15
|
+
// * DETERMINISTIC. No timestamps, no randomness. Directory listings are
|
|
16
|
+
// sorted; findings are deduped and sorted by `normalize()`.
|
|
17
|
+
//
|
|
18
|
+
// This runs BEFORE any model sees the repository. The evidence it produces is
|
|
19
|
+
// what bounds the model's later config proposal.
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
classifyCommand,
|
|
26
|
+
compareEvidence,
|
|
27
|
+
emptyEvidence,
|
|
28
|
+
envName,
|
|
29
|
+
envNamesFromDotenv,
|
|
30
|
+
envNamesFromInterpolation,
|
|
31
|
+
normalize,
|
|
32
|
+
resolveLimits,
|
|
33
|
+
} from "./evidence.mjs";
|
|
34
|
+
|
|
35
|
+
export { compareEvidence, emptyEvidence };
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Narrow YAML-subset parser
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
//
|
|
41
|
+
// The repo cannot assume `npm install` has run (another component owns
|
|
42
|
+
// package.json), so this module ships its own parser rather than importing
|
|
43
|
+
// `yaml`. It supports the subset Compose / CI files actually use:
|
|
44
|
+
//
|
|
45
|
+
// block mappings, block sequences, sequences of mappings, nested blocks,
|
|
46
|
+
// quoted and plain scalars, inline flow sequences `[a, b]` and flow maps
|
|
47
|
+
// `{a: b}`, `#` comments, block scalars `|` and `>`, and the first document
|
|
48
|
+
// of a multi-document file.
|
|
49
|
+
//
|
|
50
|
+
// It does NOT support anchors/aliases/merge keys, complex keys, or multi-line
|
|
51
|
+
// plain scalars. Those degrade: the parser records a note and the caller falls
|
|
52
|
+
// back to regex extraction for Compose files.
|
|
53
|
+
|
|
54
|
+
const BLANK_LINE = /^[ \t]*$/;
|
|
55
|
+
|
|
56
|
+
function expandIndent(raw) {
|
|
57
|
+
const ws = raw.match(/^[ \t]*/)[0];
|
|
58
|
+
const width = ws.replace(/\t/g, " ").length;
|
|
59
|
+
return { width, rest: raw.slice(ws.length) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stripComment(text) {
|
|
63
|
+
let out = "";
|
|
64
|
+
let quote = null;
|
|
65
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
66
|
+
const ch = text[i];
|
|
67
|
+
if (quote) {
|
|
68
|
+
out += ch;
|
|
69
|
+
if (ch === "\\" && quote === '"' && i + 1 < text.length) {
|
|
70
|
+
out += text[i + 1];
|
|
71
|
+
i += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (ch === quote) quote = null;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === "'" || ch === '"') {
|
|
78
|
+
quote = ch;
|
|
79
|
+
out += ch;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]))) break;
|
|
83
|
+
out += ch;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function lineInfo(raw) {
|
|
89
|
+
const { width, rest } = expandIndent(raw);
|
|
90
|
+
const body = stripComment(rest).replace(/\s+$/, "");
|
|
91
|
+
return { indent: width, body };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function nextSignificant(state) {
|
|
95
|
+
while (state.i < state.src.length) {
|
|
96
|
+
const raw = state.src[state.i];
|
|
97
|
+
if (BLANK_LINE.test(raw)) {
|
|
98
|
+
state.i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const info = lineInfo(raw);
|
|
102
|
+
if (info.body === "") {
|
|
103
|
+
state.i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (info.body === "---" || info.body === "...") {
|
|
107
|
+
if (state.started) {
|
|
108
|
+
// Only the first document is parsed.
|
|
109
|
+
state.i = state.src.length;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
state.i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
return info;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function consume(state) {
|
|
121
|
+
state.i += 1;
|
|
122
|
+
state.started = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function unquote(text) {
|
|
126
|
+
if (typeof text !== "string" || text.length < 2) return text;
|
|
127
|
+
const first = text[0];
|
|
128
|
+
const last = text[text.length - 1];
|
|
129
|
+
if (first === last && (first === '"' || first === "'")) {
|
|
130
|
+
const inner = text.slice(1, -1);
|
|
131
|
+
return first === '"'
|
|
132
|
+
? inner.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\(["\\])/g, "$1")
|
|
133
|
+
: inner.replace(/''/g, "'");
|
|
134
|
+
}
|
|
135
|
+
return text;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function splitTopLevel(text, separator) {
|
|
139
|
+
const parts = [];
|
|
140
|
+
let depth = 0;
|
|
141
|
+
let quote = null;
|
|
142
|
+
let current = "";
|
|
143
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
144
|
+
const ch = text[i];
|
|
145
|
+
if (quote) {
|
|
146
|
+
current += ch;
|
|
147
|
+
if (ch === "\\" && quote === '"' && i + 1 < text.length) {
|
|
148
|
+
current += text[i + 1];
|
|
149
|
+
i += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (ch === quote) quote = null;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ch === "'" || ch === '"') {
|
|
156
|
+
quote = ch;
|
|
157
|
+
current += ch;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === "[" || ch === "{") depth += 1;
|
|
161
|
+
if (ch === "]" || ch === "}") depth -= 1;
|
|
162
|
+
if (ch === separator && depth === 0) {
|
|
163
|
+
parts.push(current);
|
|
164
|
+
current = "";
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
current += ch;
|
|
168
|
+
}
|
|
169
|
+
parts.push(current);
|
|
170
|
+
return parts.map((p) => p.trim()).filter((p) => p !== "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function splitKey(body) {
|
|
174
|
+
let depth = 0;
|
|
175
|
+
let quote = null;
|
|
176
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
177
|
+
const ch = body[i];
|
|
178
|
+
if (quote) {
|
|
179
|
+
if (ch === "\\" && quote === '"') {
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === quote) quote = null;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (ch === "'" || ch === '"') {
|
|
187
|
+
quote = ch;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === "[" || ch === "{") depth += 1;
|
|
191
|
+
else if (ch === "]" || ch === "}") depth -= 1;
|
|
192
|
+
else if (ch === ":" && depth === 0 && (i + 1 >= body.length || /\s/.test(body[i + 1]))) {
|
|
193
|
+
const key = unquote(body.slice(0, i).trim());
|
|
194
|
+
if (key === "") return null;
|
|
195
|
+
return { key, rest: body.slice(i + 1).trim() };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseScalar(text, state) {
|
|
202
|
+
let value = text.trim();
|
|
203
|
+
if (value === "") return null;
|
|
204
|
+
if (value.startsWith("&")) {
|
|
205
|
+
// Anchor definition: keep the value, drop the anchor token.
|
|
206
|
+
state.notes.push("YAML anchors are not resolved by the narrow parser");
|
|
207
|
+
value = value.replace(/^&\S+\s*/, "");
|
|
208
|
+
if (value === "") return null;
|
|
209
|
+
}
|
|
210
|
+
if (value.startsWith("*")) {
|
|
211
|
+
state.notes.push("YAML aliases are not resolved by the narrow parser");
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (value.startsWith("!!")) value = value.replace(/^!!\S+\s*/, "");
|
|
215
|
+
if (value === "~" || /^null$/i.test(value)) return null;
|
|
216
|
+
if (/^true$/i.test(value)) return true;
|
|
217
|
+
if (/^false$/i.test(value)) return false;
|
|
218
|
+
if (/^-?\d+$/.test(value)) {
|
|
219
|
+
const n = Number(value);
|
|
220
|
+
if (Number.isSafeInteger(n)) return n;
|
|
221
|
+
}
|
|
222
|
+
if (/^-?\d*\.\d+$/.test(value)) return Number(value);
|
|
223
|
+
return unquote(value);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseFlow(text, state) {
|
|
227
|
+
const trimmed = text.trim();
|
|
228
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
229
|
+
return splitTopLevel(trimmed.slice(1, -1), ",").map((part) => parseFlow(part, state));
|
|
230
|
+
}
|
|
231
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
232
|
+
const map = {};
|
|
233
|
+
for (const part of splitTopLevel(trimmed.slice(1, -1), ",")) {
|
|
234
|
+
const kv = splitKey(part) || (part.includes(":") ? { key: part.split(":")[0].trim(), rest: part.split(":").slice(1).join(":").trim() } : null);
|
|
235
|
+
if (kv) map[unquote(kv.key)] = parseFlow(kv.rest, state);
|
|
236
|
+
}
|
|
237
|
+
return map;
|
|
238
|
+
}
|
|
239
|
+
return parseScalar(trimmed, state);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readBlockScalar(state, parentIndent, indicator) {
|
|
243
|
+
const folded = indicator.trim().startsWith(">");
|
|
244
|
+
const lines = [];
|
|
245
|
+
let blockIndent = null;
|
|
246
|
+
while (state.i < state.src.length) {
|
|
247
|
+
const raw = state.src[state.i];
|
|
248
|
+
if (BLANK_LINE.test(raw)) {
|
|
249
|
+
lines.push("");
|
|
250
|
+
consume(state);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const { width, rest } = expandIndent(raw);
|
|
254
|
+
if (width <= parentIndent) break;
|
|
255
|
+
if (blockIndent === null) blockIndent = width;
|
|
256
|
+
const pad = width - blockIndent;
|
|
257
|
+
lines.push((pad > 0 ? " ".repeat(pad) : "") + rest.replace(/\s+$/, ""));
|
|
258
|
+
consume(state);
|
|
259
|
+
}
|
|
260
|
+
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
261
|
+
return folded ? lines.join(" ").replace(/\s+/g, " ").trim() : lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseAfterKey(state, parentIndent, rest) {
|
|
265
|
+
if (rest === "") {
|
|
266
|
+
return parseNode(state, parentIndent + 1);
|
|
267
|
+
}
|
|
268
|
+
if (/^[|>][-+]?\d*$/.test(rest)) {
|
|
269
|
+
return readBlockScalar(state, parentIndent, rest);
|
|
270
|
+
}
|
|
271
|
+
return parseFlow(rest, state);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseMap(state, indent) {
|
|
275
|
+
const map = {};
|
|
276
|
+
for (;;) {
|
|
277
|
+
const info = nextSignificant(state);
|
|
278
|
+
if (!info || info.indent < indent) break;
|
|
279
|
+
if (info.indent > indent) {
|
|
280
|
+
state.notes.push("unexpected indentation; line skipped");
|
|
281
|
+
consume(state);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (info.body === "-" || info.body.startsWith("- ")) break;
|
|
285
|
+
const kv = splitKey(info.body);
|
|
286
|
+
if (!kv) {
|
|
287
|
+
state.notes.push("unparsed YAML line skipped");
|
|
288
|
+
consume(state);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
consume(state);
|
|
292
|
+
map[kv.key] = parseAfterKey(state, indent, kv.rest);
|
|
293
|
+
}
|
|
294
|
+
return map;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseSeq(state, indent) {
|
|
298
|
+
const list = [];
|
|
299
|
+
for (;;) {
|
|
300
|
+
const info = nextSignificant(state);
|
|
301
|
+
if (!info || info.indent < indent) break;
|
|
302
|
+
if (info.indent > indent) {
|
|
303
|
+
state.notes.push("unexpected indentation; line skipped");
|
|
304
|
+
consume(state);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (!(info.body === "-" || info.body.startsWith("- "))) break;
|
|
308
|
+
const dash = info.body.match(/^-\s*/)[0];
|
|
309
|
+
const rest = info.body.slice(dash.length).trim();
|
|
310
|
+
const childIndent = info.indent + Math.max(dash.length, 2);
|
|
311
|
+
consume(state);
|
|
312
|
+
if (rest === "") {
|
|
313
|
+
list.push(parseNode(state, indent + 1));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const kv = rest.startsWith("[") || rest.startsWith("{") ? null : splitKey(rest);
|
|
317
|
+
if (kv) {
|
|
318
|
+
const entry = {};
|
|
319
|
+
entry[kv.key] = parseAfterKey(state, childIndent, kv.rest);
|
|
320
|
+
Object.assign(entry, parseMap(state, childIndent));
|
|
321
|
+
list.push(entry);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (/^[|>][-+]?\d*$/.test(rest)) {
|
|
325
|
+
list.push(readBlockScalar(state, info.indent, rest));
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
list.push(parseFlow(rest, state));
|
|
329
|
+
}
|
|
330
|
+
return list;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseNode(state, minIndent) {
|
|
334
|
+
const info = nextSignificant(state);
|
|
335
|
+
if (!info || info.indent < minIndent) return null;
|
|
336
|
+
if (info.body === "-" || info.body.startsWith("- ")) return parseSeq(state, info.indent);
|
|
337
|
+
return parseMap(state, info.indent);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parse the supported YAML subset. Returns `{ value, notes }`. Never throws for
|
|
342
|
+
* unsupported syntax; it records a note and degrades.
|
|
343
|
+
*/
|
|
344
|
+
export function parseYamlSubset(text) {
|
|
345
|
+
const state = {
|
|
346
|
+
src: String(text ?? "").replace(/\r\n?/g, "\n").split("\n"),
|
|
347
|
+
i: 0,
|
|
348
|
+
notes: [],
|
|
349
|
+
started: false,
|
|
350
|
+
};
|
|
351
|
+
let value = null;
|
|
352
|
+
try {
|
|
353
|
+
value = parseNode(state, 0);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
state.notes.push(`YAML subset parser failed: ${err && err.message ? err.message : "unknown error"}`);
|
|
356
|
+
value = null;
|
|
357
|
+
}
|
|
358
|
+
return { value, notes: [...new Set(state.notes)] };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Small helpers
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
function toPosix(p) {
|
|
366
|
+
return String(p).split(path.sep).join("/");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function asList(value) {
|
|
370
|
+
if (Array.isArray(value)) return value;
|
|
371
|
+
if (value === null || value === undefined) return [];
|
|
372
|
+
return [value];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function flatten(value) {
|
|
376
|
+
// Compose `command` / healthcheck `test` may be a string or a list.
|
|
377
|
+
if (Array.isArray(value)) return value.filter((v) => v !== null && v !== undefined).map(String).join(" ");
|
|
378
|
+
if (value === null || value === undefined) return null;
|
|
379
|
+
if (typeof value === "object") return null;
|
|
380
|
+
return String(value);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function readCapped(fullPath, limits) {
|
|
384
|
+
let stat;
|
|
385
|
+
try {
|
|
386
|
+
stat = fs.statSync(fullPath);
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (!stat.isFile()) return null;
|
|
391
|
+
const size = Math.min(stat.size, limits.fileBytes);
|
|
392
|
+
let fd;
|
|
393
|
+
try {
|
|
394
|
+
fd = fs.openSync(fullPath, "r");
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const buf = Buffer.alloc(size);
|
|
400
|
+
if (size > 0) fs.readSync(fd, buf, 0, size, 0);
|
|
401
|
+
return { text: buf.toString("utf8"), truncated: stat.size > size };
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
} finally {
|
|
405
|
+
try {
|
|
406
|
+
fs.closeSync(fd);
|
|
407
|
+
} catch {
|
|
408
|
+
/* ignore */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseJsonLoose(text) {
|
|
414
|
+
// JSONC: devcontainer.json and tsconfig-style files allow comments and
|
|
415
|
+
// trailing commas.
|
|
416
|
+
const withoutComments = String(text)
|
|
417
|
+
.replace(/\\"/g, "\u0000ESCQ\u0000")
|
|
418
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, (m) => m.replace(/\/\//g, "\u0000SL\u0000").replace(/\/\*/g, "\u0000BC\u0000"))
|
|
419
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
420
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, "$1")
|
|
421
|
+
.replace(/\u0000SL\u0000/g, "//")
|
|
422
|
+
.replace(/\u0000BC\u0000/g, "/*")
|
|
423
|
+
.replace(/\u0000ESCQ\u0000/g, '\\"')
|
|
424
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
425
|
+
try {
|
|
426
|
+
return JSON.parse(withoutComments);
|
|
427
|
+
} catch {
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(text);
|
|
430
|
+
} catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// File classification
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
const IGNORED_DIRS = new Set([
|
|
441
|
+
".git", ".hg", ".svn", "node_modules", "bower_components", ".venv", "venv", "env",
|
|
442
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", "dist", "build",
|
|
443
|
+
"out", "target", "vendor", "coverage", ".next", ".nuxt", ".svelte-kit", ".turbo",
|
|
444
|
+
".cache", ".parcel-cache", ".gradle", ".idea", ".vs", ".terraform", "site-packages",
|
|
445
|
+
"Pods", ".yarn", ".pnpm-store", "test-results", "playwright-report",
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
// A path segment naming a fixtures-style directory anywhere in a matched
|
|
449
|
+
// file's relative path -- not a directory to skip walking (a real repo can
|
|
450
|
+
// have legitimate content elsewhere under the same tree), just a signal to
|
|
451
|
+
// label that specific finding as sample data once it's found. Deliberately
|
|
452
|
+
// narrower than "tests"/"__tests__" alone; see the comment at its call site.
|
|
453
|
+
const FIXTURE_PATH_RE = /(^|\/)(fixtures?|__fixtures__|testdata|test-data)(\/|$)/i;
|
|
454
|
+
|
|
455
|
+
const GREPPABLE_EXT = new Set([
|
|
456
|
+
".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts",
|
|
457
|
+
".py", ".go", ".java", ".kt", ".rb", ".cs", ".php", ".rs",
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
const COMPOSE_RE = /^(docker-compose|compose)(\.[A-Za-z0-9_.-]+)?\.ya?ml$/i;
|
|
461
|
+
const DOCKERFILE_RE = /^(Dockerfile|Containerfile)([.-][A-Za-z0-9_.-]+)?$/i;
|
|
462
|
+
const DOCKERFILE_SUFFIX_RE = /\.(dockerfile|Dockerfile)$/;
|
|
463
|
+
const REQUIREMENTS_RE = /^requirements([-_.][A-Za-z0-9_.-]+)?\.txt$/i;
|
|
464
|
+
const GO_MOD_RE = /^go\.mod$/;
|
|
465
|
+
const CARGO_TOML_RE = /^Cargo\.toml$/;
|
|
466
|
+
const ENV_EXAMPLE_RE = /^\.?env(\.[A-Za-z0-9_-]+)*\.(example|sample|template|dist|defaults)$/i;
|
|
467
|
+
const ENV_EXAMPLE_SUFFIX_RE = /\.env\.(example|sample|template|dist)$/i;
|
|
468
|
+
const PLAYWRIGHT_RE = /^playwright(-[a-z]+)?\.config\.[cm]?[jt]s$/i;
|
|
469
|
+
const CYPRESS_RE = /^cypress\.config\.[cm]?[jt]s$|^cypress\.json$/i;
|
|
470
|
+
const MAKEFILE_RE = /^(GNUmakefile|Makefile|makefile)$/;
|
|
471
|
+
const TASKFILE_RE = /^(Taskfile|taskfile)(\.dist)?\.ya?ml$/;
|
|
472
|
+
const PROCFILE_RE = /^Procfile(\.[A-Za-z0-9_-]+)?$/;
|
|
473
|
+
const CI_WORKFLOW_RE = /^\.(github|gitea)\/workflows\/[^/]+\.ya?ml$/i;
|
|
474
|
+
// Anchored to a `scripts/`-style directory at any depth so monorepo packages
|
|
475
|
+
// are covered; CI workflow paths stay root-anchored because that is the only
|
|
476
|
+
// place the CI provider reads them from.
|
|
477
|
+
const INTEGRATION_SCRIPT_RE = /(^|\/)(scripts|bin|tools|ci|hack)\/[^/]*(integration|e2e|smoke|acceptance|end-to-end)[^/]*$/i;
|
|
478
|
+
|
|
479
|
+
function classifyFile(relPath) {
|
|
480
|
+
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
481
|
+
const lower = relPath.toLowerCase();
|
|
482
|
+
|
|
483
|
+
if (COMPOSE_RE.test(base)) return "compose";
|
|
484
|
+
if (DOCKERFILE_RE.test(base) || DOCKERFILE_SUFFIX_RE.test(base)) return "dockerfile";
|
|
485
|
+
if (base === "devcontainer.json" || base === ".devcontainer.json") return "devcontainer";
|
|
486
|
+
if (base === "package.json") return "package-json";
|
|
487
|
+
if (base === "pnpm-workspace.yaml" || base === "pnpm-workspace.yml") return "pnpm-workspace";
|
|
488
|
+
if (base === "pyproject.toml") return "pyproject";
|
|
489
|
+
if (REQUIREMENTS_RE.test(base)) return "requirements";
|
|
490
|
+
if (GO_MOD_RE.test(base)) return "go-mod";
|
|
491
|
+
if (CARGO_TOML_RE.test(base)) return "cargo-toml";
|
|
492
|
+
if (MAKEFILE_RE.test(base)) return "makefile";
|
|
493
|
+
if (TASKFILE_RE.test(base)) return "taskfile";
|
|
494
|
+
if (PROCFILE_RE.test(base)) return "procfile";
|
|
495
|
+
if (PLAYWRIGHT_RE.test(base)) return "playwright-config";
|
|
496
|
+
if (CYPRESS_RE.test(base)) return "cypress-config";
|
|
497
|
+
if (ENV_EXAMPLE_RE.test(base) || ENV_EXAMPLE_SUFFIX_RE.test(base)) return "env-example";
|
|
498
|
+
if (CI_WORKFLOW_RE.test(relPath)) return "ci-github";
|
|
499
|
+
if (base === ".gitlab-ci.yml" || base === ".gitlab-ci.yaml") return "ci-gitlab";
|
|
500
|
+
if (lower === ".circleci/config.yml" || lower === ".circleci/config.yaml") return "ci-circle";
|
|
501
|
+
if (base === "azure-pipelines.yml" || base === "azure-pipelines.yaml") return "ci-azure";
|
|
502
|
+
if (base === "Jenkinsfile") return "ci-jenkins";
|
|
503
|
+
if (INTEGRATION_SCRIPT_RE.test(relPath)) return "integration-script";
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Directory walk
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
function walk(root, limits) {
|
|
512
|
+
const matched = [];
|
|
513
|
+
const greppable = [];
|
|
514
|
+
let visited = 0;
|
|
515
|
+
let overflow = false;
|
|
516
|
+
|
|
517
|
+
const queue = [{ dir: root, rel: "", depth: 0 }];
|
|
518
|
+
while (queue.length) {
|
|
519
|
+
const { dir, rel, depth } = queue.shift();
|
|
520
|
+
let entries;
|
|
521
|
+
try {
|
|
522
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
523
|
+
} catch {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
527
|
+
for (const entry of entries) {
|
|
528
|
+
if (visited >= limits.maxEntries) {
|
|
529
|
+
overflow = true;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
visited += 1;
|
|
533
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
534
|
+
if (entry.isSymbolicLink()) continue; // never follow links out of the repo
|
|
535
|
+
if (entry.isDirectory()) {
|
|
536
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
537
|
+
if (depth + 1 > limits.maxDepth) continue;
|
|
538
|
+
queue.push({ dir: path.join(dir, entry.name), rel: relPath, depth: depth + 1 });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (!entry.isFile()) continue;
|
|
542
|
+
const kind = classifyFile(relPath);
|
|
543
|
+
if (kind) matched.push({ relPath, kind, full: path.join(dir, entry.name) });
|
|
544
|
+
else if (GREPPABLE_EXT.has(path.extname(entry.name).toLowerCase())) {
|
|
545
|
+
greppable.push({ relPath, full: path.join(dir, entry.name) });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (overflow) break;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
matched.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
|
|
552
|
+
greppable.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
|
|
553
|
+
return { matched, greppable, overflow };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Compose
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
function parseComposePort(value, service, source) {
|
|
561
|
+
if (typeof value === "number") {
|
|
562
|
+
return { service, source, published: null, target: value, protocol: "tcp", raw: String(value) };
|
|
563
|
+
}
|
|
564
|
+
if (value && typeof value === "object") {
|
|
565
|
+
return {
|
|
566
|
+
service,
|
|
567
|
+
source,
|
|
568
|
+
published: value.published ?? null,
|
|
569
|
+
target: value.target ?? null,
|
|
570
|
+
protocol: value.protocol ?? "tcp",
|
|
571
|
+
host: value.host_ip ?? null,
|
|
572
|
+
raw: null,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
576
|
+
const raw = value.trim();
|
|
577
|
+
let body = raw;
|
|
578
|
+
let protocol = "tcp";
|
|
579
|
+
const slash = body.lastIndexOf("/");
|
|
580
|
+
if (slash !== -1 && /^[a-zA-Z]+$/.test(body.slice(slash + 1))) {
|
|
581
|
+
protocol = body.slice(slash + 1).toLowerCase();
|
|
582
|
+
body = body.slice(0, slash);
|
|
583
|
+
}
|
|
584
|
+
const parts = body.split(":");
|
|
585
|
+
let host = null;
|
|
586
|
+
let published = null;
|
|
587
|
+
let target = null;
|
|
588
|
+
if (parts.length === 1) {
|
|
589
|
+
target = parts[0];
|
|
590
|
+
} else if (parts.length === 2) {
|
|
591
|
+
published = parts[0];
|
|
592
|
+
target = parts[1];
|
|
593
|
+
} else {
|
|
594
|
+
host = parts.slice(0, parts.length - 2).join(":");
|
|
595
|
+
published = parts[parts.length - 2];
|
|
596
|
+
target = parts[parts.length - 1];
|
|
597
|
+
}
|
|
598
|
+
return { service, source, host, published, target, protocol, raw };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Environment variable NAMES from a compose `environment:` block. Values dropped. */
|
|
602
|
+
function composeEnvNames(value) {
|
|
603
|
+
if (Array.isArray(value)) {
|
|
604
|
+
return value
|
|
605
|
+
.map((entry) => (typeof entry === "string" ? entry.split("=")[0] : null))
|
|
606
|
+
.map((name) => envName(name))
|
|
607
|
+
.filter(Boolean);
|
|
608
|
+
}
|
|
609
|
+
if (value && typeof value === "object") {
|
|
610
|
+
return Object.keys(value).map((name) => envName(name)).filter(Boolean);
|
|
611
|
+
}
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Interpolated `${VAR}` names appearing anywhere in a nested value. */
|
|
616
|
+
function collectInterpolatedNames(value, out = [], depth = 0) {
|
|
617
|
+
if (depth > 6) return out;
|
|
618
|
+
if (typeof value === "string") {
|
|
619
|
+
out.push(...envNamesFromInterpolation(value));
|
|
620
|
+
} else if (Array.isArray(value)) {
|
|
621
|
+
for (const entry of value) collectInterpolatedNames(entry, out, depth + 1);
|
|
622
|
+
} else if (value && typeof value === "object") {
|
|
623
|
+
for (const [k, v] of Object.entries(value)) {
|
|
624
|
+
out.push(...envNamesFromInterpolation(k));
|
|
625
|
+
collectInterpolatedNames(v, out, depth + 1);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const SEED_HINT_RE = /(seed|init|migrat|fixture|bootstrap|schema)/i;
|
|
632
|
+
|
|
633
|
+
function composeRegexFallback(text, source, raw) {
|
|
634
|
+
// Degraded path: locate service names and a few obvious keys by regex.
|
|
635
|
+
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
|
|
636
|
+
let inServices = false;
|
|
637
|
+
let serviceIndent = null;
|
|
638
|
+
let current = null;
|
|
639
|
+
const services = [];
|
|
640
|
+
for (const line of lines) {
|
|
641
|
+
if (/^services:\s*$/.test(line)) {
|
|
642
|
+
inServices = true;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!inServices) continue;
|
|
646
|
+
if (/^\S/.test(line)) {
|
|
647
|
+
if (!/^\s/.test(line) && line.trim() !== "") break; // next top-level key
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const m = line.match(/^(\s+)([A-Za-z0-9_][A-Za-z0-9_.-]*):\s*$/);
|
|
651
|
+
if (m) {
|
|
652
|
+
const indent = m[1].length;
|
|
653
|
+
if (serviceIndent === null) serviceIndent = indent;
|
|
654
|
+
if (indent === serviceIndent) {
|
|
655
|
+
current = { name: m[2], source, ports: [], environmentNames: [], dependsOn: [], profiles: [] };
|
|
656
|
+
services.push(current);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (!current) continue;
|
|
661
|
+
const image = line.match(/^\s+image:\s*(\S+)/);
|
|
662
|
+
if (image) current.image = image[1];
|
|
663
|
+
const port = line.match(/^\s*-\s*["']?([0-9.:\-/]+)["']?\s*$/);
|
|
664
|
+
if (port && /\d/.test(port[1])) {
|
|
665
|
+
const parsed = parseComposePort(port[1], current.name, source);
|
|
666
|
+
if (parsed) raw.ports.push(parsed);
|
|
667
|
+
current.ports.push(port[1]);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
for (const service of services) raw.services.push(service);
|
|
671
|
+
return services.length;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function handleCompose(text, source, raw) {
|
|
675
|
+
const { value, notes } = parseYamlSubset(text);
|
|
676
|
+
for (const note of notes) raw.notes.push({ level: "warn", message: note, source });
|
|
677
|
+
|
|
678
|
+
const services = value && typeof value === "object" ? value.services : null;
|
|
679
|
+
if (!services || typeof services !== "object" || Array.isArray(services)) {
|
|
680
|
+
const found = composeRegexFallback(text, source, raw);
|
|
681
|
+
raw.notes.push({
|
|
682
|
+
level: "warn",
|
|
683
|
+
message: found
|
|
684
|
+
? "Compose parsed by regex fallback; only service names, images and ports are reliable."
|
|
685
|
+
: "Compose file found but no services could be extracted.",
|
|
686
|
+
source,
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const profileMap = new Map();
|
|
692
|
+
|
|
693
|
+
for (const [name, spec] of Object.entries(services)) {
|
|
694
|
+
if (!name || typeof name !== "string") continue;
|
|
695
|
+
const service = {
|
|
696
|
+
name,
|
|
697
|
+
source,
|
|
698
|
+
image: null,
|
|
699
|
+
build: null,
|
|
700
|
+
command: null,
|
|
701
|
+
ports: [],
|
|
702
|
+
environmentNames: [],
|
|
703
|
+
envFiles: [],
|
|
704
|
+
dependsOn: [],
|
|
705
|
+
profiles: [],
|
|
706
|
+
healthcheck: false,
|
|
707
|
+
};
|
|
708
|
+
const def = spec && typeof spec === "object" && !Array.isArray(spec) ? spec : {};
|
|
709
|
+
|
|
710
|
+
service.image = typeof def.image === "string" ? def.image : null;
|
|
711
|
+
if (typeof def.build === "string") service.build = def.build;
|
|
712
|
+
else if (def.build && typeof def.build === "object") {
|
|
713
|
+
service.build = [def.build.context, def.build.dockerfile, def.build.target]
|
|
714
|
+
.filter((v) => typeof v === "string")
|
|
715
|
+
.join(" ") || "(build)";
|
|
716
|
+
}
|
|
717
|
+
service.command = flatten(def.command) || flatten(def.entrypoint);
|
|
718
|
+
|
|
719
|
+
for (const entry of asList(def.ports)) {
|
|
720
|
+
const parsed = parseComposePort(entry, name, source);
|
|
721
|
+
if (!parsed) continue;
|
|
722
|
+
raw.ports.push(parsed);
|
|
723
|
+
service.ports.push(parsed.raw || `${parsed.published ?? ""}:${parsed.target ?? ""}`);
|
|
724
|
+
}
|
|
725
|
+
for (const entry of asList(def.expose)) {
|
|
726
|
+
const parsed = parseComposePort(entry, name, source);
|
|
727
|
+
if (parsed) raw.ports.push({ ...parsed, published: null });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// NAMES ONLY: values are deliberately discarded here.
|
|
731
|
+
const envNames = composeEnvNames(def.environment);
|
|
732
|
+
service.environmentNames = envNames;
|
|
733
|
+
for (const envVar of envNames) {
|
|
734
|
+
raw.environment.push({ name: envVar, scope: "compose", service: name, source });
|
|
735
|
+
}
|
|
736
|
+
for (const file of asList(def.env_file)) {
|
|
737
|
+
const target = typeof file === "string" ? file : file && typeof file === "object" ? file.path : null;
|
|
738
|
+
if (typeof target === "string") service.envFiles.push(target);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const dependsOn = def.depends_on;
|
|
742
|
+
if (Array.isArray(dependsOn)) {
|
|
743
|
+
for (const dep of dependsOn) {
|
|
744
|
+
if (typeof dep !== "string") continue;
|
|
745
|
+
service.dependsOn.push(dep);
|
|
746
|
+
raw.dependencies.push({ from: name, to: dep, condition: null, source });
|
|
747
|
+
}
|
|
748
|
+
} else if (dependsOn && typeof dependsOn === "object") {
|
|
749
|
+
for (const [dep, cfg] of Object.entries(dependsOn)) {
|
|
750
|
+
service.dependsOn.push(dep);
|
|
751
|
+
raw.dependencies.push({
|
|
752
|
+
from: name,
|
|
753
|
+
to: dep,
|
|
754
|
+
condition: cfg && typeof cfg === "object" ? cfg.condition ?? null : null,
|
|
755
|
+
source,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
for (const link of asList(def.links)) {
|
|
760
|
+
if (typeof link === "string") {
|
|
761
|
+
const target = link.split(":")[0];
|
|
762
|
+
raw.dependencies.push({ from: name, to: target, condition: "link", source });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (def.healthcheck && typeof def.healthcheck === "object") {
|
|
767
|
+
service.healthcheck = true;
|
|
768
|
+
raw.healthchecks.push({
|
|
769
|
+
service: name,
|
|
770
|
+
test: flatten(def.healthcheck.test),
|
|
771
|
+
interval: def.healthcheck.interval ?? null,
|
|
772
|
+
timeout: def.healthcheck.timeout ?? null,
|
|
773
|
+
retries: def.healthcheck.retries ?? null,
|
|
774
|
+
startPeriod: def.healthcheck.start_period ?? null,
|
|
775
|
+
source,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
for (const profile of asList(def.profiles)) {
|
|
780
|
+
if (typeof profile !== "string") continue;
|
|
781
|
+
service.profiles.push(profile);
|
|
782
|
+
if (!profileMap.has(profile)) profileMap.set(profile, []);
|
|
783
|
+
profileMap.get(profile).push(name);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
for (const volume of asList(def.volumes)) {
|
|
787
|
+
const spec2 = typeof volume === "string" ? volume : volume && typeof volume === "object" ? volume.source : null;
|
|
788
|
+
if (typeof spec2 !== "string") continue;
|
|
789
|
+
const hostPath = spec2.split(":")[0];
|
|
790
|
+
if (SEED_HINT_RE.test(hostPath) || /\.(sql|sh)$/i.test(hostPath) || /initdb/i.test(spec2)) {
|
|
791
|
+
raw.seeds.push({ name: hostPath, kind: "compose-volume", target: spec2, service: name, source });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (service.command) {
|
|
796
|
+
raw.commands.push({
|
|
797
|
+
name: `compose:${name}`,
|
|
798
|
+
kind: classifyCommand(name, service.command),
|
|
799
|
+
command: service.command,
|
|
800
|
+
source,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
raw.services.push(service);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
for (const [profile, members] of profileMap) {
|
|
808
|
+
raw.profiles.push({ name: profile, services: members, source });
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
for (const envVar of collectInterpolatedNames(value)) {
|
|
812
|
+
raw.environment.push({ name: envVar, scope: "compose-interpolation", source });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
raw.tooling.push({ name: "docker compose", category: "container", detail: source, source });
|
|
816
|
+
raw.commands.push({
|
|
817
|
+
name: "compose up",
|
|
818
|
+
kind: "start",
|
|
819
|
+
command: `docker compose -f ${source} up -d`,
|
|
820
|
+
source,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
// package.json / node ecosystem
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
const NODE_TOOLING = [
|
|
829
|
+
[/^@playwright\/test$|^playwright$|^playwright-core$/, "playwright", "e2e"],
|
|
830
|
+
[/^cypress$/, "cypress", "e2e"],
|
|
831
|
+
[/^puppeteer/, "puppeteer", "e2e"],
|
|
832
|
+
[/^(webdriverio|selenium-webdriver|@wdio\/)/, "webdriver", "e2e"],
|
|
833
|
+
[/^(testcontainers|@testcontainers\/)/, "testcontainers", "container"],
|
|
834
|
+
[/^jest$|^ts-jest$/, "jest", "test"],
|
|
835
|
+
[/^vitest$/, "vitest", "test"],
|
|
836
|
+
[/^mocha$/, "mocha", "test"],
|
|
837
|
+
[/^ava$/, "ava", "test"],
|
|
838
|
+
[/^supertest$/, "supertest", "test"],
|
|
839
|
+
[/^prisma$|^@prisma\/client$/, "prisma", "migration"],
|
|
840
|
+
[/^knex$/, "knex", "migration"],
|
|
841
|
+
[/^typeorm$/, "typeorm", "migration"],
|
|
842
|
+
[/^sequelize/, "sequelize", "migration"],
|
|
843
|
+
[/^drizzle-kit$/, "drizzle", "migration"],
|
|
844
|
+
[/^(express|fastify|koa|@nestjs\/core|next|nuxt)$/, "node web framework", "runtime"],
|
|
845
|
+
[/^(pg|mysql2?|mongodb|redis|ioredis)$/, "database client", "runtime"],
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
function handlePackageJson(text, source, raw) {
|
|
849
|
+
const pkg = parseJsonLoose(text);
|
|
850
|
+
if (!pkg || typeof pkg !== "object") {
|
|
851
|
+
raw.notes.push({ level: "warn", message: "package.json could not be parsed", source });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
856
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
857
|
+
if (typeof command !== "string") continue;
|
|
858
|
+
raw.commands.push({
|
|
859
|
+
name: `npm run ${name}`,
|
|
860
|
+
kind: classifyCommand(name, command),
|
|
861
|
+
command,
|
|
862
|
+
source,
|
|
863
|
+
});
|
|
864
|
+
for (const envVar of envNamesFromInterpolation(command)) {
|
|
865
|
+
raw.environment.push({ name: envVar, scope: "npm-script", source });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (typeof pkg.packageManager === "string") {
|
|
870
|
+
raw.tooling.push({
|
|
871
|
+
name: pkg.packageManager.split("@")[0],
|
|
872
|
+
category: "package-manager",
|
|
873
|
+
detail: pkg.packageManager,
|
|
874
|
+
source,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (pkg.workspaces) {
|
|
878
|
+
const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : asList(pkg.workspaces.packages);
|
|
879
|
+
raw.tooling.push({
|
|
880
|
+
name: "npm workspaces",
|
|
881
|
+
category: "package-manager",
|
|
882
|
+
detail: globs.filter((g) => typeof g === "string").join(", ") || "(declared)",
|
|
883
|
+
source,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
if (pkg.engines && typeof pkg.engines === "object" && typeof pkg.engines.node === "string") {
|
|
887
|
+
raw.tooling.push({ name: "node", category: "runtime", detail: pkg.engines.node, source });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const deps = {};
|
|
891
|
+
for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
892
|
+
const bucket = pkg[field];
|
|
893
|
+
if (bucket && typeof bucket === "object") Object.assign(deps, bucket);
|
|
894
|
+
}
|
|
895
|
+
const seen = new Set();
|
|
896
|
+
for (const dep of Object.keys(deps).sort()) {
|
|
897
|
+
for (const [re, name, category] of NODE_TOOLING) {
|
|
898
|
+
if (re.test(dep) && !seen.has(name)) {
|
|
899
|
+
seen.add(name);
|
|
900
|
+
raw.tooling.push({ name, category, detail: `dependency ${dep}`, source });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// Python ecosystem
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
const PYTHON_TOOLING = [
|
|
911
|
+
[/^pytest/, "pytest", "test"],
|
|
912
|
+
[/^(playwright|pytest-playwright)$/, "playwright", "e2e"],
|
|
913
|
+
[/^testcontainers/, "testcontainers", "container"],
|
|
914
|
+
[/^(alembic)$/, "alembic", "migration"],
|
|
915
|
+
[/^(django)$/, "django", "runtime"],
|
|
916
|
+
[/^(flask|fastapi|starlette)$/, "python web framework", "runtime"],
|
|
917
|
+
[/^(uvicorn|gunicorn|hypercorn)$/, "python app server", "runtime"],
|
|
918
|
+
[/^(celery)$/, "celery", "runtime"],
|
|
919
|
+
[/^(psycopg2?|asyncpg|pymysql|redis|pymongo|sqlalchemy)/, "database client", "runtime"],
|
|
920
|
+
[/^(tox|nox)$/, "tox", "test"],
|
|
921
|
+
[/^(ruff|flake8|black|mypy)$/, "python linting", "lint"],
|
|
922
|
+
];
|
|
923
|
+
|
|
924
|
+
function recordPythonDependency(dep, source, raw, seen) {
|
|
925
|
+
const name = dep.trim().toLowerCase().replace(/[[<>=!~;].*$/, "").trim();
|
|
926
|
+
if (!name) return;
|
|
927
|
+
for (const [re, label, category] of PYTHON_TOOLING) {
|
|
928
|
+
if (re.test(name) && !seen.has(label)) {
|
|
929
|
+
seen.add(label);
|
|
930
|
+
raw.tooling.push({ name: label, category, detail: `dependency ${name}`, source });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function handleRequirements(text, source, raw) {
|
|
936
|
+
const seen = new Set();
|
|
937
|
+
for (const line of String(text).replace(/\r\n?/g, "\n").split("\n")) {
|
|
938
|
+
const trimmed = line.trim();
|
|
939
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
|
|
940
|
+
recordPythonDependency(trimmed, source, raw, seen);
|
|
941
|
+
}
|
|
942
|
+
raw.tooling.push({ name: "pip", category: "package-manager", detail: source, source });
|
|
943
|
+
raw.commands.push({ name: "pip install", kind: "build", command: `pip install -r ${source}`, source });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function handlePyproject(text, source, raw) {
|
|
947
|
+
const body = String(text).replace(/\r\n?/g, "\n");
|
|
948
|
+
const seen = new Set();
|
|
949
|
+
for (const match of body.matchAll(/["']([A-Za-z0-9_.-]+)\s*(?:[[<>=!~].*?)?["']/g)) {
|
|
950
|
+
recordPythonDependency(match[1], source, raw, seen);
|
|
951
|
+
}
|
|
952
|
+
for (const match of body.matchAll(/^\s*([A-Za-z0-9_.-]+)\s*=\s*["{]/gm)) {
|
|
953
|
+
recordPythonDependency(match[1], source, raw, seen);
|
|
954
|
+
}
|
|
955
|
+
if (/^\s*\[tool\.poetry\]/m.test(body)) {
|
|
956
|
+
raw.tooling.push({ name: "poetry", category: "package-manager", detail: source, source });
|
|
957
|
+
}
|
|
958
|
+
if (/^\s*\[tool\.uv/m.test(body)) {
|
|
959
|
+
raw.tooling.push({ name: "uv", category: "package-manager", detail: source, source });
|
|
960
|
+
}
|
|
961
|
+
if (/^\s*\[tool\.pytest/m.test(body)) {
|
|
962
|
+
raw.tooling.push({ name: "pytest", category: "test", detail: "pytest config", source });
|
|
963
|
+
raw.commands.push({ name: "pytest", kind: "test", command: "pytest", source });
|
|
964
|
+
}
|
|
965
|
+
// [project.scripts] / [tool.poetry.scripts] entry points
|
|
966
|
+
const sections = body.split(/^\s*\[/m);
|
|
967
|
+
for (const section of sections) {
|
|
968
|
+
if (!/^(project\.scripts|tool\.poetry\.scripts)\]/.test(section)) continue;
|
|
969
|
+
for (const match of section.matchAll(/^\s*([A-Za-z0-9_.-]+)\s*=\s*["']([^"']+)["']/gm)) {
|
|
970
|
+
raw.commands.push({
|
|
971
|
+
name: match[1],
|
|
972
|
+
kind: classifyCommand(match[1], match[2]),
|
|
973
|
+
command: match[2],
|
|
974
|
+
source,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ---------------------------------------------------------------------------
|
|
981
|
+
// Makefile / Taskfile / Procfile
|
|
982
|
+
// ---------------------------------------------------------------------------
|
|
983
|
+
|
|
984
|
+
function handleGoMod(text, source, raw) {
|
|
985
|
+
const body = String(text).replace(/\r\n?/g, "\n");
|
|
986
|
+
const match = body.match(/^\s*module\s+(\S+)/m);
|
|
987
|
+
raw.tooling.push({ name: "go", category: "package-manager", detail: match ? `module ${match[1]}` : source, source });
|
|
988
|
+
raw.commands.push({ name: "go build", kind: "build", command: "go build ./...", source });
|
|
989
|
+
raw.commands.push({ name: "go test", kind: "test", command: "go test ./...", source });
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function handleCargoToml(text, source, raw) {
|
|
993
|
+
const body = String(text).replace(/\r\n?/g, "\n");
|
|
994
|
+
const section = body.split(/^\s*\[/m).find((s) => /^package\]/.test(s)) ?? "";
|
|
995
|
+
const match = section.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
|
|
996
|
+
raw.tooling.push({ name: "cargo", category: "package-manager", detail: match ? `package ${match[1]}` : source, source });
|
|
997
|
+
raw.commands.push({ name: "cargo build", kind: "build", command: "cargo build", source });
|
|
998
|
+
raw.commands.push({ name: "cargo test", kind: "test", command: "cargo test", source });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function handleMakefile(text, source, raw) {
|
|
1002
|
+
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
|
|
1003
|
+
let current = null;
|
|
1004
|
+
for (const line of lines) {
|
|
1005
|
+
const target = line.match(/^([A-Za-z0-9_][A-Za-z0-9_.\-/]*)\s*:(?!=)\s*(.*)$/);
|
|
1006
|
+
if (target) {
|
|
1007
|
+
current = target[1];
|
|
1008
|
+
raw.commands.push({
|
|
1009
|
+
name: `make ${current}`,
|
|
1010
|
+
kind: classifyCommand(current, ""),
|
|
1011
|
+
command: `make ${current}`,
|
|
1012
|
+
source,
|
|
1013
|
+
});
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (current && /^\t/.test(line)) {
|
|
1017
|
+
const recipe = line.replace(/^\t/, "").replace(/^[@-]+/, "").trim();
|
|
1018
|
+
if (recipe && !recipe.startsWith("#")) {
|
|
1019
|
+
for (const envVar of envNamesFromInterpolation(recipe)) {
|
|
1020
|
+
raw.environment.push({ name: envVar, scope: "makefile", source });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const assignment = line.match(/^([A-Z][A-Z0-9_]*)\s*[:?+]?=/);
|
|
1026
|
+
if (assignment) {
|
|
1027
|
+
// NAME only; the assigned value is never recorded.
|
|
1028
|
+
const name = envName(assignment[1]);
|
|
1029
|
+
if (name) raw.environment.push({ name, scope: "makefile", source });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
raw.tooling.push({ name: "make", category: "build", detail: source, source });
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function handleTaskfile(text, source, raw) {
|
|
1036
|
+
const { value, notes } = parseYamlSubset(text);
|
|
1037
|
+
for (const note of notes) raw.notes.push({ level: "warn", message: note, source });
|
|
1038
|
+
const tasks = value && typeof value === "object" ? value.tasks : null;
|
|
1039
|
+
if (!tasks || typeof tasks !== "object") return;
|
|
1040
|
+
for (const [name, spec] of Object.entries(tasks)) {
|
|
1041
|
+
const cmds = spec && typeof spec === "object" ? asList(spec.cmds ?? spec.cmd) : [];
|
|
1042
|
+
const command = cmds
|
|
1043
|
+
.map((c) => (typeof c === "string" ? c : c && typeof c === "object" ? c.cmd ?? c.task : null))
|
|
1044
|
+
.filter((c) => typeof c === "string")
|
|
1045
|
+
.join(" && ");
|
|
1046
|
+
raw.commands.push({
|
|
1047
|
+
name: `task ${name}`,
|
|
1048
|
+
kind: classifyCommand(name, command),
|
|
1049
|
+
command: command || `task ${name}`,
|
|
1050
|
+
source,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
raw.tooling.push({ name: "task", category: "build", detail: source, source });
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function handleProcfile(text, source, raw) {
|
|
1057
|
+
for (const line of String(text).replace(/\r\n?/g, "\n").split("\n")) {
|
|
1058
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
|
|
1059
|
+
if (!match) continue;
|
|
1060
|
+
raw.commands.push({
|
|
1061
|
+
name: match[1],
|
|
1062
|
+
kind: classifyCommand(match[1], match[2]) === "other" ? "start" : classifyCommand(match[1], match[2]),
|
|
1063
|
+
command: match[2],
|
|
1064
|
+
source,
|
|
1065
|
+
});
|
|
1066
|
+
for (const envVar of envNamesFromInterpolation(match[2])) {
|
|
1067
|
+
raw.environment.push({ name: envVar, scope: "procfile", source });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
// Dockerfile / devcontainer
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
function handleDockerfile(text, source, raw) {
|
|
1077
|
+
const joined = String(text).replace(/\r\n?/g, "\n").replace(/\\\n/g, " ");
|
|
1078
|
+
for (const rawLine of joined.split("\n")) {
|
|
1079
|
+
const line = rawLine.trim();
|
|
1080
|
+
if (!line || line.startsWith("#")) continue;
|
|
1081
|
+
const match = line.match(/^([A-Za-z]+)\s+([\s\S]*)$/);
|
|
1082
|
+
if (!match) continue;
|
|
1083
|
+
const directive = match[1].toUpperCase();
|
|
1084
|
+
const rest = match[2].trim();
|
|
1085
|
+
|
|
1086
|
+
if (directive === "EXPOSE") {
|
|
1087
|
+
for (const token of rest.split(/\s+/)) {
|
|
1088
|
+
const parsed = parseComposePort(token, null, source);
|
|
1089
|
+
if (parsed) raw.ports.push({ ...parsed, published: null });
|
|
1090
|
+
}
|
|
1091
|
+
} else if (directive === "ENV" || directive === "ARG") {
|
|
1092
|
+
// NAMES ONLY.
|
|
1093
|
+
const assignments = [...rest.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*=/g)].map((m) => m[1]);
|
|
1094
|
+
const names = assignments.length ? assignments : [rest.split(/\s+/)[0]];
|
|
1095
|
+
for (const candidate of names) {
|
|
1096
|
+
const name = envName(candidate);
|
|
1097
|
+
if (name) {
|
|
1098
|
+
raw.environment.push({
|
|
1099
|
+
name,
|
|
1100
|
+
scope: directive === "ARG" ? "dockerfile-arg" : "dockerfile-env",
|
|
1101
|
+
source,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
} else if (directive === "HEALTHCHECK") {
|
|
1106
|
+
const interval = rest.match(/--interval=(\S+)/);
|
|
1107
|
+
const timeout = rest.match(/--timeout=(\S+)/);
|
|
1108
|
+
const retries = rest.match(/--retries=(\S+)/);
|
|
1109
|
+
const startPeriod = rest.match(/--start-period=(\S+)/);
|
|
1110
|
+
const cmd = rest.match(/\bCMD\b\s+([\s\S]+)$/);
|
|
1111
|
+
raw.healthchecks.push({
|
|
1112
|
+
service: null,
|
|
1113
|
+
test: cmd ? cmd[1] : rest,
|
|
1114
|
+
interval: interval ? interval[1] : null,
|
|
1115
|
+
timeout: timeout ? timeout[1] : null,
|
|
1116
|
+
retries: retries ? retries[1] : null,
|
|
1117
|
+
startPeriod: startPeriod ? startPeriod[1] : null,
|
|
1118
|
+
source,
|
|
1119
|
+
});
|
|
1120
|
+
} else if (directive === "CMD" || directive === "ENTRYPOINT") {
|
|
1121
|
+
const parsedJson = rest.startsWith("[") ? parseJsonLoose(rest) : null;
|
|
1122
|
+
const command = Array.isArray(parsedJson) ? parsedJson.join(" ") : rest;
|
|
1123
|
+
raw.commands.push({ name: `${source} ${directive}`, kind: "start", command, source });
|
|
1124
|
+
} else if (directive === "FROM") {
|
|
1125
|
+
const image = rest.split(/\s+/)[0];
|
|
1126
|
+
if (image) raw.tooling.push({ name: "base image", category: "container", detail: image, source });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
raw.tooling.push({ name: "docker", category: "container", detail: source, source });
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function handleDevcontainer(text, source, raw) {
|
|
1133
|
+
const cfg = parseJsonLoose(text);
|
|
1134
|
+
if (!cfg || typeof cfg !== "object") {
|
|
1135
|
+
raw.notes.push({ level: "warn", message: "devcontainer.json could not be parsed", source });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
raw.tooling.push({ name: "devcontainer", category: "container", detail: source, source });
|
|
1139
|
+
if (typeof cfg.image === "string") {
|
|
1140
|
+
raw.tooling.push({ name: "devcontainer image", category: "container", detail: cfg.image, source });
|
|
1141
|
+
}
|
|
1142
|
+
for (const file of asList(cfg.dockerComposeFile)) {
|
|
1143
|
+
if (typeof file === "string") {
|
|
1144
|
+
raw.tooling.push({ name: "devcontainer compose", category: "container", detail: file, source });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (typeof cfg.service === "string") {
|
|
1148
|
+
raw.services.push({ name: cfg.service, source, image: typeof cfg.image === "string" ? cfg.image : null });
|
|
1149
|
+
}
|
|
1150
|
+
for (const port of [...asList(cfg.forwardPorts), ...asList(cfg.appPort)]) {
|
|
1151
|
+
const parsed = parseComposePort(port, cfg.service ?? null, source);
|
|
1152
|
+
if (parsed) raw.ports.push(parsed);
|
|
1153
|
+
}
|
|
1154
|
+
for (const field of ["postCreateCommand", "postStartCommand", "postAttachCommand", "initializeCommand", "onCreateCommand", "updateContentCommand"]) {
|
|
1155
|
+
const value = cfg[field];
|
|
1156
|
+
const command = typeof value === "string" ? value : Array.isArray(value) ? value.join(" ") : null;
|
|
1157
|
+
if (command) {
|
|
1158
|
+
raw.commands.push({ name: field, kind: classifyCommand(field, command), command, source });
|
|
1159
|
+
} else if (value && typeof value === "object") {
|
|
1160
|
+
for (const [name, inner] of Object.entries(value)) {
|
|
1161
|
+
const flat = flatten(inner);
|
|
1162
|
+
if (flat) raw.commands.push({ name: `${field}.${name}`, kind: classifyCommand(name, flat), command: flat, source });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
for (const field of ["remoteEnv", "containerEnv"]) {
|
|
1167
|
+
const bucket = cfg[field];
|
|
1168
|
+
if (!bucket || typeof bucket !== "object") continue;
|
|
1169
|
+
// NAMES ONLY.
|
|
1170
|
+
for (const name of Object.keys(bucket)) {
|
|
1171
|
+
const valid = envName(name);
|
|
1172
|
+
if (valid) raw.environment.push({ name: valid, scope: `devcontainer-${field}`, source });
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (cfg.features && typeof cfg.features === "object") {
|
|
1176
|
+
for (const feature of Object.keys(cfg.features)) {
|
|
1177
|
+
raw.tooling.push({ name: "devcontainer feature", category: "container", detail: feature, source });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
// E2E configs
|
|
1184
|
+
// ---------------------------------------------------------------------------
|
|
1185
|
+
|
|
1186
|
+
function handleBrowserConfig(text, source, raw, tool) {
|
|
1187
|
+
raw.tooling.push({ name: tool, category: "e2e", detail: source, source });
|
|
1188
|
+
const body = String(text);
|
|
1189
|
+
|
|
1190
|
+
const webServerCommand = body.match(/command\s*:\s*["'`]([^"'`]{1,300})["'`]/);
|
|
1191
|
+
if (webServerCommand) {
|
|
1192
|
+
raw.commands.push({
|
|
1193
|
+
name: `${tool} webServer`,
|
|
1194
|
+
kind: "start",
|
|
1195
|
+
command: webServerCommand[1],
|
|
1196
|
+
source,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
for (const match of body.matchAll(/\b(?:baseURL|baseUrl|url)\s*:\s*["'`]([^"'`]{1,200})["'`]/g)) {
|
|
1200
|
+
const portMatch = match[1].match(/:(\d{2,5})(?:\/|$)/);
|
|
1201
|
+
if (portMatch) {
|
|
1202
|
+
raw.ports.push({ service: null, source, published: portMatch[1], target: portMatch[1], protocol: "tcp", raw: match[1] });
|
|
1203
|
+
}
|
|
1204
|
+
for (const envVar of envNamesFromInterpolation(match[1])) {
|
|
1205
|
+
raw.environment.push({ name: envVar, scope: `${tool}-config`, source });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
for (const match of body.matchAll(/\bport\s*:\s*(\d{2,5})\b/g)) {
|
|
1209
|
+
raw.ports.push({ service: null, source, published: match[1], target: match[1], protocol: "tcp", raw: match[1] });
|
|
1210
|
+
}
|
|
1211
|
+
for (const match of body.matchAll(/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
1212
|
+
const name = envName(match[1]);
|
|
1213
|
+
if (name) raw.environment.push({ name, scope: `${tool}-config`, source });
|
|
1214
|
+
}
|
|
1215
|
+
for (const match of body.matchAll(/process\.env\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\]/g)) {
|
|
1216
|
+
const name = envName(match[1]);
|
|
1217
|
+
if (name) raw.environment.push({ name, scope: `${tool}-config`, source });
|
|
1218
|
+
}
|
|
1219
|
+
raw.commands.push({
|
|
1220
|
+
name: tool === "playwright" ? "playwright test" : "cypress run",
|
|
1221
|
+
kind: "e2e",
|
|
1222
|
+
command: tool === "playwright" ? "npx playwright test" : "npx cypress run",
|
|
1223
|
+
source,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// ---------------------------------------------------------------------------
|
|
1228
|
+
// CI
|
|
1229
|
+
// ---------------------------------------------------------------------------
|
|
1230
|
+
|
|
1231
|
+
const CI_RUN_CAP = 25;
|
|
1232
|
+
|
|
1233
|
+
function handleGithubWorkflow(text, source, raw) {
|
|
1234
|
+
const { value, notes } = parseYamlSubset(text);
|
|
1235
|
+
for (const note of notes) raw.notes.push({ level: "warn", message: note, source });
|
|
1236
|
+
const doc = value && typeof value === "object" ? value : {};
|
|
1237
|
+
const jobs = doc.jobs && typeof doc.jobs === "object" ? doc.jobs : {};
|
|
1238
|
+
const triggers = doc.on && typeof doc.on === "object" && !Array.isArray(doc.on)
|
|
1239
|
+
? Object.keys(doc.on)
|
|
1240
|
+
: asList(doc.on).filter((t) => typeof t === "string");
|
|
1241
|
+
|
|
1242
|
+
const serviceNames = [];
|
|
1243
|
+
let runs = 0;
|
|
1244
|
+
for (const [jobName, job] of Object.entries(jobs)) {
|
|
1245
|
+
const def = job && typeof job === "object" ? job : {};
|
|
1246
|
+
if (def.services && typeof def.services === "object") {
|
|
1247
|
+
for (const [svc, spec] of Object.entries(def.services)) {
|
|
1248
|
+
serviceNames.push(svc);
|
|
1249
|
+
raw.services.push({
|
|
1250
|
+
name: svc,
|
|
1251
|
+
source,
|
|
1252
|
+
image: spec && typeof spec === "object" ? spec.image ?? null : null,
|
|
1253
|
+
environmentNames: spec && typeof spec === "object" ? composeEnvNames(spec.env) : [],
|
|
1254
|
+
});
|
|
1255
|
+
if (spec && typeof spec === "object") {
|
|
1256
|
+
for (const envVar of composeEnvNames(spec.env)) {
|
|
1257
|
+
raw.environment.push({ name: envVar, scope: "ci-service", service: svc, source });
|
|
1258
|
+
}
|
|
1259
|
+
for (const port of asList(spec.ports)) {
|
|
1260
|
+
const parsed = parseComposePort(port, svc, source);
|
|
1261
|
+
if (parsed) raw.ports.push(parsed);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
for (const envVar of composeEnvNames(def.env)) {
|
|
1267
|
+
raw.environment.push({ name: envVar, scope: "ci-job", source });
|
|
1268
|
+
}
|
|
1269
|
+
for (const step of asList(def.steps)) {
|
|
1270
|
+
if (!step || typeof step !== "object") continue;
|
|
1271
|
+
for (const envVar of composeEnvNames(step.env)) {
|
|
1272
|
+
raw.environment.push({ name: envVar, scope: "ci-step", source });
|
|
1273
|
+
}
|
|
1274
|
+
if (typeof step.uses === "string") {
|
|
1275
|
+
raw.tooling.push({ name: "github action", category: "ci", detail: step.uses.split("@")[0], source });
|
|
1276
|
+
}
|
|
1277
|
+
if (typeof step.run === "string" && runs < CI_RUN_CAP) {
|
|
1278
|
+
runs += 1;
|
|
1279
|
+
const first = step.run.split("\n").map((l) => l.trim()).filter(Boolean)[0] || step.run;
|
|
1280
|
+
raw.commands.push({
|
|
1281
|
+
name: `ci:${jobName}:${step.name || `step${runs}`}`,
|
|
1282
|
+
kind: classifyCommand(step.name || jobName, step.run),
|
|
1283
|
+
command: first,
|
|
1284
|
+
source,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
for (const envVar of composeEnvNames(doc.env)) {
|
|
1290
|
+
raw.environment.push({ name: envVar, scope: "ci-workflow", source });
|
|
1291
|
+
}
|
|
1292
|
+
raw.ci.push({
|
|
1293
|
+
path: source,
|
|
1294
|
+
name: typeof doc.name === "string" ? doc.name : null,
|
|
1295
|
+
jobs: Object.keys(jobs),
|
|
1296
|
+
triggers,
|
|
1297
|
+
services: serviceNames,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function handleGitlabCi(text, source, raw) {
|
|
1302
|
+
const { value, notes } = parseYamlSubset(text);
|
|
1303
|
+
for (const note of notes) raw.notes.push({ level: "warn", message: note, source });
|
|
1304
|
+
const doc = value && typeof value === "object" ? value : {};
|
|
1305
|
+
const jobs = [];
|
|
1306
|
+
const serviceNames = [];
|
|
1307
|
+
let runs = 0;
|
|
1308
|
+
for (const [key, spec] of Object.entries(doc)) {
|
|
1309
|
+
if (key.startsWith(".") || !spec || typeof spec !== "object" || Array.isArray(spec)) continue;
|
|
1310
|
+
const scripts = [...asList(spec.script), ...asList(spec.before_script)];
|
|
1311
|
+
if (!scripts.length && !spec.stage) continue;
|
|
1312
|
+
jobs.push(key);
|
|
1313
|
+
for (const svc of asList(spec.services ?? spec.image)) {
|
|
1314
|
+
const name = typeof svc === "string" ? svc : svc && typeof svc === "object" ? svc.name : null;
|
|
1315
|
+
if (typeof name === "string") serviceNames.push(name);
|
|
1316
|
+
}
|
|
1317
|
+
for (const line of scripts) {
|
|
1318
|
+
if (typeof line !== "string" || runs >= CI_RUN_CAP) continue;
|
|
1319
|
+
runs += 1;
|
|
1320
|
+
raw.commands.push({ name: `ci:${key}`, kind: classifyCommand(key, line), command: line, source });
|
|
1321
|
+
}
|
|
1322
|
+
for (const envVar of composeEnvNames(spec.variables)) {
|
|
1323
|
+
raw.environment.push({ name: envVar, scope: "ci-job", source });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
for (const envVar of composeEnvNames(doc.variables)) {
|
|
1327
|
+
raw.environment.push({ name: envVar, scope: "ci-workflow", source });
|
|
1328
|
+
}
|
|
1329
|
+
raw.ci.push({ path: source, name: "gitlab-ci", jobs, triggers: [], services: serviceNames });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ---------------------------------------------------------------------------
|
|
1333
|
+
// Scanner
|
|
1334
|
+
// ---------------------------------------------------------------------------
|
|
1335
|
+
|
|
1336
|
+
const TESTCONTAINERS_RE = /testcontainers/i;
|
|
1337
|
+
|
|
1338
|
+
function emptyRaw() {
|
|
1339
|
+
return {
|
|
1340
|
+
files: [],
|
|
1341
|
+
services: [],
|
|
1342
|
+
commands: [],
|
|
1343
|
+
ports: [],
|
|
1344
|
+
healthchecks: [],
|
|
1345
|
+
dependencies: [],
|
|
1346
|
+
environment: [],
|
|
1347
|
+
tooling: [],
|
|
1348
|
+
profiles: [],
|
|
1349
|
+
seeds: [],
|
|
1350
|
+
ci: [],
|
|
1351
|
+
notes: [],
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Scan a repository and return a normalized evidence package.
|
|
1357
|
+
*
|
|
1358
|
+
* Synchronous, read-only and side-effect free. Missing or unreadable files are
|
|
1359
|
+
* normal; a repo containing none of the recognized files still returns a valid
|
|
1360
|
+
* (empty) evidence package.
|
|
1361
|
+
*
|
|
1362
|
+
* @param {string} repoDir absolute or relative path to the repository root
|
|
1363
|
+
* @param {{limits?: object}} [options]
|
|
1364
|
+
*/
|
|
1365
|
+
export function scanRepository(repoDir, options = {}) {
|
|
1366
|
+
const limits = resolveLimits(options.limits);
|
|
1367
|
+
const root = path.resolve(String(repoDir ?? "."));
|
|
1368
|
+
const raw = emptyRaw();
|
|
1369
|
+
|
|
1370
|
+
let stat = null;
|
|
1371
|
+
try {
|
|
1372
|
+
stat = fs.statSync(root);
|
|
1373
|
+
} catch {
|
|
1374
|
+
stat = null;
|
|
1375
|
+
}
|
|
1376
|
+
if (!stat || !stat.isDirectory()) {
|
|
1377
|
+
raw.notes.push({ level: "warn", message: "repository directory not found or unreadable", source: toPosix(root) });
|
|
1378
|
+
return { ...normalize(raw, { repoDir: root, limits, includeAbsolutePath: options.includeAbsolutePath === true }), fixturePaths: [] };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const { matched, greppable, overflow } = walk(root, limits);
|
|
1382
|
+
if (overflow) {
|
|
1383
|
+
raw.notes.push({ level: "warn", message: "directory walk hit the entry cap; scan is partial" });
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// A path under a fixtures-style directory is sample data for testing the
|
|
1387
|
+
// repo itself, not real infrastructure -- observed directly against this
|
|
1388
|
+
// project: tests/fixtures/node-stack/compose.yaml was reported as a real
|
|
1389
|
+
// service with real ports. Deliberately NOT matching bare "tests/"/
|
|
1390
|
+
// "__tests__/" alone: a repo's genuine test infrastructure (e.g. a real
|
|
1391
|
+
// docker-compose for its own integration suite) can legitimately live
|
|
1392
|
+
// there, and that evidence should still be reported normally. Labeled,
|
|
1393
|
+
// not excluded, from the evidence itself -- this scan still answers "what
|
|
1394
|
+
// did you find", any narrower proposal built from it decides what to trust.
|
|
1395
|
+
const fixturePaths = [...new Set(matched.filter((f) => FIXTURE_PATH_RE.test(f.relPath)).map((f) => f.relPath))];
|
|
1396
|
+
for (const source of fixturePaths) {
|
|
1397
|
+
raw.notes.push({ level: "info", message: "path looks like test fixture data, not real repository infrastructure -- excluded from any automated .nomarmy.yml proposal", source });
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
for (const file of matched) {
|
|
1401
|
+
const source = file.relPath;
|
|
1402
|
+
raw.files.push({ path: source, kind: file.kind });
|
|
1403
|
+
|
|
1404
|
+
// `.env.example` and friends are read for NAMES. A real `.env` is never
|
|
1405
|
+
// classified and therefore never read.
|
|
1406
|
+
const read = readCapped(file.full, limits);
|
|
1407
|
+
if (!read) {
|
|
1408
|
+
raw.notes.push({ level: "info", message: "file could not be read; skipped", source });
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (read.truncated) {
|
|
1412
|
+
raw.notes.push({ level: "info", message: "file truncated at the byte cap before parsing", source });
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
dispatch(file.kind, read.text, source, raw);
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
raw.notes.push({
|
|
1419
|
+
level: "warn",
|
|
1420
|
+
message: `parser error, finding skipped: ${err && err.message ? err.message : "unknown"}`,
|
|
1421
|
+
source,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Bounded content grep for testcontainers usage in source files.
|
|
1427
|
+
let grepped = 0;
|
|
1428
|
+
for (const file of greppable) {
|
|
1429
|
+
if (grepped >= limits.grepFiles) {
|
|
1430
|
+
raw.notes.push({ level: "info", message: "source grep hit the file cap; usage detection is partial" });
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
grepped += 1;
|
|
1434
|
+
const read = readCapped(file.full, { ...limits, fileBytes: Math.min(limits.fileBytes, 32 * 1024) });
|
|
1435
|
+
if (!read) continue;
|
|
1436
|
+
if (TESTCONTAINERS_RE.test(read.text)) {
|
|
1437
|
+
raw.tooling.push({
|
|
1438
|
+
name: "testcontainers",
|
|
1439
|
+
category: "container",
|
|
1440
|
+
detail: "referenced in source",
|
|
1441
|
+
source: file.relPath,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return { ...normalize(raw, { repoDir: root, limits, includeAbsolutePath: options.includeAbsolutePath === true }), fixturePaths };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function dispatch(kind, text, source, raw) {
|
|
1450
|
+
switch (kind) {
|
|
1451
|
+
case "compose":
|
|
1452
|
+
handleCompose(text, source, raw);
|
|
1453
|
+
break;
|
|
1454
|
+
case "dockerfile":
|
|
1455
|
+
handleDockerfile(text, source, raw);
|
|
1456
|
+
break;
|
|
1457
|
+
case "devcontainer":
|
|
1458
|
+
handleDevcontainer(text, source, raw);
|
|
1459
|
+
break;
|
|
1460
|
+
case "package-json":
|
|
1461
|
+
handlePackageJson(text, source, raw);
|
|
1462
|
+
break;
|
|
1463
|
+
case "pnpm-workspace": {
|
|
1464
|
+
const { value } = parseYamlSubset(text);
|
|
1465
|
+
const packages = value && typeof value === "object" ? asList(value.packages) : [];
|
|
1466
|
+
raw.tooling.push({
|
|
1467
|
+
name: "pnpm",
|
|
1468
|
+
category: "package-manager",
|
|
1469
|
+
detail: packages.filter((p) => typeof p === "string").join(", ") || "workspace",
|
|
1470
|
+
source,
|
|
1471
|
+
});
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
case "pyproject":
|
|
1475
|
+
handlePyproject(text, source, raw);
|
|
1476
|
+
break;
|
|
1477
|
+
case "requirements":
|
|
1478
|
+
handleRequirements(text, source, raw);
|
|
1479
|
+
break;
|
|
1480
|
+
case "go-mod":
|
|
1481
|
+
handleGoMod(text, source, raw);
|
|
1482
|
+
break;
|
|
1483
|
+
case "cargo-toml":
|
|
1484
|
+
handleCargoToml(text, source, raw);
|
|
1485
|
+
break;
|
|
1486
|
+
case "makefile":
|
|
1487
|
+
handleMakefile(text, source, raw);
|
|
1488
|
+
break;
|
|
1489
|
+
case "taskfile":
|
|
1490
|
+
handleTaskfile(text, source, raw);
|
|
1491
|
+
break;
|
|
1492
|
+
case "procfile":
|
|
1493
|
+
handleProcfile(text, source, raw);
|
|
1494
|
+
break;
|
|
1495
|
+
case "playwright-config":
|
|
1496
|
+
handleBrowserConfig(text, source, raw, "playwright");
|
|
1497
|
+
break;
|
|
1498
|
+
case "cypress-config":
|
|
1499
|
+
handleBrowserConfig(text, source, raw, "cypress");
|
|
1500
|
+
break;
|
|
1501
|
+
case "env-example":
|
|
1502
|
+
for (const name of envNamesFromDotenv(text)) {
|
|
1503
|
+
// NAMES ONLY: the right-hand side of every line is discarded.
|
|
1504
|
+
raw.environment.push({ name, scope: "env-example", source });
|
|
1505
|
+
}
|
|
1506
|
+
break;
|
|
1507
|
+
case "ci-github":
|
|
1508
|
+
handleGithubWorkflow(text, source, raw);
|
|
1509
|
+
break;
|
|
1510
|
+
case "ci-gitlab":
|
|
1511
|
+
handleGitlabCi(text, source, raw);
|
|
1512
|
+
break;
|
|
1513
|
+
case "ci-circle":
|
|
1514
|
+
case "ci-azure":
|
|
1515
|
+
case "ci-jenkins":
|
|
1516
|
+
raw.ci.push({ path: source, name: kind, jobs: [], triggers: [], services: [] });
|
|
1517
|
+
raw.tooling.push({ name: kind, category: "ci", detail: source, source });
|
|
1518
|
+
break;
|
|
1519
|
+
case "integration-script": {
|
|
1520
|
+
const base = source.slice(source.lastIndexOf("/") + 1);
|
|
1521
|
+
const classified = classifyCommand(base, "");
|
|
1522
|
+
raw.commands.push({
|
|
1523
|
+
name: base,
|
|
1524
|
+
kind: classified === "other" ? "e2e" : classified,
|
|
1525
|
+
command: `./${source}`,
|
|
1526
|
+
source,
|
|
1527
|
+
});
|
|
1528
|
+
for (const name of envNamesFromInterpolation(text)) {
|
|
1529
|
+
raw.environment.push({ name, scope: "script", source });
|
|
1530
|
+
}
|
|
1531
|
+
break;
|
|
1532
|
+
}
|
|
1533
|
+
default:
|
|
1534
|
+
break;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
export default scanRepository;
|