# Diff output

> How diff, diffLines, lcsMatrix, and withContext render live-versus-desired changes, including redacted secret values.

- Repository: jaredpalmer/vwaffle
- GitHub: https://github.com/jaredpalmer/vwaffle
- Human docs: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581
- Complete Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms-full.txt

## Source Files

- `src/diff.ts`
- `src/index.ts`
- `src/config.ts`
- `src/api.ts`

---

---
title: "Diff output"
description: "How diff, diffLines, lcsMatrix, and withContext render live-versus-desired changes, including redacted secret values."
---

`diff(live, desired)` in `src/diff.ts` pretty-prints both sides as tab-indented JSON, compares those strings, and either returns the exact constant `NO_DRIFT` (`No drift detected.`) or a custom unified-style listing of live-versus-desired lines. `cmdPlan` prints that string as-is. `cmdApply` (`apply --yes`) runs the same `diff`, then `redactText` before stdout, and only then `putConfig` when the result is not `NO_DRIFT`. `apply --dry-run` never calls `diff`; it prints `redacted(config, secrets)` of the interpolated desired payload only.

## Command surfaces

| Command | Compare live? | Printer | Side effect |
| --- | --- | --- | --- |
| `vwaffle plan` | Yes — `getActiveConfig` vs `loadDesiredConfig(..., { strict: false })` | `console.log(diff(live, config))` — **not redacted** | None. `--check` sets `process.exitCode = 1` when the result is not `NO_DRIFT` |
| `vwaffle apply --yes` | Yes — same pair, but `loadDesiredConfig(..., { strict: true })` | `console.log(redactText(diff(live, config), secrets))` | `putConfig` only when the result is not `NO_DRIFT` |
| `vwaffle apply --dry-run` | No | `console.log(redacted(config, secrets))` | No GET, no PUT |

```mermaid
flowchart TB
  subgraph cli ["src/index.ts"]
    plan["cmdPlan"]
    apply["cmdApply --yes"]
    dry["cmdApply --dry-run"]
  end
  subgraph cfg ["src/config.ts"]
    load["loadDesiredConfig / interpolate"]
    redactObj["redacted(value, secrets)"]
  end
  subgraph api ["src/api.ts"]
    get["getActiveConfig → .active ?? body"]
    put["putConfig PUT /v1/security/firewall/config"]
  end
  subgraph engine ["src/diff.ts"]
    stringify["JSON.stringify(..., null, '\\t')"]
    lcs["lcsMatrix"]
    lines["diffLines"]
    ctx["withContext(context = 3)"]
    nodrift["NO_DRIFT"]
  end
  plan --> load
  apply --> load
  dry --> load
  plan --> get
  apply --> get
  load --> stringify
  get --> stringify
  stringify -->|identical| nodrift
  stringify -->|differ| lcs --> lines --> ctx
  plan -->|raw string| nodrift
  plan -->|raw string| ctx
  apply --> redactText["redactText(diff, secrets)"]
  redactText --> nodrift
  redactText --> ctx
  apply -->|if not NO_DRIFT| put
  dry --> redactObj
```

<Warning>
`plan` interpolates `${VAR_NAME}` into `config` and prints `diff(live, config)` without `redactText`. Expanded secret values appear on `+` lines (and on `  ` / `-` lines when the live value matches). Only `apply --yes` redacts the diff, and only `apply --dry-run` redacts the full desired payload.
</Warning>

Desired input is the object returned by `loadDesiredConfig`, not the on-disk JSON: placeholders are expanded, and under `strict: false` (`plan`, `apply --dry-run`) rules that still contain unset `${VAR_NAME}` are dropped by `removeRulesWithMissingEnv` before comparison or print.

Live input is `getActiveConfig`: `GET /v1/security/firewall/config/active` unwrapped as `response.active ?? response`. Extra live-only properties (rule `id`s, API-only keys, different key order) participate in the string compare.

## Output format

Identical pretty-prints return only:

```text
No drift detected.
```

Otherwise `diff` joins these lines with `\n` (no trailing newline beyond the last line):

1. `--- live firewall configuration`
2. `+++ desired firewall configuration`
3. The `withContext(diffLines(oldLines, newLines))` body

There are no `@@` hunk headers, file paths, or git index lines.

```text
--- live firewall configuration
+++ desired firewall configuration
  {
    ...
- 	"firewallEnabled": false
+ 	"firewallEnabled": true
    ...
  }
```

| Prefix | Meaning |
| --- | --- |
| `  ` (two spaces) | Line present on both sides (LCS match) |
| `- ` | Line in live, not chosen as a match — removed relative to desired |
| `+ ` | Line in desired, not chosen as a match — added relative to live |
| `  ...` | One or more omitted unchanged lines (emitted once per skipped run) |

Headers have no prefix. Indent inside JSON lines is a literal tab (`\t`), which is why the unit test asserts `- \t"a": 1` and `+ \t"a": 2`.

<RequestExample>
```bash
vwaffle plan
vwaffle plan --check
vwaffle apply --yes
```
</RequestExample>

<ResponseExample>
```text
--- live firewall configuration
+++ desired firewall configuration
  {
- 	"a": 1
+ 	"a": 2
  }
```
</ResponseExample>

For `{ a: 1 }` vs `{ a: 2 }`, both braces are LCS matches; the property lines are a delete-then-add. `withContext` keeps every line because all sit inside the default 3-line window.

## Rendering pipeline

### JSON line encoding

<ParamField body="live" type="unknown" required>
Active firewall config from `getActiveConfig`, or any value passed to `diff`. `undefined`/`null` becomes `null` via `live ?? null`.
</ParamField>

<ParamField body="desired" type="unknown" required>
Interpolated desired config from `loadDesiredConfig`. `desired ?? null` before stringify.
</ParamField>

Both sides run `JSON.stringify(value ?? null, null, '\t').split('\n')`. Equality is `oldLines.join('\n') === newLines.join('\n')` — not a semantic object compare.

Consequences:

- Key insertion order is significant.
- Omitted optional fields (`undefined` properties dropped by `JSON.stringify`) differ from explicit `null`.
- Live-only keys such as `id` on `rules[]` / `ips[]` appear as `-` lines if the desired file does not contain the same keys in the same order.

### `lcsMatrix`

```ts
function lcsMatrix(a: string[], b: string[]): number[][]
```

Not exported. Builds an `(a.length + 1) × (b.length + 1)` matrix of longest common subsequence lengths, filled from the bottom-right:

- `a[i] === b[j]` → `matrix[i + 1][j + 1] + 1`
- else → `max(matrix[i + 1][j], matrix[i][j + 1])`

The walk in `diffLines` uses these lengths; it does not reconstruct an LCS string array.

### `diffLines`

```ts
function diffLines(a: string[], b: string[]): string[]
```

Not exported. `a` is live lines, `b` is desired lines. Walks `i`, `j` from `0`:

| Condition | Action |
| --- | --- |
| `a[i] === b[j]` | Push `  ${a[i]}`; increment both |
| `matrix[i + 1][j] >= matrix[i][j + 1]` | Push `- ${a[i]}`; increment `i` (prefer delete live when lengths tie) |
| else | Push `+ ${b[j]}`; increment `j` |
| leftover `a` | All `- ${a[i++]}` |
| leftover `b` | All `+ ${b[j++]}` |

Equal-length replacements therefore render as a `-` line followed by a `+` line, matching the `{ a: 1 }` / `{ a: 2 }` test.

### `withContext`

```ts
function withContext(lines: string[], context = 3): string[]
```

Not exported. `context` is hardcoded to `3`; the CLI does not expose it.

1. A line is a change when it does **not** start with two spaces (`- ` and `+ `).
2. Every change keeps itself plus `context` neighbors on each side.
3. Unkept runs collapse to a single `  ...`. Consecutive omitted regions do not emit multiple ellipses.

Unchanged JSON lines always start with `  ` because `diffLines` prefixes matches that way. `withContext` is not called on the no-drift path.

## Function reference

<ParamField body="diff" type="(live: unknown, desired: unknown) => string" required>
Only exported renderer. Returns `NO_DRIFT` or the two headers plus context-trimmed line ops, joined by `\n`.
</ParamField>

<ParamField body="NO_DRIFT" type="string" required>
Exact value `'No drift detected.'`. `cmdPlan --check` and `cmdApply` compare with `!==`, not a boolean flag.
</ParamField>

<ParamField body="redactText" type="(text: string, secrets: Map<string, string>) => string">
Private to `src/index.ts`. For each non-empty `secrets` value, `text.split(secret).join('[REDACTED]')`. Used only on the apply-path diff string.
</ParamField>

<ParamField body="redacted" type="(value: unknown, secrets: Map<string, string>) => string">
Exported from `src/config.ts`. `JSON.stringify(value, null, '\t')` then the same substring replace. Used only by `apply --dry-run`.
</ParamField>

`secrets` is filled by `interpolate`: each successful `${NAME}` replacement records `secrets.set(name, replacement)`. Missing variables stay as `${NAME}` and are not entered in the map.

## Secret redaction

| Path | What is printed | What is replaced |
| --- | --- | --- |
| `plan` | Raw `diff` string | Nothing |
| `apply --yes` | `redactText(diff, secrets)` | Every non-empty interpolated env **value** (map values), anywhere it appears in the already-rendered text |
| `apply --dry-run` | `redacted(config, secrets)` | Same replacement on the desired JSON only |

Replacement is literal substring replace, not JSON-aware:

- A secret that appears on both sides (live already equals the interpolated value) becomes `[REDACTED]` on `-`, `+`, and `  ` lines.
- Live-only secret strings that are not values in `secrets` stay visible on apply diffs.
- Empty-string env values are skipped (`if (secret)`).
- Short interpolated values that also occur in other JSON text are over-replaced.

`interpolate` only treats successful `${[A-Za-z_][A-Za-z0-9_]*}` expansions as secrets. Hard-coded literals in the desired file are never added to the map.

<Note>
Help text says interpolated values are “redacted in all output.” That is true for `apply --yes` (diff) and `apply --dry-run` (payload). It is not true for `plan`.
</Note>

## Equality, drift, and apply

`cmdPlan`:

```ts
const result = diff(live, config);
console.log(result);
if (options.check && result !== NO_DRIFT) process.exitCode = 1;
```

`cmdApply` after a successful load and GET:

```ts
const result = diff(live, config);
console.log(redactText(result, secrets));
if (result === NO_DRIFT) return;
const applied = await putConfig(context, config);
```

- `--check` uses the printed `diff` result, not a separate semantic compare. Any pretty-print mismatch fails CI.
- `apply --yes` still prints the (redacted) `NO_DRIFT` line when there is no drift, then returns without PUT.
- `apply` without `--yes` or `--dry-run` throws before `diff`: `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.`

## Constraints and failure modes

| Situation | Behavior |
| --- | --- |
| Identical tab-indented JSON | Single line `No drift detected.` |
| Semantic match, different key order or extra live keys | Drift listing; `--check` exits 1 |
| Unset `${VAR}` on `plan` | Warning to stderr; matching rules removed; remaining interpolated values still appear unredacted in the diff |
| Unset `${VAR}` on `apply --yes` | Throws `missing environment variables: …` — no diff, no PUT |
| Unset `${VAR}` on `apply --dry-run` | Same skip-rules warning as plan; prints redacted remaining payload |
| Large unchanged regions | Collapsed to `  ...` with 3 lines of context around each change |
| `init` / `pull` | Do not call `diff` |

Tests in `src/config.test.ts` cover `NO_DRIFT` for `{ a: 1 }` vs `{ a: 1 }`, `-`/`+` lines for `{ a: 1 }` vs `{ a: 2 }`, and `redacted` replacing a recorded secret with `[REDACTED]`. There is no unit test for `withContext` ellipsis or for `redactText` on a full plan-style listing.

## Related pages

<CardGroup>
  <Card title="Desired vs live config" href="/desired-vs-live-config">
    What is compared: local desired JSON versus the active config `getActiveConfig` unwraps.
  </Card>
  <Card title="Secret interpolation" href="/secret-interpolation">
    How `${VAR_NAME}` is expanded and recorded in the `secrets` map used by `redactText` / `redacted`.
  </Card>
  <Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
    When `plan`, `--check`, `--dry-run`, and `apply --yes` run, including the no-drift early return.
  </Card>
  <Card title="Preview and apply changes" href="/preview-and-apply-changes">
    Operator path: `plan`, then `apply --dry-run`, then `apply --yes`.
  </Card>
  <Card title="Detect drift in CI" href="/detect-drift-in-ci">
    `plan --check` treats any non-`NO_DRIFT` pretty-print as exit 1.
  </Card>
  <Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
    Unset variables on plan versus apply, and `--check` exit 1 on drift.
  </Card>
</CardGroup>
