|
| 1 | +## Context |
| 2 | + |
| 3 | +`taskless check` today (`packages/cli/src/commands/check.ts`) enumerates every `*.yml` under |
| 4 | +`.taskless/rules/`, writes a fixed `.taskless/sgconfig.yml` with `ruleDirs: [rules]` |
| 5 | +(`src/filesystem/sgconfig.ts`), and shells out to `sg scan` (`src/rules/scan.ts`). It has no |
| 6 | +notion of authenticity, no signing, no network call, and no auth dependency — `--anonymous` |
| 7 | +is a pure no-op. There is no rule-hash code, no conformance harness, and the generated API |
| 8 | +schema (`src/generated/api.d.ts`) exposes only `whoami` / `rule` / `rule/{id}` / |
| 9 | +`rule/{id}/iterate`. Reconciliation is entirely greenfield. |
| 10 | + |
| 11 | +The backend (TSKL-270) is introducing `POST /cli/api/reconcile`: the CLI reports the rule |
| 12 | +files it holds as `{ file, signature }`, and the server returns the exact subset that may run. |
| 13 | +The signature format, the `normalize()` procedure, and the conformance vectors are **frozen** |
| 14 | +and specified in `tmp/rule-signatures.md`; the endpoint may not be live in every environment |
| 15 | +on day one. The existing plumbing we build on: `resolveIdentity(cwd)` → |
| 16 | +`{ token, orgId, repositoryUrl }` (`src/auth/identity.ts`), `getToken(cwd)` |
| 17 | +(`src/auth/token.ts`), the `openapi-fetch` client + base-URL resolution |
| 18 | +(`src/api/client.ts`, `src/api/config.ts`), and the stable error envelope |
| 19 | +(`src/types/errors.ts`). |
| 20 | + |
| 21 | +## Goals / Non-Goals |
| 22 | + |
| 23 | +**Goals:** |
| 24 | + |
| 25 | +- A signature module that reproduces the server's `rule-hash.ts` byte-for-byte, verified by |
| 26 | + the shared conformance vectors. |
| 27 | +- A reconcile client for `POST /cli/api/reconcile` returning typed `run`/`unsafe`/`unknown`/ |
| 28 | + `missing` buckets. |
| 29 | +- `check` executes only the `run` set (matched by signature) when it can reconcile, and |
| 30 | + surfaces the other buckets as advisory. |
| 31 | +- A fallback that keeps `check` working offline / logged-out / `--anonymous` / before the |
| 32 | + endpoint ships, preserving today's linter posture. |
| 33 | + |
| 34 | +**Non-Goals:** |
| 35 | + |
| 36 | +- Defining or implementing how a **server-owned/runtime rule executes**. The reconciliation |
| 37 | + contract is rule-type-agnostic (it gates which files run). Rule execution is a deliberate, |
| 38 | + team-owned follow-up shipped as a **separate proposal stacked on top of this change**; this |
| 39 | + change only makes such rules gate-able. |
| 40 | +- A `sync` / `pull` / `list` command to bulk-download rules (none exists today; reconciliation |
| 41 | + does not require one — the CLI reports what is already on disk). |
| 42 | +- Server-side approval of `unknown` files (explicitly server-side future work). |
| 43 | +- Changing the on-disk rule naming scheme. The reconcile join key is the signature, not the |
| 44 | + path, so `<id>.yml` naming is fine as-is. |
| 45 | + |
| 46 | +## Decisions |
| 47 | + |
| 48 | +### Decision: New `rule-hash.ts` mirroring the server, web-standard crypto only |
| 49 | + |
| 50 | +Add `packages/cli/src/rules/rule-hash.ts` exporting `ALGO_VERSION`, `normalize(text)`, |
| 51 | +`canonicalHash(bytes|text)` (returns the envelope string), and `parseSignature(sig)`. Use |
| 52 | +`crypto.subtle.digest('SHA-256', …)` + `TextEncoder` exclusively — no `node:crypto`. This |
| 53 | +matches the server reference (`packages/shared/src/rule-hash.ts`) and runs identically in |
| 54 | +workerd and Node 20+. `normalize()` operates on raw decoded text (BOM strip, CRLF/CR→LF, |
| 55 | +collapse trailing newlines to one LF) and never parses YAML, so it is future-proof for any |
| 56 | +rule type. |
| 57 | + |
| 58 | +_Alternative rejected:_ `node:crypto` `createHash`. Simpler locally but diverges from the |
| 59 | +web-standard reference the server pins, and risks subtle cross-repo drift the vectors exist to |
| 60 | +prevent. |
| 61 | + |
| 62 | +### Decision: Build-wired resilient fetch with a committed cache; test asserts exact reproduction |
| 63 | + |
| 64 | +Commit the vectors as `packages/cli/test/fixtures/rule-hash.vectors.json` in the cross-repo |
| 65 | +source-of-truth format (a bare `[{ name, input, signature }]` array kept pure-ASCII with |
| 66 | +`\uXXXX` escapes, so git stays byte-stable). `scripts/fetch-rule-hash-vectors.ts` (npm |
| 67 | +`generate:rule-hash-vectors`) refreshes it from `GET /cli/api/rule-hash-vectors`, unwrapping |
| 68 | +the endpoint's `{ vectors: [...] }` and re-escaping to ASCII. It is wired as `prebuild`, so |
| 69 | +every build/CI run tries to refresh but falls back to the committed cache on any |
| 70 | +network/HTTP/shape failure (only a missing cache is fatal). A vitest test |
| 71 | +(`test/rule-hash.test.ts`) parses each `input` as JSON (decoding `\uXXXX`) and asserts |
| 72 | +`canonicalHash(input) === signature` for every entry; a mismatch fails the build. CI gets the |
| 73 | +freshest vectors when reachable while offline builds and the unit suite stay hermetic. |
| 74 | + |
| 75 | +_Alternatives rejected:_ (a) fetch live during the test run — couples the unit suite to |
| 76 | +endpoint availability; (b) a purely manual refresh — drifts silently from the server. |
| 77 | + |
| 78 | +### Decision: Reconcile client via a hand-typed request over the existing fetch layer |
| 79 | + |
| 80 | +The reconcile endpoint is not in the generated `paths`, and the handoff says it may not be |
| 81 | +deployed everywhere yet. Add `packages/cli/src/api/reconcile.ts` exporting |
| 82 | +`reconcile(token, { repositoryUrl, files })`. Reuse `createApiClient(token)` where possible; |
| 83 | +because the path is absent from the generated schema, type the request/response with local |
| 84 | +interfaces (`ReconcileRequest`, `ReconcileResponse` with `run`/`unsafe`/`unknown`/`missing`) |
| 85 | +and issue the call with an explicit `Authorization: Bearer` header against `getApiBaseUrl()`'s |
| 86 | +origin. Map a `401` to an unauthorized signal and any transport/`404`/not-deployed outcome to |
| 87 | +a distinct "reconcile unavailable" signal that drives the fallback (never a hard failure). |
| 88 | +When the endpoint lands in the published schema, this can be migrated onto the typed client |
| 89 | +without changing `check`. |
| 90 | + |
| 91 | +_Alternative rejected:_ add the path to `api.d.ts` by hand — the file is generated |
| 92 | +(`pnpm generate:api`) and hand-edits would be clobbered; wait for the server schema to expose |
| 93 | +it, then regenerate. |
| 94 | + |
| 95 | +### Decision: Gate the scan by materializing a run-set rule directory |
| 96 | + |
| 97 | +`sg scan` selects rules via `ruleDirs` in `sgconfig.yml`, so limiting execution to the `run` |
| 98 | +set means pointing ast-grep at only those files. Materialize an ephemeral, gitignored |
| 99 | +`.taskless/.run/rules/` containing just the blessed files (copied from their local matches by |
| 100 | +signature), generate an sgconfig whose `ruleDirs` points there, and scan that. Keeps |
| 101 | +`src/rules/scan.ts` unchanged (it already accepts a config path and positional paths) and |
| 102 | +avoids mutating the user's `.taskless/rules/`. On fallback, generate the current sgconfig |
| 103 | +(`ruleDirs: [rules]`) and scan everything, exactly as today. |
| 104 | + |
| 105 | +_Alternatives considered:_ (a) delete non-run files in place — destructive, unacceptable; |
| 106 | +(b) pass each blessed rule as an inline `--rule` arg — brittle across ast-grep versions and |
| 107 | +loses `testConfigs`. The ephemeral-dir approach is the least invasive and reuses existing |
| 108 | +config generation. |
| 109 | + |
| 110 | +### Decision: Auth state is the behavior axis; degrade (not fail) when authed reconcile can't complete |
| 111 | + |
| 112 | +`check` requires no auth and picks its path from auth state: |
| 113 | + |
| 114 | +- **No token** (or `--anonymous`) → run all local rules with no network. This is the normal |
| 115 | + offline linter posture, so it emits no warning (an informational line is optional). |
| 116 | +- **Authenticated** (token + resolvable `repositoryUrl`, `--anonymous` unset) → reconcile, |
| 117 | + run only the `run` set, and **warn** on `unsafe`/`unknown`/`missing` mismatches. |
| 118 | +- **Authenticated but reconcile can't complete** (no git remote, endpoint unreachable / |
| 119 | + not-deployed, transport error) → **degrade**: warn that verification couldn't be performed |
| 120 | + and scan all local rules, without a non-zero exit. |
| 121 | + |
| 122 | +All warnings are human-output only and suppressed under `--json` to keep the machine shape |
| 123 | +stable. This honors the handoff's trust model — local `check` stays advisory like a linter, |
| 124 | +the server-owned allow-list applies when authenticated, and the paid-plan CI backstop is the |
| 125 | +real enforcement point — and prevents the not-yet-live endpoint from bricking `check`. |
| 126 | +Separating "no auth" (silent, expected) from "authed-but-couldn't-verify" (warned) keeps |
| 127 | +routine offline use quiet while still flagging a genuine verification gap. |
| 128 | + |
| 129 | +### Decision: Add `RECONCILE_FAILED` to the error enum, used sparingly |
| 130 | + |
| 131 | +Extend `CLIErrorCode` in `src/types/errors.ts` with `RECONCILE_FAILED`. Because reconcile |
| 132 | +failure normally triggers the fallback (not an error), this code is reserved for the case |
| 133 | +where reconciliation itself is the requested operation and hard-fails in a way the user asked |
| 134 | +to be surfaced. Adding a code is permitted by the `cli` capability without a major bump. |
| 135 | + |
| 136 | +## Risks / Trade-offs |
| 137 | + |
| 138 | +- **[Endpoint not deployed on day one]** → Fallback treats unreachable/404 as "reconcile |
| 139 | + unavailable" and scans locally; no behavior regression versus today until the endpoint |
| 140 | + ships. |
| 141 | +- **[Signature drift from the server reference]** → Committed conformance vectors + a |
| 142 | + build-blocking test catch any divergence in `normalize()`/hashing before release. |
| 143 | +- **[`check` gains a network + auth dependency]** → Reconciliation is strictly additive and |
| 144 | + gated; unauthenticated and `--anonymous` runs keep working with no auth via the fallback. |
| 145 | +- **[Ephemeral run-dir leaks into git]** → Write under `.taskless/.run/` and ensure |
| 146 | + `.taskless/.gitignore` covers it (same mechanism that hides `sgconfig.yml`); regenerate on |
| 147 | + every run. |
| 148 | +- **[Users read the fallback as "verified"]** → The one-line notice explicitly states rules |
| 149 | + are unverified and names the CI backstop as the enforcement point. |
| 150 | +- **[Reconcile latency on large corpora]** → One request per `check`; signatures are cheap |
| 151 | + SHA-256 over small files. Acceptable; can batch/cache later if needed. |
| 152 | + |
| 153 | +## Open Questions |
| 154 | + |
| 155 | +- **Warning verbosity**: how loudly to warn on `unknown`/`missing` in human output (always, |
| 156 | + or behind a `--verbose`/`--strict` flag) — resolve during implementation against the help |
| 157 | + copy for `check`/`ci`. `unsafe` (tamper/drift) should warn by default. |
| 158 | +- **CI `--strict` mode**: whether the CI backstop invocation should turn `unsafe` into a |
| 159 | + non-zero exit (the enforcement posture) versus advisory locally. Defer unless the backstop |
| 160 | + spec requires it here. |
0 commit comments