@intentius/chant 0.49.0 → 0.51.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.
Files changed (294) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -133,6 +133,27 @@ async function getJsonAt(
133
133
  return { status: res.status, body: await res.json() };
134
134
  }
135
135
 
136
+ /**
137
+ * Like `getJsonAt`, but for search endpoints specifically: a 404 here means
138
+ * "this endpoint doesn't exist" (an unsupported/misconfigured search API — the
139
+ * real-world case for Forgejo, whose code-search availability is undocumented,
140
+ * #520), not "zero results" the way a missing *file* would be. Zero results are
141
+ * a 200 with an empty array/list, so treating 404 as an error (rather than
142
+ * `getJsonAt`'s "return null" leniency) lets a genuinely unsupported search API
143
+ * fall back to the walk instead of silently reporting no matches.
144
+ */
145
+ async function getSearchJsonAt(url: string, headers: Record<string, string>, doFetch: typeof fetch, timeoutMs: number): Promise<unknown> {
146
+ let res: Response;
147
+ try {
148
+ res = await doFetch(url, { headers, redirect: "manual", signal: timeoutSignal(timeoutMs) });
149
+ } catch (err) {
150
+ throw new FetchError(`Request failed: ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ if (res.status >= 300 && res.status < 400) throw new FetchError(`Refusing to follow redirect from ${url}`);
153
+ if (!res.ok) throw new FetchError(`${url} returned ${res.status}`);
154
+ return res.json();
155
+ }
156
+
136
157
  function projectId(owner: string, repo: string): string {
137
158
  return encodeURIComponent(`${owner}/${repo}`);
138
159
  }
@@ -154,73 +175,263 @@ interface TreeEntry {
154
175
  size?: number;
155
176
  }
156
177
 
157
- /** List every blob path in the repo (recursive), with size where the host reports it. */
158
- async function listTree(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
159
- if (host.kind === "gitlab") {
160
- // GitLab search API requires authentication even for public projects. When a
161
- // token is present, use content search — one query per lexicon finds only
162
- // relevant files regardless of repo size, and catches non-canonical CI paths
163
- // that path-based detection misses (#518, #520). Without a token, fall back
164
- // to the non-recursive BFS which works for public repos.
165
- if ("PRIVATE-TOKEN" in headers) {
166
- const out: TreeEntry[] = [];
167
- const seen = new Set<string>();
168
- const addPath = (p: string) => { if (typeof p === "string" && !seen.has(p)) { seen.add(p); out.push({ path: p, type: "blob" }); } };
169
- // Ordered most → least selective so the seen-set dedup reduces noise from
170
- // broader terms (e.g. `apiVersion` won't re-add files already found by
171
- // the more specific `apiVersion: v2`).
172
- const SEARCH_TERMS = [
173
- "AWSTemplateFormatVersion", // CloudFormation
174
- "deploymentTemplate", // Azure ARM ($schema substring)
175
- "cnrm.cloud.google.com", // GCP Config Connector
176
- "apiVersion: v2", // Helm Chart.yaml
177
- "stages:", // GitLab CI — root file + non-canonical includes (#520)
178
- "FROM ", // Dockerfiles (content-based; space avoids false matches)
179
- "services:", // Docker Compose
180
- "apiVersion", // k8s manifests (broad; runs last)
181
- ];
182
- for (const term of SEARCH_TERMS) {
183
- for (let page = 1; page <= 3; page++) {
184
- const url = `${host.api}/projects/${projectId(owner, repo)}/search?scope=blobs&search=${encodeURIComponent(term)}&per_page=100&page=${page}&ref=${encodeURIComponent(ref)}`;
185
- const { body } = await getJsonAt(url, headers, doFetch, ms);
186
- if (!Array.isArray(body) || body.length === 0) break;
187
- for (const e of body as Array<{ path: string }>) addPath(e.path);
188
- if (body.length < 100) break;
189
- }
190
- }
191
- return out;
178
+ // ── Search-first discovery for large repos (#520) ───────────────────────────
179
+ //
180
+ // A full tree walk + extension filter over-fetches badly on a large monorepo
181
+ // (thousands of `.yml`/`.json` candidates, almost all irrelevant) and still
182
+ // misses IaC/CI content living at non-canonical paths. Above a size threshold,
183
+ // switch from "walk everything `isCandidatePath` allows" to "search for each
184
+ // content-detected lexicon's characteristic signature, download only hits":
185
+ //
186
+ // - Known-path lexicons (github, forgejo, and gitlab's canonical file) never
187
+ // need a search query — their location is fixed, so a plain path/tree
188
+ // lookup already finds them for any repo size. GitHub/Forgejo Actions
189
+ // *must* live under `.github`/`.forgejo` workflows (the platform enforces
190
+ // it), so no search term is listed for them at all. GitLab's `include:`
191
+ // can pull in CI files from anywhere, so its canonical file is still
192
+ // backed by a `stages:` search term to catch those satellites (#518).
193
+ // - Content-detected lexicons (k8s, aws, azure, gcp, docker, helm) have no
194
+ // fixed path, so at scale the only cheap way to find them is a search for
195
+ // the grep-equivalent of their `detectTemplate` signature.
196
+ // - Search is additive, never load-bearing: any error, rate limit, or
197
+ // unsupported/unavailable API degrades to the walk rather than failing
198
+ // the audit.
199
+
200
+ /** Repos above this many tree entries switch from the walk to search-first
201
+ * discovery. Cheap to evaluate — it's the size of a call already being made
202
+ * (GitHub/Forgejo's one-shot recursive tree; GitLab's first root-tree page,
203
+ * see below) — and keeps small repos on the walk, which needs no search API
204
+ * at all and so works even where search is unavailable (e.g. a Forgejo
205
+ * instance with no code indexer). */
206
+ const LARGE_REPO_TREE_ENTRIES = 500;
207
+
208
+ /** How many result pages to pull per search term. GitLab's basic (non-
209
+ * Elasticsearch) blob search returns at most ~20 results per query regardless
210
+ * of `per_page` and doesn't paginate past them; GitHub code search is rate-
211
+ * limited (~10 req/min unauthenticated) and caps at 1000 results/query. Either
212
+ * way, a short page (`< per_page`) already stops the loop early — this is just
213
+ * a hard ceiling on round trips per term. */
214
+ const MAX_SEARCH_PAGES = 3;
215
+
216
+ /**
217
+ * Content-detected lexicons: no canonical path, so search-first discovery
218
+ * needs one characteristic content term per lexicon, taken from that lexicon's
219
+ * own `detectTemplate` signature. One row per lexicon — docker gets two
220
+ * (Dockerfile and Compose share nothing in content) — so adding a new
221
+ * content-detected lexicon here is the only step needed to search for it.
222
+ * Ordered most → least selective; the caller de-duplicates by path, so a
223
+ * broader later term (`apiVersion`) adds nothing for files the more specific
224
+ * earlier term (`apiVersion: v2`) already found.
225
+ */
226
+ const CONTENT_SEARCH_TERMS: Array<{ lexicon: AuditLexicon; term: string }> = [
227
+ { lexicon: "aws", term: "AWSTemplateFormatVersion" }, // CloudFormation
228
+ { lexicon: "azure", term: "deploymentTemplate" }, // ARM $schema substring
229
+ { lexicon: "gcp", term: "cnrm.cloud.google.com" }, // GCP Config Connector
230
+ { lexicon: "helm", term: "apiVersion: v2" }, // Chart.yaml
231
+ { lexicon: "docker", term: "FROM " }, // Dockerfiles (space avoids false hits)
232
+ { lexicon: "docker", term: "services:" }, // Docker Compose
233
+ { lexicon: "k8s", term: "apiVersion" }, // any k8s resource (broad; runs last)
234
+ ];
235
+
236
+ /** GitLab-only: `include:` can pull a CI file in from anywhere, so the
237
+ * canonical-path lexicon still gets a search term (#518, #520). GitHub and
238
+ * Forgejo Actions have no such mechanism, so they need none. */
239
+ const GITLAB_CI_TERM = "stages:";
240
+
241
+ /** One page of GitLab's blob search (`scope=blobs`) for a single term. */
242
+ async function gitlabSearchPage(host: HostConfig, owner: string, repo: string, ref: string, term: string, page: number, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<string[]> {
243
+ const url = `${host.api}/projects/${projectId(owner, repo)}/search?scope=blobs&search=${encodeURIComponent(term)}&per_page=100&page=${page}&ref=${encodeURIComponent(ref)}`;
244
+ const body = await getSearchJsonAt(url, headers, doFetch, ms);
245
+ if (!Array.isArray(body)) return [];
246
+ return (body as Array<{ path?: string }>).map((e) => e.path).filter((p): p is string => typeof p === "string");
247
+ }
248
+
249
+ /**
250
+ * GitLab content search across every term (CI + content-detected lexicons),
251
+ * de-duplicated by path. Throws on the first request error (a 401 with a bad
252
+ * or absent token, a rate limit, a transient failure) — the caller catches
253
+ * that and falls back to the BFS walk (#520).
254
+ */
255
+ async function gitlabSearch(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
256
+ const out: TreeEntry[] = [];
257
+ const seen = new Set<string>();
258
+ const terms = [GITLAB_CI_TERM, ...CONTENT_SEARCH_TERMS.map((t) => t.term)];
259
+ for (const term of terms) {
260
+ for (let page = 1; page <= MAX_SEARCH_PAGES; page++) {
261
+ const hits = await gitlabSearchPage(host, owner, repo, ref, term, page, doFetch, headers, ms);
262
+ if (hits.length === 0) break;
263
+ for (const p of hits) if (!seen.has(p)) { seen.add(p); out.push({ path: p, type: "blob" }); }
264
+ if (hits.length < 100) break;
192
265
  }
193
- // Unauthenticated fallback: non-recursive BFS. GitLab's recursive tree API
194
- // lists ALL directories before any blobs for large repos (#518), so we walk
195
- // directories breadth-first to ensure root blobs appear on the first request.
196
- const out: TreeEntry[] = [];
197
- const queue: string[] = [""]; // "" = repo root
198
- const MAX_DIRS = 30;
199
- const MAX_BLOBS = 200;
200
- let dirs = 0;
201
- while (queue.length > 0 && dirs < MAX_DIRS && out.length < MAX_BLOBS) {
202
- const dir = queue.shift()!;
203
- dirs++;
204
- const pathParam = dir ? `&path=${encodeURIComponent(dir)}` : "";
205
- for (let page = 1; page <= 5; page++) {
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /**
271
+ * Non-recursive BFS over a bounded directory frontier. GitLab's recursive tree
272
+ * API lists ALL directories before any blobs for large repos (#518), so this
273
+ * walks directories breadth-first to ensure root blobs appear on the first
274
+ * request regardless of how many subdirectories follow. `seedRootPage1`, when
275
+ * given, is the root's page-1 entries already fetched by the size probe below
276
+ * — reused here so the small-repo path never double-fetches it.
277
+ */
278
+ async function gitlabBfsWalk(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number, seedRootPage1?: Array<{ path: string; type: string }>): Promise<TreeEntry[]> {
279
+ const out: TreeEntry[] = [];
280
+ const queue: string[] = [""]; // "" = repo root
281
+ const MAX_DIRS = 30;
282
+ const MAX_BLOBS = 200;
283
+ let dirs = 0;
284
+ while (queue.length > 0 && dirs < MAX_DIRS && out.length < MAX_BLOBS) {
285
+ const dir = queue.shift()!;
286
+ dirs++;
287
+ const pathParam = dir ? `&path=${encodeURIComponent(dir)}` : "";
288
+ for (let page = 1; page <= 5; page++) {
289
+ let body: unknown;
290
+ if (dir === "" && page === 1 && seedRootPage1) {
291
+ body = seedRootPage1;
292
+ } else {
206
293
  const url = `${host.api}/projects/${projectId(owner, repo)}/repository/tree?per_page=100&page=${page}&ref=${encodeURIComponent(ref)}${pathParam}`;
207
- const { body } = await getJsonAt(url, headers, doFetch, ms);
208
- if (!Array.isArray(body) || body.length === 0) break;
209
- for (const e of body as Array<{ path: string; type: string }>) {
210
- if (e.type === "blob") out.push({ path: e.path, type: "blob" });
211
- else if (e.type === "tree") queue.push(e.path);
212
- }
213
- if (body.length < 100) break;
294
+ ({ body } = await getJsonAt(url, headers, doFetch, ms));
295
+ }
296
+ if (!Array.isArray(body) || body.length === 0) break;
297
+ for (const e of body as Array<{ path: string; type: string }>) {
298
+ if (e.type === "blob") out.push({ path: e.path, type: "blob" });
299
+ else if (e.type === "tree") queue.push(e.path);
214
300
  }
301
+ if (body.length < 100) break;
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /**
308
+ * GitLab tree discovery: a cheap size probe (the root's first tree page, which
309
+ * every path below needs anyway) decides walk vs. search. GitLab's own
310
+ * recursive-tree endpoint can't be used to size the repo up front — that's the
311
+ * exact pagination trap #518 fixed (directories dominate the page cap before
312
+ * any blob appears) — so "is the root itself large" (a full 100-entry page)
313
+ * stands in for "is the repo large".
314
+ */
315
+ async function listTreeGitLab(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
316
+ const rootUrl = `${host.api}/projects/${projectId(owner, repo)}/repository/tree?per_page=100&page=1&ref=${encodeURIComponent(ref)}`;
317
+ const { body: rootBody } = await getJsonAt(rootUrl, headers, doFetch, ms);
318
+ const rootPage1 = Array.isArray(rootBody) ? (rootBody as Array<{ path: string; type: string }>) : [];
319
+ const isLarge = rootPage1.length >= 100;
320
+
321
+ if (isLarge && "PRIVATE-TOKEN" in headers) {
322
+ // GitLab's search API requires authentication even for public projects, so
323
+ // this branch only ever runs with a token. Any failure — no search access,
324
+ // a rate limit, a transient error — falls back to the walk (#520) rather
325
+ // than failing the audit.
326
+ try {
327
+ return await gitlabSearch(host, owner, repo, ref, doFetch, headers, ms);
328
+ } catch {
329
+ // fall through to the walk below
330
+ }
331
+ }
332
+ return gitlabBfsWalk(host, owner, repo, ref, doFetch, headers, ms, rootPage1);
333
+ }
334
+
335
+ /** One page of GitHub's code search (`GET /search/code`) for a single term. */
336
+ async function githubSearchPage(host: HostConfig, owner: string, repo: string, term: string, page: number, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<string[]> {
337
+ const q = encodeURIComponent(`${term} repo:${owner}/${repo}`);
338
+ const url = `${host.api}/search/code?q=${q}&per_page=100&page=${page}`;
339
+ const body = await getSearchJsonAt(url, { ...headers, Accept: "application/vnd.github+json" }, doFetch, ms);
340
+ const items = (body as { items?: Array<{ path?: string }> } | null)?.items;
341
+ if (!Array.isArray(items)) return [];
342
+ return items.map((e) => e.path).filter((p): p is string => typeof p === "string");
343
+ }
344
+
345
+ /**
346
+ * GitHub code search across the content-detected lexicons. Rate-limited to
347
+ * ~10 req/min unauthenticated (#520) — a 403/422/429 throws (via `getSearchJsonAt`)
348
+ * and the caller falls back to the already-fetched full tree.
349
+ */
350
+ async function githubCodeSearch(host: HostConfig, owner: string, repo: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
351
+ const out: TreeEntry[] = [];
352
+ const seen = new Set<string>();
353
+ for (const { term } of CONTENT_SEARCH_TERMS) {
354
+ for (let page = 1; page <= MAX_SEARCH_PAGES; page++) {
355
+ const hits = await githubSearchPage(host, owner, repo, term, page, doFetch, headers, ms);
356
+ if (hits.length === 0) break;
357
+ for (const p of hits) if (!seen.has(p)) { seen.add(p); out.push({ path: p, type: "blob" }); }
358
+ if (hits.length < 100) break;
215
359
  }
216
- return out;
217
360
  }
218
- // GitHub / Forgejo (Gitea) share the git/trees recursive API.
361
+ return out;
362
+ }
363
+
364
+ /**
365
+ * One page of a Forgejo/Gitea repo code search. The shape here mirrors Gitea's
366
+ * `{ok, data}` search envelope (also used by `GET /repos/search`), tolerating
367
+ * a bare array too — Forgejo's code-search REST surface (and whether an
368
+ * instance even runs a code indexer) isn't consistently documented across
369
+ * versions (#520), so this is best-effort: any unexpected shape/status throws
370
+ * and the caller falls back to the full tree, never breaking the audit.
371
+ */
372
+ async function forgejoSearchPage(host: HostConfig, owner: string, repo: string, term: string, page: number, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<string[]> {
373
+ const url = `${host.api}/repos/${owner}/${repo}/search?q=${encodeURIComponent(term)}&page=${page}&limit=100`;
374
+ const body = await getSearchJsonAt(url, headers, doFetch, ms);
375
+ const data = Array.isArray(body) ? body : (body as { data?: unknown } | null)?.data;
376
+ if (!Array.isArray(data)) return [];
377
+ return (data as Array<{ path?: string }>).map((e) => e.path).filter((p): p is string => typeof p === "string");
378
+ }
379
+
380
+ /** Forgejo/Gitea code search across the content-detected lexicons. See `forgejoSearchPage`. */
381
+ async function forgejoCodeSearch(host: HostConfig, owner: string, repo: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
382
+ const out: TreeEntry[] = [];
383
+ const seen = new Set<string>();
384
+ for (const { term } of CONTENT_SEARCH_TERMS) {
385
+ for (let page = 1; page <= MAX_SEARCH_PAGES; page++) {
386
+ const hits = await forgejoSearchPage(host, owner, repo, term, page, doFetch, headers, ms);
387
+ if (hits.length === 0) break;
388
+ for (const p of hits) if (!seen.has(p)) { seen.add(p); out.push({ path: p, type: "blob" }); }
389
+ if (hits.length < 100) break;
390
+ }
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /**
396
+ * GitHub/Forgejo tree discovery: their `git/trees?recursive=1` API returns the
397
+ * whole tree in one call (no GitLab-style ordering trap), so the entry count
398
+ * from that single call is a free large-repo signal. Below the threshold,
399
+ * behavior is unchanged — the full blob list, later filtered by
400
+ * `isCandidatePath`. Above it, known-path lexicons (CI) are kept straight from
401
+ * the tree we already have — free, no search needed — and the content-detected
402
+ * lexicons are found by search instead of downloaded wholesale.
403
+ */
404
+ async function listTreeGitHubLike(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
219
405
  const url = `${host.api}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
220
406
  const { body } = await getJsonAt(url, headers, doFetch, ms);
221
- const tree = (body as { tree?: TreeEntry[] } | null)?.tree;
407
+ const treeBody = body as { tree?: TreeEntry[]; truncated?: boolean } | null;
408
+ const tree = treeBody?.tree;
222
409
  if (!Array.isArray(tree)) return [];
223
- return tree.filter((e) => e.type === "blob").map((e) => ({ path: e.path, type: "blob", size: e.size }));
410
+ const blobs = tree.filter((e) => e.type === "blob").map((e) => ({ path: e.path, type: "blob", size: e.size }));
411
+
412
+ const isLarge = blobs.length > LARGE_REPO_TREE_ENTRIES || treeBody?.truncated === true;
413
+ if (!isLarge) return blobs;
414
+
415
+ const { ciLexiconForPath } = await import("./discover");
416
+ const known = blobs.filter((e) => ciLexiconForPath(e.path));
417
+ try {
418
+ const hits = host.kind === "github"
419
+ ? await githubCodeSearch(host, owner, repo, doFetch, headers, ms)
420
+ : await forgejoCodeSearch(host, owner, repo, doFetch, headers, ms);
421
+ const knownPaths = new Set(known.map((e) => e.path));
422
+ return [...known, ...hits.filter((h) => !knownPaths.has(h.path))];
423
+ } catch {
424
+ // Search unavailable/errored/rate-limited — degrade to the full tree we
425
+ // already have, filtered by isCandidatePath downstream, same as a small
426
+ // repo (#520: search is additive, never load-bearing).
427
+ return blobs;
428
+ }
429
+ }
430
+
431
+ /** List blob paths worth considering for download (all lexicons), choosing walk vs. search-first per host (#520). */
432
+ async function listTree(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
433
+ if (host.kind === "gitlab") return listTreeGitLab(host, owner, repo, ref, doFetch, headers, ms);
434
+ return listTreeGitHubLike(host, owner, repo, ref, doFetch, headers, ms);
224
435
  }
225
436
 
226
437
  /**
@@ -87,7 +87,10 @@ function renderNeedsReview(clusters: GuidanceCluster[], n: number): string {
87
87
  const rules = c.rules
88
88
  .map(({ meta, findings }) => {
89
89
  const locs = findings
90
- .map((f) => `<li><code>${esc(f.file)}</code>${f.entity ? ` (<code>${esc(f.entity)}</code>)` : ""} — ${esc(f.message)}</li>`)
90
+ .map((f) => {
91
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
92
+ return `<li><code>${esc(loc)}</code>${f.entity ? ` (<code>${esc(f.entity)}</code>)` : ""} — ${esc(f.message)}</li>`;
93
+ })
91
94
  .join("");
92
95
  return `<div class="rule"><div><span class="sev ${findings[0].severity}"></span><strong>${ruleLink(meta.id)}</strong> — ${esc(meta.title)}. <span class="muted">${esc(meta.remediation)}</span>${authorityLinks(meta)}</div><ul>${locs}</ul></div>`;
93
96
  })
@@ -108,7 +111,7 @@ function renderReportOnly(findings: EnrichedFinding[], n: number): string {
108
111
  function renderHeader(counts: ReportCounts, snapshot: AuditSnapshot | undefined, notes: string[]): string {
109
112
  const sev = `<span class="sev error"></span>${counts.errors} error <span class="sev warning"></span>${counts.warnings} warning <span class="sev info"></span>${counts.infos} info`;
110
113
  const tiers = `<span class="chip">${counts.quickWin} quick-win</span> <span class="chip">${counts.needsReview} needs-review</span> <span class="chip">${counts.reportOnly} hygiene</span>`;
111
- const cats = `<span class="chip">${counts.security} security</span> <span class="chip">${counts.correctness} correctness</span> <span class="chip">${counts.bestPractice} best-practice</span>`;
114
+ const cats = `<span class="chip">${counts.security} security</span> <span class="chip">${counts.correctness} correctness</span> <span class="chip">${counts.bestPractice} best-practice</span> <span class="chip">${counts.efficiency} efficiency</span>`;
112
115
  const meta: string[] = [];
113
116
  if (snapshot) {
114
117
  if (snapshot.host) meta.push(esc(snapshot.host));
@@ -62,6 +62,8 @@ export interface ReportCounts {
62
62
  security: number;
63
63
  correctness: number;
64
64
  bestPractice: number;
65
+ /** Waste findings (#444) — excluded from `security`/`correctness` by construction (disjoint categories). */
66
+ efficiency: number;
65
67
  }
66
68
 
67
69
  export interface ReportModel {
@@ -92,6 +94,10 @@ export interface SerializedFinding {
92
94
  message: string;
93
95
  file: string;
94
96
  entity?: string;
97
+ /** 1-based line within `file`, when the finding can pin one (e.g. secrets detection, #443). */
98
+ line?: number;
99
+ /** Redaction-safe fingerprint of a flagged value, when applicable (see `secrets.ts`). */
100
+ fingerprint?: string;
95
101
  lexicon: string;
96
102
  tier: Tier;
97
103
  fixKind: FixKind;
@@ -265,6 +271,7 @@ export function buildReportModel(findings: AuditFinding[], opts: BuildModelOptio
265
271
  security: shown.filter((f) => f.meta.category === "security").length,
266
272
  correctness: shown.filter((f) => f.meta.category === "correctness").length,
267
273
  bestPractice: shown.filter((f) => f.meta.category === "best-practice").length,
274
+ efficiency: shown.filter((f) => f.meta.category === "efficiency").length,
268
275
  };
269
276
 
270
277
  return {
@@ -295,6 +302,8 @@ export function buildReportJson(
295
302
  message: f.message,
296
303
  file: f.file,
297
304
  entity: f.entity,
305
+ line: f.line,
306
+ fingerprint: f.fingerprint,
298
307
  lexicon: f.lexicon,
299
308
  tier: f.meta.tier,
300
309
  fixKind: f.meta.fixKind,
@@ -127,6 +127,28 @@ describe("renderMarkdown — reworked structure", () => {
127
127
  });
128
128
  });
129
129
 
130
+ describe("efficiency dimension (#444)", () => {
131
+ test("buildReportModel tallies efficiency separately, excluded from security/correctness", async () => {
132
+ const { buildReportModel } = await import("./report-model");
133
+ const findings: AuditFinding[] = [
134
+ { checkId: "GHA063", severity: "info", message: "no cache.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" }, // efficiency
135
+ { checkId: "GHA021", severity: "warning", message: "unpinned checkout.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" }, // security
136
+ { checkId: "GHA028", severity: "error", message: "no triggers.", file: ".github/workflows/ci.yml", lexicon: "github" }, // correctness
137
+ ];
138
+ const { counts } = buildReportModel(findings, { catalog: CATALOG });
139
+ expect(counts.efficiency).toBe(1);
140
+ expect(counts.security).toBe(1);
141
+ expect(counts.correctness).toBe(1);
142
+ // the efficiency finding must not have leaked into either tally
143
+ expect(counts.security + counts.correctness + counts.bestPractice + counts.efficiency).toBe(counts.total);
144
+ });
145
+
146
+ test("markdown report surfaces the efficiency tally in the By category line", () => {
147
+ const out = md([{ checkId: "GHA063", severity: "info", message: "no cache.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" }]);
148
+ expect(out).toContain("By category: 0 security, 0 correctness, 0 best-practice, 1 efficiency.");
149
+ });
150
+ });
151
+
130
152
  describe("catalog threading (#687)", () => {
131
153
  test("buildReportModel uses a passed-in catalog over core's static one", async () => {
132
154
  const { buildReportModel } = await import("./report-model");
@@ -72,7 +72,8 @@ function renderNeedsReview(clusters: GuidanceCluster[]): string[] {
72
72
  for (const { meta, findings } of cluster.rules) {
73
73
  lines.push(`- **${ruleLink(meta.id)}** — ${meta.title} (${findings[0].severity}). ${meta.remediation}${authorityLinks(meta)}`);
74
74
  for (const f of findings) {
75
- const where = f.entity ? `\`${f.file}\` (\`${f.entity}\`)` : `\`${f.file}\``;
75
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
76
+ const where = f.entity ? `\`${loc}\` (\`${f.entity}\`)` : `\`${loc}\``;
76
77
  lines.push(` - ${where} — ${escapeCell(f.message)}`);
77
78
  }
78
79
  }
@@ -110,7 +111,7 @@ export function renderMarkdown(findings: AuditFinding[], opts: RenderOptions = {
110
111
  `${counts.quickWin} quick-win, ${counts.needsReview} needs-review, ${counts.reportOnly} report-only ` +
111
112
  `(${counts.errors} error, ${counts.warnings} warning, ${counts.infos} info).`,
112
113
  "",
113
- `By category: ${counts.security} security, ${counts.correctness} correctness, ${counts.bestPractice} best-practice.`,
114
+ `By category: ${counts.security} security, ${counts.correctness} correctness, ${counts.bestPractice} best-practice, ${counts.efficiency} efficiency.`,
114
115
  "",
115
116
  );
116
117
 
@@ -21,6 +21,8 @@ const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
21
21
  { heading: "GCP Config Connector (WGC)", prefixes: ["WGC"], blurb: "Run against Config Connector (cnrm.cloud.google.com) manifests." },
22
22
  { heading: "Helm (WHM)", prefixes: ["WHM"], blurb: "Run against Helm charts (Chart.yaml + templates)." },
23
23
  { heading: "fountain (FTN)", prefixes: ["FTN"], blurb: "Run against fountain manifests (`apiVersion: fountain.dev/v1`) — standalone `fountain apply` YAML is parsed back into the entity graph, so the same rules fire on `chant build` and `chant audit`." },
24
+ { heading: "Secrets & credentials (SEC)", prefixes: ["SEC"], blurb: "Lexicon-independent — scans the raw text of every scanned file for likely credentials, regardless of which audit lexicons are installed. Matched values are always redacted; see [suppressing false positives](/chant/cli/audit/#suppressing-a-secrets-finding)." },
25
+ { heading: "Wrangler config (WRG)", prefixes: ["WRG"], blurb: "Lexicon-independent, audit-only (#446) — scans `wrangler.toml`, Cloudflare Workers' native deploy config, which the engine cannot otherwise parse (it is not YAML/JSON). No authoring surface: chant does not write Wrangler config, it only reads it for these checks." },
24
26
  ];
25
27
 
26
28
  /**