@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.
@@ -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, isGitRepo, } from "./git.js";
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
- function contentHash(value) {
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
- function exactRevision(root, ref) {
68
- const revision = gitText(root, ["rev-parse", "--verify", `${ref}^{commit}`]).toLowerCase();
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
- return revision;
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 value;
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
- function manifestBlobs(root, revision) {
92
- const raw = gitBuffer(root, ["ls-tree", "--full-tree", "-r", "-z", revision], 64 * 1024 * 1024);
93
- const manifests = [];
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: head[3].toLowerCase(),
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 = head[1];
122
- const oid = head[3].toLowerCase();
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: head[2] === "blob" ? mode : `${head[2]}:${mode}`, oid, bytes: null, contentHash: null });
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 selected.map((manifest) => {
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, revision) {
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 record of nulRecords(raw)) {
244
- const tab = record.indexOf(0x09);
245
- if (tab < 0)
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
- const mode = head[2] === "blob" ? head[1] : `${head[2]}:${head[1]}`;
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)).map((blob) => {
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, revision)) {
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
- function deliveryDeclarationBlobs(root, revision) {
889
- const raw = gitBuffer(root, ["ls-tree", "--full-tree", "-r", "-z", revision], 64 * 1024 * 1024);
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 record of nulRecords(raw)) {
892
- const tab = record.indexOf(0x09);
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 = record.subarray(tab + 1);
899
- let path;
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 (deliveryDeclarationSpec(approximate)) {
1013
+ if (!firstPartyDeclarationPath(approximate))
1014
+ continue;
1015
+ if (apiDeclarationFormat(approximate)) {
906
1016
  discovered.push({
907
- path: `<unsafe-delivery-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
1017
+ path: `<unsafe-api-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
908
1018
  mode: "unsafe-path",
909
- oid: head[3].toLowerCase(),
1019
+ oid: entry.oid,
1020
+ objectSize: entry.objectSize,
910
1021
  bytes: null,
911
1022
  contentHash: null,
912
- spec: null,
1023
+ format: null,
913
1024
  });
914
1025
  }
915
1026
  continue;
916
1027
  }
917
- const spec = deliveryDeclarationSpec(path);
918
- if (!spec)
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-delivery-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
1036
+ path: `<unsafe-api-declaration:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
923
1037
  mode: "unsafe-path",
924
- oid: head[3].toLowerCase(),
1038
+ oid: entry.oid,
1039
+ objectSize: entry.objectSize,
925
1040
  bytes: null,
926
1041
  contentHash: null,
927
- spec: null,
1042
+ format: null,
928
1043
  });
929
1044
  continue;
930
1045
  }
931
1046
  discovered.push({
932
1047
  path,
933
- mode: head[2] === "blob" ? head[1] : `${head[2]}:${head[1]}`,
934
- oid: head[3].toLowerCase(),
1048
+ mode: treeEntryMode(entry),
1049
+ oid: entry.oid,
1050
+ objectSize: entry.objectSize,
935
1051
  bytes: null,
936
1052
  contentHash: null,
937
- spec,
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, MAX_DELIVERY_DECLARATIONS).map((blob) => {
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
- const KUBERNETES_WORKLOAD_KINDS = new Set(["Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "Pod"]);
955
- function stripYamlScalarComment(input) {
956
- let quote = null;
957
- let escaped = false;
958
- for (let index = 0; index < input.length; index += 1) {
959
- const char = input[index];
960
- if (quote === "\"") {
961
- if (escaped)
962
- escaped = false;
963
- else if (char === "\\")
964
- escaped = true;
965
- else if (char === quote)
966
- quote = null;
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 (char === "\"" || char === "'")
977
- quote = char;
978
- else if (char === "#" && (index === 0 || /\s/.test(input[index - 1])))
979
- return input.slice(0, index);
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
- return input;
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
- let parsed;
988
- if (value.startsWith("\"")) {
989
- if (!value.endsWith("\""))
990
- return null;
991
- try {
992
- const decoded = JSON.parse(value);
993
- if (typeof decoded !== "string")
994
- return null;
995
- parsed = decoded;
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
- catch {
998
- return null;
1115
+ if (char === "}" || char === "]") {
1116
+ depth -= 1;
1117
+ continue;
999
1118
  }
1000
- }
1001
- else if (value.startsWith("'")) {
1002
- if (!value.endsWith("'"))
1003
- return null;
1004
- const inner = value.slice(1, -1);
1005
- let decoded = "";
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 (inner[index + 1] !== "'")
1013
- return null;
1014
- decoded += "'";
1015
- index += 1;
1127
+ if (source[index] === '"')
1128
+ break;
1016
1129
  }
1017
- parsed = decoded;
1018
- }
1019
- else {
1020
- if (!/^[A-Za-z0-9][A-Za-z0-9._/@-]{0,255}$/.test(value))
1021
- return null;
1022
- parsed = value;
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 parsed.length > 0 && parsed.length <= 256
1025
- && !/[\u0000-\u001f\u007f]/.test(parsed)
1026
- && isCredentialFreeText(parsed)
1027
- ? parsed
1028
- : null;
1141
+ return keys;
1029
1142
  }
1030
- function kubernetesYamlDocuments(source) {
1031
- return source
1032
- .split(/^(?:---|\.\.\.)[ \t]*(?:#.*)?\r?$/m)
1033
- .map((document, index) => ({ index, source: document }))
1034
- .filter((document) => document.source.split(/\r?\n/)
1035
- .some((line) => !!line.trim() && !line.trimStart().startsWith("#")));
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 kubernetesDocumentHeader(source) {
1038
- const header = {
1039
- apiVersion: undefined,
1040
- kind: undefined,
1041
- name: undefined,
1042
- namespace: undefined,
1043
- duplicate: false,
1044
- };
1045
- let metadataIndent = null;
1046
- let metadataChildIndent = null;
1047
- const assign = (field, raw) => {
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
- header[field] = boundedYamlScalar(raw);
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 (const rawLine of source.split(/\r?\n/)) {
1055
- if (!rawLine.trim() || rawLine.trimStart().startsWith("#"))
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
- const indentation = rawLine.match(/^ */)[0].length;
1058
- const mapping = rawLine.slice(indentation).match(/^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:(.*)$/);
1059
- if (indentation === 0) {
1060
- metadataIndent = null;
1061
- metadataChildIndent = null;
1062
- if (!mapping)
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 (metadataIndent === null || indentation <= metadataIndent || !mapping)
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
- if (metadataChildIndent === null)
1074
- metadataChildIndent = indentation;
1075
- if (indentation !== metadataChildIndent)
1209
+ }
1210
+ if (char === "/" && next === "/") {
1211
+ if (depth === 0)
1212
+ statement += " ";
1213
+ state = "line-comment";
1214
+ index += 1;
1076
1215
  continue;
1077
- const [_, key, raw] = mapping;
1078
- if (key === "name" || key === "namespace")
1079
- assign(key, raw);
1080
- }
1081
- return header;
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 (typeof header.kind !== "string" || !KUBERNETES_WORKLOAD_KINDS.has(header.kind))
1224
+ if (char === '"') {
1225
+ if (depth === 0)
1226
+ statement += char;
1227
+ state = "string";
1106
1228
  continue;
1107
- const namespace = header.namespace === undefined ? "default" : header.namespace;
1108
- if (typeof header.apiVersion !== "string" || !validKubernetesApiVersion(header.apiVersion)
1109
- || typeof header.name !== "string" || !validKubernetesName(header.name)
1110
- || typeof namespace !== "string" || !validKubernetesNamespace(namespace)) {
1111
- issues.push({
1112
- code: "delivery_declaration_invalid",
1113
- sourcePath: blob.path,
1114
- sourceField: `documents[${document.index}].metadata.name`,
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
- const identity = `${header.apiVersion.toLowerCase()}/${header.kind.toLowerCase()}/${namespace}/${header.name}`;
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 deliveryDeclarations(root, revision, issues) {
1183
- const discovered = deliveryDeclarationBlobs(root, revision);
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
- ? `container image declared by ${base}`
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 safe(identity, null);
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
- if (!isGitRepo(root))
1431
- throw new Error("landscape discovery requires a Git repository");
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(root, revision);
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 manifest of manifests) {
1629
- const rawName = typeof manifest.value.name === "string" ? manifest.value.name.trim() : "";
1630
- if (!rawName) {
1631
- issues.push({ code: "package_name_missing", sourcePath: manifest.path, sourceField: "name", detail: `${manifest.path} has no package name` });
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
- const name = validPackageName(rawName);
1635
- if (!name) {
1636
- issues.push({ code: "package_name_invalid", sourcePath: manifest.path, sourceField: "name", detail: `${manifest.path} has an invalid package name` });
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}`));