@kirrosh/zond 0.22.0 → 0.26.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -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 +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -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 +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -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 +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. 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;
@@ -20,6 +28,12 @@ export interface EndpointInfo {
20
28
  security: string[];
21
29
  deprecated?: boolean;
22
30
  requiresEtag?: boolean;
31
+ /** ARV-189 (m-21): vendor extensions starting with `x-` from the
32
+ * operation (and merged from the path item — operation wins on key
33
+ * collision). Used by the `x-zond-*` opt-in/skip rules so callers can
34
+ * declare check-level policy directly in the spec without an overlay
35
+ * yaml file. Empty/undefined when the spec carries no extensions. */
36
+ extensions?: Record<string, unknown>;
23
37
  }
24
38
 
25
39
  export interface SecuritySchemeInfo {
@@ -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
+ }
@@ -0,0 +1,158 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+ import type { Issue, RuleId, Severity } from "../types.ts";
3
+ import { DEFAULT_SEVERITY } from "../types.ts";
4
+ import { RULE_AFFECTS } from "../affects.ts";
5
+ import type { SchemaContext } from "../walker.ts";
6
+ import { normalisedTypes } from "../walker.ts";
7
+ import { validateExampleAgainstFormat } from "../format.ts";
8
+
9
+ interface RuleSink {
10
+ push(rule: RuleId, severity: Severity, message: string, opts: Partial<Pick<Issue, "path" | "method" | "fix_hint">> & { jsonpointer: string }): void;
11
+ }
12
+
13
+ export function runConsistencyRules(ctx: SchemaContext, sink: RuleSink): void {
14
+ const s = ctx.schema;
15
+ if (!s || typeof s !== "object") return;
16
+
17
+ const types = normalisedTypes(s);
18
+ const isStringy = types.includes("string");
19
+ const isNumeric = types.includes("number") || types.includes("integer");
20
+
21
+ const examples = collectExamples(s);
22
+ for (const { value, pointer } of examples) {
23
+ checkValueAgainstSchema(value, s, pointer, "example", isStringy, isNumeric, ctx, sink);
24
+ }
25
+
26
+ if (s.default !== undefined) {
27
+ checkValueAgainstSchema(s.default, s, `${ctx.jsonpointer}/default`, "default", isStringy, isNumeric, ctx, sink);
28
+ }
29
+
30
+ // A6: enum values pairwise unique.
31
+ if (Array.isArray(s.enum) && s.enum.length > 0) {
32
+ const seen = new Set<string>();
33
+ let dupAt = -1;
34
+ for (let i = 0; i < s.enum.length; i++) {
35
+ const k = stableKey(s.enum[i]);
36
+ if (seen.has(k)) { dupAt = i; break; }
37
+ seen.add(k);
38
+ }
39
+ if (dupAt >= 0) {
40
+ sink.push("A6", DEFAULT_SEVERITY.A6, `enum has duplicate value at index ${dupAt}`, {
41
+ jsonpointer: `${ctx.jsonpointer}/enum/${dupAt}`,
42
+ path: ctx.path, method: ctx.method,
43
+ fix_hint: "remove the duplicate enum entry",
44
+ });
45
+ }
46
+ }
47
+ }
48
+
49
+ function collectExamples(s: OpenAPIV3.SchemaObject): Array<{ value: unknown; pointer: string }> {
50
+ const out: Array<{ value: unknown; pointer: string }> = [];
51
+ if ((s as { example?: unknown }).example !== undefined) {
52
+ out.push({ value: (s as { example: unknown }).example, pointer: "example" });
53
+ }
54
+ // OpenAPI 3.1 allows examples[]
55
+ const arr = (s as { examples?: unknown }).examples;
56
+ if (Array.isArray(arr)) {
57
+ arr.forEach((v, i) => out.push({ value: v, pointer: `examples/${i}` }));
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function checkValueAgainstSchema(
63
+ value: unknown,
64
+ s: OpenAPIV3.SchemaObject,
65
+ basePointer: string,
66
+ kind: "example" | "default",
67
+ isStringy: boolean,
68
+ isNumeric: boolean,
69
+ ctx: SchemaContext,
70
+ sink: RuleSink,
71
+ ): void {
72
+ // basePointer for example/default is sometimes a relative segment like "example".
73
+ // Prepend ctx.jsonpointer if needed.
74
+ const jsonpointer = basePointer.startsWith("/")
75
+ ? basePointer
76
+ : `${ctx.jsonpointer}/${basePointer}`;
77
+ const ruleA1 = kind === "example" ? "A1" : "A5";
78
+ const ruleA2 = kind === "example" ? "A2" : "A5";
79
+ const ruleA3 = kind === "example" ? "A3" : "A5";
80
+ const ruleA4 = kind === "example" ? "A4" : "A5";
81
+
82
+ // Format check
83
+ const fmt = (s as { format?: string }).format;
84
+ if (fmt && isStringy && typeof value === "string") {
85
+ if (!validateExampleAgainstFormat(value, fmt)) {
86
+ sink.push(ruleA1, DEFAULT_SEVERITY[ruleA1], `${kind} ${JSON.stringify(value)} violates format: ${fmt}`, {
87
+ jsonpointer, path: ctx.path, method: ctx.method,
88
+ fix_hint: `make ${kind} match RFC for format: ${fmt}`,
89
+ });
90
+ }
91
+ }
92
+
93
+ // Enum check
94
+ if (Array.isArray(s.enum) && s.enum.length > 0) {
95
+ const key = stableKey(value);
96
+ if (!s.enum.some(e => stableKey(e) === key)) {
97
+ sink.push(ruleA2, DEFAULT_SEVERITY[ruleA2], `${kind} ${JSON.stringify(value)} is not in enum`, {
98
+ jsonpointer, path: ctx.path, method: ctx.method,
99
+ fix_hint: `pick a value from enum: ${JSON.stringify(s.enum)}`,
100
+ });
101
+ }
102
+ }
103
+
104
+ // Pattern check
105
+ const pattern = (s as { pattern?: string }).pattern;
106
+ if (pattern && typeof value === "string") {
107
+ let re: RegExp | null = null;
108
+ try { re = new RegExp(pattern); } catch { /* invalid regex — skip silently */ }
109
+ if (re && !re.test(value)) {
110
+ sink.push(ruleA3, DEFAULT_SEVERITY[ruleA3], `${kind} ${JSON.stringify(value)} does not match pattern ${pattern}`, {
111
+ jsonpointer, path: ctx.path, method: ctx.method,
112
+ fix_hint: "adjust the example to match the regex",
113
+ });
114
+ }
115
+ }
116
+
117
+ // Length / range
118
+ if (isStringy && typeof value === "string") {
119
+ const min = (s as { minLength?: number }).minLength;
120
+ const max = (s as { maxLength?: number }).maxLength;
121
+ if (typeof min === "number" && value.length < min) {
122
+ sink.push(ruleA4, DEFAULT_SEVERITY[ruleA4], `${kind} length ${value.length} < minLength ${min}`, {
123
+ jsonpointer, path: ctx.path, method: ctx.method,
124
+ });
125
+ }
126
+ if (typeof max === "number" && value.length > max) {
127
+ sink.push(ruleA4, DEFAULT_SEVERITY[ruleA4], `${kind} length ${value.length} > maxLength ${max}`, {
128
+ jsonpointer, path: ctx.path, method: ctx.method,
129
+ });
130
+ }
131
+ }
132
+ if (isNumeric && typeof value === "number") {
133
+ const minimum = (s as { minimum?: number }).minimum;
134
+ const maximum = (s as { maximum?: number }).maximum;
135
+ if (typeof minimum === "number" && value < minimum) {
136
+ sink.push(ruleA4, DEFAULT_SEVERITY[ruleA4], `${kind} ${value} < minimum ${minimum}`, {
137
+ jsonpointer, path: ctx.path, method: ctx.method,
138
+ });
139
+ }
140
+ if (typeof maximum === "number" && value > maximum) {
141
+ sink.push(ruleA4, DEFAULT_SEVERITY[ruleA4], `${kind} ${value} > maximum ${maximum}`, {
142
+ jsonpointer, path: ctx.path, method: ctx.method,
143
+ });
144
+ }
145
+ }
146
+ }
147
+
148
+ function stableKey(v: unknown): string {
149
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
150
+ if (Array.isArray(v)) return "[" + v.map(stableKey).join(",") + "]";
151
+ const obj = v as Record<string, unknown>;
152
+ const keys = Object.keys(obj).sort();
153
+ return "{" + keys.map(k => JSON.stringify(k) + ":" + stableKey(obj[k])).join(",") + "}";
154
+ }
155
+
156
+ // Suppress unused-export warning: RULE_AFFECTS imported here for future inline
157
+ // affects-tagging if rules grow more local context.
158
+ void RULE_AFFECTS;
@@ -0,0 +1,97 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+ import type { Issue, RuleId, Severity, HeuristicConfig } from "../types.ts";
3
+ import { DEFAULT_SEVERITY } from "../types.ts";
4
+ import type { ParamContext, SchemaContext, RequestBodyContext } from "../walker.ts";
5
+ import { normalisedTypes } from "../walker.ts";
6
+
7
+ interface RuleSink {
8
+ push(rule: RuleId, severity: Severity, message: string, opts: Partial<Pick<Issue, "path" | "method" | "fix_hint">> & { jsonpointer: string }): void;
9
+ }
10
+
11
+ /**
12
+ * B2 — path/query param named like an id (`*_id`, `id`) without `format: uuid`
13
+ * or `pattern`. Heuristic: only on params whose name matches the id-suffix list.
14
+ */
15
+ export function runParamHeuristics(ctx: ParamContext, sink: RuleSink, h: HeuristicConfig): void {
16
+ const p = ctx.param;
17
+ if (p.in !== "path" && p.in !== "query") return;
18
+ const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject;
19
+ const types = normalisedTypes(schema);
20
+ // B2 only applies to string-shaped ids (uuid). Integer ids are constrained
21
+ // by minimum/maximum (B3 territory), not by format: uuid.
22
+ if (types.length > 0 && !types.includes("string")) return;
23
+ const looksLikeId = p.name === "id" || h.id_suffixes.some(s => p.name.endsWith(s));
24
+ if (!looksLikeId) return;
25
+ const fmt = (schema as { format?: string }).format;
26
+ const hasPattern = !!(schema as { pattern?: string }).pattern;
27
+ if (fmt !== "uuid" && !hasPattern) {
28
+ sink.push("B2", DEFAULT_SEVERITY.B2, `id-like param "${p.name}" missing format: uuid or pattern (heuristic)`, {
29
+ jsonpointer: ctx.jsonpointer, path: ctx.path, method: ctx.method,
30
+ fix_hint: "if the id is a UUID, add format: uuid; otherwise add a pattern",
31
+ });
32
+ }
33
+ }
34
+
35
+ /**
36
+ * B5/B6 — schema property name suggests a known semantic type, but `format`
37
+ * is missing.
38
+ * - `*_at`, `*_date`, `*_time`, `created`, `updated`, `timestamp` → `date-time`
39
+ * - `email`, `url`, `website`, `homepage` → `email` / `uri`
40
+ */
41
+ export function runSchemaHeuristics(ctx: SchemaContext, sink: RuleSink, h: HeuristicConfig): void {
42
+ if (ctx.origin !== "property" || !ctx.propertyName) return;
43
+ const s = ctx.schema;
44
+ const types = normalisedTypes(s);
45
+ if (!types.includes("string")) return;
46
+ const fmt = (s as { format?: string }).format;
47
+ const name = ctx.propertyName;
48
+
49
+ // B5 — timestamp fields
50
+ const looksLikeTimestamp =
51
+ h.timestamp_suffixes.some(suf => name.endsWith(suf)) ||
52
+ ["created", "updated", "timestamp"].includes(name);
53
+ if (looksLikeTimestamp && fmt !== "date-time" && fmt !== "date") {
54
+ sink.push("B5", DEFAULT_SEVERITY.B5, `field "${name}" looks like a timestamp but has no format: date-time (heuristic)`, {
55
+ jsonpointer: ctx.jsonpointer, path: ctx.path, method: ctx.method,
56
+ fix_hint: "add format: date-time so --validate-schema enforces RFC3339",
57
+ });
58
+ }
59
+
60
+ // B6 — email / url
61
+ if (name === "email" && fmt !== "email") {
62
+ sink.push("B6", DEFAULT_SEVERITY.B6, `field "${name}" missing format: email (heuristic)`, {
63
+ jsonpointer: ctx.jsonpointer, path: ctx.path, method: ctx.method,
64
+ fix_hint: "add format: email",
65
+ });
66
+ }
67
+ if (h.url_names.includes(name) && fmt !== "uri" && fmt !== "url") {
68
+ sink.push("B6", DEFAULT_SEVERITY.B6, `field "${name}" missing format: uri (heuristic)`, {
69
+ jsonpointer: ctx.jsonpointer, path: ctx.path, method: ctx.method,
70
+ fix_hint: "add format: uri",
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * B9 — request-body schema declares semantically-required-looking properties
77
+ * (`name`, `email`, `title`) but `required: []` is empty / absent. Heuristic.
78
+ */
79
+ export function runRequestBodyHeuristics(ctx: RequestBodyContext, sink: RuleSink, h: HeuristicConfig): void {
80
+ if (!ctx.requestBody.content) return;
81
+ for (const [ct, mt] of Object.entries(ctx.requestBody.content)) {
82
+ if (!ct.includes("json")) continue;
83
+ const schema = mt.schema as OpenAPIV3.SchemaObject | undefined;
84
+ if (!schema || !schema.properties) continue;
85
+ const required = (schema.required ?? []) as string[];
86
+ const propNames = Object.keys(schema.properties);
87
+ const semanticPresent = h.semantic_required.filter(n => propNames.includes(n));
88
+ const semanticMissing = semanticPresent.filter(n => !required.includes(n));
89
+ if (semanticPresent.length > 0 && semanticMissing.length === semanticPresent.length) {
90
+ sink.push("B9", DEFAULT_SEVERITY.B9, `request body has properties [${semanticPresent.join(", ")}] but none are required (heuristic)`, {
91
+ jsonpointer: `${ctx.jsonpointer}/content/${ct.replace(/~/g, "~0").replace(/\//g, "~1")}/schema/required`,
92
+ path: ctx.path, method: ctx.method,
93
+ fix_hint: `consider adding to required: [${semanticMissing.map(n => `"${n}"`).join(", ")}]`,
94
+ });
95
+ }
96
+ }
97
+ }