@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.
- package/CHANGELOG.md +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +132 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +61 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +282 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +59 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +216 -0
- package/src/db/queries/runs.ts +289 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +75 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- 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 {
|
|
Binary file
|
|
@@ -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
|
+
}
|