@shortlink-org/portolan 0.2.4 → 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 +109 -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/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- 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/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 +1 -1
- 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/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 +48 -5
- package/src/catalog-validation.ts +9 -0
- 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/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- 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/catalog-diff.ts +1 -1
- 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/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/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -169,7 +169,7 @@ function goDeployables(root, files) {
|
|
|
169
169
|
return out;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
|
|
172
|
+
function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true, preview = []) {
|
|
173
173
|
if (!candidates.length) return null;
|
|
174
174
|
return {
|
|
175
175
|
plugin,
|
|
@@ -178,15 +178,74 @@ function detected(plugin, candidates, options = {}, label = candidates[0], ambig
|
|
|
178
178
|
candidates,
|
|
179
179
|
options,
|
|
180
180
|
selected,
|
|
181
|
+
...(preview.length ? { preview } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ADR_STATUSES = new Map([
|
|
186
|
+
["proposed", "proposed"], ["draft", "proposed"], ["pending", "proposed"], ["на рассмотрении", "proposed"],
|
|
187
|
+
["accepted", "accepted"], ["approved", "accepted"], ["adopted", "accepted"], ["принято", "accepted"], ["принят", "accepted"],
|
|
188
|
+
["superseded", "superseded"], ["заменено", "superseded"], ["заменён", "superseded"],
|
|
189
|
+
["deprecated", "deprecated"], ["obsolete", "deprecated"], ["устарело", "deprecated"],
|
|
190
|
+
["rejected", "rejected"], ["declined", "rejected"], ["отклонено", "rejected"],
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
function normalizedAdrStatus(value) {
|
|
194
|
+
return ADR_STATUSES.get(value.trim().replace(/[.:]+$/, "").toLowerCase()) ?? "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Discovery mirrors the tolerant shapes accepted by extract-adr closely
|
|
198
|
+
// enough to enable the capability with confidence and to show what it found.
|
|
199
|
+
// Git-backed dates are resolved by the extractor, so a format without an
|
|
200
|
+
// explicit Date can still be previewed here without inventing one.
|
|
201
|
+
function adrPreview(root, name) {
|
|
202
|
+
let source = "";
|
|
203
|
+
try { source = readFileSync(join(root, name), "utf8").replaceAll("\r\n", "\n"); } catch { return null; }
|
|
204
|
+
const lines = source.split("\n");
|
|
205
|
+
const heading = lines.find((line) => line.trim()) ?? "";
|
|
206
|
+
const base = posix.basename(name, ".md");
|
|
207
|
+
const numberedFile = /^(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(base);
|
|
208
|
+
|
|
209
|
+
let number = "";
|
|
210
|
+
let title = "";
|
|
211
|
+
let match = /^#\s+[a-z][a-z0-9.-]*\.(\d{4})\s+—\s+(.+?)\s*$/i.exec(heading);
|
|
212
|
+
if (match) [number, title] = match.slice(1);
|
|
213
|
+
else if ((match = /^#\s+(\d+)\.\s+(.+?)\s*$/.exec(heading))) [number, title] = match.slice(1);
|
|
214
|
+
else if ((match = /^#\s+ADR[-\s]?0*(\d+)\s*[.:—-]\s*(.+?)\s*$/i.exec(heading))) [number, title] = match.slice(1);
|
|
215
|
+
else if (numberedFile && (match = /^#\s+(.+?)\s*$/.exec(heading))) {
|
|
216
|
+
number = numberedFile[1];
|
|
217
|
+
title = match[1];
|
|
218
|
+
}
|
|
219
|
+
if (!number || !title || !numberedFile || Number(numberedFile[1]) !== Number(number)) return null;
|
|
220
|
+
if (!lines.some((line) => /^#{2,6}\s/.test(line))) return null;
|
|
221
|
+
|
|
222
|
+
let status = "";
|
|
223
|
+
const bullet = /^-\s+\*\*Status:\*\*\s*(.*?)\s*$/mi.exec(source);
|
|
224
|
+
if (bullet) status = normalizedAdrStatus(bullet[1]);
|
|
225
|
+
if (!status) {
|
|
226
|
+
const at = lines.findIndex((line) => /^#{2,6}\s+(?:Status|Статус)\s*:?/i.test(line));
|
|
227
|
+
if (at >= 0) {
|
|
228
|
+
const inline = /^#{2,6}\s+(?:Status|Статус)\s*:?\s*(.*?)\s*$/i.exec(lines[at])?.[1] ?? "";
|
|
229
|
+
const following = lines.slice(at + 1).find((line) => line.trim() && !/^#{1,6}\s/.test(line)) ?? "";
|
|
230
|
+
status = normalizedAdrStatus(inline || following);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!status) return null;
|
|
234
|
+
|
|
235
|
+
const writtenDate = /^(?:-\s+\*\*Date:\*\*|Date:)\s*(\S.*?)\s*$/mi.exec(source)?.[1];
|
|
236
|
+
if (writtenDate) {
|
|
237
|
+
const stamp = new Date(`${writtenDate}T00:00:00Z`);
|
|
238
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(writtenDate) || Number.isNaN(stamp.getTime()) || stamp.toISOString().slice(0, 10) !== writtenDate) return null;
|
|
239
|
+
}
|
|
240
|
+
const date = writtenDate ?? "from git history";
|
|
241
|
+
return {
|
|
242
|
+
file: name,
|
|
243
|
+
fields: { number: String(Number(number)), title: title.trim(), status, date },
|
|
181
244
|
};
|
|
182
245
|
}
|
|
183
246
|
|
|
184
247
|
function compatibleAdrs(root, candidates) {
|
|
185
|
-
return candidates.
|
|
186
|
-
let source = "";
|
|
187
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
|
|
188
|
-
return /^#\s+[^\n]+\.\d{4}\s+[—-]/m.test(source) && /^-\s+\*\*Status:\*\*/mi.test(source) && /^-\s+\*\*Date:\*\*/mi.test(source);
|
|
189
|
-
});
|
|
248
|
+
return candidates.map((name) => adrPreview(root, name)).filter(Boolean);
|
|
190
249
|
}
|
|
191
250
|
|
|
192
251
|
function goDomainEvidence(root, files) {
|
|
@@ -211,6 +270,18 @@ function goDomainEvidence(root, files) {
|
|
|
211
270
|
return "";
|
|
212
271
|
}
|
|
213
272
|
|
|
273
|
+
// This is a discovery hint; the Go AST extractor proves the handler binding.
|
|
274
|
+
function goHTTPServerEvidence(root, files) {
|
|
275
|
+
for (const name of matches(files, /\.go$/).filter((name) => !/(?:_test|\.gen|_generated)\.go$/.test(name) && !/(?:^|\/)(?:testdata|vendor)\//.test(name))) {
|
|
276
|
+
let source;
|
|
277
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
278
|
+
if (/Code generated .*DO NOT EDIT/.test(source)) continue;
|
|
279
|
+
if (/"net\/http"/.test(source) && /\.Handle(?:Func)?\s*\(/.test(source)) return name;
|
|
280
|
+
if (/"github\.com\/(?:go-chi\/chi|gin-gonic\/gin|labstack\/echo)(?:\/v\d+)?"/.test(source) && /\.(?:Get|Post|Put|Patch|Delete|GET|POST|PUT|PATCH|DELETE)\s*\(/.test(source)) return name;
|
|
281
|
+
}
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
|
|
214
285
|
function laidOutDomainEvidence(root, files, language) {
|
|
215
286
|
const extension = language === "typescript" ? "ts" : language === "rust" ? "rs" : "java";
|
|
216
287
|
const prefix = language === "java" ? /(?:^|\/)domain\/([^/]+)\/[^/]+\.java$/i : /^src\/domain\/([^/]+)\/[^/]+\.(?:ts|rs)$/i;
|
|
@@ -291,8 +362,9 @@ function detectionsFor(root, files) {
|
|
|
291
362
|
const graphql = matches(files, /\.graphqls?$/i);
|
|
292
363
|
const protos = matches(files, /\.proto$/i);
|
|
293
364
|
const sql = matches(files, /(^|\/)(migrations?|repository)(\/|.*\/).*\.sql$/i);
|
|
294
|
-
const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i);
|
|
295
|
-
const
|
|
365
|
+
const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i).filter((name) => posix.basename(name).toLowerCase() !== "readme.md");
|
|
366
|
+
const adrPreviews = compatibleAdrs(root, adrs);
|
|
367
|
+
const supportedAdrs = adrPreviews.map((item) => item.file);
|
|
296
368
|
const glossaries = matches(files, /(^|\/)glossary\.md$/i);
|
|
297
369
|
// The app module is the one file a Celery project always has; the tasks
|
|
298
370
|
// and the calls that enqueue them are found from there.
|
|
@@ -308,7 +380,7 @@ function detectionsFor(root, files) {
|
|
|
308
380
|
const protoDirs = compactDirectories(protos);
|
|
309
381
|
const projectMarkers = ["go.mod", "package.json", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "manage.py", "Dockerfile", "README.md"].filter((name) => files.has(name));
|
|
310
382
|
const projectEvidence = projectMarkers.length ? projectMarkers : [[...files].sort()[0]].filter(Boolean);
|
|
311
|
-
const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) : "";
|
|
383
|
+
const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) || goHTTPServerEvidence(root, files) : "";
|
|
312
384
|
const goHTTPClient = files.has("go.mod") ? goHTTPClientEvidence(root, files) : "";
|
|
313
385
|
const goSOAPClient = files.has("go.mod") ? goSOAPClientEvidence(root, files) : "";
|
|
314
386
|
const goRedis = files.has("go.mod") ? goRedisEvidence(root, files) : "";
|
|
@@ -349,6 +421,7 @@ function detectionsFor(root, files) {
|
|
|
349
421
|
supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
|
|
350
422
|
!supportedAdrs.length,
|
|
351
423
|
supportedAdrs.length > 0,
|
|
424
|
+
adrPreviews,
|
|
352
425
|
),
|
|
353
426
|
detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
|
|
354
427
|
].filter(Boolean);
|
package/scripts/manifest.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { readFileSync } from "node:fs";
|
|
|
12
12
|
import { normalize } from "node:path";
|
|
13
13
|
|
|
14
14
|
import Ajv from "ajv/dist/2020.js";
|
|
15
|
+
import { warningPolicyProblems } from "./warning-policy.mjs";
|
|
15
16
|
|
|
16
17
|
// Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
|
|
17
18
|
const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
|
|
@@ -38,20 +39,21 @@ export function loadManifest(path = "portolan.json") {
|
|
|
38
39
|
*/
|
|
39
40
|
export function parseManifest(text, path = "portolan.json") {
|
|
40
41
|
const manifest = JSON.parse(text);
|
|
42
|
+
const policyProblems = warningPolicyProblems(manifest.warningPolicies, path);
|
|
41
43
|
|
|
42
44
|
let schema;
|
|
43
45
|
try {
|
|
44
46
|
schema = JSON.parse(readFileSync(schemaFile(), "utf8"));
|
|
45
47
|
} catch {
|
|
46
|
-
return { manifest, problems:
|
|
48
|
+
return { manifest, problems: policyProblems };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
const ajv = new Ajv({ allErrors: true, strictSchema: false });
|
|
50
52
|
const validate = ajv.compile(schema);
|
|
51
53
|
|
|
52
|
-
if (validate(manifest)) return { manifest, problems:
|
|
54
|
+
if (validate(manifest)) return { manifest, problems: policyProblems };
|
|
53
55
|
|
|
54
|
-
return { manifest, problems: explain(validate.errors ?? [], manifest, schema, path) };
|
|
56
|
+
return { manifest, problems: [...explain(validate.errors ?? [], manifest, schema, path), ...policyProblems] };
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -58,6 +58,30 @@ describe("the manifest schema", () => {
|
|
|
58
58
|
expect(check(good)).toEqual([]);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it("accepts a typed CEL warning policy only with an action and reason", () => {
|
|
62
|
+
expect(check({
|
|
63
|
+
...good,
|
|
64
|
+
warningPolicies: [{
|
|
65
|
+
when: "plugin == 'openapi' && rule == 'openapi.missing-operation-id' && count > 10",
|
|
66
|
+
action: "suppress",
|
|
67
|
+
reason: "The contract is owned upstream.",
|
|
68
|
+
}],
|
|
69
|
+
})).toEqual([]);
|
|
70
|
+
|
|
71
|
+
const problems = check({
|
|
72
|
+
...good,
|
|
73
|
+
warningPolicies: [{ when: "plugin == 'openapi'", action: "suppress" }],
|
|
74
|
+
});
|
|
75
|
+
expect(problems.join("\n")).toContain('warningPolicies/0: "reason" is missing');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("refuses a CEL warning policy with unknown variables or a non-boolean result", () => {
|
|
79
|
+
expect(check({ ...good, warningPolicies: [{ when: "owner == 'team'", action: "suppress", reason: "test" }] }).join("\n"))
|
|
80
|
+
.toContain("Unknown variable: owner");
|
|
81
|
+
expect(check({ ...good, warningPolicies: [{ when: "plugin", action: "suppress", reason: "test" }] }).join("\n"))
|
|
82
|
+
.toContain("CEL expression must return bool");
|
|
83
|
+
});
|
|
84
|
+
|
|
61
85
|
it("refuses an unstable project id", () => {
|
|
62
86
|
const problems = check({
|
|
63
87
|
...good,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Why a generated file is not what the generator produces now, in one line.
|
|
2
|
+
//
|
|
3
|
+
// `gen --check` used to say "changed docs/x.md" and nothing else, which sent
|
|
4
|
+
// the reader to a diff of the whole file to learn that one number moved.
|
|
5
|
+
// The first difference is usually the whole story: a JSON fragment names the
|
|
6
|
+
// path that differs and both values, a page names the line.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string | Buffer | null} current what is on disk, null when absent
|
|
10
|
+
* @param {string | Buffer} wanted what the generator produced
|
|
11
|
+
* @param {string} name the file's name, for its format
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function explainChange(current, wanted, name) {
|
|
15
|
+
if (current === null) return "not on disk yet";
|
|
16
|
+
if (Buffer.isBuffer(current) || Buffer.isBuffer(wanted)) return "binary contents differ";
|
|
17
|
+
if (name.endsWith(".json")) {
|
|
18
|
+
const found = jsonDifference(current, wanted);
|
|
19
|
+
if (found) return found;
|
|
20
|
+
}
|
|
21
|
+
return lineDifference(current, wanted);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The first path where two JSON documents disagree, or "" when either does not parse. */
|
|
25
|
+
export function jsonDifference(current, wanted) {
|
|
26
|
+
let a;
|
|
27
|
+
let b;
|
|
28
|
+
try {
|
|
29
|
+
a = JSON.parse(current);
|
|
30
|
+
b = JSON.parse(wanted);
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
return firstDifference(a, b, "") ?? "same value, different formatting";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function firstDifference(a, b, path) {
|
|
38
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
39
|
+
const shared = Math.min(a.length, b.length);
|
|
40
|
+
for (let i = 0; i < shared; i++) {
|
|
41
|
+
const found = firstDifference(a[i], b[i], `${path}[${i}]`);
|
|
42
|
+
if (found) return found;
|
|
43
|
+
}
|
|
44
|
+
if (a.length !== b.length) {
|
|
45
|
+
return `${path || "the document"}: ${a.length} item${a.length === 1 ? "" : "s"} → ${b.length}`;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (isObject(a) && isObject(b)) {
|
|
50
|
+
for (const key of Object.keys(b)) {
|
|
51
|
+
const at = path ? `${path}.${key}` : key;
|
|
52
|
+
if (!(key in a)) return `${at}: added ${short(b[key])}`;
|
|
53
|
+
const found = firstDifference(a[key], b[key], at);
|
|
54
|
+
if (found) return found;
|
|
55
|
+
}
|
|
56
|
+
for (const key of Object.keys(a)) {
|
|
57
|
+
if (!(key in b)) return `${path ? `${path}.${key}` : key}: removed`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (Object.is(a, b)) return null;
|
|
62
|
+
return `${path || "the document"}: ${short(a)} → ${short(b)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The first line that differs, with both sides, or the extra lines when one is a prefix of the other. */
|
|
66
|
+
export function lineDifference(current, wanted) {
|
|
67
|
+
const a = current.split("\n");
|
|
68
|
+
const b = wanted.split("\n");
|
|
69
|
+
const shared = Math.min(a.length, b.length);
|
|
70
|
+
for (let i = 0; i < shared; i++) {
|
|
71
|
+
if (a[i] !== b[i]) return `line ${i + 1}: ${short(a[i])} → ${short(b[i])}`;
|
|
72
|
+
}
|
|
73
|
+
if (a.length === b.length) return "identical";
|
|
74
|
+
const extra = Math.abs(a.length - b.length);
|
|
75
|
+
return a.length < b.length
|
|
76
|
+
? `line ${shared + 1}: ${extra} line${extra === 1 ? "" : "s"} added`
|
|
77
|
+
: `line ${shared + 1}: ${extra} line${extra === 1 ? "" : "s"} removed`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isObject(value) {
|
|
81
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A value as one short token: strings quoted, structures summarised. */
|
|
85
|
+
function short(value) {
|
|
86
|
+
const text = typeof value === "string" ? JSON.stringify(value) : isObject(value) || Array.isArray(value) ? summarise(value) : String(value);
|
|
87
|
+
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function summarise(value) {
|
|
91
|
+
if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
|
|
92
|
+
const keys = Object.keys(value);
|
|
93
|
+
return `{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", …" : ""}}`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { explainChange, jsonDifference, lineDifference } from "./output-diff.mjs";
|
|
4
|
+
|
|
5
|
+
describe("explainChange", () => {
|
|
6
|
+
it("names the JSON path and both values", () => {
|
|
7
|
+
const current = JSON.stringify({ contexts: [{ services: [{ commands: [{ source: "package.json:58" }] }] }] });
|
|
8
|
+
const wanted = JSON.stringify({ contexts: [{ services: [{ commands: [{ source: "package.json:59" }] }] }] });
|
|
9
|
+
expect(explainChange(current, wanted, "commands.json")).toBe(
|
|
10
|
+
'contexts[0].services[0].commands[0].source: "package.json:58" → "package.json:59"',
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("says what was added, what was removed, and when a list grew", () => {
|
|
15
|
+
expect(jsonDifference('{"a":1}', '{"a":1,"b":{"x":1,"y":2}}')).toBe("b: added {x, y}");
|
|
16
|
+
expect(jsonDifference('{"a":1,"b":2}', '{"a":1}')).toBe("b: removed");
|
|
17
|
+
expect(jsonDifference('{"flows":[1]}', '{"flows":[1,2,3]}')).toBe("flows: 1 item → 3");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("falls back to lines for text, and for JSON that does not parse", () => {
|
|
21
|
+
expect(lineDifference("a\nb\nc", "a\nB\nc")).toBe('line 2: "b" → "B"');
|
|
22
|
+
expect(lineDifference("a\nb", "a\nb\nc\nd")).toBe("line 3: 2 lines added");
|
|
23
|
+
expect(lineDifference("a\nb\nc", "a")).toBe("line 2: 2 lines removed");
|
|
24
|
+
expect(explainChange("{not json", "{still not", "x.json")).toBe('line 1: "{not json" → "{still not"');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("knows a file that is not there yet, and a binary one", () => {
|
|
28
|
+
expect(explainChange(null, "x", "x.md")).toBe("not on disk yet");
|
|
29
|
+
expect(explainChange(Buffer.from([1]), Buffer.from([2]), "x.png")).toBe("binary contents differ");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps a long value readable", () => {
|
|
33
|
+
const long = "x".repeat(200);
|
|
34
|
+
expect(jsonDifference('{"a":"short"}', `{"a":"${long}"}`)).toMatch(/^a: "short" → "x{56}…$/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// This catches accidental cwd coupling before the same package reaches npm.
|
|
3
3
|
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { resolve } from "node:path";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
10
|
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
@@ -31,9 +31,58 @@ try {
|
|
|
31
31
|
writeFileSync(resolve(fixture, "package.json"), '{"name":"package-smoke","version":"1.0.0"}\n');
|
|
32
32
|
writeFileSync(resolve(fixture, "README.md"), "# Package smoke\n");
|
|
33
33
|
// A Go domain layout: init should notice it and wire the extractor without being asked.
|
|
34
|
-
writeFileSync(resolve(fixture, "go.mod"), "module example.com/smoke\n\ngo 1.
|
|
34
|
+
writeFileSync(resolve(fixture, "go.mod"), "module example.com/smoke\n\ngo 1.27.0\n");
|
|
35
35
|
mkdirSync(resolve(fixture, "internal/domain/order"), { recursive: true });
|
|
36
36
|
writeFileSync(resolve(fixture, "internal/domain/order/order.go"), "package order\n\ntype Order struct{ ID string }\n");
|
|
37
|
+
writeGoFixture("app/main.go", `package app
|
|
38
|
+
import (
|
|
39
|
+
"example.com/smoke/actions/rules"
|
|
40
|
+
"example.com/smoke/connector"
|
|
41
|
+
)
|
|
42
|
+
type Router struct{}
|
|
43
|
+
func (*Router) POST(string, func()) {}
|
|
44
|
+
type Requester interface { ConnExec(connector.API) }
|
|
45
|
+
func Start(r *Router) { r.POST("/rules", RulesAction) }
|
|
46
|
+
func RulesAction() {
|
|
47
|
+
request := &rules.Request{}
|
|
48
|
+
Dispatch(request)
|
|
49
|
+
}
|
|
50
|
+
func Dispatch(request Requester) { Invoke(request) }
|
|
51
|
+
func Invoke(request Requester) {
|
|
52
|
+
conn := connector.Build("runtime")
|
|
53
|
+
request.ConnExec(conn)
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
writeGoFixture("actions/rules/request.go", `package rules
|
|
57
|
+
import "example.com/smoke/connector"
|
|
58
|
+
type Request struct{}
|
|
59
|
+
func (*Request) ConnExec(conn connector.API) { conn.Rules() }
|
|
60
|
+
`);
|
|
61
|
+
writeGoFixture("connector/factory.go", `package connector
|
|
62
|
+
import "example.com/smoke/provider/alpha"
|
|
63
|
+
type API interface { Rules() }
|
|
64
|
+
func Build(name string) API {
|
|
65
|
+
switch name { case "alpha": return alpha.New(); default: return nil }
|
|
66
|
+
}
|
|
67
|
+
`);
|
|
68
|
+
writeGoFixture("provider/alpha/connector.go", `package alpha
|
|
69
|
+
import "example.com/smoke/provider/alpha/client"
|
|
70
|
+
type rulesClient interface { FetchRules() }
|
|
71
|
+
type Connector struct { client rulesClient }
|
|
72
|
+
func New() *Connector { return &Connector{client: &client.Client{}} }
|
|
73
|
+
func (c *Connector) Rules() { c.client.FetchRules() }
|
|
74
|
+
`);
|
|
75
|
+
writeGoFixture("provider/alpha/client/client.go", `package client
|
|
76
|
+
import "net/http"
|
|
77
|
+
type Client struct{}
|
|
78
|
+
func (c *Client) FetchRules() { c.fetchRules() }
|
|
79
|
+
func (c *Client) fetchRules() { c.finishRules() }
|
|
80
|
+
func (c *Client) finishRules() {
|
|
81
|
+
_, _ = http.Get("https://alpha.example/v1/rules")
|
|
82
|
+
if false { c.fetchRules() }
|
|
83
|
+
}
|
|
84
|
+
func (*Client) CheckRules() { _, _ = http.Get("https://alpha.example/v1/check-rules") }
|
|
85
|
+
`);
|
|
37
86
|
run("git", ["init", "--quiet"]);
|
|
38
87
|
run("git", ["config", "user.email", "portolan@example.invalid"]);
|
|
39
88
|
run("git", ["config", "user.name", "Portolan smoke test"]);
|
|
@@ -44,9 +93,13 @@ try {
|
|
|
44
93
|
run(process.execPath, [cli, "check", "--cwd", fixture]);
|
|
45
94
|
run(process.execPath, [cli, "build", "--cwd", fixture, "--output", "dist", "--base", "/architecture/"]);
|
|
46
95
|
|
|
47
|
-
for (const path of ["portolan/project.json", "portolan/domain.json", "docs/README.md", "dist/index.html", "dist/404.html"]) {
|
|
96
|
+
for (const path of ["portolan/project.json", "portolan/domain.json", "portolan/http-clients.json", "docs/README.md", "dist/index.html", "dist/404.html"]) {
|
|
48
97
|
if (!existsSync(resolve(fixture, path))) throw new Error(`smoke test did not write ${path}`);
|
|
49
98
|
}
|
|
99
|
+
const calls = JSON.parse(readFileSync(resolve(fixture, "portolan/http-clients.json"), "utf8"));
|
|
100
|
+
if (!calls.flows.some((flow) => flow.name === "POST /rules → provider APIs")) {
|
|
101
|
+
throw new Error(`package smoke did not preserve the typed HTTP provider flow; got ${calls.flows.map((flow) => flow.name).join(", ")}`);
|
|
102
|
+
}
|
|
50
103
|
console.log("package smoke: init, generate, check, and build passed outside the repository");
|
|
51
104
|
} finally {
|
|
52
105
|
rmSync(fixture, { recursive: true, force: true });
|
|
@@ -56,3 +109,8 @@ try {
|
|
|
56
109
|
function run(command, args) {
|
|
57
110
|
execFileSync(command, args, { cwd: fixture, stdio: "inherit" });
|
|
58
111
|
}
|
|
112
|
+
|
|
113
|
+
function writeGoFixture(path, contents) {
|
|
114
|
+
mkdirSync(dirname(resolve(fixture, path)), { recursive: true });
|
|
115
|
+
writeFileSync(resolve(fixture, path), contents);
|
|
116
|
+
}
|
package/scripts/plugin-host.mjs
CHANGED
|
@@ -185,8 +185,28 @@ export function validateResponse(name, response) {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
function validBase64(value) {
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
if (value.length % 4 !== 0) return false;
|
|
189
|
+
|
|
190
|
+
let payloadLength = value.length;
|
|
191
|
+
if (value.endsWith("==")) payloadLength -= 2;
|
|
192
|
+
else if (value.endsWith("=")) payloadLength -= 1;
|
|
193
|
+
|
|
194
|
+
// A repeated-group regexp over a multi-megabyte binary can exhaust V8's
|
|
195
|
+
// regexp stack. Validate the same alphabet iteratively so vendoring a large
|
|
196
|
+
// file remains bounded by its bytes rather than the JavaScript call stack.
|
|
197
|
+
for (let index = 0; index < payloadLength; index += 1) {
|
|
198
|
+
const code = value.charCodeAt(index);
|
|
199
|
+
const alphabet = code >= 65 && code <= 90
|
|
200
|
+
|| code >= 97 && code <= 122
|
|
201
|
+
|| code >= 48 && code <= 57
|
|
202
|
+
|| code === 43
|
|
203
|
+
|| code === 47;
|
|
204
|
+
if (!alphabet) return false;
|
|
205
|
+
}
|
|
206
|
+
for (let index = payloadLength; index < value.length; index += 1) {
|
|
207
|
+
if (value.charCodeAt(index) !== 61) return false;
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
190
210
|
}
|
|
191
211
|
|
|
192
212
|
function safeFileName(name) {
|
|
@@ -21,6 +21,15 @@ describe("plugin response validation", () => {
|
|
|
21
21
|
expect(() => validateResponse("fixture", { files: [{ name: "image.png", contents: "x", encoding: "binary" }] })).toThrow("encoding is not supported");
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it("validates multi-megabyte base64 without overflowing the regexp stack", () => {
|
|
25
|
+
const contents = "A".repeat(13 * 1024 * 1024);
|
|
26
|
+
expect(validateResponse("fixture", { files: [{ name: "large.bin", contents, encoding: "base64" }] }).files[0]).toEqual({
|
|
27
|
+
name: "large.bin",
|
|
28
|
+
contents,
|
|
29
|
+
encoding: "base64",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
24
33
|
it.each(["../secret", "/tmp/result", "C:\\tmp\\result", "a/../../secret", "./result"])(
|
|
25
34
|
"rejects unsafe output name %s",
|
|
26
35
|
(name) => {
|
|
@@ -10,7 +10,10 @@ async function run() {
|
|
|
10
10
|
const wasi = new WASI({
|
|
11
11
|
version: "preview1",
|
|
12
12
|
args: [workerData.name],
|
|
13
|
-
env: {
|
|
13
|
+
env: {
|
|
14
|
+
GOOS: process.env.GOOS || ({ win32: "windows" }[process.platform] ?? process.platform),
|
|
15
|
+
GOARCH: process.env.GOARCH || ({ x64: "amd64", ia32: "386" }[process.arch] ?? process.arch),
|
|
16
|
+
},
|
|
14
17
|
// Empty for a generator or a describe request. An extract or verify
|
|
15
18
|
// step gets the workspace as `/`, and nothing else (portolan.0006).
|
|
16
19
|
preopens: workerData.workspace ? { "/": workerData.workspace } : {},
|