@kirrosh/zond 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -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 +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -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 +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +842 -53
- 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 +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -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 +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -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 +61 -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 +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +151 -11
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -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 +42 -16
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +445 -19
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +37 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -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 +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -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 +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -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 +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -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 +284 -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 +58 -1
- 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 +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +89 -17
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -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 +415 -16
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -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 +128 -0
- package/src/db/queries/runs.ts +235 -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 +72 -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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond refresh-api <name>` — re-fetch the OpenAPI spec and regenerate
|
|
3
|
+
* the four artifacts (spec.json, .api-catalog.yaml, .api-resources.yaml,
|
|
4
|
+
* .api-fixtures.yaml).
|
|
5
|
+
*
|
|
6
|
+
* Without --spec, refresh re-reads from the source recorded at register
|
|
7
|
+
* time (treating workspace-relative paths as the local snapshot — which
|
|
8
|
+
* is a no-op refresh useful only for re-emitting derived artifacts after
|
|
9
|
+
* a builder change). With --spec, the new source is fetched, dereferenced,
|
|
10
|
+
* and replaces the local snapshot.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { getDb } from "../../db/schema.ts";
|
|
16
|
+
import { findCollectionByNameOrId, updateCollection } from "../../db/queries.ts";
|
|
17
|
+
import { findWorkspaceRoot } from "../../core/workspace/root.ts";
|
|
18
|
+
import { readOpenApiSpec } from "../../core/generator/openapi-reader.ts";
|
|
19
|
+
import {
|
|
20
|
+
resolveCollectionSpec,
|
|
21
|
+
writeArtifactsFromDoc,
|
|
22
|
+
SPEC_SNAPSHOT_FILENAME,
|
|
23
|
+
} from "../../core/setup-api.ts";
|
|
24
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
25
|
+
import { printError, printSuccess } from "../output.ts";
|
|
26
|
+
|
|
27
|
+
export interface RefreshApiOptions {
|
|
28
|
+
api: string;
|
|
29
|
+
/** When provided, fetch this source and replace spec.json. Otherwise
|
|
30
|
+
* re-read the existing local snapshot and just rebuild artifacts. */
|
|
31
|
+
spec?: string;
|
|
32
|
+
insecure?: boolean;
|
|
33
|
+
json?: boolean;
|
|
34
|
+
dbPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function refreshApiCommand(opts: RefreshApiOptions): Promise<number> {
|
|
38
|
+
try {
|
|
39
|
+
getDb(opts.dbPath);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const m = `DB unavailable: ${(err as Error).message}`;
|
|
42
|
+
if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const collection = findCollectionByNameOrId(opts.api);
|
|
47
|
+
if (!collection) {
|
|
48
|
+
const m = `API '${opts.api}' not found.`;
|
|
49
|
+
if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!collection.base_dir) {
|
|
54
|
+
const m = `API '${opts.api}' has no base_dir recorded — cannot refresh artifacts.`;
|
|
55
|
+
if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const workspaceRoot = findWorkspaceRoot().root;
|
|
60
|
+
const baseDir = collection.base_dir;
|
|
61
|
+
|
|
62
|
+
// 1. Pick spec source
|
|
63
|
+
let specSource: string;
|
|
64
|
+
let usedExternal: boolean;
|
|
65
|
+
if (opts.spec) {
|
|
66
|
+
specSource = opts.spec;
|
|
67
|
+
usedExternal = true;
|
|
68
|
+
} else if (collection.openapi_spec) {
|
|
69
|
+
specSource = resolveCollectionSpec(collection.openapi_spec);
|
|
70
|
+
usedExternal = false;
|
|
71
|
+
if (!existsSync(specSource) && !/^https?:\/\//i.test(specSource)) {
|
|
72
|
+
const m = `Local spec snapshot missing at ${specSource}. Pass --spec <path|url> to re-pull from upstream.`;
|
|
73
|
+
if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const m = `API '${opts.api}' has no spec recorded. Pass --spec <path|url>.`;
|
|
78
|
+
if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Dereference (fetch if URL)
|
|
83
|
+
let doc: unknown;
|
|
84
|
+
try {
|
|
85
|
+
doc = await readOpenApiSpec(specSource, { insecure: opts.insecure });
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const m = `Failed to read spec ${specSource}: ${(err as Error).message}`;
|
|
88
|
+
if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Determine baseUrl from the doc
|
|
93
|
+
const baseUrl = ((doc as any).servers?.[0]?.url as string | undefined) ?? "";
|
|
94
|
+
|
|
95
|
+
// 4. Write spec.json + 3 artifacts
|
|
96
|
+
writeArtifactsFromDoc({
|
|
97
|
+
doc,
|
|
98
|
+
baseDir,
|
|
99
|
+
apiName: collection.name,
|
|
100
|
+
baseUrl,
|
|
101
|
+
workspaceRoot,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 5. If we pulled fresh from external, ensure the DB points to the local snapshot
|
|
105
|
+
const expectedDbSpec = `apis/${collection.name}/${SPEC_SNAPSHOT_FILENAME}`;
|
|
106
|
+
if (collection.openapi_spec !== expectedDbSpec) {
|
|
107
|
+
updateCollection(collection.id, { openapi_spec: expectedDbSpec });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 6. Surface result
|
|
111
|
+
const localSpec = join(baseDir, SPEC_SNAPSHOT_FILENAME);
|
|
112
|
+
const endpointCount = readEndpointCount(localSpec);
|
|
113
|
+
const result = {
|
|
114
|
+
api: collection.name,
|
|
115
|
+
baseDir,
|
|
116
|
+
spec: localSpec,
|
|
117
|
+
pulledFrom: usedExternal ? specSource : null,
|
|
118
|
+
endpointCount,
|
|
119
|
+
artifacts: [".api-catalog.yaml", ".api-resources.yaml", ".api-fixtures.yaml"],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (opts.json) {
|
|
123
|
+
printJson(jsonOk("refresh-api", result));
|
|
124
|
+
} else {
|
|
125
|
+
printSuccess(`Refreshed '${collection.name}' (${endpointCount} endpoints)${usedExternal ? ` from ${specSource}` : ""}`);
|
|
126
|
+
process.stdout.write(` spec: ${localSpec}\n`);
|
|
127
|
+
process.stdout.write(` artifacts: ${result.artifacts.join(", ")}\n`);
|
|
128
|
+
process.stdout.write(` Run \`zond doctor --api ${collection.name}\` to verify fixtures.\n`);
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readEndpointCount(specPath: string): number {
|
|
134
|
+
try {
|
|
135
|
+
const doc = JSON.parse(readFileSync(specPath, "utf-8")) as any;
|
|
136
|
+
let count = 0;
|
|
137
|
+
for (const item of Object.values(doc.paths ?? {})) {
|
|
138
|
+
if (item && typeof item === "object") {
|
|
139
|
+
for (const k of Object.keys(item as object)) {
|
|
140
|
+
if (["get", "post", "put", "patch", "delete", "head", "options"].includes(k.toLowerCase())) count++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return count;
|
|
145
|
+
} catch {
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
import type { Command } from "commander";
|
|
151
|
+
import { globalJson } from "../resolve.ts";
|
|
152
|
+
|
|
153
|
+
export function registerRefreshApi(program: Command): void {
|
|
154
|
+
program
|
|
155
|
+
.command("refresh-api <name>")
|
|
156
|
+
.description("Re-snapshot the OpenAPI spec into apis/<name>/spec.json and regenerate the 3 artifacts (catalog/resources/fixtures)")
|
|
157
|
+
.option("--spec <path>", "Pull fresh from this path or URL (overrides registered source)")
|
|
158
|
+
.option("--insecure", "Allow self-signed TLS when --spec is an https URL")
|
|
159
|
+
.option("--db <path>", "Path to SQLite database file")
|
|
160
|
+
.action(async (name: string, opts, cmd: Command) => {
|
|
161
|
+
process.exitCode = await refreshApiCommand({
|
|
162
|
+
api: name,
|
|
163
|
+
spec: opts.spec,
|
|
164
|
+
insecure: opts.insecure === true,
|
|
165
|
+
dbPath: typeof opts.db === "string" ? opts.db : undefined,
|
|
166
|
+
json: globalJson(cmd),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond remove api <name>` — unregister an API from the workspace.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `zond add api`. Removes the `collections` row, optionally the
|
|
5
|
+
* associated `runs`/`results`, and (by default) the `apis/<name>/`
|
|
6
|
+
* directory on disk. If the removed API was the active one
|
|
7
|
+
* (`.zond/current-api`), the marker is cleared.
|
|
8
|
+
*
|
|
9
|
+
* Without `--purge`, runs that referenced the collection are detached
|
|
10
|
+
* (`collection_id = NULL`) so historical run data survives the removal.
|
|
11
|
+
* `--purge` deletes them outright. `--keep-files` leaves the directory
|
|
12
|
+
* on disk and only drops the DB row, useful when the user wants to
|
|
13
|
+
* snapshot the artifacts elsewhere first.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, rmSync } from "node:fs";
|
|
17
|
+
import { relative, resolve } from "node:path";
|
|
18
|
+
import { getDb } from "../../db/schema.ts";
|
|
19
|
+
import {
|
|
20
|
+
deleteCollection,
|
|
21
|
+
findCollectionByNameOrId,
|
|
22
|
+
} from "../../db/queries.ts";
|
|
23
|
+
import { findWorkspaceRoot } from "../../core/workspace/root.ts";
|
|
24
|
+
import { clearCurrentApi, readCurrentApi } from "../../core/context/current.ts";
|
|
25
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
26
|
+
import { printError, printSuccess } from "../output.ts";
|
|
27
|
+
|
|
28
|
+
export interface RemoveApiOptions {
|
|
29
|
+
api: string;
|
|
30
|
+
purge?: boolean;
|
|
31
|
+
keepFiles?: boolean;
|
|
32
|
+
yes?: boolean;
|
|
33
|
+
dbPath?: string;
|
|
34
|
+
json?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RemoveApiResult {
|
|
38
|
+
api: string;
|
|
39
|
+
collectionId: number;
|
|
40
|
+
removedDir: string | null;
|
|
41
|
+
detachedRuns: number;
|
|
42
|
+
deletedRuns: number;
|
|
43
|
+
clearedCurrent: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function removeApiCommand(opts: RemoveApiOptions): Promise<number> {
|
|
47
|
+
try {
|
|
48
|
+
getDb(opts.dbPath);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const m = `DB unavailable: ${(err as Error).message}`;
|
|
51
|
+
if (opts.json) printJson(jsonError("remove-api", [m])); else printError(m);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const collection = findCollectionByNameOrId(opts.api);
|
|
56
|
+
if (!collection) {
|
|
57
|
+
const m = `API '${opts.api}' not found.`;
|
|
58
|
+
if (opts.json) printJson(jsonError("remove-api", [m])); else printError(m);
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const db = getDb();
|
|
63
|
+
const runsCount = (db
|
|
64
|
+
.query("SELECT COUNT(*) AS c FROM runs WHERE collection_id = ?")
|
|
65
|
+
.get(collection.id) as { c: number }).c;
|
|
66
|
+
|
|
67
|
+
const workspaceRoot = findWorkspaceRoot().root;
|
|
68
|
+
const dirAbs = collection.base_dir
|
|
69
|
+
? resolve(workspaceRoot, collection.base_dir)
|
|
70
|
+
: null;
|
|
71
|
+
const willRemoveDir = !opts.keepFiles && dirAbs !== null && existsSync(dirAbs);
|
|
72
|
+
const dirRel = dirAbs ? relative(workspaceRoot, dirAbs).replace(/\\/g, "/") : null;
|
|
73
|
+
|
|
74
|
+
if (!opts.yes && !opts.json) {
|
|
75
|
+
const parts = [
|
|
76
|
+
`Removing API '${collection.name}' (id=${collection.id})`,
|
|
77
|
+
willRemoveDir ? ` • directory: ${dirRel}` : ` • directory: kept`,
|
|
78
|
+
opts.purge
|
|
79
|
+
? ` • runs: ${runsCount} runs + their results will be DELETED`
|
|
80
|
+
: ` • runs: ${runsCount} runs will be detached (collection_id=NULL)`,
|
|
81
|
+
];
|
|
82
|
+
process.stderr.write(parts.join("\n") + "\nPass --yes to confirm.\n");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const detachedRuns = opts.purge ? 0 : runsCount;
|
|
87
|
+
const deletedRuns = opts.purge ? runsCount : 0;
|
|
88
|
+
deleteCollection(collection.id, opts.purge === true);
|
|
89
|
+
|
|
90
|
+
let removedDir: string | null = null;
|
|
91
|
+
if (willRemoveDir && dirAbs) {
|
|
92
|
+
rmSync(dirAbs, { recursive: true, force: true });
|
|
93
|
+
removedDir = relative(workspaceRoot, dirAbs).replace(/\\/g, "/");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let clearedCurrent = false;
|
|
97
|
+
const current = readCurrentApi(workspaceRoot);
|
|
98
|
+
if (current === collection.name) {
|
|
99
|
+
clearedCurrent = clearCurrentApi(workspaceRoot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result: RemoveApiResult = {
|
|
103
|
+
api: collection.name,
|
|
104
|
+
collectionId: collection.id,
|
|
105
|
+
removedDir,
|
|
106
|
+
detachedRuns,
|
|
107
|
+
deletedRuns,
|
|
108
|
+
clearedCurrent,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (opts.json) {
|
|
112
|
+
printJson(jsonOk("remove-api", result));
|
|
113
|
+
} else {
|
|
114
|
+
printSuccess(`Removed API '${collection.name}' (id=${collection.id})`);
|
|
115
|
+
if (removedDir) process.stdout.write(` Directory: ${removedDir} (deleted)\n`);
|
|
116
|
+
else if (opts.keepFiles) process.stdout.write(` Directory: ${dirRel ?? "<unknown>"} (kept by --keep-files)\n`);
|
|
117
|
+
if (opts.purge) process.stdout.write(` Runs: ${deletedRuns} deleted (--purge)\n`);
|
|
118
|
+
else if (detachedRuns > 0) process.stdout.write(` Runs: ${detachedRuns} detached (collection_id=NULL)\n`);
|
|
119
|
+
if (clearedCurrent) process.stdout.write(` Cleared .zond/current-api marker (was '${collection.name}')\n`);
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
import type { Command } from "commander";
|
|
125
|
+
import { globalJson } from "../resolve.ts";
|
|
126
|
+
|
|
127
|
+
export function registerRemove(program: Command): void {
|
|
128
|
+
const remove = program
|
|
129
|
+
.command("remove")
|
|
130
|
+
.alias("rm")
|
|
131
|
+
.description("Unregister objects from the workspace");
|
|
132
|
+
remove
|
|
133
|
+
.command("api <name>")
|
|
134
|
+
.description("Unregister an API: drops collections row, removes apis/<name>/, optionally purges run history")
|
|
135
|
+
.option("--purge", "Also delete the runs+results that referenced this API (default: detach to NULL)")
|
|
136
|
+
.option("--keep-files", "Leave apis/<name>/ on disk; only remove the DB record")
|
|
137
|
+
.option("--yes", "Skip the interactive confirmation prompt")
|
|
138
|
+
.option("--db <path>", "Path to SQLite database file")
|
|
139
|
+
.action(async (name: string, opts, cmd: Command) => {
|
|
140
|
+
const json = globalJson(cmd);
|
|
141
|
+
process.exitCode = await removeApiCommand({
|
|
142
|
+
api: name,
|
|
143
|
+
purge: opts.purge === true,
|
|
144
|
+
keepFiles: opts.keepFiles === true,
|
|
145
|
+
yes: opts.yes === true || json,
|
|
146
|
+
dbPath: typeof opts.db === "string" ? opts.db : undefined,
|
|
147
|
+
json,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TASK-143: `zond report bundle <range> [--output <dir>] [--include ...]`.
|
|
3
|
+
*
|
|
4
|
+
* Batch triage exporter — collects case-study + HTML report + diagnose JSON
|
|
5
|
+
* for a range of run-ids in one command, plus a top-level index.md with a
|
|
6
|
+
* table of run-id, totals, and links.
|
|
7
|
+
*
|
|
8
|
+
* Range forms:
|
|
9
|
+
* A..B inclusive numeric range, e.g. "135..142"
|
|
10
|
+
* A,B,C comma-separated list
|
|
11
|
+
* --session <id> all runs for a CLI session (DB column `session_id`)
|
|
12
|
+
*/
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { mkdir } from "fs/promises";
|
|
15
|
+
import { getDb } from "../../db/schema.ts";
|
|
16
|
+
import { getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
|
|
17
|
+
import type { RunRecord, StoredStepResult } from "../../db/queries/types.ts";
|
|
18
|
+
import { renderHtmlReport } from "../../core/exporter/html-report/index.ts";
|
|
19
|
+
import { renderCaseStudy } from "../../core/exporter/case-study/index.ts";
|
|
20
|
+
import { diagnoseRun } from "../../core/diagnostics/db-analysis.ts";
|
|
21
|
+
import { applySanitizer } from "../../core/exporter/exporter.ts";
|
|
22
|
+
import { resolveCollectionSpec } from "../../core/setup-api.ts";
|
|
23
|
+
import { readOpenApiSpec } from "../../core/generator/openapi-reader.ts";
|
|
24
|
+
import { printError, printWarning } from "../output.ts";
|
|
25
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
26
|
+
import { VERSION } from "../version.ts";
|
|
27
|
+
|
|
28
|
+
export type BundleArtifact = "case-study" | "export" | "diagnose";
|
|
29
|
+
|
|
30
|
+
export interface ReportBundleOptions {
|
|
31
|
+
/** "A..B" range, "A,B,C" list, or unused when sessionId is set. */
|
|
32
|
+
range?: string;
|
|
33
|
+
/** Resolve runs by session_id instead of explicit ids. */
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
output?: string;
|
|
36
|
+
include?: BundleArtifact[];
|
|
37
|
+
bodyCapBytes?: number;
|
|
38
|
+
dbPath?: string;
|
|
39
|
+
json?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_BODY_CAP_BYTES = 8192;
|
|
43
|
+
const ALL_ARTIFACTS: BundleArtifact[] = ["case-study", "export", "diagnose"];
|
|
44
|
+
|
|
45
|
+
interface BundleEntry {
|
|
46
|
+
runId: number;
|
|
47
|
+
spec: string | null;
|
|
48
|
+
totals: { total: number; passed: number; failed: number; skipped: number };
|
|
49
|
+
caseStudy?: string;
|
|
50
|
+
htmlReport?: string;
|
|
51
|
+
diagnose?: string;
|
|
52
|
+
agentDirective?: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseBundleRange(input: string): number[] | { error: string } {
|
|
56
|
+
const trimmed = input.trim();
|
|
57
|
+
if (!trimmed) return { error: "empty range" };
|
|
58
|
+
|
|
59
|
+
const rangeMatch = trimmed.match(/^(\d+)\.\.(\d+)$/);
|
|
60
|
+
if (rangeMatch) {
|
|
61
|
+
const a = parseInt(rangeMatch[1]!, 10);
|
|
62
|
+
const b = parseInt(rangeMatch[2]!, 10);
|
|
63
|
+
if (a > b) return { error: `range start ${a} is greater than end ${b}` };
|
|
64
|
+
const out: number[] = [];
|
|
65
|
+
for (let i = a; i <= b; i++) out.push(i);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (trimmed.includes(",")) {
|
|
70
|
+
const parts = trimmed.split(",").map(s => s.trim()).filter(Boolean);
|
|
71
|
+
const ids: number[] = [];
|
|
72
|
+
for (const p of parts) {
|
|
73
|
+
const n = parseInt(p, 10);
|
|
74
|
+
if (!Number.isFinite(n) || n <= 0) return { error: `not a positive integer: ${p}` };
|
|
75
|
+
ids.push(n);
|
|
76
|
+
}
|
|
77
|
+
return Array.from(new Set(ids)).sort((a, b) => a - b);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const n = parseInt(trimmed, 10);
|
|
81
|
+
if (!Number.isFinite(n) || n <= 0) return { error: `not a recognised range: ${trimmed}` };
|
|
82
|
+
return [n];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function reportBundleCommand(options: ReportBundleOptions): Promise<number> {
|
|
86
|
+
try {
|
|
87
|
+
getDb(options.dbPath);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const msg = `Failed to open database: ${(err as Error).message}`;
|
|
90
|
+
if (options.json) printJson(jsonError("report bundle", [msg]));
|
|
91
|
+
else printError(msg);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let runIds: number[];
|
|
96
|
+
if (options.sessionId) {
|
|
97
|
+
runIds = listRunIdsBySession(options.sessionId);
|
|
98
|
+
if (runIds.length === 0) {
|
|
99
|
+
const msg = `No runs found for --session ${options.sessionId}`;
|
|
100
|
+
if (options.json) printJson(jsonError("report bundle", [msg]));
|
|
101
|
+
else printError(msg);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
if (!options.range) {
|
|
106
|
+
const msg = "report bundle requires <range> (A..B / A,B,C) or --session <id>";
|
|
107
|
+
if (options.json) printJson(jsonError("report bundle", [msg]));
|
|
108
|
+
else printError(msg);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
const parsed = parseBundleRange(options.range);
|
|
112
|
+
if (!Array.isArray(parsed)) {
|
|
113
|
+
const msg = `Invalid range: ${parsed.error}. Examples: 135..142, 135,137,141`;
|
|
114
|
+
if (options.json) printJson(jsonError("report bundle", [msg]));
|
|
115
|
+
else printError(msg);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
runIds = parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const include = options.include && options.include.length > 0 ? options.include : ALL_ARTIFACTS;
|
|
122
|
+
const outDir = options.output ?? join("triage", "bundle", new Date().toISOString().replace(/[:.]/g, "-"));
|
|
123
|
+
await mkdir(outDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const entries: BundleEntry[] = [];
|
|
126
|
+
const skipped: Array<{ runId: number; reason: string }> = [];
|
|
127
|
+
const bodyCap = options.bodyCapBytes ?? DEFAULT_BODY_CAP_BYTES;
|
|
128
|
+
|
|
129
|
+
for (const runId of runIds) {
|
|
130
|
+
const run = getRunById(runId);
|
|
131
|
+
if (!run) {
|
|
132
|
+
skipped.push({ runId, reason: "not found" });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const results = getResultsByRunId(runId);
|
|
136
|
+
const runDir = join(outDir, String(runId));
|
|
137
|
+
await mkdir(runDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const entry: BundleEntry = {
|
|
140
|
+
runId,
|
|
141
|
+
spec: await loadSpecTitle(run),
|
|
142
|
+
totals: {
|
|
143
|
+
total: run.total ?? 0,
|
|
144
|
+
passed: run.passed ?? 0,
|
|
145
|
+
failed: run.failed ?? 0,
|
|
146
|
+
skipped: run.skipped ?? 0,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (include.includes("export")) {
|
|
151
|
+
const html = renderHtmlReport({
|
|
152
|
+
run,
|
|
153
|
+
results,
|
|
154
|
+
zondVersion: VERSION,
|
|
155
|
+
generatedAt: new Date(),
|
|
156
|
+
collectionName: run.collection_id != null ? getCollectionById(run.collection_id)?.name ?? null : null,
|
|
157
|
+
bodyCapBytes: bodyCap,
|
|
158
|
+
});
|
|
159
|
+
const file = join(runDir, "report.html");
|
|
160
|
+
await Bun.write(file, html);
|
|
161
|
+
entry.htmlReport = file;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (include.includes("case-study")) {
|
|
165
|
+
const failure = pickPrimaryFailure(results);
|
|
166
|
+
if (failure) {
|
|
167
|
+
const specDoc = await loadSpecDoc(run);
|
|
168
|
+
const md = applySanitizer(renderCaseStudy({
|
|
169
|
+
result: failure,
|
|
170
|
+
run,
|
|
171
|
+
specTitle: entry.spec,
|
|
172
|
+
specVersion: null,
|
|
173
|
+
zondVersion: VERSION,
|
|
174
|
+
bodyCapBytes: bodyCap,
|
|
175
|
+
apiName: collectionApiName(run),
|
|
176
|
+
specDoc: specDoc as Parameters<typeof renderCaseStudy>[0]["specDoc"],
|
|
177
|
+
}));
|
|
178
|
+
const file = join(runDir, "case-study.md");
|
|
179
|
+
await Bun.write(file, md);
|
|
180
|
+
entry.caseStudy = file;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (include.includes("diagnose")) {
|
|
185
|
+
const diag = diagnoseRun(runId, false, options.dbPath, 5);
|
|
186
|
+
const file = join(runDir, "diagnose.json");
|
|
187
|
+
await Bun.write(file, JSON.stringify(diag, null, 2));
|
|
188
|
+
entry.diagnose = file;
|
|
189
|
+
entry.agentDirective = (diag as unknown as { agent_directive?: string }).agent_directive ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
entries.push(entry);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (entries.length === 0) {
|
|
196
|
+
const msg = `No runs in [${runIds.join(", ")}] resolved to existing rows`;
|
|
197
|
+
if (options.json) printJson(jsonError("report bundle", [msg]));
|
|
198
|
+
else printError(msg);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const indexPath = join(outDir, "index.md");
|
|
203
|
+
await Bun.write(indexPath, renderIndex(entries, skipped));
|
|
204
|
+
|
|
205
|
+
if (options.json) {
|
|
206
|
+
printJson(
|
|
207
|
+
jsonOk("report bundle", {
|
|
208
|
+
outputDir: outDir,
|
|
209
|
+
index: indexPath,
|
|
210
|
+
entries: entries.map(e => ({
|
|
211
|
+
runId: e.runId,
|
|
212
|
+
spec: e.spec,
|
|
213
|
+
totals: e.totals,
|
|
214
|
+
caseStudy: e.caseStudy ?? null,
|
|
215
|
+
htmlReport: e.htmlReport ?? null,
|
|
216
|
+
diagnose: e.diagnose ?? null,
|
|
217
|
+
agentDirective: e.agentDirective ?? null,
|
|
218
|
+
})),
|
|
219
|
+
skipped,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
if (skipped.length > 0) {
|
|
224
|
+
for (const s of skipped) printWarning(`Run #${s.runId} skipped: ${s.reason}`);
|
|
225
|
+
}
|
|
226
|
+
// TASK-241: status → stderr; stdout carries only the bundle dir path.
|
|
227
|
+
process.stderr.write(`zond: bundle written (${entries.length} run(s), index: ${indexPath})\n`,);
|
|
228
|
+
process.stdout.write(`${outDir}\n`);
|
|
229
|
+
}
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function listRunIdsBySession(sessionId: string): number[] {
|
|
234
|
+
const db = getDb();
|
|
235
|
+
const rows = db.query("SELECT id FROM runs WHERE session_id = ? ORDER BY started_at ASC")
|
|
236
|
+
.all(sessionId) as Array<{ id: number }>;
|
|
237
|
+
return rows.map(r => r.id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function loadSpecTitle(run: RunRecord): Promise<string | null> {
|
|
241
|
+
if (run.collection_id == null) return null;
|
|
242
|
+
const collection = getCollectionById(run.collection_id);
|
|
243
|
+
if (!collection?.openapi_spec) return collection?.name ?? null;
|
|
244
|
+
try {
|
|
245
|
+
const doc = await readOpenApiSpec(resolveCollectionSpec(collection.openapi_spec));
|
|
246
|
+
return doc.info?.title ?? collection.name ?? null;
|
|
247
|
+
} catch {
|
|
248
|
+
return collection.name ?? null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** ARV-107: load the parsed spec document for the run's collection so the
|
|
253
|
+
* case-study renderer can auto-extract the operation slice. */
|
|
254
|
+
async function loadSpecDoc(run: RunRecord): Promise<unknown | null> {
|
|
255
|
+
if (run.collection_id == null) return null;
|
|
256
|
+
const collection = getCollectionById(run.collection_id);
|
|
257
|
+
if (!collection?.openapi_spec) return null;
|
|
258
|
+
try {
|
|
259
|
+
return await readOpenApiSpec(resolveCollectionSpec(collection.openapi_spec));
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** ARV-106/107: short registry slug (`--api <name>` form) for the run's
|
|
266
|
+
* collection, used to fill the case-study `apiName` option. */
|
|
267
|
+
function collectionApiName(run: RunRecord): string | null {
|
|
268
|
+
if (run.collection_id == null) return null;
|
|
269
|
+
return getCollectionById(run.collection_id)?.name ?? null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function pickPrimaryFailure(results: StoredStepResult[]): StoredStepResult | null {
|
|
273
|
+
// Prefer a result classified as a real bug.
|
|
274
|
+
const bugFirst = results.find(
|
|
275
|
+
r => r.status !== "pass" && (r.failure_class === "definitely_bug" || r.failure_class === "likely_bug"),
|
|
276
|
+
);
|
|
277
|
+
if (bugFirst) return bugFirst;
|
|
278
|
+
return results.find(r => r.status === "fail" || r.status === "5xx") ?? null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderIndex(entries: BundleEntry[], skipped: Array<{ runId: number; reason: string }>): string {
|
|
282
|
+
const lines: string[] = [];
|
|
283
|
+
lines.push("# Bundle index", "");
|
|
284
|
+
lines.push(`Generated: ${new Date().toISOString()}`);
|
|
285
|
+
lines.push(`Runs: ${entries.length}` + (skipped.length > 0 ? ` (skipped: ${skipped.length})` : ""));
|
|
286
|
+
lines.push("");
|
|
287
|
+
lines.push("| Run | Spec | Total | Pass | Fail | Skip | Artefacts | Directive |");
|
|
288
|
+
lines.push("|----:|------|------:|-----:|-----:|-----:|-----------|-----------|");
|
|
289
|
+
|
|
290
|
+
for (const e of entries) {
|
|
291
|
+
const links: string[] = [];
|
|
292
|
+
if (e.caseStudy) links.push(`[case-study](${rel(e.caseStudy)})`);
|
|
293
|
+
if (e.htmlReport) links.push(`[html](${rel(e.htmlReport)})`);
|
|
294
|
+
if (e.diagnose) links.push(`[diagnose](${rel(e.diagnose)})`);
|
|
295
|
+
lines.push(
|
|
296
|
+
`| ${e.runId} | ${e.spec ?? "—"} | ${e.totals.total} | ${e.totals.passed} | ${e.totals.failed} | ${e.totals.skipped} | ${links.join(" · ") || "—"} | ${truncate(e.agentDirective ?? "", 80)} |`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (skipped.length > 0) {
|
|
301
|
+
lines.push("", "## Skipped", "");
|
|
302
|
+
for (const s of skipped) lines.push(`- run #${s.runId} — ${s.reason}`);
|
|
303
|
+
}
|
|
304
|
+
return lines.join("\n") + "\n";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function rel(p: string): string {
|
|
308
|
+
// Index lives at <dir>/index.md, run files at <dir>/<id>/<file> — strip the
|
|
309
|
+
// shared parent so relative links still work after the dir is moved.
|
|
310
|
+
const seg = p.split(/[/\\]/);
|
|
311
|
+
const tail = seg.slice(-2);
|
|
312
|
+
return tail.join("/");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function truncate(s: string, n: number): string {
|
|
316
|
+
if (s.length <= n) return s.replace(/\n/g, " ");
|
|
317
|
+
return s.slice(0, n - 1).replace(/\n/g, " ") + "…";
|
|
318
|
+
}
|