@kirrosh/zond 0.21.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 +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  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 +55 -6
  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 +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  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 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  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 -21
  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
@@ -8,6 +8,14 @@ export interface ResponseInfo {
8
8
 
9
9
  export interface EndpointInfo {
10
10
  path: string;
11
+ /** ARV-183: original spec path before ARV-40 path-param disambiguation
12
+ * renamed `{id}` โ†’ `{<resource>_id}`. Set only when a rename happened;
13
+ * unset means `path` is the original. Used by checks that look up
14
+ * `doc.paths[...]` by string equality (status_code_conformance,
15
+ * response_headers_conformance) โ€” without this they miss the spec
16
+ * entry and either fire phantom findings (status_code) or silently
17
+ * skip (response_headers). */
18
+ originalPath?: string;
11
19
  method: string;
12
20
  operationId?: string;
13
21
  summary?: string;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `.identity.yaml` โ€” gitignored flat YAML file holding non-secret-but-
3
+ * personally-identifying values for an API (TASK-174, m-10).
4
+ *
5
+ * # apis/<name>/.identity.yaml
6
+ * organization_id_or_slug: "acme-eng"
7
+ * member_id: "12345"
8
+ *
9
+ * # apis/<name>/.env.yaml
10
+ * organization_id_or_slug: "@identity:organization_id_or_slug"
11
+ * auth_token: "@secret:auth_token"
12
+ *
13
+ * Mental model:
14
+ * - `.secrets.yaml` โ†’ values auto-registered with SecretRegistry,
15
+ * replaced with `<redacted:<name>>` in every persisted artifact.
16
+ * - `.identity.yaml` โ†’ values are visible locally and visible in
17
+ * case-study drafts by default. The opt-in `--redact-identity`
18
+ * flag (TASK-173) swaps them for placeholders when sharing
19
+ * outbound. Doctor shows them as plain text.
20
+ *
21
+ * The file is git-invisible (gitignore is amended by setup-api) so a
22
+ * teammate forking the repo doesn't accidentally inherit your org slug.
23
+ */
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+
28
+ const IDENTITY_FILENAME = ".identity.yaml";
29
+ const IDENTITY_REF_RE = /^@identity:([A-Za-z_][A-Za-z0-9_.-]*)$/;
30
+
31
+ /** Canonical identity-key vocabulary. The setup-api seeder uses this to
32
+ * decide which placeholders to put in a fresh `.identity.yaml`. */
33
+ export const CANONICAL_IDENTITY_KEYS = new Set<string>([
34
+ "organization_id_or_slug",
35
+ "organization_slug",
36
+ "organization_id",
37
+ "member_id",
38
+ "user_id",
39
+ "project_id_or_slug",
40
+ "project_slug",
41
+ "project_id",
42
+ "team_slug",
43
+ "team_id",
44
+ "account_id",
45
+ ]);
46
+
47
+ export interface IdentityFile {
48
+ filePath: string;
49
+ values: Record<string, string>;
50
+ }
51
+
52
+ export function loadIdentityFile(dir: string): IdentityFile | null {
53
+ const filePath = join(dir, IDENTITY_FILENAME);
54
+ if (!existsSync(filePath)) return null;
55
+ const text = readFileSync(filePath, "utf-8");
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = (Bun as any).YAML.parse(text);
59
+ } catch (err) {
60
+ throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`);
61
+ }
62
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
63
+ throw new Error(`${filePath} must contain a flat YAML object of key: "value" entries`);
64
+ }
65
+ const values: Record<string, string> = {};
66
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
67
+ if (v == null) continue;
68
+ if (typeof v === "object") {
69
+ throw new Error(`${filePath}: nested values are not supported (key "${k}").`);
70
+ }
71
+ values[k] = String(v);
72
+ }
73
+ return { filePath, values };
74
+ }
75
+
76
+ export function loadIdentityFromAncestor(start: string, stopAt?: string): IdentityFile | null {
77
+ let dir = start;
78
+ for (let i = 0; i < 8; i++) {
79
+ const file = loadIdentityFile(dir);
80
+ if (file) return file;
81
+ if (stopAt && dir === stopAt) return null;
82
+ const parent = dirname(dir);
83
+ if (parent === dir) return null;
84
+ dir = parent;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Replace every value from an identity map with `<identity:<key>>`
91
+ * inside `text`. Used by `--redact-identity` (TASK-173). Mirrors the
92
+ * SecretRegistry's logic โ€” longest values first so a containing value
93
+ * wins, minimum length 2 (identity slugs can be short like `acme`).
94
+ */
95
+ export function redactIdentityIn(text: string, values: Record<string, string>): string {
96
+ if (!text || Object.keys(values).length === 0) return text;
97
+ const entries = Object.entries(values)
98
+ .filter(([, v]) => typeof v === "string" && v.length >= 2)
99
+ .sort((a, b) => b[1].length - a[1].length);
100
+ let out = text;
101
+ for (const [name, value] of entries) {
102
+ if (out.indexOf(value) === -1) continue;
103
+ out = out.split(value).join(`<identity:${name}>`);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ export function resolveIdentityRefs(
109
+ envValues: Record<string, string>,
110
+ identity: IdentityFile | null,
111
+ filePath: string,
112
+ ): Record<string, string> {
113
+ const out: Record<string, string> = { ...envValues };
114
+ for (const [k, v] of Object.entries(out)) {
115
+ const m = typeof v === "string" ? v.match(IDENTITY_REF_RE) : null;
116
+ if (!m) continue;
117
+ const refName = m[1]!;
118
+ const value = identity?.values[refName];
119
+ if (value == null) {
120
+ const where = identity ? identity.filePath : `${dirname(filePath)}/${IDENTITY_FILENAME}`;
121
+ throw new Error(
122
+ `${filePath}: key "${k}" references @identity:${refName} but no such entry exists in ${where}. ` +
123
+ `Add \`${refName}: "<value>"\` to ${where} (or remove the @identity: prefix to use a literal value).`,
124
+ );
125
+ }
126
+ out[k] = value;
127
+ }
128
+ return out;
129
+ }
@@ -0,0 +1,28 @@
1
+ import type { RuleId } from "./types.ts";
2
+
3
+ /**
4
+ * Cross-reference: which other zond commands are made noisier or less reliable
5
+ * by an unfixed issue of each rule. Surfaced in JSON output as `affects[]` so
6
+ * agents and IDEs can predict which probe runs will produce false-positive 5xx
7
+ * or which `--validate-schema` checks will silently no-op.
8
+ */
9
+ export const RULE_AFFECTS: Record<RuleId, string[]> = {
10
+ // Group A โ€” lax examples mislead generators and downstream consumers.
11
+ A1: ["run:--validate-schema", "generate"],
12
+ A2: ["run:--validate-schema", "generate"],
13
+ A3: ["run:--validate-schema", "generate"],
14
+ A4: ["run:--validate-schema", "generate"],
15
+ A5: ["generate"],
16
+ A6: [],
17
+
18
+ // Group B โ€” loose schema lets the spec accept what the server rejects.
19
+ B1: ["probe-validation:invalid-path-uuid", "probe-methods"],
20
+ B2: ["probe-validation:invalid-path-uuid"],
21
+ B3: ["probe-validation:boundary-string"],
22
+ B4: ["probe-validation:boundary-string"],
23
+ B5: ["run:--validate-schema"],
24
+ B6: ["run:--validate-schema"],
25
+ B7: ["run:--validate-schema"],
26
+ B8: ["probe-mass-assignment"],
27
+ B9: ["probe-validation:missing-required"],
28
+ };
@@ -0,0 +1,96 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import type { LintConfig, RuleId, RuleSetting } from "./types.ts";
3
+ import { ALL_RULES, DEFAULT_HEURISTICS, DEFAULT_SEVERITY } from "./types.ts";
4
+
5
+ export function defaultConfig(): LintConfig {
6
+ const rules: Partial<Record<RuleId, RuleSetting>> = {};
7
+ for (const r of ALL_RULES) rules[r] = DEFAULT_SEVERITY[r];
8
+ return {
9
+ rules,
10
+ heuristics: { ...DEFAULT_HEURISTICS },
11
+ ignore_paths: [],
12
+ };
13
+ }
14
+
15
+ interface RawConfig {
16
+ rules?: Record<string, string>;
17
+ heuristics?: Partial<typeof DEFAULT_HEURISTICS>;
18
+ ignore_paths?: string[];
19
+ }
20
+
21
+ /**
22
+ * Merge order: defaults โ†’ file config (.zond-lint.json) โ†’ CLI --rule overrides.
23
+ * --rule format: comma-separated `R1` (enable at default severity), `!R1`
24
+ * (disable), or `R1=high|medium|low` (set severity).
25
+ */
26
+ export function loadConfig(opts: {
27
+ configPath?: string;
28
+ cliRule?: string;
29
+ includePaths?: string[];
30
+ maxIssues?: number;
31
+ }): LintConfig {
32
+ const cfg = defaultConfig();
33
+
34
+ if (opts.configPath) {
35
+ if (!existsSync(opts.configPath)) {
36
+ throw new Error(`Config file not found: ${opts.configPath}`);
37
+ }
38
+ const raw = JSON.parse(readFileSync(opts.configPath, "utf8")) as RawConfig;
39
+ if (raw.rules) {
40
+ for (const [rule, val] of Object.entries(raw.rules)) {
41
+ if (!ALL_RULES.includes(rule as RuleId)) continue;
42
+ cfg.rules[rule as RuleId] = normaliseSetting(val);
43
+ }
44
+ }
45
+ if (raw.heuristics) cfg.heuristics = { ...cfg.heuristics, ...raw.heuristics };
46
+ if (raw.ignore_paths) cfg.ignore_paths = raw.ignore_paths;
47
+ }
48
+
49
+ if (opts.cliRule) {
50
+ for (const tok of opts.cliRule.split(",").map(s => s.trim()).filter(Boolean)) {
51
+ if (tok.startsWith("!")) {
52
+ const r = tok.slice(1) as RuleId;
53
+ if (ALL_RULES.includes(r)) cfg.rules[r] = "off";
54
+ } else if (tok.includes("=")) {
55
+ const [r, sev] = tok.split("=") as [RuleId, string];
56
+ if (ALL_RULES.includes(r)) cfg.rules[r] = normaliseSetting(sev);
57
+ } else {
58
+ const r = tok as RuleId;
59
+ if (ALL_RULES.includes(r)) cfg.rules[r] = DEFAULT_SEVERITY[r];
60
+ }
61
+ }
62
+ }
63
+
64
+ if (opts.includePaths && opts.includePaths.length > 0) cfg.include_paths = opts.includePaths;
65
+ if (opts.maxIssues) cfg.max_issues = opts.maxIssues;
66
+ return cfg;
67
+ }
68
+
69
+ function normaliseSetting(raw: string): RuleSetting {
70
+ const v = raw.toLowerCase();
71
+ if (v === "off" || v === "false" || v === "no") return "off";
72
+ // ARV-255: spec-lint is hygiene โ€” severity capped at LOW/INFO. User
73
+ // overrides via `--rule R=high|medium` are still parsed for back-compat
74
+ // but silently downgraded so the cap is enforced uniformly.
75
+ if (v === "high" || v === "error") return "low";
76
+ if (v === "medium" || v === "warn" || v === "warning") return "low";
77
+ if (v === "low") return "low";
78
+ if (v === "info" || v === "informational") return "info";
79
+ return "off";
80
+ }
81
+
82
+ /**
83
+ * Glob matcher โ€” supports `*` and `**`. Used for `ignore_paths` /
84
+ * `include_paths`.
85
+ */
86
+ export function matchGlob(glob: string, path: string): boolean {
87
+ const re = new RegExp(
88
+ "^" + glob
89
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
90
+ .replace(/\*\*/g, "<<<DSTAR>>>")
91
+ .replace(/\*/g, "[^/]*")
92
+ .replace(/<<<DSTAR>>>/g, ".*") +
93
+ "$",
94
+ );
95
+ return re.test(path);
96
+ }
@@ -0,0 +1,42 @@
1
+ import Ajv from "ajv";
2
+ import addFormats from "ajv-formats";
3
+ import { STRICT_RFC3339_DATE_TIME } from "../runner/schema-validator.ts";
4
+
5
+ const ajv = new Ajv({ strict: false, allErrors: false });
6
+ addFormats(ajv);
7
+ ajv.addFormat("date-time", { type: "string", validate: STRICT_RFC3339_DATE_TIME });
8
+
9
+ const SUPPORTED = new Set([
10
+ "date-time", "date", "time",
11
+ "email", "idn-email",
12
+ "uri", "uri-reference", "url",
13
+ "uuid",
14
+ "ipv4", "ipv6",
15
+ "hostname", "idn-hostname",
16
+ "regex",
17
+ "byte", "binary", "password",
18
+ ]);
19
+
20
+ const cache = new Map<string, (v: unknown) => boolean>();
21
+
22
+ /**
23
+ * True if `value` satisfies the OpenAPI/JSON-Schema `format`. Returns true for
24
+ * unknown formats (we can't say). Returns true for non-string values
25
+ * (OpenAPI's `format` only constrains strings).
26
+ */
27
+ export function validateExampleAgainstFormat(value: unknown, format: string): boolean {
28
+ if (typeof value !== "string") return true;
29
+ if (!SUPPORTED.has(format)) return true;
30
+ if (format === "url") format = "uri";
31
+
32
+ let validator = cache.get(format);
33
+ if (!validator) {
34
+ try {
35
+ validator = ajv.compile({ type: "string", format }) as (v: unknown) => boolean;
36
+ cache.set(format, validator);
37
+ } catch {
38
+ return true;
39
+ }
40
+ }
41
+ return validator(value);
42
+ }
@@ -0,0 +1,94 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+ import type { Issue, RuleId, Severity, LintConfig, LintResult } from "./types.ts";
3
+ import { walk } from "./walker.ts";
4
+ import { matchGlob } from "./config.ts";
5
+ import { runConsistencyRules } from "./rules/consistency.ts";
6
+ import {
7
+ runParamStrictnessRules,
8
+ runResponseStrictnessRules,
9
+ runRequestBodyStrictnessRules,
10
+ runSchemaStrictnessRules,
11
+ } from "./rules/strictness.ts";
12
+ import {
13
+ runParamHeuristics,
14
+ runSchemaHeuristics,
15
+ runRequestBodyHeuristics,
16
+ } from "./rules/heuristics.ts";
17
+ import { RULE_AFFECTS } from "./affects.ts";
18
+
19
+ export type { Issue, LintConfig, LintResult, LintStats, Severity, RuleId } from "./types.ts";
20
+ export { loadConfig, defaultConfig } from "./config.ts";
21
+ export { formatHuman, formatNdjson, formatGrouped, buildRuleSummary } from "./reporter.ts";
22
+ export type { RuleSummaryEntry } from "./reporter.ts";
23
+
24
+ export function lintSpec(doc: OpenAPIV3.Document, config: LintConfig): LintResult {
25
+ const issues: Issue[] = [];
26
+ const endpoints = new Set<string>();
27
+
28
+ const sink = {
29
+ push(rule: RuleId, severity: Severity, message: string, opts: { jsonpointer: string; path?: string; method?: string; fix_hint?: string }) {
30
+ const setting = config.rules[rule];
31
+ if (setting === "off" || setting === undefined) return;
32
+
33
+ // Path-include / ignore filters operate on opts.path when present.
34
+ if (opts.path) {
35
+ if (config.ignore_paths.some(g => matchGlob(g, opts.path!))) return;
36
+ if (config.include_paths && config.include_paths.length > 0
37
+ && !config.include_paths.some(g => matchGlob(g, opts.path!))) return;
38
+ }
39
+
40
+ const issue: Issue = {
41
+ rule,
42
+ severity: setting as Severity,
43
+ jsonpointer: opts.jsonpointer,
44
+ message,
45
+ recommended_action: "fix_spec",
46
+ };
47
+ if (opts.path) issue.path = opts.path;
48
+ if (opts.method && opts.method !== "*") issue.method = opts.method;
49
+ if (opts.fix_hint) issue.fix_hint = opts.fix_hint;
50
+ const aff = RULE_AFFECTS[rule];
51
+ if (aff && aff.length > 0) issue.affects = aff;
52
+
53
+ if (opts.path) endpoints.add(`${opts.method ?? ""} ${opts.path}`);
54
+
55
+ issues.push(issue);
56
+ },
57
+ };
58
+
59
+ walk(doc, ctx => {
60
+ if (config.max_issues && issues.length >= config.max_issues) return;
61
+ switch (ctx.kind) {
62
+ case "parameter":
63
+ runParamStrictnessRules(ctx, sink, config.heuristics);
64
+ runParamHeuristics(ctx, sink, config.heuristics);
65
+ break;
66
+ case "response":
67
+ runResponseStrictnessRules(ctx, sink);
68
+ break;
69
+ case "requestBody":
70
+ runRequestBodyStrictnessRules(ctx, sink);
71
+ runRequestBodyHeuristics(ctx, sink, config.heuristics);
72
+ break;
73
+ case "schema":
74
+ runConsistencyRules(ctx, sink);
75
+ runSchemaStrictnessRules(ctx, sink);
76
+ runSchemaHeuristics(ctx, sink, config.heuristics);
77
+ break;
78
+ }
79
+ });
80
+
81
+ // Trim to max_issues if exceeded mid-walk
82
+ const trimmed = config.max_issues ? issues.slice(0, config.max_issues) : issues;
83
+
84
+ const stats = {
85
+ total: trimmed.length,
86
+ critical: trimmed.filter(i => i.severity === "critical").length,
87
+ high: trimmed.filter(i => i.severity === "high").length,
88
+ medium: trimmed.filter(i => i.severity === "medium").length,
89
+ low: trimmed.filter(i => i.severity === "low").length,
90
+ info: trimmed.filter(i => i.severity === "info").length,
91
+ endpoints: endpoints.size,
92
+ };
93
+ return { issues: trimmed, stats };
94
+ }
@@ -0,0 +1,128 @@
1
+ import type { Issue, LintStats, Severity } from "./types.ts";
2
+ import { severityGlyph, rankSeverity } from "../severity/index.ts";
3
+
4
+ const RED = "\x1b[31m";
5
+ const YELLOW = "\x1b[33m";
6
+ const DIM = "\x1b[2m";
7
+ const BOLD = "\x1b[1m";
8
+ const RESET = "\x1b[0m";
9
+
10
+ const useColor = (): boolean => process.stdout.isTTY === true;
11
+
12
+ const ICON: Record<Severity, string> = {
13
+ critical: "๐Ÿšจ", high: "๐Ÿ”ด", medium: "โš ๏ธ ", low: "โ„น๏ธ ", info: "ยท ",
14
+ };
15
+ const COLOR: Record<Severity, string> = {
16
+ critical: RED, high: RED, medium: YELLOW, low: DIM, info: DIM,
17
+ };
18
+
19
+ export function formatHuman(issues: Issue[], stats: LintStats): string {
20
+ if (issues.length === 0) {
21
+ return useColor() ? `${BOLD}โœ“ no issues${RESET}\n` : "โœ“ no issues\n";
22
+ }
23
+ const groups: Record<Severity, Issue[]> = {
24
+ critical: [], high: [], medium: [], low: [], info: [],
25
+ };
26
+ for (const i of issues) groups[i.severity].push(i);
27
+
28
+ const lines: string[] = [];
29
+ for (const sev of ["critical", "high", "medium", "low", "info"] as Severity[]) {
30
+ const g = groups[sev];
31
+ if (g.length === 0) continue;
32
+ const header = `${ICON[sev]} ${sev.toUpperCase()} (${g.length})`;
33
+ lines.push(useColor() ? `${COLOR[sev]}${BOLD}${header}${RESET}` : header);
34
+ for (const i of g) {
35
+ const where = formatWhere(i);
36
+ const tail = useColor() ? `${DIM}(${i.rule})${RESET}` : `(${i.rule})`;
37
+ lines.push(` ${where} ${i.message} ${tail}`);
38
+ if (i.fix_hint) {
39
+ lines.push(useColor() ? ` ${DIM}โ†’ ${i.fix_hint}${RESET}` : ` โ†’ ${i.fix_hint}`);
40
+ }
41
+ }
42
+ lines.push("");
43
+ }
44
+ lines.push(`${stats.total} issue(s) across ${stats.endpoints} endpoint(s)`);
45
+ return lines.join("\n") + "\n";
46
+ }
47
+
48
+ function formatWhere(i: Issue): string {
49
+ if (i.path && i.method && i.method !== "*") return `${i.method} ${i.path}`;
50
+ if (i.path) return i.path;
51
+ return i.jsonpointer;
52
+ }
53
+
54
+ export function formatNdjson(issues: Issue[]): string {
55
+ return issues.map(i => JSON.stringify(i)).join("\n") + (issues.length ? "\n" : "");
56
+ }
57
+
58
+ /**
59
+ * TASK-279: rule ร— severity rollup. The flat `formatHuman` output has a habit
60
+ * of producing 700+ lines on real-world specs (one large SaaS spec we
61
+ * benchmarked had 385 of 714 issues from a single rule). This collapses
62
+ * them to one row per rule so a human can
63
+ * triage by impact instead of `grep '(B1)' | wc -l`.
64
+ */
65
+ export interface RuleSummaryEntry {
66
+ rule: string;
67
+ severity: Severity;
68
+ count: number;
69
+ endpoints: number;
70
+ message: string;
71
+ sample?: { method?: string; path?: string; jsonpointer?: string };
72
+ }
73
+
74
+ export function buildRuleSummary(issues: Issue[]): RuleSummaryEntry[] {
75
+ type Bucket = { rule: string; severity: Severity; count: number; endpointSet: Set<string>; message: string; sample?: RuleSummaryEntry["sample"] };
76
+ const map = new Map<string, Bucket>();
77
+ for (const i of issues) {
78
+ const key = `${i.rule}|${i.severity}`;
79
+ let b = map.get(key);
80
+ if (!b) {
81
+ b = { rule: i.rule, severity: i.severity, count: 0, endpointSet: new Set(), message: i.message };
82
+ if (i.path || i.method || i.jsonpointer) {
83
+ b.sample = { method: i.method, path: i.path, jsonpointer: i.jsonpointer };
84
+ }
85
+ map.set(key, b);
86
+ }
87
+ b.count++;
88
+ if (i.path) b.endpointSet.add(`${i.method ?? "*"} ${i.path}`);
89
+ else if (i.jsonpointer) b.endpointSet.add(i.jsonpointer);
90
+ }
91
+ return [...map.values()]
92
+ .sort((a, b) => rankSeverity(a.severity) - rankSeverity(b.severity) || b.count - a.count)
93
+ .map(b => ({
94
+ rule: b.rule,
95
+ severity: b.severity,
96
+ count: b.count,
97
+ endpoints: b.endpointSet.size,
98
+ message: b.message,
99
+ ...(b.sample ? { sample: b.sample } : {}),
100
+ }));
101
+ }
102
+
103
+ export function formatGrouped(issues: Issue[], stats: LintStats, opts: { top?: number } = {}): string {
104
+ if (issues.length === 0) {
105
+ return useColor() ? `${BOLD}โœ“ no issues${RESET}\n` : "โœ“ no issues\n";
106
+ }
107
+ const summary = buildRuleSummary(issues);
108
+ const rows = opts.top != null && opts.top > 0 ? summary.slice(0, opts.top) : summary;
109
+
110
+ const lines: string[] = [];
111
+ let lastSev: Severity | null = null;
112
+ for (const r of rows) {
113
+ if (r.severity !== lastSev) {
114
+ const header = `${ICON[r.severity]} ${r.severity.toUpperCase()}`;
115
+ lines.push(useColor() ? `${COLOR[r.severity]}${BOLD}${header}${RESET}` : header);
116
+ lastSev = r.severity;
117
+ }
118
+ const tag = useColor() ? `${COLOR[r.severity]}${r.rule}${RESET}` : r.rule;
119
+ const endpointsLabel = r.endpoints === r.count ? `${r.count}` : `${r.count} (${r.endpoints} endpoints)`;
120
+ lines.push(` ${tag.padEnd(useColor() ? 14 : 4)} ${endpointsLabel.padStart(6)} ${r.message}`);
121
+ }
122
+ lines.push("");
123
+ const truncated = opts.top != null && opts.top > 0 && summary.length > rows.length
124
+ ? ` (showing top ${rows.length} of ${summary.length} rules; pass --top 0 or --verbose for all)`
125
+ : "";
126
+ lines.push(`${stats.total} issue(s) across ${stats.endpoints} endpoint(s)${truncated}. Re-run with --verbose for the flat list.`);
127
+ return lines.join("\n") + "\n";
128
+ }