diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 00000000..091ff77d --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,3 @@ +Conductor skills are not re-vendored in this repository. + +Use `.cursor/skills/conductor/SKILL.md` plus `conductor/index.md`. If a machine-level Conductor plugin is already installed, follow that protocol against this repo’s `conductor/` artifacts. diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000..1bddad96 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "node .cursor/hooks/session-context.mjs" + } + ], + "stop": [ + { + "command": "node .cursor/hooks/stop-verify-reminder.mjs", + "loop_limit": 1 + } + ] + } +} diff --git a/.cursor/hooks/session-context.mjs b/.cursor/hooks/session-context.mjs new file mode 100644 index 00000000..d35dddf1 --- /dev/null +++ b/.cursor/hooks/session-context.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +process.stdout.write( + `${JSON.stringify({ + additional_context: + "Read AGENTS.md and conductor/index.md before writing. This is a solo-maintainer repo: 0 required reviews, no CODEOWNERS. Fail-closed on live Substack writes. After TypeScript changes run npm run verify:agent. Do not rewrite conductor/github-programme.json unless that is the task.", + })}\n`, +); diff --git a/.cursor/hooks/stop-verify-reminder.mjs b/.cursor/hooks/stop-verify-reminder.mjs new file mode 100644 index 00000000..48e80a97 --- /dev/null +++ b/.cursor/hooks/stop-verify-reminder.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +process.stdout.write( + `${JSON.stringify({ + user_message: + "If TypeScript changed, run npm run verify:agent (typecheck + test + inspect examples/basic.md). Do not commit unless asked.", + })}\n`, +); diff --git a/.cursor/rules/secrets-and-substack.mdc b/.cursor/rules/secrets-and-substack.mdc new file mode 100644 index 00000000..c680b0d4 --- /dev/null +++ b/.cursor/rules/secrets-and-substack.mdc @@ -0,0 +1,13 @@ +--- +description: Secrets handling and fail-closed Substack writes +globs: "**/{src/publish,src/browser,src/auth,src/substack-api,src/config}/**/*.{ts,js}" +alwaysApply: false +--- + +# Secrets and Substack writes + +- Never commit `.env`, cookies, `substack.sid`, Browserbase keys, or `.substack-cli/`. +- Redact credentials in CLI, MCP, logs, and fixtures. Prefer `redact()` helpers. +- Default tests use mocks/fixtures. Do not call live Substack in `npm test` or PR CI. +- Publish and schedule require `--yes`. Dry-run first. Missing confirmation must throw. +- E2E against a real publication is `workflow_dispatch` only and needs owner secrets. diff --git a/.cursor/rules/solo-maintainer.mdc b/.cursor/rules/solo-maintainer.mdc new file mode 100644 index 00000000..95de9084 --- /dev/null +++ b/.cursor/rules/solo-maintainer.mdc @@ -0,0 +1,14 @@ +--- +description: Solo-maintainer merge and review policy for this repository +alwaysApply: true +--- + +# Solo maintainer + +- One maintainer may merge. Do not add CODEOWNERS, required reviewers, or team assignment. +- Automated status checks are the merge gate. Required approving review count is 0. +- Fail-closed on live Substack writes, npm publish, and registry submissions. Require `--yes` / explicit authorization and a receipt. +- Prefer this repo’s `AGENTS.md` and `conductor/`. Ignore parent `careops-finance` AGENTS.md data-boundary rules. +- Do not rewrite `conductor/github-programme.json` or Project #38 nesting unless that track is the task. +- Leave unrelated dirty work alone. Do not commit secrets or `.substack-cli/`. +- PowerShell: no `&&`; use `;`. Quote git `@{u}`. diff --git a/.cursor/rules/typescript-esm.mdc b/.cursor/rules/typescript-esm.mdc new file mode 100644 index 00000000..885bfb2e --- /dev/null +++ b/.cursor/rules/typescript-esm.mdc @@ -0,0 +1,14 @@ +--- +description: TypeScript ESM conventions for the Substack CLI +globs: "**/*.{ts,mts,js,mjs}" +alwaysApply: false +--- + +# TypeScript ESM + +- `type: module`. Use `.js` specifiers in relative imports. +- Keep `strict` TypeScript. Do not add `any` to silence parser or API types. +- Command handlers in `src/cli.ts` stay thin; put logic in `src/parser`, `src/config`, `src/publish`, `src/substack-api`, or `src/browser`. +- Tests are Vitest `*.test.ts` next to the module, or under `src/test/assurance/` for taxonomy suites. +- After TypeScript changes run `npm run verify:agent` (typecheck, test, inspect `examples/basic.md`). +- Do not add Camoufox/optional browser installs to default paths. diff --git a/.cursor/skills/conductor/SKILL.md b/.cursor/skills/conductor/SKILL.md new file mode 100644 index 00000000..07ad74c9 --- /dev/null +++ b/.cursor/skills/conductor/SKILL.md @@ -0,0 +1,20 @@ +--- +name: conductor +description: Use Conductor context in this repository. Start at conductor/index.md for tracks, specs, and plans. Use when the user mentions Conductor, tracks, conductor-implement, or programme work. +--- + +# Conductor (repo pointer) + +Do not re-vendor the Conductor plugin here. The maintainer already has Conductor skills installed. + +## Before changing product code + +1. Read `conductor/index.md` and follow its links. +2. Resolve the active track from `conductor/tracks.md` or `conductor/tracks/`. +3. Read that track’s `spec.md` / `plan.md` / `metadata.json` only after the path is verified. +4. Follow `conductor/workflow.md`. Mark tasks `[~]` while in progress and `[x]` only with evidence. +5. Leave `conductor/github-programme.json` and Project #38 nesting to the programme agent unless this task owns them. + +## Codex / Cursor + +If a full Conductor skill is available on the machine (for example under `~/.codex/skills/conductor/`), follow that protocol. This file only binds agents to this repo’s `conductor/` artifacts. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 67462ba8..1b2ad207 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -24,6 +24,11 @@ body: options: [Local read-only, Local mutation, Browser or authentication, Live publication write, Security or privacy, Release or registry] validations: required: true + - type: input + id: conductor + attributes: + label: Conductor track or issue + description: Track ID (for example T10-01) or existing issue number. Leave blank for unplanned requests. - type: textarea id: alternatives attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ece0949e..91f46b8d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,9 +15,17 @@ Describe the focused change and why it is needed. ## Validation -List the exact commands and hosted checks used as evidence. +List the exact commands and hosted checks used as evidence. Human approval is not required. -- [ ] The one-command quality gate or a documented scoped equivalent passed. +```powershell +npm run verify:agent +``` + +Scoped alternative (say why the full gate was deferred): + +- [ ] `npm run verify:agent` or a documented scoped equivalent passed. - [ ] Tests cover the changed behavior and relevant regression risks. - [ ] Generated artifacts, contracts, and documentation are synchronized. - [ ] All actionable workflow, review, and issue comments have been addressed. + +Required reviewers: **0**. Do not add CODEOWNERS or a review count. diff --git a/.github/renovate.json b/.github/renovate.json index 753d0ebb..bd2ee079 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,13 +7,17 @@ ], "labels": ["dependencies", "renovate"], "dependencyDashboard": true, + "dependencyDashboardTitle": "Renovate Dependency Dashboard", "dependencyDashboardLabels": ["dependencies", "renovate"], "rangeStrategy": "pin", "ignorePaths": ["vendor/**"], "rebaseWhen": "behind-base-branch", "prCreation": "immediate", "prConcurrentLimit": 5, + "automerge": true, + "automergeType": "pr", "platformAutomerge": true, + "ignoreTests": false, "packageRules": [ { "description": "Follow selected mainline prerelease channels", diff --git a/.github/security-insights.yml b/.github/security-insights.yml new file mode 100644 index 00000000..d5a1e204 --- /dev/null +++ b/.github/security-insights.yml @@ -0,0 +1,37 @@ +header: + schema-version: "1.0.0" + last-updated: "2026-08-11" + last-reviewed: "2026-08-11" + url: https://github.com/edithatogo/substack-cli-ts + comment: | + Solo-maintainer TypeScript CLI. Automated checks replace required human review. + Live Substack writes stay fail-closed. + +project: + name: substack-cli-ts + administrators: + - name: edithatogo + affiliation: maintainer + primary: true + repositories: + - name: edithatogo/substack-cli-ts + url: https://github.com/edithatogo/substack-cli-ts + comment: Canonical source + +vulnerability-reporting: + reports-accepted: true + bug-bounty-available: false + security-policy: https://github.com/edithatogo/substack-cli-ts/security/policy + comment: Use GitHub private vulnerability reporting. Do not file public issues. + +security-artifacts: + - type: security-policy + url: https://github.com/edithatogo/substack-cli-ts/blob/master/SECURITY.md + - type: license + url: https://github.com/edithatogo/substack-cli-ts/blob/master/LICENSE + - type: scorecard + url: https://github.com/edithatogo/substack-cli-ts/security/code-scanning + +dependencies: + third-party-packages: true + dependencies-comments: Renovate is the dependency-update bot. Dependabot PRs are disabled. diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 1481328b..b2f9fc71 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -23,4 +23,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} run: | + # Waits for required status checks only. Codecov is informational and + # must not be required, or SKIPPED uploads block Renovate automerge. gh pr merge "$PR_URL" --auto --squash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc21ac37..bd4e52ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: name: Quality runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + id-token: write steps: - name: Checkout @@ -84,8 +87,11 @@ jobs: uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} + use_oidc: true files: ./coverage/lcov.info - # Protected-branch uploads require a token; do not fail PR Quality on missing token. + flags: quality + name: quality + # Token or OIDC; do not fail PR Quality on a missing/skipped upload. fail_ci_if_error: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} - name: Production Audit @@ -94,6 +100,24 @@ jobs: - name: Secret Pattern Scan run: npm run scan:secrets + fuzz: + name: Fuzz + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 26.5.1 + cache: npm + - run: npm ci + - name: Bounded parser, config, and schema fuzz + env: + FUZZ_RUNS: "80" + run: npm run test:fuzz + smoke: name: Smoke runs-on: ubuntu-latest diff --git a/.github/workflows/hardening.yml b/.github/workflows/hardening.yml index 7e095d0d..73463dea 100644 --- a/.github/workflows/hardening.yml +++ b/.github/workflows/hardening.yml @@ -166,6 +166,25 @@ jobs: path: reports/sbom if-no-files-found: error + fuzz-extended: + name: Extended Fuzz + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 26.5.1 + cache: npm + - run: npm ci + - name: Longer parser and schema fuzz + env: + FUZZ_RUNS: "400" + run: npm run test:fuzz + frontier-drift: name: Frontier Drift Monitor if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' diff --git a/AGENTS.md b/AGENTS.md index 82873c95..ebcc26ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,52 +1,80 @@ # Repository Guidelines -## Project Structure & Module Organization +This is `edithatogo/substack-cli-ts`, a solo-maintainer TypeScript CLI and MCP server for publishing local Markdown to a user-owned Substack publication. -This repository contains a TypeScript CLI scaffold for publishing local Markdown files to a user-owned Substack publication. +**Context conflict:** `OneDrive - Flinders/AGENTS.md` belongs to a different repository (`careops-finance`). Do not apply that file’s data-boundary, review, or finance-track rules here. Prefer this file, `conductor/index.md`, and verifiable evidence in this repo. -- `conductor.json` declares the setup and run entrypoints used by Conductor. -- `bin/conductor-setup` prepares a workspace by linking shared secrets and installing Ruby and Node dependencies. -- `script/server` configures runtime ports and Redis isolation, then starts the app through `bin/dev`. -- `src/cli.ts` defines the command surface. -- `src/parser/` contains front matter, Markdown, and Tiptap/ProseMirror conversion. -- `src/browser/` and `src/publish/` hold browser-session and publishing workflow code. -- `examples/` contains sample Markdown inputs. +## Solo-maintainer contract -## Build, Test, and Development Commands +- One person may create, review, merge, and release. +- Do **not** add CODEOWNERS, required approving reviews, team assignment, or mandatory human gates. +- Automated checks replace reviewers. Required status checks stay on; review count stays `0`. +- Fail-closed on unconfirmed Substack writes. Never claim a live draft, publish, schedule, npm publish, or registry submission without a hosted receipt. +- Do not commit `.env`, tokens, `.substack-cli/`, cookies, storage-state, traces, screenshots, or private publication content. -- `npm install --omit=optional`: installs the core CLI dependencies without optional Camoufox packages. -- `npm run build`: compiles TypeScript to `dist/`. -- `npm run typecheck`: runs TypeScript checks without emitting files. -- `npm test`: builds and runs the parser tests. -- `node dist/cli.js inspect examples/basic.md`: prints generated HTML and ProseMirror JSON. -- `node dist/cli.js config show`: shows effective non-secret configuration. -- `node dist/cli.js config set-runtime local`: uses a local persistent Chrome profile under `.substack-cli/chrome-profile`. -- `node dist/cli.js auth status`: shows Browserbase and session readiness. -- `node dist/cli.js schema capture examples/basic.md --out fixtures/prosemirror/basic.json`: writes a parser fixture. -- `bin/conductor-setup`: links shared secrets when present, then runs dependency installation. +## Read before writing -Run scripts from the repository root. On Windows, use a Bash-compatible shell for these scripts. +1. This file. +2. `conductor/index.md` and the active track under `conductor/tracks/` (or `conductor/tracks.md`). +3. `git status`, remotes, and the existing diff. Do not clobber unrelated dirty work. +4. `docs/quality-frontier.md` when changing tests, CI, Codecov, Renovate, or Scorecard. -## Coding Style & Naming Conventions +## Project structure -Use strict TypeScript, ESM imports, and explicit module boundaries. Keep command handlers thin and delegate parsing, config, browser, and publishing logic to separate modules. Shell scripts should use clear environment variable names, quote variable expansions, and prefer small, explicit setup steps. +- `src/cli.ts` — command surface. Keep handlers thin. +- `src/parser/` — front matter, Markdown, Tiptap/ProseMirror. +- `src/browser/` and `src/publish/` — browser session and publish workflow. +- `src/substack-api/` — HTTP transport. Mock or fixture in tests; no live network in default CI. +- `src/config/` — local non-secret config. Secrets come from env. +- `src/mcp/` — MCP server. Shared behavior lives in `src/` once. +- `src/test/assurance/` — taxonomy suites (property, DST, contract, metamorphic, agentic, fuzz). +- `examples/` and `fixtures/prosemirror/` — sample Markdown and frozen parser contracts. +- `conductor/` — product context, tracks, contracts. `conductor/github-programme.json` is owned by the GitHub programme track; do not rewrite it unless that track asks. -## Testing Guidelines +## Commands -Tests are written with Vitest. Name tests `*.test.ts` and keep them close to the module under test. +Run from the repository root. In PowerShell do not chain with `&&`; use `;`. npm scripts may still use `&&` (npm translates them on Windows). -Before changing parser or runtime behavior, verify: +| Intent | Command | +| --- | --- | +| Install (no Camoufox) | `npm install --omit=optional` or `npm ci` | +| Agent PR gate | `npm run verify:agent` | +| Typecheck | `npm run typecheck` | +| Unit + default Vitest | `npm test` / `npm run test:unit` | +| Coverage | `npm run test:coverage` | +| Inspect example | `node dist/cli.js inspect examples/basic.md` | +| Taxonomy / assurance | `npm run test:assurance` | +| Fuzz (bounded) | `npm run test:fuzz` | +| Mutation | `npm run test:mutation` | +| Full local quality | `npm run quality` | +| Lint / format | `npm run ci` | -- `npm run typecheck` -- `npm test` -- `node dist/cli.js inspect examples/basic.md` +Do not start optional Camoufox or live Substack logins unless the user explicitly asks. -## Commit & Pull Request Guidelines +## Coding style -This directory does not currently include Git history, so no local commit convention can be inferred. Use short, imperative commit subjects such as `Add Conductor server hook` or `Document workspace setup`. +Strict TypeScript, ESM only, explicit module boundaries. Parser, config, browser, and publish stay separate. Command handlers delegate. Redact secrets on every output path. -Pull requests should include a concise summary, commands run to validate the change, any required environment variables, and notes about changes to browser automation, generated payloads, or secret handling. +## Testing -## Security & Configuration Tips +Tests are Vitest `*.test.ts` next to modules, plus `src/test/assurance/` for the taxonomy. Each modality has an npm script and CI wiring. See `test/testing-taxonomy.json` and `docs/quality-frontier.md`. -Do not commit `.env`, `config/master.key`, `.substack-cli/`, browser storage-state files, credentials, traces, screenshots, or generated dependency directories. `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD` are supported for `auth login --auto-login`, but must remain in ignored local config only. Use `.env.example` for documented variables only. +Before changing parser or runtime behavior: + +1. `npm run typecheck` +2. `npm test` +3. `node dist/cli.js inspect examples/basic.md` + +Or `npm run verify:agent`. + +## Conductor + +Use Conductor tracks for planned work. Start at `conductor/index.md`. Implementation marks the active task `[~]`, follows `conductor/workflow.md`, and marks `[x]` only with evidence. Do not invent a parallel planning system. Do not fight in-flight GitHub Project #38 / issue-nesting work. + +## Commits and pull requests + +Short imperative subjects. PRs need a summary, exact validation commands, contract/track IDs when applicable, and a secrets/live-write checkbox. Human approval is not required. + +## Security + +`SUBSTACK_EMAIL`, `SUBSTACK_PASSWORD`, `SUBSTACK_COOKIE`, and Browserbase keys stay in ignored local config. Use `.env.example` for documented names only. Live publish/schedule requires `--yes` after `--dry-run`. Default CI must not hit Substack. diff --git a/README.md b/README.md index a9c6d9ee..2ba83db0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ [![npm](https://img.shields.io/npm/v/%40edithatogo%2Fsubstack-cli)](https://www.npmjs.com/package/@edithatogo/substack-cli) [![npm downloads](https://img.shields.io/npm/dm/%40edithatogo%2Fsubstack-cli)](https://www.npmjs.com/package/@edithatogo/substack-cli) [![codecov](https://codecov.io/gh/edithatogo/substack-cli-ts/branch/master/graph/badge.svg)](https://codecov.io/gh/edithatogo/substack-cli-ts) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/edithatogo/substack-cli-ts/badge)](https://securityscorecards.dev/viewer/?uri=github.com/edithatogo/substack-cli-ts) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) +[![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org) [![TypeScript](https://img.shields.io/badge/typescript-5.9-blue)](https://www.typescriptlang.org) [![Renovate](https://img.shields.io/badge/renovate-enabled-brightgreen?logo=renovate)](https://github.com/edithatogo/substack-cli-ts/network/updates) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) diff --git a/SECURITY.md b/SECURITY.md index d6825407..c21f92e2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,8 @@ You should receive a response within 48 hours. If the issue is confirmed, a fix The repository includes `scripts/secret-scan.mjs` for automated secret scanning. Run `npm run scan:secrets` before committing to detect accidentally included secrets. +GitHub secret scanning and push protection are enabled on `edithatogo/substack-cli-ts`. Non-provider-pattern and validity-check toggles may still need a repo-admin PAT. CodeQL uses GitHub default setup (do not add a second workflow). OpenSSF Scorecard runs from `.github/workflows/security.yml` and uploads SARIF. Dependency updates come from Renovate, not Dependabot PRs. + ## Disclosure Policy - Vulnerabilities are coordinated through a private GitHub security advisory and released as a patch version. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..8fb66d83 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,28 @@ +codecov: + require_ci_to_pass: false + notify: + wait_for_ci: false + +coverage: + status: + project: + default: + informational: true + target: auto + threshold: 2% + patch: + default: + informational: true + target: auto + threshold: 2% + +comment: + layout: "header, diff, flags, files" + behavior: default + require_changes: false + +ignore: + - "src/**/*.test.ts" + - "src/test/**" + - "scripts/**" + - "vendor/**" diff --git a/conductor/design.md b/conductor/design.md index 55db02aa..318aa553 100644 --- a/conductor/design.md +++ b/conductor/design.md @@ -1,7 +1,7 @@ # Design: Substack Markdown Publisher CLI -> **Last updated:** 2026-05-13 -> **Status:** Living Document +> **Last updated:** 2026-08-11 +> **Status:** Living Document > **Architecture Style:** Modular monolith with dual-transport (browser automation + HTTP API) --- @@ -10,66 +10,54 @@ ```mermaid graph TB - CLI[CLI Layer
commander.js] --> CORE[Core Services] - CLI --> MCP[MCP Server
@modelcontextprotocol/sdk] - + CLI[CLI Layer commander.js] --> CORE[Core Services] + CLI --> MCP[MCP Server] + subgraph CORE[Core Services] - PARSER[Markdown Parser
marked + tiptap] - PREP[Post Preparer
preparePost] - PREPUB[Prepublish Validator
prepublishPost] - POLICY[Policy Evaluator
evaluateDistributionPolicy] - DOCTOR[Doctor
runDoctor] + PARSER[Markdown Parser] + PREP[Post Preparer] + PREPUB[Prepublish Validator] + POLICY[Policy Evaluator] + DOCTOR[Doctor] end - - PARSER --> SCHEMA[Schema Fixtures
fixtures.ts] - PREP --> API[API Transport Layer] - PREP --> BROWSER[Browser Automation Layer] - - subgraph API[API Transport Layer] - AUTH[Auth / Session] - READ[Read Model] - DRAFT_WRITE[Draft Write] - PUBLISH_WRITE[Publish Write] - MEDIA[Media Upload] - PAYLOAD[Payload Builder] - MAPPINGS[Draft Mappings] - SECTION[Section Resolution] - DUPLICATE[Duplicate Lookup] - INSPECT[Draft Inspection] + + PARSER --> SCHEMA[Schema Fixtures] + PREP --> API[API Transport] + PREP --> BROWSER[Browser Automation] + API --> SUBSTACK[Substack] + BROWSER --> SUBSTACK +``` --- -## 2. Data Flow: Draft → Publish Pipeline +## 2. Data Flow: Draft to Publish ```mermaid sequenceDiagram participant User - participant CLI as CLI (commander) + participant CLI as CLI participant Prep as preparePost participant Parser as Markdown Parser participant API as API Transport participant Browser as Browser Automation - participant Substack as Substack API - - User->>CLI: substack draft post.md - CLI->>Prep: preparePost(post.md, {mode: "draft"}) - Prep->>Parser: parseMarkdownFile() - Parser-->>Prep: ParsedPost (metadata + ProseMirror doc) - - alt Transport === "api" or "auto" - CLI->>API: resolveApiAuthMaterial() - API-->>CLI: ApiAuthMaterial - CLI->>API: planCreateDraft() - API-->>CLI: DraftWritePlan - CLI->>API: executeDraftWrite() + participant Substack as Substack + + User->>CLI: draft post.md + CLI->>Prep: preparePost + Prep->>Parser: parseMarkdownFile + Parser-->>Prep: ParsedPost + + alt transport api or auto + CLI->>API: executeDraftWrite API->>Substack: POST /api/v1/drafts Substack-->>API: DraftResponse - API-->>CLI: DraftWriteResult - else Transport === "browser" - CLI->>Browser: runBrowserWorkflow() - Browser->>Substack: Open editor, fill fields - Browser->>Substack: Click Publish - Substack-->>Browser: Draft URL + else transport browser + CLI->>Browser: runBrowserWorkflow + Note over Browser,Substack: Live publish/schedule requires --yes + end + + CLI-->>User: JSON result +``` --- @@ -78,43 +66,18 @@ sequenceDiagram ```mermaid graph LR CLI[cli.ts] --> CONFIG[config/store.ts] - CLI --> AUTH[auth/*] - CLI --> BROWSER[browser/*] - CLI --> PARSE[parser/*] - CLI --> PUBLISH[publish/*] - CLI --> SUB_API[substack-api/*] - CLI --> MCP[mcp/*] - CLI --> POLICY[policy/distribution.ts] - CLI --> DOCTOR[doctor/doctor.ts] - CLI --> SCHEMA[schema/fixtures.ts] - CLI --> UTIL[util/redact.ts] - + CLI --> AUTH[auth] + CLI --> BROWSER[browser] + CLI --> PARSE[parser] + CLI --> PUBLISH[publish] + CLI --> SUB_API[substack-api] + CLI --> MCP[mcp] + CLI --> POLICY[policy] SUB_API --> CONFIG - SUB_API --> AUTH - SUB_API --> PARSE - SUB_API --> PUBLISH - BROWSER --> CONFIG - BROWSER --> AUTH - MCP --> SUB_API - MCP --> BROWSER - MCP --> POLICY - MCP --> DOCTOR - MCP --> SCHEMA - MCP --> PARSE - MCP --> PUBLISH - - PUBLISH --> CONFIG PUBLISH --> BROWSER - PUBLISH --> SUB_API - PARSE --> PUBLISH - - style CLI fill:#e1f5fe,stroke:#01579b - style SUB_API fill:#e8f5e9,stroke:#2e7d32 - style BROWSER fill:#fff3e0,stroke:#e65100 - style MCP fill:#f3e5f5,stroke:#7b1fa2 ``` --- @@ -123,102 +86,44 @@ graph LR ```mermaid graph TB - subgraph SECRETS[Secrets & Credentials] - ENV[.env file] - PROFILE[Local Chrome Profile
substack.sid cookie] - SESSION[Session File] - end - - subgraph REDACT[Redaction Layer] - R1[redact() masks secret values] - R2[redactUrl() masks session URLs] - R3[summarizeApiAuthMaterial()] - end - - subgraph STORAGE[Storage Rules] - GIT[.gitignore excludes secrets] - end - - ENV --> R1 - PROFILE --> R1 - SESSION --> R1 - R1 --> OUTPUT[CLI Output - Redacted JSON] - R1 --> MCP_OUT[MCP Output - Redacted] - - GIT -->|excludes| ENV - GIT -->|excludes| PROFILE_DIR[.substack-cli/] - GIT -->|excludes| BROWSER_ARTIFACTS[traces, screenshots] + ENV[.env] --> REDACT[redact helpers] + PROFILE[Local Chrome profile] --> REDACT + REDACT --> OUTPUT[CLI and MCP output] + GIT[.gitignore] -->|excludes| ENV + GIT -->|excludes| STATEDIR[.substack-cli] ``` +Live publish and schedule stay fail-closed without `--yes`. Default CI never uses Substack credentials. + --- ## 5. CI/CD Pipeline ```mermaid graph LR - PR[Pull Request] --> CI[GitHub Actions] - - subgraph CI - Q[Quality Job
15 min] - M[Mutation Job
30 min
needs: quality] - E2E[E2E Job
manual only] - end - - Q --> FORMAT[format:check] - Q --> LINT[lint] - Q --> TYPECHECK[typecheck] - Q --> BUILD[build] - Q --> TEST[test:coverage] - Q --> AUDIT[audit:prod] - Q --> SCAN[scan:secrets] - - M --> STRYKE[Stryker break ≥ 50] - - E2E --> E2E_TEST[npm run test:e2e] + PR[Pull Request] --> Q[Quality] + PR --> Fuzz[Fuzz] + PR --> Smoke[Smoke] + PR --> Assure[Assurance taxonomy] + PR --> Mut[Mutation] + PR --> Sec[Security OSV review Scorecard on push] + Q --> Codecov[Codecov informational on PRs] ``` +Required merge checks are listed in `docs/ruleset-solo-maintainer.json`. Review count is 0. + --- ## 6. Key Design Decisions | Decision | Choice | Rationale | -|----------|--------|-----------| -| **CLI Framework** | `commander.js` v14 | De facto standard for Node.js CLIs | -| **Parser** | `marked` + `@tiptap/core` + `@tiptap/html` | Markdown → HTML → ProseMirror; Tiptap is Substack's editor engine | -| **Browser Automation** | Playwright + Stagehand | Playwright for browser control; Stagehand for AI-driven interactions | -| **MCP** | `@modelcontextprotocol/sdk` | Standard MCP protocol for AI agent integration | -| **API Transport** | Native `fetch`, Zod validation | No extra deps; Zod ensures type safety on API responses | -| **Dual Transport** | `--transport browser|api|auto` | Users can choose; auto falls back gracefully | -| **Auth** | Cookie extraction + env vars | `substack.sid` from local Chrome; no OAuth needed | -| **Secrets** | `redact()` function on all output | Prevents accidental secret leakage | -| **Testing** | Vitest (fast), Stryker (mutation) | Vitest is modern; Stryker validates test quality | -| **Quality** | Prettier + ESLint + TypeScript strict | Enforced in CI; zero-warning lint | - - Browser-->>CLI: WorkflowResult - end - - CLI->>CLI: maybeWriteTrace() - CLI-->>User: JSON result -``` - - end - - subgraph BROWSER[Browser Automation Layer] - LOCAL_BROWSER[Local Browser] - STAGEHAND[Stagehand Session] - EDITOR[Editor Actions] - DIAGNOSTICS[Diagnostics] - DRAFT_CAPTURE[Draft Capture] - DRAFT_CONTRACT[Draft Contract] - CONTRACT_MATRIX[Contract Matrix] - ERRORS[Error Handling] - end - - API --> API_MODULES[API Feature Modules] - AUTH --> SUBSTACK[Substack API] - READ --> SUBSTACK - DRAFT_WRITE --> SUBSTACK - PUBLISH_WRITE --> SUBSTACK - MEDIA --> SUBSTACK - BROWSER --> SUBSTACK -``` +| --- | --- | --- | +| CLI | commander.js | Thin handlers over modules | +| Parser | marked + Tiptap | Markdown → HTML → ProseMirror | +| Browser | Playwright + Stagehand | Local or Browserbase | +| API | fetch + Zod | No extra HTTP client for first-party calls | +| Dual transport | `--transport browser\|api\|auto` | Auto can fall back | +| Auth | Cookie / env | No OAuth in-repo | +| Testing | Vitest, fast-check, Stryker | Taxonomy in `docs/quality-frontier.md` | +| Dependencies | Renovate | Dependabot PRs disabled | +| Reviews | 0 required | Solo maintainer; machines gate | diff --git a/conductor/index.md b/conductor/index.md index d4ef071d..fd623b46 100644 --- a/conductor/index.md +++ b/conductor/index.md @@ -1,10 +1,32 @@ -# Conductor context -- [product.md](./product.md) -- [product-guidelines.md](./product-guidelines.md) -- [tech-stack.md](./tech-stack.md) -- [workflow.md](./workflow.md) -- [tracks.md](./tracks.md) -- [contracts/substack-cli-maturity.contract.json](./contracts/substack-cli-maturity.contract.json) -- [traceability.json](./traceability.json) +# Project Context + +This repository is `edithatogo/substack-cli-ts`. The parent `OneDrive - Flinders/AGENTS.md` belongs to `careops-finance` and does not apply here. + +## Definition + +- [Product Definition](./product.md) +- [Product Guidelines](./product-guidelines.md) +- [Tech Stack](./tech-stack.md) +- [Requirements (MoSCoW)](./requirements.md) +- [Design](./design.md) + +## Workflow + +- [Workflow](./workflow.md) +- [Tracks registry](./tracks.md) +- [Agent contract](../AGENTS.md) +- [Quality frontier](../docs/quality-frontier.md) +- [Governance](../GOVERNANCE.md) + +## Contracts and evidence + +- [Canonical maturity contract](./contracts/substack-cli-maturity.contract.json) +- [Traceability](./traceability.json) +- [Testing taxonomy](../test/testing-taxonomy.json) + +## Capabilities + +- In-repo Conductor pointer: [`.cursor/skills/conductor/`](../.cursor/skills/conductor/) +- Hosted programme mapping lives in `conductor/github-programme.json` (owned by the Project #38 / P19 agent — do not rewrite unless that is the task). planSha256: f549b29a92f6549798d24d977f79face381dbc23d4e20c0c6b243bc36c46c344 diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md index 0e5ff832..e8790993 100644 --- a/conductor/product-guidelines.md +++ b/conductor/product-guidelines.md @@ -5,3 +5,6 @@ - Explicit evidence for every mapped contract. - Every implementation task must add or update automated tests, or record a bounded applicability receipt explaining why tests do not apply. +- Fail-closed: unconfirmed publish/schedule, missing credentials, and unverified live writes are errors, not best-effort successes. +- Solo maintainer: automated checks replace required human review. Do not add CODEOWNERS or a review count. +- Prefer this repo’s `AGENTS.md` over the parent `careops-finance` contract. diff --git a/conductor/requirements.md b/conductor/requirements.md index cac990af..f549efe1 100644 --- a/conductor/requirements.md +++ b/conductor/requirements.md @@ -142,6 +142,7 @@ | C3.1 | E2E tests with live Substack | Manual workflow_dispatch | | C3.2 | Coverage badges | Present in README | | C3.3 | Load/stress testing | Future consideration | +| C3.4 | Parser/config fuzz + OpenSSF Scorecard | `npm run test:fuzz`; Security Scorecard job | --- @@ -172,5 +173,5 @@ | **Secrets Management** | Redacted output, env vars + local Chrome profile, `.gitignore` exclusion | | **Dual Transport** | `--transport browser|api|auto` on draft/publish/schedule commands | | **Observability** | Workflow traces, draft capture artifacts, analytics snapshots | -| **Dependency Management** | Renovate + Dependabot for automated PRs; production audit | +| **Dependency Management** | Renovate for automated PRs (Dependabot PRs disabled); production audit | | **Quality Gates** | Format → Lint → TypeScript → Build → Test with enforced baseline coverage and incremental movement toward 91% → Mutation (break ≥ 50) | diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md index 23146bd1..5b6fcad6 100644 --- a/conductor/tech-stack.md +++ b/conductor/tech-stack.md @@ -1,4 +1,35 @@ # Tech Stack -- TypeScript CLI implementation remains unchanged in planning. -- GitHub CLI orchestration for issues, issues hierarchy, project, and checks. -- Node script driven planning artifacts and validator. + +Inferred from this repository (brownfield). Do not introduce a second runtime. + +## Runtime + +- TypeScript ESM CLI (`"type": "module"`), Node `>=22`, current CI Node `26.5.1` +- npm `11.17.0` (`packageManager`) +- Commander for the CLI; MCP via `@modelcontextprotocol/sdk` + +## Parsing and publishing + +- Markdown: `marked` + YAML-ish front matter in `src/parser/` +- Editor model: Tiptap / ProseMirror (`@tiptap/*`) +- Dual transport: HTTP API (`src/substack-api/`) and browser (Playwright + Stagehand) +- Config: Zod schemas in `src/config/store.ts` +- Secrets: env + local Chrome profile; never committed + +## Quality + +- Vitest + `@vitest/coverage-v8` +- fast-check (property + fuzz) +- Stryker (mutation, parser/publish scope) +- Biome (lint/format) +- Knip, npm audit, secret scan, SBOM scripts +- GitHub Actions: CI, Hardening, Security (OSV, dependency-review, Scorecard, actionlint/zizmor) +- CodeQL: GitHub default setup (do not add a second CodeQL workflow) +- Codecov OIDC + `CODECOV_TOKEN` +- Renovate (`github>edithatogo/renovate-config`), not Dependabot PRs + +## Orchestration + +- Conductor artifacts under `conductor/` +- GitHub CLI for issues, project evidence, and checks +- Cursor rules/hooks under `.cursor/` diff --git a/conductor/workflow.md b/conductor/workflow.md index 2c80fe8f..8e656457 100644 --- a/conductor/workflow.md +++ b/conductor/workflow.md @@ -1,20 +1,36 @@ # Workflow +## Solo-maintainer rules + +- Required approving reviews: 0. No CODEOWNERS. No team assignment. +- Automated CI, contracts, and receipts are the merge gate. +- Fail-closed on live Substack writes, npm publish, and registry submissions. +- Windows/PowerShell agents: do not chain with `&&`; use `;`. + ## Plan-only protocol + +Used only when a planning-only invocation is explicitly requested. + - Confirm git status and record the starting SHA. - Refresh upstream facts from official sources and the repository itself. -- Install/update Conductor using the current official supported mechanism and run brownfield setup. -- Create the canonical contract and traceability system. -- Create/update all phases, tracks, plans, metadata and task lists. -- Create the nested GitHub issue hierarchy and project. -- Open the planning PR. -- Run and watch GitHub Actions with gh pr checks --watch or the current equivalent. -- Fix planning-artefact failures only; do not begin runtime implementation. -- Post a planning receipt and STOP. +- Create/update phases, tracks, plans, metadata and task lists without starting runtime implementation. +- Open the planning PR and watch GitHub Actions. +- Fix planning-artefact failures only, then STOP. + +## Implementation protocol + +1. Read `AGENTS.md`, this file, and the active track plan. +2. Inspect `git status` and leave unrelated dirty work untouched. +3. Mark the active task `[~]`. +4. Implement the smallest reversible change. Keep parser, config, browser, and publish boundaries. +5. Add or update a test in the matching taxonomy modality. +6. Run `npm run verify:agent`. Expand to `npm run test:assurance` or `npm run quality` when the change warrants it. +7. Mark the task `[x]` only with commands and evidence. Do not claim hosted publish from local output. ## Track closeout + - Run the track test and required quality/security checks before marking the track complete. -- Merge the track PR and inspect all workflow, review, and issue comments. +- Merge the track PR (squash) after required checks are green. Human approval is not required. - Confirm the completed track has no dirty disposable worktree and no open PR depending on it. -- Remove the completed track worktree, prune stale worktree metadata, and delete only merged or otherwise confirmed-unused local branches. +- Remove only merged or otherwise confirmed-unused local branches. - Preserve the canonical worktree, active branches, unmerged branches, and any worktree containing uncommitted changes. diff --git a/docs/branch-protection.md b/docs/branch-protection.md index 335b725e..e8d394fb 100644 --- a/docs/branch-protection.md +++ b/docs/branch-protection.md @@ -1,52 +1,57 @@ # Branch Protection -This policy separates required merge gates from advisory hardening lanes. It is intentionally stricter for release and dependency work than for day-to-day local planning because Substack live writes, npm publication, and registry submissions remain owner-approved gates. +Solo-maintainer policy: **0 approving reviews**, no CODEOWNERS, no last-push approval. Automated checks are the merge gate. Force-push and default-branch deletion stay off. Linear history and resolved conversations stay on. Signed commits are **not** required. -## Required Pull Request Checks +Live capture (2026-08-11): classic protection on `master` plus ruleset `20603600` (`Solo maintainer default branch`). Machine-readable copy: `docs/ruleset-solo-maintainer.json`. -Require these checks before merging feature or dependency pull requests: +## Required Pull Request Checks +- `Analyze (actions)` +- `Analyze (javascript-typescript)` - `Quality` - `Smoke` +- `Deterministic Assurance Taxonomy` +- `Mutation` - `Required Audit And Secret Scan` -- `Node Compatibility (20)` +- `SBOM Evidence` +- `Strictest TypeScript` +- `Dependency Declaration Strictness` +- `Index Signature Strictness` - `Node Compatibility (22)` - `Node Compatibility (24)` -- `codecov/patch` +- `Node Compatibility (26.5.1)` +- `ubuntu-latest / Node 26.5.1` +- `windows-latest / Node 26.5.1` +- `macos-latest / Node 26.5.1` -Treat the generated contract check as required when public CLI, MCP, schema, or run-log surfaces change. Treat frontier drift as required when safe-surface coverage, endpoint evidence, or launch/admin readiness changes. +Do **not** require `codecov/patch`, `codecov/project`, `Fuzz`, or `OpenSSF Scorecard`. Codecov is informational on PRs so SKIPPED uploads cannot block Renovate automerge. -## Advisory Checks +Treat generated contract drift as required when public CLI, MCP, schema, or run-log surfaces change (covered today by Quality / Assurance / fixture tests). -Keep these checks visible but advisory until their runtime dependencies are stable enough for every pull request: +## Advisory Checks -- `Mutation` -- `E2E` -- `Strictest TypeScript` -- `Experimental Dependency Lane` +- `Fuzz` (bounded on PRs; longer on Hardening schedule) +- `codecov/patch` / `codecov/project` +- `OpenSSF Scorecard` (push to `master` and weekly) +- `E2E` (manual `workflow_dispatch`, live credentials) - `Frontier Drift Monitor` -- `Index Signature Strictness` -- `Dependency Declaration Strictness` - -Mutation is already run and reviewed before track closeout, but it should not block emergency security fixes until module-level thresholds are stable. E2E stays manual because it needs live Substack credentials and a test publication. +- `Extended Fuzz` ## Dependency Pull Requests -Dependency PRs may be merged only after required checks pass and the change is reviewed for runtime risk. +Renovate is the update bot. Dependabot PRs stay disabled. -- Stable tooling bumps should pass `Quality`, `Smoke`, hardening Node compatibility, audit, and secret scan. -- Parser, Markdown, Tiptap, or ProseMirror bumps should also run `npm test` and `node dist/cli.js inspect examples/basic.md`. -- Browser, Playwright, Stagehand, or authentication-adjacent bumps should run smoke locally and use manual E2E before release. -- Security updates should run `npm run audit:prod` and `npm run scan:secrets`. -- Experimental dependency lanes must not automerge. +- Non-major updates may automerge after **required** checks pass. +- Majors stay labeled `breaking` and need dashboard approval. +- Parser / Tiptap bumps should also run `npm test` and `node dist/cli.js inspect examples/basic.md`. +- Do not automerge live-canary or credential-bearing workflow changes. ## Release Rules Before creating a release tag, verify: -- Latest `master` commit is green for `CI`, `Hardening`, `Deploy GitHub Pages`, and `Release Drafter`. +- Latest `master` is green for `CI`, `Hardening`, `Security`, `Deploy GitHub Pages`, and `Release Drafter`. - `npm run sbom` produces an SBOM artifact. - `node dist/cli.js coverage release-scorecard` reports `localStatus: "ready"`. -- `.github/workflows/publish.yml` uses `npm publish --provenance --access public`. -- `.github/workflows/publish.yml` grants `id-token: write` for npm provenance. +- `.github/workflows/publish.yml` uses `npm publish --provenance --access public` and `id-token: write`. - External npm, GitHub release, MCP registry, marketplace, and Substack admin gates remain owner-approved. diff --git a/docs/ci-hardening.md b/docs/ci-hardening.md index ec895c71..8f0fc7ab 100644 --- a/docs/ci-hardening.md +++ b/docs/ci-hardening.md @@ -4,10 +4,14 @@ This repo separates stable merge gates from experimental hardening lanes so feat ## Required Gates -- `CI / Quality`: Biome, Knip, typecheck, build, coverage, production audit, and secret scan. +- `CI / Quality`: Biome, Knip, typecheck, build, coverage, Codecov upload, production audit, and secret scan. - `CI / Smoke`: build plus `node dist/cli.js inspect examples/basic.md`. +- `CI / Deterministic Assurance Taxonomy`: unit-adjacent taxonomy including property, DST, contract, metamorphic, agentic, edge, VCR, and schema fuzz. +- `CI / Fuzz`: bounded `npm run test:fuzz` (not a required merge check). - `Hardening / Required Audit And Secret Scan`: production dependency audit and secret-pattern scan without advisory `continue-on-error`. -- `Hardening / Node Compatibility`: build and smoke tests across the supported Node line. +- `Hardening / Node Compatibility`: build and smoke tests on Node 22, 24, and 26.5.1. +- `Hardening / Extended Fuzz`: longer `FUZZ_RUNS` on schedule or `workflow_dispatch`. +- `Security / OpenSSF Scorecard`: SARIF + `publish_results` on non-PR events. ## Strictness Lanes @@ -65,4 +69,6 @@ Require these checks for feature and dependency PRs after the generated API cont - generated contract check - frontier drift check -Keep index-signature strictness, dependency declaration strictness, E2E, and experimental dependency checks advisory until their false-positive, upstream declaration, and fixture requirements are resolved. Treat mutation as review-required and CI-visible; raise module thresholds gradually rather than using one global jump. +Index-signature and dependency-declaration lanes are required today even though they exit 0 on advisory diagnostics. Keep E2E and Extended Fuzz advisory. Mutation is already a required check with Stryker break 60. Do not require Codecov status checks. + +See `docs/quality-frontier.md` for the full test matrix, Codecov/Renovate status, and OpenSSF exceptions. diff --git a/docs/quality-frontier.md b/docs/quality-frontier.md new file mode 100644 index 00000000..465e1d19 --- /dev/null +++ b/docs/quality-frontier.md @@ -0,0 +1,111 @@ +# Quality frontier + +Solo-maintainer quality, security, and assurance map for `edithatogo/substack-cli-ts`. Human review count is **0**. Machines gate merges. + +## Agent local gate + +```powershell +npm run verify:agent +``` + +Runs typecheck, `npm test` (build + Vitest), and `node dist/cli.js inspect examples/basic.md`. + +## Test matrix + +| Type | Exists | Script | PR CI | Schedule / deep | +| --- | --- | --- | --- | --- | +| Unit | Yes — `src/**/*.test.ts` | `test:unit` / `npm test` | Quality | — | +| Integration | Yes — parser→contract, CLI help | `test:integration`, `test:cli-integration` | Assurance | — | +| Contract / CDC | Yes — MCP manifest + frozen ProseMirror fixtures | `test:cdc`, `test:contract` | Assurance + unit fixtures | — | +| Property | Yes — fast-check front matter / markdown | `test:property` | Assurance + Fuzz | Extended Fuzz | +| Fuzz | Yes — Zod schemas + parser/config/ProseMirror | `test:fuzz`, `test:schema-fuzz` | Fuzz (`FUZZ_RUNS=80`) | Extended Fuzz (`FUZZ_RUNS=400`) | +| DST | Yes — seeded retry + publish gate | `test:dst` | Assurance | — | +| Metamorphic | Yes — line endings, idempotent parse, headings/links | `test:metamorphic` | Assurance | — | +| Agentic / AI | Yes — synthetic planner + CLI recipes, no paid LLM | `test:agentic` | Assurance | — | +| Semantic judge | Yes — recorded adapter only | `test:semantic-judge` | Assurance | live model is owner-gated | +| Smoke | Yes — built CLI inspect/prepublish | `test:smoke` | Smoke | — | +| Edge | Yes — empty/unicode/CRLF/invalid enum/fail-closed publish | `test:edge` | Assurance | — | +| E2E (CLI) | Yes — `inspect examples/basic.md` | `test:e2e` | Assurance (built CLI file) | live browser E2E is `workflow_dispatch` only | +| Mutation | Yes — Stryker, parser/publish scope, break 60 | `test:mutation` | Mutation | — | +| Coverage | Yes — Vitest V8 → `coverage/lcov.info` | `test:coverage` | Quality → Codecov | fail upload errors on `master` push only | +| Lint / format | Yes — Biome | `ci` | Quality | — | +| Typecheck | Yes | `typecheck` | Quality + Strictest TypeScript | advisory index/dependency lanes | +| Profiling | Yes — performance suite + CPU profile | `profile`, `profile:parser` | Performance budgets | — | +| VCR / network replay | Yes — fixture transport | `test:vcr` | Assurance | — | + +Taxonomy source of truth: `test/testing-taxonomy.json`, enforced by `npm run test:taxonomy`. + +## CI lanes + +**Required for merge** (ruleset + classic protection, 0 approving reviews): + +- CodeQL default setup: `Analyze (actions)`, `Analyze (javascript-typescript)` +- `Quality`, `Smoke`, `Deterministic Assurance Taxonomy`, `Mutation` +- `Required Audit And Secret Scan`, `SBOM Evidence`, `Strictest TypeScript` +- Node compatibility 22 / 24 / 26.5.1 +- Cross-platform `ubuntu-latest / windows-latest / macos-latest` × Node 26.5.1 + +**Visible, not required** (so SKIPPED checks cannot block Renovate automerge): + +- `codecov/patch` and `codecov/project` — informational on PRs; Quality still uploads +- `Fuzz` — bounded; Scorecard-facing name +- `OpenSSF Scorecard` — push/`master` + weekly, skipped on PRs +- `E2E`, Frontier Drift, experimental/live canaries + +## Codecov + +- Upload: `.github/workflows/ci.yml` Quality job, `codecov/codecov-action` v7, `coverage/lcov.info` +- `CODECOV_TOKEN` repo secret exists (created 2026-05-14). OIDC (`id-token: write` + `use_oidc: true`) is also enabled as a fallback. +- `fail_ci_if_error` is true only on push to `master`. +- `codecov.yml` marks project/patch informational so a missing token cannot fail PRs or Renovate. +- Observed working: `codecov/patch` passed on PRs #552 and #553. + +If uploads skip after a token rotation, re-add the Codecov GitHub App / OIDC bind at [Codecov GitHub OIDC](https://docs.codecov.com/docs/github-oidc-app) and keep `CODECOV_TOKEN` as backup. Do not invent tokens. + +## Renovate + +- Config: `.github/renovate.json` extends `github>edithatogo/renovate-config` +- Dependabot PRs are not used. Do not add `dependabot.yml`. Dependabot **alerts** may remain. +- Hosted proof: renovate[bot] PR #535 (`@biomejs/biome` 2.5.7) +- `platformAutomerge: true` for non-major updates. Majors stay manual (`breaking` label, dashboard approval). +- Auto-merge waits only for **required** checks. Codecov is not required, so SKIPPED Codecov cannot block. +- Dependency Dashboard is enabled in config. If the issue is missing, open the Mend dashboard or wait for the next Renovate run; do not fake the GitHub App. + +App install (human, if the bot stops opening PRs): https://github.com/apps/renovate + +## OpenSSF Scorecard + +- Workflow job: `Security / OpenSSF Scorecard` with SARIF upload and `publish_results: true` +- Public API `https://api.securityscorecards.dev/projects/github.com/edithatogo/substack-cli-ts` returned **404** on 2026-08-11 (results not in the public weekly index yet). Latest `master` Security runs succeeded (for example run `31478890049`). +- Intentional low/failed checks for a solo repo: + - **Code-Review**: no required human reviewers. Documented exception. + - **Branch-Protection / contributors**: one maintainer; force-push and deletion are off; status checks are on. +- Expected to stay strong: License (Apache-2.0), Security-Policy, CI-Tests, SAST (CodeQL), Dependency-Update-Tool (Renovate), Token-Permissions, Pinned-Dependencies, Dangerous-Workflow, Binary-Artifacts, Fuzzing (after the Fuzz job), Maintained. + +Do not raise required review count to chase the Code-Review score. + +## GitHub security features + +| Feature | Status (2026-08-11) | +| --- | --- | +| Secret scanning | Enabled | +| Push protection | Enabled | +| Secret scanning non-provider patterns | Disabled — enable if the token allows | +| Secret scanning validity checks | Disabled — enable if the token allows | +| Dependabot security updates (PRs) | Disabled on purpose (Renovate) | +| CodeQL default setup | Working (`Analyze (*)` + `CodeQL`) | +| Dependency review | Security workflow, PRs | +| OSV scanner | Security workflow | +| actionlint + zizmor | Workflow Policy job | +| Private vulnerability reporting | `SECURITY.md` | + +Enable leftover Advanced Security toggles (human PAT / repo admin): + +```powershell +gh api -X PATCH repos/edithatogo/substack-cli-ts -f security_and_analysis[secret_scanning_non_provider_patterns][status]=enabled +gh api -X PATCH repos/edithatogo/substack-cli-ts -f security_and_analysis[secret_scanning_validity_checks][status]=enabled +``` + +## Parent-repo conflict + +`OneDrive - Flinders/AGENTS.md` is for `careops-finance`. It does not own this repository. Do not apply its `data/raw` database rules here. diff --git a/docs/ruleset-solo-maintainer.json b/docs/ruleset-solo-maintainer.json new file mode 100644 index 00000000..430a382c --- /dev/null +++ b/docs/ruleset-solo-maintainer.json @@ -0,0 +1,47 @@ +{ + "id": 20603600, + "name": "Solo maintainer default branch", + "target": "branch", + "source": "edithatogo/substack-cli-ts", + "enforcement": "active", + "capturedAt": "2026-08-11T09:45:00Z", + "notes": [ + "required_approving_review_count is 0 on purpose.", + "require_code_owner_review is false. Do not add CODEOWNERS.", + "codecov/patch and codecov/project are intentionally not required so SKIPPED Codecov cannot block automerge.", + "Fuzz and OpenSSF Scorecard are not required merge gates." + ], + "pull_request": { + "required_approving_review_count": 0, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + }, + "restrictions": { + "allow_force_pushes": false, + "allow_deletions": false, + "required_linear_history": true, + "required_signatures": false, + "enforce_admins": true + }, + "required_status_checks": [ + "Analyze (actions)", + "Analyze (javascript-typescript)", + "Dependency Declaration Strictness", + "Index Signature Strictness", + "Node Compatibility (22)", + "Node Compatibility (24)", + "Node Compatibility (26.5.1)", + "Quality", + "Required Audit And Secret Scan", + "SBOM Evidence", + "Strictest TypeScript", + "Smoke", + "Deterministic Assurance Taxonomy", + "Mutation", + "ubuntu-latest / Node 26.5.1", + "windows-latest / Node 26.5.1", + "macos-latest / Node 26.5.1" + ] +} diff --git a/package.json b/package.json index 0cd6d5d9..6f8b25a3 100644 --- a/package.json +++ b/package.json @@ -75,11 +75,15 @@ "test:dst": "vitest run src/test/assurance/simulation.dst.test.ts", "test:cdc": "vitest run src/test/assurance/contract.cdc.test.ts", "test:metamorphic": "vitest run src/test/assurance/metamorphic.test.ts", - "test:agentic": "vitest run src/test/assurance/agentic.test.ts", + "test:agentic": "npm run build && vitest run src/test/assurance/agentic.test.ts", "test:semantic-judge": "vitest run src/test/assurance/semantic-judge.test.ts", "test:vcr": "vitest run src/test/assurance/network.vcr.test.ts", "test:cli-integration": "npm run build && vitest run src/test/assurance/cli.integration.test.ts", "test:schema-fuzz": "vitest run src/test/assurance/schema-zod.fuzz.test.ts", + "test:fuzz": "vitest run src/test/assurance/parser.fuzz.test.ts src/test/assurance/schema-zod.fuzz.test.ts src/test/assurance/property.test.ts", + "test:contract": "vitest run src/schema/fixtures.test.ts src/test/assurance/contract.cdc.test.ts", + "verify:agent": "npm run typecheck && npm test && node dist/cli.js inspect examples/basic.md", + "profile:parser": "npm run build && node --cpu-prof --cpu-prof-dir=reports/profiling dist/cli.js inspect examples/basic.md", "test:taxonomy": "node scripts/validate-testing-taxonomy.mjs", "test:assurance": "npm run test:taxonomy && npm run build && vitest run src/test/assurance src/test/smoke.test.ts && vitest run --config vitest.e2e.config.ts", "typecheck": "tsc --noEmit", diff --git a/scripts/validate-testing-taxonomy.mjs b/scripts/validate-testing-taxonomy.mjs index bcdaae89..3c37ffde 100644 --- a/scripts/validate-testing-taxonomy.mjs +++ b/scripts/validate-testing-taxonomy.mjs @@ -10,6 +10,7 @@ const requiredIds = new Set([ "property-based", "deterministic-simulation", "consumer-driven-contract", "metamorphic", "autonomous-agentic", "semantic-llm-judge", "network-replay-vcr", "tui-cli-integration", "zod-schema-fuzz", + "parser-fuzz", ]); const seen = new Set(); diff --git a/skill.md b/skill.md index 21ec400b..ea60636d 100644 --- a/skill.md +++ b/skill.md @@ -1,23 +1,19 @@ # Repository Skill -Use this repository as a draft-first Substack CLI and MCP project. +Use this repository as a draft-first Substack CLI and MCP project. Full agent contract: `AGENTS.md`. Conductor entry: `conductor/index.md`. Quality map: `docs/quality-frontier.md`. ## How To Work -- Keep CLI and MCP changes aligned. Shared behavior should live in `src/` once, then be exposed through the CLI, MCP server, and tests. -- Update the relevant `tracks/*.md` file when scope changes or a milestone completes. +- Keep CLI and MCP changes aligned. Shared behavior should live in `src/` once. +- Update the relevant Conductor track when scope changes or a milestone completes. - Keep secrets, browser sessions, traces, and `.substack-cli/` out of Git. -- Run `npm run quality` and `npm run scan:secrets` before committing changes. +- After TypeScript changes run `npm run verify:agent`. +- Fail-closed on live Substack writes. Do not add required reviewers or CODEOWNERS. ## Primary References -- `workflow.md` for the high-level delivery strategy. +- `conductor/workflow.md` for delivery rules. - `docs/workflows/cli.md` for command and track work. - `docs/workflows/mcp.md` for MCP surface changes. - `README.md` for user-facing command examples. - -## Editing Rules - -- Prefer small, typed modules over shared ad hoc helpers. -- Keep redacted summaries redacted. -- Treat direct browser/API probing as discovery input, not the default path. +- `.cursor/skills/conductor/SKILL.md` to discover Conductor without re-vendoring the plugin. diff --git a/src/config/store.test.ts b/src/config/store.test.ts index 03c88132..19963cb5 100644 --- a/src/config/store.test.ts +++ b/src/config/store.test.ts @@ -10,7 +10,14 @@ import { saveSession, } from "../auth/session-store.js"; import { configFilePath, sessionFilePath, stateDir } from "./paths.js"; -import { buildOperatorPolicy, loadConfig, loadEffectiveConfig, updateConfig } from "./store.js"; +import { + buildOperatorPolicy, + loadConfig, + loadEffectiveConfig, + parseAppConfig, + safeParseAppConfig, + updateConfig, +} from "./store.js"; describe("config store", () => { it("loads defaults when no config file exists", async () => { @@ -47,6 +54,11 @@ describe("config store", () => { }); }); + it("parses and rejects config JSON without touching disk", () => { + assert.equal(parseAppConfig({}).browserRuntime, "browserbase"); + assert.equal(safeParseAppConfig({ browserRuntime: "netscape" }).success, false); + }); + it("rejects invalid operator modes", async () => { await withTempCwd(async () => { await assert.rejects( diff --git a/src/config/store.ts b/src/config/store.ts index aaebad92..b1711558 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -15,6 +15,14 @@ const AppConfigSchema = z.object({ export type AppConfig = z.infer; export type OperatorMode = AppConfig["operatorMode"]; +export function parseAppConfig(input: unknown): AppConfig { + return AppConfigSchema.parse(input); +} + +export function safeParseAppConfig(input: unknown) { + return AppConfigSchema.safeParse(input); +} + export interface OperatorPolicy { mode: OperatorMode; requiresExplicitConfirmation: boolean; diff --git a/src/test/assurance/agentic.test.ts b/src/test/assurance/agentic.test.ts index 8fb761fa..48b10022 100644 --- a/src/test/assurance/agentic.test.ts +++ b/src/test/assurance/agentic.test.ts @@ -1,7 +1,25 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, it } from "vitest"; import { runAutonomousAgentScenario, type AgentTool } from "../harness/agent-runner.js"; +const root = resolve(import.meta.dirname, "../../.."); +const recipes = JSON.parse( + readFileSync(resolve(root, "tests/agentic/cli-recipes.json"), "utf8"), +) as { + paidLlm: boolean; + recipes: Array<{ + id: string; + argv: string[]; + expectExit: number; + documentType?: string; + title?: string; + status?: string; + }>; +}; + describe("autonomous agentic assurance", () => { it("executes a bounded read-review sequence", async () => { const calls: string[] = []; @@ -55,4 +73,26 @@ describe("autonomous agentic assurance", () => { assert.equal(executed, false); assert.match(result.reason ?? "", /not allowed/); }); + + it("scores documented CLI recipes without calling a paid model", () => { + assert.equal(recipes.paidLlm, false); + const scores = recipes.recipes.map((recipe) => { + const output = execFileSync(process.execPath, ["dist/cli.js", ...recipe.argv], { + cwd: root, + encoding: "utf8", + timeout: 60_000, + }); + const parsed = JSON.parse(output) as { + document?: { type?: string }; + metadata?: { title?: string }; + status?: string; + }; + if (recipe.documentType) assert.equal(parsed.document?.type, recipe.documentType); + if (recipe.title) assert.equal(parsed.metadata?.title, recipe.title); + if (recipe.status) assert.equal(parsed.status, recipe.status); + return { id: recipe.id, passed: true }; + }); + assert.ok(scores.every((score) => score.passed)); + assert.equal(scores.length, recipes.recipes.length); + }); }); diff --git a/src/test/assurance/contract.cdc.test.ts b/src/test/assurance/contract.cdc.test.ts index 79f24d86..15c2d17a 100644 --- a/src/test/assurance/contract.cdc.test.ts +++ b/src/test/assurance/contract.cdc.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; +import { resolve } from "node:path"; import { describe, it } from "vitest"; import { buildMcpSurfaceManifest } from "../../mcp/manifest.js"; +import { compareFixture } from "../../schema/fixtures.js"; + +const root = resolve(import.meta.dirname, "../../.."); describe("MCP consumer-driven contract", () => { it("satisfies the local agent consumer contract", () => { @@ -16,4 +20,16 @@ describe("MCP consumer-driven contract", () => { manifest.resources.length, ); }); + + it("keeps the basic ProseMirror fixture frozen against the parser", async () => { + const result = await compareFixture( + resolve(root, "examples/basic.md"), + resolve(root, "fixtures/prosemirror/basic.json"), + ); + assert.equal( + result.equal, + true, + "Re-capture with: node dist/cli.js schema capture examples/basic.md --out fixtures/prosemirror/basic.json", + ); + }); }); diff --git a/src/test/assurance/edge.test.ts b/src/test/assurance/edge.test.ts index 05a7d4c2..c2dd6cb6 100644 --- a/src/test/assurance/edge.test.ts +++ b/src/test/assurance/edge.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "vitest"; import { parseFrontmatter } from "../../parser/frontmatter.js"; +import { parseMarkdownString } from "../../parser/markdown.js"; +import { simulatePublishGate } from "../harness/deterministic-simulator.js"; describe("edge assurance", () => { it("handles empty, delimiter-like, Unicode and CRLF input deterministically", () => { @@ -17,4 +19,25 @@ describe("edge assurance", () => { it("rejects invalid enum boundaries instead of coercing them", () => { assert.throws(() => parseFrontmatter("---\naudience: administrators\n---\nBody")); }); + + it("parses deeply nested headings without calling Substack", async () => { + const markdown = Array.from( + { length: 8 }, + (_, index) => `${"#".repeat((index % 6) + 1)} H${index}`, + ).join("\n\n"); + const parsed = await parseMarkdownString(markdown); + assert.equal(parsed.document.type, "doc"); + assert.ok((parsed.document.content?.length ?? 0) >= 8); + }); + + it("keeps unconfirmed publish fail-closed", () => { + const result = simulatePublishGate({ + seed: 7, + mode: "publish", + confirmed: false, + dryRun: false, + }); + assert.equal(result.status, "blocked"); + assert.match(result.reason ?? "", /--yes/); + }); }); diff --git a/src/test/assurance/metamorphic.test.ts b/src/test/assurance/metamorphic.test.ts index ff1f6168..21e6e474 100644 --- a/src/test/assurance/metamorphic.test.ts +++ b/src/test/assurance/metamorphic.test.ts @@ -17,4 +17,23 @@ describe("metamorphic parser assurance", () => { const transformed = await parseMarkdownString("# Heading\n\nParagraph\n\n\n"); assert.deepEqual(transformed.document, base.document); }); + + it("is idempotent: parsing the same markdown twice matches", async () => { + const source = "# Once\n\nA [link](https://example.com/path) and **bold**."; + const first = await parseMarkdownString(source); + const second = await parseMarkdownString(source); + assert.deepEqual(second.document, first.document); + assert.equal(second.html, first.html); + }); + + it("preserves heading text and link targets", async () => { + const parsed = await parseMarkdownString( + "# Release notes\n\nSee [docs](https://example.com/docs).", + ); + const json = JSON.stringify(parsed.document); + assert.match(json, /Release notes/); + assert.match(json, /https:\/\/example.com\/docs/); + assert.match(parsed.html, /Release notes/); + assert.match(parsed.html, /https:\/\/example.com\/docs/); + }); }); diff --git a/src/test/assurance/parser.fuzz.test.ts b/src/test/assurance/parser.fuzz.test.ts new file mode 100644 index 00000000..a2fd64ae --- /dev/null +++ b/src/test/assurance/parser.fuzz.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import fc from "fast-check"; +import { describe, it } from "vitest"; +import { safeParseAppConfig } from "../../config/store.js"; +import { parseFrontmatter } from "../../parser/frontmatter.js"; +import { parseMarkdownString } from "../../parser/markdown.js"; +import { validateProseMirrorDocument } from "../../parser/schema.js"; + +const numRuns = Number(process.env.FUZZ_RUNS ?? 80); + +describe("parser and config fuzzing", () => { + it("never throws unstructured errors for arbitrary front matter text", () => { + fc.assert( + fc.property(fc.string({ maxLength: 200 }), (source) => { + try { + const parsed = parseFrontmatter(source); + assert.ok(Array.isArray(parsed.metadata.tags)); + assert.equal(typeof parsed.body, "string"); + } catch (error) { + assert.ok(error instanceof Error); + } + }), + { seed: 20_260_811, numRuns }, + ); + }); + + it("round-trips generated safe tag lists", () => { + fc.assert( + fc.property( + fc.array(fc.stringMatching(/^[A-Za-z0-9_-]{1,12}$/), { maxLength: 8 }), + (tags) => { + const parsed = parseFrontmatter(`---\ntags: ${tags.join(", ")}\n---\nBody`); + assert.deepEqual(parsed.metadata.tags, tags); + }, + ), + { seed: 20_260_812, numRuns }, + ); + }); + + it("produces a ProseMirror document or a structured failure for markdown", async () => { + await fc.assert( + fc.asyncProperty(fc.stringMatching(/^[#>*`\sA-Za-z0-9._-]{0,120}$/), async (body) => { + const parsed = await parseMarkdownString(`---\ntitle: Fuzz\n---\n${body}`); + assert.equal(validateProseMirrorDocument(parsed.document).type, "doc"); + }), + { seed: 20_260_813, numRuns }, + ); + }); + + it("accepts or rejects arbitrary JSON as app config without hanging", () => { + fc.assert( + fc.property(fc.jsonValue(), (value) => { + const result = safeParseAppConfig(value); + assert.equal(typeof result.success, "boolean"); + }), + { seed: 20_260_814, numRuns }, + ); + }); + + it("rejects unknown browser runtimes and accepts documented ones", () => { + fc.assert( + fc.property( + fc.constantFrom("browserbase", "local", "camoufox"), + fc.stringMatching(/^[a-z]{3,12}$/), + (runtime, noise) => { + assert.equal(safeParseAppConfig({ browserRuntime: runtime }).success, true); + if (noise !== runtime) { + assert.equal(safeParseAppConfig({ browserRuntime: noise }).success, false); + } + }, + ), + { seed: 20_260_815, numRuns: Math.min(numRuns, 40) }, + ); + }); +}); diff --git a/src/test/assurance/property.test.ts b/src/test/assurance/property.test.ts index 6027b7f6..6b63300a 100644 --- a/src/test/assurance/property.test.ts +++ b/src/test/assurance/property.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import fc from "fast-check"; import { describe, it } from "vitest"; +import { safeParseAppConfig } from "../../config/store.js"; import { parseFrontmatter } from "../../parser/frontmatter.js"; +import { parseMarkdownString } from "../../parser/markdown.js"; + +const numRuns = Number(process.env.FUZZ_RUNS ?? 120); describe("property-based assurance", () => { it("normalizes every generated safe tag list without empty values", () => { @@ -14,7 +18,47 @@ describe("property-based assurance", () => { assert.ok(parsed.metadata.tags.every((tag) => tag.length > 0)); }, ), - { seed: 13_013, numRuns: 200 }, + { seed: 13_013, numRuns }, + ); + }); + + it("keeps a missing front-matter document as the entire body", () => { + fc.assert( + fc.property( + fc.stringMatching(/^[A-Za-z0-9 \n]{0,80}$/).filter((body) => !body.startsWith("---")), + (body) => { + const parsed = parseFrontmatter(body); + assert.equal(parsed.body, body); + assert.deepEqual(parsed.metadata.tags, []); + }, + ), + { seed: 13_015, numRuns }, + ); + }); + + it("always emits a doc node for heading-only markdown", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 4 }), + fc.stringMatching(/^[A-Za-z]{1,24}$/), + async (depth, title) => { + const parsed = await parseMarkdownString(`${"#".repeat(depth)} ${title}\n`); + assert.equal(parsed.document.type, "doc"); + assert.equal(parsed.document.content?.[0]?.type, "heading"); + }, + ), + { seed: 13_016, numRuns }, + ); + }); + + it("treats operatorMode as a closed enum", () => { + fc.assert( + fc.property(fc.constantFrom("solo", "team", "agency", "ci"), (mode) => { + const parsed = safeParseAppConfig({ operatorMode: mode }); + assert.equal(parsed.success, true); + assert.equal(safeParseAppConfig({ operatorMode: `${mode}-x` }).success, false); + }), + { seed: 13_017, numRuns: 40 }, ); }); }); diff --git a/src/test/assurance/simulation.dst.test.ts b/src/test/assurance/simulation.dst.test.ts index 10b7bcff..81b8d0da 100644 --- a/src/test/assurance/simulation.dst.test.ts +++ b/src/test/assurance/simulation.dst.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "vitest"; -import { simulateRetrySchedule } from "../harness/deterministic-simulator.js"; +import { simulatePublishGate, simulateRetrySchedule } from "../harness/deterministic-simulator.js"; describe("deterministic simulation testing", () => { it("replays the same virtual timeline for the same seed", () => { @@ -19,4 +19,33 @@ describe("deterministic simulation testing", () => { assert.equal(result.status, "exhausted"); assert.equal(result.events.length, 3); }); + + it("blocks unconfirmed publish and schedule for the same seed", () => { + const publish = simulatePublishGate({ + seed: 20260811, + mode: "publish", + confirmed: false, + dryRun: false, + }); + const again = simulatePublishGate({ + seed: 20260811, + mode: "publish", + confirmed: false, + dryRun: false, + }); + assert.deepEqual(publish, again); + assert.equal(publish.status, "blocked"); + assert.equal( + simulatePublishGate({ seed: 1, mode: "schedule", confirmed: false, dryRun: false }).status, + "blocked", + ); + assert.equal( + simulatePublishGate({ seed: 1, mode: "publish", confirmed: false, dryRun: true }).status, + "dry-run", + ); + assert.equal( + simulatePublishGate({ seed: 1, mode: "draft", confirmed: false, dryRun: false }).status, + "allowed", + ); + }); }); diff --git a/src/test/harness/deterministic-simulator.ts b/src/test/harness/deterministic-simulator.ts index 2b095fd1..0860850c 100644 --- a/src/test/harness/deterministic-simulator.ts +++ b/src/test/harness/deterministic-simulator.ts @@ -12,6 +12,39 @@ export interface SimulationResult { events: SimulationEvent[]; } +export type PublishMode = "draft" | "publish" | "schedule"; + +export interface PublishGateResult { + seed: number; + mode: PublishMode; + status: "allowed" | "blocked" | "dry-run"; + reason?: string; +} + +export function simulatePublishGate(options: { + seed: number; + mode: PublishMode; + confirmed: boolean; + dryRun: boolean; +}): PublishGateResult { + const { seed, mode, confirmed, dryRun } = options; + if (!Number.isInteger(seed)) { + throw new Error("Publish-gate simulation requires an integer seed."); + } + if (dryRun) { + return { seed, mode, status: "dry-run", reason: "dry-run does not mutate Substack" }; + } + if ((mode === "publish" || mode === "schedule") && !confirmed) { + return { + seed, + mode, + status: "blocked", + reason: "Publishing and scheduling require --yes. Run with --dry-run first.", + }; + } + return { seed, mode, status: "allowed" }; +} + export function simulateRetrySchedule( seed: number, statuses: readonly number[], diff --git a/test/testing-taxonomy.json b/test/testing-taxonomy.json index 896ad119..186099b2 100644 --- a/test/testing-taxonomy.json +++ b/test/testing-taxonomy.json @@ -11,12 +11,13 @@ { "id": "mutation", "name": "Mutation testing", "script": "test:mutation", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["stryker.config.json"] }, { "id": "property-based", "name": "Property-based testing", "script": "test:property", "tier": "fast", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/property.test.ts"] }, { "id": "deterministic-simulation", "name": "Deterministic Simulation (DST) testing", "script": "test:dst", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/simulation.dst.test.ts", "src/test/harness/deterministic-simulator.ts"] }, - { "id": "consumer-driven-contract", "name": "Contract Testing (CDC)", "script": "test:cdc", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/contract.cdc.test.ts"] }, + { "id": "consumer-driven-contract", "name": "Contract Testing (CDC)", "script": "test:cdc", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/contract.cdc.test.ts", "src/schema/fixtures.test.ts"] }, { "id": "metamorphic", "name": "Metamorphic testing", "script": "test:metamorphic", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/metamorphic.test.ts"] }, { "id": "autonomous-agentic", "name": "Autonomous Agentic testing", "script": "test:agentic", "tier": "deep", "deterministic": true, "applicability": "required-synthetic", "evidence": ["src/test/assurance/agentic.test.ts", "src/test/harness/agent-runner.ts"] }, { "id": "semantic-llm-judge", "name": "Semantic LLM-as-a-Judge testing", "script": "test:semantic-judge", "tier": "deep", "deterministic": true, "applicability": "required-recorded-adapter; live-model-canary", "evidence": ["src/test/assurance/semantic-judge.test.ts", "src/test/harness/semantic-judge.ts"] }, { "id": "network-replay-vcr", "name": "Network Interaction Replay (VCR) testing", "script": "test:vcr", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/network.vcr.test.ts", "src/substack-api/fixture-transport.ts"] }, { "id": "tui-cli-integration", "name": "TUI / CLI Integration testing", "script": "test:cli-integration", "tier": "fast", "deterministic": true, "applicability": "CLI-required; TUI-not-applicable-no-TUI", "evidence": ["src/test/assurance/cli.integration.test.ts"] }, - { "id": "zod-schema-fuzz", "name": "Schema Fuzzing (Zod-based)", "script": "test:schema-fuzz", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/schema-zod.fuzz.test.ts", "src/contracts/schemas.ts"] } + { "id": "zod-schema-fuzz", "name": "Schema Fuzzing (Zod-based)", "script": "test:schema-fuzz", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/schema-zod.fuzz.test.ts", "src/contracts/schemas.ts"] }, + { "id": "parser-fuzz", "name": "Parser and config fuzzing", "script": "test:fuzz", "tier": "deep", "deterministic": true, "applicability": "required", "evidence": ["src/test/assurance/parser.fuzz.test.ts", "src/test/assurance/schema-zod.fuzz.test.ts"] } ] } diff --git a/tests/agentic/cli-recipes.json b/tests/agentic/cli-recipes.json new file mode 100644 index 00000000..aaf4ba2a --- /dev/null +++ b/tests/agentic/cli-recipes.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "paidLlm": false, + "recipes": [ + { + "id": "inspect-basic", + "argv": ["inspect", "examples/basic.md"], + "expectExit": 0, + "documentType": "doc", + "title": "Example Substack Draft" + }, + { + "id": "prepublish-basic", + "argv": ["prepublish", "examples/basic.md"], + "expectExit": 0, + "status": "ready" + } + ] +}