From db55ca142fb5ce23272676f9d57582ec15a6cf52 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 24 May 2026 18:08:29 -0400 Subject: [PATCH 1/6] feat: validate end-to-end pack test flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five focused changes so downstream packs can scaffold from this template with confidence the harness has teeth and the artifact ships clean: 1. Harness teeth meta-tests (tests/harness-teeth.test.ts) — prove every assertion guard (assertPartialMatch, assertRequiredFields, parseSimpleFilter) fires on its target failure mode. Pure unit; no Cribl required. Locks in the assertion contract against silent refactor regressions. 2. New-pack scaffolding runbook (docs/new-pack.md) — 6-step "I cloned the template, what do I edit" guide consolidating the org-admin token wrapper, required package.json fields, fixture authorship, and matrix override. 3. Tarball drift guard (tests/tarball-parity.test.ts) — pins the test-path whitelist (PACK_ROOT_ENTRIES in cribl-client.ts, now exported) to the release-path whitelist (INCLUDE in build-crbl.sh). Drift would silently ship a tarball CI never validated. 4. Eliminate silent skips in route filter tests — parseSimpleFilter returns a discriminated union; routes.test.ts fails loudly when it encounters a filter form the local matcher can't auto-evaluate. Authors must either simplify the filter or extend parse-filter.ts. 5. Multi-version Cribl matrix (.github/workflows/cribl-pack-test.yml) — cribl_versions input drives a parallel test matrix. Default: `latest` required + `3.5.4` best-effort (continue-on-error). Adding a version = one JSON entry. Per-pack override supported. Coverage matrix + version policy documented in docs/test-harness.md. Assisted-by: Claude --- .github/workflows/cribl-pack-test.yml | 23 +++- AGENTS.md | 1 + README.md | 12 +- docs/new-pack.md | 112 +++++++++++++++++++ docs/test-harness.md | 63 ++++++++++- tests/cribl-client.ts | 11 +- tests/harness-teeth.test.ts | 154 ++++++++++++++++++++++++++ tests/parse-filter.ts | 27 +++-- tests/pipelines.test.ts | 32 +----- tests/routes.test.ts | 78 ++++++------- tests/tarball-parity.test.ts | 135 ++++++++++++++++++++++ tests/test-helpers.ts | 43 ++++++- 12 files changed, 603 insertions(+), 88 deletions(-) create mode 100644 docs/new-pack.md create mode 100644 tests/harness-teeth.test.ts create mode 100644 tests/tarball-parity.test.ts diff --git a/.github/workflows/cribl-pack-test.yml b/.github/workflows/cribl-pack-test.yml index 52e1db9..3bd173d 100644 --- a/.github/workflows/cribl-pack-test.yml +++ b/.github/workflows/cribl-pack-test.yml @@ -15,10 +15,15 @@ on: description: "Pack type — 'edge' or 'stream'." type: string required: true - cribl_version: - description: "cribl/cribl Docker image tag. Defaults to 'latest'." + cribl_versions: + description: >- + JSON array of {version, required} objects. Each entry spawns one + parallel test leg against `cribl/cribl:`. Legs with + `required: true` block PR merges on failure; `required: false` legs + run best-effort (failures visible but non-blocking). Default covers + `latest` plus the most recent previous-major patch (3.5.4 EOL). type: string - default: 'latest' + default: '[{"version":"latest","required":true},{"version":"3.5.4","required":false}]' node_version: description: 'Node.js version for the test runner.' type: string @@ -56,14 +61,22 @@ jobs: run: ./scripts/validate-pack-structure.sh test: - name: Test pack pipelines (Vitest) + # One leg per entry in `cribl_versions`. Required legs block PR merges; + # optional legs run best-effort. `fail-fast: false` so every leg's result + # is visible regardless of sibling outcomes. + name: Test pack pipelines (Cribl ${{ matrix.cribl.version }}) runs-on: ubuntu-latest needs: validate + strategy: + fail-fast: false + matrix: + cribl: ${{ fromJSON(inputs.cribl_versions) }} + continue-on-error: ${{ !matrix.cribl.required }} env: PACK_TYPE: ${{ inputs.pack_type }} services: cribl: - image: cribl/cribl:${{ inputs.cribl_version }} + image: cribl/cribl:${{ matrix.cribl.version }} ports: - 9000:9000 - 10080:10080 diff --git a/AGENTS.md b/AGENTS.md index 0aacb8e..a223db1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ scaffold from here via `gh repo create --template`. | Local development setup | [`docs/development.md`](docs/development.md) | | Release process | [`docs/release-process.md`](docs/release-process.md) | | Validator rules | [`docs/validator-rules.md`](docs/validator-rules.md) | +| Scaffolding a new pack from this template | [`docs/new-pack.md`](docs/new-pack.md) | ## Top-level rules diff --git a/README.md b/README.md index 3bf3fef..aa53c08 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ that wraps the common day-to-day commands. ## Installation -Create a new pack repo from this template: +Full end-to-end runbook: [`docs/new-pack.md`](docs/new-pack.md). Short version: ```sh gh repo create dryvist/cc-edge--io \ @@ -26,9 +26,10 @@ cd cc-edge--io make install # installs Node deps + git hooks ``` -`make install` requires `node` (20+) and `pnpm` (10+). Docker is only needed -when you run `make test`. See [`docs/development.md`](docs/development.md) for -installation paths. +Repo creation requires the org-admin token wrapper — see `docs/new-pack.md` +step 1. `make install` requires `node` (20+) and `pnpm` (10+). Docker is only +needed when you run `make test`. See [`docs/development.md`](docs/development.md) +for installation paths. ## After scaffolding @@ -69,8 +70,9 @@ version bump on every push to `main`; merge that PR to publish a release. See | Doc | What it covers | |---|---| +| [`docs/new-pack.md`](docs/new-pack.md) | Step-by-step runbook for creating a new pack from this template | | [`docs/development.md`](docs/development.md) | Local dev setup, Make targets, optional Nix shell | -| [`docs/test-harness.md`](docs/test-harness.md) | What gets tested, fixture conventions | +| [`docs/test-harness.md`](docs/test-harness.md) | What gets tested, fixture conventions, Cribl version matrix | | [`docs/file-boundary.md`](docs/file-boundary.md) | Generic vs pack-specific files (sync rules) | | [`docs/release-process.md`](docs/release-process.md) | release-please flow, version bump rules | | [`docs/validator-rules.md`](docs/validator-rules.md) | vct-cribl-pack-validator rules + how they're enforced | diff --git a/docs/new-pack.md b/docs/new-pack.md new file mode 100644 index 0000000..4e1877f --- /dev/null +++ b/docs/new-pack.md @@ -0,0 +1,112 @@ +# New pack from this template + +End-to-end runbook for creating a new Cribl pack repo from +`dryvist/cc-edge-pack-template`. Six steps, ~15 minutes. + +## 1. Create the repo + +Repo creation in the dryvist org requires the org-admin PAT (the day-to-day +`GH_PAT_DRYVIST` cannot create repos). One-off wrapper: + +```sh +GH_TOKEN=$(security find-generic-password -s GH_PAT_ORG_ADMIN -a ai-cli-coder \ + -w ~/Library/Keychains/elevate-access.keychain-db) \ + gh repo create dryvist/cc-edge--io \ + --template dryvist/cc-edge-pack-template \ + --public --clone +``` + +Naming convention enforced by `scripts/validate-pack-structure.sh`: +`cc-{edge,stream}--io`. The validator emits a warning (not an error) +for non-matching names, so deviation is possible but discouraged. + +## 2. Install deps + git hooks + +```sh +cd cc-edge--io +make install +``` + +Requires Node 20+ and pnpm 10+. Docker is only needed when you run `make test`. + +## 3. Edit the manifest + +`package.json` fields the validator hard-requires (`validate-pack-structure.sh` +will fail CI if any is missing or blank): + +- `name` — e.g. `cc-edge-myservice-io` +- `version` — release-please rewrites this; the template ships `0.0.1` +- `minLogStreamVersion` — minimum Cribl version your pipeline relies on + (e.g. `4.10.0`). See your Cribl release notes for the feature you depend on. + +Soft-recommended (not validator-enforced, but used by the Cribl UI): +`displayName`, `description`, `tags`, `author`. + +If this is a Cribl Stream pack (not Edge), flip +`.github/workflows/test.yml`: `pack_type: stream`. + +## 4. Write your pipeline + first fixture + +Replace the demo passthrough with the real pipeline: + +- Pipeline config: `default/pipelines//conf.yml` +- Route filter: edit `default/pipelines/route.yml` so its filter matches your + expected input shape and points at `` +- Remove the demo: delete `default/pipelines/passthrough/` and + `tests/fixtures/passthrough/` + +Create the first fixture pair (the harness auto-discovers — no test code +edits needed): + +```sh +mkdir -p tests/fixtures/ +# Paste a real input event: +${EDITOR} tests/fixtures//baseline.json +# Paste the desired output (partial-match: only fields you assert on): +${EDITOR} tests/fixtures//baseline.expected.json +``` + +See [`test-harness.md`](test-harness.md) for the fixture convention. + +## 5. Run tests locally + +```sh +make docker-up && make test +``` + +`make test` runs `pnpm vitest` against the live Cribl container. All three test +files run: `routes.test.ts`, `pipelines.test.ts` (your fixtures), +`harness-teeth.test.ts` and `tarball-parity.test.ts` (template-level guards). + +## 6. Push + let CI close it out + +```sh +git checkout -b feat/initial-pack +git add . && git commit -m "feat: initial pipeline + fixture" +git push -u origin feat/initial-pack +gh pr create --fill +``` + +CI runs validate + the multi-version Cribl matrix (`latest` required; older +majors best-effort per [`test-harness.md`](test-harness.md)). Once merged, +release-please opens a release PR; merging that publishes the `.crbl` to +GitHub Releases. + +## Cribl version matrix overrides + +The reusable test workflow defaults to testing against `cribl/cribl:latest` +(required) plus the most recent previous-major patch (best-effort). If your +pack needs to pin or extend, override in your `.github/workflows/test.yml`: + +```yaml +uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main +with: + pack_type: edge + cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":true}]' +``` + +## Branch protection (one-time, per repo) + +After the first green CI run, require the `Test pack pipelines (Cribl latest)` +status check in the repo's branch protection rules. Older-version legs stay +visible but non-blocking. diff --git a/docs/test-harness.md b/docs/test-harness.md index 758c191..2556619 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -10,10 +10,29 @@ convention — no TypeScript edits needed to add tests. | `routes.test.ts` (structure) | route.yml exists, every route has a pipeline, every referenced pipeline file exists, routes use `output: __group`, filters aren't statically falsy, no pipeline named `main` | | `routes.test.ts` (dynamic flow) | Per route: a synthetic event matching its filter triggers the named pipeline and isn't dropped (uses live Cribl) | | `pipelines.test.ts` | Per fixture: pipeline produces non-empty output; partial-match against `.expected.json` if present; required-fields assertion (`sourcetype`+`index` for Edge; `host`+`source`+`_time` for Stream) unless `.skip-required-fields` marker present | +| `tarball-parity.test.ts` | The whitelist in `tests/cribl-client.ts::PACK_ROOT_ENTRIES` (used by every test-time pack install) matches `INCLUDE=` in `scripts/build-crbl.sh` (used by every release). Catches drift before a release ships a tarball CI never validated. | +| `harness-teeth.test.ts` | Meta-tests: every assertion helper used by the suites above actually throws on its target failure mode. Pure unit-level; no Cribl required. | + +## Coverage matrix (what the harness proves it catches) + +Every guard listed here has at least one teeth-test in `harness-teeth.test.ts`. +Adding a guard? Add a teeth-test in the same PR. + +| Guard | Lives in | Catches | Proven by | +|---|---|---|---| +| `assertPartialMatch` — missing expected key | `tests/test-helpers.ts` | Pipeline drops a field the fixture expects | `harness-teeth.test.ts` → "throws when an expected key is missing" | +| `assertPartialMatch` — wrong value | `tests/test-helpers.ts` | Pipeline sets a field to the wrong value | `harness-teeth.test.ts` → "throws when an expected value differs" | +| `assertPartialMatch` — event-count mismatch | `tests/test-helpers.ts` | Pipeline duplicates/drops events | `harness-teeth.test.ts` → "throws on event-count mismatch (actual longer/shorter)" | +| Smoke assertion (`length > 0`) | `tests/pipelines.test.ts` (inline) | Pipeline drops every event under a real filter/eval | `harness-teeth.test.ts` covers the underlying `expect().toBeGreaterThan` indirectly via Vitest; observed in CI when a pipeline regression empties output | +| `assertRequiredFields` — missing edge field | `tests/cribl-client.ts` | Edge pipeline output lacks `sourcetype` or `index` | `harness-teeth.test.ts` → "throws when edge sourcetype/index is missing" | +| `assertRequiredFields` — missing stream field | `tests/cribl-client.ts` | Stream pipeline output lacks `host`/`source`/`_time` | `harness-teeth.test.ts` → "throws when stream host/_time is missing" | +| Tarball whitelist drift | `tests/tarball-parity.test.ts` | `build-crbl.sh` and `createPackTarball` diverge | `tarball-parity.test.ts` (also self-proving: flip an `INCLUDE=` entry and watch CI go red) | + +If you add a new assertion helper, add a teeth-test in the same PR or coverage rots silently. ## Fixture convention -``` +```text tests/fixtures//.json # input event(s) tests/fixtures//.expected.json # optional partial-match expected output tests/fixtures/.skip-required-fields # optional org-wide opt-out marker @@ -23,6 +42,48 @@ The generic `pipelines.test.ts` auto-discovers and parametrizes one Vitest case per `.json`. Add a fixture → tests run automatically. Remove a fixture → tests stop running. No code changes. +## Cribl version matrix + +The reusable test workflow (`cribl-pack-test.yml`) runs Vitest in parallel +against every Cribl version listed in its `cribl_versions` input. Single +source of truth: the input's JSON-array default. + +Shape: + +```json +[ + {"version": "latest", "required": true}, + {"version": "3.5.4", "required": false} +] +``` + +- `required: true` — failure blocks PR merges (required status check) +- `required: false` — failure is visible but non-blocking (best-effort smoke) + +Each leg posts a distinct GitHub status check named +`Test pack pipelines (Cribl )`, so branch-protection rules can +require the `latest` check specifically while leaving older legs informational. + +**Extending the matrix.** Add a JSON entry. No other edits. For example, to +also pin against the previous minor of the current major: + +```yaml +# In .github/workflows/test.yml (the per-pack caller) +uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main +with: + pack_type: edge + cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"3.5.4","required":false}]' +``` + +**Version policy.** + +- `latest` always floats — tracks the current major's newest patch (4.x today; + will track 5.x once Cribl ships it). +- Pinned older entries should be exact patches (e.g. `3.5.4`, `4.17.1`) since + EOL or older lines don't get new patches. +- When the current major changes, pin the last patch of the outgoing major as + a new entry: `{"version": "4.18.1", "required": false}`. + ## Required-fields assertion Edge packs are expected to set `sourcetype` and `index` (typically via an Eval diff --git a/tests/cribl-client.ts b/tests/cribl-client.ts index 7f71751..4be3fbc 100644 --- a/tests/cribl-client.ts +++ b/tests/cribl-client.ts @@ -62,7 +62,10 @@ const REQUIRED_FIELDS: Record = { // as repo-level dev tooling (flake.nix, biome.jsonc, etc.) accumulates. // node-tar's create() recurses into the directory entries automatically and // emits proper directory headers (Cribl rejects tarballs missing them). -const PACK_ROOT_ENTRIES = new Set([ +// +// Exported so `tarball-parity.test.ts` can assert it stays in sync with the +// `INCLUDE=(...)` line in `scripts/build-crbl.sh` (the release-path whitelist). +export const PACK_ROOT_ENTRIES = new Set([ "data", "default", "package.json", @@ -400,11 +403,11 @@ export class CriblClient { for (const route of config.routes ?? []) { if (route.disabled === true) continue; const parsed = parseSimpleFilter(route.filter); - if (parsed === null) { - skippedFilters.push(route.filter ?? ""); + if (parsed.kind === "unsupported") { + skippedFilters.push(parsed.expression); continue; } - const [field, expectedValue] = parsed; + const { field, value: expectedValue } = parsed; const matches = events.some((event) => event[field] === expectedValue); if (matches) { const output = await this.runPipeline( diff --git a/tests/harness-teeth.test.ts b/tests/harness-teeth.test.ts new file mode 100644 index 0000000..6a86b59 --- /dev/null +++ b/tests/harness-teeth.test.ts @@ -0,0 +1,154 @@ +/** + * Harness teeth — meta-tests that prove the assertion helpers used by + * pipelines.test.ts actually catch the failure modes they claim to catch. + * + * GENERIC: copied verbatim from the template into every pack repo. + * + * These are pure unit tests over the assertion helpers — no Cribl required, + * no fixtures consumed. They exist so that a future refactor that silently + * breaks `assertPartialMatch` or `assertRequiredFields` (e.g. drops a guard, + * narrows a check, swaps a `!==` for `==`) fails CI on every pack that + * inherits this harness. Without these, "the tests pass" only tells us the + * fixtures we happened to write didn't fail; it tells us nothing about + * whether the assertion library can catch a real pipeline regression. + * + * Each test phrases a known-bad input and asserts the helper throws with a + * recognizable diagnostic. If you add a new assertion helper to + * `test-helpers.ts` or `cribl-client.ts`, add a teeth test here. + */ + +import { describe, expect, it } from "vitest"; +import { CriblClient, type CriblEvent } from "./cribl-client.js"; +import { parseSimpleFilter } from "./parse-filter.js"; +import { assertPartialMatch } from "./test-helpers.js"; + +describe("assertPartialMatch (teeth)", () => { + it("passes when actual matches expected exactly", () => { + expect(() => + assertPartialMatch([{ a: 1, b: "x" }], [{ a: 1, b: "x" }], "ctx"), + ).not.toThrow(); + }); + + it("passes when actual has extra keys beyond expected (partial-match contract)", () => { + expect(() => + assertPartialMatch( + [{ a: 1, b: "x", extra: true }], + [{ a: 1, b: "x" }], + "ctx", + ), + ).not.toThrow(); + }); + + it("throws when an expected key is missing from actual", () => { + expect(() => + assertPartialMatch([{ a: 1 }], [{ a: 1, b: "x" }], "fixture-a"), + ).toThrow(/expected key 'b' missing/); + }); + + it("throws when an expected value differs from actual", () => { + expect(() => + assertPartialMatch([{ a: 1 }], [{ a: 2 }], "fixture-b"), + ).toThrow(/key 'a'/); + }); + + it("throws on event-count mismatch (actual longer)", () => { + expect(() => + assertPartialMatch([{ a: 1 }, { a: 2 }], [{ a: 1 }], "fixture-c"), + ).toThrow(/produced 2 events, expected 1/); + }); + + it("throws on event-count mismatch (actual shorter)", () => { + expect(() => + assertPartialMatch([{ a: 1 }], [{ a: 1 }, { a: 2 }], "fixture-d"), + ).toThrow(/produced 1 events, expected 2/); + }); +}); + +describe("CriblClient.assertRequiredFields (teeth)", () => { + it("passes when edge required fields are present", () => { + const events: CriblEvent[] = [{ sourcetype: "s", index: "main" }]; + expect(() => + CriblClient.assertRequiredFields(events, "edge"), + ).not.toThrow(); + }); + + it("throws when edge sourcetype is missing", () => { + const events: CriblEvent[] = [{ index: "main" }]; + expect(() => CriblClient.assertRequiredFields(events, "edge")).toThrow( + /sourcetype/, + ); + }); + + it("throws when edge index is missing", () => { + const events: CriblEvent[] = [{ sourcetype: "s" }]; + expect(() => CriblClient.assertRequiredFields(events, "edge")).toThrow( + /index/, + ); + }); + + it("passes when stream required fields are present", () => { + const events: CriblEvent[] = [{ host: "h", source: "src", _time: 0 }]; + expect(() => + CriblClient.assertRequiredFields(events, "stream"), + ).not.toThrow(); + }); + + it("throws when stream host is missing", () => { + const events: CriblEvent[] = [{ source: "src", _time: 0 }]; + expect(() => CriblClient.assertRequiredFields(events, "stream")).toThrow( + /host/, + ); + }); + + it("throws when stream _time is missing", () => { + const events: CriblEvent[] = [{ host: "h", source: "src" }]; + expect(() => CriblClient.assertRequiredFields(events, "stream")).toThrow( + /_time/, + ); + }); + + it("reports the count of violating events", () => { + const events: CriblEvent[] = [ + { sourcetype: "s", index: "main" }, + { sourcetype: "s" }, + {}, + ]; + expect(() => CriblClient.assertRequiredFields(events, "edge")).toThrow( + /2\/3 event\(s\)/, + ); + }); +}); + +describe("parseSimpleFilter (teeth)", () => { + it("classifies canonical equality as simple", () => { + const parsed = parseSimpleFilter("datatype=='cribl-demo'"); + expect(parsed.kind).toBe("simple"); + if (parsed.kind === "simple") { + expect(parsed.field).toBe("datatype"); + expect(parsed.value).toBe("cribl-demo"); + } + }); + + it("classifies double-quoted equality as simple", () => { + const parsed = parseSimpleFilter('sourcetype=="json"'); + expect(parsed.kind).toBe("simple"); + }); + + it("classifies boolean expressions as unsupported (not silently simple)", () => { + const parsed = parseSimpleFilter("a=='x' && b=='y'"); + expect(parsed.kind).toBe("unsupported"); + if (parsed.kind === "unsupported") { + expect(parsed.expression).toBe("a=='x' && b=='y'"); + } + }); + + it("classifies function calls as unsupported", () => { + const parsed = parseSimpleFilter("includes(_raw, 'error')"); + expect(parsed.kind).toBe("unsupported"); + }); + + it("classifies null/undefined input as unsupported with empty expression", () => { + expect(parseSimpleFilter(null).kind).toBe("unsupported"); + expect(parseSimpleFilter(undefined).kind).toBe("unsupported"); + }); +}); diff --git a/tests/parse-filter.ts b/tests/parse-filter.ts index 73c872a..01d9d3c 100644 --- a/tests/parse-filter.ts +++ b/tests/parse-filter.ts @@ -1,18 +1,31 @@ // Canonical Cribl filter shape we can auto-resolve: `==''`. // Anything more complex (boolean ops, function calls, regex) is out of scope -// for the local evaluator — callers should it.skip when this returns null. +// for the local evaluator. Callers receive an explicit `{kind: 'unsupported'}` +// so they can decide whether to fail loudly or fall back — silent skips are +// banned (they let coverage rot invisibly). const SIMPLE_FILTER_RE = /^\s*([A-Za-z_]\w*)\s*==\s*['"](.*?)['"]\s*$/; +export type ParsedFilter = + | { kind: "simple"; field: string; value: string } + | { kind: "unsupported"; expression: string }; + /** - * Parse `==''` (or `==""`) into [field, value]. - * Returns null for any expression we cannot statically resolve. + * Classify a Cribl route filter expression. + * + * Returns `{kind: "simple", field, value}` for `==''` (or + * `==""`); `{kind: "unsupported", expression}` for anything else. + * + * Discriminated union (not nullable) so callers MUST handle the unsupported + * case explicitly — a missed branch becomes a TypeScript error rather than + * an implicit skip. */ export function parseSimpleFilter( expr: string | null | undefined, -): [string, string] | null { - const match = (expr ?? "").match(SIMPLE_FILTER_RE); +): ParsedFilter { + const expression = expr ?? ""; + const match = expression.match(SIMPLE_FILTER_RE); if (!match || match[1] === undefined || match[2] === undefined) { - return null; + return { kind: "unsupported", expression }; } - return [match[1], match[2]]; + return { kind: "simple", field: match[1], value: match[2] }; } diff --git a/tests/pipelines.test.ts b/tests/pipelines.test.ts index e3c9aad..a0900bc 100644 --- a/tests/pipelines.test.ts +++ b/tests/pipelines.test.ts @@ -24,7 +24,11 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import * as path from "node:path"; import { describe, expect, it } from "vitest"; import { CriblClient, type CriblEvent } from "./cribl-client.js"; -import { getInstalledPackId, makeClient } from "./test-helpers.js"; +import { + assertPartialMatch, + getInstalledPackId, + makeClient, +} from "./test-helpers.js"; const FIXTURES = path.join(import.meta.dirname, "fixtures"); const SKIP_REQUIRED_FIELDS_MARKER = path.join( @@ -68,32 +72,6 @@ function loadEvents(filePath: string): CriblEvent[] { return Array.isArray(raw) ? (raw as CriblEvent[]) : [raw as CriblEvent]; } -function assertPartialMatch( - actual: CriblEvent[], - expected: CriblEvent[], - context: string, -): void { - expect( - actual.length, - `${context}: pipeline produced ${actual.length} events, expected ${expected.length}`, - ).toBe(expected.length); - for (let i = 0; i < expected.length; i++) { - const act = actual[i]; - const exp = expected[i]; - if (act === undefined || exp === undefined) continue; - for (const [key, expVal] of Object.entries(exp)) { - expect( - key in act, - `${context} event ${i}: expected key '${key}' missing from output`, - ).toBe(true); - expect( - act[key], - `${context} event ${i}, key '${key}': expected ${JSON.stringify(expVal)}, got ${JSON.stringify(act[key])}`, - ).toEqual(expVal); - } - } -} - const CASES = discoverCases(); const SKIP_REQUIRED_FIELDS = existsSync(SKIP_REQUIRED_FIELDS_MARKER); diff --git a/tests/routes.test.ts b/tests/routes.test.ts index 77ccd33..d40c02a 100644 --- a/tests/routes.test.ts +++ b/tests/routes.test.ts @@ -116,43 +116,47 @@ describe("route flow (dynamic)", () => { } for (const route of routesForFlow) { + const routeId = route.id ?? ""; const parsed = parseSimpleFilter(route.filter); - const skipReason = - parsed === null - ? `filter '${route.filter}' not auto-resolvable by the local matcher (expected \`==''\`)` - : null; - - it.skipIf(skipReason !== null)( - `${route.id ?? ""} routes a synthetic event to its pipeline${skipReason !== null ? ` [skip: ${skipReason}]` : ""}`, - async () => { - // parsed is guaranteed non-null here (test would be skipped otherwise), - // but TS doesn't know — re-derive in-test for type narrowing. - const reparsed = parseSimpleFilter(route.filter); - if (reparsed === null) throw new Error("unreachable: skipped above"); - const [field, value] = reparsed; - const event = { [field]: value, _raw: "{}", _time: Date.now() / 1000 }; - - const client = makeClient(); - const packId = getInstalledPackId(); - const sampleId = await client.saveSample(`route-flow-${route.id}`, [ - event, - ]); - try { - const result = await client.runRouteFlow(sampleId, [event], { - pack: packId, - }); - expect( - result.route.id, - `Expected route '${route.id}' to match the synthetic event, but route '${result.route.id}' matched first.`, - ).toBe(route.id); - expect( - result.events.length, - `Pipeline '${result.pipeline}' produced no output for the synthetic event matching route '${route.id}' — check the pipeline for unconditional Drop functions.`, - ).toBeGreaterThan(0); - } finally { - await client.deleteSample(sampleId); - } - }, - ); + + if (parsed.kind === "unsupported") { + // Loud failure (not silent skip) — the harness can't auto-resolve this + // filter, so the dynamic flow assertion would otherwise erode invisibly + // as packs ship more complex filters. Author must either simplify the + // route filter to `==''` or extend tests/parse-filter.ts + // to handle the new form. + it(`${routeId} route filter is auto-resolvable by the local matcher`, () => { + expect.fail( + `Route '${routeId}' filter '${parsed.expression}' is not the canonical \`==''\` shape. The dynamic-flow test cannot exercise it. Fix by (a) simplifying the route filter to the canonical shape, or (b) extending tests/parse-filter.ts to handle this form (and adding a teeth-test for the new shape).`, + ); + }); + continue; + } + + it(`${routeId} routes a synthetic event to its pipeline`, async () => { + const { field, value } = parsed; + const event = { [field]: value, _raw: "{}", _time: Date.now() / 1000 }; + + const client = makeClient(); + const packId = getInstalledPackId(); + const sampleId = await client.saveSample(`route-flow-${route.id}`, [ + event, + ]); + try { + const result = await client.runRouteFlow(sampleId, [event], { + pack: packId, + }); + expect( + result.route.id, + `Expected route '${route.id}' to match the synthetic event, but route '${result.route.id}' matched first.`, + ).toBe(route.id); + expect( + result.events.length, + `Pipeline '${result.pipeline}' produced no output for the synthetic event matching route '${route.id}' — check the pipeline for unconditional Drop functions.`, + ).toBeGreaterThan(0); + } finally { + await client.deleteSample(sampleId); + } + }); } }); diff --git a/tests/tarball-parity.test.ts b/tests/tarball-parity.test.ts new file mode 100644 index 0000000..4918f34 --- /dev/null +++ b/tests/tarball-parity.test.ts @@ -0,0 +1,135 @@ +/** + * Tarball parity — the test-path tarball builder and the release-path tarball + * builder must include the same top-level entries. + * + * GENERIC: copied verbatim from the template into every pack repo. + * + * Two parallel implementations of the .crbl whitelist live in this repo: + * - `tests/cribl-client.ts::PACK_ROOT_ENTRIES` (used in CI test job) + * - `scripts/build-crbl.sh::INCLUDE=(...)` (used in release workflow) + * + * If they drift, CI passes against one whitelist and ships a tarball with a + * different whitelist — exactly the failure mode "fixed in commit 452f029 + * (top-level whitelist for createPackTarball)" was meant to prevent. This + * test pins them together: it builds the tarball both ways from the live + * pack root and asserts the set of entries matches. + * + * No Cribl required. Runs as a plain unit test. + */ + +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { CriblClient, PACK_ROOT, PACK_ROOT_ENTRIES } from "./cribl-client.js"; + +const BUILD_SCRIPT = path.join(PACK_ROOT, "scripts", "build-crbl.sh"); +const REPO_NAME = "tarball-parity-test"; +const TAG_NAME = "v0.0.0-parity"; +const RELEASE_TARBALL = `/tmp/${REPO_NAME}-${TAG_NAME}.crbl`; +const RELEASE_TARBALL_LATEST = `/tmp/${REPO_NAME}.crbl`; + +let tmpDir: string; +let testTarballPath: string; + +beforeAll(async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), "tarball-parity-")); + + // Build the release-path tarball. + execFileSync(BUILD_SCRIPT, { + cwd: PACK_ROOT, + env: { ...process.env, REPO_NAME, TAG_NAME }, + stdio: "pipe", + }); + + // Build the test-path tarball (in-memory) and write it next to the release + // tarball so we can extract both the same way. + const buffer = await CriblClient.createPackTarball(PACK_ROOT); + testTarballPath = path.join(tmpDir, "test-path.crbl"); + writeFileSync(testTarballPath, buffer); +}); + +afterAll(() => { + for (const p of [tmpDir, RELEASE_TARBALL, RELEASE_TARBALL_LATEST]) { + if (existsSync(p)) rmSync(p, { recursive: true, force: true }); + } +}); + +function listTopLevelEntries(tarball: string): string[] { + // `tar -tzf` prints every entry; keep only the first path segment. + const out = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" }); + const top = new Set(); + for (const line of out.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const segment = trimmed.replace(/\/.*$/, "").replace(/\/$/, ""); + if (segment.length > 0) top.add(segment); + } + return Array.from(top).sort(); +} + +function parseIncludeFromShellScript(): string[] { + const script = readFileSync(BUILD_SCRIPT, "utf8"); + const match = script.match(/^INCLUDE=\(([^)]+)\)/m); + if (match === null || match[1] === undefined) { + throw new Error( + `Could not parse INCLUDE=(...) from ${BUILD_SCRIPT} — has the script format changed?`, + ); + } + return match[1] + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .sort(); +} + +describe("tarball builder parity", () => { + it("release-path and test-path tarballs contain the same top-level entries", () => { + const releaseEntries = listTopLevelEntries(RELEASE_TARBALL); + const testEntries = listTopLevelEntries(testTarballPath); + expect(testEntries).toEqual(releaseEntries); + }); + + it("shell INCLUDE=(...) is a subset of PACK_ROOT_ENTRIES", () => { + // The shell whitelist conditionally adds LICENSE inside an `if` block, so + // we compare the static INCLUDE list against PACK_ROOT_ENTRIES rather than + // requiring exact equality. Every name in the shell list must appear in + // the TS set — drift in either direction fails. + const shellIncludes = parseIncludeFromShellScript(); + const tsWhitelist = Array.from(PACK_ROOT_ENTRIES).sort(); + for (const name of shellIncludes) { + expect( + tsWhitelist, + `shell INCLUDE=(...) contains '${name}' but PACK_ROOT_ENTRIES does not — drift detected`, + ).toContain(name); + } + }); + + it("PACK_ROOT_ENTRIES covers every conditional entry in build-crbl.sh", () => { + // Catches the inverse drift: build-crbl.sh's conditional `LICENSE` (or any + // future conditional addition) must be allow-listed in PACK_ROOT_ENTRIES. + const script = readFileSync(BUILD_SCRIPT, "utf8"); + const conditionalAdds = Array.from( + script.matchAll(/INCLUDE\+=\(([^)]+)\)/g), + (m) => (m[1] ?? "").trim(), + ); + for (const raw of conditionalAdds) { + // Skip the ADDITIONAL_FILES loop iteration — it expands a shell variable, + // not a baked-in conditional path. + if (/\$\{extra\}/.test(raw)) continue; + for (const name of raw.split(/\s+/).filter((s) => s.length > 0)) { + expect( + Array.from(PACK_ROOT_ENTRIES), + `build-crbl.sh conditionally adds '${name}' but PACK_ROOT_ENTRIES does not list it — drift detected`, + ).toContain(name); + } + } + }); +}); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index a7701f5..0489fd6 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -1,13 +1,18 @@ /** - * Per-test-file Cribl client helper. + * Per-test-file Cribl client helper + shared assertion utilities. * * Vitest's globalSetup runs in a separate process, so we can't share a token * — each test file constructs its own CriblClient that re-authenticates on * first use. The pack itself is already installed (from globalSetup); the * pack id flows via env var. + * + * `assertPartialMatch` lives here (not inside pipelines.test.ts) so the + * harness-teeth meta-tests can exercise it in isolation without spinning up + * Cribl. */ -import { CriblClient } from "./cribl-client.js"; +import { expect } from "vitest"; +import { CriblClient, type CriblEvent } from "./cribl-client.js"; export function makeClient(): CriblClient { return new CriblClient({ @@ -30,3 +35,37 @@ export function getInstalledPackId(): string { } return id; } + +/** + * Partial-match assertion: every key in each `expected[i]` must be present in + * `actual[i]` with an equal value. Extra keys in `actual` are allowed. + * + * Throws (via vitest's `expect`) with a context-prefixed message on mismatch + * — the message names the offending event index and key so failures point + * straight at the pipeline output that diverged. + */ +export function assertPartialMatch( + actual: CriblEvent[], + expected: CriblEvent[], + context: string, +): void { + expect( + actual.length, + `${context}: pipeline produced ${actual.length} events, expected ${expected.length}`, + ).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + const act = actual[i]; + const exp = expected[i]; + if (act === undefined || exp === undefined) continue; + for (const [key, expVal] of Object.entries(exp)) { + expect( + key in act, + `${context} event ${i}: expected key '${key}' missing from output`, + ).toBe(true); + expect( + act[key], + `${context} event ${i}, key '${key}': expected ${JSON.stringify(expVal)}, got ${JSON.stringify(act[key])}`, + ).toEqual(expVal); + } + } +} From e4e495a5351712d8cc2830e03e08966d279459a0 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 24 May 2026 18:16:34 -0400 Subject: [PATCH 2/6] fix(matrix): pin 4.17.1 (previous minor) instead of 3.5.4 in default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.5.4 was the most recent previous-MAJOR patch — but in May 2026 that maps to a 2022-10-13 release that's nearly four years old. Nobody pinning Cribl in production runs anything close to that line, so the leg added zero signal and ~6 minutes of CI runtime per PR. Replace with `4.17.1` (released 2026-04-22) — the last patch of the previous MINOR. That's a real compatibility test: did the latest 4.18 patches change anything that breaks pipelines authored against 4.17? Also: 4.17.1 >= the template's minLogStreamVersion=4.17.0, so the leg actually passes by default rather than producing expected-failure noise the PR author has to mentally filter. Updated version policy in docs/test-harness.md accordingly — drop the "previous major" framing (only makes sense at major-version boundaries) and replace with "last patch of previous minor" + when-to-bump guidance. Assisted-by: Claude --- .github/workflows/cribl-pack-test.yml | 6 ++++-- docs/new-pack.md | 10 +++++++--- docs/test-harness.md | 26 +++++++++++++++++--------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cribl-pack-test.yml b/.github/workflows/cribl-pack-test.yml index 3bd173d..80374d3 100644 --- a/.github/workflows/cribl-pack-test.yml +++ b/.github/workflows/cribl-pack-test.yml @@ -21,9 +21,11 @@ on: parallel test leg against `cribl/cribl:`. Legs with `required: true` block PR merges on failure; `required: false` legs run best-effort (failures visible but non-blocking). Default covers - `latest` plus the most recent previous-major patch (3.5.4 EOL). + `latest` plus the last patch of the previous minor (4.17.1 as of + 2026-05). Bump the second entry when a new minor lands and the old + previous-minor stops being a meaningful compatibility signal. type: string - default: '[{"version":"latest","required":true},{"version":"3.5.4","required":false}]' + default: '[{"version":"latest","required":true},{"version":"4.17.1","required":false}]' node_version: description: 'Node.js version for the test runner.' type: string diff --git a/docs/new-pack.md b/docs/new-pack.md index 4e1877f..cd29ef0 100644 --- a/docs/new-pack.md +++ b/docs/new-pack.md @@ -95,16 +95,20 @@ GitHub Releases. ## Cribl version matrix overrides The reusable test workflow defaults to testing against `cribl/cribl:latest` -(required) plus the most recent previous-major patch (best-effort). If your -pack needs to pin or extend, override in your `.github/workflows/test.yml`: +(required) plus the last patch of the previous minor (best-effort, currently +`4.17.1` as of 2026-05). If your pack needs to pin or extend, override in your +`.github/workflows/test.yml`: ```yaml uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main with: pack_type: edge - cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":true}]' + cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"4.16.1","required":false}]' ``` +See `docs/test-harness.md` "Cribl version matrix" for the version policy +(when to bump, when to add, when to remove). + ## Branch protection (one-time, per repo) After the first green CI run, require the `Test pack pipelines (Cribl latest)` diff --git a/docs/test-harness.md b/docs/test-harness.md index 2556619..0b7cbfa 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -53,7 +53,7 @@ Shape: ```json [ {"version": "latest", "required": true}, - {"version": "3.5.4", "required": false} + {"version": "4.17.1", "required": false} ] ``` @@ -65,24 +65,32 @@ Each leg posts a distinct GitHub status check named require the `latest` check specifically while leaving older legs informational. **Extending the matrix.** Add a JSON entry. No other edits. For example, to -also pin against the previous minor of the current major: +also pin against two minors back: ```yaml # In .github/workflows/test.yml (the per-pack caller) uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main with: pack_type: edge - cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"3.5.4","required":false}]' + cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"4.16.1","required":false}]' ``` **Version policy.** -- `latest` always floats — tracks the current major's newest patch (4.x today; - will track 5.x once Cribl ships it). -- Pinned older entries should be exact patches (e.g. `3.5.4`, `4.17.1`) since - EOL or older lines don't get new patches. -- When the current major changes, pin the last patch of the outgoing major as - a new entry: `{"version": "4.18.1", "required": false}`. +- `latest` always floats — tracks Cribl's newest patch of the current + major+minor. +- The second entry is the **last patch of the previous minor** — a real + compatibility signal that a recent Cribl release didn't break the pack. + As of 2026-05, that's `4.17.1` (previous minor is 4.17; latest is on + 4.18.x). Bump when Cribl ships a new minor (e.g., when 4.19 becomes + `latest`, pin `4.18.x` here). +- Don't pin years-old majors as the default. They're not a meaningful + signal — neither downstream packs nor their consumers run multi-year-old + Cribl in production. If a specific pack truly needs to support an + older line, that pack overrides `cribl_versions` per-repo to add it. +- When the current major changes (e.g., Cribl ships 5.0 as `latest`), the + default becomes `[{latest, required}, {, required: false}]` — + same shape, just a one-major-line shift. ## Required-fields assertion From 28cad8d4bab0361ff98953851829c0cb4006be1b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 24 May 2026 19:10:34 -0400 Subject: [PATCH 3/6] fix: scrub personal-setup leak, N/N-1/N-2 matrix, trim over-docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to the bundle: 1. Personal-setup leak in docs/new-pack.md: removed the `security find-generic-password -s GH_PAT_ORG_ADMIN -a ai-cli-coder -w ~/Library/Keychains/elevate-access.keychain-db` snippet. That's one maintainer's local keychain wiring — has no business in a public template that downstream packs scaffold from. Replaced with a token-agnostic `GH_TOKEN=` placeholder + a sentence explaining the required scope. 2. Matrix is now N/N-1/N-2 (latest + 4.17.1 + 4.16.1) instead of just latest + previous-major (3.5.4 in the original, 4.17.1 alone after the first fix). Three legs covers the realistic compatibility band for packs anyone actually runs. Lowered the template's minLogStreamVersion from 4.17.0 to 4.16.0 so all three legs pass by default — downstream packs raise it if they need a newer feature. 3. Trimmed over-documentation that was DRY-violating the code: - Deleted docs/new-pack.md entirely. The README's existing Installation + After-scaffolding sections covered 90% of it; the remaining gotcha (org-admin token) is now one sentence in the README. - Removed the "Coverage matrix" mega-table from test-harness.md. Test names in harness-teeth.test.ts are the source of truth for what each guard catches; transcribing them into a table makes every test rename a doc edit. - Shrunk the "Cribl version matrix" section to two paragraphs that point at the workflow input default instead of transcribing it. Self-documenting code stays self-documenting; docs cover only the rationale and gotchas that aren't visible from a `cat`. Assisted-by: Claude --- .github/workflows/cribl-pack-test.yml | 14 ++-- AGENTS.md | 1 - README.md | 18 ++-- docs/new-pack.md | 116 -------------------------- docs/test-harness.md | 73 +++------------- package.json | 2 +- 6 files changed, 25 insertions(+), 199 deletions(-) delete mode 100644 docs/new-pack.md diff --git a/.github/workflows/cribl-pack-test.yml b/.github/workflows/cribl-pack-test.yml index 80374d3..c236aae 100644 --- a/.github/workflows/cribl-pack-test.yml +++ b/.github/workflows/cribl-pack-test.yml @@ -17,15 +17,13 @@ on: required: true cribl_versions: description: >- - JSON array of {version, required} objects. Each entry spawns one - parallel test leg against `cribl/cribl:`. Legs with - `required: true` block PR merges on failure; `required: false` legs - run best-effort (failures visible but non-blocking). Default covers - `latest` plus the last patch of the previous minor (4.17.1 as of - 2026-05). Bump the second entry when a new minor lands and the old - previous-minor stops being a meaningful compatibility signal. + JSON array of {version, required} objects — one parallel test leg + per entry. `required: true` blocks merges on failure; `required: + false` is best-effort. Default: `latest` (N) plus the last patches + of N-1 and N-2 minors. Bump all three when `latest` rolls over to + a new minor. type: string - default: '[{"version":"latest","required":true},{"version":"4.17.1","required":false}]' + default: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"4.16.1","required":false}]' node_version: description: 'Node.js version for the test runner.' type: string diff --git a/AGENTS.md b/AGENTS.md index a223db1..0aacb8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,6 @@ scaffold from here via `gh repo create --template`. | Local development setup | [`docs/development.md`](docs/development.md) | | Release process | [`docs/release-process.md`](docs/release-process.md) | | Validator rules | [`docs/validator-rules.md`](docs/validator-rules.md) | -| Scaffolding a new pack from this template | [`docs/new-pack.md`](docs/new-pack.md) | ## Top-level rules diff --git a/README.md b/README.md index aa53c08..0d04fa8 100644 --- a/README.md +++ b/README.md @@ -15,21 +15,20 @@ that wraps the common day-to-day commands. ## Installation -Full end-to-end runbook: [`docs/new-pack.md`](docs/new-pack.md). Short version: - ```sh -gh repo create dryvist/cc-edge--io \ - --template dryvist/cc-edge-pack-template \ - --public --clone +GH_TOKEN= \ + gh repo create dryvist/cc-edge--io \ + --template dryvist/cc-edge-pack-template \ + --public --clone cd cc-edge--io make install # installs Node deps + git hooks ``` -Repo creation requires the org-admin token wrapper — see `docs/new-pack.md` -step 1. `make install` requires `node` (20+) and `pnpm` (10+). Docker is only -needed when you run `make test`. See [`docs/development.md`](docs/development.md) -for installation paths. +Org-repo creation needs a token with `Administration: Read & write` on the +dryvist org (standard user PATs typically can't). `make install` needs Node +20+ and pnpm 10+. Docker is only needed for `make test`. See +[`docs/development.md`](docs/development.md) for the dev shell. ## After scaffolding @@ -70,7 +69,6 @@ version bump on every push to `main`; merge that PR to publish a release. See | Doc | What it covers | |---|---| -| [`docs/new-pack.md`](docs/new-pack.md) | Step-by-step runbook for creating a new pack from this template | | [`docs/development.md`](docs/development.md) | Local dev setup, Make targets, optional Nix shell | | [`docs/test-harness.md`](docs/test-harness.md) | What gets tested, fixture conventions, Cribl version matrix | | [`docs/file-boundary.md`](docs/file-boundary.md) | Generic vs pack-specific files (sync rules) | diff --git a/docs/new-pack.md b/docs/new-pack.md deleted file mode 100644 index cd29ef0..0000000 --- a/docs/new-pack.md +++ /dev/null @@ -1,116 +0,0 @@ -# New pack from this template - -End-to-end runbook for creating a new Cribl pack repo from -`dryvist/cc-edge-pack-template`. Six steps, ~15 minutes. - -## 1. Create the repo - -Repo creation in the dryvist org requires the org-admin PAT (the day-to-day -`GH_PAT_DRYVIST` cannot create repos). One-off wrapper: - -```sh -GH_TOKEN=$(security find-generic-password -s GH_PAT_ORG_ADMIN -a ai-cli-coder \ - -w ~/Library/Keychains/elevate-access.keychain-db) \ - gh repo create dryvist/cc-edge--io \ - --template dryvist/cc-edge-pack-template \ - --public --clone -``` - -Naming convention enforced by `scripts/validate-pack-structure.sh`: -`cc-{edge,stream}--io`. The validator emits a warning (not an error) -for non-matching names, so deviation is possible but discouraged. - -## 2. Install deps + git hooks - -```sh -cd cc-edge--io -make install -``` - -Requires Node 20+ and pnpm 10+. Docker is only needed when you run `make test`. - -## 3. Edit the manifest - -`package.json` fields the validator hard-requires (`validate-pack-structure.sh` -will fail CI if any is missing or blank): - -- `name` — e.g. `cc-edge-myservice-io` -- `version` — release-please rewrites this; the template ships `0.0.1` -- `minLogStreamVersion` — minimum Cribl version your pipeline relies on - (e.g. `4.10.0`). See your Cribl release notes for the feature you depend on. - -Soft-recommended (not validator-enforced, but used by the Cribl UI): -`displayName`, `description`, `tags`, `author`. - -If this is a Cribl Stream pack (not Edge), flip -`.github/workflows/test.yml`: `pack_type: stream`. - -## 4. Write your pipeline + first fixture - -Replace the demo passthrough with the real pipeline: - -- Pipeline config: `default/pipelines//conf.yml` -- Route filter: edit `default/pipelines/route.yml` so its filter matches your - expected input shape and points at `` -- Remove the demo: delete `default/pipelines/passthrough/` and - `tests/fixtures/passthrough/` - -Create the first fixture pair (the harness auto-discovers — no test code -edits needed): - -```sh -mkdir -p tests/fixtures/ -# Paste a real input event: -${EDITOR} tests/fixtures//baseline.json -# Paste the desired output (partial-match: only fields you assert on): -${EDITOR} tests/fixtures//baseline.expected.json -``` - -See [`test-harness.md`](test-harness.md) for the fixture convention. - -## 5. Run tests locally - -```sh -make docker-up && make test -``` - -`make test` runs `pnpm vitest` against the live Cribl container. All three test -files run: `routes.test.ts`, `pipelines.test.ts` (your fixtures), -`harness-teeth.test.ts` and `tarball-parity.test.ts` (template-level guards). - -## 6. Push + let CI close it out - -```sh -git checkout -b feat/initial-pack -git add . && git commit -m "feat: initial pipeline + fixture" -git push -u origin feat/initial-pack -gh pr create --fill -``` - -CI runs validate + the multi-version Cribl matrix (`latest` required; older -majors best-effort per [`test-harness.md`](test-harness.md)). Once merged, -release-please opens a release PR; merging that publishes the `.crbl` to -GitHub Releases. - -## Cribl version matrix overrides - -The reusable test workflow defaults to testing against `cribl/cribl:latest` -(required) plus the last patch of the previous minor (best-effort, currently -`4.17.1` as of 2026-05). If your pack needs to pin or extend, override in your -`.github/workflows/test.yml`: - -```yaml -uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main -with: - pack_type: edge - cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"4.16.1","required":false}]' -``` - -See `docs/test-harness.md` "Cribl version matrix" for the version policy -(when to bump, when to add, when to remove). - -## Branch protection (one-time, per repo) - -After the first green CI run, require the `Test pack pipelines (Cribl latest)` -status check in the repo's branch protection rules. Older-version legs stay -visible but non-blocking. diff --git a/docs/test-harness.md b/docs/test-harness.md index 0b7cbfa..b0a97a4 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -13,22 +13,9 @@ convention — no TypeScript edits needed to add tests. | `tarball-parity.test.ts` | The whitelist in `tests/cribl-client.ts::PACK_ROOT_ENTRIES` (used by every test-time pack install) matches `INCLUDE=` in `scripts/build-crbl.sh` (used by every release). Catches drift before a release ships a tarball CI never validated. | | `harness-teeth.test.ts` | Meta-tests: every assertion helper used by the suites above actually throws on its target failure mode. Pure unit-level; no Cribl required. | -## Coverage matrix (what the harness proves it catches) - -Every guard listed here has at least one teeth-test in `harness-teeth.test.ts`. -Adding a guard? Add a teeth-test in the same PR. - -| Guard | Lives in | Catches | Proven by | -|---|---|---|---| -| `assertPartialMatch` — missing expected key | `tests/test-helpers.ts` | Pipeline drops a field the fixture expects | `harness-teeth.test.ts` → "throws when an expected key is missing" | -| `assertPartialMatch` — wrong value | `tests/test-helpers.ts` | Pipeline sets a field to the wrong value | `harness-teeth.test.ts` → "throws when an expected value differs" | -| `assertPartialMatch` — event-count mismatch | `tests/test-helpers.ts` | Pipeline duplicates/drops events | `harness-teeth.test.ts` → "throws on event-count mismatch (actual longer/shorter)" | -| Smoke assertion (`length > 0`) | `tests/pipelines.test.ts` (inline) | Pipeline drops every event under a real filter/eval | `harness-teeth.test.ts` covers the underlying `expect().toBeGreaterThan` indirectly via Vitest; observed in CI when a pipeline regression empties output | -| `assertRequiredFields` — missing edge field | `tests/cribl-client.ts` | Edge pipeline output lacks `sourcetype` or `index` | `harness-teeth.test.ts` → "throws when edge sourcetype/index is missing" | -| `assertRequiredFields` — missing stream field | `tests/cribl-client.ts` | Stream pipeline output lacks `host`/`source`/`_time` | `harness-teeth.test.ts` → "throws when stream host/_time is missing" | -| Tarball whitelist drift | `tests/tarball-parity.test.ts` | `build-crbl.sh` and `createPackTarball` diverge | `tarball-parity.test.ts` (also self-proving: flip an `INCLUDE=` entry and watch CI go red) | - -If you add a new assertion helper, add a teeth-test in the same PR or coverage rots silently. +Adding a new assertion helper? Add a matching case to `harness-teeth.test.ts` +in the same PR — the `it()` names there are the source of truth for what each +guard catches. ## Fixture convention @@ -44,53 +31,13 @@ tests stop running. No code changes. ## Cribl version matrix -The reusable test workflow (`cribl-pack-test.yml`) runs Vitest in parallel -against every Cribl version listed in its `cribl_versions` input. Single -source of truth: the input's JSON-array default. - -Shape: - -```json -[ - {"version": "latest", "required": true}, - {"version": "4.17.1", "required": false} -] -``` - -- `required: true` — failure blocks PR merges (required status check) -- `required: false` — failure is visible but non-blocking (best-effort smoke) - -Each leg posts a distinct GitHub status check named -`Test pack pipelines (Cribl )`, so branch-protection rules can -require the `latest` check specifically while leaving older legs informational. - -**Extending the matrix.** Add a JSON entry. No other edits. For example, to -also pin against two minors back: - -```yaml -# In .github/workflows/test.yml (the per-pack caller) -uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main -with: - pack_type: edge - cribl_versions: '[{"version":"latest","required":true},{"version":"4.17.1","required":false},{"version":"4.16.1","required":false}]' -``` - -**Version policy.** - -- `latest` always floats — tracks Cribl's newest patch of the current - major+minor. -- The second entry is the **last patch of the previous minor** — a real - compatibility signal that a recent Cribl release didn't break the pack. - As of 2026-05, that's `4.17.1` (previous minor is 4.17; latest is on - 4.18.x). Bump when Cribl ships a new minor (e.g., when 4.19 becomes - `latest`, pin `4.18.x` here). -- Don't pin years-old majors as the default. They're not a meaningful - signal — neither downstream packs nor their consumers run multi-year-old - Cribl in production. If a specific pack truly needs to support an - older line, that pack overrides `cribl_versions` per-repo to add it. -- When the current major changes (e.g., Cribl ships 5.0 as `latest`), the - default becomes `[{latest, required}, {, required: false}]` — - same shape, just a one-major-line shift. +Default: `latest` plus the last patches of the previous two minors (N / N-1 / +N-2). `latest` is required; older legs are best-effort. Shape lives in the +`cribl_versions` input default in `.github/workflows/cribl-pack-test.yml` — +edit the JSON there to bump, add, or remove versions. Each leg posts its own +status check (`Test pack pipelines (Cribl )`) so branch protection +can require `latest` specifically. When `latest` rolls over to a new minor, +bump both older entries forward one. ## Required-fields assertion diff --git a/package.json b/package.json index e86ba3d..18aca15 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cc-edge-demo-io", "version": "0.0.1", - "minLogStreamVersion": "4.17.0", + "minLogStreamVersion": "4.16.0", "author": "dryvist ", "description": "Demo passthrough pack — replace name/displayName/description/author when scaffolding from this template.", "displayName": "Cribl Edge Demo Pack", From 5006ad1e00639a6065ff17120061ed4b4a1ff143 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 24 May 2026 19:27:02 -0400 Subject: [PATCH 4/6] feat(renovate): patch-only auto-bumps for cribl version pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a repo-local renovate.json that extends dryvist's org default and defines a custom regex manager. The manager finds `"version":"X.Y.Z"` strings inside .github/workflows/cribl-pack-test.yml's matrix default and treats each as a docker dep on cribl/cribl, so Renovate opens a PR when a newer patch ships. Package rules constrain it to patch bumps only: - 4.17.1 → 4.17.2 ✓ (auto-merge, criblio is in trusted-org list) - 4.17.1 → 4.18.0 ✗ (minor/major bumps disabled) The `"latest"` entry isn't captured by the regex (no semver triple), so it stays untouched and continues to float at runtime. This keeps the user's "ALWAYS the last released version" principle enforced at the patch level. Minor-window shifts (when `latest` rolls over to a new minor) remain manual — they're a deliberate compat-band decision, not a routine bump. Assisted-by: Claude --- renovate.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..66bcf67 --- /dev/null +++ b/renovate.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>dryvist/.github"], + "customManagers": [ + { + "description": "Track Cribl version pins inside the multi-version test matrix default in .github/workflows/cribl-pack-test.yml. Captures any `\"version\":\"X.Y.Z\"` inside the cribl_versions JSON-array default; ignores `\"version\":\"latest\"` because the regex only matches semver triples. Each match becomes a docker dep tied to cribl/cribl, so Renovate opens a PR when a newer patch ships.", + "customType": "regex", + "managerFilePatterns": [ + "/\\.github/workflows/cribl-pack-test\\.yml$/" + ], + "matchStrings": [ + "\"version\"\\s*:\\s*\"(?\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "cribl/cribl" + } + ], + "packageRules": [ + { + "description": "Allow only patch bumps on cribl/cribl pins. The matrix shape is `latest` + last-patch-of-N-1 + last-patch-of-N-2; Renovate keeps each pinned minor on its newest patch. Minor/major shifts (when the N window rolls forward) stay manual — they're a deliberate compat-band decision, not a routine bump.", + "matchPackageNames": ["cribl/cribl"], + "matchUpdateTypes": ["minor", "major"], + "enabled": false + }, + { + "description": "Cribl is in the criblio/** trusted-org list per JacobPEvans renovate-presets, so patch bumps auto-merge after the inherited stabilization window.", + "matchPackageNames": ["cribl/cribl"], + "matchUpdateTypes": ["patch"], + "automerge": true + } + ] +} From 9126e2cc248a170e195fd65c412aa7ea43c860a4 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Mon, 25 May 2026 16:30:26 -0400 Subject: [PATCH 5/6] fix(ci): align biome schema with 2.4.13 + autoformat renovate.json Biome auto-bumped from 2.0.x to 2.4.13 via Renovate; the schema URL pinned to 2.3.6 then failed Biome's strict schema-version check. Bump the schema URL to match the installed CLI version, and apply Biome's suggested format for renovate.json to clear the lint failure on feat/validate-test-flow. --- biome.jsonc | 2 +- renovate.json | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 26f652a..9a65237 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,6 +1,6 @@ { // dryvist canonical Biome config (mirror of dryvist/.github/biome.jsonc). - "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/renovate.json b/renovate.json index 66bcf67..13ca454 100644 --- a/renovate.json +++ b/renovate.json @@ -5,9 +5,7 @@ { "description": "Track Cribl version pins inside the multi-version test matrix default in .github/workflows/cribl-pack-test.yml. Captures any `\"version\":\"X.Y.Z\"` inside the cribl_versions JSON-array default; ignores `\"version\":\"latest\"` because the regex only matches semver triples. Each match becomes a docker dep tied to cribl/cribl, so Renovate opens a PR when a newer patch ships.", "customType": "regex", - "managerFilePatterns": [ - "/\\.github/workflows/cribl-pack-test\\.yml$/" - ], + "managerFilePatterns": ["/\\.github/workflows/cribl-pack-test\\.yml$/"], "matchStrings": [ "\"version\"\\s*:\\s*\"(?\\d+\\.\\d+\\.\\d+)\"" ], From 4b3cfc65521834381599899d98e2f04329e536df Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Mon, 25 May 2026 16:35:40 -0400 Subject: [PATCH 6/6] fix(parse-filter): reject embedded quotes so boolean exprs flag unsupported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regex used a lazy capture group ['"](.*?)['"] which let the value span across quote boundaries — "a=='x' && b=='y'" then matched as a simple filter with value "x' && b=='y" instead of being flagged unsupported. Use a back-referenced quote group ['"])([^'"]*)\2 so the opening and closing quote must match and the value cannot contain a quote. Boolean expressions, function calls, and other multi-clause filters now correctly return {kind: 'unsupported'} as the contract documents. Verified against the failing harness-teeth case plus the existing simple and double-quoted shapes. --- tests/parse-filter.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/parse-filter.ts b/tests/parse-filter.ts index 01d9d3c..274c842 100644 --- a/tests/parse-filter.ts +++ b/tests/parse-filter.ts @@ -3,7 +3,12 @@ // for the local evaluator. Callers receive an explicit `{kind: 'unsupported'}` // so they can decide whether to fail loudly or fall back — silent skips are // banned (they let coverage rot invisibly). -const SIMPLE_FILTER_RE = /^\s*([A-Za-z_]\w*)\s*==\s*['"](.*?)['"]\s*$/; +// +// The quote group `(['"])` is back-referenced via `\2` to require matching +// open/close quotes, and the value class `[^'"]*` rejects embedded quotes — +// without this, `(.*?)` would greedily span boolean expressions like +// `a=='x' && b=='y'` and silently classify them as simple. +const SIMPLE_FILTER_RE = /^\s*([A-Za-z_]\w*)\s*==\s*(['"])([^'"]*)\2\s*$/; export type ParsedFilter = | { kind: "simple"; field: string; value: string } @@ -24,8 +29,8 @@ export function parseSimpleFilter( ): ParsedFilter { const expression = expr ?? ""; const match = expression.match(SIMPLE_FILTER_RE); - if (!match || match[1] === undefined || match[2] === undefined) { + if (!match || match[1] === undefined || match[3] === undefined) { return { kind: "unsupported", expression }; } - return { kind: "simple", field: match[1], value: match[2] }; + return { kind: "simple", field: match[1], value: match[3] }; }