@intentius/chant 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +93 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.ts +12 -4
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +14 -2
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +103 -2
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +4 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +45 -9
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
package/src/audit/discover.ts
CHANGED
|
@@ -104,6 +104,27 @@ function isDockerfileName(name: string): boolean {
|
|
|
104
104
|
return name === "Dockerfile" || name.startsWith("Dockerfile.") || name.endsWith(".Dockerfile") || name.endsWith(".dockerfile");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Filenames the secrets scan (#443) wants that no lexicon otherwise reads —
|
|
109
|
+
* dotenv files and key/cert material. Included in `isCandidatePath` so both
|
|
110
|
+
* the local walk and the remote fetch pick them up; `classifyFiles` doesn't
|
|
111
|
+
* claim them for any lexicon, they just ride along for `scanForSecrets`.
|
|
112
|
+
*/
|
|
113
|
+
function isSecretBearingName(name: string): boolean {
|
|
114
|
+
if (/^\.env(\..+)?$/i.test(name)) return true;
|
|
115
|
+
return /\.(pem|key|crt|cer|pfx|p12)$/i.test(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `wrangler.toml` (#446) — Cloudflare Workers' declarative deploy config.
|
|
120
|
+
* Native TOML, so it rides along the same way secret-bearing filenames do:
|
|
121
|
+
* `wrangler.ts` reads it directly (no lexicon plugin, no authoring surface),
|
|
122
|
+
* so it only needs to be a candidate path, not a `detectTemplate` entry.
|
|
123
|
+
*/
|
|
124
|
+
function isWranglerConfigName(name: string): boolean {
|
|
125
|
+
return name === "wrangler.toml";
|
|
126
|
+
}
|
|
127
|
+
|
|
107
128
|
/** Split a possibly multi-document YAML string into per-doc parsed objects. */
|
|
108
129
|
function parseDocs(content: string): Record<string, unknown>[] {
|
|
109
130
|
const docs: Record<string, unknown>[] = [];
|
|
@@ -231,8 +252,12 @@ export interface DetectPlugin {
|
|
|
231
252
|
detectTemplate?: (data: unknown) => boolean;
|
|
232
253
|
}
|
|
233
254
|
|
|
234
|
-
/**
|
|
235
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Which CI lexicon a path belongs to (by location — content can't disambiguate
|
|
257
|
+
* github vs forgejo). Exported for the fetch module: known-path lexicons stay a
|
|
258
|
+
* direct path match even in search-first (large-repo) discovery (#520).
|
|
259
|
+
*/
|
|
260
|
+
export function ciLexiconForPath(path: string): AuditLexicon | undefined {
|
|
236
261
|
if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path)) return "github";
|
|
237
262
|
if (/^\.forgejo\/workflows\/[^/]+\.ya?ml$/.test(path)) return "forgejo";
|
|
238
263
|
if (path === ".gitlab-ci.yml") return "gitlab";
|
|
@@ -248,6 +273,8 @@ export function isCandidatePath(path: string): boolean {
|
|
|
248
273
|
if (ciLexiconForPath(path)) return true;
|
|
249
274
|
if (isDockerfileName(name)) return true;
|
|
250
275
|
if (name === "Chart.yaml") return true;
|
|
276
|
+
if (isSecretBearingName(name)) return true;
|
|
277
|
+
if (isWranglerConfigName(name)) return true;
|
|
251
278
|
return /\.(ya?ml|json|template)$/i.test(name);
|
|
252
279
|
}
|
|
253
280
|
|
package/src/audit/fetch.test.ts
CHANGED
|
@@ -54,15 +54,28 @@ function gitTreeMock(files: Record<string, string>, sizes: Record<string, number
|
|
|
54
54
|
return { impl, calls };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A full root-level tree page (>=100 entries) — the "large repo" signal `listTreeGitLab` probes for. */
|
|
58
|
+
const LARGE_ROOT_PAGE = Array.from({ length: 100 }, (_, i) => ({ path: `dir${i}`, type: "tree" }));
|
|
59
|
+
/** A small root-level tree page (<100 entries) — the "small repo" signal, keeps the walk. */
|
|
60
|
+
const SMALL_ROOT_PAGE: Array<{ path: string; type: string }> = [];
|
|
61
|
+
|
|
57
62
|
/**
|
|
58
63
|
* GitLab mock for authenticated search-based discovery. `searchMap` maps search
|
|
59
64
|
* term → array of blob paths the search API returns. `files` maps path → content.
|
|
60
|
-
*
|
|
61
|
-
* search path
|
|
65
|
+
* The root tree probe reports a large repo by default (`rootPage`
|
|
66
|
+
* overridable) so these mocks exercise the search path; pass `token` to
|
|
67
|
+
* `fetchRepoFiles` too, since search additionally requires authentication.
|
|
62
68
|
*/
|
|
63
|
-
function gitlabMock(searchMap: Record<string, string[]>, files: Record<string, string>) {
|
|
69
|
+
function gitlabMock(searchMap: Record<string, string[]>, files: Record<string, string>, rootPage: Array<{ path: string; type: string }> = LARGE_ROOT_PAGE) {
|
|
64
70
|
const impl = (async (url: string | URL | Request) => {
|
|
65
71
|
const u = String(url);
|
|
72
|
+
if (u.includes("/repository/tree")) {
|
|
73
|
+
const params = new URL(u).searchParams;
|
|
74
|
+
const path = params.get("path") ?? "";
|
|
75
|
+
const page = Number(params.get("page") ?? "1");
|
|
76
|
+
const entries = path === "" && page === 1 ? rootPage : [];
|
|
77
|
+
return new Response(JSON.stringify(entries), { status: 200 });
|
|
78
|
+
}
|
|
66
79
|
if (u.includes("/search")) {
|
|
67
80
|
const params = new URL(u).searchParams;
|
|
68
81
|
const term = params.get("search") ?? "";
|
|
@@ -112,6 +125,85 @@ function gitlabBfsMock(
|
|
|
112
125
|
return { impl };
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A GitHub-shaped mock for large-repo (search-first) discovery: `fillerCount`
|
|
130
|
+
* inflates the recursive tree past `LARGE_REPO_TREE_ENTRIES` so `listTree`
|
|
131
|
+
* switches strategies; `searchMap` maps a bare search term (no `repo:` qualifier)
|
|
132
|
+
* to the paths GitHub's code search returns for it, paginated 100/page.
|
|
133
|
+
*/
|
|
134
|
+
function githubCodeSearchMock(fillerCount: number, files: Record<string, string>, searchMap: Record<string, string[]>) {
|
|
135
|
+
const calls: Array<{ url: string }> = [];
|
|
136
|
+
const impl = (async (url: string | URL | Request) => {
|
|
137
|
+
const u = String(url);
|
|
138
|
+
calls.push({ url: u });
|
|
139
|
+
if (u.includes("/git/trees/")) {
|
|
140
|
+
const filler = Array.from({ length: fillerCount }, (_, i) => ({ path: `filler/file${i}.md`, type: "blob", size: 10 }));
|
|
141
|
+
const real = Object.keys(files).map((path) => ({ path, type: "blob", size: files[path].length }));
|
|
142
|
+
return new Response(JSON.stringify({ tree: [...filler, ...real] }), { status: 200 });
|
|
143
|
+
}
|
|
144
|
+
if (u.includes("/search/code")) {
|
|
145
|
+
const params = new URL(u).searchParams;
|
|
146
|
+
const term = (params.get("q") ?? "").replace(/\s*repo:.*/, "");
|
|
147
|
+
const page = Number(params.get("page") ?? "1");
|
|
148
|
+
const all = searchMap[term] ?? [];
|
|
149
|
+
const start = (page - 1) * 100;
|
|
150
|
+
const items = all.slice(start, start + 100).map((path) => ({ path }));
|
|
151
|
+
return new Response(JSON.stringify({ items, total_count: all.length }), { status: 200 });
|
|
152
|
+
}
|
|
153
|
+
const rawm = u.match(/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/[^/]+\/(.+)$/);
|
|
154
|
+
if (rawm) {
|
|
155
|
+
const path = decodeURIComponent(rawm[1]);
|
|
156
|
+
if (files[path] === undefined) return new Response("not found", { status: 404 });
|
|
157
|
+
return new Response(files[path], { status: 200 });
|
|
158
|
+
}
|
|
159
|
+
const cm = u.match(/\/contents\/(.+?)\?/);
|
|
160
|
+
if (cm) {
|
|
161
|
+
const path = decodeURIComponent(cm[1]);
|
|
162
|
+
if (files[path] === undefined) return new Response("not found", { status: 404 });
|
|
163
|
+
return new Response(JSON.stringify({ path, type: "file", content: b64(files[path]), encoding: "base64" }), { status: 200 });
|
|
164
|
+
}
|
|
165
|
+
if (/\/repos\/[^/]+\/[^/]+(\?|$)/.test(u)) return new Response(JSON.stringify({ default_branch: "main" }), { status: 200 });
|
|
166
|
+
return new Response("not found", { status: 404 });
|
|
167
|
+
}) as unknown as typeof fetch;
|
|
168
|
+
return { impl, calls };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A Forgejo/Gitea-shaped mock for large-repo discovery. `searchMap` maps a bare
|
|
173
|
+
* search term to the paths its (best-effort, `{ok, data}`-enveloped) search
|
|
174
|
+
* endpoint returns, paginated 100/page.
|
|
175
|
+
*/
|
|
176
|
+
function forgejoCodeSearchMock(fillerCount: number, files: Record<string, string>, searchMap: Record<string, string[]>) {
|
|
177
|
+
const calls: Array<{ url: string }> = [];
|
|
178
|
+
const impl = (async (url: string | URL | Request) => {
|
|
179
|
+
const u = String(url);
|
|
180
|
+
calls.push({ url: u });
|
|
181
|
+
if (u.includes("/git/trees/")) {
|
|
182
|
+
const filler = Array.from({ length: fillerCount }, (_, i) => ({ path: `filler/file${i}.md`, type: "blob", size: 10 }));
|
|
183
|
+
const real = Object.keys(files).map((path) => ({ path, type: "blob", size: files[path].length }));
|
|
184
|
+
return new Response(JSON.stringify({ tree: [...filler, ...real] }), { status: 200 });
|
|
185
|
+
}
|
|
186
|
+
if (u.includes("/search")) {
|
|
187
|
+
const params = new URL(u).searchParams;
|
|
188
|
+
const term = params.get("q") ?? "";
|
|
189
|
+
const page = Number(params.get("page") ?? "1");
|
|
190
|
+
const all = searchMap[term] ?? [];
|
|
191
|
+
const start = (page - 1) * 100;
|
|
192
|
+
const data = all.slice(start, start + 100).map((path) => ({ path }));
|
|
193
|
+
return new Response(JSON.stringify({ ok: true, data }), { status: 200 });
|
|
194
|
+
}
|
|
195
|
+
const cm = u.match(/\/contents\/(.+?)\?/);
|
|
196
|
+
if (cm) {
|
|
197
|
+
const path = decodeURIComponent(cm[1]);
|
|
198
|
+
if (files[path] === undefined) return new Response("not found", { status: 404 });
|
|
199
|
+
return new Response(JSON.stringify({ path, type: "file", content: b64(files[path]), encoding: "base64" }), { status: 200 });
|
|
200
|
+
}
|
|
201
|
+
if (/\/repos\/[^/]+\/[^/]+(\?|$)/.test(u)) return new Response(JSON.stringify({ default_branch: "main" }), { status: 200 });
|
|
202
|
+
return new Response("not found", { status: 404 });
|
|
203
|
+
}) as unknown as typeof fetch;
|
|
204
|
+
return { impl, calls };
|
|
205
|
+
}
|
|
206
|
+
|
|
115
207
|
describe("parseRepoUrl", () => {
|
|
116
208
|
test("rejects non-https", () => {
|
|
117
209
|
expect(() => parseRepoUrl("http://github.com/o/r")).toThrow(FetchError);
|
|
@@ -285,12 +377,133 @@ describe("fetchRepoFiles", () => {
|
|
|
285
377
|
expect(files.map((f) => f.path)).toContain(".gitlab-ci.yml");
|
|
286
378
|
});
|
|
287
379
|
|
|
380
|
+
// ── Strategy selection: small repo → walk, large repo → search (#520) ────
|
|
381
|
+
|
|
382
|
+
test("gitlab: a small repo uses the walk even when a token is present (no search call)", async () => {
|
|
383
|
+
// The root page has one blob and no filler dirs — well under the large-repo
|
|
384
|
+
// threshold — so the walk is used regardless of the token, and the search
|
|
385
|
+
// API is never called.
|
|
386
|
+
const rootPage = [{ path: ".gitlab-ci.yml", type: "blob" }];
|
|
387
|
+
const { impl } = gitlabMock({ "stages:": [".gitlab-ci.yml"] }, { ".gitlab-ci.yml": "stages:\n - build\n" }, rootPage);
|
|
388
|
+
const calls: string[] = [];
|
|
389
|
+
const spy = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
390
|
+
calls.push(String(url));
|
|
391
|
+
return impl(url, init);
|
|
392
|
+
}) as unknown as typeof fetch;
|
|
393
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: spy, token: "tok" });
|
|
394
|
+
expect(files.map((f) => f.path)).toEqual([".gitlab-ci.yml"]);
|
|
395
|
+
expect(calls.some((c) => c.includes("/search"))).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("gitlab: a large repo without a token falls back to the BFS walk (search needs auth)", async () => {
|
|
399
|
+
const dirEntries = {
|
|
400
|
+
"": [{ path: ".gitlab-ci.yml", type: "blob" as const }, ...Array.from({ length: 99 }, (_, i) => ({ path: `dir${i}`, type: "tree" as const }))],
|
|
401
|
+
};
|
|
402
|
+
const { impl } = gitlabBfsMock(dirEntries, { ".gitlab-ci.yml": "stages:\n - build\n" });
|
|
403
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl }); // no token
|
|
404
|
+
expect(files.map((f) => f.path)).toContain(".gitlab-ci.yml");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("gitlab: search failure on a large repo falls back to the BFS walk (#520)", async () => {
|
|
408
|
+
const dirEntries = {
|
|
409
|
+
"": [{ path: ".gitlab-ci.yml", type: "blob" as const }, ...Array.from({ length: 99 }, (_, i) => ({ path: `dir${i}`, type: "tree" as const }))],
|
|
410
|
+
};
|
|
411
|
+
const { impl: bfsImpl } = gitlabBfsMock(dirEntries, { ".gitlab-ci.yml": "stages:\n - build\n" });
|
|
412
|
+
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
413
|
+
if (String(url).includes("/search")) return new Response("rate limited", { status: 429 });
|
|
414
|
+
return bfsImpl(url, init);
|
|
415
|
+
}) as unknown as typeof fetch;
|
|
416
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl, token: "tok" });
|
|
417
|
+
expect(files.map((f) => f.path)).toContain(".gitlab-ci.yml");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("github: a small repo never calls the code search API", async () => {
|
|
421
|
+
const { impl, calls } = gitTreeMock({ ".github/workflows/ci.yml": CI_YAML, "k8s/deploy.yaml": "apiVersion: apps/v1\nkind: Deployment\n" });
|
|
422
|
+
await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: impl });
|
|
423
|
+
expect(calls.some((c) => c.url.includes("/search/code"))).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("github: a large repo switches to per-lexicon search; known-path CI bypasses search entirely", async () => {
|
|
427
|
+
const files = { ".github/workflows/ci.yml": CI_YAML, "infra/eks.yaml": "apiVersion: v1\nkind: ConfigMap\n" };
|
|
428
|
+
const { impl, calls } = githubCodeSearchMock(600, files, { apiVersion: ["infra/eks.yaml"] });
|
|
429
|
+
const found = await fetchRepoFiles("https://github.com/acme/monorepo", { fetchImpl: impl, maxFiles: 100 });
|
|
430
|
+
const paths = found.map((f) => f.path);
|
|
431
|
+
expect(paths).toContain(".github/workflows/ci.yml");
|
|
432
|
+
expect(paths).toContain("infra/eks.yaml");
|
|
433
|
+
// The workflow came straight from the tree already in hand — no search
|
|
434
|
+
// query was needed (or issued) to find it.
|
|
435
|
+
expect(calls.some((c) => c.url.includes("/search/code") && c.url.includes("workflows"))).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("github: search results paginate past 100 until a short page", async () => {
|
|
439
|
+
const manyPaths = Array.from({ length: 150 }, (_, i) => `k8s/deploy${i}.yaml`);
|
|
440
|
+
const files: Record<string, string> = {};
|
|
441
|
+
for (const p of manyPaths) files[p] = "apiVersion: apps/v1\nkind: Deployment\n";
|
|
442
|
+
const { impl, calls } = githubCodeSearchMock(600, files, { apiVersion: manyPaths });
|
|
443
|
+
const found = await fetchRepoFiles("https://github.com/acme/monorepo", { fetchImpl: impl, maxFiles: 1000 });
|
|
444
|
+
expect(found.length).toBe(150);
|
|
445
|
+
const searchCalls = calls.filter((c) => c.url.includes("/search/code") && c.url.includes("q=apiVersion"));
|
|
446
|
+
expect(searchCalls.some((c) => c.url.includes("page=1"))).toBe(true);
|
|
447
|
+
expect(searchCalls.some((c) => c.url.includes("page=2"))).toBe(true);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("github: search failure (rate limit) on a large repo falls back to the full tree", async () => {
|
|
451
|
+
const files = { ".github/workflows/ci.yml": CI_YAML, "infra/eks.yaml": "apiVersion: v1\nkind: ConfigMap\n" };
|
|
452
|
+
const { impl: base } = githubCodeSearchMock(600, files, {});
|
|
453
|
+
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
454
|
+
if (String(url).includes("/search/code")) return new Response("rate limited", { status: 403 });
|
|
455
|
+
return base(url, init);
|
|
456
|
+
}) as unknown as typeof fetch;
|
|
457
|
+
const found = await fetchRepoFiles("https://github.com/acme/monorepo", { fetchImpl: impl, maxFiles: 1000 });
|
|
458
|
+
const paths = found.map((f) => f.path);
|
|
459
|
+
expect(paths).toContain(".github/workflows/ci.yml");
|
|
460
|
+
expect(paths).toContain("infra/eks.yaml");
|
|
461
|
+
});
|
|
462
|
+
|
|
288
463
|
test("forgejo (codeberg): gitea tree + contents", async () => {
|
|
289
464
|
const { impl } = gitTreeMock({ ".forgejo/workflows/ci.yml": CI_YAML });
|
|
290
465
|
const files = await fetchRepoFiles("https://codeberg.org/acme/widgets", { fetchImpl: impl });
|
|
291
466
|
expect(files.map((f) => f.path)).toEqual([".forgejo/workflows/ci.yml"]);
|
|
292
467
|
});
|
|
293
468
|
|
|
469
|
+
test("forgejo: a large repo switches to search; known-path CI bypasses search entirely", async () => {
|
|
470
|
+
const files = { ".forgejo/workflows/ci.yml": CI_YAML, "charts/app/Chart.yaml": "apiVersion: v2\nname: app\nversion: 1.0.0\n" };
|
|
471
|
+
const { impl, calls } = forgejoCodeSearchMock(600, files, { "apiVersion: v2": ["charts/app/Chart.yaml"] });
|
|
472
|
+
const found = await fetchRepoFiles("https://codeberg.org/acme/monorepo", { fetchImpl: impl, maxFiles: 100 });
|
|
473
|
+
const paths = found.map((f) => f.path);
|
|
474
|
+
expect(paths).toContain(".forgejo/workflows/ci.yml");
|
|
475
|
+
expect(paths).toContain("charts/app/Chart.yaml");
|
|
476
|
+
expect(calls.some((c) => c.url.includes("/search") && c.url.includes("workflows"))).toBe(false);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("forgejo: search paginates past 100 until a short page", async () => {
|
|
480
|
+
const manyPaths = Array.from({ length: 120 }, (_, i) => `k8s/deploy${i}.yaml`);
|
|
481
|
+
const files: Record<string, string> = {};
|
|
482
|
+
for (const p of manyPaths) files[p] = "apiVersion: apps/v1\nkind: Deployment\n";
|
|
483
|
+
const { impl, calls } = forgejoCodeSearchMock(600, files, { apiVersion: manyPaths });
|
|
484
|
+
const found = await fetchRepoFiles("https://codeberg.org/acme/monorepo", { fetchImpl: impl, maxFiles: 1000 });
|
|
485
|
+
expect(found.length).toBe(120);
|
|
486
|
+
const searchCalls = calls.filter((c) => c.url.includes("/search") && c.url.includes("q=apiVersion"));
|
|
487
|
+
expect(searchCalls.some((c) => c.url.includes("page=1"))).toBe(true);
|
|
488
|
+
expect(searchCalls.some((c) => c.url.includes("page=2"))).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("forgejo: an instance with no code search (404) falls back to the full tree", async () => {
|
|
492
|
+
// Forgejo's code-search availability/shape is undocumented (#520) — an
|
|
493
|
+
// instance without a code indexer, or with a different API shape, must
|
|
494
|
+
// still produce a full audit via the fallback, not an empty/failed one.
|
|
495
|
+
const files = { ".forgejo/workflows/ci.yml": CI_YAML, "charts/app/Chart.yaml": "apiVersion: v2\nname: app\nversion: 1.0.0\n" };
|
|
496
|
+
const { impl: base } = forgejoCodeSearchMock(600, files, {});
|
|
497
|
+
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
498
|
+
if (String(url).includes("/search")) return new Response("not found", { status: 404 });
|
|
499
|
+
return base(url, init);
|
|
500
|
+
}) as unknown as typeof fetch;
|
|
501
|
+
const found = await fetchRepoFiles("https://codeberg.org/acme/monorepo", { fetchImpl: impl, maxFiles: 1000 });
|
|
502
|
+
const paths = found.map((f) => f.path);
|
|
503
|
+
expect(paths).toContain(".forgejo/workflows/ci.yml");
|
|
504
|
+
expect(paths).toContain("charts/app/Chart.yaml");
|
|
505
|
+
});
|
|
506
|
+
|
|
294
507
|
test("an empty repo returns []", async () => {
|
|
295
508
|
const { impl } = gitTreeMock({});
|
|
296
509
|
const files = await fetchRepoFiles("https://github.com/acme/empty", { fetchImpl: impl });
|