@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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +114 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -4
- package/plugins/extract-python-kafka/extract_test.py +18 -2
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +79 -21
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +70 -6
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +52 -2
- package/src/catalog.test.ts +13 -2
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +336 -6
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +104 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +20 -2
- package/src/lib/derive.ts +1 -0
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +33 -10
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +200 -44
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +61 -1
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- 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
|
|
195
|
-
if (files.size === 0
|
|
196
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
341
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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("
|
|
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
|
-
|
|
114
|
-
expect(
|
|
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))).
|
|
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)).
|
|
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
|
});
|
package/scripts/local-api.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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.
|
|
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("
|
|
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
|
+
});
|