@kirrosh/zond 0.22.0 → 0.23.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 (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Unified severity matrix (ARV-250, m-21 pivot).
3
+ *
4
+ * Single source of truth for severity classification across all
5
+ * finding-producing subsystems (lint, checks, probes). Replaces three
6
+ * divergent ladders (lint 3-tier, checks 4-tier, probes 4-tier).
7
+ *
8
+ * Principle: **no evidence — no high severity**. Severity reflects
9
+ * proven impact, not anomaly presence. CRITICAL exists in the type
10
+ * but is reserved for end-to-end exploit chains; producers without
11
+ * such chains must NOT emit it.
12
+ */
13
+
14
+ export type Severity = "critical" | "high" | "medium" | "low" | "info";
15
+
16
+ export const SEVERITY_ORDER: readonly Severity[] = [
17
+ "critical",
18
+ "high",
19
+ "medium",
20
+ "low",
21
+ "info",
22
+ ] as const;
23
+
24
+ const SEVERITY_RANK: Record<Severity, number> = {
25
+ critical: 0,
26
+ high: 1,
27
+ medium: 2,
28
+ low: 3,
29
+ info: 4,
30
+ };
31
+
32
+ /**
33
+ * Strength of evidence backing a finding. Drives the severity cap
34
+ * applied at finding-emission time.
35
+ *
36
+ * - `end_to_end`: zond demonstrated the impact itself (read another
37
+ * user's data, executed action without auth, file read confirmed).
38
+ * Required for CRITICAL.
39
+ * - `evidence_chain`: ≥2 requests prove the finding (storage +
40
+ * reflection found, follow-up GET shows persistence, OOB callback
41
+ * received). Required for HIGH.
42
+ * - `single_signal`: one request/response indicates an anomaly but
43
+ * no follow-up confirms impact (server accepted CRLF / 169.254 /
44
+ * is_admin field — outcome unknown). Capped at LOW.
45
+ * - `static`: spec-lint, style, naming, missing additionalProperties.
46
+ * No runtime evidence. Capped at INFO.
47
+ */
48
+ export type ProofKind = "end_to_end" | "evidence_chain" | "single_signal" | "static";
49
+
50
+ const PROOF_CAP: Record<ProofKind, Severity> = {
51
+ end_to_end: "critical",
52
+ evidence_chain: "high",
53
+ single_signal: "low",
54
+ static: "info",
55
+ };
56
+
57
+ /**
58
+ * Caps a claimed severity by the strength of evidence behind it.
59
+ * Producers should pass their natural severity claim and the proof
60
+ * kind; the cap function downgrades if claim exceeds what evidence
61
+ * supports.
62
+ *
63
+ * Example: mass-assignment probe wants HIGH (dangerous field), but
64
+ * only has single-signal proof (server returned 200, didn't verify
65
+ * persistence). Cap returns LOW. To get HIGH, probe must escalate
66
+ * proof to evidence_chain by doing follow-up GET.
67
+ */
68
+ export function capSeverityByProof(claim: Severity, proof: ProofKind): Severity {
69
+ const cap = PROOF_CAP[proof];
70
+ return rankSeverity(claim) < rankSeverity(cap) ? cap : claim;
71
+ }
72
+
73
+ export function rankSeverity(s: Severity): number {
74
+ return SEVERITY_RANK[s];
75
+ }
76
+
77
+ /** True iff `a` is at least as severe as `b`. */
78
+ export function isAtLeast(a: Severity, b: Severity): boolean {
79
+ return rankSeverity(a) <= rankSeverity(b);
80
+ }
81
+
82
+ /** Highest severity among inputs; returns 'info' on empty list. */
83
+ export function maxSeverity(items: readonly Severity[]): Severity {
84
+ let best: Severity = "info";
85
+ for (const s of items) {
86
+ if (rankSeverity(s) < rankSeverity(best)) best = s;
87
+ }
88
+ return best;
89
+ }
90
+
91
+ /**
92
+ * Empty severity-bucket map. Use as starting tally; downstream code
93
+ * increments per finding.
94
+ */
95
+ export function emptySeverityBuckets(): Record<Severity, number> {
96
+ return { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
97
+ }
98
+
99
+ /**
100
+ * SARIF level mapping (sarif.ts previously hardcoded 4-tier; this
101
+ * keeps the same external semantics + adds 'info' → 'note').
102
+ */
103
+ export function severityToSarifLevel(s: Severity): "error" | "warning" | "note" {
104
+ if (s === "critical" || s === "high") return "error";
105
+ if (s === "medium") return "warning";
106
+ return "note"; // low + info
107
+ }
108
+
109
+ /**
110
+ * Console glyph for severity. Stable per-glyph keeps fb-loop diff
111
+ * compares clean.
112
+ */
113
+ export function severityGlyph(s: Severity): string {
114
+ switch (s) {
115
+ case "critical": return "🚨";
116
+ case "high": return "🔴";
117
+ case "medium": return "⚠️";
118
+ case "low": return "ℹ️";
119
+ case "info": return "·";
120
+ }
121
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ARV-122 (m-19, blocker-m-18): layered spec model.
3
+ *
4
+ * Today two artifact sources contribute to the resource map under
5
+ * `apis/<name>/`:
6
+ * 1. `.api-resources.yaml` — sha-tracked, regenerated by
7
+ * `refresh-api` from `spec.json` (upstream OpenAPI).
8
+ * 2. `.api-resources.local.yaml` — ARV-111 user extension that
9
+ * survives `refresh-api` (write-only endpoints, common SaaS-style
10
+ * ingest, …).
11
+ *
12
+ * `readResourceMap` in `cli/commands/discover.ts` merges them by hand:
13
+ * extensions win on `resource` name collision. m-18 (quicktype-derived
14
+ * and mitmproxy-derived schema layers) will add two more sources with
15
+ * different precedence and merge semantics — doing that ad-hoc would
16
+ * end the way ad-hoc output-format parsing did (ARV-97 family,
17
+ * resolved by m-19/ARV-116).
18
+ *
19
+ * This module is the same move for spec sources: declare each source
20
+ * once as a `SpecLayer`, with precedence and merge policy, and feed
21
+ * them through `composeSpec()`. The result is a `ComposedSpec` plus a
22
+ * `ProvenanceMap` (`resource → layer.id`) that downstream code can
23
+ * consult to explain *where* a field came from (m-18 follow-ups will
24
+ * surface this in `doctor` / `catalog --provenance`; this task only
25
+ * ships the internal API).
26
+ *
27
+ * Scope of this task: ONLY the resource-map dimension (the actual
28
+ * `ResourceYaml` shape used by every other module). Other dimensions
29
+ * (raw OpenAPI document, fixture manifest) keep their own loaders —
30
+ * generalising further before there's a second consumer would be
31
+ * premature.
32
+ */
33
+
34
+ /** How a layer's entries combine with already-resolved entries. */
35
+ export type MergePolicy =
36
+ /** Layer entries replace lower-precedence entries on key collision. */
37
+ | "override"
38
+ /** Layer entries are dropped if a lower-precedence entry already
39
+ * owns the key. (Useful for "default" layers that fill gaps but
40
+ * shouldn't shadow user input.) */
41
+ | "preserve"
42
+ /** Layer entries are appended even when a key already exists,
43
+ * producing duplicate keys. Reserved for future fixture layers;
44
+ * not used by the current two-layer resource composition. */
45
+ | "append";
46
+
47
+ /** Domain a layer contributes to. Today only `resources` is wired —
48
+ * m-18 will likely add `fixtures` (manifest merge) and `spec`
49
+ * (raw OpenAPI augmentation). Listed up front so layer factories
50
+ * carry the tag at construction. */
51
+ export type LayerScope = "resources" | "fixtures" | "spec";
52
+
53
+ /** A single typed source of spec data. */
54
+ export interface SpecLayer<T> {
55
+ /** Stable identifier surfaced in the provenance map (e.g.,
56
+ * `"upstream"`, `"extension"`, `"quicktype"`). Must be unique
57
+ * inside one `composeSpec` call. */
58
+ id: string;
59
+ /** Source path/URL for diagnostics. Optional — synthetic layers
60
+ * (in-memory test fixtures) can omit. */
61
+ path?: string;
62
+ /** Higher precedence wins on key collisions when `mergePolicy` is
63
+ * `"override"`. Ties: layer ordering in the input array. */
64
+ precedence: number;
65
+ /** Which composition this layer participates in. `composeSpec` does
66
+ * not currently route on scope — it expects callers to filter — but
67
+ * the field makes layer arrays self-describing. */
68
+ scope: LayerScope;
69
+ /** How this layer's entries combine with lower-precedence ones. */
70
+ mergePolicy: MergePolicy;
71
+ /** Load the layer's entries. Async to accommodate filesystem and
72
+ * network sources (an HTTP-fetched OpenAPI snapshot in the future
73
+ * could implement this directly). */
74
+ load: () => Promise<T[]> | T[];
75
+ }
76
+
77
+ /** Function deriving the merge key for an entry. Pulled out so callers
78
+ * can compose resource lists (key = resource name) and, later, fixture
79
+ * lists (key = var name) with the same engine. */
80
+ export type KeyFn<T> = (entry: T) => string;
81
+
82
+ /** Output of `composeSpec`. `entries` is the merged list in stable
83
+ * order (lowest precedence first, with higher-precedence overrides
84
+ * applied in-place); `provenance` maps the merged key back to the
85
+ * layer id that ultimately owns it. */
86
+ export interface ComposedSpec<T> {
87
+ entries: T[];
88
+ provenance: Map<string, string>;
89
+ }
90
+
91
+ /**
92
+ * Compose layers into a single entry list + provenance map.
93
+ *
94
+ * Order of operations:
95
+ * 1. Layers are sorted by ascending `precedence` (low → high). A
96
+ * stable sort is used so ties keep input order.
97
+ * 2. Each layer's entries are loaded in sequence (the loader is
98
+ * async-safe so a slow loader can't block parallel ones — but
99
+ * we keep ordering deterministic by awaiting in turn).
100
+ * 3. Entries are folded into the result Map keyed by `keyFn(entry)`:
101
+ * - `mergePolicy === "override"` → replace; provenance updates.
102
+ * - `mergePolicy === "preserve"` → only set if the key is new.
103
+ * - `mergePolicy === "append"` → key suffixed with `#<n>` to
104
+ * avoid collision; provenance still recorded.
105
+ *
106
+ * The result preserves insertion order of the underlying Map, which
107
+ * is the natural "lowest-precedence-first" view: callers that need a
108
+ * different ordering re-sort downstream.
109
+ */
110
+ export async function composeSpec<T>(
111
+ layers: SpecLayer<T>[],
112
+ keyFn: KeyFn<T>,
113
+ ): Promise<ComposedSpec<T>> {
114
+ const ordered = [...layers].sort((a, b) => a.precedence - b.precedence);
115
+
116
+ const seenIds = new Set<string>();
117
+ for (const l of ordered) {
118
+ if (seenIds.has(l.id)) {
119
+ throw new Error(`composeSpec: duplicate layer id "${l.id}"`);
120
+ }
121
+ seenIds.add(l.id);
122
+ }
123
+
124
+ const merged = new Map<string, T>();
125
+ const provenance = new Map<string, string>();
126
+ let appendCounter = 0;
127
+
128
+ for (const layer of ordered) {
129
+ const entries = await layer.load();
130
+ for (const entry of entries) {
131
+ const key = keyFn(entry);
132
+ switch (layer.mergePolicy) {
133
+ case "override":
134
+ merged.set(key, entry);
135
+ provenance.set(key, layer.id);
136
+ break;
137
+ case "preserve":
138
+ if (!merged.has(key)) {
139
+ merged.set(key, entry);
140
+ provenance.set(key, layer.id);
141
+ }
142
+ break;
143
+ case "append": {
144
+ const tagged = `${key}#${appendCounter++}`;
145
+ merged.set(tagged, entry);
146
+ provenance.set(tagged, layer.id);
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return { entries: Array.from(merged.values()), provenance };
154
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * ARV-249: human-readable duration formatter for ETA / progress lines.
3
+ *
4
+ * Distinct from `core/reporter/console.formatDuration`, which is geared to
5
+ * per-step latencies (sub-second resolution, single unit). Here we drop
6
+ * sub-second precision but emit two units once we cross a minute, so a
7
+ * five-minute ETA reads `5m12s` instead of `5m 12s`.
8
+ */
9
+ export function formatEta(seconds: number): string {
10
+ if (!Number.isFinite(seconds) || seconds < 0) return "?";
11
+ const s = Math.round(seconds);
12
+ if (s < 60) return `${s}s`;
13
+ if (s < 3600) {
14
+ const m = Math.floor(s / 60);
15
+ const rem = s % 60;
16
+ return rem > 0 ? `${m}m${rem}s` : `${m}m`;
17
+ }
18
+ const h = Math.floor(s / 3600);
19
+ const m = Math.floor((s % 3600) / 60);
20
+ return m > 0 ? `${h}h${m}m` : `${h}h`;
21
+ }
package/src/core/utils.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export function getByPath(obj: unknown, path: string, defaultVal?: unknown): unknown {
2
- const keys = path.split(".");
2
+ // Normalize JSONPath-like bracket indexing (`data[0].id`) to dotted form
3
+ // (`data.0.id`) so callers can use either spelling. Numeric segments also
4
+ // index arrays correctly because `array["0"]` is equivalent to `array[0]`.
5
+ const normalized = path.replace(/\[(\d+)\]/g, ".$1").replace(/^\./, "");
6
+ const keys = normalized.split(".");
3
7
  let result: unknown = obj;
4
8
  for (const key of keys) {
5
9
  result = (result as Record<string, unknown>)?.[key];
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Workspace `zond.config.yml` loader (TASK-301).
3
+ *
4
+ * Centralises read-only access to workspace-level defaults. Right now only
5
+ * two fields are honoured:
6
+ *
7
+ * defaults:
8
+ * timeout_ms: 30000 # used by cleanup / prepare-fixtures / probe
9
+ * # mass-assignment / probe security / request
10
+ * rate_limit: 5 # used by `zond run` (number, or "auto")
11
+ *
12
+ * Resolution chain across the CLI (highest wins):
13
+ *
14
+ * CLI flag → per-API .env.yaml meta → workspace defaults → hard-coded fallback
15
+ *
16
+ * Per-API overrides live in `apis/<name>/.env.yaml` as `rateLimit:` /
17
+ * `timeoutMs:` (see `loadEnvMeta`); we deliberately don't carve a second
18
+ * channel into this file to avoid two ways of saying the same thing.
19
+ *
20
+ * Read-once-and-cache: the file is parsed at most once per process from
21
+ * the workspace root resolved by `findWorkspaceRoot`. Tests can call
22
+ * `_resetWorkspaceConfigCache()` between runs.
23
+ */
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { findWorkspaceRoot } from "./root.ts";
28
+
29
+ export interface WorkspaceDefaults {
30
+ /** Per-request timeout in ms applied when the CLI flag and `.env.yaml` are silent. */
31
+ timeoutMs?: number;
32
+ /** Run-time rate limit (rps) or `"auto"`. */
33
+ rateLimit?: number | "auto";
34
+ }
35
+
36
+ let cache: { root: string; defaults: WorkspaceDefaults } | null = null;
37
+
38
+ function parseTimeoutMs(v: unknown): number | undefined {
39
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
40
+ if (typeof v === "string") {
41
+ const n = Number.parseInt(v, 10);
42
+ if (Number.isFinite(n) && n > 0) return n;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function parseRateLimit(v: unknown): number | "auto" | undefined {
48
+ if (v === "auto") return "auto";
49
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
50
+ if (typeof v === "string") {
51
+ if (v === "auto") return "auto";
52
+ const n = Number.parseFloat(v);
53
+ if (Number.isFinite(n) && n > 0) return n;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function readDefaults(configPath: string): WorkspaceDefaults {
59
+ if (!existsSync(configPath)) return {};
60
+ let parsed: unknown;
61
+ try {
62
+ const text = readFileSync(configPath, "utf8");
63
+ parsed = Bun.YAML.parse(text);
64
+ } catch {
65
+ return {};
66
+ }
67
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
68
+ const obj = parsed as Record<string, unknown>;
69
+ const defaultsRaw = obj.defaults;
70
+ if (typeof defaultsRaw !== "object" || defaultsRaw === null || Array.isArray(defaultsRaw)) return {};
71
+ const d = defaultsRaw as Record<string, unknown>;
72
+ const out: WorkspaceDefaults = {};
73
+ const t = parseTimeoutMs(d.timeout_ms ?? d.timeoutMs);
74
+ if (t !== undefined) out.timeoutMs = t;
75
+ const r = parseRateLimit(d.rate_limit ?? d.rateLimit);
76
+ if (r !== undefined) out.rateLimit = r;
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Returns the workspace defaults block, cached for the lifetime of the
82
+ * process. When no workspace marker is found, returns `{}` (no defaults).
83
+ */
84
+ export function loadWorkspaceDefaults(cwd?: string): WorkspaceDefaults {
85
+ const ws = findWorkspaceRoot(cwd);
86
+ if (ws.fromFallback) return {};
87
+ if (cache && cache.root === ws.root) return cache.defaults;
88
+ const defaults = readDefaults(join(ws.root, "zond.config.yml"));
89
+ cache = { root: ws.root, defaults };
90
+ return defaults;
91
+ }
92
+
93
+ /** Test helper: drop the parse cache so the next call re-reads from disk. */
94
+ export function _resetWorkspaceConfigCache(): void {
95
+ cache = null;
96
+ }
97
+
98
+ export const HARD_DEFAULT_TIMEOUT_MS = 30000;
99
+
100
+ /**
101
+ * Resolve `--timeout` via CLI > per-API `.env.yaml` (`timeoutMs`) > workspace
102
+ * `defaults.timeout_ms` > 30000. Each layer accepts `undefined` to mean
103
+ * "fall through".
104
+ */
105
+ export function resolveTimeoutMs(
106
+ cliFlag: number | undefined,
107
+ envMetaTimeout: number | undefined,
108
+ cwd?: string,
109
+ ): number {
110
+ if (cliFlag !== undefined) return cliFlag;
111
+ if (envMetaTimeout !== undefined) return envMetaTimeout;
112
+ const ws = loadWorkspaceDefaults(cwd);
113
+ return ws.timeoutMs ?? HARD_DEFAULT_TIMEOUT_MS;
114
+ }
115
+
116
+ /**
117
+ * Resolve `--rate-limit` via CLI > per-API `.env.yaml` (`rateLimit`) >
118
+ * workspace `defaults.rate_limit` > undefined (no throttling).
119
+ */
120
+ export function resolveRateLimit(
121
+ cliFlag: number | "auto" | undefined,
122
+ envMetaRateLimit: number | "auto" | undefined,
123
+ cwd?: string,
124
+ ): number | "auto" | undefined {
125
+ if (cliFlag !== undefined) return cliFlag;
126
+ if (envMetaRateLimit !== undefined) return envMetaRateLimit;
127
+ const ws = loadWorkspaceDefaults(cwd);
128
+ return ws.rateLimit;
129
+ }