diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md new file mode 100644 index 00000000..4ef53df0 --- /dev/null +++ b/.agents/skills/verify-synac/SKILL.md @@ -0,0 +1,251 @@ +--- +name: verify-synac +description: "Drive SynAc's content CLI and the public Next.js glossary. Use to launch the local stack, run the read-only doctor check, exercise CLI side effects and the site like a user, and capture command output, screenshots, and traces." +disable-model-invocation: true +--- + +# Verify SynAc + +SynAc is a public cybersecurity glossary. Readers search and browse terms, acronyms, tags, and sources in the Next.js app. Maintainers compile, check, sync, and ingest content with the CLI in `tools/content` and `tools/ingest`. There are no accounts. + +The feature map is [`references/features/README.md`](references/features/README.md). Open the one file that matches the change. Walk the README top to bottom for a broad sweep, and finish with `journeys.md`. + +A maintain pass (`maintain-verification-skill`) edits only this directory: `SKILL.md`, `references/features/`, `scripts/`, and `playwright.config.mjs`. Product code stays untouched. Outcomes are `clean`, `changed` (one PR), or `blocked`. The `verify:*` scripts in the root `package.json` are thin wrappers. Change the script bodies here, and leave those wrappers pointed at this directory. + +## Isolate + +This skill owns one stack: + +- Convex anonymous backend on `http://127.0.0.1:3210` (site port `3211`) +- Web app on `http://127.0.0.1:3000` + +A second checkout cannot bind those ports. Launch refuses to start when port 3000 or 3210 is already taken by a process this skill did not start. Doctor may read an existing server. Cleanup never stops it. + +Playwright uses a fresh browser context per test, so theme and clipboard stay inside that context. The Convex dataset is the synced repo content and is shared by every context on this machine. + +## Launch + +`pnpm verify:*` is defined on the repo root. Run it from the root of this checkout. From a package directory, pnpm looks at that package's scripts and exits 254, so use `pnpm -w verify:launch` (and the same `-w` form for doctor, smoke, drive, and cleanup). Each script finds this repo from its own file path and changes to that directory before it runs a content command or Playwright. + +From the repo root, with Node 24 first on `PATH` (see `.node-version`): + +```bash +export PATH="${NVM_DIR:-$HOME/.nvm}/versions/node/$(nvm version 24)/bin:$PATH" +node -v +pnpm install --frozen-lockfile +pnpm verify:launch +``` + +`pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: + +1. Exit 2 when Node is older than 24. +2. If the saved snapshot names a different repo root, has no checkout identity, or cannot be read, exit and do not restore. The message names `/tmp/synac-verify-/snapshot` and tells you to remove that directory or move it aside, then launch again. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. +3. Otherwise, if the previous launch was killed before it wrote its end-of-launch observation, restore every tool-owned file that differs from that snapshot. Then restore tool-owned files from a finished snapshot when their current bytes still match what that run wrote, and snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. Right after `npx convex dev` and `npx convex env set`, record only the Convex-owned paths that changed during that step (`skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`) and write each claim to `writes.json` as soon as it is recorded. Right after `next build` or the Next server start, record only `apps/web/AGENTS.md` and `apps/web/next-env.d.ts` when those changed during that step, the same way. Any other change is warned and left in place, including `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json`. Cleanup records generator-owned files that change after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. +4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. +5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. +6. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. +7. Start tmux session `synac-v--convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Stdin is `/dev/null`, so Convex does not run its interactive agent-file install. The session name is written to `run.json` as soon as tmux returns, before the ready check. A launch that times out still leaves the session for cleanup. Ready means the log line `Convex functions ready` in `/tmp/synac-verify-/synac-verify-convex.log`. The listener pid is stored with its `/proc` start time once port 3210 is open. +8. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. +9. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify-/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. +10. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. +11. Start tmux session `synac-v--web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The session is written to `run.json` before the port is open. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. + +`docs/contributing/local-dev.md` is the same stack, split across terminals. Use the helper so the sessions are recorded for cleanup. + +Logs and state. `` is the first 12 hex characters of sha256 of this repo root. A second worktree gets its own directory and its own tmux session names. `SYNAC_VERIFY_STATE_DIR` overrides the directory. + +- `/tmp/synac-verify-` is created mode `0700` and must be owned by the user who ran launch. If that directory already exists with another mode or owner, launch exits and does not change its permissions. +- `/tmp/synac-verify-/run.json` lists sessions and listener pids this launch started. Each pid keeps the process start time and command line from `/proc`. +- `/tmp/synac-verify-/synac-verify-convex.log` +- `/tmp/synac-verify-/synac-verify-web.log` +- `/tmp/synac-verify-/evidence/` is created mode `0700` at launch and kept by cleanup. If that directory already exists with another mode, launch exits and does not change its permissions. + +`.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. + +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify-/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch refuses when that path is different, when the manifest has no checkout identity, or when the manifest cannot be read, and the error tells you to remove `/tmp/synac-verify-/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout. It backs the foreign or legacy manifest up under `/tmp/synac-verify-/restored//manifest.json`, warns, and skips the file restore. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, writes that claim to `writes.json` before the next path, and keeps each file mode so a restore puts the executable bit back. If launch is killed before it writes the end-of-launch observation, the next launch that is not reusing a live stack restores tool-owned files from that snapshot before it takes a new one. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify-/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. + +## Doctor + +Run this first whenever a drive looks wrong. It only sends GET requests. + +```bash +pnpm verify:doctor +``` + +`READY` (exit 0) means all of the following: + +- `GET /api/healthz` is 200 and `ok` is true +- `GET /` is 200 and the HTML contains `Glossary search` +- `GET /api/v1/search?q=soc` is 200 and `meta.total` is greater than 0 + +Anything else prints `NOT_READY` and exits 2. + +| Code | Meaning | +| --- | --- | +| `unreachable` | Nothing answered on the base URL | +| `empty-dataset` | `/api/healthz` is 503 or `ok` is false. The content backend did not answer | +| `home-unhealthy` | The home page is up without the glossary search landmark | +| `search-unreadable` | Home rendered, and the search response was not the expected JSON | +| `search-empty` | Health and home are fine, and `q=soc` has `meta.total` 0 | + +```bash +node .agents/skills/verify-synac/scripts/doctor.mjs --help +node .agents/skills/verify-synac/scripts/doctor.mjs --json +``` + +Doctor prints who is listening on port 3000. A screenshot taken while doctor is `NOT_READY` is not evidence. + +## Checkpoint + +`pnpm gate` is the full checkpoint. Run it once on the final head, after the fast group for the code you touched is green. The CI job timeout is 20 minutes. Stop and report the log if a local gate exceeds 20 minutes. Do not start a second gate while one is still running. + +While iterating, run one named group and stay inside its budget: + +| Group | Command | Budget | +| --- | --- | --- | +| `web` | `pnpm --filter @synac/web test` | 2 minutes | +| `content` | `pnpm --filter @synac/content-tools test` | 2 minutes | +| `ingest` | `pnpm --filter @synac/ingest-tools test` | 2 minutes | +| `convex` | `pnpm test:convex` | 3 minutes | +| `content-check` | `pnpm content:check` | 1 minute | +| `verify-smoke` | `pnpm verify:smoke` with the stack already up | 4 minutes | + +`pnpm gate` runs lint, convex lint, format, typecheck, convex typecheck, test, tagging check, content check, content history, and build. It does not launch the site, it does not run `verify:drive`, and it does not need tmux. The checkout session test is `pnpm test:verify-tmux`. That command needs tmux on `PATH`. Required CI installs tmux and runs it after `pnpm test`. The optional verify-smoke workflow does the same. The end-to-end suite in `apps/e2e` stays `E2E_BASE_URL=http://127.0.0.1:3000 pnpm --filter @synac/e2e test:e2e` against this same stack. Use it when the change touches accessibility or a route the feature map does not list. + +## Drive + +Prefer the helpers in this skill. They use the accessible names already on the pages. + +Fast path (doctor, `content:check`, `content:compile` file write, home search `soc`, open the first result): + +```bash +pnpm verify:smoke +``` + +Feature sweep (every file under `references/features/`, including CLI side effects): + +```bash +pnpm verify:drive +``` + +Both commands run doctor first. If doctor exits 2, the browser does not open and the CLI drive does not run. + +One UI feature, after doctor is `READY`: + +```bash +E2E_BASE_URL=http://127.0.0.1:3000 \ +VERIFY_EVIDENCE_DIR=/tmp/synac-verify-/evidence \ +NODE_PATH="$PWD/apps/e2e/node_modules" \ +node apps/e2e/node_modules/@playwright/test/cli.js test \ + --config .agents/skills/verify-synac/playwright.config.mjs \ + .agents/skills/verify-synac/scripts/drive.spec.ts \ + -g "command palette" +``` + +`NODE_PATH` is required. The specs import `@playwright/test`, which is installed for `@synac/e2e` and is not hoisted to the repo root. `pnpm verify:smoke` and `pnpm verify:drive` set it. + +Run that from the repo root. The config file is `playwright.config.mjs` next to this skill. + +One CLI feature: + +```bash +node .agents/skills/verify-synac/scripts/cli.mjs --help +node .agents/skills/verify-synac/scripts/cli.mjs +``` + +Stable handles: + +- Home search: textbox `Search` inside the page (the header control named `Search` is a button and opens the palette). +- Palette: button `Search`, then combobox `Search terms and acronyms`. `Control+K` or `Meta+K` opens it. `/` opens it when focus is outside a text field. `Escape` closes it. `Enter` activates the highlighted option. +- Browse: navigation `Term letters` or `Acronym letters`, textbox `Filter entries`, combobox `Sort`. +- Tags: textbox `Filter tags`. +- Theme: button whose name starts with `Theme:`. The stored value is `localStorage['synac-theme']` and `document.documentElement.dataset.theme`. +- Entry: button `Copy link to this sense`, status text `Link copied`, summary `Cite`, link `Citation record (JSON)`. +- Mobile, below 920px: button `Open menu`, navigation `Mobile`. +- Header navigation `Primary`. Footer is `contentinfo`. + +Seeded slugs the corpus is known to contain (the same ones `apps/e2e/tests/helpers.ts` uses): + +- term `access-control` (heading `access control`) +- term `domain` +- acronym `black` (heading `BLACK`) +- acronym `2fa` (a `/term/2fa` visit redirects to `/acronym/2fa`) +- tag `identity-access` (heading `Identity and access`) +- source `rfc4949` +- query `soc` + +## Evidence + +Proof artifacts go to `/tmp/synac-verify-/evidence/` (override with `VERIFY_EVIDENCE_DIR`). Cleanup never deletes that directory. + +- `cli-*.log` and `cli-report.json` from the CLI helper +- `cli-sync.log` from launch, with the line `tag convergence: verified` +- `*.png` screenshots taken by the specs +- `test-results/` Playwright traces (`trace.zip` per test, trace is `on`) +- `playwright-report/` HTML report +- `tools/content/dist/compiled.json` is the compile side effect inside the repo. `dist/` is gitignored. The proof is the file's `contentVersion` matching the `content ok:` line, plus `entries` length greater than 0 + +A proof exercises the user path: type in the search box, click a result, press the shortcut, run the CLI. Reading `/api/healthz` is the doctor check, and it is not the proof that search works. + +Capture the action and the end state. Examples: the search box filled, then the entry heading; `content:compile` run, then `compiled.json` on disk; sync log `tag convergence: verified`, then `GET /api/v1/search?q=soc` with `meta.total` greater than 0. + +Side effects worth reading: + +- URL, heading, and `aria-current` +- `localStorage['synac-theme']` and `data-theme` after a click and again after reload +- clipboard text after Copy link to this sense +- `tools/content/dist/compiled.json` after `pnpm content:compile` +- the isolated ingest directory `/tmp/synac-verify-/ingest-content/generated/rfc4949.json` when the upstream fetch runs +- response status and JSON for the public read API, including a 304 on `If-None-Match` + +The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. + +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A name counts only when it ends in `Error` or `Exception` (`TypeError`, `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`), or the line is an `✗` or `✘` result, or it is an unprefixed message. A `warning:` line is not an error. A line that is only braces or punctuation, including the closing `}` of a Node error dump, is not an error. A later matching error wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, `TypeError: fetch failed`, or the ingest CLI line `✗ : fetch failed`, which also covers a TLS failure because Node uses that same message). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. + +## Cleanup + +```bash +pnpm verify:cleanup +``` + +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-v--convex` and `synac-v--web` when this checkout's `run.json` lists them. `` is the first 12 hex characters of sha256 of this repo root. Another worktree has a different directory and different session names, so cleanup does not stop that worktree's sessions or pids. A `run.json` with no repo root is not this checkout. Cleanup warns, copies that file aside, and does not signal its pids. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot is foreign, legacy, or unreadable, it warns, copies that manifest aside, skips the file restore, and still stops processes recorded for this checkout. A run state from another checkout is not used to stop processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify-/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify-/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. + +Confirm the evidence directory still exists after cleanup before citing it. + +## Helpers + +| Command | Script | +| --- | --- | +| `pnpm verify:launch` | `scripts/launch.mjs` | +| `pnpm verify:doctor` | `scripts/doctor.mjs` | +| `pnpm verify:smoke` | `scripts/smoke.mjs` (doctor, CLI smoke, `smoke.spec.ts`) | +| `pnpm verify:drive` | `scripts/smoke.mjs --drive` (doctor, full CLI, `drive.spec.ts`) | +| `pnpm verify:cleanup` | `scripts/cleanup.mjs` | + +```bash +node .agents/skills/verify-synac/scripts/launch.mjs --help +node .agents/skills/verify-synac/scripts/doctor.mjs --help +node .agents/skills/verify-synac/scripts/cli.mjs --help +node .agents/skills/verify-synac/scripts/smoke.mjs --help +node .agents/skills/verify-synac/scripts/cleanup.mjs --help +``` + +`scripts/evidence.ts` writes screenshots into `VERIFY_EVIDENCE_DIR`. + +## How to report + +Put a Proof section on the pull request: + +```markdown +### Proof +- Commands: the exact commands, including `pnpm verify:doctor` and the drive you ran +- Evidence: command logs, screenshot paths, and trace paths that still exist after cleanup +- Results: pass or fail per command, plus the observable end state (URL, heading, file written, record read back, attribute) +- INCONCLUSIVE: any step that could not run, and the concrete reason (port busy, revalidate secret unset, browser library missing, ingest network unreachable). Any other ingest failure is a failure. +``` + +A missing check is `INCONCLUSIVE`. Do not describe it as passed. + +## Feature map + +[`references/features/README.md`](references/features/README.md) is the sweep order. Each feature file has the same four headings: Sub-features, How to get to it (user POV), Driving it with Playwright or Driving it with the CLI, Gotchas. diff --git a/.agents/skills/verify-synac/playwright.config.mjs b/.agents/skills/verify-synac/playwright.config.mjs new file mode 100644 index 00000000..2377b75a --- /dev/null +++ b/.agents/skills/verify-synac/playwright.config.mjs @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { evidenceDir } from './scripts/runtime.mjs'; + +const skillDir = path.dirname(fileURLToPath(import.meta.url)); + +export default { + testDir: path.join(skillDir, 'scripts'), + outputDir: path.join(evidenceDir, 'test-results'), + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [ + ['line'], + ['html', { outputFolder: path.join(evidenceDir, 'playwright-report'), open: 'never' }], + ], + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000', + trace: 'on', + screenshot: 'on', + viewport: { width: 1280, height: 800 }, + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}; diff --git a/.agents/skills/verify-synac/references/features/README.md b/.agents/skills/verify-synac/references/features/README.md new file mode 100644 index 00000000..278e5755 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/README.md @@ -0,0 +1,77 @@ +# SynAc feature map + +Behavior-level inventory of the public glossary and the maintainer CLI. Agents use this map to decide what to drive and what evidence counts. The order below is the regression sweep. + +## Baseline preconditions + +- Node 24 on `PATH`. Run `pnpm verify:launch` and clear a `NOT_READY` doctor before driving the UI. +- Doctor `READY` means `/api/healthz` is ok, the home page has the glossary search landmark, and `GET /api/v1/search?q=soc` has `meta.total` greater than 0. +- One stack only: web on `127.0.0.1:3000`, Convex on `127.0.0.1:3210`. Launch refuses a second owner of those ports. +- Prefer accessible names (`Search`, `Theme: Dark`, `Term letters`, `Copy link to this sense`). The header `Search` control is a button. The page search field is a textbox with the same accessible name. +- Seeded slugs: term `access-control`, term `domain`, acronym `black`, redirect `2fa`, tag `identity-access`, source `rfc4949`, query `soc`. +- `pnpm gate` is the full checkpoint in `SKILL.md`. Run it once on the final head. Use the named fast group while iterating. + +## Proof and skip reporting + +The relevant feature file is the coverage set. When a change crosses search, an entry, and the CLI, also read `journeys.md`. + +- Exercise every reachable entry point the change can affect, including empty, error, and hostile inputs listed in Gotchas. +- Record the action and the final observable state, including files written and records read back. +- Mocks count only where the product already isolates an upstream fetch. The glossary reads the synced Convex dataset. Do not stub those reads. +- When a path is unreachable, name it and the concrete prerequisite. A live RFC ingest is `INCONCLUSIVE` only when the final thrown error and its cause are a network failure with no HTTP status. Any HTTP status or other ingest failure fails the drive. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. + +## Full sweep + +Walk this list top to bottom. Finish with `journeys.md`. + +## Home and search + +- [home](home.md): Landing page, glossary search, recently updated. +- [search](search.md): Query, type filter, entries versus meanings, empty and ignored queries. +- [command-palette](command-palette.md): Header search button and keyboard palette. + +## Browse + +- [terms-browse](terms-browse.md): Term index, letters, filter, sort. +- [acronyms-browse](acronyms-browse.md): Acronym index. +- [tags](tags.md): Tag directory and a tag page. +- [sources](sources.md): Source index and a source page. +- [recent](recent.md): Recently updated index. + +## Entry + +- [entry](entry.md): Term and acronym pages, senses, copy link, citation record. +- [not-found](not-found.md): Unknown slug and the `2fa` redirect. + +## Chrome + +- [site-chrome](site-chrome.md): Header, footer, mobile menu. +- [theme](theme.md): System, dark, and light, kept in localStorage. +- [about](about.md): About page. +- [legal](legal.md): Privacy policy and terms of use. +- [changelog](changelog.md): Changelog page and RSS feed. + +## HTTP API + +- [public-api](public-api.md): Public read API under `/api/v1`. + +## Maintainer CLI + +- [content-check](content-check.md): `pnpm content:check`. +- [content-compile](content-compile.md): `pnpm content:compile` writes `compiled.json`. +- [content-history](content-history.md): `pnpm content:history`. +- [content-sync](content-sync.md): sync into the local Convex deployment. +- [ingest](ingest.md): regenerate one bundle from upstream. + +## Journeys + +- [journeys](journeys.md): Search to an entry, then a theme that survives navigation. + +## Entry contract + +Every feature file uses the same four H2s: + +1. `Sub-features` +2. `How to get to it (user POV)` +3. `Driving it with Playwright` or `Driving it with the CLI` +4. `Gotchas` diff --git a/.agents/skills/verify-synac/references/features/about.md b/.agents/skills/verify-synac/references/features/about.md new file mode 100644 index 00000000..b66085d9 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/about.md @@ -0,0 +1,22 @@ +# About + +A static explanation of how entries are structured and maintained. + +## Sub-features + +- page: `/about`, heading `About SynAc` + +## How to get to it (user POV) + +Header link `About`, footer link `About`, or the palette row `About`. + +## Driving it with Playwright + +`drive.spec.ts` test `about page`. + +Open `/about` and expect the heading `About SynAc`. Screenshot `about.png`. + +## Gotchas + +- The page is prose. A proof is the heading plus a visible paragraph, not a screenshot of the hero alone. +- There is no form and no write. An empty dataset does not blank this page. diff --git a/.agents/skills/verify-synac/references/features/acronyms-browse.md b/.agents/skills/verify-synac/references/features/acronyms-browse.md new file mode 100644 index 00000000..d9e3f9ce --- /dev/null +++ b/.agents/skills/verify-synac/references/features/acronyms-browse.md @@ -0,0 +1,24 @@ +# Acronyms browse + +Alphabetical index of published acronyms. The controls match the term index. + +## Sub-features + +- letters: navigation `Acronym letters` +- filter, sort, tag chips, and paging: same parameters as terms, on `/acronyms` + +## How to get to it (user POV) + +Header link `Acronyms`, or `/acronyms`. The heading is `Acronyms`. + +## Driving it with Playwright + +`drive.spec.ts` test `acronyms browse opens BLACK`. + +Open `/acronyms`, click `b` in `Acronym letters` (the letter text is lowercase), then open `/acronym/black`. The heading is `BLACK`. Screenshot `acronym-black.png`. + +## Gotchas + +- Letter `b` is sorted by title. `BLACK` is near the start of that letter. If a future corpus pushes it past page 1, follow pagination `Next` until the link is visible and say so. +- Empty letters use the sentence `No published acronyms yet for `. +- A hostile `letter` value falls back to A, the same way the term index does. diff --git a/.agents/skills/verify-synac/references/features/changelog.md b/.agents/skills/verify-synac/references/features/changelog.md new file mode 100644 index 00000000..02b8f727 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/changelog.md @@ -0,0 +1,25 @@ +# Changelog + +Versioned notes, plus an RSS feed. + +## Sub-features + +- page: `/changelog`, heading `Changelog`, one section per version +- rss: link `RSS` to `/changelog/rss.xml` +- empty: `No changelog entries yet.` when the list is empty + +## How to get to it (user POV) + +Footer link `Changelog`, or the palette row `Changelog`. + +## Driving it with Playwright + +`drive.spec.ts` test `changelog page and rss feed`. + +Open `/changelog`, expect the heading, and expect the `RSS` link href `/changelog/rss.xml`. `GET /changelog/rss.xml` is 200 and the body contains `/evidence/cli-content-check.log`. + +```bash +pnpm content:check +``` + +Stdout contains `content ok:` and `(version ` plus 12 hex characters. Exit 0. + +The hostile check uses a scratch directory so the repo stays untouched: + +```bash +dir=/tmp/synac-verify-/hostile-content +mkdir -p "$dir" +printf '{' > "$dir/tags.json" +SYNAC_CONTENT_DIR="$dir" pnpm content:check +``` + +Exit is non-zero and the output contains `content check failed` or `invalid JSON`. Log: `cli-content-hostile.log`. + +## Gotchas + +- `SYNAC_CONTENT_DIR` redirects the read. Forget it and the command checks the real `content/` tree. +- Warnings print as `⚠` and still exit 0 when there are no errors. A proof of failure needs a non-zero exit. +- This command does not write `tools/content/dist/compiled.json`. That file is the compile feature. diff --git a/.agents/skills/verify-synac/references/features/content-compile.md b/.agents/skills/verify-synac/references/features/content-compile.md new file mode 100644 index 00000000..2b10e50f --- /dev/null +++ b/.agents/skills/verify-synac/references/features/content-compile.md @@ -0,0 +1,31 @@ +# Content compile + +Compiles `content/` and writes the dataset to disk. + +## Sub-features + +- emit: `pnpm content:compile` (the `check` script with `--emit`) +- file: `tools/content/dist/compiled.json` + +## How to get to it (user POV) + +A maintainer runs it when they need the compiled JSON locally. The web app does not read this file. Sync reads the compiler in process. + +## Driving it with the CLI + +`pnpm verify:smoke` runs compile after content check. + +```bash +pnpm content:compile +test -s tools/content/dist/compiled.json +``` + +Stdout contains `compiled dataset written to tools/content/dist/compiled.json`. The JSON has `contentVersion` starting with the 12 hex characters from the `content ok:` line, and `entries` is a non-empty array. Log: `cli-content-compile.log`. + +`dist/` is gitignored. Leave the file in the working tree. Do not commit it. + +## Gotchas + +- A failed check exits before the write. If the file already existed from an older run, a failed compile does not prove the new run wrote it. Compare `contentVersion` to the log line from the same run. +- The path is fixed. `SYNAC_CONTENT_DIR` changes the input directory and still writes `tools/content/dist/compiled.json` under the repo. +- Compile does not push to Convex. Search staying empty after compile means sync has not run. diff --git a/.agents/skills/verify-synac/references/features/content-history.md b/.agents/skills/verify-synac/references/features/content-history.md new file mode 100644 index 00000000..274fb837 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/content-history.md @@ -0,0 +1,28 @@ +# Content history + +Checks that `content/tag-assignments.json` history still chains from a git base ref. + +## Sub-features + +- unchanged: prints `tag assignment history ok:` when the file matches the base +- changed: validates `previousAssignmentsHash` when the file differs from the base + +## How to get to it (user POV) + +A maintainer runs `pnpm content:history` before a content PR. CI runs it inside `pnpm gate`. + +## Driving it with the CLI + +`pnpm verify:drive` runs it. Smoke skips it. + +```bash +pnpm content:history +``` + +Stdout contains `tag assignment history ok:` and the path `content/tag-assignments.json`. Log: `cli-content-history.log`. A non-zero exit fails the drive. + +## Gotchas + +- The default base is the merge base of `HEAD` and `origin/main`. When that ref is missing, the command falls back to `HEAD`, the diff is empty, and it still prints the ok line. That pass does not prove history against `origin/main`. +- `--base ` overrides the base. A missing value is an error: `--base requires a Git ref`. +- This command reads git. It does not write assignments. diff --git a/.agents/skills/verify-synac/references/features/content-sync.md b/.agents/skills/verify-synac/references/features/content-sync.md new file mode 100644 index 00000000..d7a74cc0 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/content-sync.md @@ -0,0 +1,32 @@ +# Content sync + +Pushes the compiled corpus into the local Convex deployment and checks tag counts. + +## Sub-features + +- push: `pnpm --filter @synac/content-tools sync` +- status: stdout `sync status:` and `tag convergence: verified` +- read-back: the site search returns seeded rows after sync + +## How to get to it (user POV) + +A maintainer runs sync after `npx convex dev` and `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. Readers then see the entries on the site. + +## Driving it with the CLI + +`pnpm verify:launch` runs sync once and copies stdout to `/tmp/synac-verify-/evidence/cli-sync.log`. `` is the first 12 hex characters of sha256 of the repo root. + +```bash +pnpm --filter @synac/content-tools sync +``` + +The log contains `tag convergence: verified` and `to the local dev deployment`. Then doctor `READY` is the read-back: `GET /api/v1/search?q=soc` has `meta.total` greater than 0, and `/term/access-control` renders. + +`--prod` targets a production deployment and needs `CONVEX_DEPLOY_KEY`. The verify skill does not pass `--prod`. A production sync is `INCONCLUSIVE` in this environment. + +## Gotchas + +- Sync against a Convex process this skill did not start is refused by launch. Do not point sync at a shared deployment to "save time". +- Re-running sync is idempotent. A second `tag convergence: verified` still counts. The proof is the line plus a search that returns data. +- If the service key is unset, sync's Convex calls fail. Launch sets `SYNAC_CONVEX_SERVICE_KEY=local-dev` before sync. +- The command writes records. It does not rewrite `content/generated/`. diff --git a/.agents/skills/verify-synac/references/features/entry.md b/.agents/skills/verify-synac/references/features/entry.md new file mode 100644 index 00000000..a0cd9785 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/entry.md @@ -0,0 +1,30 @@ +# Entry + +A published term or acronym: title, senses, copy link, and citation. + +## Sub-features + +- title: heading level 1 is the entry title (`access control`, `BLACK`) +- senses: anchors `#sense-...`, table-of-contents links +- copy: button `Copy link to this sense`, status `Link copied` +- cite: summary `Cite`, then link `Citation record (JSON)` +- related: region `Related entries` when the entry has relationships + +## How to get to it (user POV) + +Open a search result, a browse row, or a direct URL such as `/term/access-control` or `/acronym/black`. + +## Driving it with Playwright + +`drive.spec.ts` test `entry page copy link and citation record`. + +Open `/term/access-control`. The heading is `access control`. Click the first visible `#sense-` link (the mobile chip row is hidden at desktop width) and expect that fragment in the URL. Click `Copy link to this sense` and expect the status `Link copied`. Read the clipboard. It contains `/term/access-control#`. Open `Cite`, follow `Citation record (JSON)`, and expect HTTP 200. The body `slug` is `access-control` and `primaryCitation.sourceSlug` is set. The lead sense on this entry is whichever source the compiler ranked first (NICCS in the current corpus), so do not require `rfc4949` on that first record. Screenshot `entry-access-control.png`. + +The acronym path is the end of the acronyms browse test: heading `BLACK`. + +## Gotchas + +- Clipboard write needs a browser permission and a click. A headless context without `clipboard-read` and `clipboard-write` shows `Could not copy the link`. That failure is the copy path, not a missing button. +- A sense with no definition text says `No definition yet.` +- Citation JSON is a real route, `/api/v1/senses/citation.json`. Fetch the href the page rendered. Do not invent the sense key. +- Multi-sense terms such as `domain` show a numbered list. `access-control` may show a single sense. The copy button is still there. diff --git a/.agents/skills/verify-synac/references/features/home.md b/.agents/skills/verify-synac/references/features/home.md new file mode 100644 index 00000000..005aa99b --- /dev/null +++ b/.agents/skills/verify-synac/references/features/home.md @@ -0,0 +1,32 @@ +# Home + +The landing page: a glossary search box and a short list of recently updated entries. + +## Sub-features + +- hero-search: text box that submits to `/search` +- recent: up to eight recently updated entries, plus a link to the full list +- quick-links: terms, acronyms, and tags + +## How to get to it (user POV) + +Open `/`. The heading is `SynAc`. The search field is the large text box. Recently updated sits under it. + +## Driving it with Playwright + +```bash +pnpm verify:smoke +``` + +Or the home test inside `pnpm verify:drive`. + +Fill the textbox named `Search` with `soc` and press Enter. The URL becomes `/search?q=soc` and at least one result link is visible. The region named `Recently updated` contains entry links when the dataset is synced. + +Screenshot `smoke-home.png` shows the filled path's start. `home.png` is the drive shot. + +## Gotchas + +- The header control named `Search` is a button that opens the palette. The home field is a textbox with the same accessible name. +- An empty dataset renders `No published entries yet.` inside Recently updated. Doctor reports `empty-dataset` or `search-empty` before you treat that as the normal home. +- A one-character query and the stopwords `a`, `an`, `and`, `or`, `the` do not run a search. Drive those on the search page. +- A query longer than 120 characters is truncated. The form still submits. diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md new file mode 100644 index 00000000..23cf6db5 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -0,0 +1,55 @@ +# Ingest + +Regenerates `content/generated/.json` from an upstream document. + +## Sub-features + +- one source: `pnpm ingest -- --source rfc4949` +- all sources: `pnpm ingest -- --all` +- usage: no arguments exits 1 and prints `usage: ingest --source | --all` +- missing source: `--source not-a-source` prints `no source registry file` and exits 1 + +## How to get to it (user POV) + +A maintainer, or the weekly ingest workflow, regenerates a bundle and opens a PR when bytes change. Readers see the new definitions only after that bundle is merged and synced. + +## Driving it with the CLI + +`pnpm verify:drive` runs the local rejection checks and one isolated RFC fetch. + +Usage, from the repo root: + +```bash +pnpm --filter @synac/ingest-tools ingest +``` + +Exit 1. Stderr contains `usage: ingest`. + +Unknown source, against a scratch content dir: + +```bash +pnpm --filter @synac/ingest-tools ingest -- --source not-a-source +``` + +Exit 1. Output contains `no source registry file`. + +Live fetch, isolated so the repo bundle cannot change: + +```bash +dir=/tmp/synac-verify-/ingest-content +mkdir -p "$dir/sources" "$dir/generated" +cp content/sources/rfc4949.json "$dir/sources/" +cp content/generated/rfc4949.json "$dir/generated/" +SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rfc4949 +``` + +A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. + +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. The name must end in `Error` or `Exception`. A `warning:` line is not an error. A line that is only braces or punctuation is not an error, so the closing `}` of a Node fetch dump does not hide `ENOTFOUND`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. The ingest CLI prints `✗ : fetch failed` with no `TypeError` prefix, and that line is inconclusive when it has no HTTP status. A bare `fetch failed` also covers TLS errors. Node uses that same message for a certificate or handshake failure. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. + +## Gotchas + +- `--all` walks every source and will try the network for each. The sweep uses `rfc4949` only. +- A run with `SYNAC_CONTENT_DIR` unset writes `content/generated/` in the repo. The helper always sets the variable for the live fetch. +- `unchanged` means the new bundle compared equal to the previous file. Confirm the isolated file still exists. The word alone is not the side effect. +- A blocked host or a DNS failure is `INCONCLUSIVE` when the final thrown error and its cause are a network failure with no HTTP status. Any HTTP status is a failure. An earlier retry line does not change the class of the final error. Do not vendor a fake RFC into product content to force a pass. diff --git a/.agents/skills/verify-synac/references/features/journeys.md b/.agents/skills/verify-synac/references/features/journeys.md new file mode 100644 index 00000000..565e0d3c --- /dev/null +++ b/.agents/skills/verify-synac/references/features/journeys.md @@ -0,0 +1,26 @@ +# Journeys + +Paths that cross more than one feature. Walk this file at the end of a broad sweep. + +## Sub-features + +- search-to-entry: home search `access control` opens `/term/access-control` +- theme-survives-navigation: dark theme set on the entry is still dark on `/` + +## How to get to it (user POV) + +Start at the home search box. Open `access control`. Change the theme. Go back home. + +## Driving it with Playwright + +`drive.spec.ts` test `journey from search to a saved theme`. + +Fill the home textbox `Search` with `access control` and press Enter. The URL is `/search` with that query. Open the link `/term/access-control`. The heading is `access control`. Click the theme button once. Visit `/`. `` is set and `localStorage['synac-theme']` is `dark`. Screenshot `journey-home-dark.png`. + +The CLI half of a cross-surface change is still `pnpm verify:drive`: compile writes `compiled.json`, and launch's sync log plus this page are the read-back of those records. + +## Gotchas + +- The search form encodes the space as `+` or `%20`. Accept either in the URL. +- Theme state is per browser context. This journey sets it inside one test. +- If `access control` is missing from search while doctor is `READY`, the corpus changed. Update the seeded slug in this file and in `drive.spec.ts` together, after you confirm the new slug in `content/`. diff --git a/.agents/skills/verify-synac/references/features/legal.md b/.agents/skills/verify-synac/references/features/legal.md new file mode 100644 index 00000000..43f1b569 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/legal.md @@ -0,0 +1,23 @@ +# Legal + +Privacy policy and terms of use. + +## Sub-features + +- privacy: `/legal/privacy`, heading `Privacy Policy` +- terms: `/legal/terms`, heading `Terms of Use` + +## How to get to it (user POV) + +Footer links `Privacy` and `Terms of Use`. + +## Driving it with Playwright + +`drive.spec.ts` test `legal privacy and terms`. + +Open both URLs and expect those headings. Screenshot `legal-terms.png` is the terms page. + +## Gotchas + +- These pages are static prose. They do not read the glossary dataset. +- The footer label is `Terms of Use`. The header label `Terms` is the glossary index, a different page. diff --git a/.agents/skills/verify-synac/references/features/not-found.md b/.agents/skills/verify-synac/references/features/not-found.md new file mode 100644 index 00000000..c58d81f7 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/not-found.md @@ -0,0 +1,27 @@ +# Not found and redirects + +Unknown URLs and the permanent redirect from a term URL to an acronym URL. + +## Sub-features + +- missing-page: heading `Page not found` +- term-to-acronym: `/term/2fa` responds 308 to `/acronym/2fa` +- missing-tag: `/tags/nonexistent` is the same not-found page + +## How to get to it (user POV) + +Follow a stale link, or type a slug that is not published. + +## Driving it with Playwright + +`drive.spec.ts` test `missing entry and acronym redirect`. + +`/term/this-slug-does-not-exist-9f3a` shows `Page not found`. Screenshot `not-found.png`. + +`/term/2fa` lands on `/acronym/2fa` with a level-1 heading. Playwright follows the redirect. A status check belongs on `request.get` with `maxRedirects: 0`, which returns 308 and a `location` containing `/acronym/2fa`. + +## Gotchas + +- The not-found copy mentions the palette shortcut. The page still offers home, terms, and acronyms links. +- A hostile slug with spaces or slashes is a different route, not this page. Use a single unknown path segment. +- The redirect is only for slugs that exist as the other entry type. An unknown slug stays 404. diff --git a/.agents/skills/verify-synac/references/features/public-api.md b/.agents/skills/verify-synac/references/features/public-api.md new file mode 100644 index 00000000..7c25280a --- /dev/null +++ b/.agents/skills/verify-synac/references/features/public-api.md @@ -0,0 +1,38 @@ +# Public API + +Unauthenticated read API. The pages and the API read the same synced generation. + +## Sub-features + +- health: `GET /api/healthz` (doctor uses this) +- search: `GET /api/v1/search` +- entry: `GET /api/v1/entries/by-slug` +- lists: `GET /api/v1/terms`, `/api/v1/acronyms`, `/api/v1/tags`, `/api/v1/sources`, `/api/v1/sources/rfc4949` +- citation: `GET /api/v1/senses/citation.json` +- export: `GET /api/v1/export/entries.json` +- document: `GET /api/v1/openapi.json` +- operator: `POST /api/v1/internal/revalidate` (secret required) +- reports: `POST /api/v1/csp-report` + +## How to get to it (user POV) + +Readers do not see a separate API screen. The search page, the palette, and the citation link on an entry call these routes. `docs/api.md` lists them for programmatic readers. + +## Driving it with Playwright + +`drive.spec.ts` test `public api search, etag, openapi, and export`, plus the citation fetch in the entry test. + +- `GET /api/v1/search?q=soc` is 200, `meta.total` greater than 0, and `results` is an array. +- The same URL with `If-None-Match` set to the first response's `etag` returns 304 and an empty body. +- `GET /api/v1/search?q=the` is 200 and `meta.total` is 0. +- `GET /api/v1/openapi.json` is 200 and `openapi` starts with `3.`. +- `GET /api/v1/entries/by-slug?type=TERM&slug=access-control` is 200 and `entry.slug` is `access-control`. +- The same route with slug `this-slug-does-not-exist-9f3a` is 404. +- `GET /api/v1/export/entries.json?page=1` is 200 and `results` is non-empty. + +## Gotchas + +- A refused query (empty, one character, stopword) is 200 with an empty `results` array. It is not a 400. +- Search is limited to 60 calls a minute per caller. A sweep that loops will start returning 429. Stay on the calls listed above. +- `POST /api/v1/internal/revalidate` stays dark unless `SYNAC_REVALIDATE_SECRET` is set and at least 16 characters. That path is `INCONCLUSIVE` without the secret. Do not send a guessed bearer token. +- Export page 1 returns real rows on `results`. Count that array. Do not download the whole corpus in a smoke run. diff --git a/.agents/skills/verify-synac/references/features/recent.md b/.agents/skills/verify-synac/references/features/recent.md new file mode 100644 index 00000000..4b72418c --- /dev/null +++ b/.agents/skills/verify-synac/references/features/recent.md @@ -0,0 +1,24 @@ +# Recent + +Published entries ordered by most recent update. + +## Sub-features + +- list: `/recent`, 50 rows +- paging: `page`, hard cap 4 + +## How to get to it (user POV) + +Home link `View all` under Recently updated, or the palette row `Recent updates`, or `/recent`. The heading is `Recently updated`. + +## Driving it with Playwright + +`drive.spec.ts` test `recent page paginates inside the cap`. + +`/recent` shows pagination text `Page 1`. `/recent?page=99` redirects to `/recent?page=4`. Screenshot `recent.png`. + +## Gotchas + +- Page numbers above 4 redirect to page 4. They do not 404. +- A non-numeric `page` is treated as page 1. +- The home block shows eight rows. The recent page shows the longer list. Both read the same recent query. diff --git a/.agents/skills/verify-synac/references/features/search.md b/.agents/skills/verify-synac/references/features/search.md new file mode 100644 index 00000000..e6cf905d --- /dev/null +++ b/.agents/skills/verify-synac/references/features/search.md @@ -0,0 +1,34 @@ +# Search + +Full-text search over published entries and meanings. + +## Sub-features + +- query: GET form, parameter `q` +- type-filter: All, Terms, Acronyms (`type=TERM` or `type=ACRONYM`) +- scope: Entries or Meanings (`scope=senses`) +- paging: `page`, capped at 10, page size 20 + +## How to get to it (user POV) + +Submit the home search box, or open `/search`. The heading is `Search`. With a real query, filters sit above the results. + +## Driving it with Playwright + +`drive.spec.ts` test `search empty, ignored, filtered, and hostile queries`. + +- `/search` shows `Search published entries`. +- `/search?q=the` shows `Try a more specific query`. +- `/search?q=soc&type=TERM&scope=senses` keeps Meanings as the current filter and lists result links. +- `/search?q=` still renders the Search heading. The query stays text in the box. + +Screenshot `search-empty.png` and `search-soc.png`. + +The matching read is `GET /api/v1/search?q=soc` with `meta.total` greater than 0. That is the same query the page runs. + +## Gotchas + +- Empty, one character, and the bare stopwords never call the search index. They are guidance, not "no matches". +- After 60 searches in a minute from one caller, the page says `Too many searches` and the API returns 429. Do not flood the bucket during a sweep. A later drive in the same minute can look empty because the limiter refused. +- `page=0`, `page=-1`, and `page=999` clamp into 1 through 10. There is no error page for a wild page number. +- Unknown `type` and `scope` values fall back to all entries. They do not 400. diff --git a/.agents/skills/verify-synac/references/features/site-chrome.md b/.agents/skills/verify-synac/references/features/site-chrome.md new file mode 100644 index 00000000..481b5a3a --- /dev/null +++ b/.agents/skills/verify-synac/references/features/site-chrome.md @@ -0,0 +1,25 @@ +# Site chrome + +The header, footer, and small-screen menu that wrap every page. + +## Sub-features + +- header: brand `SynAc` to `/`, navigation `Primary` with Terms, Acronyms, Tags, Sources, About +- footer: contentinfo links About, Privacy, Terms of Use, Changelog, GitHub +- mobile: below 920px the primary nav hides and button `Open menu` opens navigation `Mobile` + +## How to get to it (user POV) + +The chrome is on every page. Narrow the window to use the menu. + +## Driving it with Playwright + +`drive.spec.ts` test `header, footer, and mobile menu`. + +At 1280px, navigation `Primary` shows `Terms`, and the footer link `Privacy` is visible. Resize to 390 by 844, click `Open menu`, and follow `Tags` in navigation `Mobile`. The URL ends in `/tags`. Screenshot `mobile-tags.png`. + +## Gotchas + +- The menu button is `display: none` above 920px. A click at desktop width does not count. +- Escape closes the mobile panel. The drive uses a link click, which also closes it. +- The GitHub footer link leaves the site. Do not follow it in the sweep. diff --git a/.agents/skills/verify-synac/references/features/sources.md b/.agents/skills/verify-synac/references/features/sources.md new file mode 100644 index 00000000..b7183f28 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/sources.md @@ -0,0 +1,27 @@ +# Sources + +The registry of cited sources, with license facts on each source page. + +## Sub-features + +- index: `/sources`, one link per source +- detail: `/sources/rfc4949`, region `Source facts` (base URL, verified date, license, trust) +- cited entries: list on the source page, paged + +## How to get to it (user POV) + +Header link `Sources`. Open a source name. RFC 4949 is the seeded source. + +## Driving it with Playwright + +`drive.spec.ts` test `sources list opens RFC 4949`. + +Open `/sources`, click the link whose name contains `RFC 4949`, and expect `/sources/rfc4949` plus the region `Source facts`. Screenshot `source-rfc4949.png`. + +The read-back for the same source is `GET /api/v1/sources/rfc4949` (covered with the public API sweep). + +## Gotchas + +- An empty registry says `No sources yet.` Doctor `search-empty` is the signal that sync did not load the corpus. Do not treat that empty state as a content bug until doctor is `READY`. +- An unknown source slug 404s. +- The source title contains punctuation from the registry. Match the link with `/RFC 4949/` rather than a hand-copied glyph. diff --git a/.agents/skills/verify-synac/references/features/tags.md b/.agents/skills/verify-synac/references/features/tags.md new file mode 100644 index 00000000..4ba6331a --- /dev/null +++ b/.agents/skills/verify-synac/references/features/tags.md @@ -0,0 +1,27 @@ +# Tags + +Curated taxonomy directory and one page per tag. + +## Sub-features + +- directory: `/tags`, textbox `Filter tags`, client-side filter +- tag page: `/tags/identity-access`, heading `Identity and access` +- type chips: All, Terms, Acronyms on the tag page +- retired tags: a retired slug redirects or explains that the tag is retired + +## How to get to it (user POV) + +Header link `Tags`. Type in `Filter tags`. Open a tag name. + +## Driving it with Playwright + +`drive.spec.ts` test `tags filter, empty filter, and a tag page`. + +On `/tags`, fill `zzzz-no-such-tag` and expect `No matching tags.` Then fill `identity` and open `Identity and access`. The URL is `/tags/identity-access` and the heading matches the tag name. Screenshot `tag-identity-access.png`. + +## Gotchas + +- The directory filter is local. It does not change the URL and it does not call the API. +- An unknown tag slug returns 404 with the page-not-found heading. Drive that from `not-found.md` when the change touches routing. +- A tag with zero published entries says `No published entries yet for this tag.` +- Filtering is a substring over name, slug, and description. `identity` matches `Identity and access`. diff --git a/.agents/skills/verify-synac/references/features/terms-browse.md b/.agents/skills/verify-synac/references/features/terms-browse.md new file mode 100644 index 00000000..3b1623e3 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/terms-browse.md @@ -0,0 +1,30 @@ +# Terms browse + +Alphabetical index of published terms. + +## Sub-features + +- letters: A through Z and `0-9`, navigation `Term letters` +- filter: textbox `Filter entries`, query parameter `q`, debounced +- sort: combobox `Sort`, `title` (default) or `updated` +- tag chips: navigation `Tag filter` +- paging: 50 rows, page capped at 10 + +## How to get to it (user POV) + +Header link `Terms`, or `/terms`. The heading is `Terms`. + +## Driving it with Playwright + +`drive.spec.ts` test `terms browse letter, sort, filter, and a rejected letter`. + +Open `/terms?letter=d`. The letter link `d` is current and at least one `/term/` row is visible. Letter text is lowercase. `domain` is not on page 1 of D. Choose `Recently updated` in `Sort` and expect `sort=updated` in the URL. Type `domain` into `Filter entries` and expect the link `/term/domain`. Screenshot `terms-d.png`. + +`/terms?letter=DROP%20TABLE` falls back to letter A, which is current. + +## Gotchas + +- An unknown letter, including a hostile string, is served as `a`. The page stays 200. +- A letter with no rows says `No published terms yet for `. Assert that sentence when the list is empty. Do not invent rows. +- The filter waits about 260ms before it changes the URL. Wait on the URL, not a fixed sleep alone. +- Page numbers above 10 are clamped. The UI does not offer a page the backend will not serve. diff --git a/.agents/skills/verify-synac/references/features/theme.md b/.agents/skills/verify-synac/references/features/theme.md new file mode 100644 index 00000000..99b2f94f --- /dev/null +++ b/.agents/skills/verify-synac/references/features/theme.md @@ -0,0 +1,25 @@ +# Theme + +Cycles system, dark, and light. The choice is stored in the browser. + +## Sub-features + +- cycle: button name `Theme: System`, then `Theme: Dark`, then `Theme: Light`, then back to system +- persist: `localStorage['synac-theme']` and `data-theme` on `` +- system: no `data-theme` attribute, so the OS preference applies + +## How to get to it (user POV) + +The theme button sits in the header on every page. + +## Driving it with Playwright + +`drive.spec.ts` test `theme cycles and survives reload`. + +A fresh context starts at `Theme: System`. Click once. The name is `Theme: Dark`, `` is set, and `localStorage['synac-theme']` is `dark`. Reload. The attribute is still `dark`. Screenshot `theme-dark.png`. + +## Gotchas + +- System removes `data-theme`. Assert the attribute is absent for that state. A missing attribute is the success state. +- Storage that throws still applies the theme for the current view. The reload check is what proves persistence. +- Each Playwright test has its own context. A theme set in one test is not visible in the next. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs new file mode 100755 index 00000000..0dc09db4 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import { + beginCleanup, + evidenceDir, + noteWritesSinceObserved, + ownedSessions, + readRun, + restoreOwnedWrites, + sessionAlive, + sessionConvex, + sessionWeb, + stateDir, + stopRecordedPid, + tmux, + warnUnclaimedChanges, + writeRun, +} from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs + +Stops tmux sessions this skill started for this checkout (${sessionConvex} and +${sessionWeb}) and the listener pids recorded in ${stateDir}/run.json, after +checking each pid's start time. Sends SIGTERM, then SIGKILL only while that +start time still matches. Another checkout's sessions and pids are left +alone. A foreign or legacy snapshot is backed up and not restored. Processes +recorded for this checkout are still stopped. +Restores a watched file to the launch snapshot when its current bytes still +match a recorded tool write. Backs that file up under +${stateDir}/restored// first, including when the path is a +symlink. Never changes the git index. Any other change is left in place +and printed as a warning. + +Leaves ${stateDir}/evidence/ in place. +`; + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + const gate = beginCleanup(); + if (!gate.stopOwn) { + console.log(`evidence ${evidenceDir}`); + return; + } + const run = readRun(); + const sessions = run.sessions.filter((name) => ownedSessions.has(name)); + for (const name of sessions) { + if (!sessionAlive(name)) continue; + const result = tmux(['kill-session', '-t', name], { stdio: 'inherit' }); + if (result.status !== 0) { + console.error(`cleanup: tmux kill-session ${name} exited ${result.status}`); + } else { + console.log(`stopped session ${name}`); + } + } + + const recorded = [ + ['convex', run.pids.convex], + ['web', run.pids.web], + ['convexSite', run.pids.convexSite], + ]; + const signaled = []; + const seen = new Set(); + for (const [name, saved] of recorded) { + if (!saved) continue; + if (seen.has(saved.pid)) continue; + seen.add(saved.pid); + const outcome = stopRecordedPid(saved); + if (outcome === 'exited') { + console.log(`pid ${saved.pid} (${name}) already exited`); + continue; + } + if (outcome === 'reused' || outcome === 'reused-before-kill') { + console.log(`skipped pid ${saved.pid} (${name}): it is no longer the process launch started`); + continue; + } + if (outcome === 'kill') { + console.log(`sent SIGKILL to pid ${saved.pid} (${name}) after SIGTERM`); + } + if (outcome === 'term' || outcome === 'kill') signaled.push(saved.pid); + } + if (signaled.length > 0) console.log(`signaled pids ${signaled.join(', ')}`); + + if (gate.restore) { + noteWritesSinceObserved(); + warnUnclaimedChanges(); + restoreOwnedWrites(); + } + const cleared = readRun(); + writeRun({ + ...cleared, + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + dirtyBefore: undefined, + }); + console.log(`evidence ${evidenceDir}`); +} + +main(); diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs new file mode 100755 index 00000000..dd4c1dd9 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { evidenceDir, ensureState, root, runLogged, stateDir } from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/cli.mjs [--smoke] + +Runs the content CLI and checks side effects. + +--smoke content:check and content:compile (writes tools/content/dist/compiled.json) +default smoke, plus content:history, a hostile content dir, ingest usage, + and an isolated rfc4949 ingest. A network-unreachable ingest is + INCONCLUSIVE when the final thrown error and its cause are a + network failure with no HTTP status. Indented stack frames are + ignored. Any HTTP status, parser error, or other failure exits 1. +`; + +const compiledPath = path.join(root, 'tools/content/dist/compiled.json'); + +function sha256(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function requireOk(step, result, needle) { + if (result.status !== 0) { + throw new Error(`${step} exited ${result.status}. See ${result.logPath}`); + } + if (needle && !`${result.stdout}\n${result.stderr}`.includes(needle)) { + throw new Error(`${step} output missing ${JSON.stringify(needle)}. See ${result.logPath}`); + } + return result; +} + +function smokeSteps(report) { + const check = requireOk( + 'content:check', + runLogged('pnpm', ['content:check'], 'cli-content-check.log'), + 'content ok:', + ); + const versionMatch = check.stdout.match(/\(version ([0-9a-f]{12})\)/); + const version = versionMatch?.[1]; + if (!version) throw new Error('content:check did not print a content version'); + + requireOk( + 'content:compile', + runLogged('pnpm', ['content:compile'], 'cli-content-compile.log'), + 'compiled dataset written to tools/content/dist/compiled.json', + ); + const compiled = JSON.parse(readFileSync(compiledPath, 'utf8')); + if (typeof compiled.contentVersion !== 'string' || !compiled.contentVersion.startsWith(version)) { + throw new Error('compiled.json contentVersion does not match content:check'); + } + if (!Array.isArray(compiled.entries) || compiled.entries.length === 0) { + throw new Error('compiled.json has no entries'); + } + report.steps.push({ + name: 'content-check-and-compile', + status: 'pass', + version, + entries: compiled.entries.length, + file: compiledPath, + }); +} + +function historyStep(report) { + const result = requireOk( + 'content:history', + runLogged('pnpm', ['content:history'], 'cli-content-history.log'), + 'tag assignment history ok:', + ); + report.steps.push({ name: 'content-history', status: 'pass', log: result.logPath }); +} + +function hostileStep(report) { + const dir = path.join(stateDir, 'hostile-content'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'tags.json'), '{'); + const bad = runLogged('pnpm', ['content:check'], 'cli-content-hostile.log', { + env: { SYNAC_CONTENT_DIR: dir }, + }); + if (bad.status === 0) { + throw new Error('content:check accepted a hostile content dir'); + } + const text = `${bad.stdout}\n${bad.stderr}`; + if (!text.includes('content check failed') && !text.includes('invalid JSON')) { + throw new Error(`hostile content:check did not report a schema failure. See ${bad.logPath}`); + } + report.steps.push({ name: 'content-hostile', status: 'pass', log: bad.logPath }); + + const usage = runLogged( + 'pnpm', + ['--filter', '@synac/ingest-tools', 'ingest'], + 'cli-ingest-usage.log', + ); + const usageText = `${usage.stdout}\n${usage.stderr}`; + if (usage.status === 0 || !usageText.includes('usage: ingest')) { + throw new Error(`ingest with no args did not print usage. See ${usage.logPath}`); + } + report.steps.push({ name: 'ingest-usage', status: 'pass', log: usage.logPath }); + + const missing = runLogged( + 'pnpm', + ['--filter', '@synac/ingest-tools', 'ingest', '--', '--source', 'not-a-source'], + 'cli-ingest-missing.log', + { env: { SYNAC_CONTENT_DIR: dir } }, + ); + const missingText = `${missing.stdout}\n${missing.stderr}`; + if (missing.status === 0 || !missingText.includes('no source registry file')) { + throw new Error(`missing ingest source was not rejected. See ${missing.logPath}`); + } + report.steps.push({ name: 'ingest-missing-source', status: 'pass', log: missing.logPath }); +} + +function ingestStep(report) { + const dir = path.join(stateDir, 'ingest-content'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(path.join(dir, 'sources'), { recursive: true }); + mkdirSync(path.join(dir, 'generated'), { recursive: true }); + const source = path.join(root, 'content/sources/rfc4949.json'); + const bundle = path.join(root, 'content/generated/rfc4949.json'); + cpSync(source, path.join(dir, 'sources/rfc4949.json')); + cpSync(bundle, path.join(dir, 'generated/rfc4949.json')); + const before = sha256(bundle); + + const result = runLogged( + 'pnpm', + ['--filter', '@synac/ingest-tools', 'ingest', '--', '--source', 'rfc4949'], + 'cli-ingest-rfc4949.log', + { env: { SYNAC_CONTENT_DIR: dir }, timeoutMs: 180_000 }, + ); + const after = sha256(bundle); + if (before !== after) { + throw new Error('isolated ingest modified content/generated/rfc4949.json in the repo'); + } + const text = `${result.stdout}\n${result.stderr}`; + const wrote = existsSync(path.join(dir, 'generated/rfc4949.json')); + if (result.status === 0 && wrote && /rfc4949: (unchanged|updated|skipped)/.test(text)) { + report.steps.push({ + name: 'ingest-rfc4949', + status: 'pass', + log: result.logPath, + isolatedDir: dir, + }); + return; + } + const detail = text.trim().split('\n').slice(-8).join(' '); + if (ingestNetworkUnreachable(text)) { + report.steps.push({ + name: 'ingest-rfc4949', + status: 'INCONCLUSIVE', + reason: `network unreachable (exit ${result.status}). ${detail}`, + log: result.logPath, + }); + console.log(`INCONCLUSIVE ingest rfc4949: network unreachable. See ${result.logPath}`); + return; + } + throw new Error( + `ingest rfc4949 failed (exit ${result.status}). ${detail} See ${result.logPath}`, + ); +} + +const networkUnreachable = + /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b|[✗✘]\s+\S+:\s+fetch failed\b/; + +function isWrapperLine(line) { + if (line.length === 0 || line === '^' || line.startsWith('\u2009')) return true; + if (/^\$\s/.test(line) || /^>\s/.test(line)) return true; + if (/^exit\s+\d+$/.test(line) || /^Exit status\b/.test(line)) return true; + if (/^(?:npm|pnpm)\s/.test(line)) return true; + if (/^\/\S+:$/.test(line)) return true; + return false; +} + +function isNamedError(line) { + const match = /^(?:\[([A-Za-z_][\w$]*)|([A-Za-z_][\w$]*)): \S/.exec(line); + if (!match) return false; + const name = match[1] ?? match[2] ?? ''; + return name.endsWith('Error') || name.endsWith('Exception'); +} + +function isMarkedError(line) { + return /^[✗✘]\s+\S/.test(line); +} + +function isBraceOrPunctuation(line) { + return /^[^\p{L}\p{N}]+$/u.test(line); +} + +function isErrorLine(line) { + if (line.length === 0 || /^\s/.test(line) || isWrapperLine(line) || isBraceOrPunctuation(line)) { + return false; + } + if (isMarkedError(line) || isNamedError(line)) return true; + return !/: /.test(line); +} + +function isExplicitCause(line) { + const trimmed = line.trim(); + return /^\[cause\]:/i.test(trimmed) || /^caused by:/i.test(trimmed) || /^cause:/i.test(trimmed); +} + +function isStackFrame(line) { + return /^\s+at\s/.test(line); +} + +export function finalThrownError(text) { + const lines = text.split(/\r?\n/); + let start = -1; + for (let i = 0; i < lines.length; i += 1) { + if (isErrorLine(lines[i] ?? '')) start = i; + } + if (start === -1) return ''; + const block = [lines[start] ?? '']; + for (let i = start + 1; i < lines.length; i += 1) { + const line = lines[i] ?? ''; + if (isExplicitCause(line)) { + block.push(line); + continue; + } + if (line.trim() === '' && isExplicitCause(lines[i + 1] ?? '')) { + block.push(line); + continue; + } + if (isStackFrame(line) || isBraceOrPunctuation(line)) continue; + break; + } + return block.join('\n'); +} + +function hasHttpStatus(text) { + return ( + /\bfetch failed \(\d{3}\)/i.test(text) || + /\bHTTP\/\d+(?:\.\d+)?\s+\d{3}\b/.test(text) || + /\bHTTP\s+\d{3}\b/.test(text) || + /\bstatus(?:\s+code)?\s*[:=]?\s*\d{3}\b/i.test(text) + ); +} + +export function ingestNetworkUnreachable(text) { + const errorText = finalThrownError(text); + if (!errorText) return false; + if (hasHttpStatus(errorText)) return false; + return networkUnreachable.test(errorText); +} + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + ensureState(); + const smoke = process.argv.includes('--smoke'); + const report = { steps: [] }; + try { + smokeSteps(report); + if (!smoke) { + historyStep(report); + hostileStep(report); + ingestStep(report); + } + } catch (error) { + report.steps.push({ + name: 'failed', + status: 'fail', + reason: error instanceof Error ? error.message : String(error), + }); + writeFileSync(path.join(evidenceDir, 'cli-report.json'), `${JSON.stringify(report, null, 2)}\n`); + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + writeFileSync(path.join(evidenceDir, 'cli-report.json'), `${JSON.stringify(report, null, 2)}\n`); + console.log(`cli report ${path.join(evidenceDir, 'cli-report.json')}`); +} + +function sameFile(left, right) { + try { + return realpathSync(left) === realpathSync(right); + } catch { + return false; + } +} + +const invokedDirectly = + process.argv[1] !== undefined && sameFile(process.argv[1], fileURLToPath(import.meta.url)); +if (invokedDirectly) main(); diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs new file mode 100644 index 00000000..5473b305 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { ingestNetworkUnreachable } from './cli.mjs'; + +test('ENOTFOUND on the final error is INCONCLUSIVE', () => { + const log = [ + '$ pnpm ingest', + 'exit 1', + '✗ rfc4949: getaddrinfo ENOTFOUND www.rfc-editor.org', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), true); +}); + +test('HTTP 404 on the final error is FAIL', () => { + const log = [ + '✗ rfc4949: RFC 4949 fetch failed (404) for https://www.rfc-editor.org/rfc/rfc4949.txt', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a parser error is FAIL', () => { + const log = "✗ rfc4949: Unexpected token '<' is not valid JSON"; + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a retry followed by a parser error is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'TypeError: fetch failed', + "✗ rfc4949: Unexpected token '<' is not valid JSON", + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a network cause on the final error is INCONCLUSIVE', () => { + const log = [ + '✗ rfc4949: TypeError: fetch failed', + ' [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), true); +}); + +test('an HTTP status in the final cause is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + '✗ rfc4949: TypeError: fetch failed', + ' [cause]: Error: fetch failed (503)', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('RangeError on the final error is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'RangeError: Invalid array length', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a custom error on the final error is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'AdapterError: parser failed', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a bracketed Error after a fetch-failed retry is FAIL', () => { + const log = [ + 'retry 1 failed: TypeError: fetch failed', + "[Error: Unexpected token '<' is not valid JSON]", + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a later RangeError, ZodError, or IngestError wins over TypeError: fetch failed', () => { + const earlier = [ + 'TypeError: fetch failed', + ' at node:internal/deps/undici/undici:14902:13', + ' [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + ]; + for (const finalLine of [ + 'RangeError: Invalid array length', + 'ZodError: invalid document', + 'IngestError: parser rejected the document', + "[Error: Unexpected token '<' is not valid JSON]", + "Unexpected token '<' is not valid JSON", + ]) { + const log = [...earlier, finalLine].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false, finalLine); + } +}); + +test('a warning line is not an error name', () => { + const stolen = [ + "SyntaxError: Unexpected token '<'", + 'warning: getaddrinfo ENOTFOUND www.rfc-editor.org', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(stolen), false); + const kept = [ + 'TypeError: fetch failed', + ' [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'warning: leaving files in place', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(kept), true); +}); + +test('an indented ENOTFOUND frame does not make a parser error INCONCLUSIVE', () => { + const log = [ + 'TypeError: fetch failed', + ' at node:internal/deps/undici/undici:14902:13', + ' Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + "SyntaxError: Unexpected token '<'", + ' at JSON.parse ()', + ' at parse (ingest.js:10:5)', + ' Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + '\u2009ELIFECYCLE\u2009 Command failed with exit code 1.', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +// Captured from Node v24.22.0 `console.error` of a failed fetch. The last line is only `}`. +const node24FetchDump = `[TypeError: fetch failed] { + [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.invalid + at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:123:26) { + errno: -3008, + code: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'www.rfc-editor.invalid' + } +}`; + +test('a Node 24 fetch error dump ending in a brace is INCONCLUSIVE', () => { + assert.match(node24FetchDump, /^\[TypeError: fetch failed\] \{$/m); + assert.equal(node24FetchDump.trim().split('\n').at(-1), '}'); + assert.equal(ingestNetworkUnreachable(node24FetchDump), true); +}); + +test( + 'a live fetch dump is INCONCLUSIVE when SYNAC_VERIFY_LIVE_FETCH=1', + { skip: process.env.SYNAC_VERIFY_LIVE_FETCH !== '1' }, + () => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + 'try {', + " await fetch('https://www.rfc-editor.invalid/rfc/rfc4949.txt');", + '} catch (error) {', + ' console.error(error);', + '}', + ].join('\n'), + ], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.equal(result.status, 0); + const dump = result.stderr; + assert.match(dump, /fetch failed/); + assert.equal(ingestNetworkUnreachable(dump), true); + }, +); + +test('the ingest CLI fetch failed line is INCONCLUSIVE', () => { + const log = [ + '$ pnpm --filter @synac/ingest-tools ingest -- --source rfc4949', + '✗ rfc4949: fetch failed', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), true); + assert.equal(ingestNetworkUnreachable('✗ rfc4949: fetch failed (404)'), false); +}); + +test('cli.mjs --help runs through the .cursor skill symlink', () => { + const result = spawnSync( + process.execPath, + ['.cursor/skills/verify-synac/scripts/cli.mjs', '--help'], + { cwd: fileURLToPath(new URL('../../../..', import.meta.url)), encoding: 'utf8' }, + ); + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage: node/); +}); diff --git a/.agents/skills/verify-synac/scripts/doctor.mjs b/.agents/skills/verify-synac/scripts/doctor.mjs new file mode 100755 index 00000000..26805e78 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/doctor.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { baseUrl, portOwner, webPort } from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/doctor.mjs [--json] [--base-url URL] + +Read-only check that the local SynAc site is worth driving. + +READY (exit 0) requires all of the following: + GET /api/healthz is 200 and {"ok":true} + GET / is 200 and the HTML contains the glossary search landmark + GET /api/v1/search?q=soc is 200 and meta.total is greater than 0 + +Anything else prints NOT_READY and exits 2. +`; + +function argValue(name) { + const index = process.argv.indexOf(name); + if (index < 0) return undefined; + return process.argv[index + 1]; +} + +export async function runDoctor(url = baseUrl) { + const report = { + status: 'NOT_READY', + code: 'unreachable', + baseUrl: url, + portOwner: portOwner(webPort), + healthz: null, + home: null, + search: null, + }; + + let health; + let home; + let search; + try { + health = await fetch(`${url}/api/healthz`, { redirect: 'manual' }); + home = await fetch(`${url}/`, { redirect: 'manual' }); + search = await fetch(`${url}/api/v1/search?q=soc`, { redirect: 'manual' }); + } catch (error) { + report.detail = error instanceof Error ? error.message : String(error); + return report; + } + + const healthText = await health.text(); + const homeText = await home.text(); + let healthBody = null; + try { + healthBody = JSON.parse(healthText); + } catch { + healthBody = null; + } + let searchBody = null; + try { + searchBody = JSON.parse(await search.text()); + } catch { + searchBody = null; + } + + const total = + searchBody && + typeof searchBody === 'object' && + searchBody.meta && + typeof searchBody.meta.total === 'number' + ? searchBody.meta.total + : null; + + report.healthz = { status: health.status, ok: healthBody?.ok === true }; + report.home = { + status: home.status, + hasSearch: homeText.includes('Glossary search'), + }; + report.search = { status: search.status, total }; + + if (health.status === 503 || healthBody?.ok === false) { + report.code = 'empty-dataset'; + return report; + } + if (home.status !== 200 || !homeText.includes('Glossary search')) { + report.code = 'home-unhealthy'; + return report; + } + if (!searchBody || search.status !== 200) { + report.code = 'search-unreadable'; + return report; + } + if (total === 0) { + report.code = 'search-empty'; + return report; + } + if (typeof total !== 'number') { + report.code = 'search-unreadable'; + return report; + } + + report.status = 'READY'; + report.code = 'ready'; + return report; +} + +function printHuman(report) { + console.log(`${report.status} ${report.code}`); + console.log(`base ${report.baseUrl}`); + console.log(`port ${webPort} owner: ${report.portOwner}`); + if (report.detail) console.log(`detail ${report.detail}`); + if (report.healthz) { + console.log(`healthz ${report.healthz.status} ok=${report.healthz.ok}`); + } + if (report.home) { + console.log(`home ${report.home.status} glossary-search=${report.home.hasSearch}`); + } + if (report.search) { + console.log(`search ${report.search.status} total=${report.search.total ?? 'none'}`); + } +} + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + const url = argValue('--base-url') ?? baseUrl; + const report = await runDoctor(url); + if (process.argv.includes('--json')) { + console.log(JSON.stringify(report, null, 2)); + } else { + printHuman(report); + } + process.exit(report.status === 'READY' ? 0 : 2); +} + +const invoked = + process.argv[1] && + (process.argv[1].endsWith('/doctor.mjs') || process.argv[1].endsWith('doctor.mjs')); +if (invoked) await main(); diff --git a/.agents/skills/verify-synac/scripts/drive.spec.ts b/.agents/skills/verify-synac/scripts/drive.spec.ts new file mode 100644 index 00000000..03f3ddf8 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/drive.spec.ts @@ -0,0 +1,262 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { capture } from './evidence'; + +async function shot(page: Page, name: string) { + await capture(page, name); +} + +test('home lists recent entries', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1, name: 'SynAc' })).toBeVisible(); + const recent = page.getByRole('region', { name: 'Recently updated' }); + await expect(recent.getByRole('link').first()).toBeVisible(); + await shot(page, 'home.png'); +}); + +test('search empty, ignored, filtered, and hostile queries', async ({ page }) => { + await page.goto('/search'); + await expect(page.getByText('Search published entries')).toBeVisible(); + await shot(page, 'search-empty.png'); + + await page.goto('/search?q=the'); + await expect(page.getByText('Try a more specific query')).toBeVisible(); + + await page.goto('/search?q=soc&type=TERM&scope=senses'); + await expect(page.getByRole('heading', { level: 1, name: 'Search' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Meanings' })).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('a[href*="/term/"], a[href*="/acronym/"]').first()).toBeVisible(); + await shot(page, 'search-soc.png'); + + await page.goto('/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'); + await expect(page.getByRole('heading', { level: 1, name: 'Search' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Search' })).toHaveValue( + '', + ); +}); + +test('command palette opens from the keyboard and navigates', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Search' }).waitFor(); + const input = page.getByRole('combobox', { name: 'Search terms and acronyms' }); + await expect(async () => { + await page.keyboard.press('Control+KeyK'); + await expect(input).toBeVisible({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await input.fill('soc'); + await expect(page.getByRole('option').first()).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/(search|term|acronym)/); + await shot(page, 'palette.png'); +}); + +test('terms browse letter, sort, filter, and a rejected letter', async ({ page }) => { + await page.goto('/terms?letter=d'); + await expect(page.getByRole('heading', { level: 1, name: 'Terms' })).toBeVisible(); + const letters = page.getByRole('navigation', { name: 'Term letters' }); + await expect(letters.getByRole('link', { name: 'd', exact: true })).toHaveAttribute( + 'aria-current', + 'true', + ); + await expect(page.locator('a[href^="/term/"]').first()).toBeVisible(); + await shot(page, 'terms-d.png'); + + await page.getByRole('combobox', { name: 'Sort' }).selectOption('updated'); + await expect(page).toHaveURL(/sort=updated/); + + const filter = page.getByRole('textbox', { name: 'Filter entries' }); + await filter.fill('domain'); + await expect(page).toHaveURL(/q=domain/, { timeout: 10_000 }); + await expect(page.locator('a[href="/term/domain"]')).toBeVisible(); + + await page.goto('/terms?letter=DROP%20TABLE'); + await expect(letters.getByRole('link', { name: 'a', exact: true })).toHaveAttribute( + 'aria-current', + 'true', + ); +}); + +test('acronyms browse opens BLACK', async ({ page }) => { + await page.goto('/acronyms'); + await expect(page.getByRole('heading', { level: 1, name: 'Acronyms' })).toBeVisible(); + await page.getByRole('navigation', { name: 'Acronym letters' }).getByRole('link', { name: 'b', exact: true }).click(); + await expect(page).toHaveURL(/letter=b/); + await page.locator('a[href="/acronym/black"]').click(); + await expect(page.getByRole('heading', { level: 1, name: 'BLACK' })).toBeVisible(); + await shot(page, 'acronym-black.png'); +}); + +test('entry page copy link and citation record', async ({ page, context, request }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/term/access-control'); + await expect(page.getByRole('heading', { level: 1, name: 'access control' })).toBeVisible(); + const toc = page.locator('a[href^="#sense-"]:visible').first(); + await expect(toc).toBeVisible(); + const fragment = await toc.getAttribute('href'); + expect(fragment).toBeTruthy(); + await toc.click(); + await expect(page).toHaveURL(new RegExp(`${fragment}$`)); + + await page.getByRole('button', { name: 'Copy link to this sense' }).first().click(); + await expect(page.getByRole('status').first()).toHaveText('Link copied'); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('/term/access-control#'); + await shot(page, 'entry-access-control.png'); + + await page.getByText('Cite', { exact: true }).first().click(); + const jsonLink = page.getByRole('link', { name: 'Citation record (JSON)' }).first(); + await expect(jsonLink).toBeVisible(); + const href = await jsonLink.getAttribute('href'); + expect(href).toBeTruthy(); + const response = await request.get(href ?? ''); + expect(response.status()).toBe(200); + const body = (await response.json()) as { + slug?: string; + primaryCitation?: { sourceSlug?: string }; + }; + expect(body.slug).toBe('access-control'); + expect(body.primaryCitation?.sourceSlug).toBeTruthy(); +}); + +test('tags filter, empty filter, and a tag page', async ({ page }) => { + await page.goto('/tags'); + await expect(page.getByRole('heading', { level: 1, name: 'Tags' })).toBeVisible(); + const filter = page.getByRole('textbox', { name: 'Filter tags' }); + await filter.fill('zzzz-no-such-tag'); + await expect(page.getByText('No matching tags.')).toBeVisible(); + await filter.fill('identity'); + await page.getByRole('link', { name: 'Identity and access' }).click(); + await expect(page).toHaveURL(/\/tags\/identity-access$/); + await expect(page.getByRole('heading', { level: 1, name: 'Identity and access' })).toBeVisible(); + await shot(page, 'tag-identity-access.png'); +}); + +test('sources list opens RFC 4949', async ({ page }) => { + await page.goto('/sources'); + await expect(page.getByRole('heading', { level: 1, name: 'Sources' })).toBeVisible(); + await page.getByRole('link', { name: /RFC 4949/ }).click(); + await expect(page).toHaveURL(/\/sources\/rfc4949$/); + await expect(page.getByRole('region', { name: 'Source facts' })).toBeVisible(); + await shot(page, 'source-rfc4949.png'); +}); + +test('recent page paginates inside the cap', async ({ page }) => { + await page.goto('/recent'); + await expect(page.getByRole('heading', { level: 1, name: 'Recently updated' })).toBeVisible(); + await expect(page.getByRole('navigation', { name: 'Pagination' })).toContainText('Page 1'); + await page.goto('/recent?page=99'); + await expect(page).toHaveURL(/\/recent\?page=4/); + await shot(page, 'recent.png'); +}); + +test('changelog page and rss feed', async ({ page, request }) => { + await page.goto('/changelog'); + await expect(page.getByRole('heading', { level: 1, name: 'Changelog' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'RSS' })).toHaveAttribute('href', '/changelog/rss.xml'); + const rss = await request.get('/changelog/rss.xml'); + expect(rss.status()).toBe(200); + expect(await rss.text()).toContain(' { + await page.goto('/about'); + await expect(page.getByRole('heading', { level: 1, name: 'About SynAc' })).toBeVisible(); + await shot(page, 'about.png'); +}); + +test('legal privacy and terms', async ({ page }) => { + await page.goto('/legal/privacy'); + await expect(page.getByRole('heading', { level: 1, name: 'Privacy Policy' })).toBeVisible(); + await page.goto('/legal/terms'); + await expect(page.getByRole('heading', { level: 1, name: 'Terms of Use' })).toBeVisible(); + await shot(page, 'legal-terms.png'); +}); + +test('theme cycles and survives reload', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: /Theme:/ }); + await expect(toggle).toHaveAttribute('aria-label', 'Theme: System'); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-label', 'Theme: Dark'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + const stored = await page.evaluate(() => window.localStorage.getItem('synac-theme')); + expect(stored).toBe('dark'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await shot(page, 'theme-dark.png'); +}); + +test('header, footer, and mobile menu', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Terms' })).toBeVisible(); + await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Privacy' })).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('navigation', { name: 'Mobile' }).getByRole('link', { name: 'Tags' }).click(); + await expect(page).toHaveURL(/\/tags$/); + await shot(page, 'mobile-tags.png'); +}); + +test('missing entry and acronym redirect', async ({ page }) => { + await page.goto('/term/this-slug-does-not-exist-9f3a'); + await expect(page.getByRole('heading', { level: 1, name: 'Page not found' })).toBeVisible(); + await shot(page, 'not-found.png'); + await page.goto('/term/2fa'); + await expect(page).toHaveURL(/\/acronym\/2fa$/); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); +}); + +test('public api search, etag, openapi, and export', async ({ request }) => { + const search = await request.get('/api/v1/search?q=soc'); + expect(search.status()).toBe(200); + const body = (await search.json()) as { meta?: { total?: number }; results?: unknown[] }; + expect(body.meta?.total ?? 0).toBeGreaterThan(0); + expect(Array.isArray(body.results)).toBe(true); + + const ignored = await request.get('/api/v1/search?q=the'); + expect(ignored.status()).toBe(200); + const ignoredBody = (await ignored.json()) as { meta?: { total?: number } }; + expect(ignoredBody.meta?.total).toBe(0); + + const etag = search.headers()['etag'] ?? ''; + expect(etag).toBeTruthy(); + const cached = await request.get('/api/v1/search?q=soc', { + headers: { 'if-none-match': etag }, + }); + expect(cached.status()).toBe(304); + + const spec = await request.get('/api/v1/openapi.json'); + expect(spec.status()).toBe(200); + const specBody = (await spec.json()) as { openapi?: string }; + expect(specBody.openapi).toMatch(/^3\./); + + const entry = await request.get('/api/v1/entries/by-slug?type=TERM&slug=access-control'); + expect(entry.status()).toBe(200); + const entryBody = (await entry.json()) as { entry?: { slug?: string } }; + expect(entryBody.entry?.slug).toBe('access-control'); + + const missing = await request.get('/api/v1/entries/by-slug?type=TERM&slug=this-slug-does-not-exist-9f3a'); + expect(missing.status()).toBe(404); + + const exported = await request.get('/api/v1/export/entries.json?page=1'); + expect(exported.status()).toBe(200); + const exportBody = (await exported.json()) as { results?: unknown[] }; + expect((exportBody.results ?? []).length).toBeGreaterThan(0); +}); + +test('journey from search to a saved theme', async ({ page }) => { + await page.goto('/'); + const search = page.getByRole('textbox', { name: 'Search' }); + await search.fill('access control'); + await search.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=access\+control|\/search\?q=access%20control/); + await page.locator('a[href="/term/access-control"]').first().click(); + await expect(page.getByRole('heading', { level: 1, name: 'access control' })).toBeVisible(); + await page.getByRole('button', { name: /Theme:/ }).click(); + await page.goto('/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + const stored = await page.evaluate(() => window.localStorage.getItem('synac-theme')); + expect(stored).toBe('dark'); + await shot(page, 'journey-home-dark.png'); +}); diff --git a/.agents/skills/verify-synac/scripts/evidence.ts b/.agents/skills/verify-synac/scripts/evidence.ts new file mode 100644 index 00000000..e7be6a96 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/evidence.ts @@ -0,0 +1,13 @@ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; + +import type { Page } from '@playwright/test'; + +export const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? '/tmp/synac-verify/evidence'; + +export async function capture(page: Page, name: string): Promise { + mkdirSync(evidenceDir, { recursive: true }); + const file = path.join(evidenceDir, name.endsWith('.png') ? name : `${name}.png`); + await page.screenshot({ path: file, fullPage: true }); + return file; +} diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs new file mode 100755 index 00000000..94f986f8 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { runDoctor } from './doctor.mjs'; +import { + baseUrl, + convexPort, + convexSitePort, + convexUrl, + assertSnapshotCheckout, + beginLaunch, + captureToolWrites, + markStepBaseline, + ensureState, + evidenceDir, + finishLaunchSnapshot, + nodeMajor, + noteSession, + portOpen, + rememberListener, + root, + runLogged, + sessionAlive, + sessionConvex, + sessionWeb, + shQuote, + startSession, + stateDir, + waitFor, + webPort, +} from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/launch.mjs + +Starts the local SynAc stack in tmux sessions ${sessionConvex} and +${sessionWeb}, syncs repo content, and waits until doctor is READY. +State for this checkout is ${stateDir}. + +Env: + VERIFY_WEB_MODE=start|dev start is a production build (default) + VERIFY_SKIP_BUILD=1 reuse apps/web/.next when present + VERIFY_SKIP_BROWSER_INSTALL=1 skip Playwright Chromium install + VERIFY_SKIP_INSTALL=1 skip pnpm install +`; + +const webEnv = { + NEXT_PUBLIC_CONVEX_URL: convexUrl, + NEXT_PUBLIC_SITE_URL: baseUrl, + SYNAC_CONVEX_SERVICE_KEY: 'local-dev', + SYNAC_RATE_LIMIT_SALT: 'local-dev', + E2E_BASE_URL: baseUrl, +}; + +function fail(message) { + const error = new Error(message); + error.exitCode = 2; + throw error; +} + +function exportPrefix() { + const pathEnv = process.env.PATH ?? ''; + const pairs = { PATH: pathEnv, ...webEnv }; + return Object.entries(pairs) + .map(([key, value]) => `export ${key}=${shQuote(value)};`) + .join(' '); +} + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + try { + await run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + const exitCode = error instanceof Error && 'exitCode' in error && error.exitCode === 2 ? 2 : 1; + process.exitCode = exitCode; + } finally { + try { + finishLaunchSnapshot(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + if (!process.exitCode) process.exitCode = 1; + } + } +} + +async function captureAfter(tool, fn) { + markStepBaseline(); + try { + return await fn(); + } finally { + try { + captureToolWrites(tool); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + } + } +} + +async function run() { + if (nodeMajor() < 24) { + fail( + `node ${process.version} is below 24. Put nvm Node 24 first on PATH, then rerun.`, + ); + } + if (!baseUrl.startsWith('http://127.0.0.1:3000') && baseUrl !== 'http://localhost:3000') { + fail(`refusing base URL ${baseUrl}. This skill owns http://127.0.0.1:3000.`); + } + + ensureState(); + assertSnapshotCheckout(); + const doctor = await runDoctor(baseUrl); + const phase = beginLaunch(doctor.status); + if (phase === 'reuse') { + console.log('REUSED'); + console.log(`evidence ${evidenceDir}`); + return; + } + + const webUp = await portOpen(webPort); + const convexUp = await portOpen(convexPort); + if (webUp && !sessionAlive(sessionWeb)) { + fail( + `port ${webPort} is open (${doctor.portOwner}) and this skill did not start it. Leave it alone.`, + ); + } + if (webUp) { + noteSession(sessionWeb); + rememberListener('web', webPort); + } + if (convexUp && !sessionAlive(sessionConvex)) { + fail( + `port ${convexPort} is open and this skill did not start it. Leave it alone.`, + ); + } + if (convexUp) { + noteSession(sessionConvex); + rememberListener('convex', convexPort); + if (await portOpen(convexSitePort)) rememberListener('convexSite', convexSitePort); + } + + if (process.env.VERIFY_SKIP_INSTALL !== '1' && !existsSync(path.join(root, 'node_modules'))) { + const install = spawnSync('pnpm', ['install', '--frozen-lockfile'], { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (install.status !== 0) fail('pnpm install failed'); + } + + if (process.env.VERIFY_SKIP_BROWSER_INSTALL !== '1') { + const browsers = spawnSync( + 'pnpm', + ['--filter', '@synac/e2e', 'exec', 'playwright', 'install', 'chromium'], + { cwd: root, stdio: 'inherit', env: process.env }, + ); + if (browsers.status !== 0) fail('playwright install chromium failed'); + } + + if (!convexUp) { + await captureAfter('convex', async () => { + const log = path.join(stateDir, 'synac-verify-convex.log'); + startSession( + sessionConvex, + `${exportPrefix()} export CONVEX_AGENT_MODE=anonymous; cd ${shQuote(root)}; npx convex dev < /dev/null > ${shQuote(log)} 2>&1`, + ); + noteSession(sessionConvex); + await waitFor( + 'Convex functions ready', + async () => { + if (await portOpen(convexPort)) rememberListener('convex', convexPort); + try { + return readFileSync(log, 'utf8').includes('Convex functions ready'); + } catch { + return false; + } + }, + 180_000, + ); + if (!(await portOpen(convexPort))) fail(`convex log went ready but port ${convexPort} is closed`); + rememberListener('convex', convexPort); + if (await portOpen(convexSitePort)) rememberListener('convexSite', convexSitePort); + }); + } + + await captureAfter('convex', async () => { + let envSet = 1; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const result = spawnSync( + 'npx', + ['convex', 'env', 'set', 'SYNAC_CONVEX_SERVICE_KEY', 'local-dev'], + { cwd: root, stdio: 'inherit', env: { ...process.env, CONVEX_AGENT_MODE: 'anonymous' } }, + ); + envSet = result.status ?? 1; + if (envSet === 0) break; + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + if (envSet !== 0) fail('npx convex env set SYNAC_CONVEX_SERVICE_KEY failed'); + }); + + const sync = runLogged( + 'pnpm', + ['--filter', '@synac/content-tools', 'sync'], + 'cli-sync.log', + { timeoutMs: 600_000, env: { CONVEX_AGENT_MODE: 'anonymous' } }, + ); + if (sync.status !== 0 || !sync.stdout.includes('tag convergence: verified')) { + fail(`content sync failed. See ${sync.logPath}`); + } + console.log(`sync log ${sync.logPath}`); + + const mode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; + const buildId = path.join(root, 'apps/web/.next/BUILD_ID'); + const skipBuild = mode === 'dev' || (process.env.VERIFY_SKIP_BUILD === '1' && existsSync(buildId)); + if (!skipBuild) { + await captureAfter('next', async () => { + const build = spawnSync('pnpm', ['--filter', '@synac/web', 'build'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, ...webEnv }, + }); + if (build.status !== 0) fail('web build failed'); + }); + } + + if (!(await portOpen(webPort))) { + await captureAfter('next', async () => { + const log = path.join(stateDir, 'synac-verify-web.log'); + const webCommand = + mode === 'dev' + ? 'pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000' + : 'pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000'; + startSession( + sessionWeb, + `${exportPrefix()} cd ${shQuote(root)}; ${webCommand} > ${shQuote(log)} 2>&1`, + ); + noteSession(sessionWeb); + await waitFor( + 'web port', + async () => { + if (await portOpen(webPort)) { + rememberListener('web', webPort); + return true; + } + if (!sessionAlive(sessionWeb)) { + throw new Error(`web session exited before port ${webPort} opened. See ${log}`); + } + return false; + }, + 120_000, + ); + rememberListener('web', webPort); + }); + } + + let ready = await runDoctor(baseUrl); + const doctorDeadline = Date.now() + 60_000; + while (ready.status !== 'READY' && Date.now() < doctorDeadline) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + ready = await runDoctor(baseUrl); + } + if (ready.status !== 'READY') { + console.error(JSON.stringify(ready, null, 2)); + fail(`doctor is ${ready.code} after launch. Logs are in ${stateDir}`); + } + console.log('STARTED'); + console.log(`evidence ${evidenceDir}`); +} + +await main(); diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs new file mode 100755 index 00000000..a0f53089 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -0,0 +1,1091 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + +export const root = path.resolve(process.env.SYNAC_VERIFY_ROOT ?? path.resolve(scriptDir, '../../../..')); +if (process.cwd() !== root) process.chdir(root); + +function canonicalRoot(repoRoot) { + const resolved = path.resolve(repoRoot); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + +export function checkoutKey(repoRoot) { + return createHash('sha256').update(canonicalRoot(repoRoot)).digest('hex').slice(0, 12); +} + +export function stateDirFor(repoRoot) { + return path.join('/tmp', `synac-verify-${checkoutKey(repoRoot)}`); +} + +export function sessionsFor(repoRoot) { + const key = checkoutKey(repoRoot); + return { + convex: `synac-v-${key}-convex`, + web: `synac-v-${key}-web`, + }; +} + +const namedWatched = [ + 'AGENTS.md', + 'CLAUDE.md', + 'apps/web/AGENTS.md', + 'apps/web/CLAUDE.md', + 'convex.json', + 'skills-lock.json', + 'apps/web/next-env.d.ts', + '.cursor/rules/convex_rules.mdc', +]; + +const handMaintained = new Set([ + 'AGENTS.md', + 'CLAUDE.md', + 'apps/web/CLAUDE.md', + 'convex.json', +]); + +const snapshotDirName = 'snapshot'; +let tookSnapshot = false; +export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? stateDirFor(root); +export const evidenceDir = + process.env.VERIFY_EVIDENCE_DIR ?? path.join(stateDir, 'evidence'); +export const runFile = path.join(stateDir, 'run.json'); +export const baseUrl = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000'; +export const webPort = 3000; +export const convexPort = 3210; +export const convexSitePort = 3211; +export const convexUrl = 'http://127.0.0.1:3210'; +const ownSessions = sessionsFor(root); +export const sessionConvex = ownSessions.convex; +export const sessionWeb = ownSessions.web; +export const ownedSessions = new Set([sessionConvex, sessionWeb]); + +const tmuxConfig = process.env.SYNAC_VERIFY_TMUX_CONFIG + ? process.env.SYNAC_VERIFY_TMUX_CONFIG + : existsSync('/exec-daemon/tmux.portal.conf') + ? '/exec-daemon/tmux.portal.conf' + : ''; + +function assertPrivateDir(dir, created) { + const listed = lstatSync(dir); + if (listed.isSymbolicLink() || !listed.isDirectory()) { + throw new Error(`${dir} is not a private directory`); + } + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (uid !== null && listed.uid !== uid) { + throw new Error(`${dir} is owned by uid ${listed.uid}, expected ${uid}`); + } + if (created) chmodSync(dir, 0o700); + const mode = statSync(dir).mode & 0o777; + if (mode !== 0o700) { + throw new Error( + `${dir} mode is 0${mode.toString(8)}, expected 0700. Refusing to change an existing directory.`, + ); + } +} + +function preparePrivateDir(dir) { + let created = false; + try { + lstatSync(dir); + } catch { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + created = true; + } + assertPrivateDir(dir, created); +} + +export function ensureState() { + preparePrivateDir(stateDir); + preparePrivateDir(evidenceDir); +} + +function readRunFile() { + if (!existsSync(runFile)) return { status: 'missing' }; + let raw; + try { + raw = JSON.parse(readFileSync(runFile, 'utf8')); + } catch { + return { status: 'unreadable' }; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { status: 'unreadable' }; + const repoRoot = typeof raw.repoRoot === 'string' ? raw.repoRoot : ''; + if (!repoRoot) return { status: 'legacy' }; + if (repoRoot !== root) return { status: 'foreign', repoRoot }; + return { status: 'ok' }; +} + +function backupRunFile() { + const id = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const dir = path.join(stateDir, 'restored', id); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const dest = path.join(dir, 'run.json'); + copyFileSync(runFile, dest); + return dest; +} + +export function readRun() { + const identity = readRunFile(); + if (identity.status === 'legacy' || identity.status === 'unreadable') return emptyRun(); + try { + const value = JSON.parse(readFileSync(runFile, 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) return emptyRun(); + const pids = value.pids && typeof value.pids === 'object' ? value.pids : {}; + return { + sessions: Array.isArray(value.sessions) ? value.sessions : [], + pids: { + convex: normalizePid(pids.convex), + web: normalizePid(pids.web), + convexSite: normalizePid(pids.convexSite), + }, + startedAt: typeof value.startedAt === 'string' ? value.startedAt : '', + webMode: value.webMode === 'dev' ? 'dev' : 'start', + baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : baseUrl, + dirtyBefore: Array.isArray(value.dirtyBefore) + ? value.dirtyBefore.filter((item) => typeof item === 'string') + : undefined, + repoRoot: typeof value.repoRoot === 'string' ? value.repoRoot : '', + }; + } catch { + return emptyRun(); + } +} + +function emptyRun() { + return { + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + startedAt: '', + webMode: 'start', + baseUrl, + repoRoot: '', + }; +} + +function normalizePid(value) { + if (!value || typeof value !== 'object') return null; + const pid = value.pid; + if (!Number.isInteger(pid) || pid <= 1) return null; + const startTime = typeof value.startTime === 'string' ? value.startTime : ''; + const cmdline = typeof value.cmdline === 'string' ? value.cmdline : ''; + if (!startTime && !cmdline) return null; + return { pid, startTime, cmdline }; +} + +export function writeRun(run) { + ensureState(); + const identity = readRunFile(); + if (identity.status === 'legacy' || identity.status === 'unreadable') { + const dest = backupRunFile(); + console.warn( + `warning: run state has no checkout identity. Backed up the run state to ${dest}.`, + ); + } + const next = { ...run, repoRoot: typeof run.repoRoot === 'string' && run.repoRoot ? run.repoRoot : root }; + writeFileSync(runFile, `${JSON.stringify(next, null, 2)}\n`); +} + +export function shQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +export function tmux(args, options = {}) { + const prefix = tmuxConfig ? ['-f', tmuxConfig] : []; + return spawnSync('tmux', [...prefix, ...args], { + encoding: 'utf8', + ...options, + }); +} + +export function sessionAlive(name) { + if (!ownedSessions.has(name)) return false; + const result = tmux(['has-session', '-t', name], { stdio: 'ignore' }); + return result.status === 0; +} + +export function startSession(name, script) { + if (!ownedSessions.has(name)) { + throw new Error(`refusing to start unknown session ${name}`); + } + const result = tmux( + ['new-session', '-d', '-s', name, '-c', root, '--', 'bash', '-c', script], + { stdio: 'inherit' }, + ); + if (result.status !== 0) { + throw new Error(`tmux new-session ${name} failed`); + } +} + +export function portOpen(port, host = '127.0.0.1') { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + const done = (open) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(open); + }; + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + socket.setTimeout(500, () => done(false)); + }); +} + +function parseProcNet(file, port) { + let text = ''; + try { + text = readFileSync(file, 'utf8'); + } catch { + return []; + } + const hex = port.toString(16).toUpperCase().padStart(4, '0'); + const inodes = []; + for (const line of text.split('\n').slice(1)) { + const cols = line.trim().split(/\s+/); + const local = cols[1]; + const inode = cols[9]; + if (!local || !inode) continue; + if (local.endsWith(`:${hex}`)) inodes.push(inode); + } + return inodes; +} + +function pidForInode(inode) { + let pids = []; + try { + pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name)); + } catch { + return null; + } + const needle = `socket:[${inode}]`; + for (const pid of pids) { + let fds = []; + try { + fds = readdirSync(`/proc/${pid}/fd`); + } catch { + continue; + } + for (const fd of fds) { + try { + if (readlinkSync(`/proc/${pid}/fd/${fd}`) === needle) return Number(pid); + } catch { + continue; + } + } + } + return null; +} + +export function portOwner(port) { + const inodes = [ + ...parseProcNet('/proc/net/tcp', port), + ...parseProcNet('/proc/net/tcp6', port), + ]; + const pids = []; + for (const inode of inodes) { + const pid = pidForInode(inode); + if (pid && !pids.includes(pid)) pids.push(pid); + } + if (pids.length === 0) return 'none'; + return pids + .map((pid) => { + let cmd = ''; + try { + cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); + } catch { + cmd = ''; + } + return cmd ? `${pid} ${cmd}` : String(pid); + }) + .join('; '); +} + +export function listenerPids(port) { + const inodes = [ + ...parseProcNet('/proc/net/tcp', port), + ...parseProcNet('/proc/net/tcp6', port), + ]; + const pids = []; + for (const inode of inodes) { + const pid = pidForInode(inode); + if (pid && !pids.includes(pid)) pids.push(pid); + } + return pids; +} + +export async function waitFor(label, predicate, timeoutMs, intervalMs = 1000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`timed out waiting for ${label}`); +} + +export function runLogged(command, args, logName, options = {}) { + ensureState(); + const logPath = path.join(evidenceDir, logName); + const result = spawnSync(command, args, { + cwd: root, + encoding: 'utf8', + env: { ...process.env, ...options.env }, + timeout: options.timeoutMs, + }); + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? ''; + const header = `$ ${command} ${args.join(' ')}\nexit ${result.status ?? 'null'}\n`; + writeFileSync(logPath, `${header}\n${stdout}\n${stderr}`); + return { status: result.status ?? 1, stdout, stderr, logPath, error: result.error }; +} + +export function nodeMajor() { + return Number(process.versions.node.split('.')[0] ?? 0); +} + +export function noteSession(session) { + if (!ownedSessions.has(session)) { + throw new Error(`refusing to record unknown session ${session}`); + } + const run = readRun(); + if (!run.sessions.includes(session)) run.sessions.push(session); + run.baseUrl = baseUrl; + run.webMode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; + if (!run.startedAt) run.startedAt = new Date().toISOString(); + run.repoRoot = root; + writeRun(run); +} + +export function processIdentity(pid) { + if (!Number.isInteger(pid) || pid <= 1) return null; + let cmdline = ''; + try { + cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ').trim(); + } catch { + return null; + } + let startTime = ''; + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const end = stat.lastIndexOf(')'); + if (end !== -1) { + const fields = stat.slice(end + 2).trim().split(/\s+/); + startTime = fields[19] ?? ''; + } + } catch { + startTime = ''; + } + if (!cmdline && !startTime) return null; + return { pid, startTime, cmdline }; +} + +export function sameProcess(saved, pid) { + if (!saved || saved.pid !== pid) return false; + const live = processIdentity(pid); + if (!live) return false; + if (saved.startTime && live.startTime) return saved.startTime === live.startTime; + if (saved.cmdline && live.cmdline) return saved.cmdline === live.cmdline; + return false; +} + +function childPids(pid) { + const file = `/proc/${pid}/task/${pid}/children`; + if (!existsSync(file)) return []; + return readFileSync(file, 'utf8') + .trim() + .split(/\s+/) + .map((part) => Number(part)) + .filter((child) => Number.isInteger(child) && child > 1); +} + +function signalTree(pid, signal) { + for (const child of childPids(pid)) signalTree(child, signal); + try { + process.kill(pid, signal); + } catch { + return; + } +} + +function sleepMs(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function processState(pid) { + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const end = stat.lastIndexOf(')'); + if (end === -1) return ''; + return stat.slice(end + 2).trim().split(/\s+/)[0] ?? ''; + } catch { + return ''; + } +} + +function deadProcess(pid) { + return !processIdentity(pid) || processState(pid) === 'Z'; +} + +export function stopRecordedPid(saved, graceMs = 2000) { + if (!saved || !Number.isInteger(saved.pid) || saved.pid <= 1) return 'skip'; + if (deadProcess(saved.pid)) return 'exited'; + if (!sameProcess(saved, saved.pid)) return 'reused'; + signalTree(saved.pid, 'SIGTERM'); + const deadline = Date.now() + graceMs; + while (Date.now() < deadline) { + if (deadProcess(saved.pid)) return 'term'; + sleepMs(50); + } + if (deadProcess(saved.pid)) return 'term'; + if (!sameProcess(saved, saved.pid)) return 'reused-before-kill'; + signalTree(saved.pid, 'SIGKILL'); + return 'kill'; +} + +export function rememberListener(pidKey, port) { + const pid = listenerPids(port)[0]; + if (!pid) return; + const identity = processIdentity(pid); + if (!identity || (!identity.startTime && !identity.cmdline)) return; + const run = readRun(); + run.pids[pidKey] = identity; + writeRun(run); +} + +function sha256Bytes(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function gitBlob(spec) { + const result = spawnSync('git', ['rev-parse', '-q', '--verify', spec], { + cwd: root, + encoding: 'utf8', + }); + if (result.status !== 0) return null; + const value = (result.stdout ?? '').trim(); + return value || null; +} + +function stagedRecord(rel) { + const indexHash = gitBlob(`:${rel}`); + const headHash = gitBlob(`HEAD:${rel}`); + return { indexHash, headHash, staged: indexHash !== headHash }; +} + +function describeWorktree(rel) { + const abs = path.join(root, rel); + let listed; + try { + listed = lstatSync(abs); + } catch { + return { kind: 'absent', hash: null }; + } + if (listed.isSymbolicLink()) { + const target = readlinkSync(abs); + return { kind: 'symlink', hash: sha256Bytes(target), target }; + } + if (!listed.isFile()) return { kind: 'other', hash: null }; + return { + kind: 'file', + hash: sha256Bytes(readFileSync(abs)), + mode: listed.mode & 0o777, + }; +} + +function walkFiles(dir, prefix) { + let names = []; + try { + names = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const rels = []; + for (const name of names) { + const rel = `${prefix}/${name.name}`; + const abs = path.join(dir, name.name); + if (name.isDirectory()) rels.push(...walkFiles(abs, rel)); + else if (name.isFile() || name.isSymbolicLink()) rels.push(rel); + } + return rels; +} + +function watchedRels() { + const rels = new Set(namedWatched); + const skills = path.join(root, '.agents/skills'); + let skillNames = []; + try { + skillNames = readdirSync(skills, { withFileTypes: true }); + } catch { + skillNames = []; + } + for (const name of skillNames) { + if (!name.isDirectory() || !name.name.startsWith('convex')) continue; + for (const rel of walkFiles(path.join(skills, name.name), `.agents/skills/${name.name}`)) { + rels.add(rel); + } + } + for (const rel of walkFiles(path.join(root, 'convex/_generated/ai'), 'convex/_generated/ai')) { + rels.add(rel); + } + return [...rels]; +} + +function snapshotRoot() { + return path.join(stateDir, snapshotDirName); +} + +function manifestPath() { + return path.join(snapshotRoot(), 'manifest.json'); +} + +function writesPath() { + return path.join(snapshotRoot(), 'writes.json'); +} + +function contentPath(rel) { + return path.join(snapshotRoot(), 'files', rel); +} + +function readJson(file, fallback) { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return fallback; + } +} + +function sameDescribed(left, right) { + return left.kind === right.kind && left.hash === right.hash; +} + +function safeRel(rel) { + if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)) return false; + if (rel.includes('\\') || rel.split('/').includes('..')) return false; + return true; +} + +function readBackupId() { + const idFile = path.join(snapshotRoot(), 'backup-id'); + try { + const raw = readFileSync(idFile, 'utf8').trim(); + if (/^[\w.-]+$/.test(raw) && raw !== 'unknown') return raw; + } catch { + return null; + } + return null; +} + +let restoreBackupDir = null; + +function beginRestoreBackup() { + const id = readBackupId() ?? `restore-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const dir = path.join(stateDir, 'restored', id); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + restoreBackupDir = dir; + return dir; +} + +function endRestoreBackup() { + restoreBackupDir = null; +} + +function backupRunDir() { + if (!restoreBackupDir) beginRestoreBackup(); + return restoreBackupDir; +} + +function backupRel(rel) { + const abs = path.join(root, rel); + let listed; + try { + listed = lstatSync(abs); + } catch { + return null; + } + const dest = path.join(backupRunDir(), rel); + mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); + rmSync(dest, { recursive: true, force: true }); + if (listed.isSymbolicLink()) { + symlinkSync(readlinkSync(abs), dest); + return dest; + } + if (listed.isDirectory()) return null; + if (!listed.isFile()) return null; + copyFileSync(abs, dest); + return dest; +} + +function absentRecord() { + return { + kind: 'absent', + hash: null, + indexHash: null, + headHash: null, + staged: false, + }; +} + +function trackedInGit(rel) { + const result = spawnSync('git', ['ls-files', '--', rel], { + cwd: root, + encoding: 'utf8', + }); + return (result.stdout ?? '').trim().length > 0; +} + +function pruneEmptyConvexSkillDirs(rel) { + let parent = path.dirname(rel); + while (parent && parent !== '.' && parent.startsWith('.agents/skills/convex')) { + if (trackedInGit(parent)) break; + const absParent = path.join(root, parent); + let names = []; + try { + names = readdirSync(absParent); + } catch { + break; + } + if (names.length > 0) break; + rmdirSync(absParent); + parent = path.dirname(parent); + } +} + +function placeSnapshot(rel, snap) { + const abs = path.join(root, rel); + let listed = null; + try { + listed = lstatSync(abs); + } catch { + listed = null; + } + if (listed?.isDirectory()) return false; + if (snap.kind === 'absent') { + if (!listed) return true; + rmSync(abs, { force: true }); + pruneEmptyConvexSkillDirs(rel); + return true; + } + if (snap.kind === 'symlink') { + if (typeof snap.target !== 'string') return false; + if (listed) rmSync(abs, { force: true }); + mkdirSync(path.dirname(abs), { recursive: true }); + symlinkSync(snap.target, abs); + return true; + } + if (snap.kind !== 'file') return false; + const src = contentPath(rel); + if (!existsSync(src)) return false; + if (listed?.isSymbolicLink()) rmSync(abs, { force: true }); + mkdirSync(path.dirname(abs), { recursive: true }); + copyFileSync(src, abs); + if (typeof snap.mode === 'number') chmodSync(abs, snap.mode); + return true; +} + +export function snapshotWatchedFiles() { + ensureState(); + const staging = path.join(stateDir, `${snapshotDirName}.next`); + rmSync(staging, { recursive: true, force: true }); + mkdirSync(path.join(staging, 'files'), { recursive: true, mode: 0o700 }); + const files = {}; + for (const rel of watchedRels()) { + if (!safeRel(rel)) continue; + const described = describeWorktree(rel); + if (described.kind === 'file') { + const bytes = readFileSync(path.join(root, rel)); + described.hash = sha256Bytes(bytes); + const dest = path.join(staging, 'files', rel); + mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); + writeFileSync(dest, bytes); + } + files[rel] = { ...described, ...stagedRecord(rel) }; + } + const manifest = { repoRoot: root, files }; + writeFileSync(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + writeFileSync(path.join(staging, 'writes.json'), '{}\n'); + const backupId = new Date().toISOString().replace(/[:.]/g, '-'); + writeFileSync(path.join(staging, 'backup-id'), `${backupId}\n`); + const previous = path.join(stateDir, `${snapshotDirName}.prev`); + rmSync(previous, { recursive: true, force: true }); + if (existsSync(snapshotRoot())) renameSync(snapshotRoot(), previous); + renameSync(staging, snapshotRoot()); + rmSync(previous, { recursive: true, force: true }); + tookSnapshot = true; + stepBaseline = null; +} + +function loadManifest() { + if (!existsSync(manifestPath())) return { status: 'missing' }; + let raw; + try { + raw = JSON.parse(readFileSync(manifestPath(), 'utf8')); + } catch { + return { status: 'unreadable' }; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { status: 'unreadable' }; + if (typeof raw.repoRoot !== 'string' || !raw.files || typeof raw.files !== 'object' || Array.isArray(raw.files)) { + return { status: 'legacy' }; + } + if (raw.repoRoot !== root) return { status: 'foreign', repoRoot: raw.repoRoot }; + return { status: 'ok', raw }; +} + +function readManifest() { + const loaded = loadManifest(); + if (loaded.status !== 'ok') return null; + return loaded.raw; +} + +function clearSnapshotHint() { + return `Remove ${snapshotRoot()} or move it aside, then launch again.`; +} + +export function assertSnapshotCheckout() { + const loaded = loadManifest(); + if (loaded.status === 'missing' || loaded.status === 'ok') return; + if (loaded.status === 'foreign') { + throw new Error( + `snapshot repo root is ${loaded.repoRoot}, this checkout is ${root}. Refusing to launch. ${clearSnapshotHint()}`, + ); + } + if (loaded.status === 'legacy') { + throw new Error(`snapshot manifest has no checkout identity. Refusing to launch. ${clearSnapshotHint()}`); + } + throw new Error(`snapshot manifest is unreadable. Refusing to launch. ${clearSnapshotHint()}`); +} + +function backupRejectedManifest() { + const id = `manifest-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const dir = path.join(stateDir, 'restored', id); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const dest = path.join(dir, 'manifest.json'); + copyFileSync(manifestPath(), dest); + return dest; +} + +export function beginCleanup() { + const loaded = loadManifest(); + const stopOwn = runBelongsToCheckout(); + if (loaded.status === 'missing' || loaded.status === 'ok') { + return { restore: loaded.status === 'ok', stopOwn, backup: null }; + } + const dest = backupRejectedManifest(); + const why = + loaded.status === 'foreign' + ? `snapshot repo root is ${loaded.repoRoot}, this checkout is ${root}` + : loaded.status === 'legacy' + ? 'snapshot manifest has no checkout identity' + : 'snapshot manifest is unreadable'; + console.warn(`warning: ${why}. Leaving files in place. Backed up the manifest to ${dest}.`); + return { restore: false, stopOwn, backup: dest }; +} + +export function runBelongsToCheckout() { + const identity = readRunFile(); + if (identity.status === 'missing' || identity.status === 'ok') return true; + if (identity.status === 'foreign') { + console.warn( + `warning: run state is for ${identity.repoRoot}, this checkout is ${root}. Not stopping those processes.`, + ); + return false; + } + const dest = backupRunFile(); + const why = + identity.status === 'legacy' ? 'run state has no checkout identity' : 'run state is unreadable'; + console.warn(`warning: ${why}. Not stopping those processes. Backed up the run state to ${dest}.`); + return false; +} + +function isNewConvexSkill(rel, snap) { + return rel.startsWith('.agents/skills/convex') && (!snap || snap.kind === 'absent'); +} + +function toolOwns(tool, rel) { + if (tool === 'convex') { + return ( + rel === 'skills-lock.json' || + rel === '.cursor/rules/convex_rules.mdc' || + rel.startsWith('.agents/skills/convex') || + rel.startsWith('convex/_generated/ai/') + ); + } + if (tool === 'next') { + return rel === 'apps/web/AGENTS.md' || rel === 'apps/web/next-env.d.ts'; + } + return false; +} + +function readWriteMap() { + const writes = readJson(writesPath(), {}); + if (!writes || typeof writes !== 'object' || Array.isArray(writes)) return {}; + return { ...writes }; +} + +function snapDescribed(files, rel) { + const snap = files[rel]; + return snap ? { kind: snap.kind, hash: snap.hash ?? null } : { kind: 'absent', hash: null }; +} + +function writeRecord(current, tool) { + return { kind: current.kind, hash: current.hash, tool }; +} + +let stepBaseline = null; + +function describedMap() { + const manifest = readManifest(); + if (!manifest) return null; + const described = {}; + const rels = new Set([...Object.keys(manifest.files), ...watchedRels()]); + for (const rel of rels) { + if (!safeRel(rel)) continue; + const current = describeWorktree(rel); + described[rel] = { kind: current.kind, hash: current.hash }; + } + return { files: manifest.files, described }; +} + +export function markStepBaseline() { + const mapped = describedMap(); + stepBaseline = mapped ? mapped.described : null; +} + +export function captureToolWrites(tool) { + if (tool !== 'convex' && tool !== 'next') { + throw new Error(`unknown verify tool ${tool}`); + } + const before = stepBaseline; + const mapped = describedMap(); + if (!before || !mapped) return; + const { files, described } = mapped; + const writeMap = readWriteMap(); + for (const rel of Object.keys(described)) { + if (handMaintained.has(rel) || !toolOwns(tool, rel)) continue; + const current = described[rel]; + const priorStep = before[rel] ?? { kind: 'absent', hash: null }; + if (!current || sameDescribed(current, priorStep)) continue; + if (sameDescribed(current, snapDescribed(files, rel))) { + delete writeMap[rel]; + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); + continue; + } + if (isNewConvexSkill(rel, files[rel])) console.warn(`warning: new convex skill file ${rel}`); + writeMap[rel] = writeRecord(current, tool); + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); + } + stepBaseline = described; +} + +function observedPath() { + return path.join(snapshotRoot(), 'observed.json'); +} + +export function markObserved() { + if (!tookSnapshot) return; + const manifest = readManifest(); + if (!manifest) return; + const observed = {}; + const rels = new Set([...Object.keys(manifest.files), ...watchedRels()]); + for (const rel of rels) { + if (!safeRel(rel)) continue; + const current = describeWorktree(rel); + observed[rel] = { kind: current.kind, hash: current.hash }; + } + writeFileSync(observedPath(), `${JSON.stringify(observed, null, 2)}\n`); +} + +export function finishLaunchSnapshot() { + markObserved(); + warnUnclaimedChanges(); +} + +export function noteWritesSinceObserved() { + const manifest = readManifest(); + if (!manifest) return; + const observed = readJson(observedPath(), null); + if (!observed || typeof observed !== 'object' || Array.isArray(observed)) return; + const files = manifest.files; + const writeMap = readWriteMap(); + const rels = new Set([...Object.keys(files), ...watchedRels(), ...Object.keys(observed)]); + for (const rel of rels) { + if (!safeRel(rel) || handMaintained.has(rel)) continue; + const tool = toolOwns('convex', rel) ? 'convex' : toolOwns('next', rel) ? 'next' : null; + if (!tool) continue; + const current = describeWorktree(rel); + const seen = observed[rel]; + const seenDescribed = + seen && typeof seen === 'object' ? { kind: seen.kind, hash: seen.hash ?? null } : { kind: 'absent', hash: null }; + if (sameDescribed(current, seenDescribed)) continue; + const prior = writeMap[rel]; + const priorDescribed = + prior && typeof prior === 'object' ? { kind: prior.kind, hash: prior.hash ?? null } : null; + if (priorDescribed && !sameDescribed(current, priorDescribed)) continue; + if (isNewConvexSkill(rel, files[rel])) console.warn(`warning: new convex skill file ${rel}`); + writeMap[rel] = writeRecord(current, tool); + } + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); +} + +export function warnUnclaimedChanges() { + const manifest = readManifest(); + if (!manifest) return; + const files = manifest.files; + const writeMap = readWriteMap(); + const rels = new Set([...Object.keys(files), ...watchedRels()]); + for (const rel of rels) { + if (!safeRel(rel)) continue; + const current = describeWorktree(rel); + if (sameDescribed(current, snapDescribed(files, rel))) continue; + const write = writeMap[rel]; + if ( + write && + typeof write === 'object' && + sameDescribed(current, { kind: write.kind, hash: write.hash ?? null }) + ) { + continue; + } + console.warn(`warning: ${rel} changed during the run and was left in place`); + } +} + +function priorRunUnfinished() { + if (existsSync(observedPath())) return false; + return loadManifest().status === 'ok'; +} + +export function restoreUnfinishedRun() { + if (!priorRunUnfinished()) return { restored: [] }; + console.warn('warning: previous launch did not finish. Restoring tool-owned files from its snapshot.'); + try { + return restoreUnfinishedInner(); + } finally { + endRestoreBackup(); + } +} + +function restoreUnfinishedInner() { + const manifest = readManifest(); + if (!manifest) return { restored: [] }; + const restored = []; + for (const rel of watchedRels()) { + if (!safeRel(rel) || handMaintained.has(rel)) continue; + if (!toolOwns('convex', rel) && !toolOwns('next', rel)) continue; + const snap = manifest.files[rel] ?? absentRecord(); + const current = describeWorktree(rel); + if (sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) continue; + const liveStage = stagedRecord(rel); + const snapIndex = typeof snap.indexHash === 'string' ? snap.indexHash : null; + const snapStaged = snap.staged === true; + if (liveStage.indexHash !== snapIndex || liveStage.staged !== snapStaged) { + console.warn(`warning: ${rel} staged state changed during the run and was left in place`); + continue; + } + const dest = backupRel(rel); + if (dest) console.log(`backed up ${rel} to ${dest}`); + if (!placeSnapshot(rel, snap)) { + console.warn(`warning: ${rel} changed during the run and was left in place`); + continue; + } + restored.push(rel); + console.log(`restored ${rel} to launch snapshot`); + } + return { restored }; +} + +export function beginLaunch(doctorStatus) { + if (doctorStatus === 'READY') return 'reuse'; + restoreUnfinishedRun(); + restoreOwnedWrites(); + snapshotWatchedFiles(); + return 'snapshot'; +} + +export function restoreOwnedWrites() { + try { + return restoreOwnedWritesInner(); + } finally { + endRestoreBackup(); + } +} + +function restoreOwnedWritesInner() { + const manifest = readManifest(); + if (!manifest) return { restored: [], warned: [], backups: [] }; + const files = manifest.files; + const writes = readJson(writesPath(), {}); + const writeMap = writes && typeof writes === 'object' && !Array.isArray(writes) ? writes : {}; + const rels = new Set([...Object.keys(files), ...Object.keys(writeMap)]); + const restored = []; + const warned = []; + const backups = []; + for (const rel of rels) { + if (!safeRel(rel)) continue; + const snap = files[rel] ?? absentRecord(); + const current = describeWorktree(rel); + if (handMaintained.has(rel)) { + if (!sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) { + warned.push(rel); + console.warn(`warning: ${rel} changed during the run and was left in place`); + } + continue; + } + const liveStage = stagedRecord(rel); + const snapIndex = typeof snap.indexHash === 'string' ? snap.indexHash : null; + const snapStaged = snap.staged === true; + if (liveStage.indexHash !== snapIndex || liveStage.staged !== snapStaged) { + warned.push(rel); + console.warn(`warning: ${rel} staged state changed during the run and was left in place`); + continue; + } + if (sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) continue; + const write = writeMap[rel]; + const owned = + write && + typeof write === 'object' && + sameDescribed(current, { kind: write.kind, hash: write.hash ?? null }); + if (!owned) { + warned.push(rel); + console.warn(`warning: ${rel} changed during the run and was left in place`); + continue; + } + const dest = backupRel(rel); + if (dest) { + backups.push({ rel, dest }); + console.log(`backed up ${rel} to ${dest}`); + } + if (!placeSnapshot(rel, snap)) { + warned.push(rel); + console.warn(`warning: ${rel} changed during the run and was left in place`); + continue; + } + restored.push(rel); + console.log(`restored ${rel} to launch snapshot`); + } + return { restored, warned, backups }; +} diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs new file mode 100644 index 00000000..7fa8bf8d --- /dev/null +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -0,0 +1,539 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const realRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const repo = `/tmp/synac-verify-repo-${process.pid}`; +const state = `/tmp/synac-verify-test-${process.pid}`; +const scratchDir = path.join(repo, '.agents/skills/convex-backup-test'); + +function git(args) { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +} + +rmSync(repo, { recursive: true, force: true }); +rmSync(state, { recursive: true, force: true }); +mkdirSync(path.join(repo, 'apps/web'), { recursive: true }); +mkdirSync(path.join(repo, '.agents/skills'), { recursive: true }); +mkdirSync(path.join(repo, 'convex/_generated/ai'), { recursive: true }); +writeFileSync(path.join(repo, 'AGENTS.md'), 'agents\n'); +writeFileSync(path.join(repo, 'CLAUDE.md'), 'claude\n'); +writeFileSync(path.join(repo, 'apps/web/AGENTS.md'), 'web agents\n'); +writeFileSync(path.join(repo, 'apps/web/CLAUDE.md'), '@AGENTS.md\n'); +writeFileSync(path.join(repo, 'skills-lock.json'), '{}\n'); +writeFileSync(path.join(repo, 'convex/_generated/ai/guidelines.md'), 'guide\n'); +git(['init']); +git(['config', 'user.email', 'verify@example.com']); +git(['config', 'user.name', 'verify']); +git(['add', '.']); +git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'init']); + +process.env.SYNAC_VERIFY_ROOT = repo; +process.env.SYNAC_VERIFY_STATE_DIR = state; +process.env.VERIFY_EVIDENCE_DIR = path.join(state, 'evidence'); + +// runtime.mjs binds the checkout and chdir at import, so this import stays +// after SYNAC_VERIFY_ROOT points at the temp repo. +const { + root, + ensureState, + snapshotWatchedFiles, + captureToolWrites, + markStepBaseline, + markObserved, + noteWritesSinceObserved, + restoreOwnedWrites, + assertSnapshotCheckout, + beginCleanup, + beginLaunch, + runBelongsToCheckout, + writeRun, + readRun, + runFile, + stopRecordedPid, + processIdentity, +} = await import('./runtime.mjs'); + +after(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(state, { recursive: true, force: true }); +}); + +const linkRel = '.agents/skills/convex-backup-test/link.md'; +const linkAbs = path.join(root, linkRel); + +function backupId() { + return readFileSync(path.join(state, 'snapshot/backup-id'), 'utf8').trim(); +} + +function exists(file) { + try { + lstatSync(file); + return true; + } catch { + return false; + } +} + +test('each snapshot stores backups in its own directory, including symlinks', () => { + try { + snapshotWatchedFiles(); + const firstId = backupId(); + markStepBaseline(); + mkdirSync(scratchDir, { recursive: true }); + symlinkSync('target.txt', linkAbs); + captureToolWrites('convex'); + const first = restoreOwnedWrites(); + const firstBackup = path.join(state, 'restored', firstId, linkRel); + assert.equal(first.restored.includes(linkRel), true); + assert.equal(lstatSync(firstBackup).isSymbolicLink(), true); + assert.equal(readlinkSync(firstBackup), 'target.txt'); + assert.equal(exists(linkAbs), false); + + snapshotWatchedFiles(); + const secondId = backupId(); + assert.notEqual(secondId, firstId); + markStepBaseline(); + mkdirSync(scratchDir, { recursive: true }); + symlinkSync('other.txt', linkAbs); + captureToolWrites('convex'); + restoreOwnedWrites(); + const secondBackup = path.join(state, 'restored', secondId, linkRel); + assert.equal(lstatSync(secondBackup).isSymbolicLink(), true); + assert.equal(readlinkSync(secondBackup), 'other.txt'); + assert.equal(lstatSync(firstBackup).isSymbolicLink(), true); + assert.equal(readlinkSync(firstBackup), 'target.txt'); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test('launch refuses a foreign or legacy manifest and says how to clear it', () => { + snapshotWatchedFiles(); + const manifestPath = path.join(state, 'snapshot/manifest.json'); + const original = readFileSync(manifestPath, 'utf8'); + const manifest = JSON.parse(original); + assert.equal(manifest.repoRoot, root); + assert.equal(manifest.files['apps/web/CLAUDE.md'].kind, 'file'); + assert.equal(manifest.files['.cursor/rules/convex_rules.mdc'].kind, 'absent'); + const launch = path.join(realRoot, '.agents/skills/verify-synac/scripts/launch.mjs'); + const env = { + ...process.env, + SYNAC_VERIFY_ROOT: repo, + SYNAC_VERIFY_STATE_DIR: state, + VERIFY_EVIDENCE_DIR: path.join(state, 'evidence'), + }; + try { + writeFileSync(manifestPath, `${JSON.stringify({ 'AGENTS.md': { kind: 'file', hash: 'abc' } })}\n`); + assert.throws(() => assertSnapshotCheckout(), /no checkout identity/); + assert.throws(() => assertSnapshotCheckout(), /Remove .*snapshot/); + const legacy = spawnSync(process.execPath, [launch], { env, encoding: 'utf8' }); + assert.notEqual(legacy.status, 0); + assert.match(legacy.stderr, /Refusing to launch/); + assert.match(legacy.stderr, /Remove .*snapshot/); + const legacyGate = beginCleanup(); + assert.equal(legacyGate.restore, false); + assert.equal(legacyGate.stopOwn, true); + assert.equal(readFileSync(legacyGate.backup, 'utf8').includes('AGENTS.md'), true); + + manifest.repoRoot = '/tmp/other-checkout'; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + assert.throws(() => assertSnapshotCheckout(), /Refusing to launch/); + const foreign = spawnSync(process.execPath, [launch], { env, encoding: 'utf8' }); + assert.notEqual(foreign.status, 0); + assert.match(foreign.stderr, /\/tmp\/other-checkout/); + assert.match(foreign.stderr, /Remove .*snapshot/); + assert.equal(readFileSync(manifestPath, 'utf8').includes('/tmp/other-checkout'), true); + } finally { + writeFileSync(manifestPath, original); + } + assertSnapshotCheckout(); +}); + +test('cleanup stops this checkout and only skips restore for a foreign manifest', async () => { + snapshotWatchedFiles(); + const manifestPath = path.join(state, 'snapshot/manifest.json'); + const original = readFileSync(manifestPath, 'utf8'); + const lock = path.join(root, 'skills-lock.json'); + const before = readFileSync(lock, 'utf8'); + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + try { + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + const saved = processIdentity(child.pid); + writeFileSync(lock, '{"keep":true}\n'); + const manifest = JSON.parse(original); + manifest.repoRoot = '/tmp/other-checkout'; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + writeRun({ + ...readRun(), + repoRoot: root, + sessions: [], + pids: { convex: saved, web: null, convexSite: null }, + }); + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { + env: { + ...process.env, + SYNAC_VERIFY_ROOT: repo, + SYNAC_VERIFY_STATE_DIR: state, + VERIFY_EVIDENCE_DIR: path.join(state, 'evidence'), + }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, /Leaving files in place/); + assert.match(output, /Backed up the manifest to /); + assert.match(output, new RegExp(`signaled pids ${child.pid}`)); + assert.equal(readFileSync(lock, 'utf8'), '{"keep":true}\n'); + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', () => resolve()); + }); + assert.equal(processIdentity(child.pid), null); + const backedUp = output.match(/Backed up the manifest to (\S+)\./); + assert.ok(backedUp); + assert.equal(readFileSync(backedUp[1], 'utf8').includes('/tmp/other-checkout'), true); + assert.equal(readFileSync(manifestPath, 'utf8').includes('/tmp/other-checkout'), true); + } finally { + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + writeFileSync(manifestPath, original); + writeFileSync(lock, before); + } +}); + +test('a hand edit survives and a tool write is restored', () => { + const agents = path.join(root, 'AGENTS.md'); + const agentsOriginal = readFileSync(agents); + const lock = path.join(root, 'skills-lock.json'); + const lockOriginal = readFileSync(lock); + const claude = path.join(root, 'apps/web/CLAUDE.md'); + const claudeOriginal = readFileSync(claude); + try { + snapshotWatchedFiles(); + writeFileSync(agents, Buffer.concat([agentsOriginal, Buffer.from('verify-human\n')])); + writeFileSync(lock, '{"human":true}\n'); + writeFileSync(claude, '@AGENTS.md\nverify-human\n'); + markStepBaseline(); + captureToolWrites('next'); + markObserved(); + noteWritesSinceObserved(); + const left = restoreOwnedWrites(); + assert.equal(readFileSync(agents, 'utf8').includes('verify-human'), true); + assert.equal(readFileSync(lock, 'utf8'), '{"human":true}\n'); + assert.equal(readFileSync(claude, 'utf8').includes('verify-human'), true); + assert.equal(left.restored.includes('AGENTS.md'), false); + assert.equal(left.restored.includes('skills-lock.json'), false); + assert.equal(left.restored.includes('apps/web/CLAUDE.md'), false); + assert.equal(left.warned.includes('AGENTS.md'), true); + assert.equal(left.warned.includes('skills-lock.json'), true); + + writeFileSync(agents, agentsOriginal); + writeFileSync(lock, lockOriginal); + writeFileSync(claude, claudeOriginal); + snapshotWatchedFiles(); + markStepBaseline(); + writeFileSync(lock, '{"tool":true}\n'); + captureToolWrites('convex'); + const restored = restoreOwnedWrites(); + assert.equal(readFileSync(lock, 'utf8'), lockOriginal.toString()); + assert.equal(restored.restored.includes('skills-lock.json'), true); + } finally { + writeFileSync(agents, agentsOriginal); + writeFileSync(lock, lockOriginal); + writeFileSync(claude, claudeOriginal); + } +}); + +test('restore keeps the executable bit and a reused stack is not restored', () => { + const scriptRel = '.agents/skills/convex-backup-test/run.sh'; + const scriptAbs = path.join(root, scriptRel); + try { + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(scriptAbs, '#!/bin/sh\necho snapshot\n'); + chmodSync(scriptAbs, 0o755); + snapshotWatchedFiles(); + markStepBaseline(); + writeFileSync(scriptAbs, '#!/bin/sh\necho edited\n'); + chmodSync(scriptAbs, 0o644); + captureToolWrites('convex'); + assert.equal(beginLaunch('READY'), 'reuse'); + assert.equal(readFileSync(scriptAbs, 'utf8'), '#!/bin/sh\necho edited\n'); + assert.equal(beginLaunch('NOT_READY'), 'snapshot'); + assert.equal(readFileSync(scriptAbs, 'utf8'), '#!/bin/sh\necho snapshot\n'); + assert.equal(statSync(scriptAbs).mode & 0o777, 0o755); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test('a convex skill file created after launch is recorded and warned', () => { + const rel = '.agents/skills/convex-backup-test/SKILL.md'; + const abs = path.join(root, rel); + const warnings = []; + const orig = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + snapshotWatchedFiles(); + markObserved(); + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(abs, 'after launch\n'); + noteWritesSinceObserved(); + const writes = JSON.parse(readFileSync(path.join(state, 'snapshot/writes.json'), 'utf8')); + assert.equal(writes[rel].kind, 'file'); + assert.equal(writes[rel].tool, 'convex'); + assert.equal( + warnings.some((line) => line.includes(`new convex skill file ${rel}`)), + true, + ); + restoreOwnedWrites(); + assert.equal(exists(abs), false); + } finally { + console.warn = orig; + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test('a missing backup id does not reuse one directory', () => { + const rel = '.agents/skills/convex-backup-test/extra.md'; + const abs = path.join(root, rel); + try { + snapshotWatchedFiles(); + markStepBaseline(); + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(abs, 'one\n'); + captureToolWrites('convex'); + rmSync(path.join(state, 'snapshot/backup-id')); + const first = restoreOwnedWrites(); + snapshotWatchedFiles(); + markStepBaseline(); + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(abs, 'two\n'); + captureToolWrites('convex'); + rmSync(path.join(state, 'snapshot/backup-id')); + const second = restoreOwnedWrites(); + assert.equal(first.backups.length, 1); + assert.equal(second.backups.length, 1); + const firstDir = path.dirname(first.backups[0].dest); + const secondDir = path.dirname(second.backups[0].dest); + assert.notEqual(firstDir, secondDir); + assert.equal(firstDir.includes(`${path.sep}unknown`), false); + assert.equal(secondDir.includes(`${path.sep}unknown`), false); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test('an existing evidence directory with the wrong mode is refused', () => { + ensureState(); + const evidence = path.join(state, 'evidence'); + chmodSync(evidence, 0o755); + assert.throws(() => ensureState(), /evidence mode is 0755/); + assert.equal(statSync(evidence).mode & 0o777, 0o755); + chmodSync(evidence, 0o700); +}); + +test('an unfinished launch restores tool writes before the next snapshot', () => { + const lock = path.join(root, 'skills-lock.json'); + const original = readFileSync(lock, 'utf8'); + const agents = path.join(root, 'AGENTS.md'); + const agentsOriginal = readFileSync(agents, 'utf8'); + try { + snapshotWatchedFiles(); + assert.equal(readFileSync(path.join(state, 'snapshot/writes.json'), 'utf8').trim(), '{}'); + assert.equal(exists(path.join(state, 'snapshot/observed.json')), false); + writeFileSync(lock, '{"killed":true}\n'); + writeFileSync(agents, `${agentsOriginal}human\n`); + assert.equal(beginLaunch('NOT_READY'), 'snapshot'); + assert.equal(readFileSync(lock, 'utf8'), original); + assert.equal(readFileSync(agents, 'utf8'), `${agentsOriginal}human\n`); + assert.equal(readFileSync(path.join(state, 'snapshot/files/skills-lock.json'), 'utf8'), original); + } finally { + writeFileSync(lock, original); + writeFileSync(agents, agentsOriginal); + } +}); + +test('cleanup does not stop a run recorded for another checkout', () => { + const warnings = []; + const orig = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + writeRun({ + ...readRun(), + repoRoot: '/tmp/other-checkout', + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + }); + assert.equal(runBelongsToCheckout(), false); + } finally { + console.warn = orig; + } + assert.equal(warnings.some((line) => line.includes('Not stopping those processes')), true); + writeRun({ + ...readRun(), + repoRoot: root, + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + }); +}); + +test('a run.json with no repoRoot is not this checkout', async () => { + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep did not spawn')), 2000); + child.once('spawn', () => { + clearTimeout(timer); + resolve(); + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); + const saved = processIdentity(child.pid); + assert.ok(saved); + ensureState(); + writeFileSync( + runFile, + `${JSON.stringify({ sessions: [], pids: { convex: saved, web: null, convexSite: null } })}\n`, + ); + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { + env: { + ...process.env, + SYNAC_VERIFY_ROOT: repo, + SYNAC_VERIFY_STATE_DIR: state, + VERIFY_EVIDENCE_DIR: path.join(state, 'evidence'), + }, + encoding: 'utf8', + timeout: 20_000, + }); + assert.equal(result.status, 0, result.stderr); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, /no checkout identity/); + assert.match(output, /Not stopping those processes/); + assert.match(output, /Backed up the run state to /); + assert.doesNotMatch(output, new RegExp(`\\b${child.pid}\\b`)); + assert.equal(processIdentity(child.pid)?.pid, child.pid); + const backedUp = output.match(/Backed up the run state to (\S+)\./); + assert.ok(backedUp); + assert.equal(readFileSync(backedUp[1], 'utf8').includes('repoRoot'), false); + assert.equal(readFileSync(runFile, 'utf8').includes('repoRoot'), false); + } finally { + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + writeRun({ + ...readRun(), + repoRoot: root, + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + }); + } +}); + +test('SIGKILL waits for the launch start time to still match', async () => { + mkdirSync(state, { recursive: true }); + const ready = path.join(state, 'sigkill-ready'); + rmSync(ready, { force: true }); + const child = spawn( + process.execPath, + [ + '-e', + 'const fs = require("node:fs"); process.on("SIGTERM", () => {}); fs.writeFileSync(process.env.SYNAC_SIGKILL_READY, "ok"); setInterval(() => {}, 1e9);', + ], + { + env: { ...process.env, SYNAC_SIGKILL_READY: ready }, + stdio: ['ignore', 'ignore', 'pipe'], + }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + try { + await new Promise((resolve, reject) => { + let settled = false; + let timer; + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(); + }; + timer = setTimeout(() => finish(new Error(`signal fixture did not become ready\n${stderr}`)), 5000); + const wait = () => { + if (settled) return; + if (exists(ready)) { + finish(); + return; + } + setTimeout(wait, 20); + }; + child.once('error', (error) => finish(error)); + child.once('exit', (code, signal) => { + finish(new Error(`signal fixture exited early code=${code} signal=${signal}\n${stderr}`)); + }); + wait(); + }); + const saved = processIdentity(child.pid); + assert.ok(saved); + assert.notEqual(saved.startTime, ''); + const wrong = { ...saved, startTime: '1' }; + assert.equal(stopRecordedPid(wrong, 200), 'reused'); + assert.equal(processIdentity(child.pid)?.pid, child.pid); + assert.equal(stopRecordedPid(saved, 400), 'kill'); + const ended = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('signal fixture survived SIGKILL')), 2000); + const done = (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }; + if (child.exitCode !== null || child.signalCode !== null) { + done(child.exitCode, child.signalCode); + return; + } + child.once('exit', done); + }); + assert.equal(ended.signal, 'SIGKILL'); + } finally { + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + } +}); + +test('runtime checks use the temp repo and leave the real checkout alone', () => { + rmSync(scratchDir, { recursive: true, force: true }); + assert.equal(root, repo); + assert.equal(exists(path.join(realRoot, '.agents/skills/convex-backup-test')), false); +}); diff --git a/.agents/skills/verify-synac/scripts/runtime.tmux.test.mjs b/.agents/skills/verify-synac/scripts/runtime.tmux.test.mjs new file mode 100644 index 00000000..be813502 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/runtime.tmux.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const realRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const repo = `/tmp/synac-verify-repo-${process.pid}`; +const state = `/tmp/synac-verify-test-${process.pid}`; + +rmSync(repo, { recursive: true, force: true }); +rmSync(state, { recursive: true, force: true }); +mkdirSync(repo, { recursive: true }); + +process.env.SYNAC_VERIFY_ROOT = repo; +process.env.SYNAC_VERIFY_STATE_DIR = state; +process.env.VERIFY_EVIDENCE_DIR = path.join(state, 'evidence'); + +// runtime.mjs binds the checkout and chdir at import, so this import stays +// after SYNAC_VERIFY_ROOT points at the temp repo. +const { root, processIdentity, sessionConvex, sessionsFor, stateDirFor, tmux } = await import( + './runtime.mjs' +); + +after(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(state, { recursive: true, force: true }); +}); + +function writeCheckoutRun(dir, repoRoot, sessions, identity) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + writeFileSync( + path.join(dir, 'run.json'), + `${JSON.stringify( + { + sessions, + pids: { convex: identity, web: null, convexSite: null }, + startedAt: '', + webMode: 'start', + baseUrl: 'http://127.0.0.1:3000', + repoRoot, + }, + null, + 2, + )}\n`, + ); +} + +function waitForSpawn(child, label) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} did not spawn`)), 2000); + child.once('spawn', () => { + clearTimeout(timer); + resolve(); + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +test('cleanup from a second checkout does not stop the first', async () => { + const rootB = `/tmp/synac-verify-repo-b-${process.pid}`; + mkdirSync(rootB, { recursive: true }); + const dirA = stateDirFor(root); + const dirB = stateDirFor(rootB); + const sessionsA = sessionsFor(root); + const sessionsB = sessionsFor(rootB); + assert.notEqual(dirA, dirB); + assert.match(dirA, /^\/tmp\/synac-verify-[0-9a-f]{12}$/); + assert.notEqual(sessionsA.convex, sessionsB.convex); + assert.equal(sessionConvex, sessionsA.convex); + assert.notEqual(sessionConvex, 'synac-verify-convex'); + const started = []; + const sleepA = spawn('sleep', ['60'], { stdio: 'ignore' }); + let sleepB; + try { + await waitForSpawn(sleepA, 'sleep A'); + sleepB = spawn('sleep', ['60'], { stdio: 'ignore' }); + await waitForSpawn(sleepB, 'sleep B'); + const idA = processIdentity(sleepA.pid); + const idB = processIdentity(sleepB.pid); + assert.ok(idA); + assert.ok(idB); + writeCheckoutRun(dirA, root, [sessionsA.convex], idA); + writeCheckoutRun(dirB, rootB, [sessionsB.convex, sessionsA.convex], idB); + for (const name of [sessionsA.convex, sessionsB.convex]) { + tmux(['kill-session', '-t', name], { timeout: 5000 }); + const created = tmux(['new-session', '-d', '-s', name, 'sleep', '60'], { timeout: 5000 }); + assert.equal(created.status, 0, created.stderr); + started.push(name); + } + const env = { ...process.env }; + delete env.SYNAC_VERIFY_STATE_DIR; + delete env.VERIFY_EVIDENCE_DIR; + env.SYNAC_VERIFY_ROOT = rootB; + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { env, encoding: 'utf8', timeout: 20_000 }); + assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, new RegExp(`stopped session ${sessionsB.convex}`)); + assert.doesNotMatch(output, new RegExp(`stopped session ${sessionsA.convex}`)); + assert.doesNotMatch(output, new RegExp(`\\b${sleepA.pid}\\b`)); + assert.equal(tmux(['has-session', '-t', sessionsA.convex]).status, 0); + assert.notEqual(tmux(['has-session', '-t', sessionsB.convex]).status, 0); + assert.equal(processIdentity(sleepA.pid)?.pid, sleepA.pid); + assert.match(output, new RegExp(`\\b${sleepB.pid}\\b`)); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep B was not stopped')), 3000); + const done = () => { + clearTimeout(timer); + resolve(); + }; + if (sleepB.exitCode !== null || sleepB.signalCode !== null) done(); + else sleepB.once('exit', done); + }); + assert.equal(processIdentity(sleepB.pid), null); + } finally { + for (const name of started) tmux(['kill-session', '-t', name], { timeout: 5000 }); + try { + sleepA.kill('SIGKILL'); + } catch { + // already exited + } + try { + sleepB?.kill('SIGKILL'); + } catch { + // already exited + } + rmSync(rootB, { recursive: true, force: true }); + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } +}); diff --git a/.agents/skills/verify-synac/scripts/smoke.mjs b/.agents/skills/verify-synac/scripts/smoke.mjs new file mode 100755 index 00000000..11f22d98 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/smoke.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runDoctor } from './doctor.mjs'; +import { baseUrl, evidenceDir, ensureState, root } from './runtime.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const skillDir = path.resolve(scriptDir, '..'); + +const help = `Usage: node .agents/skills/verify-synac/scripts/smoke.mjs [--drive] + +Runs doctor, then the CLI checks, then Playwright. +--drive walks the feature map. The default is the short smoke path. +`; + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + ensureState(); + const drive = process.argv.includes('--drive'); + const doctor = spawnSync(process.execPath, [path.join(scriptDir, 'doctor.mjs')], { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (doctor.status !== 0) { + console.error('doctor is not READY. Browser and CLI drives were skipped.'); + process.exit(doctor.status ?? 2); + } + + const cliArgs = [path.join(scriptDir, 'cli.mjs')]; + if (!drive) cliArgs.push('--smoke'); + const cli = spawnSync(process.execPath, cliArgs, { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (cli.status !== 0) process.exit(cli.status ?? 1); + + const spec = path.join(scriptDir, drive ? 'drive.spec.ts' : 'smoke.spec.ts'); + const config = path.join(skillDir, 'playwright.config.mjs'); + const playwright = path.join(root, 'apps/e2e/node_modules/@playwright/test/cli.js'); + const result = spawnSync( + process.execPath, + [playwright, 'test', '--config', config, spec], + { + cwd: root, + stdio: 'inherit', + env: { + ...process.env, + E2E_BASE_URL: baseUrl, + VERIFY_EVIDENCE_DIR: evidenceDir, + NODE_PATH: path.join(root, 'apps/e2e/node_modules'), + }, + }, + ); + process.exit(result.status ?? 1); +} + +main(); diff --git a/.agents/skills/verify-synac/scripts/smoke.spec.ts b/.agents/skills/verify-synac/scripts/smoke.spec.ts new file mode 100644 index 00000000..75baf8b8 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/smoke.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +import { capture } from './evidence'; + +test('home search opens an entry', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1, name: 'SynAc' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Recently updated' })).toBeVisible(); + await capture(page, 'smoke-home.png'); + + const search = page.getByRole('textbox', { name: 'Search' }); + await search.fill('soc'); + await search.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=soc/); + await expect(page.getByRole('heading', { level: 1, name: 'Search' })).toBeVisible(); + const results = page.locator('a[href^="/term/"], a[href^="/acronym/"]'); + await expect(results.first()).toBeVisible(); + await capture(page, 'smoke-search.png'); + + const href = await results.first().getAttribute('href'); + expect(href).toBeTruthy(); + await results.first().click(); + await expect(page).toHaveURL(new RegExp(`${href}$`)); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await capture(page, 'smoke-entry.png'); +}); diff --git a/.cursor/skills/verify-synac b/.cursor/skills/verify-synac new file mode 120000 index 00000000..39e1d174 --- /dev/null +++ b/.cursor/skills/verify-synac @@ -0,0 +1 @@ +../../.agents/skills/verify-synac \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65121c41..9c351745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,14 @@ jobs: - name: Test run: pnpm test + # Not part of `pnpm gate`. The session test needs tmux, which the + # default test script does not install or invoke. + - name: Install tmux + run: sudo apt-get update && sudo apt-get install -y tmux + + - name: Verify session isolation + run: pnpm test:verify-tmux + - name: Tagging check run: pnpm tagging:check diff --git a/.github/workflows/verify-smoke.yml b/.github/workflows/verify-smoke.yml new file mode 100644 index 00000000..719b8cd1 --- /dev/null +++ b/.github/workflows/verify-smoke.yml @@ -0,0 +1,76 @@ +# Optional smoke for the verification skill. +# This workflow is not part of the required CI or Quality checks. +# Branch protection should not list it. + +name: Verify smoke + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify-smoke: + name: Doctor and smoke + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.34.4 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install tmux + run: sudo apt-get update && sudo apt-get install -y tmux + + - name: Session isolation test + run: pnpm test:verify-tmux + + - name: Install Playwright chromium + run: pnpm --filter @synac/e2e exec playwright install --with-deps chromium + + - name: Launch + run: pnpm verify:launch + env: + VERIFY_SKIP_BROWSER_INSTALL: "1" + + - name: Doctor + run: pnpm verify:doctor + + - name: Smoke + run: pnpm verify:smoke + + - name: Cleanup + if: always() + run: pnpm verify:cleanup + + - name: Resolve evidence dir + if: always() + run: node --input-type=module -e 'import { evidenceDir } from "./.agents/skills/verify-synac/scripts/runtime.mjs"; import { appendFileSync } from "node:fs"; appendFileSync(process.env.GITHUB_ENV, `VERIFY_EVIDENCE_DIR=${evidenceDir}\n`);' + + - name: Upload evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: verify-smoke-evidence + path: ${{ env.VERIFY_EVIDENCE_DIR }} + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index a4cabecf..c9d284cc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ build/ # Editor and agent scratch .vscode/ .idea/ -.cursor/ +.cursor/* +!.cursor/skills/ .claude/ # Build and test output diff --git a/AGENTS.md b/AGENTS.md index 297b275f..595a424d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,12 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro - Local backend: `CONVEX_AGENT_MODE=anonymous npx convex dev`, then `pnpm --filter @synac/content-tools sync` - Regenerate a bundle: `pnpm ingest -- --source rfc4949` +## Verification + +Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. + +`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. State for each checkout lives in `/tmp/synac-verify-`, where `` is the first 12 hex characters of sha256 of that repo root. Launch and cleanup use tmux sessions `synac-v--convex` and `synac-v--web`, and they signal only the pids recorded in that directory. Cleanup from a second worktree does not stop the first checkout. A `run.json` with no repo root is backed up and its pids are not signaled. If launch is killed before it finishes, the next launch restores tool-owned files from that snapshot before it takes a new one. Each tool claim is written to `writes.json` as soon as it is recorded. Launch refuses when the snapshot names a different checkout, has no checkout identity, or cannot be read, and tells you to remove that checkout's snapshot directory or move it aside. Cleanup still stops processes recorded for this checkout, backs that manifest up, and skips the file restore. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify-/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. + ## Cloud Agent notes - Node must be `24` (see `.node-version`). If the base image ships an older `node` ahead of nvm on `PATH` (e.g. `/exec-daemon/node`), put the nvm Node 24 bin directory first: `export PATH="$NVM_DIR/versions/node/$(nvm version 24)/bin:$PATH"`. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 643577df..0794fa55 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -7,3 +7,5 @@ This version has breaking changes — APIs, conventions, and file structure may This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + +`next dev` rewrites the marked block above. A verify run restores this file to its launch snapshot when the bytes still match that rewrite, and prints a warning when the file changed in any other way. Do not commit a rewrite with feature work. diff --git a/docs/contributing/local-dev.md b/docs/contributing/local-dev.md index fe2d8bd7..2d770055 100644 --- a/docs/contributing/local-dev.md +++ b/docs/contributing/local-dev.md @@ -69,7 +69,9 @@ CI runs the same list, so a green local gate means a green CI. Run the gate before you push, not after CI tells you. `pnpm gate` does not run the end-to-end suite. The `Quality` workflow runs -that. +that. `pnpm gate` also does not need tmux. The verification skill's checkout +session test is `pnpm test:verify-tmux`, and that command needs tmux on +`PATH`. Required CI installs tmux and runs that command after `pnpm test`. ## End-to-end tests diff --git a/package.json b/package.json index cb242e46..80e3de33 100644 --- a/package.json +++ b/package.json @@ -28,14 +28,20 @@ "lint:convex": "eslint convex tests", "typecheck": "corepack pnpm -r typecheck", "typecheck:convex": "tsc -p convex/tsconfig.json --noEmit", - "test": "corepack pnpm -r --workspace-concurrency=1 test && corepack pnpm test:convex", + "test": "corepack pnpm -r --workspace-concurrency=1 test && corepack pnpm test:convex && node --test .agents/skills/verify-synac/scripts/cli.test.mjs .agents/skills/verify-synac/scripts/runtime.test.mjs", + "test:verify-tmux": "node --test .agents/skills/verify-synac/scripts/runtime.tmux.test.mjs", "test:convex": "vitest run -c vitest.convex.config.ts", "tagging:check": "corepack pnpm --dir experiments/tagging/synthetic-reference run check && corepack pnpm --dir experiments/tagging/production-backfill run check", "format": "corepack pnpm -r format", "version:check": "node scripts/checkVersions.mjs", "convex:dev": "convex dev", "convex:validate": "convex dev --once", - "ingest": "corepack pnpm --filter @synac/ingest-tools ingest" + "ingest": "corepack pnpm --filter @synac/ingest-tools ingest", + "verify:launch": "node .agents/skills/verify-synac/scripts/launch.mjs", + "verify:doctor": "node .agents/skills/verify-synac/scripts/doctor.mjs", + "verify:smoke": "node .agents/skills/verify-synac/scripts/smoke.mjs", + "verify:drive": "node .agents/skills/verify-synac/scripts/smoke.mjs --drive", + "verify:cleanup": "node .agents/skills/verify-synac/scripts/cleanup.mjs" }, "devDependencies": { "@edge-runtime/vm": "^5.0.0",