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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
@@ -0,0 +1,679 @@
1
+ // nomArmy v1.3: normalized repository evidence package.
2
+ //
3
+ // This module owns the SHAPE of the evidence that `lib/scan.mjs` produces and
4
+ // the rules that keep it safe to hand to a model:
5
+ //
6
+ // 1. Names only, never values. Environment variables, build args and
7
+ // secrets are recorded by NAME. A value never enters this structure.
8
+ // 2. Bounded. Every category is capped and marks `truncated: true` when it
9
+ // drops findings. Long strings are truncated. Whole files are never
10
+ // inlined.
11
+ // 3. Traceable. Every finding carries the `source` path it was derived from.
12
+ // 4. Deterministic. No timestamps, no randomness, no host paths beyond the
13
+ // caller-supplied repoDir. Items are deduped and sorted.
14
+ //
15
+ // This file performs no I/O and executes nothing.
16
+
17
+ export const EVIDENCE_VERSION = 1;
18
+
19
+ /** Every category present in a normalized evidence package, in output order. */
20
+ export const CATEGORIES = Object.freeze([
21
+ "files",
22
+ "services",
23
+ "commands",
24
+ "ports",
25
+ "healthchecks",
26
+ "dependencies",
27
+ "environment",
28
+ "tooling",
29
+ "profiles",
30
+ "seeds",
31
+ "ci",
32
+ "notes",
33
+ ]);
34
+
35
+ /** Classification used for discovered commands. */
36
+ export const COMMAND_KINDS = Object.freeze([
37
+ "start",
38
+ "build",
39
+ "test",
40
+ "e2e",
41
+ "lint",
42
+ "migrate",
43
+ "seed",
44
+ "other",
45
+ ]);
46
+
47
+ export const DEFAULT_LIMITS = Object.freeze({
48
+ /** Max findings retained per category before `truncated` is set. */
49
+ perCategory: 50,
50
+ /** Max length of a generic recorded string (paths, images, names). */
51
+ stringLength: 240,
52
+ /** Max length of a recorded command line. */
53
+ commandLength: 400,
54
+ /** Max ports / env names / depends_on entries recorded inside one service. */
55
+ perService: 40,
56
+ /** Max bytes read from any single file during scanning. */
57
+ fileBytes: 256 * 1024,
58
+ /** Max files opened for content grepping (testcontainers detection). */
59
+ grepFiles: 300,
60
+ /** Max directory depth walked below the repo root. */
61
+ maxDepth: 5,
62
+ /** Max directory entries visited overall. */
63
+ maxEntries: 20000,
64
+ });
65
+
66
+ export function resolveLimits(overrides) {
67
+ const limits = { ...DEFAULT_LIMITS };
68
+ if (overrides && typeof overrides === "object") {
69
+ for (const key of Object.keys(DEFAULT_LIMITS)) {
70
+ const value = overrides[key];
71
+ if (Number.isFinite(value) && value >= 0) limits[key] = Math.floor(value);
72
+ }
73
+ }
74
+ return Object.freeze(limits);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // String hygiene
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export const TRUNCATION_MARK = "...[truncated]";
82
+
83
+ export function truncateString(value, max) {
84
+ if (typeof value !== "string") return null;
85
+ const flat = value.replace(/\r\n?/g, "\n");
86
+ const limit = Number.isFinite(max) && max > 0 ? max : DEFAULT_LIMITS.stringLength;
87
+ if (flat.length <= limit) return flat;
88
+ // The result must never exceed the limit, even when the limit is shorter
89
+ // than the marker itself.
90
+ if (limit <= TRUNCATION_MARK.length) return flat.slice(0, limit);
91
+ return flat.slice(0, limit - TRUNCATION_MARK.length) + TRUNCATION_MARK;
92
+ }
93
+
94
+ const SECRETISH_NAME = /(pass|passwd|password|secret|token|api[_-]?key|apikey|credential|private[_-]?key|access[_-]?key|session|cookie|auth)/i;
95
+
96
+ /** True when an identifier NAME suggests it holds a credential. */
97
+ export function looksLikeSecretName(name) {
98
+ return typeof name === "string" && SECRETISH_NAME.test(name);
99
+ }
100
+
101
+ /**
102
+ * Remove anything value-shaped from a command line before it is recorded.
103
+ * Commands are useful evidence; the values embedded in them are not.
104
+ */
105
+ export function redactCommand(value, max) {
106
+ if (typeof value !== "string") return null;
107
+ let out = value.replace(/\s+/g, " ").trim();
108
+ // URLs carrying userinfo: scheme://user:pass@host
109
+ out = out.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/@]+@/g, "$1<redacted>@");
110
+ // NAME=value where NAME looks like a credential
111
+ out = out.replace(
112
+ /\b([A-Za-z_][A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|\S+)/g,
113
+ (match, name) => (looksLikeSecretName(name) ? `${name}=<redacted>` : match),
114
+ );
115
+ // Explicit secret-bearing flags
116
+ out = out.replace(
117
+ /(--?(?:password|passwd|token|secret|api[-_]?key|auth)(?:[-_a-z0-9]*)?[=\s])(?:"[^"]*"|'[^']*'|\S+)/gi,
118
+ "$1<redacted>",
119
+ );
120
+ // Authorization header values: Bearer/Basic/Token/ApiKey schemes, whether
121
+ // typed raw or inside a quoted -H/--header argument.
122
+ out = out.replace(/(\bauthorization\s*:\s*)(bearer|basic|token|apikey)(\s+)\S+/gi, "$1$2$3<redacted>");
123
+ // -u/--user/--basic-auth with a user:pass-shaped value (colon, no
124
+ // whitespace) -- e.g. `curl -u admin:hunter2`. Requiring the colon keeps
125
+ // unrelated uses like `sort -u file.txt` (no colon in the value) untouched.
126
+ out = out.replace(/(-u|--user|--basic-auth)([=\s]+)(\S+:\S+)/g, "$1$2<redacted>");
127
+ // Database-CLI glued short-flag passwords, e.g. mysql's `-pSECRET` (no
128
+ // separator between flag and value). Scoped to known DB client binaries
129
+ // specifically: a bare `-p<word>` pattern would collide with unrelated
130
+ // single-dash flags elsewhere (e.g. `go test -parallel 4`).
131
+ if (/(^|[\s/])(mysql|mysqldump|psql|mongo|mongosh|redis-cli)\b/.test(out)) {
132
+ out = out.replace(/(^|\s)-p(\S+)/g, "$1-p<redacted>");
133
+ }
134
+ // Long opaque blobs (likely encoded credentials)
135
+ out = out.replace(/\b[A-Za-z0-9+/_-]{48,}={0,2}\b/g, "<redacted>");
136
+ return truncateString(out, Number.isFinite(max) ? max : DEFAULT_LIMITS.commandLength);
137
+ }
138
+
139
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
140
+
141
+ /** Validate and normalize an environment variable NAME. Returns null if it is not one. */
142
+ export function envName(value) {
143
+ if (typeof value !== "string") return null;
144
+ const name = value.trim();
145
+ if (!name || name.length > 128) return null;
146
+ return ENV_NAME_RE.test(name) ? name : null;
147
+ }
148
+
149
+ /**
150
+ * Extract environment variable NAMES from the text of a dotenv-style example
151
+ * file. Values on the right-hand side of `=` are discarded, never returned.
152
+ */
153
+ export function envNamesFromDotenv(text) {
154
+ const names = [];
155
+ if (typeof text !== "string") return names;
156
+ for (const rawLine of text.replace(/\r\n?/g, "\n").split("\n")) {
157
+ const line = rawLine.trim();
158
+ if (!line || line.startsWith("#")) continue;
159
+ const withoutExport = line.replace(/^export\s+/, "");
160
+ const eq = withoutExport.indexOf("=");
161
+ const candidate = eq === -1 ? withoutExport : withoutExport.slice(0, eq);
162
+ const name = envName(candidate);
163
+ if (name) names.push(name);
164
+ }
165
+ return names;
166
+ }
167
+
168
+ /** Extract `${VAR}` / `$VAR` interpolation NAMES from an arbitrary string. */
169
+ export function envNamesFromInterpolation(text) {
170
+ const names = [];
171
+ if (typeof text !== "string") return names;
172
+ const re = /\$\{?([A-Za-z_][A-Za-z0-9_]*)/g;
173
+ let m;
174
+ while ((m = re.exec(text)) !== null) {
175
+ const name = envName(m[1]);
176
+ if (name) names.push(name);
177
+ }
178
+ return names;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Command classification
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const CLASSIFIERS = [
186
+ ["e2e", /(^|[^a-z])(e2e|end[-_]?to[-_]?end|playwright|cypress|webdriver|selenium|puppeteer|integration(?:[-_]?tests?)?)([^a-z]|$)/i],
187
+ ["migrate", /(migrat|alembic|flyway|liquibase|prisma\s+migrate|knex\s+migrate|rails\s+db:migrate)/i],
188
+ ["seed", /(^|[^a-z])(seed|seeds|fixtures?|bootstrap[-_]?data|db:seed|loaddata)([^a-z]|$)/i],
189
+ ["test", /(^|[^a-z])(tests?|spec|specs|jest|vitest|pytest|mocha|tox|unittest|go\s+test|cargo\s+test|check)([^a-z]|$)/i],
190
+ ["lint", /(^|[^a-z])(lint|eslint|prettier|ruff|flake8|black|mypy|tsc|typecheck|format|fmt|vet|clippy)([^a-z]|$)/i],
191
+ ["build", /(^|[^a-z])(build|compile|bundle|package|dist|webpack|rollup|vite\s+build|tsc\s+-b)([^a-z]|$)/i],
192
+ ["start", /(^|[^a-z])(start|serve|server|dev|develop|run|up|watch|preview|uvicorn|gunicorn|flask\s+run|nodemon|docker[-\s]?compose\s+up)([^a-z]|$)/i],
193
+ ];
194
+
195
+ /**
196
+ * Classify a named command. The NAME dominates because it carries author
197
+ * intent; the command body is only consulted when the name says nothing.
198
+ */
199
+ export function classifyCommand(name, command) {
200
+ for (const [kind, re] of CLASSIFIERS) {
201
+ if (typeof name === "string" && re.test(name)) return kind;
202
+ }
203
+ for (const [kind, re] of CLASSIFIERS) {
204
+ if (typeof command === "string" && re.test(command)) return kind;
205
+ }
206
+ return "other";
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Normalization
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function clean(value, max) {
214
+ const s = truncateString(
215
+ typeof value === "number" || typeof value === "boolean" ? String(value) : value,
216
+ max,
217
+ );
218
+ return s && s.length ? s : null;
219
+ }
220
+
221
+ function uniqueStrings(values, cap, max) {
222
+ const out = [];
223
+ const seen = new Set();
224
+ if (!Array.isArray(values)) return out;
225
+ for (const value of values) {
226
+ const s = clean(value, max);
227
+ if (!s || seen.has(s)) continue;
228
+ seen.add(s);
229
+ out.push(s);
230
+ if (out.length >= cap) break;
231
+ }
232
+ return out;
233
+ }
234
+
235
+ function uniqueEnvNames(values, cap) {
236
+ const out = [];
237
+ const seen = new Set();
238
+ if (!Array.isArray(values)) return out;
239
+ for (const value of values) {
240
+ const name = envName(value);
241
+ if (!name || seen.has(name)) continue;
242
+ seen.add(name);
243
+ out.push(name);
244
+ if (out.length >= cap) break;
245
+ }
246
+ return out.sort();
247
+ }
248
+
249
+ function normalizePort(entry, limits) {
250
+ if (!entry || typeof entry !== "object") return null;
251
+ const published = toPortNumber(entry.published);
252
+ const target = toPortNumber(entry.target);
253
+ const raw = clean(entry.raw, limits.stringLength);
254
+ if (published === null && target === null && !raw) return null;
255
+ return {
256
+ service: clean(entry.service, limits.stringLength),
257
+ published,
258
+ target,
259
+ protocol: clean(entry.protocol, 16) || "tcp",
260
+ host: clean(entry.host, 64),
261
+ raw,
262
+ source: clean(entry.source, limits.stringLength),
263
+ };
264
+ }
265
+
266
+ function toPortNumber(value) {
267
+ if (value === null || value === undefined || value === "") return null;
268
+ const n = Number.parseInt(String(value), 10);
269
+ return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : null;
270
+ }
271
+
272
+ const SANITIZERS = {
273
+ files(item, limits) {
274
+ const path = clean(item?.path, limits.stringLength);
275
+ if (!path) return null;
276
+ return { path, kind: clean(item?.kind, 64) || "other" };
277
+ },
278
+
279
+ services(item, limits) {
280
+ const name = clean(item?.name, limits.stringLength);
281
+ if (!name) return null;
282
+ const source = clean(item?.source, limits.stringLength);
283
+ return {
284
+ name,
285
+ source,
286
+ image: clean(item?.image, limits.stringLength),
287
+ build: clean(item?.build, limits.stringLength),
288
+ command: redactCommand(item?.command, limits.commandLength),
289
+ ports: uniqueStrings(item?.ports, limits.perService, 64),
290
+ environmentNames: uniqueEnvNames(item?.environmentNames, limits.perService),
291
+ envFiles: uniqueStrings(item?.envFiles, limits.perService, limits.stringLength),
292
+ dependsOn: uniqueStrings(item?.dependsOn, limits.perService, limits.stringLength),
293
+ profiles: uniqueStrings(item?.profiles, limits.perService, limits.stringLength),
294
+ healthcheck: item?.healthcheck ? true : false,
295
+ };
296
+ },
297
+
298
+ commands(item, limits) {
299
+ const command = redactCommand(item?.command, limits.commandLength);
300
+ const name = clean(item?.name, limits.stringLength);
301
+ if (!command && !name) return null;
302
+ const kind = COMMAND_KINDS.includes(item?.kind) ? item.kind : "other";
303
+ return {
304
+ name: name || "(unnamed)",
305
+ kind,
306
+ command: command || null,
307
+ source: clean(item?.source, limits.stringLength),
308
+ };
309
+ },
310
+
311
+ ports(item, limits) {
312
+ return normalizePort(item, limits);
313
+ },
314
+
315
+ healthchecks(item, limits) {
316
+ const source = clean(item?.source, limits.stringLength);
317
+ const service = clean(item?.service, limits.stringLength);
318
+ if (!source && !service) return null;
319
+ return {
320
+ service,
321
+ test: redactCommand(item?.test, limits.commandLength),
322
+ interval: clean(item?.interval, 32),
323
+ timeout: clean(item?.timeout, 32),
324
+ retries: clean(item?.retries, 32),
325
+ startPeriod: clean(item?.startPeriod, 32),
326
+ source,
327
+ };
328
+ },
329
+
330
+ dependencies(item, limits) {
331
+ const from = clean(item?.from, limits.stringLength);
332
+ const to = clean(item?.to, limits.stringLength);
333
+ if (!from || !to) return null;
334
+ return {
335
+ from,
336
+ to,
337
+ condition: clean(item?.condition, 64),
338
+ source: clean(item?.source, limits.stringLength),
339
+ };
340
+ },
341
+
342
+ environment(item, limits) {
343
+ const name = envName(item?.name);
344
+ if (!name) return null;
345
+ return {
346
+ name,
347
+ scope: clean(item?.scope, 64) || "unknown",
348
+ service: clean(item?.service, limits.stringLength),
349
+ source: clean(item?.source, limits.stringLength),
350
+ // A hint for reviewers. The VALUE is never present in this package.
351
+ sensitiveName: looksLikeSecretName(name),
352
+ };
353
+ },
354
+
355
+ tooling(item, limits) {
356
+ const name = clean(item?.name, limits.stringLength);
357
+ if (!name) return null;
358
+ return {
359
+ name,
360
+ category: clean(item?.category, 64) || "other",
361
+ detail: clean(item?.detail, limits.stringLength),
362
+ source: clean(item?.source, limits.stringLength),
363
+ };
364
+ },
365
+
366
+ profiles(item, limits) {
367
+ const name = clean(item?.name, limits.stringLength);
368
+ if (!name) return null;
369
+ return {
370
+ name,
371
+ services: uniqueStrings(item?.services, limits.perService, limits.stringLength),
372
+ source: clean(item?.source, limits.stringLength),
373
+ };
374
+ },
375
+
376
+ seeds(item, limits) {
377
+ const target = clean(item?.target, limits.stringLength);
378
+ const name = clean(item?.name, limits.stringLength);
379
+ if (!target && !name) return null;
380
+ return {
381
+ name: name || target,
382
+ kind: clean(item?.kind, 64) || "other",
383
+ target,
384
+ service: clean(item?.service, limits.stringLength),
385
+ source: clean(item?.source, limits.stringLength),
386
+ };
387
+ },
388
+
389
+ ci(item, limits) {
390
+ const path = clean(item?.path, limits.stringLength);
391
+ if (!path) return null;
392
+ return {
393
+ path,
394
+ name: clean(item?.name, limits.stringLength),
395
+ jobs: uniqueStrings(item?.jobs, limits.perService, limits.stringLength),
396
+ triggers: uniqueStrings(item?.triggers, limits.perService, 64),
397
+ services: uniqueStrings(item?.services, limits.perService, limits.stringLength),
398
+ };
399
+ },
400
+
401
+ notes(item, limits) {
402
+ const message = clean(item?.message, limits.stringLength);
403
+ if (!message) return null;
404
+ const level = ["info", "warn", "error"].includes(item?.level) ? item.level : "info";
405
+ return { level, message, source: clean(item?.source, limits.stringLength) };
406
+ },
407
+ };
408
+
409
+ function categoryKey(category, item) {
410
+ switch (category) {
411
+ case "files":
412
+ return item.path;
413
+ case "services":
414
+ return `${item.source || ""}::${item.name}`;
415
+ case "commands":
416
+ return `${item.source || ""}::${item.kind}::${item.name}::${item.command || ""}`;
417
+ case "ports":
418
+ return `${item.source || ""}::${item.service || ""}::${item.host || ""}::${item.published}::${item.target}::${item.protocol}`;
419
+ case "healthchecks":
420
+ return `${item.source || ""}::${item.service || ""}`;
421
+ case "dependencies":
422
+ return `${item.source || ""}::${item.from}->${item.to}`;
423
+ case "environment":
424
+ return `${item.name}::${item.scope}::${item.service || ""}::${item.source || ""}`;
425
+ case "tooling":
426
+ return `${item.category}::${item.name}::${item.source || ""}`;
427
+ case "profiles":
428
+ return `${item.source || ""}::${item.name}`;
429
+ case "seeds":
430
+ return `${item.source || ""}::${item.kind}::${item.name}`;
431
+ case "ci":
432
+ return item.path;
433
+ case "notes":
434
+ return `${item.level}::${item.message}::${item.source || ""}`;
435
+ default:
436
+ return JSON.stringify(item);
437
+ }
438
+ }
439
+
440
+ /** A deterministic, locale-independent comparator. */
441
+ function compareKeys(a, b) {
442
+ if (a === b) return 0;
443
+ return a < b ? -1 : 1;
444
+ }
445
+
446
+ /** An evidence package with every category present and empty. */
447
+ export function emptyEvidence(repoDir = null, limits = DEFAULT_LIMITS) {
448
+ return { ...normalize({}, { repoDir, limits }), fixturePaths: [] };
449
+ }
450
+
451
+ /**
452
+ * Turn raw scanner findings into the normalized, capped, redacted evidence
453
+ * package. Pure: same input always yields the same output.
454
+ *
455
+ * @param {object} raw map of category -> array of raw findings
456
+ * @param {{repoDir?: string, limits?: object}} [options]
457
+ */
458
+ // Last path segment, handling both separators without importing node:path :
459
+ // this module stays dependency-free so it can be reasoned about in isolation.
460
+ function basenameOf(p) {
461
+ const trimmed = String(p).replace(/[\/]+$/, "");
462
+ const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
463
+ return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
464
+ }
465
+
466
+ export function normalize(raw, options = {}) {
467
+ const limits = resolveLimits(options.limits);
468
+ const source = raw && typeof raw === "object" ? raw : {};
469
+
470
+ const evidence = {
471
+ evidenceVersion: EVIDENCE_VERSION,
472
+ // The repo NAME only, never the absolute path. Evidence is designed to be
473
+ // handed to a model (possibly a hosted one) to propose a .nomarmy.yml, and an
474
+ // absolute path carries the local username and directory layout with it.
475
+ // Callers that genuinely need the full path pass includeAbsolutePath: true.
476
+ repoName: typeof options.repoDir === "string" ? basenameOf(options.repoDir) : null,
477
+ repoDir: options.includeAbsolutePath === true && typeof options.repoDir === "string" ? options.repoDir : null,
478
+ limits: {
479
+ perCategory: limits.perCategory,
480
+ stringLength: limits.stringLength,
481
+ commandLength: limits.commandLength,
482
+ },
483
+ truncated: false,
484
+ counts: {},
485
+ };
486
+
487
+ for (const category of CATEGORIES) {
488
+ const sanitize = SANITIZERS[category];
489
+ const input = Array.isArray(source[category]) ? source[category] : [];
490
+ const seen = new Set();
491
+ const rows = [];
492
+
493
+ for (const rawItem of input) {
494
+ const item = sanitize(rawItem, limits);
495
+ if (!item) continue;
496
+ const key = categoryKey(category, item);
497
+ if (seen.has(key)) continue;
498
+ seen.add(key);
499
+ rows.push({ key, item });
500
+ }
501
+
502
+ rows.sort((a, b) => compareKeys(a.key, b.key));
503
+ const total = rows.length;
504
+ const kept = rows.slice(0, limits.perCategory).map((row) => row.item);
505
+ const truncated = total > kept.length;
506
+ if (truncated) evidence.truncated = true;
507
+
508
+ evidence[category] = {
509
+ items: kept,
510
+ truncated,
511
+ // `total` lets a reviewer see how much was dropped without the payload.
512
+ total,
513
+ };
514
+ evidence.counts[category] = kept.length;
515
+ }
516
+
517
+ return evidence;
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Drift comparison (backs `nomarmy scan --check`)
522
+ // ---------------------------------------------------------------------------
523
+
524
+ function asArray(value) {
525
+ if (Array.isArray(value)) return value;
526
+ if (value === null || value === undefined) return [];
527
+ return [value];
528
+ }
529
+
530
+ function items(evidence, category) {
531
+ const bucket = evidence && evidence[category];
532
+ return bucket && Array.isArray(bucket.items) ? bucket.items : [];
533
+ }
534
+
535
+ function configServices(config) {
536
+ if (!config || typeof config !== "object") return [];
537
+ const raw = config.services ?? config.environment?.services ?? null;
538
+ if (Array.isArray(raw)) {
539
+ return raw.map((entry) => (typeof entry === "string" ? { name: entry } : entry)).filter(Boolean);
540
+ }
541
+ if (raw && typeof raw === "object") {
542
+ return Object.entries(raw).map(([name, value]) => ({
543
+ name,
544
+ ...(value && typeof value === "object" ? value : {}),
545
+ }));
546
+ }
547
+ return [];
548
+ }
549
+
550
+ function configServiceNames(config) {
551
+ return configServices(config)
552
+ .map((s) => (typeof s?.name === "string" ? s.name : null))
553
+ .filter(Boolean);
554
+ }
555
+
556
+ function configPorts(config) {
557
+ const out = [];
558
+ if (!config || typeof config !== "object") return out;
559
+ for (const value of asArray(config.ports)) out.push(value);
560
+ for (const service of configServices(config)) {
561
+ for (const value of asArray(service?.ports)) out.push(value);
562
+ if (service?.port !== undefined) out.push(service.port);
563
+ if (service?.healthcheck?.port !== undefined) out.push(service.healthcheck.port);
564
+ }
565
+ return out
566
+ .map((value) => {
567
+ if (value && typeof value === "object") return toPortNumber(value.published ?? value.target ?? value.port);
568
+ const text = String(value);
569
+ const match = text.match(/(\d{1,5})(?!.*\d)/);
570
+ const first = text.match(/^(\d{1,5})/);
571
+ return toPortNumber(first ? first[1] : match ? match[1] : null);
572
+ })
573
+ .filter((n) => n !== null);
574
+ }
575
+
576
+ function configEnvNames(config) {
577
+ const out = [];
578
+ if (!config || typeof config !== "object") return out;
579
+ const sources = [config.env, config.environment, config.environmentNames, config.envNames];
580
+ for (const src of sources) {
581
+ if (Array.isArray(src)) out.push(...src.map((v) => (typeof v === "string" ? v : v?.name)));
582
+ else if (src && typeof src === "object") out.push(...Object.keys(src));
583
+ }
584
+ for (const service of configServices(config)) {
585
+ const src = service?.environment ?? service?.env ?? service?.environmentNames;
586
+ if (Array.isArray(src)) out.push(...src.map((v) => (typeof v === "string" ? String(v).split("=")[0] : v?.name)));
587
+ else if (src && typeof src === "object") out.push(...Object.keys(src));
588
+ }
589
+ return out.map(envName).filter(Boolean);
590
+ }
591
+
592
+ function configCommandKinds(config) {
593
+ const out = [];
594
+ if (!config || typeof config !== "object") return out;
595
+ const commands = config.commands ?? config.scripts ?? null;
596
+ if (Array.isArray(commands)) {
597
+ for (const entry of commands) {
598
+ if (typeof entry === "string") out.push(classifyCommand(entry, entry));
599
+ else if (entry && typeof entry === "object") out.push(entry.kind || classifyCommand(entry.name, entry.command));
600
+ }
601
+ } else if (commands && typeof commands === "object") {
602
+ for (const [name, value] of Object.entries(commands)) {
603
+ out.push(COMMAND_KINDS.includes(name) ? name : classifyCommand(name, typeof value === "string" ? value : ""));
604
+ }
605
+ }
606
+ for (const kind of COMMAND_KINDS) {
607
+ if (typeof config[kind] === "string" && config[kind].trim()) out.push(kind);
608
+ }
609
+ if (typeof config.testCommand === "string") out.push("test");
610
+ if (typeof config.startCommand === "string") out.push("start");
611
+ return out.filter((kind) => COMMAND_KINDS.includes(kind));
612
+ }
613
+
614
+ function diffSets(repoValues, configValues) {
615
+ const repo = new Set(repoValues);
616
+ const cfg = new Set(configValues);
617
+ const sortValues = (set) => [...set].sort((a, b) => compareKeys(String(a), String(b)));
618
+ return {
619
+ missingFromConfig: sortValues(new Set([...repo].filter((v) => !cfg.has(v)))),
620
+ missingFromRepo: sortValues(new Set([...cfg].filter((v) => !repo.has(v)))),
621
+ matched: sortValues(new Set([...repo].filter((v) => cfg.has(v)))),
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Compare a scanned evidence package against an existing nomArmy config and
627
+ * report drift in both directions. Pure: takes both as arguments, returns data,
628
+ * reads no files.
629
+ *
630
+ * `missingFromConfig` = the repo has it, the config does not mention it.
631
+ * `missingFromRepo` = the config declares it, the scan found no evidence.
632
+ */
633
+ export function compareEvidence(evidence, existingConfig) {
634
+ const ev = evidence && typeof evidence === "object" ? evidence : {};
635
+ const configPresent = Boolean(existingConfig && typeof existingConfig === "object");
636
+ const config = configPresent ? existingConfig : null;
637
+
638
+ const repoServiceNames = items(ev, "services").map((s) => s.name);
639
+ const repoPorts = items(ev, "ports")
640
+ .map((p) => (p.published !== null && p.published !== undefined ? p.published : p.target))
641
+ .filter((n) => n !== null && n !== undefined);
642
+ const repoEnvNames = items(ev, "environment").map((e) => e.name);
643
+ const repoCommandKinds = items(ev, "commands").map((c) => c.kind);
644
+
645
+ const sections = {
646
+ services: diffSets(repoServiceNames, configServiceNames(config)),
647
+ ports: diffSets(repoPorts, configPorts(config)),
648
+ environment: diffSets(repoEnvNames, configEnvNames(config)),
649
+ commandKinds: diffSets(repoCommandKinds, configCommandKinds(config)),
650
+ };
651
+
652
+ const notes = [];
653
+ if (!configPresent) {
654
+ notes.push("No existing config supplied; every finding is reported as missing from config.");
655
+ }
656
+ if (ev.truncated) {
657
+ notes.push("Evidence package was truncated; drift may be incomplete.");
658
+ }
659
+
660
+ let missingFromConfig = 0;
661
+ let missingFromRepo = 0;
662
+ for (const section of Object.values(sections)) {
663
+ missingFromConfig += section.missingFromConfig.length;
664
+ missingFromRepo += section.missingFromRepo.length;
665
+ }
666
+
667
+ return {
668
+ evidenceVersion: EVIDENCE_VERSION,
669
+ configPresent,
670
+ ok: missingFromConfig === 0 && missingFromRepo === 0,
671
+ summary: {
672
+ missingFromConfig,
673
+ missingFromRepo,
674
+ total: missingFromConfig + missingFromRepo,
675
+ },
676
+ ...sections,
677
+ notes,
678
+ };
679
+ }