@davesheffer/hunch 1.19.0 → 1.20.0-rc.2
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 +78 -61
- package/dist/cli/index.js +99 -2
- package/dist/core/delivery.js +213 -13
- package/dist/core/landscapeAdoption.js +271 -0
- package/dist/core/landscapeDelivery.js +281 -0
- package/dist/core/types.js +128 -1
- package/dist/extractors/git.js +2 -2
- package/dist/extractors/landscapeDiscovery.js +2217 -280
- package/dist/mcp/server.js +45 -4
- package/dist/store/hunchStore.js +7 -0
- package/dist/synthesis/synthesize.js +22 -7
- package/package.json +3 -2
- package/server.json +2 -2
|
@@ -7,15 +7,30 @@ import { resourceId, resourceRelationshipId } from "../core/ids.js";
|
|
|
7
7
|
import { parseJsonc } from "../core/jsonc.js";
|
|
8
8
|
import { parseSource } from "./parse.js";
|
|
9
9
|
import { EdgeSchema, ResourceSchema, isCredentialFreeText, } from "../core/types.js";
|
|
10
|
-
import { canonicalRemoteRepositoryIdentity, foreignRepoEnv, gitNullDevice,
|
|
10
|
+
import { canonicalRemoteRepositoryIdentity, foreignRepoEnv, gitNullDevice, } from "./git.js";
|
|
11
11
|
export const LANDSCAPE_DISCOVERY_SCHEMA_VERSION = "hunch.landscape-discovery/1";
|
|
12
12
|
export const LANDSCAPE_CANDIDATE_SCHEMA_VERSION = "hunch.landscape-candidate/1";
|
|
13
13
|
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
14
14
|
const MAX_MANIFESTS = 128;
|
|
15
|
+
const MAX_WORKSPACE_DEPENDENCIES = 512;
|
|
16
|
+
const MAX_SUBMODULE_DECLARATION_BYTES = 256 * 1024;
|
|
17
|
+
const MAX_SUBMODULE_DECLARATIONS = 32;
|
|
15
18
|
const MAX_MCP_CONFIG_BYTES = 256 * 1024;
|
|
16
19
|
const MAX_MCP_DECLARATIONS = 128;
|
|
17
20
|
const MAX_DELIVERY_DECLARATION_BYTES = 256 * 1024;
|
|
18
21
|
const MAX_DELIVERY_DECLARATIONS = 128;
|
|
22
|
+
const MAX_API_DECLARATION_BYTES = 1024 * 1024;
|
|
23
|
+
const MAX_API_DECLARATIONS = 128;
|
|
24
|
+
const MAX_MIGRATION_DECLARATION_BYTES = 1024 * 1024;
|
|
25
|
+
const MAX_MIGRATION_DECLARATIONS = 128;
|
|
26
|
+
const MAX_OWNERSHIP_DECLARATION_BYTES = 256 * 1024;
|
|
27
|
+
const MAX_OWNERSHIP_TEAMS = 32;
|
|
28
|
+
const MAX_OPERATIONS_DECLARATION_BYTES = 1024 * 1024;
|
|
29
|
+
const MAX_OPERATIONS_DECLARATIONS = 128;
|
|
30
|
+
const MAX_DASHBOARD_DECLARATION_BYTES = 1024 * 1024;
|
|
31
|
+
const MAX_DASHBOARD_DECLARATIONS = 128;
|
|
32
|
+
const MAX_SLO_DECLARATION_BYTES = 1024 * 1024;
|
|
33
|
+
const MAX_SLO_DECLARATIONS = 128;
|
|
19
34
|
const ORDINARY_BLOB_MODES = new Set(["100644", "100755"]);
|
|
20
35
|
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
21
36
|
const MCP_CONFIG_SPECS = [
|
|
@@ -46,6 +61,16 @@ function gitBuffer(root, args, maxBuffer = 8 * 1024 * 1024) {
|
|
|
46
61
|
timeout: 15_000,
|
|
47
62
|
});
|
|
48
63
|
}
|
|
64
|
+
function gitBufferInput(root, args, input, maxBuffer, timeout = 15_000) {
|
|
65
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
66
|
+
encoding: "buffer",
|
|
67
|
+
env: gitEnv(),
|
|
68
|
+
input: Buffer.from(input, "ascii"),
|
|
69
|
+
maxBuffer,
|
|
70
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
71
|
+
timeout,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
49
74
|
function gitText(root, args, maxBuffer = 8 * 1024 * 1024) {
|
|
50
75
|
return gitBuffer(root, args, maxBuffer).toString("utf8").trim();
|
|
51
76
|
}
|
|
@@ -61,20 +86,30 @@ function canonical(value) {
|
|
|
61
86
|
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
62
87
|
.map(([key, child]) => [key, canonical(child)]));
|
|
63
88
|
}
|
|
64
|
-
|
|
89
|
+
/** Canonical content identity shared by discovery and its review/adoption seam. */
|
|
90
|
+
export function landscapeContentHash(value) {
|
|
65
91
|
return sha256Bytes(JSON.stringify(canonical(value)));
|
|
66
92
|
}
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
const contentHash = landscapeContentHash;
|
|
94
|
+
function exactCommitSnapshot(root, ref) {
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
// One immutable commit read proves repository availability and binds both
|
|
98
|
+
// identity and time. These were previously three separate Git processes
|
|
99
|
+
// (`isGitRepo`, `rev-parse`, `show`) for every discovery.
|
|
100
|
+
raw = gitBuffer(root, ["show", "-s", "--format=%H%x00%cI", "--end-of-options", `${ref}^{commit}`], 1024 * 1024);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new Error("landscape discovery requires a Git repository and an exact Git commit");
|
|
104
|
+
}
|
|
105
|
+
const separator = raw.indexOf(0);
|
|
106
|
+
const revision = separator < 0 ? "" : raw.subarray(0, separator).toString("ascii").trim().toLowerCase();
|
|
107
|
+
const timestamp = separator < 0 ? "" : raw.subarray(separator + 1).toString("utf8").trim();
|
|
69
108
|
if (!/^[0-9a-f]{40,64}$/.test(revision))
|
|
70
109
|
throw new Error("landscape discovery requires an exact Git commit");
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
function revisionTime(root, revision) {
|
|
74
|
-
const value = gitText(root, ["show", "-s", "--format=%cI", revision], 1024 * 1024);
|
|
75
|
-
if (!Number.isFinite(Date.parse(value)))
|
|
110
|
+
if (!Number.isFinite(Date.parse(timestamp)))
|
|
76
111
|
throw new Error("landscape discovery commit timestamp is invalid");
|
|
77
|
-
return
|
|
112
|
+
return { revision, timestamp };
|
|
78
113
|
}
|
|
79
114
|
function nulRecords(bytes) {
|
|
80
115
|
const records = [];
|
|
@@ -88,46 +123,128 @@ function nulRecords(bytes) {
|
|
|
88
123
|
records.push(bytes.subarray(start));
|
|
89
124
|
return records;
|
|
90
125
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
126
|
+
/** Parse the exact commit tree once. Every discovery family classifies this
|
|
127
|
+
* immutable snapshot, avoiding repeated Git walks and any chance of the source
|
|
128
|
+
* families observing different path sets. Invalid UTF-8 remains available as
|
|
129
|
+
* raw bytes so a relevant unsafe declaration can still fail closed. */
|
|
130
|
+
function exactTreeSnapshot(root, revision) {
|
|
131
|
+
const raw = gitBuffer(root, ["ls-tree", "--full-tree", "-r", "-t", "-l", "-z", revision], 64 * 1024 * 1024);
|
|
132
|
+
const entries = [];
|
|
94
133
|
for (const record of nulRecords(raw)) {
|
|
95
134
|
const tab = record.indexOf(0x09);
|
|
96
135
|
if (tab < 0)
|
|
97
136
|
continue;
|
|
98
|
-
const head = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40,64})$/i);
|
|
137
|
+
const head = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40,64}) +(-|[0-9]+)$/i);
|
|
99
138
|
if (!head)
|
|
100
139
|
continue;
|
|
140
|
+
const objectSize = head[4] === "-" ? null : Number(head[4]);
|
|
141
|
+
if (objectSize !== null && (!Number.isSafeInteger(objectSize) || objectSize < 0))
|
|
142
|
+
continue;
|
|
101
143
|
const pathBytes = record.subarray(tab + 1);
|
|
102
|
-
let path;
|
|
144
|
+
let path = null;
|
|
103
145
|
try {
|
|
104
146
|
path = UTF8_DECODER.decode(pathBytes);
|
|
105
147
|
}
|
|
106
148
|
catch {
|
|
149
|
+
// Retain the raw path identity for source-specific unsafe-path handling.
|
|
150
|
+
}
|
|
151
|
+
entries.push({
|
|
152
|
+
mode: head[1],
|
|
153
|
+
kind: head[2].toLowerCase(),
|
|
154
|
+
oid: head[3].toLowerCase(),
|
|
155
|
+
objectSize,
|
|
156
|
+
pathBytes,
|
|
157
|
+
path,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return entries;
|
|
161
|
+
}
|
|
162
|
+
function treeEntryMode(entry) {
|
|
163
|
+
return entry.kind === "blob" ? entry.mode : `${entry.kind}:${entry.mode}`;
|
|
164
|
+
}
|
|
165
|
+
/** Request only bodies whose exact-tree size is inside the source-family
|
|
166
|
+
* bound. The entire response is itself bounded by selected-count × per-file
|
|
167
|
+
* limit, preserving the existing memory ceiling without another Git process. */
|
|
168
|
+
function hydrateDeclarationBlobs(root, blobs, maxBytes) {
|
|
169
|
+
const result = blobs.slice();
|
|
170
|
+
const eligible = blobs.map((blob, index) => ({ blob, index }))
|
|
171
|
+
.filter(({ blob }) => blob.mode !== "unsafe-path" && ORDINARY_BLOB_MODES.has(blob.mode));
|
|
172
|
+
if (!eligible.length)
|
|
173
|
+
return result;
|
|
174
|
+
const accepted = [];
|
|
175
|
+
for (const candidate of eligible) {
|
|
176
|
+
const size = candidate.blob.objectSize;
|
|
177
|
+
if (size === null)
|
|
178
|
+
continue;
|
|
179
|
+
if (size > maxBytes) {
|
|
180
|
+
result[candidate.index] = { ...candidate.blob, contentHash: "oversized" };
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
accepted.push({ ...candidate, size });
|
|
184
|
+
}
|
|
185
|
+
if (!accepted.length)
|
|
186
|
+
return result;
|
|
187
|
+
const contentInput = `${accepted.map(({ blob }) => blob.oid).join("\n")}\n`;
|
|
188
|
+
const contentLimit = accepted.reduce((total, item) => total + item.size + 256, 1024);
|
|
189
|
+
const raw = gitBufferInput(root, ["cat-file", "--batch"], contentInput, contentLimit, 60_000);
|
|
190
|
+
const hydrated = [];
|
|
191
|
+
let offset = 0;
|
|
192
|
+
for (const candidate of accepted) {
|
|
193
|
+
const newline = raw.indexOf(0x0a, offset);
|
|
194
|
+
if (newline < 0)
|
|
195
|
+
return result;
|
|
196
|
+
const header = raw.subarray(offset, newline).toString("ascii")
|
|
197
|
+
.match(/^([0-9a-f]{40,64}) blob ([0-9]+)$/i);
|
|
198
|
+
const size = header ? Number(header[2]) : Number.NaN;
|
|
199
|
+
const start = newline + 1;
|
|
200
|
+
const end = start + size;
|
|
201
|
+
if (!header || header[1].toLowerCase() !== candidate.blob.oid
|
|
202
|
+
|| size !== candidate.size || end >= raw.length || raw[end] !== 0x0a)
|
|
203
|
+
return result;
|
|
204
|
+
hydrated.push({ index: candidate.index, bytes: Buffer.from(raw.subarray(start, end)) });
|
|
205
|
+
offset = end + 1;
|
|
206
|
+
}
|
|
207
|
+
if (offset !== raw.length)
|
|
208
|
+
return result;
|
|
209
|
+
for (const item of hydrated) {
|
|
210
|
+
const blob = result[item.index];
|
|
211
|
+
result[item.index] = { ...blob, bytes: item.bytes, contentHash: sha256Bytes(item.bytes) };
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
function manifestBlobs(tree) {
|
|
216
|
+
const manifests = [];
|
|
217
|
+
for (const entry of tree) {
|
|
218
|
+
if (entry.kind === "tree")
|
|
219
|
+
continue;
|
|
220
|
+
const { pathBytes } = entry;
|
|
221
|
+
if (entry.path === null) {
|
|
107
222
|
const suffix = Buffer.from("package.json", "utf8");
|
|
108
223
|
if (pathBytes.length >= suffix.length && pathBytes.subarray(pathBytes.length - suffix.length).equals(suffix)) {
|
|
109
224
|
manifests.push({
|
|
110
225
|
path: `<non-utf8-package-manifest:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
111
226
|
mode: "unsafe-path",
|
|
112
|
-
oid:
|
|
227
|
+
oid: entry.oid,
|
|
228
|
+
objectSize: entry.objectSize,
|
|
113
229
|
bytes: null,
|
|
114
230
|
contentHash: null,
|
|
115
231
|
});
|
|
116
232
|
}
|
|
117
233
|
continue;
|
|
118
234
|
}
|
|
235
|
+
const path = entry.path;
|
|
119
236
|
if (path !== "package.json" && !path.endsWith("/package.json"))
|
|
120
237
|
continue;
|
|
121
|
-
const mode =
|
|
122
|
-
const oid =
|
|
238
|
+
const mode = entry.mode;
|
|
239
|
+
const oid = entry.oid;
|
|
123
240
|
const segments = path.split("/");
|
|
124
241
|
if (path.length > 1024 || path.startsWith("/") || path.includes("\\")
|
|
125
242
|
|| segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
126
243
|
|| /[\u0000-\u001f\u007f]/.test(path) || !isCredentialFreeText(path)) {
|
|
127
|
-
manifests.push({ path: "<unsafe-package-manifest>", mode: "unsafe-path", oid, bytes: null, contentHash: null });
|
|
244
|
+
manifests.push({ path: "<unsafe-package-manifest>", mode: "unsafe-path", oid, objectSize: entry.objectSize, bytes: null, contentHash: null });
|
|
128
245
|
continue;
|
|
129
246
|
}
|
|
130
|
-
manifests.push({ path, mode:
|
|
247
|
+
manifests.push({ path, mode: treeEntryMode(entry), oid, objectSize: entry.objectSize, bytes: null, contentHash: null });
|
|
131
248
|
}
|
|
132
249
|
return manifests.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
133
250
|
}
|
|
@@ -137,16 +254,7 @@ function boundedManifestBlobs(root, manifests) {
|
|
|
137
254
|
...(rootManifest ? [rootManifest] : []),
|
|
138
255
|
...manifests.filter((manifest) => manifest !== rootManifest).slice(0, MAX_MANIFESTS - (rootManifest ? 1 : 0)),
|
|
139
256
|
];
|
|
140
|
-
return
|
|
141
|
-
if (manifest.mode === "unsafe-path" || !ORDINARY_BLOB_MODES.has(manifest.mode))
|
|
142
|
-
return manifest;
|
|
143
|
-
const size = Number(gitText(root, ["cat-file", "-s", manifest.oid], 1024 * 1024));
|
|
144
|
-
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_MANIFEST_BYTES) {
|
|
145
|
-
return { ...manifest, contentHash: size > MAX_MANIFEST_BYTES ? "oversized" : null };
|
|
146
|
-
}
|
|
147
|
-
const bytes = gitBuffer(root, ["cat-file", "blob", manifest.oid], MAX_MANIFEST_BYTES + 1);
|
|
148
|
-
return { ...manifest, bytes, contentHash: sha256Bytes(bytes) };
|
|
149
|
-
});
|
|
257
|
+
return hydrateDeclarationBlobs(root, selected, MAX_MANIFEST_BYTES);
|
|
150
258
|
}
|
|
151
259
|
function workspacePatterns(value, issues) {
|
|
152
260
|
const raw = Array.isArray(value)
|
|
@@ -235,40 +343,17 @@ function parseManifests(blobs, issues) {
|
|
|
235
343
|
}
|
|
236
344
|
return parsed;
|
|
237
345
|
}
|
|
238
|
-
function mcpConfigBlobs(root,
|
|
239
|
-
const raw = gitBuffer(root, [
|
|
240
|
-
"ls-tree", "--full-tree", "-z", revision, "--", ...MCP_CONFIG_SPECS.map((spec) => spec.path),
|
|
241
|
-
], 4 * 1024 * 1024);
|
|
346
|
+
function mcpConfigBlobs(root, tree) {
|
|
242
347
|
const blobs = [];
|
|
243
|
-
for (const
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
continue;
|
|
247
|
-
const head = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40,64})$/i);
|
|
248
|
-
if (!head)
|
|
348
|
+
for (const entry of tree) {
|
|
349
|
+
const path = entry.path;
|
|
350
|
+
if (path === null)
|
|
249
351
|
continue;
|
|
250
|
-
let path;
|
|
251
|
-
try {
|
|
252
|
-
path = UTF8_DECODER.decode(record.subarray(tab + 1));
|
|
253
|
-
}
|
|
254
|
-
catch {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
352
|
if (!MCP_CONFIG_BY_PATH.has(path))
|
|
258
353
|
continue;
|
|
259
|
-
|
|
260
|
-
blobs.push({ path, mode, oid: head[3].toLowerCase(), bytes: null, contentHash: null });
|
|
354
|
+
blobs.push({ path, mode: treeEntryMode(entry), oid: entry.oid, objectSize: entry.objectSize, bytes: null, contentHash: null });
|
|
261
355
|
}
|
|
262
|
-
return blobs.sort((left, right) => compareCodeUnits(left.path, right.path))
|
|
263
|
-
if (!ORDINARY_BLOB_MODES.has(blob.mode))
|
|
264
|
-
return blob;
|
|
265
|
-
const size = Number(gitText(root, ["cat-file", "-s", blob.oid], 1024 * 1024));
|
|
266
|
-
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_MCP_CONFIG_BYTES) {
|
|
267
|
-
return { ...blob, contentHash: size > MAX_MCP_CONFIG_BYTES ? "oversized" : null };
|
|
268
|
-
}
|
|
269
|
-
const bytes = gitBuffer(root, ["cat-file", "blob", blob.oid], MAX_MCP_CONFIG_BYTES + 1);
|
|
270
|
-
return { ...blob, bytes, contentHash: sha256Bytes(bytes) };
|
|
271
|
-
});
|
|
356
|
+
return hydrateDeclarationBlobs(root, blobs.sort((left, right) => compareCodeUnits(left.path, right.path)), MAX_MCP_CONFIG_BYTES);
|
|
272
357
|
}
|
|
273
358
|
function validMcpServerName(value) {
|
|
274
359
|
const name = value.trim();
|
|
@@ -697,10 +782,10 @@ function mcpDeclaration(rawName, rawEntry, blob, rootKey, revision, issues) {
|
|
|
697
782
|
},
|
|
698
783
|
};
|
|
699
784
|
}
|
|
700
|
-
function mcpDeclarations(root, revision, issues) {
|
|
785
|
+
function mcpDeclarations(root, revision, tree, issues) {
|
|
701
786
|
const declarations = [];
|
|
702
787
|
let considered = 0;
|
|
703
|
-
for (const blob of mcpConfigBlobs(root,
|
|
788
|
+
for (const blob of mcpConfigBlobs(root, tree)) {
|
|
704
789
|
if (!blob.bytes) {
|
|
705
790
|
issues.push({
|
|
706
791
|
code: blob.contentHash === "oversized" ? "mcp_config_oversized" : "mcp_config_mode",
|
|
@@ -851,6 +936,16 @@ function deliveryDeclarationSpec(path) {
|
|
|
851
936
|
relationship: "deploys",
|
|
852
937
|
};
|
|
853
938
|
}
|
|
939
|
+
if (/(^|\/)Chart\.yaml$/.test(path)) {
|
|
940
|
+
return {
|
|
941
|
+
evidenceKind: "deployment_declaration",
|
|
942
|
+
resourceKind: "artifact",
|
|
943
|
+
provider: "helm",
|
|
944
|
+
format: "yaml",
|
|
945
|
+
sourceField: "apiVersion/name/version",
|
|
946
|
+
relationship: "contains",
|
|
947
|
+
};
|
|
948
|
+
}
|
|
854
949
|
if (/(^|\/)(?:k8s|kubernetes|manifests|deploy)\/.+\.ya?ml$/.test(path)) {
|
|
855
950
|
return {
|
|
856
951
|
evidenceKind: "deployment_declaration",
|
|
@@ -885,238 +980,1295 @@ function safeDeclarationPath(path) {
|
|
|
885
980
|
&& segments.every((segment) => !!segment && segment !== "." && segment !== "..")
|
|
886
981
|
&& isCredentialFreeText(path);
|
|
887
982
|
}
|
|
888
|
-
|
|
889
|
-
|
|
983
|
+
const DEPENDENCY_TREE_SEGMENTS = new Set(["node_modules", "vendor", "third_party", "third-party"]);
|
|
984
|
+
/** Dependency-owned declarations describe the vendored package, not this
|
|
985
|
+
* repository. Ignore them before per-family caps so committed dependencies
|
|
986
|
+
* cannot crowd first-party evidence out of the bounded fragment. */
|
|
987
|
+
function firstPartyDeclarationPath(path) {
|
|
988
|
+
return !path.split("/").some((segment) => DEPENDENCY_TREE_SEGMENTS.has(segment.toLowerCase()));
|
|
989
|
+
}
|
|
990
|
+
function apiDeclarationFormat(path) {
|
|
991
|
+
const basename = posix.basename(path);
|
|
992
|
+
if (/\.proto$/i.test(basename))
|
|
993
|
+
return "protobuf";
|
|
994
|
+
const extension = basename.match(/\.(json|ya?ml)$/i);
|
|
995
|
+
if (!extension)
|
|
996
|
+
return null;
|
|
997
|
+
const stem = basename.slice(0, -extension[0].length);
|
|
998
|
+
const apiNamed = /(^|[._-])(openapi|swagger|asyncapi)(?=$|[._-])/i.test(stem);
|
|
999
|
+
const jsonSchemaNamed = extension[1].toLowerCase() === "json"
|
|
1000
|
+
&& /(^|[._-])schema(?=$|[._-])/i.test(stem);
|
|
1001
|
+
if (!apiNamed && !jsonSchemaNamed)
|
|
1002
|
+
return null;
|
|
1003
|
+
return extension[1].toLowerCase() === "json" ? "json" : "yaml";
|
|
1004
|
+
}
|
|
1005
|
+
function apiDeclarationBlobs(root, tree) {
|
|
890
1006
|
const discovered = [];
|
|
891
|
-
for (const
|
|
892
|
-
|
|
893
|
-
if (tab < 0)
|
|
894
|
-
continue;
|
|
895
|
-
const head = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40,64})$/i);
|
|
896
|
-
if (!head)
|
|
1007
|
+
for (const entry of tree) {
|
|
1008
|
+
if (entry.kind === "tree")
|
|
897
1009
|
continue;
|
|
898
|
-
const pathBytes =
|
|
899
|
-
|
|
900
|
-
try {
|
|
901
|
-
path = UTF8_DECODER.decode(pathBytes);
|
|
902
|
-
}
|
|
903
|
-
catch {
|
|
1010
|
+
const { pathBytes } = entry;
|
|
1011
|
+
if (entry.path === null) {
|
|
904
1012
|
const approximate = pathBytes.toString("latin1");
|
|
905
|
-
if (
|
|
1013
|
+
if (!firstPartyDeclarationPath(approximate))
|
|
1014
|
+
continue;
|
|
1015
|
+
if (apiDeclarationFormat(approximate)) {
|
|
906
1016
|
discovered.push({
|
|
907
|
-
path: `<unsafe-
|
|
1017
|
+
path: `<unsafe-api-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
908
1018
|
mode: "unsafe-path",
|
|
909
|
-
oid:
|
|
1019
|
+
oid: entry.oid,
|
|
1020
|
+
objectSize: entry.objectSize,
|
|
910
1021
|
bytes: null,
|
|
911
1022
|
contentHash: null,
|
|
912
|
-
|
|
1023
|
+
format: null,
|
|
913
1024
|
});
|
|
914
1025
|
}
|
|
915
1026
|
continue;
|
|
916
1027
|
}
|
|
917
|
-
const
|
|
918
|
-
if (!
|
|
1028
|
+
const path = entry.path;
|
|
1029
|
+
if (!firstPartyDeclarationPath(path))
|
|
1030
|
+
continue;
|
|
1031
|
+
const format = apiDeclarationFormat(path);
|
|
1032
|
+
if (!format)
|
|
919
1033
|
continue;
|
|
920
1034
|
if (!safeDeclarationPath(path)) {
|
|
921
1035
|
discovered.push({
|
|
922
|
-
path: `<unsafe-
|
|
1036
|
+
path: `<unsafe-api-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
923
1037
|
mode: "unsafe-path",
|
|
924
|
-
oid:
|
|
1038
|
+
oid: entry.oid,
|
|
1039
|
+
objectSize: entry.objectSize,
|
|
925
1040
|
bytes: null,
|
|
926
1041
|
contentHash: null,
|
|
927
|
-
|
|
1042
|
+
format: null,
|
|
928
1043
|
});
|
|
929
1044
|
continue;
|
|
930
1045
|
}
|
|
931
1046
|
discovered.push({
|
|
932
1047
|
path,
|
|
933
|
-
mode:
|
|
934
|
-
oid:
|
|
1048
|
+
mode: treeEntryMode(entry),
|
|
1049
|
+
oid: entry.oid,
|
|
1050
|
+
objectSize: entry.objectSize,
|
|
935
1051
|
bytes: null,
|
|
936
1052
|
contentHash: null,
|
|
937
|
-
|
|
1053
|
+
format,
|
|
938
1054
|
});
|
|
939
1055
|
}
|
|
940
1056
|
discovered.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
941
1057
|
const total = discovered.length;
|
|
942
|
-
const blobs = discovered.slice(0,
|
|
943
|
-
if (blob.mode === "unsafe-path" || !ORDINARY_BLOB_MODES.has(blob.mode))
|
|
944
|
-
return blob;
|
|
945
|
-
const size = Number(gitText(root, ["cat-file", "-s", blob.oid], 1024 * 1024));
|
|
946
|
-
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_DELIVERY_DECLARATION_BYTES) {
|
|
947
|
-
return { ...blob, contentHash: size > MAX_DELIVERY_DECLARATION_BYTES ? "oversized" : null };
|
|
948
|
-
}
|
|
949
|
-
const bytes = gitBuffer(root, ["cat-file", "blob", blob.oid], MAX_DELIVERY_DECLARATION_BYTES + 1);
|
|
950
|
-
return { ...blob, bytes, contentHash: sha256Bytes(bytes) };
|
|
951
|
-
});
|
|
1058
|
+
const blobs = hydrateDeclarationBlobs(root, discovered.slice(0, MAX_API_DECLARATIONS), MAX_API_DECLARATION_BYTES);
|
|
952
1059
|
return { blobs, total };
|
|
953
1060
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1061
|
+
function validApiVersion(dialect, value) {
|
|
1062
|
+
if (typeof value !== "string")
|
|
1063
|
+
return null;
|
|
1064
|
+
const version = value.trim();
|
|
1065
|
+
if (version.length === 0 || version.length > 128 || !isCredentialFreeText(version))
|
|
1066
|
+
return null;
|
|
1067
|
+
if (dialect === "protobuf")
|
|
1068
|
+
return version === "proto2" || version === "proto3" ? version : null;
|
|
1069
|
+
if (dialect === "jsonschema") {
|
|
1070
|
+
const dialects = {
|
|
1071
|
+
"https://json-schema.org/draft/2020-12/schema": "2020-12",
|
|
1072
|
+
"https://json-schema.org/draft/2019-09/schema": "2019-09",
|
|
1073
|
+
"http://json-schema.org/draft-07/schema#": "draft-07",
|
|
1074
|
+
};
|
|
1075
|
+
return dialects[version] ?? null;
|
|
1076
|
+
}
|
|
1077
|
+
if (dialect === "swagger")
|
|
1078
|
+
return version === "2.0" ? version : null;
|
|
1079
|
+
if (dialect === "asyncapi") {
|
|
1080
|
+
return /^(?:2|3)\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,63})?$/.test(version) ? version : null;
|
|
1081
|
+
}
|
|
1082
|
+
return /^3\.[0-9]+(?:\.[0-9]+)?(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,63})?$/.test(version) ? version : null;
|
|
1083
|
+
}
|
|
1084
|
+
function yamlApiIdentity(source) {
|
|
1085
|
+
const values = new Map();
|
|
1086
|
+
let duplicate = false;
|
|
1087
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
1088
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#"))
|
|
967
1089
|
continue;
|
|
968
|
-
|
|
969
|
-
if (quote === "'") {
|
|
970
|
-
if (char === "'" && input[index + 1] === "'")
|
|
971
|
-
index += 1;
|
|
972
|
-
else if (char === "'")
|
|
973
|
-
quote = null;
|
|
1090
|
+
if (rawLine.match(/^ */)[0].length !== 0)
|
|
974
1091
|
continue;
|
|
975
|
-
|
|
976
|
-
if (
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1092
|
+
const mapping = rawLine.match(/^(openapi|swagger|asyncapi)[ \t]*:(.*)$/);
|
|
1093
|
+
if (!mapping)
|
|
1094
|
+
continue;
|
|
1095
|
+
const dialect = mapping[1];
|
|
1096
|
+
if (values.has(dialect))
|
|
1097
|
+
duplicate = true;
|
|
1098
|
+
values.set(dialect, boundedYamlScalar(mapping[2]));
|
|
980
1099
|
}
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
function boundedYamlScalar(input) {
|
|
984
|
-
const value = stripYamlScalarComment(input).trim();
|
|
985
|
-
if (!value || value.length > 512)
|
|
1100
|
+
if (duplicate || values.size !== 1)
|
|
986
1101
|
return null;
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1102
|
+
const [dialect, rawVersion] = [...values.entries()][0];
|
|
1103
|
+
const version = validApiVersion(dialect, rawVersion);
|
|
1104
|
+
return version ? { dialect, version } : null;
|
|
1105
|
+
}
|
|
1106
|
+
function jsonTopLevelApiKeys(source) {
|
|
1107
|
+
const keys = [];
|
|
1108
|
+
let depth = 0;
|
|
1109
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1110
|
+
const char = source[index];
|
|
1111
|
+
if (char === "{" || char === "[") {
|
|
1112
|
+
depth += 1;
|
|
1113
|
+
continue;
|
|
996
1114
|
}
|
|
997
|
-
|
|
998
|
-
|
|
1115
|
+
if (char === "}" || char === "]") {
|
|
1116
|
+
depth -= 1;
|
|
1117
|
+
continue;
|
|
999
1118
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
for (let index = 0; index < inner.length; index += 1) {
|
|
1007
|
-
const char = inner[index];
|
|
1008
|
-
if (char !== "'") {
|
|
1009
|
-
decoded += char;
|
|
1119
|
+
if (char !== '"')
|
|
1120
|
+
continue;
|
|
1121
|
+
const start = index;
|
|
1122
|
+
for (index += 1; index < source.length; index += 1) {
|
|
1123
|
+
if (source[index] === "\\") {
|
|
1124
|
+
index += 1;
|
|
1010
1125
|
continue;
|
|
1011
1126
|
}
|
|
1012
|
-
if (
|
|
1013
|
-
|
|
1014
|
-
decoded += "'";
|
|
1015
|
-
index += 1;
|
|
1127
|
+
if (source[index] === '"')
|
|
1128
|
+
break;
|
|
1016
1129
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1130
|
+
if (depth !== 1 || index >= source.length)
|
|
1131
|
+
continue;
|
|
1132
|
+
let cursor = index + 1;
|
|
1133
|
+
while (cursor < source.length && /\s/.test(source[cursor]))
|
|
1134
|
+
cursor += 1;
|
|
1135
|
+
if (source[cursor] !== ":")
|
|
1136
|
+
continue;
|
|
1137
|
+
const key = JSON.parse(source.slice(start, index + 1));
|
|
1138
|
+
if (key === "openapi" || key === "swagger" || key === "asyncapi" || key === "$schema")
|
|
1139
|
+
keys.push(key);
|
|
1023
1140
|
}
|
|
1024
|
-
return
|
|
1025
|
-
&& !/[\u0000-\u001f\u007f]/.test(parsed)
|
|
1026
|
-
&& isCredentialFreeText(parsed)
|
|
1027
|
-
? parsed
|
|
1028
|
-
: null;
|
|
1141
|
+
return keys;
|
|
1029
1142
|
}
|
|
1030
|
-
function
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1143
|
+
function jsonApiIdentity(source) {
|
|
1144
|
+
let value;
|
|
1145
|
+
try {
|
|
1146
|
+
value = JSON.parse(source);
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1152
|
+
return null;
|
|
1153
|
+
const record = value;
|
|
1154
|
+
const dialects = jsonTopLevelApiKeys(source);
|
|
1155
|
+
if (dialects.length !== 1 || !Object.hasOwn(record, dialects[0]))
|
|
1156
|
+
return null;
|
|
1157
|
+
const identityField = dialects[0];
|
|
1158
|
+
const dialect = identityField === "$schema" ? "jsonschema" : identityField;
|
|
1159
|
+
const version = validApiVersion(dialect, record[identityField]);
|
|
1160
|
+
return version ? { dialect, version } : null;
|
|
1036
1161
|
}
|
|
1037
|
-
function
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
if (header[field] !== undefined) {
|
|
1049
|
-
header.duplicate = true;
|
|
1162
|
+
function protobufApiIdentity(source) {
|
|
1163
|
+
let state = "normal";
|
|
1164
|
+
let depth = 0;
|
|
1165
|
+
let statement = "";
|
|
1166
|
+
let firstStatement = null;
|
|
1167
|
+
let syntaxCount = 0;
|
|
1168
|
+
let version = null;
|
|
1169
|
+
const acceptStatement = () => {
|
|
1170
|
+
const normalized = statement.trim();
|
|
1171
|
+
statement = "";
|
|
1172
|
+
if (!normalized)
|
|
1050
1173
|
return;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1174
|
+
const complete = `${normalized};`;
|
|
1175
|
+
firstStatement ??= complete;
|
|
1176
|
+
const syntax = complete.match(/^syntax\s*=\s*"(proto2|proto3)"\s*;$/);
|
|
1177
|
+
if (/^syntax\b/.test(complete))
|
|
1178
|
+
syntaxCount += 1;
|
|
1179
|
+
if (syntax)
|
|
1180
|
+
version = syntax[1];
|
|
1053
1181
|
};
|
|
1054
|
-
for (
|
|
1055
|
-
|
|
1182
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1183
|
+
const char = source[index];
|
|
1184
|
+
const next = source[index + 1];
|
|
1185
|
+
if (state === "line-comment") {
|
|
1186
|
+
if (char === "\n")
|
|
1187
|
+
state = "normal";
|
|
1056
1188
|
continue;
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
continue;
|
|
1064
|
-
const [_, key, raw] = mapping;
|
|
1065
|
-
if (key === "apiVersion" || key === "kind")
|
|
1066
|
-
assign(key, raw);
|
|
1067
|
-
else if (key === "metadata" && !stripYamlScalarComment(raw).trim())
|
|
1068
|
-
metadataIndent = 0;
|
|
1189
|
+
}
|
|
1190
|
+
if (state === "block-comment") {
|
|
1191
|
+
if (char === "*" && next === "/") {
|
|
1192
|
+
state = "normal";
|
|
1193
|
+
index += 1;
|
|
1194
|
+
}
|
|
1069
1195
|
continue;
|
|
1070
1196
|
}
|
|
1071
|
-
if (
|
|
1197
|
+
if (state === "string") {
|
|
1198
|
+
if (depth === 0)
|
|
1199
|
+
statement += char;
|
|
1200
|
+
if (char === "\\") {
|
|
1201
|
+
if (depth === 0 && next !== undefined)
|
|
1202
|
+
statement += next;
|
|
1203
|
+
index += 1;
|
|
1204
|
+
}
|
|
1205
|
+
else if (char === '"') {
|
|
1206
|
+
state = "normal";
|
|
1207
|
+
}
|
|
1072
1208
|
continue;
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1209
|
+
}
|
|
1210
|
+
if (char === "/" && next === "/") {
|
|
1211
|
+
if (depth === 0)
|
|
1212
|
+
statement += " ";
|
|
1213
|
+
state = "line-comment";
|
|
1214
|
+
index += 1;
|
|
1076
1215
|
continue;
|
|
1077
|
-
|
|
1078
|
-
if (
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
function validKubernetesApiVersion(value) {
|
|
1084
|
-
return value.length <= 128 && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\/v[0-9][a-z0-9]*$|^v[0-9][a-z0-9]*$/i.test(value);
|
|
1085
|
-
}
|
|
1086
|
-
function validKubernetesName(value) {
|
|
1087
|
-
return value.length <= 253 && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value);
|
|
1088
|
-
}
|
|
1089
|
-
function validKubernetesNamespace(value) {
|
|
1090
|
-
return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
|
1091
|
-
}
|
|
1092
|
-
function kubernetesDeclarations(blob, source, issues) {
|
|
1093
|
-
const candidates = [];
|
|
1094
|
-
for (const document of kubernetesYamlDocuments(source)) {
|
|
1095
|
-
const header = kubernetesDocumentHeader(document.source);
|
|
1096
|
-
if (header.duplicate) {
|
|
1097
|
-
issues.push({
|
|
1098
|
-
code: "delivery_declaration_invalid",
|
|
1099
|
-
sourcePath: blob.path,
|
|
1100
|
-
sourceField: `documents[${document.index}]`,
|
|
1101
|
-
detail: `${blob.path} document ${document.index} repeats a Kubernetes identity field`,
|
|
1102
|
-
});
|
|
1216
|
+
}
|
|
1217
|
+
if (char === "/" && next === "*") {
|
|
1218
|
+
if (depth === 0)
|
|
1219
|
+
statement += " ";
|
|
1220
|
+
state = "block-comment";
|
|
1221
|
+
index += 1;
|
|
1103
1222
|
continue;
|
|
1104
1223
|
}
|
|
1105
|
-
if (
|
|
1224
|
+
if (char === '"') {
|
|
1225
|
+
if (depth === 0)
|
|
1226
|
+
statement += char;
|
|
1227
|
+
state = "string";
|
|
1106
1228
|
continue;
|
|
1107
|
-
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
detail: `${blob.path} document ${document.index} has an incomplete or unsafe Kubernetes workload identity`,
|
|
1116
|
-
});
|
|
1229
|
+
}
|
|
1230
|
+
if (char === "{") {
|
|
1231
|
+
if (depth === 0) {
|
|
1232
|
+
if (statement.trim())
|
|
1233
|
+
firstStatement ??= `${statement.trim()} {`;
|
|
1234
|
+
statement = "";
|
|
1235
|
+
}
|
|
1236
|
+
depth += 1;
|
|
1117
1237
|
continue;
|
|
1118
1238
|
}
|
|
1119
|
-
|
|
1239
|
+
if (char === "}") {
|
|
1240
|
+
depth -= 1;
|
|
1241
|
+
if (depth < 0)
|
|
1242
|
+
return null;
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (depth !== 0)
|
|
1246
|
+
continue;
|
|
1247
|
+
if (char === ";") {
|
|
1248
|
+
acceptStatement();
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
statement += char;
|
|
1252
|
+
}
|
|
1253
|
+
if (state === "block-comment" || state === "string" || depth !== 0 || syntaxCount !== 1 || !version)
|
|
1254
|
+
return null;
|
|
1255
|
+
const expected = `syntax = "${version}";`;
|
|
1256
|
+
if (!firstStatement || firstStatement.replace(/\s+/g, " ").replace(/\s*=\s*/, " = ") !== expected)
|
|
1257
|
+
return null;
|
|
1258
|
+
return { dialect: "protobuf", version };
|
|
1259
|
+
}
|
|
1260
|
+
function apiDeclarations(root, revision, tree, issues) {
|
|
1261
|
+
const discovered = apiDeclarationBlobs(root, tree);
|
|
1262
|
+
if (discovered.total > MAX_API_DECLARATIONS) {
|
|
1263
|
+
issues.push({
|
|
1264
|
+
code: "api_declaration_limit",
|
|
1265
|
+
sourcePath: ".",
|
|
1266
|
+
sourceField: "api",
|
|
1267
|
+
detail: `bounded discovery accepts at most ${MAX_API_DECLARATIONS} API declarations`,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
const declarations = [];
|
|
1271
|
+
for (const blob of discovered.blobs) {
|
|
1272
|
+
if (!blob.bytes || !blob.format) {
|
|
1273
|
+
const code = blob.contentHash === "oversized"
|
|
1274
|
+
? "api_declaration_oversized"
|
|
1275
|
+
: blob.mode === "unsafe-path"
|
|
1276
|
+
? "api_declaration_path"
|
|
1277
|
+
: "api_declaration_mode";
|
|
1278
|
+
issues.push({
|
|
1279
|
+
code,
|
|
1280
|
+
sourcePath: blob.path,
|
|
1281
|
+
sourceField: "",
|
|
1282
|
+
detail: blob.contentHash === "oversized"
|
|
1283
|
+
? `${blob.path} exceeds the ${MAX_API_DECLARATION_BYTES}-byte API declaration limit`
|
|
1284
|
+
: blob.mode === "unsafe-path"
|
|
1285
|
+
? "an API declaration uses an unsafe path"
|
|
1286
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
1287
|
+
});
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
let source;
|
|
1291
|
+
try {
|
|
1292
|
+
source = UTF8_DECODER.decode(blob.bytes);
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
issues.push({
|
|
1296
|
+
code: "api_declaration_invalid",
|
|
1297
|
+
sourcePath: blob.path,
|
|
1298
|
+
sourceField: "",
|
|
1299
|
+
detail: `${blob.path} is not valid UTF-8 API declaration data`,
|
|
1300
|
+
});
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
if (blob.format === "yaml" && !parseSource(blob.path, source)?.parseable) {
|
|
1304
|
+
issues.push({
|
|
1305
|
+
code: "api_declaration_invalid",
|
|
1306
|
+
sourcePath: blob.path,
|
|
1307
|
+
sourceField: "",
|
|
1308
|
+
detail: `${blob.path} is not structurally valid OpenAPI YAML`,
|
|
1309
|
+
});
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
const identity = blob.format === "json"
|
|
1313
|
+
? jsonApiIdentity(source)
|
|
1314
|
+
: blob.format === "yaml"
|
|
1315
|
+
? yamlApiIdentity(source)
|
|
1316
|
+
: protobufApiIdentity(source);
|
|
1317
|
+
if (!identity) {
|
|
1318
|
+
issues.push({
|
|
1319
|
+
code: "api_declaration_invalid",
|
|
1320
|
+
sourcePath: blob.path,
|
|
1321
|
+
sourceField: "openapi|swagger",
|
|
1322
|
+
detail: `${blob.path} must declare exactly one supported OpenAPI, Swagger, AsyncAPI, protobuf or JSON Schema version`,
|
|
1323
|
+
});
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
declarations.push({
|
|
1327
|
+
path: blob.path,
|
|
1328
|
+
contentHash: blob.contentHash,
|
|
1329
|
+
format: blob.format,
|
|
1330
|
+
...identity,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
return declarations;
|
|
1334
|
+
}
|
|
1335
|
+
function migrationDeclarationIdentity(path) {
|
|
1336
|
+
const prisma = path.match(/(?:^|\/)prisma\/migrations\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})\/migration\.sql$/);
|
|
1337
|
+
if (prisma) {
|
|
1338
|
+
return {
|
|
1339
|
+
provider: "prisma",
|
|
1340
|
+
migrationId: prisma[1],
|
|
1341
|
+
migrationType: "versioned",
|
|
1342
|
+
contractVersion: prisma[1],
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
const flywayVersioned = path.match(/(?:^|\/)db\/migration\/([VU])([0-9][0-9._-]{0,127})__([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.sql$/);
|
|
1346
|
+
if (flywayVersioned) {
|
|
1347
|
+
return {
|
|
1348
|
+
provider: "flyway",
|
|
1349
|
+
migrationId: `${flywayVersioned[1]}${flywayVersioned[2]}`,
|
|
1350
|
+
migrationType: flywayVersioned[1] === "V" ? "versioned" : "undo",
|
|
1351
|
+
contractVersion: flywayVersioned[2],
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
const flywayRepeatable = path.match(/(?:^|\/)db\/migration\/(R)__([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.sql$/);
|
|
1355
|
+
if (flywayRepeatable) {
|
|
1356
|
+
return {
|
|
1357
|
+
provider: "flyway",
|
|
1358
|
+
migrationId: `R__${flywayRepeatable[2]}`,
|
|
1359
|
+
migrationType: "repeatable",
|
|
1360
|
+
contractVersion: null,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const rails = path.match(/(?:^|\/)db\/migrate\/([0-9]{14})_([a-z0-9][a-z0-9_]{0,127})\.rb$/);
|
|
1364
|
+
if (rails) {
|
|
1365
|
+
return {
|
|
1366
|
+
provider: "rails",
|
|
1367
|
+
migrationId: rails[1],
|
|
1368
|
+
migrationType: "versioned",
|
|
1369
|
+
contractVersion: rails[1],
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
const django = path.match(/(?:^|\/)([a-z_][a-z0-9_]*)\/migrations\/([0-9]{4,8})_([a-z][a-z0-9_]{0,119})\.py$/);
|
|
1373
|
+
if (django) {
|
|
1374
|
+
return {
|
|
1375
|
+
provider: "django",
|
|
1376
|
+
migrationId: `${django[2]}_${django[3]}`,
|
|
1377
|
+
migrationType: "versioned",
|
|
1378
|
+
contractVersion: django[2],
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
const laravel = path.match(/(?:^|\/)database\/migrations\/([0-9]{4}_[0-9]{2}_[0-9]{2}_[0-9]{6})_([a-z][a-z0-9_]{0,127})\.php$/);
|
|
1382
|
+
if (laravel) {
|
|
1383
|
+
return {
|
|
1384
|
+
provider: "laravel",
|
|
1385
|
+
migrationId: `${laravel[1]}_${laravel[2]}`,
|
|
1386
|
+
migrationType: "versioned",
|
|
1387
|
+
contractVersion: laravel[1],
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
const alembic = path.match(/(?:^|\/)alembic\/versions\/([a-f0-9]{12,32})_([a-z][a-z0-9_]{0,119})\.py$/);
|
|
1391
|
+
return alembic
|
|
1392
|
+
? {
|
|
1393
|
+
provider: "alembic",
|
|
1394
|
+
migrationId: `${alembic[1]}_${alembic[2]}`,
|
|
1395
|
+
migrationType: "versioned",
|
|
1396
|
+
contractVersion: alembic[1],
|
|
1397
|
+
}
|
|
1398
|
+
: null;
|
|
1399
|
+
}
|
|
1400
|
+
function migrationDeclarationBlobs(root, tree) {
|
|
1401
|
+
const discovered = [];
|
|
1402
|
+
for (const entry of tree) {
|
|
1403
|
+
if (entry.kind === "tree")
|
|
1404
|
+
continue;
|
|
1405
|
+
const { pathBytes } = entry;
|
|
1406
|
+
if (entry.path === null) {
|
|
1407
|
+
const approximate = pathBytes.toString("latin1");
|
|
1408
|
+
if (!firstPartyDeclarationPath(approximate))
|
|
1409
|
+
continue;
|
|
1410
|
+
if (migrationDeclarationIdentity(approximate)) {
|
|
1411
|
+
discovered.push({
|
|
1412
|
+
path: `<unsafe-migration-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1413
|
+
mode: "unsafe-path",
|
|
1414
|
+
oid: entry.oid,
|
|
1415
|
+
objectSize: entry.objectSize,
|
|
1416
|
+
bytes: null,
|
|
1417
|
+
contentHash: null,
|
|
1418
|
+
provider: null,
|
|
1419
|
+
migrationId: null,
|
|
1420
|
+
migrationType: null,
|
|
1421
|
+
contractVersion: null,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
const path = entry.path;
|
|
1427
|
+
if (!firstPartyDeclarationPath(path))
|
|
1428
|
+
continue;
|
|
1429
|
+
const identity = migrationDeclarationIdentity(path);
|
|
1430
|
+
if (!identity)
|
|
1431
|
+
continue;
|
|
1432
|
+
if (!safeDeclarationPath(path)) {
|
|
1433
|
+
discovered.push({
|
|
1434
|
+
path: `<unsafe-migration-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1435
|
+
mode: "unsafe-path",
|
|
1436
|
+
oid: entry.oid,
|
|
1437
|
+
objectSize: entry.objectSize,
|
|
1438
|
+
bytes: null,
|
|
1439
|
+
contentHash: null,
|
|
1440
|
+
provider: null,
|
|
1441
|
+
migrationId: null,
|
|
1442
|
+
migrationType: null,
|
|
1443
|
+
contractVersion: null,
|
|
1444
|
+
});
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
discovered.push({
|
|
1448
|
+
path,
|
|
1449
|
+
mode: treeEntryMode(entry),
|
|
1450
|
+
oid: entry.oid,
|
|
1451
|
+
objectSize: entry.objectSize,
|
|
1452
|
+
bytes: null,
|
|
1453
|
+
contentHash: null,
|
|
1454
|
+
...identity,
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
discovered.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
1458
|
+
const total = discovered.length;
|
|
1459
|
+
const blobs = hydrateDeclarationBlobs(root, discovered.slice(0, MAX_MIGRATION_DECLARATIONS), MAX_MIGRATION_DECLARATION_BYTES);
|
|
1460
|
+
return { blobs, total };
|
|
1461
|
+
}
|
|
1462
|
+
function migrationDeclarations(root, revision, tree, issues) {
|
|
1463
|
+
const discovered = migrationDeclarationBlobs(root, tree);
|
|
1464
|
+
if (discovered.total > MAX_MIGRATION_DECLARATIONS) {
|
|
1465
|
+
issues.push({
|
|
1466
|
+
code: "migration_declaration_limit",
|
|
1467
|
+
sourcePath: ".",
|
|
1468
|
+
sourceField: "migration",
|
|
1469
|
+
detail: `bounded discovery accepts at most ${MAX_MIGRATION_DECLARATIONS} migration declarations`,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
const declarations = [];
|
|
1473
|
+
for (const blob of discovered.blobs) {
|
|
1474
|
+
if (!blob.bytes || !blob.provider || !blob.migrationId || !blob.migrationType) {
|
|
1475
|
+
const code = blob.contentHash === "oversized"
|
|
1476
|
+
? "migration_declaration_oversized"
|
|
1477
|
+
: blob.mode === "unsafe-path"
|
|
1478
|
+
? "migration_declaration_path"
|
|
1479
|
+
: "migration_declaration_mode";
|
|
1480
|
+
issues.push({
|
|
1481
|
+
code,
|
|
1482
|
+
sourcePath: blob.path,
|
|
1483
|
+
sourceField: "path",
|
|
1484
|
+
detail: blob.contentHash === "oversized"
|
|
1485
|
+
? `${blob.path} exceeds the ${MAX_MIGRATION_DECLARATION_BYTES}-byte migration declaration limit`
|
|
1486
|
+
: blob.mode === "unsafe-path"
|
|
1487
|
+
? "a migration declaration uses an unsafe path"
|
|
1488
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
1489
|
+
});
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
let source;
|
|
1493
|
+
try {
|
|
1494
|
+
source = UTF8_DECODER.decode(blob.bytes);
|
|
1495
|
+
}
|
|
1496
|
+
catch {
|
|
1497
|
+
issues.push({
|
|
1498
|
+
code: "migration_declaration_invalid",
|
|
1499
|
+
sourcePath: blob.path,
|
|
1500
|
+
sourceField: "path",
|
|
1501
|
+
detail: `${blob.path} is not valid UTF-8 migration data`,
|
|
1502
|
+
});
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
if (!source.replace(/^\uFEFF/, "").trim()) {
|
|
1506
|
+
issues.push({
|
|
1507
|
+
code: "migration_declaration_invalid",
|
|
1508
|
+
sourcePath: blob.path,
|
|
1509
|
+
sourceField: "path",
|
|
1510
|
+
detail: `${blob.path} is an empty migration declaration`,
|
|
1511
|
+
});
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
declarations.push({
|
|
1515
|
+
path: blob.path,
|
|
1516
|
+
contentHash: blob.contentHash,
|
|
1517
|
+
provider: blob.provider,
|
|
1518
|
+
migrationId: blob.migrationId,
|
|
1519
|
+
migrationType: blob.migrationType,
|
|
1520
|
+
contractVersion: blob.contractVersion,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
return declarations;
|
|
1524
|
+
}
|
|
1525
|
+
const MIGRATION_PROVIDER_NAMES = {
|
|
1526
|
+
prisma: "Prisma",
|
|
1527
|
+
flyway: "Flyway",
|
|
1528
|
+
rails: "Rails",
|
|
1529
|
+
django: "Django",
|
|
1530
|
+
laravel: "Laravel",
|
|
1531
|
+
alembic: "Alembic",
|
|
1532
|
+
};
|
|
1533
|
+
function migrationProviderName(provider) {
|
|
1534
|
+
return MIGRATION_PROVIDER_NAMES[provider];
|
|
1535
|
+
}
|
|
1536
|
+
const CODEOWNERS_PATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
|
|
1537
|
+
function ownershipDeclarationBlob(root, tree) {
|
|
1538
|
+
const discovered = new Map();
|
|
1539
|
+
for (const entry of tree) {
|
|
1540
|
+
const path = entry.path;
|
|
1541
|
+
if (path === null)
|
|
1542
|
+
continue;
|
|
1543
|
+
if (!CODEOWNERS_PATHS.includes(path))
|
|
1544
|
+
continue;
|
|
1545
|
+
discovered.set(path, {
|
|
1546
|
+
path,
|
|
1547
|
+
mode: treeEntryMode(entry),
|
|
1548
|
+
oid: entry.oid,
|
|
1549
|
+
objectSize: entry.objectSize,
|
|
1550
|
+
bytes: null,
|
|
1551
|
+
contentHash: null,
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
const selected = CODEOWNERS_PATHS.map((path) => discovered.get(path)).find((blob) => blob !== undefined);
|
|
1555
|
+
if (!selected || !ORDINARY_BLOB_MODES.has(selected.mode))
|
|
1556
|
+
return selected ?? null;
|
|
1557
|
+
return hydrateDeclarationBlobs(root, [selected], MAX_OWNERSHIP_DECLARATION_BYTES)[0];
|
|
1558
|
+
}
|
|
1559
|
+
function githubTeamOwner(value) {
|
|
1560
|
+
const match = value.match(/^@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,99}))$/);
|
|
1561
|
+
if (!match)
|
|
1562
|
+
return null;
|
|
1563
|
+
const organization = match[1].toLowerCase();
|
|
1564
|
+
const team = match[2].toLowerCase();
|
|
1565
|
+
return { organization, team, handle: `@${organization}/${team}` };
|
|
1566
|
+
}
|
|
1567
|
+
function ownershipDeclaration(root, revision, tree, issues) {
|
|
1568
|
+
const blob = ownershipDeclarationBlob(root, tree);
|
|
1569
|
+
if (!blob)
|
|
1570
|
+
return null;
|
|
1571
|
+
if (!blob.bytes) {
|
|
1572
|
+
const code = blob.contentHash === "oversized"
|
|
1573
|
+
? "ownership_declaration_oversized"
|
|
1574
|
+
: "ownership_declaration_mode";
|
|
1575
|
+
issues.push({
|
|
1576
|
+
code,
|
|
1577
|
+
sourcePath: blob.path,
|
|
1578
|
+
sourceField: "default-owner",
|
|
1579
|
+
detail: blob.contentHash === "oversized"
|
|
1580
|
+
? `${blob.path} exceeds the ${MAX_OWNERSHIP_DECLARATION_BYTES}-byte ownership declaration limit`
|
|
1581
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
1582
|
+
});
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
let source;
|
|
1586
|
+
try {
|
|
1587
|
+
source = UTF8_DECODER.decode(blob.bytes);
|
|
1588
|
+
}
|
|
1589
|
+
catch {
|
|
1590
|
+
issues.push({
|
|
1591
|
+
code: "ownership_declaration_invalid",
|
|
1592
|
+
sourcePath: blob.path,
|
|
1593
|
+
sourceField: "default-owner",
|
|
1594
|
+
detail: `${blob.path} is not valid UTF-8 ownership data`,
|
|
1595
|
+
});
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
let defaultTeams = [];
|
|
1599
|
+
for (const rawLine of source.replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
|
1600
|
+
const line = rawLine.trim();
|
|
1601
|
+
if (!line || line.startsWith("#"))
|
|
1602
|
+
continue;
|
|
1603
|
+
const fields = line.split(/\s+/);
|
|
1604
|
+
if (fields[0] !== "*")
|
|
1605
|
+
continue;
|
|
1606
|
+
const teams = new Map();
|
|
1607
|
+
for (const owner of fields.slice(1)) {
|
|
1608
|
+
const team = githubTeamOwner(owner);
|
|
1609
|
+
if (team)
|
|
1610
|
+
teams.set(team.handle, team);
|
|
1611
|
+
}
|
|
1612
|
+
defaultTeams = [...teams.values()].sort((left, right) => compareCodeUnits(left.handle, right.handle));
|
|
1613
|
+
}
|
|
1614
|
+
if (defaultTeams.length > MAX_OWNERSHIP_TEAMS) {
|
|
1615
|
+
issues.push({
|
|
1616
|
+
code: "ownership_declaration_limit",
|
|
1617
|
+
sourcePath: blob.path,
|
|
1618
|
+
sourceField: "default-owner",
|
|
1619
|
+
detail: `bounded discovery accepts at most ${MAX_OWNERSHIP_TEAMS} repository-wide GitHub team owners`,
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
return {
|
|
1623
|
+
path: blob.path,
|
|
1624
|
+
contentHash: blob.contentHash,
|
|
1625
|
+
teams: defaultTeams.slice(0, MAX_OWNERSHIP_TEAMS),
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
/** Deliberately narrow conventions: a named runbook file is evidence that the
|
|
1629
|
+
* repository contains operational guidance. Arbitrary Markdown and headings do
|
|
1630
|
+
* not become architecture merely because they mention incidents or dashboards. */
|
|
1631
|
+
function operationsDeclarationPath(path) {
|
|
1632
|
+
const basename = posix.basename(path);
|
|
1633
|
+
if (/^RUNBOOK\.mdx?$/i.test(path))
|
|
1634
|
+
return true;
|
|
1635
|
+
if (!/(?:^|\/)runbooks?\/.+\.mdx?$/i.test(path))
|
|
1636
|
+
return false;
|
|
1637
|
+
return !/^(?:README|INDEX)\.mdx?$/i.test(basename);
|
|
1638
|
+
}
|
|
1639
|
+
function operationsDeclarationBlobs(root, tree) {
|
|
1640
|
+
const discovered = [];
|
|
1641
|
+
for (const entry of tree) {
|
|
1642
|
+
if (entry.kind === "tree")
|
|
1643
|
+
continue;
|
|
1644
|
+
const { pathBytes } = entry;
|
|
1645
|
+
if (entry.path === null) {
|
|
1646
|
+
const approximate = pathBytes.toString("latin1");
|
|
1647
|
+
if (!firstPartyDeclarationPath(approximate))
|
|
1648
|
+
continue;
|
|
1649
|
+
if (operationsDeclarationPath(approximate)) {
|
|
1650
|
+
discovered.push({
|
|
1651
|
+
path: `<unsafe-operations-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1652
|
+
mode: "unsafe-path",
|
|
1653
|
+
oid: entry.oid,
|
|
1654
|
+
objectSize: entry.objectSize,
|
|
1655
|
+
bytes: null,
|
|
1656
|
+
contentHash: null,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
const path = entry.path;
|
|
1662
|
+
if (!firstPartyDeclarationPath(path))
|
|
1663
|
+
continue;
|
|
1664
|
+
if (!operationsDeclarationPath(path))
|
|
1665
|
+
continue;
|
|
1666
|
+
if (!safeDeclarationPath(path)) {
|
|
1667
|
+
discovered.push({
|
|
1668
|
+
path: `<unsafe-operations-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1669
|
+
mode: "unsafe-path",
|
|
1670
|
+
oid: entry.oid,
|
|
1671
|
+
objectSize: entry.objectSize,
|
|
1672
|
+
bytes: null,
|
|
1673
|
+
contentHash: null,
|
|
1674
|
+
});
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
discovered.push({
|
|
1678
|
+
path,
|
|
1679
|
+
mode: treeEntryMode(entry),
|
|
1680
|
+
oid: entry.oid,
|
|
1681
|
+
objectSize: entry.objectSize,
|
|
1682
|
+
bytes: null,
|
|
1683
|
+
contentHash: null,
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
discovered.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
1687
|
+
const total = discovered.length;
|
|
1688
|
+
const blobs = hydrateDeclarationBlobs(root, discovered.slice(0, MAX_OPERATIONS_DECLARATIONS), MAX_OPERATIONS_DECLARATION_BYTES);
|
|
1689
|
+
return { blobs, total };
|
|
1690
|
+
}
|
|
1691
|
+
function operationsDeclarations(root, revision, tree, issues) {
|
|
1692
|
+
const discovered = operationsDeclarationBlobs(root, tree);
|
|
1693
|
+
if (discovered.total > MAX_OPERATIONS_DECLARATIONS) {
|
|
1694
|
+
issues.push({
|
|
1695
|
+
code: "operations_declaration_limit",
|
|
1696
|
+
sourcePath: ".",
|
|
1697
|
+
sourceField: "runbook",
|
|
1698
|
+
detail: `bounded discovery accepts at most ${MAX_OPERATIONS_DECLARATIONS} runbook declarations`,
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
const declarations = [];
|
|
1702
|
+
for (const blob of discovered.blobs) {
|
|
1703
|
+
if (!blob.bytes) {
|
|
1704
|
+
const code = blob.contentHash === "oversized"
|
|
1705
|
+
? "operations_declaration_oversized"
|
|
1706
|
+
: blob.mode === "unsafe-path"
|
|
1707
|
+
? "operations_declaration_path"
|
|
1708
|
+
: "operations_declaration_mode";
|
|
1709
|
+
issues.push({
|
|
1710
|
+
code,
|
|
1711
|
+
sourcePath: blob.path,
|
|
1712
|
+
sourceField: "path",
|
|
1713
|
+
detail: blob.contentHash === "oversized"
|
|
1714
|
+
? `${blob.path} exceeds the ${MAX_OPERATIONS_DECLARATION_BYTES}-byte runbook declaration limit`
|
|
1715
|
+
: blob.mode === "unsafe-path"
|
|
1716
|
+
? "a runbook declaration uses an unsafe path"
|
|
1717
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
1718
|
+
});
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
let source;
|
|
1722
|
+
try {
|
|
1723
|
+
source = UTF8_DECODER.decode(blob.bytes);
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
issues.push({
|
|
1727
|
+
code: "operations_declaration_invalid",
|
|
1728
|
+
sourcePath: blob.path,
|
|
1729
|
+
sourceField: "path",
|
|
1730
|
+
detail: `${blob.path} is not valid UTF-8 runbook data`,
|
|
1731
|
+
});
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
if (!source.replace(/^\uFEFF/, "").trim()) {
|
|
1735
|
+
issues.push({
|
|
1736
|
+
code: "operations_declaration_invalid",
|
|
1737
|
+
sourcePath: blob.path,
|
|
1738
|
+
sourceField: "path",
|
|
1739
|
+
detail: `${blob.path} is an empty runbook declaration`,
|
|
1740
|
+
});
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
declarations.push({ path: blob.path, contentHash: blob.contentHash });
|
|
1744
|
+
}
|
|
1745
|
+
return declarations;
|
|
1746
|
+
}
|
|
1747
|
+
/** An explicit dashboard directory is durable operations evidence. Dashboard
|
|
1748
|
+
* titles, panels, queries, datasource names, variables and links are content,
|
|
1749
|
+
* not safe identity, so only the committed path and content hash survive. */
|
|
1750
|
+
function dashboardDeclarationPath(path) {
|
|
1751
|
+
return /(?:^|\/)dashboards\/.+\.json$/i.test(path);
|
|
1752
|
+
}
|
|
1753
|
+
function dashboardDeclarationBlobs(root, tree) {
|
|
1754
|
+
const discovered = [];
|
|
1755
|
+
for (const entry of tree) {
|
|
1756
|
+
if (entry.kind === "tree")
|
|
1757
|
+
continue;
|
|
1758
|
+
const { pathBytes } = entry;
|
|
1759
|
+
if (entry.path === null) {
|
|
1760
|
+
const approximate = pathBytes.toString("latin1");
|
|
1761
|
+
if (!firstPartyDeclarationPath(approximate))
|
|
1762
|
+
continue;
|
|
1763
|
+
if (dashboardDeclarationPath(approximate)) {
|
|
1764
|
+
discovered.push({
|
|
1765
|
+
path: `<unsafe-dashboard-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1766
|
+
mode: "unsafe-path",
|
|
1767
|
+
oid: entry.oid,
|
|
1768
|
+
objectSize: entry.objectSize,
|
|
1769
|
+
bytes: null,
|
|
1770
|
+
contentHash: null,
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
const path = entry.path;
|
|
1776
|
+
if (!firstPartyDeclarationPath(path) || !dashboardDeclarationPath(path))
|
|
1777
|
+
continue;
|
|
1778
|
+
if (!safeDeclarationPath(path)) {
|
|
1779
|
+
discovered.push({
|
|
1780
|
+
path: `<unsafe-dashboard-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1781
|
+
mode: "unsafe-path",
|
|
1782
|
+
oid: entry.oid,
|
|
1783
|
+
objectSize: entry.objectSize,
|
|
1784
|
+
bytes: null,
|
|
1785
|
+
contentHash: null,
|
|
1786
|
+
});
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
discovered.push({
|
|
1790
|
+
path,
|
|
1791
|
+
mode: treeEntryMode(entry),
|
|
1792
|
+
oid: entry.oid,
|
|
1793
|
+
objectSize: entry.objectSize,
|
|
1794
|
+
bytes: null,
|
|
1795
|
+
contentHash: null,
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
discovered.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
1799
|
+
const total = discovered.length;
|
|
1800
|
+
const blobs = hydrateDeclarationBlobs(root, discovered.slice(0, MAX_DASHBOARD_DECLARATIONS), MAX_DASHBOARD_DECLARATION_BYTES);
|
|
1801
|
+
return { blobs, total };
|
|
1802
|
+
}
|
|
1803
|
+
function dashboardDeclarations(root, tree, issues) {
|
|
1804
|
+
const discovered = dashboardDeclarationBlobs(root, tree);
|
|
1805
|
+
if (discovered.total > MAX_DASHBOARD_DECLARATIONS) {
|
|
1806
|
+
issues.push({
|
|
1807
|
+
code: "dashboard_declaration_limit",
|
|
1808
|
+
sourcePath: ".",
|
|
1809
|
+
sourceField: "dashboard",
|
|
1810
|
+
detail: `bounded discovery accepts at most ${MAX_DASHBOARD_DECLARATIONS} dashboard declarations`,
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
const declarations = [];
|
|
1814
|
+
for (const blob of discovered.blobs) {
|
|
1815
|
+
if (!blob.bytes) {
|
|
1816
|
+
const code = blob.contentHash === "oversized"
|
|
1817
|
+
? "dashboard_declaration_oversized"
|
|
1818
|
+
: blob.mode === "unsafe-path"
|
|
1819
|
+
? "dashboard_declaration_path"
|
|
1820
|
+
: "dashboard_declaration_mode";
|
|
1821
|
+
issues.push({
|
|
1822
|
+
code,
|
|
1823
|
+
sourcePath: blob.path,
|
|
1824
|
+
sourceField: "path",
|
|
1825
|
+
detail: blob.contentHash === "oversized"
|
|
1826
|
+
? `${blob.path} exceeds the ${MAX_DASHBOARD_DECLARATION_BYTES}-byte dashboard declaration limit`
|
|
1827
|
+
: blob.mode === "unsafe-path"
|
|
1828
|
+
? "a dashboard declaration uses an unsafe path"
|
|
1829
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
1830
|
+
});
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
try {
|
|
1834
|
+
const value = JSON.parse(UTF8_DECODER.decode(blob.bytes));
|
|
1835
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1836
|
+
throw new Error("dashboard root is not an object");
|
|
1837
|
+
}
|
|
1838
|
+
catch {
|
|
1839
|
+
issues.push({
|
|
1840
|
+
code: "dashboard_declaration_invalid",
|
|
1841
|
+
sourcePath: blob.path,
|
|
1842
|
+
sourceField: "path",
|
|
1843
|
+
detail: `${blob.path} is not valid JSON object dashboard data`,
|
|
1844
|
+
});
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
declarations.push({ path: blob.path, contentHash: blob.contentHash });
|
|
1848
|
+
}
|
|
1849
|
+
return declarations;
|
|
1850
|
+
}
|
|
1851
|
+
/** OpenSLO is a vendor-neutral, durable SLO declaration. Discovery keeps only
|
|
1852
|
+
* its fixed v1/SLO header plus path/content identity; metadata, objectives,
|
|
1853
|
+
* indicators, queries, services and alert policy bodies never leave parsing. */
|
|
1854
|
+
function sloDeclarationFormat(path) {
|
|
1855
|
+
const basename = posix.basename(path);
|
|
1856
|
+
const extension = basename.match(/\.(json|ya?ml)$/i);
|
|
1857
|
+
if (!extension)
|
|
1858
|
+
return null;
|
|
1859
|
+
const stem = basename.slice(0, -extension[0].length);
|
|
1860
|
+
const explicitDirectory = /(?:^|\/)(?:\.openslo|slo|slos)\//i.test(path);
|
|
1861
|
+
const explicitName = /^(?:openslo|slo)(?:[._-].+)?$/i.test(stem);
|
|
1862
|
+
if (!explicitDirectory && !explicitName)
|
|
1863
|
+
return null;
|
|
1864
|
+
return extension[1].toLowerCase() === "json" ? "json" : "yaml";
|
|
1865
|
+
}
|
|
1866
|
+
function sloDeclarationBlobs(root, tree) {
|
|
1867
|
+
const discovered = [];
|
|
1868
|
+
for (const entry of tree) {
|
|
1869
|
+
if (entry.kind === "tree")
|
|
1870
|
+
continue;
|
|
1871
|
+
const { pathBytes } = entry;
|
|
1872
|
+
if (entry.path === null) {
|
|
1873
|
+
const approximate = pathBytes.toString("latin1");
|
|
1874
|
+
if (!firstPartyDeclarationPath(approximate) || !sloDeclarationFormat(approximate))
|
|
1875
|
+
continue;
|
|
1876
|
+
discovered.push({
|
|
1877
|
+
path: `<unsafe-slo-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1878
|
+
mode: "unsafe-path",
|
|
1879
|
+
oid: entry.oid,
|
|
1880
|
+
objectSize: entry.objectSize,
|
|
1881
|
+
bytes: null,
|
|
1882
|
+
contentHash: null,
|
|
1883
|
+
format: null,
|
|
1884
|
+
});
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
const path = entry.path;
|
|
1888
|
+
if (!firstPartyDeclarationPath(path))
|
|
1889
|
+
continue;
|
|
1890
|
+
const format = sloDeclarationFormat(path);
|
|
1891
|
+
if (!format)
|
|
1892
|
+
continue;
|
|
1893
|
+
if (!safeDeclarationPath(path)) {
|
|
1894
|
+
discovered.push({
|
|
1895
|
+
path: `<unsafe-slo-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
1896
|
+
mode: "unsafe-path",
|
|
1897
|
+
oid: entry.oid,
|
|
1898
|
+
objectSize: entry.objectSize,
|
|
1899
|
+
bytes: null,
|
|
1900
|
+
contentHash: null,
|
|
1901
|
+
format: null,
|
|
1902
|
+
});
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
discovered.push({
|
|
1906
|
+
path,
|
|
1907
|
+
mode: treeEntryMode(entry),
|
|
1908
|
+
oid: entry.oid,
|
|
1909
|
+
objectSize: entry.objectSize,
|
|
1910
|
+
bytes: null,
|
|
1911
|
+
contentHash: null,
|
|
1912
|
+
format,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
discovered.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
1916
|
+
const total = discovered.length;
|
|
1917
|
+
const blobs = hydrateDeclarationBlobs(root, discovered.slice(0, MAX_SLO_DECLARATIONS), MAX_SLO_DECLARATION_BYTES);
|
|
1918
|
+
return { blobs, total };
|
|
1919
|
+
}
|
|
1920
|
+
function validOpenSloName(value) {
|
|
1921
|
+
return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 256;
|
|
1922
|
+
}
|
|
1923
|
+
function validJsonOpenSlo(source) {
|
|
1924
|
+
const value = JSON.parse(source);
|
|
1925
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1926
|
+
return false;
|
|
1927
|
+
const record = value;
|
|
1928
|
+
const metadata = record.metadata;
|
|
1929
|
+
return record.apiVersion === "openslo/v1"
|
|
1930
|
+
&& record.kind === "SLO"
|
|
1931
|
+
&& !!metadata
|
|
1932
|
+
&& typeof metadata === "object"
|
|
1933
|
+
&& !Array.isArray(metadata)
|
|
1934
|
+
&& validOpenSloName(metadata.name);
|
|
1935
|
+
}
|
|
1936
|
+
function validYamlOpenSlo(path, source) {
|
|
1937
|
+
if (!parseSource(path, source)?.parseable)
|
|
1938
|
+
return false;
|
|
1939
|
+
const topLevel = new Map();
|
|
1940
|
+
let duplicate = false;
|
|
1941
|
+
const lines = source.split(/\r?\n/);
|
|
1942
|
+
const significant = lines.map((line, index) => ({ line: line.trim(), index }))
|
|
1943
|
+
.filter(({ line }) => line && !line.startsWith("#"));
|
|
1944
|
+
const documentStarts = significant.filter(({ line }) => line === "---");
|
|
1945
|
+
const documentEnds = significant.filter(({ line }) => line === "...");
|
|
1946
|
+
if (documentStarts.length > 1
|
|
1947
|
+
|| (documentStarts.length === 1 && documentStarts[0].index !== significant[0].index)
|
|
1948
|
+
|| documentEnds.length > 1
|
|
1949
|
+
|| (documentEnds.length === 1 && documentEnds[0].index !== significant.at(-1).index))
|
|
1950
|
+
return false;
|
|
1951
|
+
for (const line of lines) {
|
|
1952
|
+
if (!line.trim() || line.trimStart().startsWith("#") || line.match(/^ */)[0].length !== 0)
|
|
1953
|
+
continue;
|
|
1954
|
+
const mapping = line.match(/^(apiVersion|kind)[ \t]*:(.*)$/);
|
|
1955
|
+
if (!mapping)
|
|
1956
|
+
continue;
|
|
1957
|
+
if (topLevel.has(mapping[1]))
|
|
1958
|
+
duplicate = true;
|
|
1959
|
+
topLevel.set(mapping[1], boundedYamlScalar(mapping[2]));
|
|
1960
|
+
}
|
|
1961
|
+
if (duplicate || topLevel.get("apiVersion") !== "openslo/v1" || topLevel.get("kind") !== "SLO")
|
|
1962
|
+
return false;
|
|
1963
|
+
const metadataIndex = lines.findIndex((line) => /^metadata[ \t]*:[ \t]*(?:#.*)?$/.test(line));
|
|
1964
|
+
if (metadataIndex < 0)
|
|
1965
|
+
return false;
|
|
1966
|
+
let directIndent = null;
|
|
1967
|
+
for (const line of lines.slice(metadataIndex + 1)) {
|
|
1968
|
+
if (!line.trim() || line.trimStart().startsWith("#"))
|
|
1969
|
+
continue;
|
|
1970
|
+
const indentation = line.match(/^ */)[0].length;
|
|
1971
|
+
if (indentation === 0)
|
|
1972
|
+
break;
|
|
1973
|
+
directIndent ??= indentation;
|
|
1974
|
+
if (indentation !== directIndent)
|
|
1975
|
+
continue;
|
|
1976
|
+
const name = line.trimStart().match(/^name[ \t]*:(.*)$/);
|
|
1977
|
+
if (name)
|
|
1978
|
+
return validOpenSloName(boundedYamlScalar(name[1]));
|
|
1979
|
+
}
|
|
1980
|
+
return false;
|
|
1981
|
+
}
|
|
1982
|
+
function sloDeclarations(root, tree, issues) {
|
|
1983
|
+
const discovered = sloDeclarationBlobs(root, tree);
|
|
1984
|
+
if (discovered.total > MAX_SLO_DECLARATIONS) {
|
|
1985
|
+
issues.push({
|
|
1986
|
+
code: "slo_declaration_limit",
|
|
1987
|
+
sourcePath: ".",
|
|
1988
|
+
sourceField: "slo",
|
|
1989
|
+
detail: `bounded discovery accepts at most ${MAX_SLO_DECLARATIONS} SLO declarations`,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
const declarations = [];
|
|
1993
|
+
for (const blob of discovered.blobs) {
|
|
1994
|
+
if (!blob.bytes || !blob.format) {
|
|
1995
|
+
const code = blob.contentHash === "oversized"
|
|
1996
|
+
? "slo_declaration_oversized"
|
|
1997
|
+
: blob.mode === "unsafe-path"
|
|
1998
|
+
? "slo_declaration_path"
|
|
1999
|
+
: "slo_declaration_mode";
|
|
2000
|
+
issues.push({
|
|
2001
|
+
code,
|
|
2002
|
+
sourcePath: blob.path,
|
|
2003
|
+
sourceField: "path",
|
|
2004
|
+
detail: blob.contentHash === "oversized"
|
|
2005
|
+
? `${blob.path} exceeds the ${MAX_SLO_DECLARATION_BYTES}-byte SLO declaration limit`
|
|
2006
|
+
: blob.mode === "unsafe-path"
|
|
2007
|
+
? "an SLO declaration uses an unsafe path"
|
|
2008
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
2009
|
+
});
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
let source;
|
|
2013
|
+
try {
|
|
2014
|
+
source = UTF8_DECODER.decode(blob.bytes);
|
|
2015
|
+
}
|
|
2016
|
+
catch {
|
|
2017
|
+
issues.push({
|
|
2018
|
+
code: "slo_declaration_invalid",
|
|
2019
|
+
sourcePath: blob.path,
|
|
2020
|
+
sourceField: "path",
|
|
2021
|
+
detail: `${blob.path} is not valid UTF-8 SLO data`,
|
|
2022
|
+
});
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
let valid = false;
|
|
2026
|
+
try {
|
|
2027
|
+
valid = blob.format === "json" ? validJsonOpenSlo(source) : validYamlOpenSlo(blob.path, source);
|
|
2028
|
+
}
|
|
2029
|
+
catch {
|
|
2030
|
+
valid = false;
|
|
2031
|
+
}
|
|
2032
|
+
if (!valid) {
|
|
2033
|
+
issues.push({
|
|
2034
|
+
code: "slo_declaration_invalid",
|
|
2035
|
+
sourcePath: blob.path,
|
|
2036
|
+
sourceField: "apiVersion/kind/metadata.name",
|
|
2037
|
+
detail: `${blob.path} is not a structurally valid OpenSLO v1 SLO declaration`,
|
|
2038
|
+
});
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
declarations.push({
|
|
2042
|
+
path: blob.path,
|
|
2043
|
+
contentHash: blob.contentHash,
|
|
2044
|
+
format: blob.format,
|
|
2045
|
+
contractVersion: "openslo/v1",
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
return declarations;
|
|
2049
|
+
}
|
|
2050
|
+
function deliveryDeclarationBlobs(root, tree) {
|
|
2051
|
+
const discovered = [];
|
|
2052
|
+
for (const entry of tree) {
|
|
2053
|
+
if (entry.kind === "tree")
|
|
2054
|
+
continue;
|
|
2055
|
+
const { pathBytes } = entry;
|
|
2056
|
+
if (entry.path === null) {
|
|
2057
|
+
const approximate = pathBytes.toString("latin1");
|
|
2058
|
+
if (!firstPartyDeclarationPath(approximate))
|
|
2059
|
+
continue;
|
|
2060
|
+
if (deliveryDeclarationSpec(approximate)) {
|
|
2061
|
+
discovered.push({
|
|
2062
|
+
path: `<unsafe-delivery-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
2063
|
+
mode: "unsafe-path",
|
|
2064
|
+
oid: entry.oid,
|
|
2065
|
+
objectSize: entry.objectSize,
|
|
2066
|
+
bytes: null,
|
|
2067
|
+
contentHash: null,
|
|
2068
|
+
spec: null,
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
continue;
|
|
2072
|
+
}
|
|
2073
|
+
const path = entry.path;
|
|
2074
|
+
if (!firstPartyDeclarationPath(path))
|
|
2075
|
+
continue;
|
|
2076
|
+
const spec = deliveryDeclarationSpec(path);
|
|
2077
|
+
if (!spec)
|
|
2078
|
+
continue;
|
|
2079
|
+
if (!safeDeclarationPath(path)) {
|
|
2080
|
+
discovered.push({
|
|
2081
|
+
path: `<unsafe-delivery-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
2082
|
+
mode: "unsafe-path",
|
|
2083
|
+
oid: entry.oid,
|
|
2084
|
+
objectSize: entry.objectSize,
|
|
2085
|
+
bytes: null,
|
|
2086
|
+
contentHash: null,
|
|
2087
|
+
spec: null,
|
|
2088
|
+
});
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
discovered.push({
|
|
2092
|
+
path,
|
|
2093
|
+
mode: treeEntryMode(entry),
|
|
2094
|
+
oid: entry.oid,
|
|
2095
|
+
objectSize: entry.objectSize,
|
|
2096
|
+
bytes: null,
|
|
2097
|
+
contentHash: null,
|
|
2098
|
+
spec,
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
discovered.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
2102
|
+
const total = discovered.length;
|
|
2103
|
+
const blobs = hydrateDeclarationBlobs(root, discovered.slice(0, MAX_DELIVERY_DECLARATIONS), MAX_DELIVERY_DECLARATION_BYTES);
|
|
2104
|
+
return { blobs, total };
|
|
2105
|
+
}
|
|
2106
|
+
const KUBERNETES_WORKLOAD_KINDS = new Set(["Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "Pod"]);
|
|
2107
|
+
function stripYamlScalarComment(input) {
|
|
2108
|
+
let quote = null;
|
|
2109
|
+
let escaped = false;
|
|
2110
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
2111
|
+
const char = input[index];
|
|
2112
|
+
if (quote === "\"") {
|
|
2113
|
+
if (escaped)
|
|
2114
|
+
escaped = false;
|
|
2115
|
+
else if (char === "\\")
|
|
2116
|
+
escaped = true;
|
|
2117
|
+
else if (char === quote)
|
|
2118
|
+
quote = null;
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
if (quote === "'") {
|
|
2122
|
+
if (char === "'" && input[index + 1] === "'")
|
|
2123
|
+
index += 1;
|
|
2124
|
+
else if (char === "'")
|
|
2125
|
+
quote = null;
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
if (char === "\"" || char === "'")
|
|
2129
|
+
quote = char;
|
|
2130
|
+
else if (char === "#" && (index === 0 || /\s/.test(input[index - 1])))
|
|
2131
|
+
return input.slice(0, index);
|
|
2132
|
+
}
|
|
2133
|
+
return input;
|
|
2134
|
+
}
|
|
2135
|
+
function boundedYamlScalar(input) {
|
|
2136
|
+
const value = stripYamlScalarComment(input).trim();
|
|
2137
|
+
if (!value || value.length > 512)
|
|
2138
|
+
return null;
|
|
2139
|
+
let parsed;
|
|
2140
|
+
if (value.startsWith("\"")) {
|
|
2141
|
+
if (!value.endsWith("\""))
|
|
2142
|
+
return null;
|
|
2143
|
+
try {
|
|
2144
|
+
const decoded = JSON.parse(value);
|
|
2145
|
+
if (typeof decoded !== "string")
|
|
2146
|
+
return null;
|
|
2147
|
+
parsed = decoded;
|
|
2148
|
+
}
|
|
2149
|
+
catch {
|
|
2150
|
+
return null;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
else if (value.startsWith("'")) {
|
|
2154
|
+
if (!value.endsWith("'"))
|
|
2155
|
+
return null;
|
|
2156
|
+
const inner = value.slice(1, -1);
|
|
2157
|
+
let decoded = "";
|
|
2158
|
+
for (let index = 0; index < inner.length; index += 1) {
|
|
2159
|
+
const char = inner[index];
|
|
2160
|
+
if (char !== "'") {
|
|
2161
|
+
decoded += char;
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
if (inner[index + 1] !== "'")
|
|
2165
|
+
return null;
|
|
2166
|
+
decoded += "'";
|
|
2167
|
+
index += 1;
|
|
2168
|
+
}
|
|
2169
|
+
parsed = decoded;
|
|
2170
|
+
}
|
|
2171
|
+
else {
|
|
2172
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/@-]{0,255}$/.test(value))
|
|
2173
|
+
return null;
|
|
2174
|
+
parsed = value;
|
|
2175
|
+
}
|
|
2176
|
+
return parsed.length > 0 && parsed.length <= 256
|
|
2177
|
+
&& !/[\u0000-\u001f\u007f]/.test(parsed)
|
|
2178
|
+
&& isCredentialFreeText(parsed)
|
|
2179
|
+
? parsed
|
|
2180
|
+
: null;
|
|
2181
|
+
}
|
|
2182
|
+
function kubernetesYamlDocuments(source) {
|
|
2183
|
+
return source
|
|
2184
|
+
.split(/^(?:---|\.\.\.)[ \t]*(?:#.*)?\r?$/m)
|
|
2185
|
+
.map((document, index) => ({ index, source: document }))
|
|
2186
|
+
.filter((document) => document.source.split(/\r?\n/)
|
|
2187
|
+
.some((line) => !!line.trim() && !line.trimStart().startsWith("#")));
|
|
2188
|
+
}
|
|
2189
|
+
function kubernetesDocumentHeader(source) {
|
|
2190
|
+
const header = {
|
|
2191
|
+
apiVersion: undefined,
|
|
2192
|
+
kind: undefined,
|
|
2193
|
+
name: undefined,
|
|
2194
|
+
namespace: undefined,
|
|
2195
|
+
duplicate: false,
|
|
2196
|
+
};
|
|
2197
|
+
let metadataIndent = null;
|
|
2198
|
+
let metadataChildIndent = null;
|
|
2199
|
+
const assign = (field, raw) => {
|
|
2200
|
+
if (header[field] !== undefined) {
|
|
2201
|
+
header.duplicate = true;
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
header[field] = boundedYamlScalar(raw);
|
|
2205
|
+
};
|
|
2206
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
2207
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#"))
|
|
2208
|
+
continue;
|
|
2209
|
+
const indentation = rawLine.match(/^ */)[0].length;
|
|
2210
|
+
const mapping = rawLine.slice(indentation).match(/^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:(.*)$/);
|
|
2211
|
+
if (indentation === 0) {
|
|
2212
|
+
metadataIndent = null;
|
|
2213
|
+
metadataChildIndent = null;
|
|
2214
|
+
if (!mapping)
|
|
2215
|
+
continue;
|
|
2216
|
+
const [_, key, raw] = mapping;
|
|
2217
|
+
if (key === "apiVersion" || key === "kind")
|
|
2218
|
+
assign(key, raw);
|
|
2219
|
+
else if (key === "metadata" && !stripYamlScalarComment(raw).trim())
|
|
2220
|
+
metadataIndent = 0;
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
if (metadataIndent === null || indentation <= metadataIndent || !mapping)
|
|
2224
|
+
continue;
|
|
2225
|
+
if (metadataChildIndent === null)
|
|
2226
|
+
metadataChildIndent = indentation;
|
|
2227
|
+
if (indentation !== metadataChildIndent)
|
|
2228
|
+
continue;
|
|
2229
|
+
const [_, key, raw] = mapping;
|
|
2230
|
+
if (key === "name" || key === "namespace")
|
|
2231
|
+
assign(key, raw);
|
|
2232
|
+
}
|
|
2233
|
+
return header;
|
|
2234
|
+
}
|
|
2235
|
+
function validKubernetesApiVersion(value) {
|
|
2236
|
+
return value.length <= 128 && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\/v[0-9][a-z0-9]*$|^v[0-9][a-z0-9]*$/i.test(value);
|
|
2237
|
+
}
|
|
2238
|
+
function validKubernetesName(value) {
|
|
2239
|
+
return value.length <= 253 && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value);
|
|
2240
|
+
}
|
|
2241
|
+
function validKubernetesNamespace(value) {
|
|
2242
|
+
return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
|
2243
|
+
}
|
|
2244
|
+
function kubernetesDeclarations(blob, source, issues) {
|
|
2245
|
+
const candidates = [];
|
|
2246
|
+
for (const document of kubernetesYamlDocuments(source)) {
|
|
2247
|
+
const header = kubernetesDocumentHeader(document.source);
|
|
2248
|
+
if (header.duplicate) {
|
|
2249
|
+
issues.push({
|
|
2250
|
+
code: "delivery_declaration_invalid",
|
|
2251
|
+
sourcePath: blob.path,
|
|
2252
|
+
sourceField: `documents[${document.index}]`,
|
|
2253
|
+
detail: `${blob.path} document ${document.index} repeats a Kubernetes identity field`,
|
|
2254
|
+
});
|
|
2255
|
+
continue;
|
|
2256
|
+
}
|
|
2257
|
+
if (typeof header.kind !== "string" || !KUBERNETES_WORKLOAD_KINDS.has(header.kind))
|
|
2258
|
+
continue;
|
|
2259
|
+
const namespace = header.namespace === undefined ? "default" : header.namespace;
|
|
2260
|
+
if (typeof header.apiVersion !== "string" || !validKubernetesApiVersion(header.apiVersion)
|
|
2261
|
+
|| typeof header.name !== "string" || !validKubernetesName(header.name)
|
|
2262
|
+
|| typeof namespace !== "string" || !validKubernetesNamespace(namespace)) {
|
|
2263
|
+
issues.push({
|
|
2264
|
+
code: "delivery_declaration_invalid",
|
|
2265
|
+
sourcePath: blob.path,
|
|
2266
|
+
sourceField: `documents[${document.index}].metadata.name`,
|
|
2267
|
+
detail: `${blob.path} document ${document.index} has an incomplete or unsafe Kubernetes workload identity`,
|
|
2268
|
+
});
|
|
2269
|
+
continue;
|
|
2270
|
+
}
|
|
2271
|
+
const identity = `${header.apiVersion.toLowerCase()}/${header.kind.toLowerCase()}/${namespace}/${header.name}`;
|
|
1120
2272
|
candidates.push({
|
|
1121
2273
|
path: blob.path,
|
|
1122
2274
|
contentHash: blob.contentHash,
|
|
@@ -1171,6 +2323,8 @@ function validDeliveryDeclaration(path, spec, source) {
|
|
|
1171
2323
|
const parsed = parseSource(path, source);
|
|
1172
2324
|
if (!parsed?.parseable)
|
|
1173
2325
|
return false;
|
|
2326
|
+
if (spec.provider === "helm")
|
|
2327
|
+
return helmChartApiVersion(source) !== null;
|
|
1174
2328
|
if (spec.provider === "github_actions" || spec.provider === "circleci")
|
|
1175
2329
|
return /^jobs\s*:/m.test(source);
|
|
1176
2330
|
if (spec.provider === "buildkite")
|
|
@@ -1179,8 +2333,35 @@ function validDeliveryDeclaration(path, spec, source) {
|
|
|
1179
2333
|
return /^services\s*:/m.test(source);
|
|
1180
2334
|
return source.trim().length > 0;
|
|
1181
2335
|
}
|
|
1182
|
-
function
|
|
1183
|
-
const
|
|
2336
|
+
function helmChartApiVersion(source) {
|
|
2337
|
+
const values = new Map();
|
|
2338
|
+
let duplicate = false;
|
|
2339
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
2340
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#"))
|
|
2341
|
+
continue;
|
|
2342
|
+
if (rawLine.match(/^ */)[0].length !== 0)
|
|
2343
|
+
continue;
|
|
2344
|
+
const mapping = rawLine.match(/^(apiVersion|name|version)[ \t]*:(.*)$/);
|
|
2345
|
+
if (!mapping)
|
|
2346
|
+
continue;
|
|
2347
|
+
const field = mapping[1];
|
|
2348
|
+
if (values.has(field))
|
|
2349
|
+
duplicate = true;
|
|
2350
|
+
values.set(field, boundedYamlScalar(mapping[2]));
|
|
2351
|
+
}
|
|
2352
|
+
const apiVersion = values.get("apiVersion");
|
|
2353
|
+
const name = values.get("name");
|
|
2354
|
+
const version = values.get("version");
|
|
2355
|
+
if (duplicate || (apiVersion !== "v1" && apiVersion !== "v2"))
|
|
2356
|
+
return null;
|
|
2357
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name))
|
|
2358
|
+
return null;
|
|
2359
|
+
if (typeof version !== "string" || !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version))
|
|
2360
|
+
return null;
|
|
2361
|
+
return apiVersion;
|
|
2362
|
+
}
|
|
2363
|
+
function deliveryDeclarations(root, revision, tree, issues) {
|
|
2364
|
+
const discovered = deliveryDeclarationBlobs(root, tree);
|
|
1184
2365
|
let limitReported = false;
|
|
1185
2366
|
const reportLimit = (sourcePath) => {
|
|
1186
2367
|
if (limitReported)
|
|
@@ -1264,6 +2445,7 @@ function deliveryDeclarations(root, revision, issues) {
|
|
|
1264
2445
|
path: blob.path,
|
|
1265
2446
|
contentHash: blob.contentHash,
|
|
1266
2447
|
spec: blob.spec,
|
|
2448
|
+
contractVersion: blob.spec.provider === "helm" ? helmChartApiVersion(source) : undefined,
|
|
1267
2449
|
metadata: blob.spec.provider === "systemd" ? { unit_name: posix.basename(blob.path) } : undefined,
|
|
1268
2450
|
});
|
|
1269
2451
|
}
|
|
@@ -1285,7 +2467,9 @@ function deliveryResourceName(declaration) {
|
|
|
1285
2467
|
const label = declaration.spec.resourceKind === "pipeline"
|
|
1286
2468
|
? `${declaration.spec.provider.replaceAll("_", " ")} pipeline: ${base}`
|
|
1287
2469
|
: declaration.spec.resourceKind === "artifact"
|
|
1288
|
-
?
|
|
2470
|
+
? declaration.spec.provider === "helm"
|
|
2471
|
+
? `Helm chart: ${declaration.path}`
|
|
2472
|
+
: `container image declared by ${base}`
|
|
1289
2473
|
: declaration.spec.provider === "systemd"
|
|
1290
2474
|
? `systemd service: ${base}`
|
|
1291
2475
|
: `Docker Compose deployment: ${base}`;
|
|
@@ -1308,29 +2492,348 @@ function repositoryKey(identity) {
|
|
|
1308
2492
|
if (identity.startsWith("file:")) {
|
|
1309
2493
|
return { key: `local/sha256/${createHash("sha256").update(identity).digest("hex")}`, locator: null };
|
|
1310
2494
|
}
|
|
1311
|
-
if (identity.startsWith("net:any://")) {
|
|
1312
|
-
return safe(identity.slice("net:any://".length), null);
|
|
2495
|
+
if (identity.startsWith("net:any://")) {
|
|
2496
|
+
return safe(identity.slice("net:any://".length), null);
|
|
2497
|
+
}
|
|
2498
|
+
return safe(identity, null);
|
|
2499
|
+
}
|
|
2500
|
+
function gitmodulesBlob(root, tree) {
|
|
2501
|
+
const entry = tree.find((candidate) => candidate.path === ".gitmodules");
|
|
2502
|
+
if (!entry)
|
|
2503
|
+
return null;
|
|
2504
|
+
const blob = {
|
|
2505
|
+
path: ".gitmodules",
|
|
2506
|
+
mode: treeEntryMode(entry),
|
|
2507
|
+
oid: entry.oid,
|
|
2508
|
+
objectSize: entry.objectSize,
|
|
2509
|
+
bytes: null,
|
|
2510
|
+
contentHash: null,
|
|
2511
|
+
};
|
|
2512
|
+
if (!ORDINARY_BLOB_MODES.has(blob.mode))
|
|
2513
|
+
return blob;
|
|
2514
|
+
return hydrateDeclarationBlobs(root, [blob], MAX_SUBMODULE_DECLARATION_BYTES)[0];
|
|
2515
|
+
}
|
|
2516
|
+
function submoduleConfigGroups(root, blob) {
|
|
2517
|
+
let raw;
|
|
2518
|
+
try {
|
|
2519
|
+
raw = gitBuffer(root, [
|
|
2520
|
+
"config", `--blob=${blob.oid}`, "--null", "--get-regexp", "^submodule\\..*\\.(path|url)$",
|
|
2521
|
+
], MAX_SUBMODULE_DECLARATION_BYTES * 2);
|
|
2522
|
+
}
|
|
2523
|
+
catch {
|
|
2524
|
+
return null;
|
|
2525
|
+
}
|
|
2526
|
+
const groups = new Map();
|
|
2527
|
+
for (const record of nulRecords(raw)) {
|
|
2528
|
+
const newline = record.indexOf(0x0a);
|
|
2529
|
+
if (newline <= 0)
|
|
2530
|
+
return null;
|
|
2531
|
+
let key;
|
|
2532
|
+
let value;
|
|
2533
|
+
try {
|
|
2534
|
+
key = UTF8_DECODER.decode(record.subarray(0, newline));
|
|
2535
|
+
value = UTF8_DECODER.decode(record.subarray(newline + 1));
|
|
2536
|
+
}
|
|
2537
|
+
catch {
|
|
2538
|
+
return null;
|
|
2539
|
+
}
|
|
2540
|
+
const match = key.match(/^submodule\.(.+)\.(path|url)$/i);
|
|
2541
|
+
if (!match || match[1].length > 1024 || /[\u0000-\u001f\u007f]/.test(match[1]))
|
|
2542
|
+
return null;
|
|
2543
|
+
const group = groups.get(match[1]) ?? { key: match[1], paths: [], urls: [] };
|
|
2544
|
+
if (match[2].toLowerCase() === "path")
|
|
2545
|
+
group.paths.push(value);
|
|
2546
|
+
else
|
|
2547
|
+
group.urls.push(value);
|
|
2548
|
+
groups.set(group.key, group);
|
|
2549
|
+
}
|
|
2550
|
+
return [...groups.values()].sort((left, right) => {
|
|
2551
|
+
const safeKey = (group) => {
|
|
2552
|
+
const path = group.paths.length === 1 && safeDeclarationPath(group.paths[0]) ? group.paths[0] : null;
|
|
2553
|
+
return path ?? `~${sha256Bytes(group.key)}`;
|
|
2554
|
+
};
|
|
2555
|
+
return compareCodeUnits(safeKey(left), safeKey(right));
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
function exactGitlinks(tree, paths) {
|
|
2559
|
+
const gitlinks = new Map();
|
|
2560
|
+
for (const entry of tree) {
|
|
2561
|
+
if (entry.mode !== "160000" || entry.kind !== "commit" || entry.path === null)
|
|
2562
|
+
continue;
|
|
2563
|
+
if (paths.has(entry.path))
|
|
2564
|
+
gitlinks.set(entry.path, entry.oid);
|
|
2565
|
+
}
|
|
2566
|
+
return gitlinks;
|
|
2567
|
+
}
|
|
2568
|
+
function canonicalSubmoduleRepository(url, root) {
|
|
2569
|
+
const value = url.trim();
|
|
2570
|
+
if (!value || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value))
|
|
2571
|
+
return null;
|
|
2572
|
+
if (/^(?:data|file|ftp|javascript|mailto):/i.test(value))
|
|
2573
|
+
return null;
|
|
2574
|
+
const scp = !value.includes("://") && /^(?:[^@/]+@)?[^:/]+:.+$/.test(value);
|
|
2575
|
+
if (!scp) {
|
|
2576
|
+
try {
|
|
2577
|
+
const parsed = new URL(value);
|
|
2578
|
+
if (!new Set(["http:", "https:", "ssh:", "git:", "git+ssh:", "ssh+git:"]).has(parsed.protocol)
|
|
2579
|
+
|| !parsed.hostname || !parsed.pathname.replace(/^\/+/, ""))
|
|
2580
|
+
return null;
|
|
2581
|
+
}
|
|
2582
|
+
catch {
|
|
2583
|
+
return null;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
const identity = canonicalRemoteRepositoryIdentity(value, root);
|
|
2587
|
+
if (!identity.startsWith("provider:") && !identity.startsWith("net:any://"))
|
|
2588
|
+
return null;
|
|
2589
|
+
const normalized = repositoryKey(identity);
|
|
2590
|
+
if (normalized.key.startsWith("opaque/"))
|
|
2591
|
+
return null;
|
|
2592
|
+
return { identity, ...normalized };
|
|
2593
|
+
}
|
|
2594
|
+
function submoduleDeclarations(root, revision, tree, rootRepositoryIdentity, issues) {
|
|
2595
|
+
const blob = gitmodulesBlob(root, tree);
|
|
2596
|
+
if (!blob)
|
|
2597
|
+
return [];
|
|
2598
|
+
if (!blob.bytes) {
|
|
2599
|
+
issues.push({
|
|
2600
|
+
code: blob.contentHash === "oversized" ? "submodule_declaration_oversized" : "submodule_declaration_mode",
|
|
2601
|
+
sourcePath: blob.path,
|
|
2602
|
+
sourceField: "submodule",
|
|
2603
|
+
detail: blob.contentHash === "oversized"
|
|
2604
|
+
? `${blob.path} exceeds the ${MAX_SUBMODULE_DECLARATION_BYTES}-byte submodule declaration limit`
|
|
2605
|
+
: `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
2606
|
+
});
|
|
2607
|
+
return [];
|
|
2608
|
+
}
|
|
2609
|
+
const groups = submoduleConfigGroups(root, blob);
|
|
2610
|
+
if (!groups) {
|
|
2611
|
+
issues.push({
|
|
2612
|
+
code: "submodule_declaration_invalid",
|
|
2613
|
+
sourcePath: blob.path,
|
|
2614
|
+
sourceField: "submodule",
|
|
2615
|
+
detail: `${blob.path} is not valid bounded Git submodule configuration`,
|
|
2616
|
+
});
|
|
2617
|
+
return [];
|
|
2618
|
+
}
|
|
2619
|
+
if (groups.length > MAX_SUBMODULE_DECLARATIONS) {
|
|
2620
|
+
issues.push({
|
|
2621
|
+
code: "submodule_declaration_limit",
|
|
2622
|
+
sourcePath: blob.path,
|
|
2623
|
+
sourceField: "submodule",
|
|
2624
|
+
detail: `bounded discovery accepts at most ${MAX_SUBMODULE_DECLARATIONS} Git submodule declarations`,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
const selectedGroups = groups.slice(0, MAX_SUBMODULE_DECLARATIONS);
|
|
2628
|
+
const safePaths = new Set(selectedGroups
|
|
2629
|
+
.flatMap((group) => group.paths.length === 1 && safeDeclarationPath(group.paths[0]) ? [group.paths[0]] : []));
|
|
2630
|
+
const gitlinks = exactGitlinks(tree, safePaths);
|
|
2631
|
+
const byPath = new Map();
|
|
2632
|
+
for (const group of selectedGroups) {
|
|
2633
|
+
if (group.paths.length !== 1 || group.urls.length !== 1 || !safeDeclarationPath(group.paths[0])) {
|
|
2634
|
+
issues.push({
|
|
2635
|
+
code: "submodule_declaration_invalid",
|
|
2636
|
+
sourcePath: blob.path,
|
|
2637
|
+
sourceField: "submodule",
|
|
2638
|
+
detail: `${blob.path} contains an incomplete, duplicate or unsafe submodule declaration`,
|
|
2639
|
+
});
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2642
|
+
const path = group.paths[0];
|
|
2643
|
+
const url = group.urls[0];
|
|
2644
|
+
const gitlinkRevision = gitlinks.get(path);
|
|
2645
|
+
const repository = canonicalSubmoduleRepository(url, root);
|
|
2646
|
+
if (!gitlinkRevision || !repository || repository.identity === rootRepositoryIdentity) {
|
|
2647
|
+
issues.push({
|
|
2648
|
+
code: "submodule_declaration_invalid",
|
|
2649
|
+
sourcePath: blob.path,
|
|
2650
|
+
sourceField: `submodule[${path}]`,
|
|
2651
|
+
detail: `${blob.path} submodule ${path} lacks a distinct credential-free network repository and matching committed gitlink`,
|
|
2652
|
+
});
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
const configEvidence = {
|
|
2656
|
+
kind: "submodule_declaration",
|
|
2657
|
+
sourcePath: blob.path,
|
|
2658
|
+
sourceField: `submodule[${path}].url`,
|
|
2659
|
+
sourceRevision: revision,
|
|
2660
|
+
sourceContentHash: blob.contentHash,
|
|
2661
|
+
};
|
|
2662
|
+
const gitlinkEvidence = {
|
|
2663
|
+
kind: "submodule_declaration",
|
|
2664
|
+
sourcePath: path,
|
|
2665
|
+
sourceField: "gitlink",
|
|
2666
|
+
sourceRevision: revision,
|
|
2667
|
+
sourceContentHash: sha256Bytes(`gitlink:${gitlinkRevision}`),
|
|
2668
|
+
};
|
|
2669
|
+
const declaration = {
|
|
2670
|
+
path,
|
|
2671
|
+
gitlinkRevision,
|
|
2672
|
+
repository: { ...repository, evidence: configEvidence },
|
|
2673
|
+
evidence: [configEvidence, gitlinkEvidence],
|
|
2674
|
+
};
|
|
2675
|
+
const pathGroup = byPath.get(path) ?? [];
|
|
2676
|
+
pathGroup.push(declaration);
|
|
2677
|
+
byPath.set(path, pathGroup);
|
|
2678
|
+
}
|
|
2679
|
+
const declarations = [];
|
|
2680
|
+
for (const path of [...byPath.keys()].sort(compareCodeUnits)) {
|
|
2681
|
+
const pathGroup = byPath.get(path);
|
|
2682
|
+
if (pathGroup.length !== 1) {
|
|
2683
|
+
issues.push({
|
|
2684
|
+
code: "submodule_declaration_invalid",
|
|
2685
|
+
sourcePath: blob.path,
|
|
2686
|
+
sourceField: `submodule[${path}]`,
|
|
2687
|
+
detail: `${blob.path} repeats submodule path ${path}; identity remains unresolved`,
|
|
2688
|
+
});
|
|
2689
|
+
continue;
|
|
2690
|
+
}
|
|
2691
|
+
declarations.push(pathGroup[0]);
|
|
2692
|
+
}
|
|
2693
|
+
return declarations;
|
|
2694
|
+
}
|
|
2695
|
+
function packageRepositoryValue(value) {
|
|
2696
|
+
if (typeof value === "string")
|
|
2697
|
+
return value;
|
|
2698
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2699
|
+
return null;
|
|
2700
|
+
const url = value.url;
|
|
2701
|
+
return typeof url === "string" ? url : null;
|
|
2702
|
+
}
|
|
2703
|
+
function validPackageName(value) {
|
|
2704
|
+
if (typeof value !== "string")
|
|
2705
|
+
return null;
|
|
2706
|
+
const name = value.trim();
|
|
2707
|
+
return name
|
|
2708
|
+
&& name.length <= 214
|
|
2709
|
+
&& !/[\u0000-\u001f\u007f\s]/.test(name)
|
|
2710
|
+
&& /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(name)
|
|
2711
|
+
? name
|
|
2712
|
+
: null;
|
|
2713
|
+
}
|
|
2714
|
+
function workspacePackageDeclarations(manifests, issues) {
|
|
2715
|
+
const byName = new Map();
|
|
2716
|
+
for (const manifest of manifests) {
|
|
2717
|
+
const rawName = typeof manifest.value.name === "string" ? manifest.value.name.trim() : "";
|
|
2718
|
+
if (!rawName) {
|
|
2719
|
+
issues.push({
|
|
2720
|
+
code: "package_name_missing",
|
|
2721
|
+
sourcePath: manifest.path,
|
|
2722
|
+
sourceField: "name",
|
|
2723
|
+
detail: `${manifest.path} has no package name`,
|
|
2724
|
+
});
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
const name = validPackageName(rawName);
|
|
2728
|
+
if (!name) {
|
|
2729
|
+
issues.push({
|
|
2730
|
+
code: "package_name_invalid",
|
|
2731
|
+
sourcePath: manifest.path,
|
|
2732
|
+
sourceField: "name",
|
|
2733
|
+
detail: `${manifest.path} has an invalid package name`,
|
|
2734
|
+
});
|
|
2735
|
+
continue;
|
|
2736
|
+
}
|
|
2737
|
+
const group = byName.get(name) ?? [];
|
|
2738
|
+
group.push({ manifest, name });
|
|
2739
|
+
byName.set(name, group);
|
|
2740
|
+
}
|
|
2741
|
+
const declarations = [];
|
|
2742
|
+
for (const name of [...byName.keys()].sort(compareCodeUnits)) {
|
|
2743
|
+
const group = byName.get(name).sort((left, right) => compareCodeUnits(left.manifest.path, right.manifest.path));
|
|
2744
|
+
if (group.length > 1) {
|
|
2745
|
+
issues.push({
|
|
2746
|
+
code: "package_identity_conflict",
|
|
2747
|
+
sourcePath: group[0].manifest.path,
|
|
2748
|
+
sourceField: "name",
|
|
2749
|
+
detail: `package ${name} is declared by ${group.length} workspace manifests; identity remains unresolved`,
|
|
2750
|
+
});
|
|
2751
|
+
continue;
|
|
2752
|
+
}
|
|
2753
|
+
declarations.push(group[0]);
|
|
2754
|
+
}
|
|
2755
|
+
return declarations;
|
|
2756
|
+
}
|
|
2757
|
+
const WORKSPACE_DEPENDENCY_FIELDS = [
|
|
2758
|
+
"dependencies",
|
|
2759
|
+
"devDependencies",
|
|
2760
|
+
"peerDependencies",
|
|
2761
|
+
"optionalDependencies",
|
|
2762
|
+
];
|
|
2763
|
+
function workspaceDependencyDeclarations(packages, revision, issues) {
|
|
2764
|
+
const byName = new Map(packages.map((declaration) => [declaration.name, declaration]));
|
|
2765
|
+
const byRelationship = new Map();
|
|
2766
|
+
for (const from of packages) {
|
|
2767
|
+
for (const field of WORKSPACE_DEPENDENCY_FIELDS) {
|
|
2768
|
+
const raw = from.manifest.value[field];
|
|
2769
|
+
if (raw === undefined)
|
|
2770
|
+
continue;
|
|
2771
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2772
|
+
issues.push({
|
|
2773
|
+
code: "package_dependency_invalid",
|
|
2774
|
+
sourcePath: from.manifest.path,
|
|
2775
|
+
sourceField: field,
|
|
2776
|
+
detail: `${from.manifest.path} must declare ${field} as an object`,
|
|
2777
|
+
});
|
|
2778
|
+
continue;
|
|
2779
|
+
}
|
|
2780
|
+
for (const [rawTarget, specifier] of Object.entries(raw).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
2781
|
+
const targetName = validPackageName(rawTarget);
|
|
2782
|
+
const to = targetName ? byName.get(targetName) : undefined;
|
|
2783
|
+
if (!to)
|
|
2784
|
+
continue;
|
|
2785
|
+
if (typeof specifier !== "string" || !specifier.trim() || from.name === to.name) {
|
|
2786
|
+
issues.push({
|
|
2787
|
+
code: "package_dependency_invalid",
|
|
2788
|
+
sourcePath: from.manifest.path,
|
|
2789
|
+
sourceField: `${field}.${targetName}`,
|
|
2790
|
+
detail: from.name === to.name
|
|
2791
|
+
? `${from.manifest.path} declares a self-dependency on its own workspace package identity`
|
|
2792
|
+
: `${from.manifest.path} declares a non-string workspace dependency specifier`,
|
|
2793
|
+
});
|
|
2794
|
+
continue;
|
|
2795
|
+
}
|
|
2796
|
+
const relationshipKey = `${from.name}\u0000${to.name}`;
|
|
2797
|
+
const evidence = {
|
|
2798
|
+
kind: "package_manifest",
|
|
2799
|
+
sourcePath: from.manifest.path,
|
|
2800
|
+
sourceField: `${field}.${to.name}`,
|
|
2801
|
+
sourceRevision: revision,
|
|
2802
|
+
sourceContentHash: from.manifest.contentHash,
|
|
2803
|
+
};
|
|
2804
|
+
const existing = byRelationship.get(relationshipKey);
|
|
2805
|
+
if (existing) {
|
|
2806
|
+
existing.evidence.push(evidence);
|
|
2807
|
+
}
|
|
2808
|
+
else {
|
|
2809
|
+
byRelationship.set(relationshipKey, {
|
|
2810
|
+
from,
|
|
2811
|
+
to,
|
|
2812
|
+
evidence: [
|
|
2813
|
+
evidence,
|
|
2814
|
+
{
|
|
2815
|
+
kind: "package_manifest",
|
|
2816
|
+
sourcePath: to.manifest.path,
|
|
2817
|
+
sourceField: "name",
|
|
2818
|
+
sourceRevision: revision,
|
|
2819
|
+
sourceContentHash: to.manifest.contentHash,
|
|
2820
|
+
},
|
|
2821
|
+
],
|
|
2822
|
+
});
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
const declarations = [...byRelationship.values()].sort((left, right) => compareCodeUnits(`${left.from.name}:${left.to.name}`, `${right.from.name}:${right.to.name}`));
|
|
2828
|
+
if (declarations.length > MAX_WORKSPACE_DEPENDENCIES) {
|
|
2829
|
+
issues.push({
|
|
2830
|
+
code: "package_dependency_limit",
|
|
2831
|
+
sourcePath: "package.json",
|
|
2832
|
+
sourceField: "workspaces",
|
|
2833
|
+
detail: `bounded discovery accepts at most ${MAX_WORKSPACE_DEPENDENCIES} internal workspace dependency relationships`,
|
|
2834
|
+
});
|
|
1313
2835
|
}
|
|
1314
|
-
return
|
|
1315
|
-
}
|
|
1316
|
-
function packageRepositoryValue(value) {
|
|
1317
|
-
if (typeof value === "string")
|
|
1318
|
-
return value;
|
|
1319
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1320
|
-
return null;
|
|
1321
|
-
const url = value.url;
|
|
1322
|
-
return typeof url === "string" ? url : null;
|
|
1323
|
-
}
|
|
1324
|
-
function validPackageName(value) {
|
|
1325
|
-
if (typeof value !== "string")
|
|
1326
|
-
return null;
|
|
1327
|
-
const name = value.trim();
|
|
1328
|
-
return name
|
|
1329
|
-
&& name.length <= 214
|
|
1330
|
-
&& !/[\u0000-\u001f\u007f\s]/.test(name)
|
|
1331
|
-
&& /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(name)
|
|
1332
|
-
? name
|
|
1333
|
-
: null;
|
|
2836
|
+
return declarations.slice(0, MAX_WORKSPACE_DEPENDENCIES);
|
|
1334
2837
|
}
|
|
1335
2838
|
function configuredRemotes(root, revision) {
|
|
1336
2839
|
let output = "";
|
|
@@ -1427,12 +2930,10 @@ function rootHistoryIdentity(root, revision) {
|
|
|
1427
2930
|
* never writes Hunch graph state: candidate authority remains explicit until a
|
|
1428
2931
|
* normal review/capture path accepts the records. */
|
|
1429
2932
|
export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
const revision = exactRevision(root, ref);
|
|
1433
|
-
const timestamp = revisionTime(root, revision);
|
|
2933
|
+
const { revision, timestamp } = exactCommitSnapshot(root, ref);
|
|
2934
|
+
const tree = exactTreeSnapshot(root, revision);
|
|
1434
2935
|
const issues = [];
|
|
1435
|
-
const blobs = manifestBlobs(
|
|
2936
|
+
const blobs = manifestBlobs(tree);
|
|
1436
2937
|
if (blobs.length > MAX_MANIFESTS) {
|
|
1437
2938
|
issues.push({
|
|
1438
2939
|
code: "manifest_limit",
|
|
@@ -1448,9 +2949,16 @@ export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
|
1448
2949
|
}
|
|
1449
2950
|
const patterns = workspacePatterns(rootManifest?.value.workspaces, issues);
|
|
1450
2951
|
const manifests = parsed.filter((manifest) => isWorkspaceManifest(manifest.path, patterns));
|
|
2952
|
+
const discoveredPackages = workspacePackageDeclarations(manifests, issues);
|
|
2953
|
+
const discoveredWorkspaceDependencies = workspaceDependencyDeclarations(discoveredPackages, revision, issues);
|
|
1451
2954
|
const declarations = repositoryDeclarations(root, revision, rootManifest);
|
|
1452
|
-
const discoveredMcp = mcpDeclarations(root, revision, issues);
|
|
1453
|
-
const discoveredDelivery = deliveryDeclarations(root, revision, issues);
|
|
2955
|
+
const discoveredMcp = mcpDeclarations(root, revision, tree, issues);
|
|
2956
|
+
const discoveredDelivery = deliveryDeclarations(root, revision, tree, issues);
|
|
2957
|
+
const discoveredApi = apiDeclarations(root, revision, tree, issues);
|
|
2958
|
+
const discoveredMigrations = migrationDeclarations(root, revision, tree, issues);
|
|
2959
|
+
const discoveredOperations = operationsDeclarations(root, revision, tree, issues);
|
|
2960
|
+
const discoveredDashboards = dashboardDeclarations(root, tree, issues);
|
|
2961
|
+
const discoveredSlos = sloDeclarations(root, tree, issues);
|
|
1454
2962
|
const identities = [...new Set(declarations.map((declaration) => declaration.identity))].sort(compareCodeUnits);
|
|
1455
2963
|
let selected = null;
|
|
1456
2964
|
if (identities.length > 1) {
|
|
@@ -1467,6 +2975,10 @@ export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
|
1467
2975
|
else {
|
|
1468
2976
|
selected = rootHistoryIdentity(root, revision);
|
|
1469
2977
|
}
|
|
2978
|
+
const discoveredSubmodules = submoduleDeclarations(root, revision, tree, selected?.identity ?? null, issues);
|
|
2979
|
+
const discoveredOwnership = selected?.key.startsWith("github.com/")
|
|
2980
|
+
? ownershipDeclaration(root, revision, tree, issues)
|
|
2981
|
+
: null;
|
|
1470
2982
|
const resources = [];
|
|
1471
2983
|
const relationships = [];
|
|
1472
2984
|
let repositoryRecord = null;
|
|
@@ -1495,6 +3007,274 @@ export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
|
1495
3007
|
});
|
|
1496
3008
|
resources.push(candidate(repositoryRecord, repositoryEvidence));
|
|
1497
3009
|
}
|
|
3010
|
+
if (repositoryRecord) {
|
|
3011
|
+
const submodulesByRepository = new Map();
|
|
3012
|
+
for (const declaration of discoveredSubmodules) {
|
|
3013
|
+
const group = submodulesByRepository.get(declaration.repository.key) ?? [];
|
|
3014
|
+
group.push(declaration);
|
|
3015
|
+
submodulesByRepository.set(declaration.repository.key, group);
|
|
3016
|
+
}
|
|
3017
|
+
for (const key of [...submodulesByRepository.keys()].sort(compareCodeUnits)) {
|
|
3018
|
+
const group = submodulesByRepository.get(key)
|
|
3019
|
+
.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
3020
|
+
const first = group[0];
|
|
3021
|
+
const evidence = group.flatMap((declaration) => declaration.evidence);
|
|
3022
|
+
const declarationPaths = group.map((declaration) => declaration.path);
|
|
3023
|
+
const gitlinkRevisions = [...new Set(group.map((declaration) => declaration.gitlinkRevision))].sort(compareCodeUnits);
|
|
3024
|
+
const submoduleRecord = ResourceSchema.parse({
|
|
3025
|
+
schema: "hunch.resource/1",
|
|
3026
|
+
id: resourceId("repository", key),
|
|
3027
|
+
kind: "repository",
|
|
3028
|
+
name: key.slice(0, 256),
|
|
3029
|
+
scope: [repositoryRecord.id],
|
|
3030
|
+
locator: first.repository.locator,
|
|
3031
|
+
lifecycle: "active",
|
|
3032
|
+
provenance: {
|
|
3033
|
+
source: "extracted:git-submodule",
|
|
3034
|
+
confidence: 0.9,
|
|
3035
|
+
evidence: evidence.map(provenanceEvidence),
|
|
3036
|
+
},
|
|
3037
|
+
currentness: resourceCurrentness(revision, evidence.map((item) => item.sourceContentHash)),
|
|
3038
|
+
metadata: {
|
|
3039
|
+
discovery_authority: "candidate",
|
|
3040
|
+
declaration_paths: declarationPaths,
|
|
3041
|
+
gitlink_revisions: gitlinkRevisions,
|
|
3042
|
+
},
|
|
3043
|
+
created_at: timestamp,
|
|
3044
|
+
updated_at: timestamp,
|
|
3045
|
+
});
|
|
3046
|
+
resources.push(candidate(submoduleRecord, evidence));
|
|
3047
|
+
const relationship = EdgeSchema.parse({
|
|
3048
|
+
schema: "hunch.resource-relationship/1",
|
|
3049
|
+
id: resourceRelationshipId(repositoryRecord.id, submoduleRecord.id, "depends_on"),
|
|
3050
|
+
from: repositoryRecord.id,
|
|
3051
|
+
to: submoduleRecord.id,
|
|
3052
|
+
type: "depends_on",
|
|
3053
|
+
reason: `committed Git submodule declarations reference repository ${key}`,
|
|
3054
|
+
strength: 0.9,
|
|
3055
|
+
provenance: {
|
|
3056
|
+
source: "extracted:git-submodule",
|
|
3057
|
+
confidence: 0.9,
|
|
3058
|
+
evidence: evidence.map(provenanceEvidence),
|
|
3059
|
+
},
|
|
3060
|
+
currentness: resourceCurrentness(revision, evidence.map((item) => item.sourceContentHash)),
|
|
3061
|
+
environment: null,
|
|
3062
|
+
metadata: { discovery_authority: "candidate", declaration_paths: declarationPaths },
|
|
3063
|
+
});
|
|
3064
|
+
relationships.push(candidate(relationship, evidence));
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
if (repositoryRecord && discoveredOwnership) {
|
|
3068
|
+
for (const team of discoveredOwnership.teams) {
|
|
3069
|
+
const evidence = {
|
|
3070
|
+
kind: "ownership_declaration",
|
|
3071
|
+
sourcePath: discoveredOwnership.path,
|
|
3072
|
+
sourceField: "default-owner",
|
|
3073
|
+
sourceRevision: revision,
|
|
3074
|
+
sourceContentHash: discoveredOwnership.contentHash,
|
|
3075
|
+
};
|
|
3076
|
+
const teamRecord = ResourceSchema.parse({
|
|
3077
|
+
schema: "hunch.resource/1",
|
|
3078
|
+
id: resourceId("team_ref", `github.com/${team.organization}/${team.team}`),
|
|
3079
|
+
kind: "team_ref",
|
|
3080
|
+
name: team.handle,
|
|
3081
|
+
scope: [],
|
|
3082
|
+
locator: `https://github.com/orgs/${team.organization}/teams/${team.team}`,
|
|
3083
|
+
lifecycle: "active",
|
|
3084
|
+
provenance: {
|
|
3085
|
+
source: "extracted:codeowners-default-team",
|
|
3086
|
+
confidence: 0.8,
|
|
3087
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3088
|
+
},
|
|
3089
|
+
currentness: resourceCurrentness(revision, [discoveredOwnership.contentHash]),
|
|
3090
|
+
metadata: {
|
|
3091
|
+
discovery_authority: "candidate",
|
|
3092
|
+
provider: "github",
|
|
3093
|
+
declaration_path: discoveredOwnership.path,
|
|
3094
|
+
},
|
|
3095
|
+
created_at: timestamp,
|
|
3096
|
+
updated_at: timestamp,
|
|
3097
|
+
});
|
|
3098
|
+
resources.push(candidate(teamRecord, [evidence]));
|
|
3099
|
+
const relationship = EdgeSchema.parse({
|
|
3100
|
+
schema: "hunch.resource-relationship/1",
|
|
3101
|
+
id: resourceRelationshipId(repositoryRecord.id, teamRecord.id, "owned_by"),
|
|
3102
|
+
from: repositoryRecord.id,
|
|
3103
|
+
to: teamRecord.id,
|
|
3104
|
+
type: "owned_by",
|
|
3105
|
+
reason: `${discoveredOwnership.path} declares ${team.handle} as a repository-wide owner`,
|
|
3106
|
+
strength: 0.8,
|
|
3107
|
+
provenance: {
|
|
3108
|
+
source: "extracted:codeowners-default-team",
|
|
3109
|
+
confidence: 0.8,
|
|
3110
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3111
|
+
},
|
|
3112
|
+
currentness: resourceCurrentness(revision, [discoveredOwnership.contentHash]),
|
|
3113
|
+
environment: null,
|
|
3114
|
+
metadata: { discovery_authority: "candidate", declaration_path: discoveredOwnership.path },
|
|
3115
|
+
});
|
|
3116
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
for (const declaration of discoveredOperations) {
|
|
3120
|
+
const evidence = {
|
|
3121
|
+
kind: "operations_declaration",
|
|
3122
|
+
sourcePath: declaration.path,
|
|
3123
|
+
sourceField: "path",
|
|
3124
|
+
sourceRevision: revision,
|
|
3125
|
+
sourceContentHash: declaration.contentHash,
|
|
3126
|
+
};
|
|
3127
|
+
const runbookRecord = ResourceSchema.parse({
|
|
3128
|
+
schema: "hunch.resource/1",
|
|
3129
|
+
id: resourceId("runbook", `repository/${declaration.path}`),
|
|
3130
|
+
kind: "runbook",
|
|
3131
|
+
name: `Runbook: ${declaration.path}`.slice(0, 256),
|
|
3132
|
+
scope: repositoryRecord ? [repositoryRecord.id] : [],
|
|
3133
|
+
locator: declaration.path,
|
|
3134
|
+
lifecycle: "active",
|
|
3135
|
+
provenance: {
|
|
3136
|
+
source: "extracted:runbook-declaration",
|
|
3137
|
+
confidence: 0.85,
|
|
3138
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3139
|
+
},
|
|
3140
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3141
|
+
metadata: {
|
|
3142
|
+
discovery_authority: "candidate",
|
|
3143
|
+
declaration_path: declaration.path,
|
|
3144
|
+
declaration_format: /\.mdx$/i.test(declaration.path) ? "mdx" : "markdown",
|
|
3145
|
+
},
|
|
3146
|
+
created_at: timestamp,
|
|
3147
|
+
updated_at: timestamp,
|
|
3148
|
+
});
|
|
3149
|
+
resources.push(candidate(runbookRecord, [evidence]));
|
|
3150
|
+
if (!repositoryRecord)
|
|
3151
|
+
continue;
|
|
3152
|
+
const relationship = EdgeSchema.parse({
|
|
3153
|
+
schema: "hunch.resource-relationship/1",
|
|
3154
|
+
id: resourceRelationshipId(repositoryRecord.id, runbookRecord.id, "contains"),
|
|
3155
|
+
from: repositoryRecord.id,
|
|
3156
|
+
to: runbookRecord.id,
|
|
3157
|
+
type: "contains",
|
|
3158
|
+
reason: `${declaration.path} declares repository operational guidance`,
|
|
3159
|
+
strength: 0.85,
|
|
3160
|
+
provenance: {
|
|
3161
|
+
source: "extracted:runbook-declaration",
|
|
3162
|
+
confidence: 0.85,
|
|
3163
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3164
|
+
},
|
|
3165
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3166
|
+
environment: null,
|
|
3167
|
+
metadata: { discovery_authority: "candidate", declaration_path: declaration.path },
|
|
3168
|
+
});
|
|
3169
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
3170
|
+
}
|
|
3171
|
+
for (const declaration of discoveredDashboards) {
|
|
3172
|
+
const evidence = {
|
|
3173
|
+
kind: "dashboard_declaration",
|
|
3174
|
+
sourcePath: declaration.path,
|
|
3175
|
+
sourceField: "path",
|
|
3176
|
+
sourceRevision: revision,
|
|
3177
|
+
sourceContentHash: declaration.contentHash,
|
|
3178
|
+
};
|
|
3179
|
+
const dashboardRecord = ResourceSchema.parse({
|
|
3180
|
+
schema: "hunch.resource/1",
|
|
3181
|
+
id: resourceId("dashboard", `repository/${declaration.path}`),
|
|
3182
|
+
kind: "dashboard",
|
|
3183
|
+
name: `Dashboard: ${declaration.path}`.slice(0, 256),
|
|
3184
|
+
scope: repositoryRecord ? [repositoryRecord.id] : [],
|
|
3185
|
+
locator: declaration.path,
|
|
3186
|
+
lifecycle: "active",
|
|
3187
|
+
provenance: {
|
|
3188
|
+
source: "extracted:dashboard-declaration",
|
|
3189
|
+
confidence: 0.85,
|
|
3190
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3191
|
+
},
|
|
3192
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3193
|
+
metadata: {
|
|
3194
|
+
discovery_authority: "candidate",
|
|
3195
|
+
declaration_path: declaration.path,
|
|
3196
|
+
declaration_format: "json",
|
|
3197
|
+
},
|
|
3198
|
+
created_at: timestamp,
|
|
3199
|
+
updated_at: timestamp,
|
|
3200
|
+
});
|
|
3201
|
+
resources.push(candidate(dashboardRecord, [evidence]));
|
|
3202
|
+
if (!repositoryRecord)
|
|
3203
|
+
continue;
|
|
3204
|
+
const relationship = EdgeSchema.parse({
|
|
3205
|
+
schema: "hunch.resource-relationship/1",
|
|
3206
|
+
id: resourceRelationshipId(repositoryRecord.id, dashboardRecord.id, "contains"),
|
|
3207
|
+
from: repositoryRecord.id,
|
|
3208
|
+
to: dashboardRecord.id,
|
|
3209
|
+
type: "contains",
|
|
3210
|
+
reason: `${declaration.path} declares a repository dashboard`,
|
|
3211
|
+
strength: 0.85,
|
|
3212
|
+
provenance: {
|
|
3213
|
+
source: "extracted:dashboard-declaration",
|
|
3214
|
+
confidence: 0.85,
|
|
3215
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3216
|
+
},
|
|
3217
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3218
|
+
environment: null,
|
|
3219
|
+
metadata: { discovery_authority: "candidate", declaration_path: declaration.path },
|
|
3220
|
+
});
|
|
3221
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
3222
|
+
}
|
|
3223
|
+
for (const declaration of discoveredSlos) {
|
|
3224
|
+
const evidence = {
|
|
3225
|
+
kind: "slo_declaration",
|
|
3226
|
+
sourcePath: declaration.path,
|
|
3227
|
+
sourceField: "apiVersion/kind",
|
|
3228
|
+
sourceRevision: revision,
|
|
3229
|
+
sourceContentHash: declaration.contentHash,
|
|
3230
|
+
};
|
|
3231
|
+
const sloRecord = ResourceSchema.parse({
|
|
3232
|
+
schema: "hunch.resource/1",
|
|
3233
|
+
id: resourceId("slo", `repository/${declaration.path}`),
|
|
3234
|
+
kind: "slo",
|
|
3235
|
+
name: `SLO declaration: ${declaration.path}`.slice(0, 256),
|
|
3236
|
+
scope: repositoryRecord ? [repositoryRecord.id] : [],
|
|
3237
|
+
locator: declaration.path,
|
|
3238
|
+
lifecycle: "active",
|
|
3239
|
+
contract_version: declaration.contractVersion,
|
|
3240
|
+
provenance: {
|
|
3241
|
+
source: "extracted:openslo-declaration",
|
|
3242
|
+
confidence: 0.9,
|
|
3243
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3244
|
+
},
|
|
3245
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3246
|
+
metadata: {
|
|
3247
|
+
discovery_authority: "candidate",
|
|
3248
|
+
declaration_path: declaration.path,
|
|
3249
|
+
declaration_format: declaration.format,
|
|
3250
|
+
slo_dialect: "openslo",
|
|
3251
|
+
},
|
|
3252
|
+
created_at: timestamp,
|
|
3253
|
+
updated_at: timestamp,
|
|
3254
|
+
});
|
|
3255
|
+
resources.push(candidate(sloRecord, [evidence]));
|
|
3256
|
+
if (!repositoryRecord)
|
|
3257
|
+
continue;
|
|
3258
|
+
const relationship = EdgeSchema.parse({
|
|
3259
|
+
schema: "hunch.resource-relationship/1",
|
|
3260
|
+
id: resourceRelationshipId(repositoryRecord.id, sloRecord.id, "contains"),
|
|
3261
|
+
from: repositoryRecord.id,
|
|
3262
|
+
to: sloRecord.id,
|
|
3263
|
+
type: "contains",
|
|
3264
|
+
reason: `${declaration.path} declares a repository OpenSLO v1 objective`,
|
|
3265
|
+
strength: 0.9,
|
|
3266
|
+
provenance: {
|
|
3267
|
+
source: "extracted:openslo-declaration",
|
|
3268
|
+
confidence: 0.9,
|
|
3269
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3270
|
+
},
|
|
3271
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3272
|
+
environment: null,
|
|
3273
|
+
contract_version: declaration.contractVersion,
|
|
3274
|
+
metadata: { discovery_authority: "candidate", declaration_path: declaration.path },
|
|
3275
|
+
});
|
|
3276
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
3277
|
+
}
|
|
1498
3278
|
const mcpByKey = new Map();
|
|
1499
3279
|
for (const declaration of discoveredMcp) {
|
|
1500
3280
|
const group = mcpByKey.get(declaration.key) ?? [];
|
|
@@ -1625,17 +3405,142 @@ export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
|
1625
3405
|
});
|
|
1626
3406
|
relationships.push(candidate(relationship, [evidence]));
|
|
1627
3407
|
}
|
|
1628
|
-
for (const
|
|
1629
|
-
const
|
|
1630
|
-
|
|
1631
|
-
|
|
3408
|
+
for (const declaration of discoveredApi) {
|
|
3409
|
+
const evidence = {
|
|
3410
|
+
kind: "api_declaration",
|
|
3411
|
+
sourcePath: declaration.path,
|
|
3412
|
+
sourceField: declaration.dialect === "protobuf"
|
|
3413
|
+
? "syntax"
|
|
3414
|
+
: declaration.dialect === "jsonschema"
|
|
3415
|
+
? "$schema"
|
|
3416
|
+
: declaration.dialect,
|
|
3417
|
+
sourceRevision: revision,
|
|
3418
|
+
sourceContentHash: declaration.contentHash,
|
|
3419
|
+
};
|
|
3420
|
+
const family = declaration.dialect === "asyncapi"
|
|
3421
|
+
? "asyncapi"
|
|
3422
|
+
: declaration.dialect === "protobuf"
|
|
3423
|
+
? "protobuf"
|
|
3424
|
+
: declaration.dialect === "jsonschema"
|
|
3425
|
+
? "json-schema"
|
|
3426
|
+
: "openapi";
|
|
3427
|
+
const displayName = declaration.dialect === "swagger"
|
|
3428
|
+
? "Swagger"
|
|
3429
|
+
: declaration.dialect === "asyncapi"
|
|
3430
|
+
? "AsyncAPI"
|
|
3431
|
+
: declaration.dialect === "protobuf"
|
|
3432
|
+
? "Protobuf"
|
|
3433
|
+
: declaration.dialect === "jsonschema"
|
|
3434
|
+
? "JSON Schema"
|
|
3435
|
+
: "OpenAPI";
|
|
3436
|
+
const apiRecord = ResourceSchema.parse({
|
|
3437
|
+
schema: "hunch.resource/1",
|
|
3438
|
+
id: resourceId("api", `${family}/${declaration.path}`),
|
|
3439
|
+
kind: "api",
|
|
3440
|
+
name: `${displayName} contract: ${posix.basename(declaration.path)}`,
|
|
3441
|
+
scope: repositoryRecord ? [repositoryRecord.id] : [],
|
|
3442
|
+
locator: declaration.path,
|
|
3443
|
+
lifecycle: "active",
|
|
3444
|
+
contract_version: declaration.version,
|
|
3445
|
+
provenance: {
|
|
3446
|
+
source: "extracted:api-declaration",
|
|
3447
|
+
confidence: 0.85,
|
|
3448
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3449
|
+
},
|
|
3450
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3451
|
+
metadata: {
|
|
3452
|
+
discovery_authority: "candidate",
|
|
3453
|
+
declaration_path: declaration.path,
|
|
3454
|
+
declaration_format: declaration.format,
|
|
3455
|
+
api_dialect: declaration.dialect,
|
|
3456
|
+
},
|
|
3457
|
+
created_at: timestamp,
|
|
3458
|
+
updated_at: timestamp,
|
|
3459
|
+
});
|
|
3460
|
+
resources.push(candidate(apiRecord, [evidence]));
|
|
3461
|
+
if (!repositoryRecord)
|
|
1632
3462
|
continue;
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
3463
|
+
const relationship = EdgeSchema.parse({
|
|
3464
|
+
schema: "hunch.resource-relationship/1",
|
|
3465
|
+
id: resourceRelationshipId(repositoryRecord.id, apiRecord.id, "contains"),
|
|
3466
|
+
from: repositoryRecord.id,
|
|
3467
|
+
to: apiRecord.id,
|
|
3468
|
+
type: "contains",
|
|
3469
|
+
reason: `${declaration.path} declares an ${declaration.dialect === "swagger" ? "OpenAPI 2.0 (Swagger)" : displayName} contract`,
|
|
3470
|
+
strength: 0.85,
|
|
3471
|
+
provenance: {
|
|
3472
|
+
source: "extracted:api-declaration",
|
|
3473
|
+
confidence: 0.85,
|
|
3474
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3475
|
+
},
|
|
3476
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3477
|
+
environment: null,
|
|
3478
|
+
contract_version: declaration.version,
|
|
3479
|
+
metadata: { discovery_authority: "candidate", declaration_path: declaration.path },
|
|
3480
|
+
});
|
|
3481
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
3482
|
+
}
|
|
3483
|
+
for (const declaration of discoveredMigrations) {
|
|
3484
|
+
const evidence = {
|
|
3485
|
+
kind: "migration_declaration",
|
|
3486
|
+
sourcePath: declaration.path,
|
|
3487
|
+
sourceField: "path",
|
|
3488
|
+
sourceRevision: revision,
|
|
3489
|
+
sourceContentHash: declaration.contentHash,
|
|
3490
|
+
};
|
|
3491
|
+
const migrationRecord = ResourceSchema.parse({
|
|
3492
|
+
schema: "hunch.resource/1",
|
|
3493
|
+
id: resourceId("artifact", `migration/${declaration.provider}/${declaration.path}`),
|
|
3494
|
+
kind: "artifact",
|
|
3495
|
+
name: `${migrationProviderName(declaration.provider)} ${declaration.migrationType} migration: ${declaration.migrationId}`,
|
|
3496
|
+
scope: repositoryRecord ? [repositoryRecord.id] : [],
|
|
3497
|
+
locator: declaration.path,
|
|
3498
|
+
lifecycle: "active",
|
|
3499
|
+
contract_version: declaration.contractVersion ?? undefined,
|
|
3500
|
+
provenance: {
|
|
3501
|
+
source: "extracted:migration-declaration",
|
|
3502
|
+
confidence: 0.9,
|
|
3503
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3504
|
+
},
|
|
3505
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3506
|
+
metadata: {
|
|
3507
|
+
discovery_authority: "candidate",
|
|
3508
|
+
artifact_type: "database_migration",
|
|
3509
|
+
migration_framework: declaration.provider,
|
|
3510
|
+
migration_type: declaration.migrationType,
|
|
3511
|
+
declaration_path: declaration.path,
|
|
3512
|
+
declaration_format: "sql",
|
|
3513
|
+
migration_id: declaration.migrationId,
|
|
3514
|
+
},
|
|
3515
|
+
created_at: timestamp,
|
|
3516
|
+
updated_at: timestamp,
|
|
3517
|
+
});
|
|
3518
|
+
resources.push(candidate(migrationRecord, [evidence]));
|
|
3519
|
+
if (!repositoryRecord)
|
|
1637
3520
|
continue;
|
|
1638
|
-
|
|
3521
|
+
const relationship = EdgeSchema.parse({
|
|
3522
|
+
schema: "hunch.resource-relationship/1",
|
|
3523
|
+
id: resourceRelationshipId(repositoryRecord.id, migrationRecord.id, "contains"),
|
|
3524
|
+
from: repositoryRecord.id,
|
|
3525
|
+
to: migrationRecord.id,
|
|
3526
|
+
type: "contains",
|
|
3527
|
+
reason: `${declaration.path} declares a ${migrationProviderName(declaration.provider)} database migration artifact`,
|
|
3528
|
+
strength: 0.9,
|
|
3529
|
+
provenance: {
|
|
3530
|
+
source: "extracted:migration-declaration",
|
|
3531
|
+
confidence: 0.9,
|
|
3532
|
+
evidence: [provenanceEvidence(evidence)],
|
|
3533
|
+
},
|
|
3534
|
+
currentness: resourceCurrentness(revision, [declaration.contentHash]),
|
|
3535
|
+
environment: null,
|
|
3536
|
+
contract_version: declaration.contractVersion ?? undefined,
|
|
3537
|
+
metadata: { discovery_authority: "candidate", declaration_path: declaration.path },
|
|
3538
|
+
});
|
|
3539
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
3540
|
+
}
|
|
3541
|
+
const packageRecords = new Map();
|
|
3542
|
+
for (const declaration of discoveredPackages) {
|
|
3543
|
+
const { manifest, name } = declaration;
|
|
1639
3544
|
const evidence = {
|
|
1640
3545
|
kind: "package_manifest",
|
|
1641
3546
|
sourcePath: manifest.path,
|
|
@@ -1662,6 +3567,7 @@ export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
|
1662
3567
|
created_at: timestamp,
|
|
1663
3568
|
updated_at: timestamp,
|
|
1664
3569
|
});
|
|
3570
|
+
packageRecords.set(name, packageRecord);
|
|
1665
3571
|
resources.push(candidate(packageRecord, [evidence]));
|
|
1666
3572
|
if (!repositoryRecord)
|
|
1667
3573
|
continue;
|
|
@@ -1680,6 +3586,37 @@ export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
|
1680
3586
|
});
|
|
1681
3587
|
relationships.push(candidate(relationship, [evidence]));
|
|
1682
3588
|
}
|
|
3589
|
+
for (const declaration of discoveredWorkspaceDependencies) {
|
|
3590
|
+
const from = packageRecords.get(declaration.from.name);
|
|
3591
|
+
const to = packageRecords.get(declaration.to.name);
|
|
3592
|
+
if (!from || !to)
|
|
3593
|
+
continue;
|
|
3594
|
+
const evidence = declaration.evidence;
|
|
3595
|
+
const dependencyFields = [...new Set(evidence
|
|
3596
|
+
.filter((item) => item.sourcePath === declaration.from.manifest.path && item.sourceField !== "name")
|
|
3597
|
+
.map((item) => item.sourceField.split(".", 1)[0]))].sort(compareCodeUnits);
|
|
3598
|
+
const relationship = EdgeSchema.parse({
|
|
3599
|
+
schema: "hunch.resource-relationship/1",
|
|
3600
|
+
id: resourceRelationshipId(from.id, to.id, "depends_on"),
|
|
3601
|
+
from: from.id,
|
|
3602
|
+
to: to.id,
|
|
3603
|
+
type: "depends_on",
|
|
3604
|
+
reason: `${declaration.from.name} declares an internal workspace dependency on ${declaration.to.name}`,
|
|
3605
|
+
strength: 1,
|
|
3606
|
+
provenance: {
|
|
3607
|
+
source: "extracted:workspace-dependency",
|
|
3608
|
+
confidence: 0.95,
|
|
3609
|
+
evidence: evidence.map(provenanceEvidence),
|
|
3610
|
+
},
|
|
3611
|
+
currentness: resourceCurrentness(revision, evidence.map((item) => item.sourceContentHash)),
|
|
3612
|
+
environment: null,
|
|
3613
|
+
metadata: {
|
|
3614
|
+
discovery_authority: "candidate",
|
|
3615
|
+
dependency_fields: dependencyFields,
|
|
3616
|
+
},
|
|
3617
|
+
});
|
|
3618
|
+
relationships.push(candidate(relationship, evidence));
|
|
3619
|
+
}
|
|
1683
3620
|
resources.sort((left, right) => compareCodeUnits(left.record.id, right.record.id));
|
|
1684
3621
|
relationships.sort((left, right) => compareCodeUnits(left.record.id, right.record.id));
|
|
1685
3622
|
issues.sort((left, right) => compareCodeUnits(`${left.code}:${left.sourcePath}:${left.sourceField}:${left.detail}`, `${right.code}:${right.sourcePath}:${right.sourceField}:${right.detail}`));
|