@shortlink-org/portolan 0.2.3 → 0.3.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 (184) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +114 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/README.md +6 -0
  64. package/plugins/extract-python-kafka/extract.py +2 -4
  65. package/plugins/extract-python-kafka/extract_test.py +18 -2
  66. package/plugins/extract-ts/extract.test.ts +2 -2
  67. package/plugins/extract-ts/extract.ts +4 -5
  68. package/plugins/extract-ts/graphql.test.ts +1 -1
  69. package/plugins/openapi/ids.go +261 -0
  70. package/plugins/openapi/ids_test.go +98 -0
  71. package/plugins/portolan-go.wasm +0 -0
  72. package/plugins/pyplugin/catalog.py +12 -1
  73. package/plugins/pyplugin/kafka.py +74 -3
  74. package/plugins/pyplugin/protocol.py +1 -5
  75. package/portolan.json +3 -2
  76. package/schema/portolan.schema.json +34 -0
  77. package/scripts/README.md +18 -12
  78. package/scripts/catalog-sources.mjs +6 -0
  79. package/scripts/delivery-presets.mjs +21 -11
  80. package/scripts/diff.mjs +5 -1
  81. package/scripts/django-aggregates.test.mjs +58 -0
  82. package/scripts/gen-likec4.mjs +79 -21
  83. package/scripts/gen-likec4.test.mjs +25 -2
  84. package/scripts/gen.mjs +118 -115
  85. package/scripts/go-discovery.test.mjs +30 -0
  86. package/scripts/history.mjs +186 -3
  87. package/scripts/history.test.mjs +1 -1
  88. package/scripts/host-plugins/fetch-git.mjs +77 -21
  89. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  90. package/scripts/local-api.mjs +71 -4
  91. package/scripts/local-api.test.mjs +63 -4
  92. package/scripts/local-discovery.mjs +82 -9
  93. package/scripts/manifest.mjs +5 -3
  94. package/scripts/manifest.test.mjs +24 -0
  95. package/scripts/output-diff.mjs +94 -0
  96. package/scripts/output-diff.test.mjs +36 -0
  97. package/scripts/package-smoke.mjs +62 -4
  98. package/scripts/plugin-host.mjs +22 -2
  99. package/scripts/plugin-host.test.mjs +9 -0
  100. package/scripts/plugin-wasm-worker.mjs +4 -1
  101. package/scripts/provenance.mjs +72 -0
  102. package/scripts/provenance.test.mjs +149 -0
  103. package/scripts/run-builtin.mjs +39 -5
  104. package/scripts/schema.mjs +29 -0
  105. package/scripts/warning-policy.mjs +161 -0
  106. package/scripts/warning-policy.test.mjs +56 -0
  107. package/src/app/Breadcrumbs.test.ts +4 -0
  108. package/src/app/Breadcrumbs.tsx +1 -0
  109. package/src/app/Sidebar.tsx +3 -3
  110. package/src/catalog-docs.test.ts +64 -0
  111. package/src/catalog-docs.ts +35 -0
  112. package/src/catalog-error.test.ts +15 -0
  113. package/src/catalog-model.ts +70 -6
  114. package/src/catalog-stores.test.ts +17 -0
  115. package/src/catalog-validation.ts +52 -2
  116. package/src/catalog.test.ts +13 -2
  117. package/src/chat/Starter.tsx +5 -11
  118. package/src/chat/tools.test.ts +27 -0
  119. package/src/chat/tools.ts +5 -9
  120. package/src/components/CatalogStamp.tsx +10 -8
  121. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  122. package/src/components/ChannelRows.test.tsx +54 -0
  123. package/src/components/ChannelRows.tsx +57 -10
  124. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  125. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  126. package/src/components/Integrations.tsx +1 -1
  127. package/src/components/LifecycleDiagram.tsx +28 -12
  128. package/src/components/MachineDocs.tsx +6 -5
  129. package/src/components/MethodRows.tsx +9 -2
  130. package/src/components/ProblemRow.tsx +4 -0
  131. package/src/components/RelationEvidence.test.tsx +14 -0
  132. package/src/components/RelationEvidence.tsx +53 -0
  133. package/src/components/WhatLinksHere.tsx +6 -4
  134. package/src/data.ts +25 -7
  135. package/src/enrich.test.ts +336 -6
  136. package/src/enrich.ts +206 -3
  137. package/src/flow/StepDetail.tsx +104 -54
  138. package/src/flow/answers.test.ts +18 -1
  139. package/src/flow/answers.ts +37 -8
  140. package/src/flow/evidence.test.ts +16 -0
  141. package/src/flow/evidence.ts +34 -0
  142. package/src/index.css +44 -0
  143. package/src/landing/DraggableReveal.tsx +3 -2
  144. package/src/landing/EvidencePipeline.tsx +105 -0
  145. package/src/landing/LandingPage.tsx +2 -59
  146. package/src/lib/backlinks.test.ts +16 -1
  147. package/src/lib/backlinks.ts +20 -0
  148. package/src/lib/catalog-diff.test.ts +18 -0
  149. package/src/lib/catalog-diff.ts +20 -2
  150. package/src/lib/derive.ts +1 -0
  151. package/src/lib/django-aggregates.d.mts +9 -0
  152. package/src/lib/django-aggregates.mjs +36 -0
  153. package/src/lib/django-aggregates.test.ts +29 -0
  154. package/src/lib/django-aggregates.ts +5 -0
  155. package/src/lib/kafka-ui.test.ts +87 -0
  156. package/src/lib/kafka-ui.ts +105 -0
  157. package/src/lib/local-api.ts +20 -2
  158. package/src/lib/setup-info.test.ts +17 -0
  159. package/src/lib/setup-info.ts +58 -0
  160. package/src/lib/warnings.test.ts +54 -0
  161. package/src/lib/warnings.ts +260 -0
  162. package/src/lib/wire-problems.test.ts +21 -0
  163. package/src/lib/wire-problems.ts +62 -1
  164. package/src/likec4/FlowView.tsx +2 -6
  165. package/src/likec4/flow-edges.test.ts +64 -1
  166. package/src/likec4/flow-edges.ts +43 -7
  167. package/src/likec4/view-index.ts +8 -2
  168. package/src/map/ContextMapGraph.tsx +76 -32
  169. package/src/merge.test.ts +23 -0
  170. package/src/merge.ts +33 -10
  171. package/src/pages/AggregatePage.tsx +8 -7
  172. package/src/pages/CatalogFailure.tsx +2 -2
  173. package/src/pages/ContextPage.tsx +6 -5
  174. package/src/pages/ServicePage.tsx +4 -3
  175. package/src/pages/Settings.tsx +200 -44
  176. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  177. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  178. package/src/routes.test.ts +2 -0
  179. package/src/routes.ts +2 -1
  180. package/src/selection/DetailPanel.tsx +61 -1
  181. package/src/virtual-provenance.d.ts +11 -0
  182. package/vite.config.ts +5 -0
  183. package/scripts/vendor-lock.mjs +0 -58
  184. package/scripts/vendor-lock.test.mjs +0 -69
@@ -44,6 +44,18 @@ export const LOCK_NAME = "git.lock.json";
44
44
  export const PIN_NAME = "git.repo.json";
45
45
  export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
46
46
 
47
+ const SNIFF_BYTES = 8 * 1024;
48
+ const BINARY_SUFFIXES = [
49
+ ".tar.gz", ".tar.bz2", ".tar.xz",
50
+ ".7z", ".a", ".apk", ".avif", ".bin", ".bmp", ".bz2", ".class",
51
+ ".deb", ".dll", ".dmg", ".doc", ".docx", ".eot", ".exe", ".gif",
52
+ ".gz", ".ico", ".iso", ".jar", ".jpeg", ".jpg", ".lib", ".mmdb",
53
+ ".mov", ".mp3", ".mp4", ".o", ".otf", ".pdf", ".png", ".pyc",
54
+ ".rar", ".so", ".tar", ".tgz", ".tif", ".tiff", ".ttf", ".war",
55
+ ".wasm", ".webm", ".webp", ".woff", ".woff2", ".xls", ".xlsx",
56
+ ".xz", ".zip",
57
+ ];
58
+
47
59
  export function describe() {
48
60
  return {
49
61
  name: "fetch-git",
@@ -105,7 +117,7 @@ export function run(request, { env = process.env } = {}) {
105
117
  }
106
118
  continue;
107
119
  }
108
- emitFetched(out, dir, want, fetched.commit, fetched.files, generatedAt);
120
+ emitFetched(out, dir, want, fetched.commit, fetched.files, fetched.skipped, generatedAt);
109
121
  }
110
122
 
111
123
  return out.response();
@@ -172,9 +184,11 @@ export function pin(repo, commit, generatedAt = "") {
172
184
  /** The lock, written the way every generated file here is written. */
173
185
  export function encodeLock(entry) {
174
186
  const files = [...entry.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
187
+ const skipped = [...(entry.skipped ?? [])].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
175
188
  const record = { repo: entry.repo, commit: entry.commit };
176
189
  if (entry.paths?.length) record.paths = [...entry.paths].sort();
177
190
  record.files = files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
191
+ if (skipped.length) record.skipped = skipped.map((file) => ({ path: file.path, size: file.size, reason: file.reason }));
178
192
  return `${JSON.stringify({ repos: [record] }, null, 2)}\n`;
179
193
  }
180
194
 
@@ -191,13 +205,15 @@ function live(url, want, out, env) {
191
205
  commit = resolve(url, want.ref, env);
192
206
  out.warn(want.repo, `is not pinned; "${want.ref || "HEAD"}" resolved to ${commit}. Pin it in portolan.json or every run is a lottery.`);
193
207
  }
194
- const files = download(url, commit, want.paths ?? [], env);
195
- if (files.size === 0) throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
196
- return { commit, files };
208
+ const fetched = download(url, commit, want.paths ?? [], env);
209
+ if (fetched.files.size === 0 && fetched.skipped.length === 0) {
210
+ throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
211
+ }
212
+ return { commit, ...fetched };
197
213
  }
198
214
 
199
- function emitFetched(out, dir, want, commit, files, generatedAt) {
200
- const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [] };
215
+ function emitFetched(out, dir, want, commit, files, skipped, generatedAt) {
216
+ const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [], skipped };
201
217
  for (const path of [...files.keys()].sort()) {
202
218
  const contents = files.get(path);
203
219
  out.file(posix.join(dir, path), contents);
@@ -205,6 +221,7 @@ function emitFetched(out, dir, want, commit, files, generatedAt) {
205
221
  }
206
222
  out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
207
223
  out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit, generatedAt));
224
+ warnSkipped(out, want.repo, skipped);
208
225
  }
209
226
 
210
227
  /**
@@ -225,6 +242,13 @@ function emitCached(out, dir, at, want, why, generatedAt = "") {
225
242
  // what is actually on disk rather than what was asked for.
226
243
  out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit, generatedAt));
227
244
  out.warn(want.repo, `not fetched (${why}); the copy committed in this repository is used unchanged`);
245
+ warnSkipped(out, want.repo, held.lock.skipped);
246
+ }
247
+
248
+ function warnSkipped(out, repo, skipped = []) {
249
+ if (skipped.length === 0) return;
250
+ const bytes = skipped.reduce((total, file) => total + file.size, 0);
251
+ out.warn(repo, `skipped ${skipped.length} binary ${skipped.length === 1 ? "file" : "files"} (${bytes} bytes); metadata is recorded in ${LOCK_NAME}`);
228
252
  }
229
253
 
230
254
  /**
@@ -252,7 +276,14 @@ function replay(dir) {
252
276
  if (repos.length !== 1) throw new Error(`${lockPath} names ${repos.length} repositories; expected exactly one`);
253
277
  const entry = repos[0];
254
278
  const files = new Map();
279
+ const kept = [];
280
+ const skipped = [...(Array.isArray(entry.skipped) ? entry.skipped : [])];
255
281
  for (const want of entry.files ?? []) {
282
+ const known = knownBinaryReason(want.path);
283
+ if (known) {
284
+ skipped.push({ path: want.path, size: want.size, reason: known });
285
+ continue;
286
+ }
256
287
  let contents;
257
288
  try {
258
289
  contents = readFileSync(join(dir, ...String(want.path).split("/")));
@@ -261,9 +292,15 @@ function replay(dir) {
261
292
  throw cause;
262
293
  }
263
294
  if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
295
+ const sniffed = sniffedBinaryReason(contents);
296
+ if (sniffed) {
297
+ skipped.push({ path: want.path, size: contents.length, reason: sniffed });
298
+ continue;
299
+ }
264
300
  files.set(want.path, contents);
301
+ kept.push(want);
265
302
  }
266
- return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: entry.files ?? [] }, files };
303
+ return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: kept, skipped }, files };
267
304
  }
268
305
 
269
306
  // --- git --------------------------------------------------------------------
@@ -305,7 +342,7 @@ function resolve(url, ref, env) {
305
342
  * its branches where it does not, which is the case for a plain file://
306
343
  * remote; either way what is read is verified to be that commit.
307
344
  *
308
- * @returns {Map<string, Buffer>} path contents
345
+ * @returns {{files: Map<string, Buffer>, skipped: {path: string, size: number, reason: string}[]}}
309
346
  */
310
347
  function download(url, commit, paths, env) {
311
348
  const tmp = mkdtempSync(join(tmpdir(), "portolan-fetch-git-"));
@@ -326,19 +363,24 @@ function download(url, commit, paths, env) {
326
363
  throw new Error(`${commit} is not a commit ${url} has, or not one reachable from a branch`);
327
364
  }
328
365
 
329
- const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(path)", commit, "--", ...paths], env);
366
+ const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(objectsize) %(path)", commit, "--", ...paths], env);
330
367
  const blobs = [];
368
+ const skipped = [];
331
369
  for (const line of listing.split("\0")) {
332
370
  if (!line) continue;
333
- const [sha, type, ...rest] = line.split(" ");
371
+ const [sha, type, rawSize, ...rest] = line.split(" ");
334
372
  if (type !== "blob") continue;
335
- blobs.push({ sha, path: posix.normalize(rest.join(" ")) });
373
+ const path = posix.normalize(rest.join(" "));
374
+ const size = Number(rawSize);
375
+ const reason = knownBinaryReason(path);
376
+ if (reason) skipped.push({ path, size, reason });
377
+ else blobs.push({ sha, path, size });
336
378
  }
337
379
  const files = new Map();
338
- if (blobs.length === 0) return files;
380
+ if (blobs.length === 0) return { files, skipped };
339
381
 
340
- // One process for every blob: `cat-file --batch` answers each sha with a
341
- // header line and the bytes.
382
+ // One `cat-file --batch` process answers every remaining sha with a header
383
+ // line and the bytes. Known binaries never enter this batch.
342
384
  const batch = execFileSync("git", ["cat-file", "--batch"], {
343
385
  cwd: tmp,
344
386
  env: { ...env, GIT_TERMINAL_PROMPT: "0" },
@@ -352,15 +394,30 @@ function download(url, commit, paths, env) {
352
394
  if (header[1] === "missing") throw new Error(`${blob.path} is missing from ${commit}`);
353
395
  const size = Number(header[2]);
354
396
  const start = newline + 1;
355
- files.set(blob.path, Buffer.from(batch.subarray(start, start + size)));
397
+ const contents = batch.subarray(start, start + size);
398
+ const reason = sniffedBinaryReason(contents);
399
+ if (reason) skipped.push({ path: blob.path, size, reason });
400
+ else files.set(blob.path, Buffer.from(contents));
356
401
  offset = start + size + 1;
357
402
  }
358
- return files;
403
+ return { files, skipped };
359
404
  } finally {
360
405
  rmSync(tmp, { recursive: true, force: true });
361
406
  }
362
407
  }
363
408
 
409
+ function knownBinaryReason(path) {
410
+ const lower = String(path).toLowerCase();
411
+ const suffix = BINARY_SUFFIXES.find((candidate) => lower.endsWith(candidate));
412
+ return suffix ? `known binary extension ${suffix}` : "";
413
+ }
414
+
415
+ function sniffedBinaryReason(contents) {
416
+ if (contents.subarray(0, SNIFF_BYTES).includes(0)) return `NUL byte in first ${SNIFF_BYTES} bytes`;
417
+ const text = contents.toString("utf8");
418
+ return Buffer.from(text, "utf8").equals(contents) ? "" : "invalid UTF-8";
419
+ }
420
+
364
421
  /** Accumulates a response the way plugin.Builder does. */
365
422
  class Builder {
366
423
  files = [];
@@ -372,11 +429,10 @@ class Builder {
372
429
  return;
373
430
  }
374
431
  const text = contents.toString("utf8");
375
- this.files.push(
376
- Buffer.from(text, "utf8").equals(contents)
377
- ? { name, contents: text }
378
- : { name, contents: contents.toString("base64"), encoding: "base64" },
379
- );
432
+ if (!Buffer.from(text, "utf8").equals(contents) || contents.subarray(0, SNIFF_BYTES).includes(0)) {
433
+ throw new Error(`${name} is binary and cannot be emitted by fetch-git`);
434
+ }
435
+ this.files.push({ name, contents: text });
380
436
  }
381
437
 
382
438
  warn(ref, message) {
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
5
6
  import { afterEach, describe, expect, it } from "vitest";
6
7
 
7
8
  import { runPlugin } from "../plugin-host.mjs";
8
- import { OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
9
+ import { LOCK_NAME, OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
9
10
 
10
11
  const created = [];
11
12
  afterEach(() => {
@@ -105,18 +106,71 @@ describe("fetch-git", () => {
105
106
  expect(inCi.warnings).toHaveLength(1);
106
107
  });
107
108
 
108
- it("preserves binary blobs online and from the offline cache", () => {
109
+ it("skips binary blobs online and from the offline cache", () => {
109
110
  const remote = repository();
110
111
  const cacheDir = cache();
111
112
  const first = fetch(options(remote, cacheDir, ["docs/example.png"]), online);
112
113
  const name = `${remote.copyDir}/docs/example.png`;
113
- const image = first.files.find((file) => file.name === name);
114
- expect(image?.encoding).toBe("base64");
114
+ expect(first.files.find((file) => file.name === name)).toBeUndefined();
115
+ expect(first.files.every((file) => file.encoding === undefined)).toBe(true);
116
+ const lockName = `${remote.copyDir}/${LOCK_NAME}`;
117
+ expect(JSON.parse(contentsOf(first, lockName)).repos[0].skipped).toEqual([
118
+ { path: "docs/example.png", size: 10, reason: "known binary extension .png" },
119
+ ]);
120
+ expect(first.warnings[0].message).toContain("skipped 1 binary file (10 bytes)");
115
121
  write(cacheDir, first);
116
- expect(readFileSync(join(cacheDir, name))).toEqual(readFileSync(join(remote.dir, "docs/example.png")));
122
+ expect(() => readFileSync(join(cacheDir, name))).toThrow();
117
123
 
118
124
  const replayed = fetch(options(remote, cacheDir, ["docs/example.png"]), offline);
119
- expect(replayed.files.find((file) => file.name === name)).toEqual(image);
125
+ expect(replayed.files.find((file) => file.name === name)).toBeUndefined();
126
+ expect(JSON.parse(contentsOf(replayed, lockName)).repos[0].skipped).toEqual([
127
+ { path: "docs/example.png", size: 10, reason: "known binary extension .png" },
128
+ ]);
129
+ });
130
+
131
+ it("does not read a large known binary into the plugin response and sniffs unknown binary files", () => {
132
+ const remote = repository();
133
+ const mmdb = "tests/integration/resources/GeoLite2-Country.mmdb";
134
+ const unknown = "tests/integration/resources/blob.data";
135
+ mkdirSync(dirname(join(remote.dir, mmdb)), { recursive: true });
136
+ writeFileSync(join(remote.dir, mmdb), Buffer.alloc(10 * 1024 * 1024, 0xab));
137
+ writeFileSync(join(remote.dir, unknown), Buffer.from([1, 0, 2]));
138
+ remote.git(["add", "."]);
139
+ remote.git(["commit", "--quiet", "-m", "binary fixtures"]);
140
+ remote.commit = remote.git(["rev-parse", "HEAD"]);
141
+
142
+ const response = fetch(options(remote, cache(), [mmdb, unknown]), online);
143
+ expect(response.files.every((file) => file.encoding === undefined)).toBe(true);
144
+ expect(JSON.stringify(response).length).toBeLessThan(20_000);
145
+ const lock = JSON.parse(contentsOf(response, `${remote.copyDir}/${LOCK_NAME}`)).repos[0];
146
+ expect(lock.files).toEqual([]);
147
+ expect(lock.skipped).toEqual([
148
+ { path: mmdb, size: 10 * 1024 * 1024, reason: "known binary extension .mmdb" },
149
+ { path: unknown, size: 3, reason: "NUL byte in first 8192 bytes" },
150
+ ]);
151
+ });
152
+
153
+ it("drops binary files from a cache written by an older Portolan", () => {
154
+ const remote = repository();
155
+ const cacheDir = cache();
156
+ const at = join(cacheDir, remote.copyDir);
157
+ const path = "docs/example.png";
158
+ const contents = readFileSync(join(remote.dir, path));
159
+ mkdirSync(dirname(join(at, path)), { recursive: true });
160
+ writeFileSync(join(at, path), contents);
161
+ writeFileSync(join(at, LOCK_NAME), encodeLock({
162
+ repo: remote.url,
163
+ commit: remote.commit,
164
+ paths: [path],
165
+ files: [{ path, size: contents.length, sha256: createHash("sha256").update(contents).digest("hex") }],
166
+ }));
167
+
168
+ const replayed = fetch(options(remote, cacheDir, [path]), offline);
169
+ expect(replayed.files.some((file) => file.name.endsWith(path))).toBe(false);
170
+ expect(JSON.parse(contentsOf(replayed, `${remote.copyDir}/${LOCK_NAME}`)).repos[0]).toMatchObject({
171
+ files: [],
172
+ skipped: [{ path, size: contents.length, reason: "known binary extension .png" }],
173
+ });
120
174
  });
121
175
 
122
176
  it("names the edited file when a vendored copy no longer matches its lock", () => {
@@ -223,8 +277,8 @@ describe("names", () => {
223
277
  repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f" }],
224
278
  });
225
279
 
226
- const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }] });
227
- expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }] }] });
280
+ const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] });
281
+ expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] }] });
228
282
  expect(JSON.parse(encodeLock({ repo: "r", commit: "c", paths: [], files: [] })).repos[0]).not.toHaveProperty("paths");
229
283
  });
230
284
  });
@@ -20,6 +20,7 @@ import { basename, dirname, join, posix, relative, resolve, sep } from "node:pat
20
20
 
21
21
  import { loadManifest, readManifest, readManifestText } from "./manifest.mjs";
22
22
  import { builtinPluginNames } from "./builtin-plugins.mjs";
23
+ import { djangoAggregateCandidates } from "../src/lib/django-aggregates.mjs";
23
24
  import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
24
25
  import {
25
26
  discoverProject,
@@ -325,7 +326,7 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
325
326
  if (plugin === "graphql") return { ...common, ...detectedOptions, out: "graphql.json" };
326
327
  if (plugin === "proto") return { ...common, ...detectedOptions, out: "proto.json" };
327
328
  if (plugin === "glossary") return { context: group, ...detectedOptions, out: "glossary.json" };
328
- if (plugin === "adr") return { ...detectedOptions, out: "adr.json" };
329
+ if (plugin === "adr") return { scope: [group, component].filter(Boolean).join(".") || "org", ...detectedOptions, out: "adr.json" };
329
330
  return {};
330
331
  }
331
332
 
@@ -617,6 +618,52 @@ export function writeManifest(path, manifest) {
617
618
  }
618
619
  }
619
620
 
621
+ export function djangoAggregateProposals(workspace) {
622
+ const path = join(workspace, "portolan.json");
623
+ const text = readFileSync(path, "utf8");
624
+ const manifest = readManifestText(text, path);
625
+ const revision = createHash("sha256").update(text).digest("hex");
626
+ let report;
627
+ try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
628
+ const proposals = [];
629
+ for (const step of report?.steps ?? []) {
630
+ if (step.phase !== "extract") continue;
631
+ const matches = (manifest.extract ?? []).map((entry, index) => ({ entry, index }))
632
+ .filter(({ entry }) => entry.plugin === step.plugin && entry.in === step.input && entry.out === step.output);
633
+ if (matches.length !== 1) continue;
634
+ const { entry, index } = matches[0];
635
+ for (const message of step.warnings ?? []) {
636
+ const candidates = djangoAggregateCandidates(message);
637
+ if (!candidates) continue;
638
+ const id = `${index}:${candidates.app}`;
639
+ if (proposals.some((proposal) => proposal.id === id)) continue;
640
+ proposals.push({ id, step: index, plugin: entry.plugin, input: entry.in, output: entry.out, message, ...candidates });
641
+ }
642
+ }
643
+ return { revision, stale: !report || report.manifestSha256 !== revision || report.status === "running", proposals };
644
+ }
645
+
646
+ export function saveDjangoAggregates(workspace, request) {
647
+ const current = djangoAggregateProposals(workspace);
648
+ if (current.stale || request.revision !== current.revision) {
649
+ throw new Error("The manifest or extraction report has changed. Regenerate and review the candidates again.");
650
+ }
651
+ if (!Array.isArray(request.selections) || !request.selections.length) throw new Error("Choose at least one aggregate root.");
652
+ const manifest = readManifest(join(workspace, "portolan.json"));
653
+ const seen = new Set();
654
+ for (const choice of request.selections) {
655
+ const proposal = current.proposals.find((candidate) => candidate.id === choice?.id);
656
+ if (!proposal || !proposal.models.some((model) => model.name === choice.model) || seen.has(choice.id)) {
657
+ throw new Error("Choose one of the reported models for each application.");
658
+ }
659
+ seen.add(choice.id);
660
+ const step = manifest.extract[proposal.step];
661
+ step.options = { ...step.options, aggregates: { ...step.options?.aggregates, [proposal.app]: choice.model } };
662
+ }
663
+ writeManifest(join(workspace, "portolan.json"), manifest);
664
+ return { saved: request.selections.length };
665
+ }
666
+
620
667
  export function writeProject(workspace, request) {
621
668
  const manifestPath = join(workspace, "portolan.json");
622
669
  const before = readFileSync(manifestPath, "utf8");
@@ -747,11 +794,21 @@ function feed(job, stream, chunk) {
747
794
  }
748
795
  }
749
796
 
750
- function workspaceFingerprint(workspace) {
797
+ // The tree with nothing in it, which every repository has. Before the first
798
+ // commit there is no HEAD to diff against, and the index diffed against this
799
+ // tree is exactly what has been staged.
800
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
801
+
802
+ export function workspaceFingerprint(workspace) {
751
803
  const hash = createHash("sha256");
752
804
  try {
753
- const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024 };
754
- hash.update(execFileSync("git", ["diff", "--binary", "HEAD", "--", "."], options));
805
+ // Git speaks to nobody here: a workspace that is not a checkout, or one
806
+ // without a commit yet, is a case this function handles, not an error to
807
+ // print from the dev server.
808
+ const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] };
809
+ let base = EMPTY_TREE;
810
+ try { base = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], options).toString().trim() || EMPTY_TREE; } catch {}
811
+ hash.update(execFileSync("git", ["diff", "--binary", base, "--", "."], options));
755
812
  const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], options)
756
813
  .toString().split("\0").filter(Boolean).sort();
757
814
  for (const name of untracked) {
@@ -865,6 +922,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
865
922
  fileCount: event.fileCount,
866
923
  changedCount: event.changedCount,
867
924
  warnings: event.warnings ?? [],
925
+ diagnostics: event.diagnostics ?? [],
868
926
  ...(event.message ? { message: event.message } : {}),
869
927
  }));
870
928
  const facts = new Map(TRIAL_FACTS.map(([key]) => [key, new Set()]));
@@ -912,10 +970,12 @@ export function summarizeProjectTrial(snapshot, plan, events) {
912
970
  for (const term of fragment.terms ?? []) add("terms", term.id ?? term.slug ?? term.name);
913
971
  }
914
972
  const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
973
+ const diagnostics = steps.flatMap((step) => step.diagnostics);
915
974
  return {
916
975
  steps,
917
976
  facts: TRIAL_FACTS.map(([key, label]) => ({ key, label, count: facts.get(key)?.size ?? 0 })).filter((fact) => fact.count > 0),
918
977
  warnings,
978
+ diagnostics,
919
979
  generatedFiles: files.length,
920
980
  };
921
981
  }
@@ -1077,6 +1137,9 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1077
1137
  const active = [...jobs.values()].find((job) => job.status === "running");
1078
1138
  return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
1079
1139
  }
1140
+ if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1141
+ return send(res, 200, djangoAggregateProposals(workspace));
1142
+ }
1080
1143
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
1081
1144
  const features = url.searchParams.has("features")
1082
1145
  ? url.searchParams.get("features").split(",").filter(Boolean)
@@ -1100,6 +1163,10 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1100
1163
  return send(res, 405, { error: "Use a local JSON request." });
1101
1164
  }
1102
1165
  const input = await body(req);
1166
+ if (url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1167
+ if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving aggregate roots.");
1168
+ return send(res, 200, saveDjangoAggregates(workspace, input));
1169
+ }
1103
1170
  if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
1104
1171
  return send(res, 201, installDeliveryPreset(workspace, input));
1105
1172
  }
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { rmSync } from "node:fs";
7
7
 
8
- import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, readLocalSource, removeProject, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, writeProject } from "./local-api.mjs";
8
+ import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, readLocalSource, removeProject, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, workspaceFingerprint, writeProject } from "./local-api.mjs";
9
9
  import { installDeliveryPreset, planDeliveryPreset, providerFromRemote, publicDeliveryPreset } from "./delivery-presets.mjs";
10
10
 
11
11
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -140,13 +140,18 @@ describe("local project setup", () => {
140
140
  writeFileSync(join(root, ".gitlab-ci.yml"), "lint:\n script: echo lint\n");
141
141
  const preview = planDeliveryPreset(root);
142
142
  expect(preview).toMatchObject({ provider: "gitlab", detectedProvider: "gitlab", status: "available" });
143
- expect(preview.files[0].diff).toContain("+\"portolan:check\":");
143
+ expect(preview.features.filter((feature) => feature.selected).map((feature) => feature.id)).toEqual(["pages"]);
144
+ expect(preview.files[0].diff).not.toContain("+\"portolan:check\":");
144
145
  const installed = installDeliveryPreset(root, { provider: "gitlab", revision: preview.revision });
145
146
  expect(installed.status).toBe("installed");
146
147
  const pipeline = readFileSync(join(root, ".gitlab-ci.yml"), "utf8");
147
148
  expect(pipeline).toContain("lint:\n script: echo lint");
148
149
  expect(pipeline).toContain("# >>> Portolan delivery preset >>>");
149
- expect(pipeline).toContain("pages:\n publish: dist");
150
+ expect(pipeline).toContain("\npages:\n");
151
+ expect(pipeline).toContain("stage: deploy");
152
+ expect(pipeline).toContain("tags:\n - runner-type:docker");
153
+ expect(pipeline).toContain("portolan build --output public");
154
+ expect(pipeline).toContain("artifacts:\n paths:\n - public");
150
155
  expect(existsSync(join(root, ".github"))).toBe(false);
151
156
  });
152
157
 
@@ -239,6 +244,33 @@ describe("local project setup", () => {
239
244
  expect(discovery.detections.find((item) => item.plugin === "adr")?.selected).toBe(false);
240
245
  });
241
246
 
247
+ it("recognizes common ADR markdown variants and previews extracted fields", () => {
248
+ const root = workspace();
249
+ const adrRoot = join(root, "services/billing/docs/adr");
250
+ rmSync(join(adrRoot, "0001.md"));
251
+ writeFileSync(join(adrRoot, "0001-store-invoices.md"), "# 1. Store invoices\n\nDate: 2025-01-02\n\n## Status\n\nAccepted\n\n## Decision\n\nUse Postgres.\n");
252
+ writeFileSync(join(adrRoot, "02-package-layout.md"), "# ADR-2. Архитектура пакетов\n\n#### Статус: на рассмотрении\n\n### Решение\n\nУпростить структуру.\n");
253
+
254
+ const discovery = discoverProject(root, "services/billing");
255
+ const adr = discovery.detections.find((item) => item.plugin === "adr");
256
+ expect(adr).toMatchObject({
257
+ selected: true,
258
+ confidence: "high",
259
+ evidence: "docs/adr/*.md",
260
+ options: { files: ["docs/adr/*.md"] },
261
+ });
262
+ expect(adr?.preview).toEqual([
263
+ { file: "docs/adr/0001-store-invoices.md", fields: { number: "1", title: "Store invoices", status: "accepted", date: "2025-01-02" } },
264
+ { file: "docs/adr/02-package-layout.md", fields: { number: "2", title: "Архитектура пакетов", status: "proposed", date: "from git history" } },
265
+ ]);
266
+
267
+ const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
268
+ const plan = planProject(root, manifest, {
269
+ root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["adr"],
270
+ });
271
+ expect(plan.steps[0].options).toEqual({ files: ["docs/adr/*.md"], scope: "finance.billing", out: "adr.json" });
272
+ });
273
+
242
274
  it("names external projects from the repository or selected component instead of the inspection cache", () => {
243
275
  expect(externalProjectDefaults("https://github.com/batazor/microservice-template-ddd")).toEqual({
244
276
  id: "microservice-template-ddd",
@@ -691,7 +723,7 @@ describe("local project setup", () => {
691
723
  plugins: ["openapi", "redis"],
692
724
  steps: [{ plugin: "openapi", out: output }, { plugin: "redis", out: output }],
693
725
  }, [
694
- { type: "step-finished", phase: "extract", plugin: "openapi", output, status: "written", durationMs: 4, fileCount: 1, changedCount: 1, files: [`${output}/api.json`], warnings: ["one route has no description"] },
726
+ { type: "step-finished", phase: "extract", plugin: "openapi", output, status: "written", durationMs: 4, fileCount: 1, changedCount: 1, files: [`${output}/api.json`], warnings: ["one route has no description"], diagnostics: [{ plugin: "openapi", rule: "plugin.openapi.other-test", severity: "warning", action: "Inspect it.", message: "one route has no description", count: 1, project: "billing", phase: "extract", suppressed: true, suppressionReason: "Owned upstream." }] },
695
727
  { type: "step-finished", phase: "extract", plugin: "redis", output, status: "written", durationMs: 3, fileCount: 1, changedCount: 1, files: [`${output}/redis.json`], warnings: [] },
696
728
  { type: "step-finished", phase: "generate", plugin: "markdown", output: "docs", status: "written", durationMs: 2, fileCount: 10, changedCount: 10, files: ["docs/index.md"], warnings: [] },
697
729
  ]);
@@ -709,6 +741,33 @@ describe("local project setup", () => {
709
741
  });
710
742
  expect(result.steps.map((step) => step.plugin)).toEqual(["openapi", "redis"]);
711
743
  expect(result.warnings).toEqual([{ plugin: "openapi", message: "one route has no description" }]);
744
+ expect(result.diagnostics).toEqual([expect.objectContaining({ rule: "plugin.openapi.other-test", suppressed: true })]);
712
745
  expect(result.generatedFiles).toBe(2);
713
746
  });
714
747
  });
748
+
749
+ describe("workspaceFingerprint", () => {
750
+ it("reads a repository before its first commit without complaint, and moves when a file does", () => {
751
+ const root = workspace();
752
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
753
+ execFileSync("git", ["add", "services/billing/go.mod"], { cwd: root });
754
+
755
+ const before = workspaceFingerprint(root);
756
+ expect(workspaceFingerprint(root)).toBe(before);
757
+
758
+ writeFileSync(join(root, "services/billing/go.mod"), "module example.com/billing\n\ngo 1.22\n");
759
+ const staged = workspaceFingerprint(root);
760
+ expect(staged).not.toBe(before);
761
+
762
+ writeFileSync(join(root, "services/billing/api/handlers.go"), "package api\n");
763
+ expect(workspaceFingerprint(root)).not.toBe(staged);
764
+ });
765
+
766
+ it("is the same whether or not the repository has a commit, for the same tree", () => {
767
+ const root = workspace();
768
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
769
+ const unborn = workspaceFingerprint(root);
770
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "--allow-empty", "-m", "empty"], { cwd: root });
771
+ expect(workspaceFingerprint(root)).toBe(unborn);
772
+ });
773
+ });