@kirrosh/zond 0.23.0 → 0.26.1
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 +176 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +53 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -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.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- 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 +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -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/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -16,8 +16,9 @@ import type { Command } from "commander";
|
|
|
16
16
|
|
|
17
17
|
import { listChecks, runChecks } from "../../core/checks/index.ts";
|
|
18
18
|
import { listStatefulChecks } from "../../core/checks/stateful.ts";
|
|
19
|
+
import { resolveBudget, isBudget, BUDGETS, type Budget } from "../../core/checks/budget.ts";
|
|
19
20
|
import { generateSarifReport } from "../../core/checks/sarif.ts";
|
|
20
|
-
import { emitToStdout } from "../../core/reporter/ndjson.ts";
|
|
21
|
+
import { emitToStdout, nowIso } from "../../core/reporter/ndjson.ts";
|
|
21
22
|
import { parseWorkers } from "../../core/runner/async-pool.ts";
|
|
22
23
|
import { createAdaptiveRateLimiter, createRateLimiter, type RateLimiter } from "../../core/runner/rate-limiter.ts";
|
|
23
24
|
import { compileOperationFilter } from "../../core/selectors/operation-filter.ts";
|
|
@@ -26,11 +27,17 @@ import { readResourceMap } from "./discover.ts";
|
|
|
26
27
|
import type { ReadbackDiffConfig, IdempotencyConfig, PaginationConfig, LifecycleConfig, SeedBodyConfig } from "../../core/generator/resources-builder.ts";
|
|
27
28
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
28
29
|
import { printError, printSuccess } from "../output.ts";
|
|
30
|
+
import { SAFE_HELP, LIVE_HELP, resolveLive } from "../safe-live.ts";
|
|
29
31
|
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
32
|
+
import { readFixtureGaps, gapIndex } from "../../core/workspace/fixture-gaps.ts";
|
|
30
33
|
import { getApi } from "../util/api-context.ts";
|
|
31
34
|
import { VERSION } from "../version.ts";
|
|
32
35
|
import { resolveOutput, OutputSpecError, type OutputSpec, type ResolvedOutput } from "../../core/output/index.ts";
|
|
33
36
|
import type { RunChecksResult } from "../../core/checks/index.ts";
|
|
37
|
+
import type { ChecksCaseEvent } from "../../core/checks/runner.ts";
|
|
38
|
+
import { beginAuditRun, finalizeAuditRun, checksPersistEnabled, type AuditCaseRecord } from "../../core/audit/persist.ts";
|
|
39
|
+
import { readCurrentSession } from "../../core/context/session.ts";
|
|
40
|
+
import { getDb } from "../../db/schema.ts";
|
|
34
41
|
|
|
35
42
|
/**
|
|
36
43
|
* ARV-118 (m-19): typed declaration of every `--report` / `--output` /
|
|
@@ -119,6 +126,21 @@ interface ChecksRunOptions {
|
|
|
119
126
|
strict405?: boolean;
|
|
120
127
|
strict401?: boolean;
|
|
121
128
|
maxRequests?: number;
|
|
129
|
+
/** ARV-342: operation-window for bounded/resumable sweeps of large specs. */
|
|
130
|
+
maxOps?: number;
|
|
131
|
+
skipOps?: number;
|
|
132
|
+
budget?: string;
|
|
133
|
+
showSuppressed?: boolean;
|
|
134
|
+
/** ARV-308: `--no-fail-on-findings` sets this to false (commander default
|
|
135
|
+
* true) — keep exit 0 even when HIGH/CRITICAL findings exist so an
|
|
136
|
+
* orchestrator can distinguish "found drift" from "command failed". */
|
|
137
|
+
failOnFindings?: boolean;
|
|
138
|
+
/** ARV-308: `--advisory` alias for --no-fail-on-findings. */
|
|
139
|
+
advisory?: boolean;
|
|
140
|
+
/** ARV-299: safe/live parity with `audit`. Default safe — mutating
|
|
141
|
+
* stateful create-chains self-skip; `--live` runs them. */
|
|
142
|
+
safe?: boolean;
|
|
143
|
+
live?: boolean;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
function parseAuthHeaders(values: string[] | undefined): Record<string, string> {
|
|
@@ -188,6 +210,8 @@ async function deriveResourceConfigsFromApi(
|
|
|
188
210
|
limitParam: r.pagination.limit_param,
|
|
189
211
|
defaultLimit: r.pagination.default_limit,
|
|
190
212
|
itemsField: r.pagination.items_field,
|
|
213
|
+
pageParam: r.pagination.page_param,
|
|
214
|
+
startPage: r.pagination.start_page,
|
|
191
215
|
};
|
|
192
216
|
}
|
|
193
217
|
if (r.lifecycle) {
|
|
@@ -240,6 +264,19 @@ async function derivePathVarsFromApi(apiName: string | undefined, dbPath: string
|
|
|
240
264
|
}
|
|
241
265
|
}
|
|
242
266
|
|
|
267
|
+
/** ARV-324: load `.fixture-gaps.yaml` (written by a prior prepare-fixtures/
|
|
268
|
+
* discover run) so findings on a known-empty/inaccessible operation get
|
|
269
|
+
* `fix_fixture` instead of `report_backend_bug`. Undefined (not an empty
|
|
270
|
+
* Set) when there's no API context or no gaps file, so the classifier
|
|
271
|
+
* can tell "nothing to check" apart from "checked, no gaps". */
|
|
272
|
+
async function deriveFixtureGapsFromApi(apiName: string | undefined, dbPath: string | undefined): Promise<Set<string> | undefined> {
|
|
273
|
+
if (!apiName) return undefined;
|
|
274
|
+
const col = resolveApiCollection(apiName, dbPath);
|
|
275
|
+
if ("error" in col || !col.baseDir) return undefined;
|
|
276
|
+
const gaps = await readFixtureGaps(col.baseDir);
|
|
277
|
+
return gaps.length > 0 ? gapIndex(gaps) : undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
243
280
|
async function deriveAuthHeadersFromApi(apiName: string | undefined, dbPath: string | undefined): Promise<Record<string, string>> {
|
|
244
281
|
if (!apiName) return {};
|
|
245
282
|
const col = resolveApiCollection(apiName, dbPath);
|
|
@@ -338,9 +375,22 @@ function splitList(values: string[] | undefined): string[] | undefined {
|
|
|
338
375
|
// instead of hand-listing cross_call_references, idempotency_replay, … —
|
|
339
376
|
// matching the prior `--phase stateful` UX promise without overloading the
|
|
340
377
|
// case-generation `--phase` flag.
|
|
341
|
-
|
|
378
|
+
// ARV-325: `ignored_auth` / `open_cors_on_sensitive` live in the stateful
|
|
379
|
+
// *registry* (they need the stateful harness to run), but semantically they
|
|
380
|
+
// are auth/security checks. Users reading `--check stateful` expect
|
|
381
|
+
// state-machine probes, not a full security pass — on Stripe the pair added
|
|
382
|
+
// ~520 extra cases and turned a sub-minute run into ~10 minutes. Keep them
|
|
383
|
+
// runnable by explicit id; just don't smuggle them in through the alias.
|
|
384
|
+
const STATEFUL_ALIAS_EXCLUDED: ReadonlySet<string> = new Set([
|
|
385
|
+
"ignored_auth",
|
|
386
|
+
"open_cors_on_sensitive",
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
export function expandStatefulAlias(ids: string[] | undefined): string[] | undefined {
|
|
342
390
|
if (!ids) return ids;
|
|
343
|
-
const statefulIds = listStatefulChecks()
|
|
391
|
+
const statefulIds = listStatefulChecks()
|
|
392
|
+
.map((c) => c.id)
|
|
393
|
+
.filter((id) => !STATEFUL_ALIAS_EXCLUDED.has(id));
|
|
344
394
|
const out: string[] = [];
|
|
345
395
|
for (const id of ids) {
|
|
346
396
|
if (id === "stateful") out.push(...statefulIds);
|
|
@@ -432,6 +482,7 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
|
|
|
432
482
|
// rounds and CI can't distinguish "spec stable" from "checks ignored deltas").
|
|
433
483
|
const pathVars = await derivePathVarsFromApi(apiName, opts.db);
|
|
434
484
|
const resourceConfigs = await deriveResourceConfigsFromApi(apiName, opts.db);
|
|
485
|
+
const fixtureGaps = await deriveFixtureGapsFromApi(apiName, opts.db);
|
|
435
486
|
|
|
436
487
|
const phaseRaw = typeof opts.phase === "string" ? opts.phase : "examples";
|
|
437
488
|
if (phaseRaw !== "examples" && phaseRaw !== "coverage" && phaseRaw !== "all") {
|
|
@@ -503,27 +554,179 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
|
|
|
503
554
|
const ndjsonOutputPath: string | undefined = ndjson && resolved.channel === "file" ? resolved.path : undefined;
|
|
504
555
|
let ndjsonFd: number | undefined;
|
|
505
556
|
let ndjsonEventCount = 0;
|
|
557
|
+
let ndjsonLastFullyWritten = true;
|
|
506
558
|
if (ndjsonOutputPath) {
|
|
507
559
|
ndjsonFd = openSync(ndjsonOutputPath, "w");
|
|
508
560
|
}
|
|
561
|
+
// ARV-343: accumulate a running summary from the stream so a SIGTERM'd
|
|
562
|
+
// run still emits a terminal `summary` line (operations/cases counted so
|
|
563
|
+
// far) instead of dying before the runner's emit stage — triage's
|
|
564
|
+
// `sweepWindows`/status-dist read `.summary.operations` and had to
|
|
565
|
+
// reconstruct it from raw NDJSON when the window was killed mid-flight.
|
|
566
|
+
const partialOps = new Set<string>();
|
|
567
|
+
const partialChecks = new Set<string>();
|
|
568
|
+
let partialCases = 0;
|
|
569
|
+
let partialFindings = 0;
|
|
570
|
+
const partialBySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
571
|
+
const accumulate = (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
|
|
572
|
+
if (ev.type === "check_result") {
|
|
573
|
+
partialCases += 1;
|
|
574
|
+
partialChecks.add(ev.check);
|
|
575
|
+
partialOps.add(`${ev.operation.method} ${ev.operation.path}`);
|
|
576
|
+
} else if (ev.type === "check_start") {
|
|
577
|
+
partialOps.add(`${ev.operation.method} ${ev.operation.path}`);
|
|
578
|
+
} else if (ev.type === "finding") {
|
|
579
|
+
partialFindings += 1;
|
|
580
|
+
const sev = ev.finding.severity as keyof typeof partialBySeverity;
|
|
581
|
+
if (sev in partialBySeverity) partialBySeverity[sev] += 1;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
509
584
|
const ndjsonOnEvent = ndjson
|
|
510
585
|
? (ndjsonFd !== undefined
|
|
511
586
|
? (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
|
|
587
|
+
accumulate(ev);
|
|
588
|
+
ndjsonLastFullyWritten = false;
|
|
512
589
|
ndjsonEventCount += 1;
|
|
513
590
|
writeSync(ndjsonFd!, `${JSON.stringify(ev)}\n`);
|
|
591
|
+
ndjsonLastFullyWritten = true;
|
|
514
592
|
}
|
|
515
|
-
:
|
|
593
|
+
// ARV-323: count stdout-channel events too — the SIGTERM handler
|
|
594
|
+
// reports `ndjsonEventCount`, and with a shell-redirected stdout
|
|
595
|
+
// stream it claimed "0 event(s) flushed" while the redirect target
|
|
596
|
+
// already held thousands of lines.
|
|
597
|
+
: (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
|
|
598
|
+
accumulate(ev);
|
|
599
|
+
ndjsonEventCount += 1;
|
|
600
|
+
emitToStdout(ev);
|
|
601
|
+
})
|
|
516
602
|
: undefined;
|
|
517
603
|
|
|
604
|
+
// ARV-230: clean SIGTERM/SIGINT shutdown for NDJSON streams. Without
|
|
605
|
+
// this, killing a long `checks run --report ndjson` leaves a truncated
|
|
606
|
+
// last line in the file (or in the consumer's pipe) and downstream
|
|
607
|
+
// `jq -s` chokes until the user strips it with `sed '$d'`. The handler
|
|
608
|
+
// closes the fd (or drains stdout) and exits with the conventional
|
|
609
|
+
// 128+signo code instead of letting Node default-terminate mid-write.
|
|
610
|
+
let removeNdjsonSigHandlers: (() => void) | undefined;
|
|
611
|
+
if (ndjson) {
|
|
612
|
+
const shutdown = (signo: number) => {
|
|
613
|
+
try {
|
|
614
|
+
// ARV-343: flush a partial terminal summary from the running
|
|
615
|
+
// accumulators. Schema-exact (CheckRunSummarySchema) so downstream
|
|
616
|
+
// `jq '.summary.operations'` and status-dist stay analyzable;
|
|
617
|
+
// by_category/skipped are left empty (not derivable in the CLI) —
|
|
618
|
+
// the stderr interrupt note below flags that it's a partial count.
|
|
619
|
+
const partialSummary = {
|
|
620
|
+
type: "summary" as const,
|
|
621
|
+
ts: nowIso(),
|
|
622
|
+
summary: {
|
|
623
|
+
operations: partialOps.size,
|
|
624
|
+
cases: partialCases,
|
|
625
|
+
checks_run: partialChecks.size,
|
|
626
|
+
findings: partialFindings,
|
|
627
|
+
by_severity: { ...partialBySeverity },
|
|
628
|
+
by_category: { security: 0, reliability: 0, contract: 0, hygiene: 0 },
|
|
629
|
+
skipped_outcomes: {},
|
|
630
|
+
skipped_outcomes_grouped: [],
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
if (ndjsonFd !== undefined) {
|
|
634
|
+
if (!ndjsonLastFullyWritten) {
|
|
635
|
+
try { writeSync(ndjsonFd, "\n"); } catch { /* fd already gone */ }
|
|
636
|
+
}
|
|
637
|
+
try { writeSync(ndjsonFd, `${JSON.stringify(partialSummary)}\n`); } catch { /* fd already gone */ }
|
|
638
|
+
try { closeSync(ndjsonFd); } catch { /* already closed */ }
|
|
639
|
+
ndjsonFd = undefined;
|
|
640
|
+
} else {
|
|
641
|
+
try { emitToStdout(partialSummary); } catch { /* stdout gone */ }
|
|
642
|
+
}
|
|
643
|
+
} catch { /* swallow; we're tearing down */ }
|
|
644
|
+
// ARV-323: "emitted" not "flushed" — on the stdout channel the last
|
|
645
|
+
// few lines may still sit in the pipe buffer, so the count is a
|
|
646
|
+
// lower bound of what the consumer/file will contain, never an
|
|
647
|
+
// excuse to discard a partial-but-real stream.
|
|
648
|
+
try { process.stderr.write(`zond: NDJSON run interrupted (signal ${signo}); ${ndjsonEventCount} event(s) + a partial summary (${partialOps.size} ops, ${partialCases} cases so far) emitted before interrupt (partial stream is usable).\n`); } catch { /* ignore */ }
|
|
649
|
+
process.exit(128 + signo);
|
|
650
|
+
};
|
|
651
|
+
const onTerm = () => shutdown(15);
|
|
652
|
+
const onInt = () => shutdown(2);
|
|
653
|
+
process.on("SIGTERM", onTerm);
|
|
654
|
+
process.on("SIGINT", onInt);
|
|
655
|
+
removeNdjsonSigHandlers = () => {
|
|
656
|
+
process.off("SIGTERM", onTerm);
|
|
657
|
+
process.off("SIGINT", onInt);
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ARV-265: accumulate every HTTP case `runChecks` dispatches so we can
|
|
662
|
+
// persist them into the run/results tables after the run completes. The
|
|
663
|
+
// adapter maps ChecksCaseEvent → AuditCaseRecord (1:1) and groups by
|
|
664
|
+
// synthetic suite path (`apis/<api>/checks/<phase>`) so `detectRunKind`
|
|
665
|
+
// would still classify these rows as `check`-kind on legacy queries.
|
|
666
|
+
const auditPersist = checksPersistEnabled();
|
|
667
|
+
const auditCases: AuditCaseRecord[] = [];
|
|
668
|
+
const auditSuiteRoot = `apis/${apiName ?? "_"}/checks`;
|
|
669
|
+
const onCase = auditPersist
|
|
670
|
+
? (ev: ChecksCaseEvent) => {
|
|
671
|
+
const phaseLabel = ev.phase === "response" ? "response" : ev.phase.replace("stateful_", "stateful/");
|
|
672
|
+
const suiteFile = `${auditSuiteRoot}/${phaseLabel}.yaml`;
|
|
673
|
+
const status = ev.verdict === "pass" ? "pass"
|
|
674
|
+
: ev.verdict === "fail" ? "fail"
|
|
675
|
+
: ev.verdict === "skip" ? "skip"
|
|
676
|
+
: "error";
|
|
677
|
+
auditCases.push({
|
|
678
|
+
suiteName: `checks/${phaseLabel}`,
|
|
679
|
+
suiteFile,
|
|
680
|
+
testName: `${ev.checkId}::${ev.operation.method.toUpperCase()} ${ev.operation.path}`,
|
|
681
|
+
status,
|
|
682
|
+
request: ev.request,
|
|
683
|
+
...(ev.response ? { response: ev.response } : {}),
|
|
684
|
+
durationMs: ev.durationMs,
|
|
685
|
+
...(ev.error ? { error: ev.error } : {}),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
: undefined;
|
|
689
|
+
|
|
690
|
+
let budget: Budget | undefined;
|
|
691
|
+
if (opts.budget !== undefined) {
|
|
692
|
+
if (!isBudget(opts.budget)) {
|
|
693
|
+
printError(`--budget must be one of: ${BUDGETS.join(", ")}; got '${opts.budget}'`);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
budget = opts.budget;
|
|
697
|
+
}
|
|
698
|
+
const includeList = expandStatefulAlias(splitList(opts.check));
|
|
699
|
+
const statefulIds = new Set(listStatefulChecks().map((c) => c.id));
|
|
700
|
+
const includesStateful = includeList?.some((id) => statefulIds.has(id)) === true;
|
|
701
|
+
const budgetResolved = resolveBudget(budget, opts.maxRequests, {
|
|
702
|
+
forceStatefulIfIncluded: includesStateful,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ARV-328: throttled progress line on stderr during long runs (CI jobs
|
|
706
|
+
// and subagents with wall-clock budgets had zero visibility — the only
|
|
707
|
+
// artifact was the growing ndjson file). stderr never corrupts stdout
|
|
708
|
+
// reports; 10s throttle keeps short runs silent.
|
|
709
|
+
const PROGRESS_INTERVAL_MS = 10_000;
|
|
710
|
+
let lastProgressAt = Date.now();
|
|
711
|
+
const onProgress = (p: { done: number; total: number; cases: number }) => {
|
|
712
|
+
const now = Date.now();
|
|
713
|
+
if (now - lastProgressAt < PROGRESS_INTERVAL_MS) return;
|
|
714
|
+
lastProgressAt = now;
|
|
715
|
+
try {
|
|
716
|
+
process.stderr.write(`zond: progress — ${p.done}/${p.total} operations, ${p.cases} case(s) run\n`);
|
|
717
|
+
} catch { /* ignore */ }
|
|
718
|
+
};
|
|
719
|
+
|
|
518
720
|
try {
|
|
519
721
|
const result = await runChecks({
|
|
520
722
|
specPath: specRes.spec,
|
|
521
723
|
baseUrl: baseRes.baseUrl,
|
|
522
|
-
include:
|
|
724
|
+
include: includeList,
|
|
523
725
|
exclude: expandStatefulAlias(splitList(opts.excludeCheck)),
|
|
524
726
|
timeoutMs: typeof opts.timeout === "number" ? opts.timeout : undefined,
|
|
525
727
|
authHeaders: Object.keys(authHeaders).length > 0 ? authHeaders : undefined,
|
|
526
728
|
pathVars: Object.keys(pathVars).length > 0 ? pathVars : undefined,
|
|
729
|
+
fixtureGaps,
|
|
527
730
|
resourceConfigs,
|
|
528
731
|
bootstrapCleanupFailed: opts.bootstrapCleanupFailed === true,
|
|
529
732
|
phase: phaseRaw as "examples" | "coverage" | "all",
|
|
@@ -533,13 +736,44 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
|
|
|
533
736
|
mode: modeRaw as "positive" | "negative" | "all",
|
|
534
737
|
operationFilter,
|
|
535
738
|
onEvent: ndjsonOnEvent,
|
|
739
|
+
onCase,
|
|
740
|
+
onProgress,
|
|
536
741
|
// ARV-8: bounded concurrency at op-level + optional rate-limiter
|
|
537
742
|
// gating. workers=1 (default) preserves the pre-ARV-8 sequential
|
|
538
743
|
// path inside runPool — same observable behaviour.
|
|
539
744
|
workers,
|
|
540
745
|
rateLimiter,
|
|
541
|
-
maxRequests:
|
|
746
|
+
maxRequests: budgetResolved.maxRequests,
|
|
747
|
+
skipStateful: budgetResolved.skipStateful,
|
|
748
|
+
maxOps: opts.maxOps,
|
|
749
|
+
skipOps: opts.skipOps,
|
|
750
|
+
safe: !resolveLive(opts),
|
|
542
751
|
});
|
|
752
|
+
|
|
753
|
+
// ARV-265: persist the accumulated cases as a `run_kind='check'` run
|
|
754
|
+
// so `zond coverage --scope audit` can count them. Failures here
|
|
755
|
+
// degrade to a warning — the user's primary command already succeeded.
|
|
756
|
+
if (auditPersist && auditCases.length > 0) {
|
|
757
|
+
try {
|
|
758
|
+
getDb(opts.db);
|
|
759
|
+
const { findCollectionByNameOrId } = await import("../../db/queries.ts");
|
|
760
|
+
const collectionId = apiName ? findCollectionByNameOrId(apiName)?.id : undefined;
|
|
761
|
+
const session = readCurrentSession();
|
|
762
|
+
const runId = beginAuditRun({
|
|
763
|
+
runKind: "check",
|
|
764
|
+
...(collectionId != null ? { collectionId } : {}),
|
|
765
|
+
...(session?.id ? { sessionId: session.id } : {}),
|
|
766
|
+
tags: ["checks", `phase:${phaseRaw}`, `mode:${modeRaw}`],
|
|
767
|
+
});
|
|
768
|
+
finalizeAuditRun(runId, auditCases);
|
|
769
|
+
} catch (err) {
|
|
770
|
+
// Audit persistence is best-effort. Surface the failure on stderr
|
|
771
|
+
// so an agent can detect it (the missing audit-coverage downstream
|
|
772
|
+
// will already point them here) without breaking the run.
|
|
773
|
+
const msg = (err as Error).message;
|
|
774
|
+
process.stderr.write(`zond: audit persistence failed (${msg}). Re-run with ZOND_CHECKS_PERSIST=0 to silence.\n`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
543
777
|
const warnings: string[] = [];
|
|
544
778
|
for (const id of result.selection.unknown) {
|
|
545
779
|
warnings.push(`Unknown check: "${id}" — ignored. Run \`zond checks list\` to see registered ids.`);
|
|
@@ -612,50 +846,104 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
|
|
|
612
846
|
}
|
|
613
847
|
const skipLine = formatSkippedOutcomes(s.skipped_outcomes);
|
|
614
848
|
if (skipLine) console.log(` ${skipLine}`);
|
|
615
|
-
// ARV-
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// and SARIF
|
|
849
|
+
// ARV-60: spec-level rollup. When the runner detected ≥80% of a
|
|
850
|
+
// check's applicable ops sharing one root cause, print one summary
|
|
851
|
+
// row with an actionable fix hint instead of N per-op rows that
|
|
852
|
+
// all say the same thing. `--verbose` always shows per-op detail
|
|
853
|
+
// (and JSON/SARIF carry the full unaggregated list regardless).
|
|
854
|
+
const rolledUpOps = new Set<string>();
|
|
855
|
+
if (!opts.verbose) {
|
|
856
|
+
for (const sf of result.data.spec_findings) {
|
|
857
|
+
if (sf.kind === "status_drift") {
|
|
858
|
+
for (const op of sf.affected_operations) {
|
|
859
|
+
rolledUpOps.add(`${sf.check}|${op.method} ${op.path}`);
|
|
860
|
+
}
|
|
861
|
+
console.log(
|
|
862
|
+
` [${sf.severity}] ${sf.check} — ${sf.reason} (${sf.count}/${sf.applicable} operations)`,
|
|
863
|
+
);
|
|
864
|
+
console.log(` → ${sf.fix_hint}`);
|
|
865
|
+
} else {
|
|
866
|
+
// missing_declaration / no_detector / other — no affected_operations
|
|
867
|
+
// to enumerate; surfaces as a single info row + fix hint.
|
|
868
|
+
const tag = sf.kind === "no_detector"
|
|
869
|
+
? `${sf.count === 0 ? "0 cases" : `${sf.count} cases`} / ${sf.applicable} applicable ops`
|
|
870
|
+
: `${sf.count}/${sf.applicable} cases`;
|
|
871
|
+
console.log(` [${sf.severity}] ${sf.check} — ${sf.reason} (${tag})`);
|
|
872
|
+
console.log(` → ${sf.fix_hint}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// ARV-283 AC#4: suppressed findings stay in the ndjson audit-trail
|
|
877
|
+
// but are hidden from the human summary unless `--show-suppressed`
|
|
878
|
+
// is passed. They never count toward CI gates regardless.
|
|
879
|
+
const activeFindings = result.data.findings.filter((f) => !f.suppressed_by);
|
|
880
|
+
const suppressedFindings = result.data.findings.filter((f) => f.suppressed_by);
|
|
881
|
+
|
|
882
|
+
// Per-op findings — skip those already covered by a status_drift
|
|
883
|
+
// rollup unless --verbose was passed.
|
|
620
884
|
if (opts.verbose) {
|
|
621
|
-
for (const f of
|
|
885
|
+
for (const f of activeFindings) {
|
|
622
886
|
console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} — ${f.message}`);
|
|
623
887
|
}
|
|
624
888
|
} else {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}
|
|
635
|
-
|
|
889
|
+
// ARV-18: dedup identical findings on the SAME op (multiple cases
|
|
890
|
+
// hitting the same gap) before printing. Spec-rollup above already
|
|
891
|
+
// handled the across-op clusters; this collapses the within-op
|
|
892
|
+
// duplicates so the human summary stays readable even when boundary
|
|
893
|
+
// mutations each trip the same status.
|
|
894
|
+
const seen = new Set<string>();
|
|
895
|
+
for (const f of activeFindings) {
|
|
896
|
+
const opKey = `${f.check}|${f.operation.method} ${f.operation.path}`;
|
|
897
|
+
if (rolledUpOps.has(opKey)) continue;
|
|
898
|
+
const dedupKey = `${f.severity}|${opKey}|${f.response_summary?.status ?? 0}|${f.message}`;
|
|
899
|
+
if (seen.has(dedupKey)) continue;
|
|
900
|
+
seen.add(dedupKey);
|
|
901
|
+
console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} — ${f.message}`);
|
|
636
902
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
console.log(` [${g.severity}] ${g.check} — ${stem} (${g.ops.size} operation${g.ops.size === 1 ? "" : "s"} affected; --verbose for per-op detail)`);
|
|
646
|
-
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (opts.showSuppressed && suppressedFindings.length > 0) {
|
|
906
|
+
console.log(`\nSuppressed (${suppressedFindings.length}, not counted in summary):`);
|
|
907
|
+
for (const f of suppressedFindings) {
|
|
908
|
+
const sb = f.suppressed_by!;
|
|
909
|
+
console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} — ${f.message}`);
|
|
910
|
+
console.log(` ↳ suppressed by ${sb.source}#${sb.rule_index}: ${sb.reason}`);
|
|
647
911
|
}
|
|
648
912
|
}
|
|
649
913
|
}
|
|
650
914
|
// Exit-code rule: 0 when no HIGH/CRITICAL findings, 1 otherwise. LOW/MEDIUM
|
|
651
915
|
// findings are reported but don't gate CI by default — agents that want
|
|
652
916
|
// strict gating can post-process the JSON envelope.
|
|
917
|
+
//
|
|
918
|
+
// ARV-308: --no-fail-on-findings / --advisory keeps exit 0 even with
|
|
919
|
+
// HIGH/CRITICAL findings, mirroring `zond run --no-fail-on-failures`, so
|
|
920
|
+
// an orchestrator can tell "found drift" (exit 0, findings in envelope)
|
|
921
|
+
// from "command failed" (exit 2). The stderr tail still names the count.
|
|
922
|
+
const advisory = opts.advisory === true || opts.failOnFindings === false;
|
|
923
|
+
// ARV-320: this used to skip on ndjson under the assumption that "stderr
|
|
924
|
+
// already carried the summary just above" — false for ndjson, which takes
|
|
925
|
+
// a separate branch that only ever writes "NDJSON report written to
|
|
926
|
+
// <path>" to stderr. Under `--report ndjson` + `set -e` in CI, that made
|
|
927
|
+
// exit 1 look unexplained (report-zond friction on the 2026-07-02 Stripe
|
|
928
|
+
// run: "this step will 'fail' silently with valid data sitting right
|
|
929
|
+
// there"). Always write the reason to stderr — it's stderr, not stdout,
|
|
930
|
+
// so ndjson's stdout-discipline (AC#5) is untouched.
|
|
931
|
+
if (result.high_or_critical > 0) {
|
|
932
|
+
const suffix = advisory
|
|
933
|
+
? " — advisory mode, exiting 0 (findings are in the envelope)"
|
|
934
|
+
: " — exiting with code 1 (pass --no-fail-on-findings / --advisory to suppress, e.g. for advisory runs)";
|
|
935
|
+
process.stderr.write(
|
|
936
|
+
`zond: ${result.high_or_critical} HIGH/CRITICAL finding(s)${suffix}.\n`,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
653
939
|
if (ndjsonFd !== undefined) closeSync(ndjsonFd);
|
|
654
|
-
|
|
940
|
+
removeNdjsonSigHandlers?.();
|
|
941
|
+
process.exit(result.high_or_critical > 0 && !advisory ? 1 : 0);
|
|
655
942
|
} catch (err) {
|
|
656
943
|
if (ndjsonFd !== undefined) {
|
|
657
944
|
try { closeSync(ndjsonFd); } catch { /* fd may already be invalid */ }
|
|
658
945
|
}
|
|
946
|
+
removeNdjsonSigHandlers?.();
|
|
659
947
|
const msg = err instanceof Error ? err.message : String(err);
|
|
660
948
|
if (json) printJson(jsonError("checks run", [msg]));
|
|
661
949
|
else printError(msg);
|
|
@@ -677,7 +965,7 @@ function defineRun(parent: Command): void {
|
|
|
677
965
|
.option("--api <name>", "Use the registered API's spec + .env.yaml")
|
|
678
966
|
.option("--spec <path>", "Explicit OpenAPI spec path (overrides --api)")
|
|
679
967
|
.option("--base-url <url>", "Base URL for requests (overrides --api env file)")
|
|
680
|
-
.option("--check <ids...>", "Only run these checks (comma-separated or repeated)")
|
|
968
|
+
.option("--check <ids...>", "Only run these checks (comma-separated or repeated). 'stateful' expands to the state-machine set: use_after_free, ensure_resource_availability, cross_call_references, idempotency_replay, pagination_invariants, lifecycle_transitions, cursor_boundary_fuzzing — NOT ignored_auth/open_cors_on_sensitive (run those by explicit id)")
|
|
681
969
|
.option("--exclude-check <ids...>", "Skip these checks (comma-separated or repeated)")
|
|
682
970
|
.option("--timeout <ms>", "Per-request timeout in ms", (v) => Number.parseInt(v, 10))
|
|
683
971
|
.option("--db <path>", "SQLite path (for --api lookup)")
|
|
@@ -741,9 +1029,37 @@ function defineRun(parent: Command): void {
|
|
|
741
1029
|
)
|
|
742
1030
|
.option(
|
|
743
1031
|
"--max-requests <n>",
|
|
744
|
-
"ARV-227: hard cap on outbound HTTP requests for the whole run (per-response + stateful share the same budget). Once reached, remaining cases short-circuit with `max-requests-cap-reached` in summary.skipped_outcomes.
|
|
1032
|
+
"ARV-227: hard cap on outbound HTTP requests for the whole run (per-response + stateful share the same budget). Once reached, remaining cases short-circuit with `max-requests-cap-reached` in summary.skipped_outcomes. Always wins over the --budget tier cap.",
|
|
1033
|
+
(v) => Number.parseInt(v, 10),
|
|
1034
|
+
)
|
|
1035
|
+
.option(
|
|
1036
|
+
"--max-ops <n>",
|
|
1037
|
+
"ARV-342: cap this run to N operations (deterministic op-window). Pair with --skip-ops to sweep a large spec in bounded, resumable slices that each finish in a short budget. A window whose summary.operations < N is the last slice.",
|
|
1038
|
+
(v) => Number.parseInt(v, 10),
|
|
1039
|
+
)
|
|
1040
|
+
.option(
|
|
1041
|
+
"--skip-ops <n>",
|
|
1042
|
+
"ARV-342: skip the first N operations (post-filter, deterministic order) before applying --max-ops. Resume token for windowed sweeps: skip 0, skip 50, skip 100, …",
|
|
745
1043
|
(v) => Number.parseInt(v, 10),
|
|
746
1044
|
)
|
|
1045
|
+
.option(
|
|
1046
|
+
"--budget <tier>",
|
|
1047
|
+
"ARV-292: adaptive cap and stateful gating tier. `quick` (cap 50, skip stateful) → ~60-sec gate. `standard` (cap 500, all checks). `full` (uncapped). Omitted ⇒ legacy uncapped behaviour. --max-requests always overrides the tier cap; `--check stateful` opts back into stateful even under `quick`.",
|
|
1048
|
+
)
|
|
1049
|
+
.option(
|
|
1050
|
+
"--show-suppressed",
|
|
1051
|
+
"Show findings suppressed by the deterministic broken-baseline guard in the text summary (with their suppressed_by trace). Suppressed findings stay in the ndjson/JSON audit-trail regardless of this flag and never count toward CI exit codes.",
|
|
1052
|
+
)
|
|
1053
|
+
.option(
|
|
1054
|
+
"--no-fail-on-findings",
|
|
1055
|
+
"ARV-308: keep exit code 0 even when HIGH/CRITICAL findings exist (advisory runs). Mirrors `zond run --no-fail-on-failures`. Lets an orchestrator distinguish 'found drift' (exit 0) from 'command failed' (exit 2). Default: exit 1 on any HIGH/CRITICAL finding.",
|
|
1056
|
+
)
|
|
1057
|
+
.option(
|
|
1058
|
+
"--advisory",
|
|
1059
|
+
"ARV-308: alias for --no-fail-on-findings.",
|
|
1060
|
+
)
|
|
1061
|
+
.option("--safe", SAFE_HELP)
|
|
1062
|
+
.option("--live", LIVE_HELP)
|
|
747
1063
|
.action(checksRunAction);
|
|
748
1064
|
}
|
|
749
1065
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { loadOrphans, markRemoved } from "../../core/probe/orphan-tracker.ts";
|
|
11
11
|
import type { OrphanRecord } from "../../core/probe/orphan-tracker.ts";
|
|
12
|
+
import { encodePathForRepro } from "../../core/probe/shared.ts";
|
|
12
13
|
import { executeRequest } from "../../core/runner/http-client.ts";
|
|
13
14
|
import { loadEnvironment, loadEnvMeta } from "../../core/parser/variables.ts";
|
|
14
15
|
import { resolveTimeoutMs } from "../../core/workspace/config.ts";
|
|
@@ -41,37 +42,10 @@ export interface CleanupOptions {
|
|
|
41
42
|
* raw `\r`, `\n`, spaces, etc. (e.g. label name `zond-safe\rX-Zond: yes`),
|
|
42
43
|
* which makes the DELETE URL malformed: the API gets a request line with an
|
|
43
44
|
* embedded CR and silently splits or routes elsewhere → 404 → record stays
|
|
44
|
-
* in the queue and the resource leaks.
|
|
45
|
-
*
|
|
46
|
-
* Encode unsafe characters per path-segment while preserving anything that
|
|
47
|
-
* is already percent-encoded. Slashes (segment separators), the unreserved
|
|
48
|
-
* set, and a conservative slice of sub-delims are kept verbatim.
|
|
45
|
+
* in the queue and the resource leaks. Encoding is shared with the
|
|
46
|
+
* security-probe digest via `core/probe/shared.ts#encodePathForRepro`.
|
|
49
47
|
*/
|
|
50
|
-
export
|
|
51
|
-
const SAFE = /[A-Za-z0-9._~!$&'()*+,;=:@-]/;
|
|
52
|
-
return deletePath
|
|
53
|
-
.split("/")
|
|
54
|
-
.map((segment) => {
|
|
55
|
-
if (segment.length === 0) return segment;
|
|
56
|
-
let out = "";
|
|
57
|
-
for (let i = 0; i < segment.length; i++) {
|
|
58
|
-
const ch = segment.charAt(i);
|
|
59
|
-
// Preserve existing percent escapes (`%XX`) — probe pre-encoded.
|
|
60
|
-
if (ch === "%" && /^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
|
|
61
|
-
out += segment.slice(i, i + 3);
|
|
62
|
-
i += 2;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (SAFE.test(ch)) {
|
|
66
|
-
out += ch;
|
|
67
|
-
} else {
|
|
68
|
-
out += encodeURIComponent(ch);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
})
|
|
73
|
-
.join("/");
|
|
74
|
-
}
|
|
48
|
+
export const encodeOrphanPath = encodePathForRepro;
|
|
75
49
|
|
|
76
50
|
export async function cleanupCommand(opts: CleanupOptions): Promise<number> {
|
|
77
51
|
if (!opts.orphans) {
|