@shortlink-org/portolan 0.2.4 → 0.4.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 +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -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 +28 -15
- package/plugin/describe.go +156 -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 +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- 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-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -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/Main.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-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- 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/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- 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-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -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 +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- 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-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- 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.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- 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/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- 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/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +116 -4
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- 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
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -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,13 +380,22 @@ 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) : "";
|
|
315
387
|
const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
|
|
316
388
|
const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
|
|
317
389
|
const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
|
|
390
|
+
// A Laravel application keeps its Eloquent models under app/Models, or
|
|
391
|
+
// under each package's src/Models when it is built from packages.
|
|
392
|
+
const laravelDomain = files.has("composer.json") ? matches(files, /^(?:app|packages\/[^/]+\/[^/]+\/src)\/Models\/[^/]+\.php$/)[0] ?? "" : "";
|
|
393
|
+
// A PHP tree laid out by bounded context keeps each module's model under
|
|
394
|
+
// src/<Context>/<Module>/Domain, with Shared beside the contexts.
|
|
395
|
+
const phpDdd = files.has("composer.json") ? matches(files, /^src\/(?!Shared\/)[^/]+\/(?!Shared\/)[^/]+\/Domain\/[^/]+\.php$/)[0] ?? "" : "";
|
|
396
|
+
// A .NET tree laid out by module keeps each module's model under
|
|
397
|
+
// src/Modules/<Module>/Domain, with the HTTP host under src/API.
|
|
398
|
+
const csharpDdd = matches(files, /^src\/Modules\/[^/]+\/Domain\/.+\.cs$/)[0] ?? "";
|
|
318
399
|
return [
|
|
319
400
|
detected("project", projectEvidence, {}, projectEvidence.join(", ")),
|
|
320
401
|
detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
|
|
@@ -322,6 +403,9 @@ function detectionsFor(root, files) {
|
|
|
322
403
|
detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
|
|
323
404
|
detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
|
|
324
405
|
detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
|
|
406
|
+
detected("laravel-domain", laravelDomain ? [laravelDomain] : [], {}, laravelDomain),
|
|
407
|
+
detected("php-ddd", phpDdd ? [phpDdd] : [], {}, phpDdd),
|
|
408
|
+
detected("csharp-ddd", csharpDdd ? [csharpDdd] : [], {}, csharpDdd),
|
|
325
409
|
detected("celery", celery, {}, celery[0]),
|
|
326
410
|
detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
|
|
327
411
|
detected(
|
|
@@ -349,6 +433,7 @@ function detectionsFor(root, files) {
|
|
|
349
433
|
supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
|
|
350
434
|
!supportedAdrs.length,
|
|
351
435
|
supportedAdrs.length > 0,
|
|
436
|
+
adrPreviews,
|
|
352
437
|
),
|
|
353
438
|
detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
|
|
354
439
|
].filter(Boolean);
|
package/scripts/manifest.mjs
CHANGED
|
@@ -12,6 +12,13 @@ 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";
|
|
16
|
+
import { problemRuleProblems } from "../src/lib/problem-rules-cel.mjs";
|
|
17
|
+
|
|
18
|
+
// The rules that have a reader, by id, so that an entry naming one is read as
|
|
19
|
+
// a switch and any other as a rule of its own. Read once, like the schema:
|
|
20
|
+
// the file is part of the package, and a manifest cannot add to it.
|
|
21
|
+
const builtinRuleIds = () => JSON.parse(readFileSync(new URL("../rules/builtin.json", import.meta.url), "utf8")).map((rule) => rule.id);
|
|
15
22
|
|
|
16
23
|
// Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
|
|
17
24
|
const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
|
|
@@ -38,20 +45,24 @@ export function loadManifest(path = "portolan.json") {
|
|
|
38
45
|
*/
|
|
39
46
|
export function parseManifest(text, path = "portolan.json") {
|
|
40
47
|
const manifest = JSON.parse(text);
|
|
48
|
+
const policyProblems = [
|
|
49
|
+
...warningPolicyProblems(manifest.warningPolicies, path),
|
|
50
|
+
...problemRuleProblems(manifest.problemRules, builtinRuleIds(), path),
|
|
51
|
+
];
|
|
41
52
|
|
|
42
53
|
let schema;
|
|
43
54
|
try {
|
|
44
55
|
schema = JSON.parse(readFileSync(schemaFile(), "utf8"));
|
|
45
56
|
} catch {
|
|
46
|
-
return { manifest, problems:
|
|
57
|
+
return { manifest, problems: policyProblems };
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
const ajv = new Ajv({ allErrors: true, strictSchema: false });
|
|
50
61
|
const validate = ajv.compile(schema);
|
|
51
62
|
|
|
52
|
-
if (validate(manifest)) return { manifest, problems:
|
|
63
|
+
if (validate(manifest)) return { manifest, problems: policyProblems };
|
|
53
64
|
|
|
54
|
-
return { manifest, problems: explain(validate.errors ?? [], manifest, schema, path) };
|
|
65
|
+
return { manifest, problems: [...explain(validate.errors ?? [], manifest, schema, path), ...policyProblems] };
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
/**
|
|
@@ -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
|
@@ -90,7 +90,7 @@ export async function runPlugin(plugin, request, requestedLimits = {}, access =
|
|
|
90
90
|
// can be named, so a manifest cannot point the host at arbitrary code.
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
|
|
93
|
-
const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr"]);
|
|
93
|
+
const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr", "fetch-k8s", "fetch-argocd"]);
|
|
94
94
|
|
|
95
95
|
async function runHost(plugin, request) {
|
|
96
96
|
const name = String(plugin.host);
|
|
@@ -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) {
|
|
@@ -195,6 +215,25 @@ function safeFileName(name) {
|
|
|
195
215
|
return parts.every((part) => part !== "" && part !== "." && part !== "..");
|
|
196
216
|
}
|
|
197
217
|
|
|
218
|
+
/**
|
|
219
|
+
* What a plugin reads, or what it makes - the groups of the plugin index. A
|
|
220
|
+
* phase says where in a run a plugin goes; the category says what kind of
|
|
221
|
+
* fact it is after, which is the question a reader choosing extractors asks.
|
|
222
|
+
* Mirrors the Category constants in plugin/describe.go.
|
|
223
|
+
*/
|
|
224
|
+
export const PLUGIN_CATEGORIES = [
|
|
225
|
+
"code",
|
|
226
|
+
"contracts",
|
|
227
|
+
"messaging",
|
|
228
|
+
"data",
|
|
229
|
+
"infrastructure",
|
|
230
|
+
"repository",
|
|
231
|
+
"documents",
|
|
232
|
+
"evidence",
|
|
233
|
+
"sources",
|
|
234
|
+
"exports",
|
|
235
|
+
];
|
|
236
|
+
|
|
198
237
|
function validateDescriptor(pluginName, descriptor) {
|
|
199
238
|
if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
|
|
200
239
|
throw new Error(`plugin ${pluginName}: describe is not an object`);
|
|
@@ -205,6 +244,9 @@ function validateDescriptor(pluginName, descriptor) {
|
|
|
205
244
|
if (typeof descriptor.summary !== "string") {
|
|
206
245
|
throw new Error(`plugin ${pluginName}: describe.summary is missing`);
|
|
207
246
|
}
|
|
247
|
+
if (!PLUGIN_CATEGORIES.includes(descriptor.category)) {
|
|
248
|
+
throw new Error(`plugin ${pluginName}: describe.category must be one of ${PLUGIN_CATEGORIES.join(", ")}`);
|
|
249
|
+
}
|
|
208
250
|
if (!Array.isArray(descriptor.phases) || descriptor.phases.some((phase) => !["extract", "verify", "generate"].includes(phase))) {
|
|
209
251
|
throw new Error(`plugin ${pluginName}: describe.phases is invalid`);
|
|
210
252
|
}
|
|
@@ -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 } : {},
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Says whether the built plugins are newer than their sources, so that a run
|
|
2
|
+
// started from the page can skip building them.
|
|
3
|
+
//
|
|
4
|
+
// `npm run gen` builds the wasm and the Java extractor first, every time,
|
|
5
|
+
// which is right for a checkout somebody just changed and wrong for the
|
|
6
|
+
// fourth trial of a recording in a row: a minute of javac and the Go
|
|
7
|
+
// toolchain to learn nothing changed. The page asks here first and runs the
|
|
8
|
+
// generator alone when the answer is yes. Anything doubtful - a missing
|
|
9
|
+
// artefact, a source newer than it, a tree that cannot be read - is "no",
|
|
10
|
+
// and the build runs as it always did.
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Where the Go sources the wasm is built from live, relative to the workspace. */
|
|
16
|
+
const GO_ROOTS = ["plugins", "catalog", "plugin", "internal", "go.mod", "go.sum"];
|
|
17
|
+
const GO_WASM = "plugins/portolan-go.wasm";
|
|
18
|
+
const JAVA_SOURCES = "plugins/extract-java/src";
|
|
19
|
+
const JAVA_BUILD = "plugins/extract-java/build";
|
|
20
|
+
|
|
21
|
+
/** Directories under a source root that hold no sources of ours. */
|
|
22
|
+
const SKIP = new Set(["node_modules", "target", "build", "testdata", ".git", "vendor"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The newest modification time under a path, counting only files whose
|
|
26
|
+
* name passes `keep`, or -1 when nothing does. A path that cannot be read is
|
|
27
|
+
* treated as newer than anything, which sends the caller down the safe road.
|
|
28
|
+
*/
|
|
29
|
+
export function newestMtime(root, keep) {
|
|
30
|
+
let newest = -1;
|
|
31
|
+
const pending = [root];
|
|
32
|
+
while (pending.length) {
|
|
33
|
+
const path = pending.pop();
|
|
34
|
+
let stat;
|
|
35
|
+
try {
|
|
36
|
+
stat = statSync(path);
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (stat.isDirectory()) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(path, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return Number.POSITIVE_INFINITY;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.isDirectory() && SKIP.has(entry.name)) continue;
|
|
49
|
+
pending.push(join(path, entry.name));
|
|
50
|
+
}
|
|
51
|
+
} else if (keep(path)) {
|
|
52
|
+
newest = Math.max(newest, stat.mtimeMs);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return newest;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function oldestArtefact(root, keep) {
|
|
59
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
60
|
+
let any = false;
|
|
61
|
+
const pending = [root];
|
|
62
|
+
while (pending.length) {
|
|
63
|
+
const path = pending.pop();
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = statSync(path);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (stat.isDirectory()) {
|
|
71
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) pending.push(join(path, entry.name));
|
|
72
|
+
} else if (keep(path)) {
|
|
73
|
+
any = true;
|
|
74
|
+
oldest = Math.min(oldest, stat.mtimeMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return any ? oldest : -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether every built plugin is at least as new as every source it is built
|
|
82
|
+
* from. `true` means `node scripts/gen.mjs` can run on its own; anything
|
|
83
|
+
* else means `npm run gen`, which builds first.
|
|
84
|
+
*/
|
|
85
|
+
export function pluginsFresh(workspace) {
|
|
86
|
+
const wasm = oldestArtefact(join(workspace, GO_WASM), () => true);
|
|
87
|
+
if (wasm < 0) return false;
|
|
88
|
+
const goSources = Math.max(
|
|
89
|
+
...GO_ROOTS.map((root) => newestMtime(join(workspace, root), (path) => /\.(go|mod|sum)$/.test(path))),
|
|
90
|
+
);
|
|
91
|
+
if (!(goSources <= wasm)) return false;
|
|
92
|
+
|
|
93
|
+
const classes = oldestArtefact(join(workspace, JAVA_BUILD), (path) => path.endsWith(".class"));
|
|
94
|
+
if (classes < 0) return false;
|
|
95
|
+
const javaSources = newestMtime(join(workspace, JAVA_SOURCES), (path) => path.endsWith(".java"));
|
|
96
|
+
return javaSources <= classes;
|
|
97
|
+
}
|