diff --git a/apps/claude-code/unic-spec-review/.claude-plugin/marketplace.json b/apps/claude-code/unic-spec-review/.claude-plugin/marketplace.json index 704c156d..88634c5e 100644 --- a/apps/claude-code/unic-spec-review/.claude-plugin/marketplace.json +++ b/apps/claude-code/unic-spec-review/.claude-plugin/marketplace.json @@ -21,7 +21,7 @@ "name": "unic-spec-review", "source": "./", "tags": ["productivity", "quality"], - "version": "0.1.0" + "version": "0.1.1" } ] } diff --git a/apps/claude-code/unic-spec-review/.claude-plugin/plugin.json b/apps/claude-code/unic-spec-review/.claude-plugin/plugin.json index 405b864a..03dbd62b 100644 --- a/apps/claude-code/unic-spec-review/.claude-plugin/plugin.json +++ b/apps/claude-code/unic-spec-review/.claude-plugin/plugin.json @@ -3,11 +3,12 @@ "name": "Unic AG", "url": "https://www.unic.com" }, + "agents": ["./agents/gaps-agent.md"], "commands": ["./commands/review-spec.md", "./commands/spec-doctor.md", "./commands/setup-confluence.md"], "description": "Adversarial review of web specifications across Confluence pages & comments, Figma designs, and the live system. Produces Confidence-scored, Six-Hats-tagged Findings with an interactive Approval Loop that posts selected comments back to Confluence.", "homepage": "https://github.com/unic/unic-agents-plugins", "keywords": ["spec-review", "confluence", "figma", "adversarial-review", "six-hats", "unic"], "license": "LGPL-3.0-or-later", "name": "unic-spec-review", - "version": "0.1.0" + "version": "0.1.1" } diff --git a/apps/claude-code/unic-spec-review/AGENTS.md b/apps/claude-code/unic-spec-review/AGENTS.md index 344192fd..ae2be8ea 100644 --- a/apps/claude-code/unic-spec-review/AGENTS.md +++ b/apps/claude-code/unic-spec-review/AGENTS.md @@ -6,7 +6,7 @@ Guidance for any AI agent working inside this Plugin directory. `CLAUDE.md` in t `unic-spec-review` is a Claude Code Plugin in the [`unic-agents-plugins`](../../../AGENTS.md) monorepo. It runs an adversarial review of web specifications across four sources (Confluence pages & comments, Figma designs via the Dev Mode MCP, the live production system via the Playwright MCP, and the local repo) and emits Confidence-scored, Six-Hats-tagged Findings. An interactive Approval Loop, gated behind `--post`, publishes selected Findings as Confluence comments. -> Status: scaffolding only. The command/agent logic is specified in the PRD under `docs/issues/` and not yet implemented. +> Status: S1 skeleton implemented (URL classify → Confluence fetch → Gaps agent → report). Full design (traversal, Figma, live-system, Approval Loop, de-dup) is specified in the [PRD](docs/issues/unic-spec-review/PRD.md) and lands in later slices. ## Where to start @@ -40,8 +40,8 @@ pnpm bump # bump plugin.json version + promote CHANGELOG pnpm sync-version # mirror plugin.json version into marketplace.json + package.json pnpm tag # create the unic-spec-review@ git tag locally pnpm verify:changelog # check CHANGELOG entry for the current version +pnpm test # run node:test suite +pnpm typecheck # tsc --noEmit type check ``` -> `pnpm test` and `pnpm typecheck` (plus `tsconfig.json` and the `scripts/`/`tests/` dirs) will be added back once the first `.mjs` script is vendored during implementation. The plugin is command-only at scaffold time. - Monorepo-wide commands (`pnpm install`, `pnpm check`, `pnpm format`, `pnpm ci:check`) are documented in the [root AGENTS.md](../../../AGENTS.md). diff --git a/apps/claude-code/unic-spec-review/CHANGELOG.md b/apps/claude-code/unic-spec-review/CHANGELOG.md index 08c64ec3..cdf45147 100644 --- a/apps/claude-code/unic-spec-review/CHANGELOG.md +++ b/apps/claude-code/unic-spec-review/CHANGELOG.md @@ -8,13 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Breaking - - (none) ### Added +- Cover two previously untested paths flagged in PR #210 review: `loadAtlassianCreds` preferring env vars over a present credentials file, and `fetchConfluencePage` capping the stripped excerpt at 800 characters. + +### Fixed +- Reject non-http(s) URLs in arg parsing, link classification, and validate `pageTitle`/`pageUrl` in the report-renderer CLI entry, so ftp/file/mailto inputs no longer slip through and missing report fields no longer render as literal `undefined`. +- Make `/review-spec` orchestration portable: write the scratch report JSON into the gitignored `.spec-review/` directory instead of the POSIX-only `/tmp` path (broke on Windows CI), and surface the structured `errors[].kind`/`errors[].message` from the fetch script so the real failure cause is shown. + +### Documentation +- Correct stale cross-plugin references in code comments (drop the `render-summary.mjs`, `doctor.mjs`, and inaccurate `ADR-0001` citations), reword the `CONTEXT.md` status line so it no longer promises an unused `(S1)` per-term marking convention, and replace em dashes with hyphens in authored comments and messages per the org typography rule. + +## [0.1.1] — 2026-06-05 + +### Breaking - (none) +### Added + +- Vendor `atlassian-fetch.mjs` and `credentials.mjs` from `unic-pr-review` (ADR-0001 self-containment); only the Confluence page-read path is used in this slice. +- Add `link-classifier` module: routes a pasted URL to `confluence` / `figma-page` / `figma-frame` / `live` / `unknown` and extracts the Confluence page id. +- Add `args` module: parses `/review-spec` arguments (URL list + `--post` flag recognition). +- Add `report-renderer` module: renders a timestamped markdown report and writes it to `.spec-review/` (gitignored). +- Add `gaps-agent` (Gaps/Completeness dimension agent): inspects a Confluence page for missing states, undefined behaviour, and absent acceptance criteria. +- Implement `/review-spec` S1 skeleton: classify URL, fetch one Confluence page, run Gaps agent, print findings, write report. +- Restore test harness: `pnpm test` and `pnpm typecheck` scripts, `tsconfig.json`, `scripts/` and `tests/` directories. + ### Fixed - (none) diff --git a/apps/claude-code/unic-spec-review/CONTEXT.md b/apps/claude-code/unic-spec-review/CONTEXT.md index 745df9ee..979756d9 100644 --- a/apps/claude-code/unic-spec-review/CONTEXT.md +++ b/apps/claude-code/unic-spec-review/CONTEXT.md @@ -2,7 +2,7 @@ Domain vocabulary for the `unic-spec-review` plugin. This is a single bounded context inside the [monorepo context map](../../../CONTEXT-MAP.md). -> Status: scaffolding. The behaviour behind these terms is specified in [`docs/issues/unic-spec-review/PRD.md`](docs/issues/unic-spec-review/PRD.md) and not yet implemented. +> Status: S1 implements classify -> Confluence fetch -> Gaps agent -> report. The rest of the vocabulary below describes the full design specified in [`docs/issues/unic-spec-review/PRD.md`](docs/issues/unic-spec-review/PRD.md). ## Vocabulary diff --git a/apps/claude-code/unic-spec-review/README.md b/apps/claude-code/unic-spec-review/README.md index 1a337e30..ae68cae0 100644 --- a/apps/claude-code/unic-spec-review/README.md +++ b/apps/claude-code/unic-spec-review/README.md @@ -6,4 +6,4 @@ Findings are presented for triage first. With `--post`, an interactive Approval The plugin is fully self-contained: it ships its own `/setup-confluence` wizard and vendored credential handling, so it can be installed and used without any other plugin. It stores credentials in `~/.unic-confluence.json` (the same convention `unic-pr-review` uses, or `CONFLUENCE_*` env vars), so a user with both plugins configures Confluence once. Figma access is via the Figma Dev Mode MCP; live-system access is via the Playwright MCP. Both are discovered at runtime. -> Status: scaffolding. Behaviour is specified in the PRD (`docs/issues/`) and not yet implemented. +> **S1 available:** pass a single Confluence page URL to run a Gaps/Completeness review. Full multi-source adversarial review (Figma, live system, Approval Loop) lands in later slices; see `docs/issues/` for the roadmap. diff --git a/apps/claude-code/unic-spec-review/agents/gaps-agent.md b/apps/claude-code/unic-spec-review/agents/gaps-agent.md new file mode 100644 index 00000000..1c84fe84 --- /dev/null +++ b/apps/claude-code/unic-spec-review/agents/gaps-agent.md @@ -0,0 +1,64 @@ +--- +name: gaps-agent +description: Gaps/Completeness Agent. Inspects a Confluence spec page for missing states, undefined behaviour, and absent acceptance criteria. Emits structured Findings with Confidence Scores. +model: opus +color: yellow +--- + +# Gaps / Completeness Agent + +You are the Gaps/Completeness reviewer for `unic-spec-review`. + +You receive a Confluence spec page (title, URL, and text content). Your job is to identify **gaps and completeness issues**: places where the specification is under-specified, silent, or missing. + +Return your output as a single JSON object with the shape below. Output ONLY that JSON object: no prose, no markdown fence, no explanation. + +## What to look for + +- **Missing states:** what happens when a user does X but condition Y is not met? +- **Undefined behaviour:** actions the spec describes but does not define the outcome of. +- **Missing acceptance criteria:** features or flows with no verifiable success condition. +- **Silent error handling:** the spec says a step happens but never says what happens on failure. +- **Absent edge cases:** boundary conditions the spec does not address (empty state, zero results, maximum limits). +- **Incomplete user journeys:** a flow that starts but has no described end or escape. + +## Confidence-Score rubric + +Every Finding must carry a Confidence Score from 0 to 100. Drop any Finding below 60; do not include it. + +| Range | Severity | When to use | +| ------ | --------- | ------------------------------------------------------- | +| 90-100 | critical | Certain gap that will cause ambiguity or build failures | +| 80-89 | important | High confidence; the spec is definitely incomplete here | +| 60-79 | minor | Real observation but minor or low-impact | +| < 60 | Drop | Do not emit | + +## Input format + +The calling command provides: + +```json +{ + "pageTitle": "...", + "pageUrl": "https://...", + "pageContent": "..." +} +``` + +## Output format + +```json +{ + "findings": [ + { + "title": "short imperative title", + "description": "one or two sentences explaining the gap and its impact", + "severity": "critical | important | minor", + "confidence": 85, + "anchor": "exact verbatim phrase from the spec text where the gap is located, or null" + } + ] +} +``` + +If the spec has no gaps, return `{ "findings": [] }`. diff --git a/apps/claude-code/unic-spec-review/commands/review-spec.md b/apps/claude-code/unic-spec-review/commands/review-spec.md index 09db1843..d8e7819b 100644 --- a/apps/claude-code/unic-spec-review/commands/review-spec.md +++ b/apps/claude-code/unic-spec-review/commands/review-spec.md @@ -1,10 +1,129 @@ --- -description: Adversarial review of web specifications (Confluence + Figma + live system). Read-only by default; --post enables the Approval Loop. -argument-hint: '[confluence/figma/live URLs…] [--post]' +allowed-tools: Agent, Bash(node *), Write +argument-hint: ' [--post]' +description: Adversarial review of web specifications (Confluence). Read-only by default; --post enables the Approval Loop (inert in S1). --- -# Review Spec +# /review-spec (S1 Skeleton) -> ⚠️ Scaffold stub: not yet implemented. Behaviour is specified in the [PRD](../docs/issues/unic-spec-review/PRD.md) (#200); the command body lands with its implementation slice. +Runs a read-only Gaps/Completeness review of one Confluence spec page, prints ranked Findings, and writes a durable timestamped report under `.spec-review/`. -This command will run an adversarial, Six-Hats-tagged review of a web specification and (with `--post`) publish selected Findings as Confluence comments. See `AGENTS.md` for the locked design. +> **S1 scope:** one source, one page, one agent. No traversal, no comments, no Figma, no live-system, no posting. `--post` is recognised but inert. + +## Step 1 - Parse arguments + +Split `$ARGUMENTS` on whitespace. Collect tokens that parse as valid `http://` or `https://` URLs into `URLS`. Set `IS_POST=true` when `--post` appears. + +If `URLS` is empty, stop with: + +``` +Usage: /review-spec [--post] +Example: /review-spec https://yoursite.atlassian.net/wiki/spaces/X/pages/123456/Title +``` + +Use `URLS[0]` as `TARGET_URL`. + +## Step 2 - Classify the URL + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/link-classifier.mjs" "$TARGET_URL" +``` + +Parse the JSON output. If `kind` is not `"confluence"`, stop with: + +``` +S1 supports only Confluence page URLs. +Got kind: for + +S1 recognises: /wiki/spaces/SPACE/pages/ID/Title or ?pageId=ID +``` + +Store `PAGE_ID` from the output. + +## Step 3 - Fetch the Confluence page + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/atlassian-fetch.mjs" --urls "$TARGET_URL" +``` + +Parse the JSON from stdout. The fetch script writes its JSON to stdout **before** exiting non-zero, so a non-zero exit together with the `url === ''` auth-error sentinel is the expected credentials-missing signal - prefer the friendly "credentials not configured" message below over a raw command-failure report. + +- If `errors` contains an entry where `kind === 'auth-error'` AND `url === ''`, stop with: + ``` + Confluence credentials not configured. + Run /unic-spec-review:setup-confluence to add them. + ``` +- Otherwise, if `errors` contains any entry whose `url === TARGET_URL` (or `url === ''`), stop and print that entry's `errors[0].kind` and `errors[0].message` verbatim, so the real cause (`parse-error` / `unreachable` / `not-found` / `auth-error`) is shown: + ``` + Could not fetch the Confluence page. + : + ``` +- If `items` is empty (and no matching error was reported), stop and report that the page returned no content. + +Extract from `items[0]`: + +- `PAGE_TITLE` = `title` +- `PAGE_CONTENT` = `excerpt` (first 800 chars of the page body, HTML-stripped) + +## Step 4 - Run the Gaps/Completeness agent + +Use the Agent tool to spawn `unic-spec-review:gaps-agent`. Pass as its prompt input: + +```json +{ + "pageTitle": "", + "pageUrl": "", + "pageContent": "" +} +``` + +Wait for the agent. Parse its JSON response to get the `findings` array. If the response is not valid JSON, print the raw output plus the parse error and stop. + +## Step 5 - Print findings conversationally + +Sort findings by `confidence` descending (highest first). + +For each finding, present: + +``` +[severity] Finding: (confidence: X%) +<description> +Anchor: <anchor text, if present> +``` + +If `findings` is empty: + +``` +No gaps or completeness issues found in this spec. +``` + +## Step 6 - Write the report + +Construct the report input object: + +```json +{ + "pageTitle": "<PAGE_TITLE>", + "pageUrl": "<TARGET_URL>", + "timestamp": "<current ISO timestamp>", + "findings": [<findings array>] +} +``` + +Use the **Write tool** to write this JSON to `.spec-review/.report-input.json` (the `.spec-review/` directory is gitignored, so the scratch file stays out of git; writing via the tool avoids shell-quoting issues with apostrophes in page titles, and the path is portable across macOS, Windows, and Linux). Then run: + +```bash +REPORT_OUTPUT_DIR=".spec-review" node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/report-renderer.mjs" .spec-review/.report-input.json +``` + +The script prints the path to the written file. Report it to the user: + +``` +Report written: .spec-review/spec-review-YYYY-MM-DDTHH-MM-SS.md +``` + +If `IS_POST` was true, note: + +``` +Note: --post is not yet active in S1. The report has been saved locally only. +``` diff --git a/apps/claude-code/unic-spec-review/package.json b/apps/claude-code/unic-spec-review/package.json index 41ab1af7..8e563da4 100644 --- a/apps/claude-code/unic-spec-review/package.json +++ b/apps/claude-code/unic-spec-review/package.json @@ -1,6 +1,6 @@ { "name": "unic-spec-review", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "LGPL-3.0-or-later", "type": "module", @@ -13,9 +13,14 @@ "bump": "unic-bump", "sync-version": "unic-sync-version", "tag": "unic-tag", + "test": "node --test tests/*.test.mjs", + "typecheck": "tsc --noEmit --project tsconfig.json", "verify:changelog": "unic-verify-changelog" }, "devDependencies": { - "@unic/release-tools": "workspace:*" + "@types/node": "catalog:", + "@unic/release-tools": "workspace:*", + "@unic/tsconfig": "workspace:*", + "typescript": "catalog:" } } diff --git a/apps/claude-code/unic-spec-review/scripts/atlassian-fetch.mjs b/apps/claude-code/unic-spec-review/scripts/atlassian-fetch.mjs new file mode 100644 index 00000000..1fe2c9cb --- /dev/null +++ b/apps/claude-code/unic-spec-review/scripts/atlassian-fetch.mjs @@ -0,0 +1,635 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: LGPL-3.0-or-later +// @ts-check +// Copyright © 2026 Unic + +/** + * atlassian-fetch.mjs - fetch Confluence page content (and optionally Jira + * issue data) for spec reviews. + * + * Pure-function library plus a thin CLI entry point. Given a list of pasted + * URLs it routes each by path (`/browse/` -> Jira, `/wiki/` -> Confluence), + * fetches the linked page / issue via the Atlassian REST APIs using the + * built-in global `fetch` (Node 22+), and normalises the response into + * structures the `/review-spec` command and Gaps agent can consume. + * + * Credentials come from `lib/credentials.mjs` (env vars override the file). + * Every HTTP call uses Basic auth (email:token) with a hard 15 s timeout. + * + * The fetch helpers accept an injectable `fetch` (via `deps.fetch`) so unit + * tests can stub HTTP without mocking globalThis. + * + * Note: Jira exports (fetchJiraIssue, parseJiraACs, parseJiraBug, + * extractJiraKey) are vendored from unic-pr-review but untested in this + * plugin - coverage lives in unic-pr-review. + */ + +import { Buffer } from 'node:buffer' +import { pathToFileURL } from 'node:url' +import { loadAtlassianCreds } from './lib/credentials.mjs' + +/** @import { AtlassianCreds, Env } from './lib/credentials.mjs' */ + +/** + * @typedef {'jira' | 'confluence' | null} UrlRoute + */ + +/** + * @typedef {(url: string, options?: any) => Promise<{ ok: boolean, status: number, json: () => Promise<any> }>} FetchLike + */ + +/** + * @typedef {Object} JiraItem + * @property {'jira'} source + * @property {string} id - the issue key (e.g. "PROJ-42") + * @property {'story' | 'bug' | 'other'} type + * @property {string} url - the originally pasted URL + * @property {string} summary + * @property {string[]} acs - acceptance criteria (Stories only; [] otherwise) + * @property {string} repro - reproduction steps (Bugs only; '' otherwise) + * @property {string} expected - expected behaviour (Bugs only; '' otherwise) + * @property {string} actual - actual behaviour (Bugs only; '' otherwise) + * @property {string[]} confluenceLinks - absolute Confluence URLs found in the issue body + */ + +/** + * @typedef {Object} ConfluenceItem + * @property {'confluence'} source + * @property {string} id - the page id + * @property {string} url - the originally pasted URL + * @property {string} title + * @property {string} excerpt - first 800 chars of the page body, HTML stripped + * @property {string[]} linkedUrls - Confluence `/wiki/` hrefs found in the body + */ + +/** + * @typedef {'unreachable' | 'not-found' | 'auth-error' | 'parse-error' | 'unsupported'} FetchErrorKind + */ + +/** + * @typedef {Object} FetchErrorJson + * @property {string} url + * @property {FetchErrorKind} kind + * @property {string} message + */ + +/** + * @typedef {Object} FetchOutput + * @property {(JiraItem | ConfluenceItem)[]} items + * @property {FetchErrorJson[]} errors + */ + +/** + * Structured fetch failure. The `kind` discriminator lets the caller (Gaps agent / + * `/review-spec` command) decide whether to hard-stop: `unreachable` and + * `auth-error` on a promised source abort the review; `not-found` is softer. + */ +export class FetchError extends Error { + /** + * @param {string} url + * @param {FetchErrorKind} kind + * @param {string} message + */ + constructor(url, kind, message) { + super(message) + this.name = 'FetchError' + this.url = url + this.kind = kind + } +} + +export const FETCH_TIMEOUT_MS = 15_000 + +/** + * @param {string} url + * @returns {URL | null} + */ +function tryParseUrl(url) { + try { + return new URL(url) + } catch { + return null + } +} + +/** + * Route a pasted URL by its path. `/browse/` → Jira, `/wiki/` → Confluence. + * Anything else (including malformed URLs and ADO Boards links) returns null. + * @param {string} url + * @returns {UrlRoute} + */ +export function routeUrl(url) { + const parsed = tryParseUrl(url) + if (!parsed) return null + if (parsed.pathname.includes('/browse/')) return 'jira' + if (parsed.pathname.includes('/wiki/')) return 'confluence' + return null +} + +/** + * Extract a Jira issue key (e.g. "PROJ-42") from a `/browse/KEY-123` URL. + * @param {string} url + * @returns {string | null} + */ +export function extractJiraKey(url) { + const parsed = tryParseUrl(url) + if (!parsed) return null + const m = parsed.pathname.match(/\/browse\/([A-Z][A-Z0-9]+-\d+)/) + return m ? m[1] : null +} + +/** + * Extract a Confluence page id from either a modern `/pages/123456/` path or a + * legacy `viewpage.action?pageId=123456` query string. + * @param {string} url + * @returns {string | null} + */ +export function extractConfluencePageId(url) { + const parsed = tryParseUrl(url) + if (!parsed) return null + const pathMatch = parsed.pathname.match(/\/pages\/(\d+)/) + if (pathMatch) return pathMatch[1] + const queryId = parsed.searchParams.get('pageId') + return queryId && /^\d+$/.test(queryId) ? queryId : null +} + +/** + * Build the base64 portion of a Basic-auth header from email + API token. + * Standard HTTP Basic auth: base64(email:token). + * @param {string} username + * @param {string} token + * @returns {string} + */ +export function buildBasicAuth(username, token) { + return Buffer.from(`${username}:${token}`).toString('base64') +} + +/** + * Concatenate the visible text of an ADF (Atlassian Document Format) node. + * @param {any} node + * @returns {string} + */ +function nodeText(node) { + if (!node) return '' + if (typeof node.text === 'string') return node.text + if (Array.isArray(node.content)) return node.content.map(nodeText).join('') + return '' +} + +/** + * Parse acceptance criteria from a Jira Story description. + * + * Cloud instances send ADF JSON; older Server/Data Center instances send a + * plain string. Both are handled: for ADF, find a heading containing + * "acceptance" and collect the list items that follow until the next heading; + * for a string, collect bulleted/numbered lines after an "acceptance criteria" + * line. + * + * @param {unknown} description - ADF object or plain string + * @returns {string[]} + */ +export function parseJiraACs(description) { + if (description == null) return [] + if (typeof description === 'string') return parseAcsFromString(description) + if (typeof description !== 'object') return [] + const adf = /** @type {any} */ (description) + const content = Array.isArray(adf.content) ? adf.content : [] + /** @type {string[]} */ + const acs = [] + let collecting = false + for (const node of content) { + if (node?.type === 'heading') { + collecting = nodeText(node).toLowerCase().includes('acceptance') + continue + } + if (!collecting) continue + if (node?.type === 'bulletList' || node?.type === 'orderedList') { + for (const listItem of node.content ?? []) { + const text = nodeText(listItem).trim() + if (text) acs.push(text) + } + } + } + return acs +} + +/** + * @param {string} text + * @returns {string[]} + */ +function parseAcsFromString(text) { + const lines = text.split(/\r?\n/) + /** @type {string[]} */ + const acs = [] + let collecting = false + for (const line of lines) { + const trimmed = line.trim() + if (/acceptance criteria/i.test(trimmed)) { + collecting = true + continue + } + if (!collecting) continue + const listMatch = trimmed.match(/^(?:[-*•]|\d+[.)])\s+(.*)$/) + if (listMatch) { + acs.push(listMatch[1].trim()) + continue + } + if (trimmed === '') continue + if (/^AC\b/i.test(trimmed)) { + acs.push(trimmed) + continue + } + break + } + return acs +} + +/** + * Group an ADF document into { heading, body } sections. + * @param {any} adf + * @returns {{ heading: string, body: string }[]} + */ +function sectionsByHeading(adf) { + const content = Array.isArray(adf?.content) ? adf.content : [] + /** @type {{ heading: string, bodyParts: string[] }[]} */ + const sections = [] + /** @type {{ heading: string, bodyParts: string[] } | null} */ + let current = null + for (const node of content) { + if (node?.type === 'heading') { + current = { heading: nodeText(node), bodyParts: [] } + sections.push(current) + } else if (current) { + const text = nodeText(node).trim() + if (text) current.bodyParts.push(text) + } + } + return sections.map((s) => ({ heading: s.heading, body: s.bodyParts.join('\n') })) +} + +/** + * @param {{ heading: string, body: string }[]} sections + * @param {RegExp} re + * @returns {string} + */ +function findSection(sections, re) { + const match = sections.find((s) => re.test(s.heading)) + return match ? match.body : '' +} + +/** + * Coerce an ADF object or a plain string field into text. + * @param {unknown} value + * @returns {string} + */ +function fieldText(value) { + if (value == null) return '' + if (typeof value === 'string') return value + if (typeof value === 'object') return nodeText(value).trim() + return String(value) +} + +/** + * Extract Repro / Expected / Actual from a Jira Bug. Prefers known custom + * fields; falls back to ADF description headings when the custom fields are + * absent. + * @param {any} fields + * @returns {{ repro: string, expected: string, actual: string }} + */ +export function parseJiraBug(fields) { + if (!fields || typeof fields !== 'object') { + return { repro: '', expected: '', actual: '' } + } + let repro = fieldText(fields.customfield_10300) + let expected = fieldText(fields.customfield_10301) + let actual = fieldText(fields.customfield_10302) + if ((!repro || !expected || !actual) && fields.description && typeof fields.description === 'object') { + const sections = sectionsByHeading(fields.description) + if (!repro) repro = findSection(sections, /repro|steps to reproduce/i) + if (!expected) expected = findSection(sections, /expected/i) + if (!actual) actual = findSection(sections, /actual/i) + } + return { repro, expected, actual } +} + +/** + * Extract Confluence `/wiki/` hrefs from an HTML body. Best-effort - scoped to + * href-embedded links, which covers the common Confluence storage format. + * Deduplicated, order preserved. + * @param {unknown} htmlBody + * @returns {string[]} + */ +export function extractConfluenceLinks(htmlBody) { + if (typeof htmlBody !== 'string') return [] + /** @type {string[]} */ + const out = [] + for (const match of htmlBody.matchAll(/href="([^"]*\/wiki\/[^"]*)"/g)) { + if (!out.includes(match[1])) out.push(match[1]) + } + return out +} + +/** + * Extract absolute Confluence URLs from arbitrary text (e.g. a stringified ADF + * body with `"href":"https://…/wiki/…"` marks, or plain-text descriptions with + * bare links). + * @param {string} text + * @returns {string[]} + */ +function extractAbsoluteWikiUrls(text) { + if (typeof text !== 'string') return [] + /** @type {string[]} */ + const out = [] + for (const match of text.matchAll(/https?:\/\/[^\s"'<>]+\/wiki\/[^\s"'<>]+/g)) { + if (!out.includes(match[0])) out.push(match[0]) + } + return out +} + +/** + * @param {string} u + * @returns {string} + */ +function stripTrailingSlash(u) { + return u.endsWith('/') ? u.slice(0, -1) : u +} + +/** + * Strip HTML tags and collapse whitespace, returning a clean text excerpt. + * @param {string} html + * @returns {string} + */ +function stripHtml(html) { + return html + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * GET a JSON resource with Basic auth and a hard timeout. Classifies failures + * into FetchError kinds and throws - never returns a partial result. + * @param {string} url + * @param {AtlassianCreds} creds + * @param {FetchLike} fetchImpl + * @returns {Promise<any>} + */ +async function fetchJson(url, creds, fetchImpl) { + const headers = { + Authorization: `Basic ${buildBasicAuth(creds.username, creds.token)}`, + Accept: 'application/json', + } + let res + try { + res = await fetchImpl(url, { method: 'GET', headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + } catch (err) { + throw new FetchError(url, 'unreachable', mapFetchError(err)) + } + if (res.status === 401 || res.status === 403) { + throw new FetchError(url, 'auth-error', `HTTP ${res.status} - credentials rejected`) + } + if (res.status === 404) { + throw new FetchError(url, 'not-found', `HTTP ${res.status} - resource not found`) + } + if (!res.ok) { + throw new FetchError(url, 'unreachable', `HTTP ${res.status}`) + } + try { + return await res.json() + } catch (err) { + throw new FetchError(url, 'parse-error', err instanceof Error ? err.message : String(err)) + } +} + +/** + * Map a fetch rejection to a readable message, recognising the timeout abort. + * @param {unknown} err + * @returns {string} + */ +export function mapFetchError(err) { + if (err instanceof Error && err.name === 'TimeoutError') { + return `Request timed out after ${FETCH_TIMEOUT_MS / 1000}s` + } + return err instanceof Error ? err.message : String(err) +} + +/** + * Map a Jira issue type name to the three canonical buckets used for + * intent extraction. Epics are bucketed as `'story'` because they can + * carry acceptance criteria in the same AC-heading format. + * @param {string | undefined} typeName + * @returns {'story' | 'bug' | 'other'} + */ +function classifyIssueType(typeName) { + const name = (typeName ?? '').toLowerCase() + if (name.includes('story') || name === 'epic') return 'story' + if (name.includes('bug') || name.includes('defect')) return 'bug' + return 'other' +} + +/** + * Fetch and normalise a Jira issue (Story → ACs, Bug → repro/expected/actual). + * @param {string} issueKeyOrUrl + * @param {AtlassianCreds} creds + * @param {{ fetch?: FetchLike }} [deps] + * @returns {Promise<JiraItem>} + */ +export async function fetchJiraIssue(issueKeyOrUrl, creds, deps = {}) { + const fetchImpl = deps.fetch ?? globalThis.fetch + const jiraBase = stripTrailingSlash(creds.jiraUrl ?? creds.url) + const key = extractJiraKey(issueKeyOrUrl) ?? issueKeyOrUrl + // customfield_10016 = story points; included so callers can surface it without a second request. + const fields = 'summary,description,issuetype,customfield_10016,customfield_10300,customfield_10301,customfield_10302' + const url = `${jiraBase}/rest/api/3/issue/${encodeURIComponent(key)}?fields=${fields}` + const json = await fetchJson(url, creds, fetchImpl) + const issueFields = json?.fields ?? {} + const type = classifyIssueType(issueFields.issuetype?.name) + const summary = typeof issueFields.summary === 'string' ? issueFields.summary : '' + const acs = type === 'story' ? parseJiraACs(issueFields.description) : [] + const bug = type === 'bug' ? parseJiraBug(issueFields) : { repro: '', expected: '', actual: '' } + const confluenceLinks = extractAbsoluteWikiUrls(JSON.stringify(issueFields.description ?? '')) + return { + source: 'jira', + id: typeof json?.key === 'string' ? json.key : key, + type, + url: issueKeyOrUrl, + summary, + acs, + repro: bug.repro, + expected: bug.expected, + actual: bug.actual, + confluenceLinks, + } +} + +/** + * Fetch and normalise a Confluence page (title + excerpt + linked wiki URLs). + * @param {string} pageIdOrUrl + * @param {AtlassianCreds} creds + * @param {{ fetch?: FetchLike }} [deps] + * @returns {Promise<ConfluenceItem>} + */ +export async function fetchConfluencePage(pageIdOrUrl, creds, deps = {}) { + const fetchImpl = deps.fetch ?? globalThis.fetch + const confluenceBase = stripTrailingSlash(creds.url) + const pageId = extractConfluencePageId(pageIdOrUrl) + if (pageId === null) { + throw new FetchError( + pageIdOrUrl, + 'not-found', + `could not extract a Confluence page ID from this URL format - only /pages/<id>/ and ?pageId=<id> are supported: ${pageIdOrUrl}` + ) + } + const url = `${confluenceBase}/wiki/rest/api/content/${encodeURIComponent(pageId)}?expand=body.storage,version` + const json = await fetchJson(url, creds, fetchImpl) + const htmlBody = json?.body?.storage?.value ?? '' + return { + source: 'confluence', + id: typeof json?.id === 'string' ? json.id : String(pageId), + url: pageIdOrUrl, + title: typeof json?.title === 'string' ? json.title : '', + excerpt: stripHtml(typeof htmlBody === 'string' ? htmlBody : '').slice(0, 800), + linkedUrls: extractConfluenceLinks(htmlBody).map((href) => + href.startsWith('http') ? href : `${confluenceBase}${href}` + ), + } +} + +/** + * @typedef {Object} CollectDeps + * @property {FetchLike} [fetch] - injectable fetch for tests + * @property {string} [homedir] - override for os.homedir(); used in tests + * @property {Env} [env] - override for process.env; used in tests + * @property {(homedir?: string, env?: Env) => (AtlassianCreds | null)} [loadCreds] - override credential loader + * @property {{ write: (s: string) => void }} [stderr] - sink for warnings + */ + +/** + * Route, fetch, and normalise every URL. Never throws - per-URL failures (and a + * missing or unreadable credential file) are collected into the `errors` array + * so the caller (Gaps agent or `/review-spec` command) decides whether to + * hard-stop. Unrecognised URLs are warned on stderr and recorded as a soft + * `unsupported` error (not silently skipped) so the caller can surface them. + * @param {string[]} urls + * @param {CollectDeps} [deps] + * @returns {Promise<FetchOutput>} + */ +export async function collectIntent(urls, deps = {}) { + const fetchImpl = deps.fetch ?? globalThis.fetch + const stderr = deps.stderr ?? process.stderr + const loadCreds = deps.loadCreds ?? loadAtlassianCreds + /** @type {AtlassianCreds | null} */ + let creds + try { + creds = loadCreds(deps.homedir, deps.env) + } catch (err) { + // A present-but-malformed or unreadable credential file throws in the + // loader. Convert it to a global auth-error (url === '') so callers get the + // structured never-throws contract and the CLI exits 1, just like missing + // credentials - a broken config can't yield valid intent either way. + const message = `credential file could not be read - ${err instanceof Error ? err.message : String(err)}` + stderr.write(`atlassian-fetch: ${message}\n`) + return { items: [], errors: [{ url: '', kind: 'auth-error', message }] } + } + if (!creds) { + return { + items: [], + errors: [ + { + url: '', + kind: 'auth-error', + message: 'No Atlassian credentials configured - run /unic-spec-review:setup-confluence', + }, + ], + } + } + + /** @type {(JiraItem | ConfluenceItem)[]} */ + const items = [] + /** @type {FetchErrorJson[]} */ + const errors = [] + + for (const url of urls) { + const route = routeUrl(url) + if (route === null) { + // Surface unsupported URLs (e.g. ADO Boards links) as a soft `unsupported` + // error instead of skipping silently, so the caller can warn the reviewer + // rather than producing empty intent with no explanation. + const message = `unrecognised URL format - only /browse/ (Jira) and /wiki/ (Confluence) paths are supported` + stderr.write(`atlassian-fetch: ${message}: ${url}; skipping\n`) + errors.push({ url, kind: 'unsupported', message }) + continue + } + try { + const item = + route === 'jira' + ? await fetchJiraIssue(url, creds, { fetch: fetchImpl }) + : await fetchConfluencePage(url, creds, { fetch: fetchImpl }) + items.push(item) + } catch (err) { + // Report the pasted URL (not the internal API URL) so a hard-stop + // message names what the reviewer actually entered (AC-7). + if (err instanceof FetchError) { + errors.push({ url, kind: err.kind, message: err.message }) + } else { + // Internal code error - flag as parse-error (soft failure) rather than + // unreachable (hard-stop), so a code defect doesn't abort the review. + const msg = err instanceof Error ? (err.stack ?? err.message) : String(err) + stderr.write(`atlassian-fetch: internal error processing ${url}: ${msg}\n`) + errors.push({ url, kind: 'parse-error', message: msg }) + } + } + } + + return { items, errors } +} + +/** + * Parse the `--urls <csv>` argument into a trimmed, non-empty URL list. + * @param {string[]} argv + * @returns {string[]} + */ +export function parseUrlsArg(argv) { + const idx = argv.indexOf('--urls') + const raw = idx >= 0 ? (argv[idx + 1] ?? '') : '' + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +/** + * CLI entry: parse argv, collect intent, write the FetchOutput as JSON to + * stdout. Returns the result so tests can assert on it without spawning. + * @param {string[]} argv + * @param {CollectDeps & { stdout?: { write: (s: string) => void } }} [deps] + * @returns {Promise<FetchOutput>} + */ +export async function main(argv, deps = {}) { + const urls = parseUrlsArg(argv) + const result = await collectIntent(urls, deps) + let serialised + try { + serialised = JSON.stringify(result) + } catch (err) { + throw new Error(`atlassian-fetch: failed to serialise result: ${err instanceof Error ? err.message : String(err)}`) + } + ;(deps.stdout ?? process.stdout).write(`${serialised}\n`) + return result +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)) + .then((result) => { + // Exit 1 only when no credentials are configured at all (global auth-error, + // url === ''). Per-URL auth errors and not-found entries exit 0 so the + // gaps-agent can apply hard-stop logic by inspecting the errors array - + // not-found is soft, auth-error/unreachable per-URL is hard. + const credsMissing = result.errors.some((e) => e.kind === 'auth-error' && e.url === '') + process.exit(credsMissing ? 1 : 0) + }) + .catch((err) => { + process.stderr.write(`atlassian-fetch: unexpected error: ${err?.stack ?? err?.message ?? String(err)}\n`) + process.exit(1) + }) +} diff --git a/apps/claude-code/unic-spec-review/scripts/lib/args.mjs b/apps/claude-code/unic-spec-review/scripts/lib/args.mjs new file mode 100644 index 00000000..f7058d79 --- /dev/null +++ b/apps/claude-code/unic-spec-review/scripts/lib/args.mjs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// @ts-check +// Copyright © 2026 Unic + +/** + * args.mjs - parse /review-spec command arguments. + * + * Accepts a raw argument string or a pre-split argv array. + * Tokens that parse as valid URLs go into `urls`; `--post` sets `post: true`; + * other flags and unrecognised tokens are silently ignored. + */ + +/** + * @typedef {Object} ReviewSpecArgs + * @property {string[]} urls + * @property {boolean} post + */ + +/** + * @param {string | string[]} input - raw argument string or argv array + * @returns {ReviewSpecArgs} + */ +export function parseReviewSpecArgs(input) { + const tokens = Array.isArray(input) ? input : input.trim().split(/\s+/).filter(Boolean) + const post = tokens.includes('--post') + /** @type {string[]} */ + const urls = [] + for (const t of tokens) { + if (t.startsWith('--')) continue + try { + const parsed = new URL(t) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + urls.push(t) + } + } catch { + // not a URL; ignore + } + } + return { urls, post } +} diff --git a/apps/claude-code/unic-spec-review/scripts/lib/credentials.mjs b/apps/claude-code/unic-spec-review/scripts/lib/credentials.mjs new file mode 100644 index 00000000..91199db4 --- /dev/null +++ b/apps/claude-code/unic-spec-review/scripts/lib/credentials.mjs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// @ts-check +// Copyright © 2026 Unic + +/** + * credentials.mjs - load Atlassian (Confluence + optional Jira) and Azure DevOps + * credentials from environment variables or the credential files under the + * user's home directory. Env vars take precedence over file contents; each + * loader returns null when neither source is configured. + */ + +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' + +/** + * @param {string} filePath + */ +function readJsonFile(filePath) { + let raw + try { + raw = readFileSync(filePath, 'utf8') + } catch (err) { + throw new Error(`${filePath} could not be read: ${err instanceof Error ? err.message : String(err)}`) + } + try { + return JSON.parse(raw) + } catch (err) { + throw new Error(`${filePath} contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`) + } +} + +/** + * @typedef {Object} AtlassianCreds + * @property {string} url + * @property {string} username + * @property {string} token + * @property {string|undefined} jiraUrl + */ + +/** + * @typedef {Object} AzureCreds + * @property {string} orgUrl + * @property {string} pat + */ + +/** + * @typedef {Object} Env + * @property {string|undefined} [CONFLUENCE_URL] + * @property {string|undefined} [CONFLUENCE_USER] + * @property {string|undefined} [CONFLUENCE_TOKEN] + * @property {string|undefined} [JIRA_URL] + * @property {string|undefined} [AZURE_DEVOPS_ORG_URL] + * @property {string|undefined} [AZURE_DEVOPS_PAT] + */ + +/** + * Load Atlassian credentials (Confluence + optional Jira). Env vars override + * the credential file. Returns null when neither source is configured. + * + * @param {string} [homedir] - override for os.homedir(); used in tests + * @param {Env} [env] - override for process.env; used in tests + * @returns {AtlassianCreds|null} + */ +export function loadAtlassianCreds(homedir, env) { + const e = env ?? /** @type {Env} */ (process.env) + if (e.CONFLUENCE_URL && e.CONFLUENCE_USER && e.CONFLUENCE_TOKEN) { + return { + url: e.CONFLUENCE_URL, + username: e.CONFLUENCE_USER, + token: e.CONFLUENCE_TOKEN, + jiraUrl: e.JIRA_URL || undefined, + } + } + const home = homedir ?? os.homedir() + const path = join(home, '.unic-confluence.json') + if (!existsSync(path)) return null + const parsed = readJsonFile(path) + if (!parsed.url || !parsed.username || !parsed.token) return null + return { + url: String(parsed.url), + username: String(parsed.username), + token: String(parsed.token), + jiraUrl: parsed.jiraUrl ? String(parsed.jiraUrl) : undefined, + } +} + +/** + * Load Azure DevOps credentials. Env vars override the credential file. + * Returns null when neither source is configured. + * + * @param {string} [homedir] - override for os.homedir(); used in tests + * @param {Env} [env] - override for process.env; used in tests + * @returns {AzureCreds|null} + */ +export function loadAzureCreds(homedir, env) { + const e = env ?? /** @type {Env} */ (process.env) + if (e.AZURE_DEVOPS_ORG_URL && e.AZURE_DEVOPS_PAT) { + return { orgUrl: e.AZURE_DEVOPS_ORG_URL, pat: e.AZURE_DEVOPS_PAT } + } + const home = homedir ?? os.homedir() + const path = join(home, '.unic-azure.json') + if (!existsSync(path)) return null + const parsed = readJsonFile(path) + if (!parsed.orgUrl || !parsed.pat) return null + return { orgUrl: String(parsed.orgUrl), pat: String(parsed.pat) } +} diff --git a/apps/claude-code/unic-spec-review/scripts/lib/link-classifier.mjs b/apps/claude-code/unic-spec-review/scripts/lib/link-classifier.mjs new file mode 100644 index 00000000..cd4a84bd --- /dev/null +++ b/apps/claude-code/unic-spec-review/scripts/lib/link-classifier.mjs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// @ts-check +// Copyright © 2026 Unic + +/** + * link-classifier.mjs - classify a pasted URL by kind and extract identifiers. + * + * Returns one of five kinds: 'confluence', 'figma-page', 'figma-frame', + * 'live', 'unknown'. Never throws; returns 'unknown' for malformed input. + */ + +import { pathToFileURL } from 'node:url' + +/** + * @typedef {'confluence' | 'figma-page' | 'figma-frame' | 'live' | 'unknown'} UrlKind + */ + +/** + * @typedef {Object} ConfluenceClassified + * @property {'confluence'} kind + * @property {string} pageId + * @property {string} url + */ + +/** + * @typedef {Object} OtherClassified + * @property {'figma-page' | 'figma-frame' | 'live' | 'unknown'} kind + * @property {string} url + */ + +/** + * @typedef {ConfluenceClassified | OtherClassified} Classified + */ + +/** + * Extract a Confluence page id from a parsed URL. + * @param {URL} parsed + * @returns {string | null} + */ +function extractPageId(parsed) { + const m = parsed.pathname.match(/\/pages\/(\d+)/) + if (m) return m[1] + const q = parsed.searchParams.get('pageId') + return q && /^\d+$/.test(q) ? q : null +} + +/** + * Classify a single pasted URL by kind, extracting identifiers where relevant. + * @param {string} url + * @returns {Classified} + */ +export function classifyUrl(url) { + let parsed + try { + parsed = new URL(url) + } catch { + return { kind: 'unknown', url } + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { kind: 'unknown', url } + } + + if (parsed.pathname.includes('/wiki/')) { + const pageId = extractPageId(parsed) + if (pageId) return { kind: 'confluence', pageId, url } + return { kind: 'unknown', url } + } + + const host = parsed.hostname.toLowerCase() + if (host === 'www.figma.com' || host === 'figma.com') { + return parsed.searchParams.has('node-id') ? { kind: 'figma-frame', url } : { kind: 'figma-page', url } + } + + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return { kind: 'live', url } + } + + return { kind: 'unknown', url } +} + +// CLI entry: output JSON classification for one URL argument +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const url = process.argv[2] + if (!url) { + process.stderr.write('link-classifier: URL argument required\n') + process.exit(1) + } + process.stdout.write(`${JSON.stringify(classifyUrl(url))}\n`) +} diff --git a/apps/claude-code/unic-spec-review/scripts/lib/report-renderer.mjs b/apps/claude-code/unic-spec-review/scripts/lib/report-renderer.mjs new file mode 100644 index 00000000..9d23a18b --- /dev/null +++ b/apps/claude-code/unic-spec-review/scripts/lib/report-renderer.mjs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// @ts-check +// Copyright © 2026 Unic + +/** + * report-renderer.mjs - render and write a timestamped spec-review report. + * + * Pure library: renderReport() accepts injectable fs deps for unit testing. + * The CLI entry reads the report JSON from a file-path argument (argv[2]), + * falling back to the REPORT_JSON env var, and writes the rendered report + * under REPORT_OUTPUT_DIR. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +/** + * @typedef {Object} Finding + * @property {string} title + * @property {string} description + * @property {'critical' | 'important' | 'minor'} [severity] + * @property {number} [confidence] + * @property {string} [anchor] + */ + +/** + * @typedef {Object} ReportInput + * @property {string} pageTitle + * @property {string} pageUrl + * @property {string} timestamp - ISO 8601, e.g. new Date().toISOString() + * @property {Finding[]} findings + */ + +/** + * @typedef {Object} RendererDeps + * @property {(dir: string, opts?: import('node:fs').MakeDirectoryOptions) => void} [mkdirSync] + * @property {(path: string, data: string) => void} [writeFileSync] + */ + +/** + * @typedef {Object} ReportResult + * @property {string} path - path of the written report file + * @property {string} markdown - rendered markdown content + */ + +/** + * Collapse an ISO timestamp to a filesystem-safe slug. + * @param {string} ts + * @returns {string} + */ +function tsToSlug(ts) { + return ts.slice(0, 19).replace(/[T:]/g, '-') +} + +/** + * @param {Finding} f + * @returns {string} + */ +function renderFinding(f) { + const badge = f.severity ? ` \`${f.severity}\`` : '' + const conf = typeof f.confidence === 'number' ? ` (${f.confidence}%)` : '' + const anchor = f.anchor ? `\n\n> Anchor: \`${f.anchor}\`` : '' + return `### ${f.title}${badge}${conf}\n\n${f.description}${anchor}` +} + +/** + * Render findings into a timestamped markdown report and write it to disk. + * @param {ReportInput} input + * @param {string} outputDir + * @param {RendererDeps} [deps] + * @returns {ReportResult} + */ +export function renderReport(input, outputDir, deps = {}) { + const mkdir = deps.mkdirSync ?? mkdirSync + const write = deps.writeFileSync ?? writeFileSync + + mkdir(outputDir, { recursive: true }) + + const slug = tsToSlug(input.timestamp) + const filename = `spec-review-${slug}.md` + const path = join(outputDir, filename) + + const body = + input.findings.length > 0 ? input.findings.map(renderFinding).join('\n\n') : '_No gaps or completeness findings._' + + const markdown = [ + `# Spec Review: ${input.pageTitle}`, + '', + `**Source:** ${input.pageUrl}`, + `**Date:** ${input.timestamp}`, + '', + '---', + '', + '## Gaps / Completeness', + '', + body, + '', + ].join('\n') + + write(path, markdown) + return { path, markdown } +} + +// CLI entry +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + // Accept JSON from a file path (argv[2]) to avoid shell-quoting issues with + // apostrophes in page titles; falls back to REPORT_JSON env var. + const jsonFilePath = process.argv[2] + let raw + if (jsonFilePath) { + try { + raw = readFileSync(jsonFilePath, 'utf8') + } catch (err) { + process.stderr.write( + `report-renderer: could not read JSON file ${jsonFilePath}: ${err instanceof Error ? err.message : String(err)}\n` + ) + process.exit(1) + } + } else { + raw = process.env.REPORT_JSON + } + if (!raw) { + process.stderr.write('report-renderer: REPORT_JSON environment variable is required\n') + process.exit(1) + } + let input + try { + input = JSON.parse(raw) + } catch (err) { + process.stderr.write( + `report-renderer: REPORT_JSON is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n` + ) + process.exit(1) + } + if (typeof input !== 'object' || input === null) { + process.stderr.write('report-renderer: REPORT_JSON must be an object\n') + process.exit(1) + } + if (!Array.isArray(input.findings)) { + process.stderr.write('report-renderer: REPORT_JSON missing required field: findings\n') + process.exit(1) + } + if (typeof input.timestamp !== 'string') { + process.stderr.write('report-renderer: REPORT_JSON missing required field: timestamp\n') + process.exit(1) + } + if (typeof input.pageTitle !== 'string') { + process.stderr.write('report-renderer: REPORT_JSON missing required field: pageTitle\n') + process.exit(1) + } + if (typeof input.pageUrl !== 'string') { + process.stderr.write('report-renderer: REPORT_JSON missing required field: pageUrl\n') + process.exit(1) + } + const outputDir = process.env.REPORT_OUTPUT_DIR ?? '.spec-review' + let result + try { + result = renderReport(input, outputDir) + } catch (err) { + process.stderr.write( + `report-renderer: could not write report to ${outputDir}: ${err instanceof Error ? err.message : String(err)}\n` + ) + process.exit(1) + } + process.stdout.write(`${result.path}\n`) +} diff --git a/apps/claude-code/unic-spec-review/tests/args.test.mjs b/apps/claude-code/unic-spec-review/tests/args.test.mjs new file mode 100644 index 00000000..108112b3 --- /dev/null +++ b/apps/claude-code/unic-spec-review/tests/args.test.mjs @@ -0,0 +1,78 @@ +// @ts-check +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright © 2026 Unic + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { parseReviewSpecArgs } from '../scripts/lib/args.mjs' + +describe('parseReviewSpecArgs', () => { + it('parses a single URL from an array, post defaults to false', () => { + assert.deepEqual(parseReviewSpecArgs(['https://x.atlassian.net/wiki/spaces/X/pages/1']), { + urls: ['https://x.atlassian.net/wiki/spaces/X/pages/1'], + post: false, + }) + }) + + it('sets post true when --post is present alongside a URL', () => { + assert.deepEqual(parseReviewSpecArgs(['https://x.atlassian.net/wiki/spaces/X/pages/1', '--post']), { + urls: ['https://x.atlassian.net/wiki/spaces/X/pages/1'], + post: true, + }) + }) + + it('parses a space-separated string the same way as an array', () => { + assert.deepEqual(parseReviewSpecArgs('https://x.atlassian.net/wiki/spaces/X/pages/1 --post'), { + urls: ['https://x.atlassian.net/wiki/spaces/X/pages/1'], + post: true, + }) + }) + + it('ignores non-URL tokens', () => { + assert.deepEqual(parseReviewSpecArgs(['hello', 'https://x.atlassian.net/wiki/p/1', 'world']), { + urls: ['https://x.atlassian.net/wiki/p/1'], + post: false, + }) + }) + + it('returns empty urls and post false for empty input', () => { + assert.deepEqual(parseReviewSpecArgs(''), { urls: [], post: false }) + assert.deepEqual(parseReviewSpecArgs([]), { urls: [], post: false }) + }) + + it('captures multiple valid URLs in order', () => { + assert.deepEqual(parseReviewSpecArgs(['https://a.example/wiki/p/1', 'https://b.example/wiki/p/2']), { + urls: ['https://a.example/wiki/p/1', 'https://b.example/wiki/p/2'], + post: false, + }) + }) + + it('ignores flags other than --post without throwing', () => { + assert.deepEqual(parseReviewSpecArgs(['--verbose', 'https://x.example/wiki/p/1', '--dry-run']), { + urls: ['https://x.example/wiki/p/1'], + post: false, + }) + }) + + it('ignores non-http(s) URL schemes (ftp, file, mailto)', () => { + assert.deepEqual( + parseReviewSpecArgs([ + 'ftp://files.example.com/spec.txt', + 'file:///etc/passwd', + 'mailto:someone@example.com', + 'https://x.example/wiki/p/1', + ]), + { + urls: ['https://x.example/wiki/p/1'], + post: false, + } + ) + }) + + it('keeps both http and https URLs', () => { + assert.deepEqual(parseReviewSpecArgs(['http://a.example/wiki/p/1', 'https://b.example/wiki/p/2']), { + urls: ['http://a.example/wiki/p/1', 'https://b.example/wiki/p/2'], + post: false, + }) + }) +}) diff --git a/apps/claude-code/unic-spec-review/tests/atlassian-fetch.test.mjs b/apps/claude-code/unic-spec-review/tests/atlassian-fetch.test.mjs new file mode 100644 index 00000000..46f07b13 --- /dev/null +++ b/apps/claude-code/unic-spec-review/tests/atlassian-fetch.test.mjs @@ -0,0 +1,313 @@ +// @ts-check +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright © 2026 Unic + +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' +import { + buildBasicAuth, + collectIntent, + extractConfluenceLinks, + extractConfluencePageId, + FETCH_TIMEOUT_MS, + fetchConfluencePage, + mapFetchError, + routeUrl, +} from '../scripts/atlassian-fetch.mjs' + +/** @import { FetchLike } from '../scripts/atlassian-fetch.mjs' */ +/** @import { AtlassianCreds } from '../scripts/lib/credentials.mjs' */ + +/** @type {AtlassianCreds} */ +const CREDS = { + url: 'https://unic.atlassian.net', + username: 'u@unic.com', + token: 'tok', + jiraUrl: 'https://unic.atlassian.net', +} + +/** + * Build a stub fetch that resolves to a 2xx JSON response. + * @param {any} json + * @returns {FetchLike} + */ +const fetchJson = (json) => async () => ({ ok: true, status: 200, json: async () => json }) + +/** + * Build a stub fetch that resolves to a non-2xx response. + * @param {number} status + * @returns {FetchLike} + */ +const fetchStatus = (status) => async () => ({ + ok: false, + status, + json: async () => ({}), +}) + +/** + * Build a stub fetch that rejects (network failure). + * @param {Error} err + * @returns {FetchLike} + */ +const fetchThrows = (err) => async () => { + throw err +} + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `atlassian-fetch-test-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +describe('routeUrl', () => { + it('returns confluence for a /wiki/ path', () => { + assert.equal(routeUrl('https://unic.atlassian.net/wiki/spaces/X/pages/123'), 'confluence') + }) + + it('returns null for an unknown path', () => { + assert.equal(routeUrl('https://example.com/something'), null) + }) + + it('returns null for an invalid URL', () => { + assert.equal(routeUrl('not-a-url'), null) + }) +}) + +describe('extractConfluencePageId', () => { + it('extracts a numeric id from a modern /pages/123456/ URL', () => { + assert.equal(extractConfluencePageId('https://x.atlassian.net/wiki/spaces/X/pages/123456/Title'), '123456') + }) + + it('extracts pageId from a legacy viewpage.action query string', () => { + assert.equal(extractConfluencePageId('https://x.atlassian.net/wiki/pages/viewpage.action?pageId=789'), '789') + }) + + it('returns null when no id is found', () => { + assert.equal(extractConfluencePageId('https://example.com/wiki/something'), null) + }) +}) + +describe('buildBasicAuth', () => { + it('returns base64 of user:token', () => { + assert.equal(buildBasicAuth('u@unic.com', 'tok'), Buffer.from('u@unic.com:tok').toString('base64')) + }) +}) + +describe('extractConfluenceLinks', () => { + it('extracts a wiki href from an HTML body', () => { + assert.deepEqual(extractConfluenceLinks('<a href="/wiki/spaces/X/pages/123">link</a>'), [ + '/wiki/spaces/X/pages/123', + ]) + }) + + it('deduplicates repeated links', () => { + const html = '<a href="/wiki/p/1">a</a><a href="/wiki/p/1">b</a>' + assert.deepEqual(extractConfluenceLinks(html), ['/wiki/p/1']) + }) + + it('ignores non-wiki hrefs', () => { + assert.deepEqual(extractConfluenceLinks('<a href="/other/page">link</a>'), []) + }) + + it('returns an empty array for a non-string body', () => { + assert.deepEqual(extractConfluenceLinks(null), []) + }) +}) + +describe('fetchConfluencePage', () => { + it('returns title, excerpt, and linkedUrls', async () => { + const page = { + id: '123456', + title: 'Design Doc', + body: { storage: { value: '<p>Hello world</p><a href="/wiki/spaces/Y/pages/9">y</a>' } }, + } + const item = await fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/123456/Title', CREDS, { + fetch: fetchJson(page), + }) + assert.equal(item.title, 'Design Doc') + assert.equal(item.id, '123456') + assert.match(item.excerpt, /Hello world/) + assert.deepEqual(item.linkedUrls, ['https://unic.atlassian.net/wiki/spaces/Y/pages/9']) + }) + + it('strips HTML tags from the excerpt', async () => { + const page = { id: '1', title: 'T', body: { storage: { value: '<p>Hello <strong>world</strong></p>' } } } + const item = await fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/1', CREDS, { + fetch: fetchJson(page), + }) + assert.equal(item.excerpt, 'Hello world') + }) + + it('caps the excerpt at 800 characters', async () => { + const longBody = 'word '.repeat(400) + const page = { id: '2', title: 'Long', body: { storage: { value: longBody } } } + const item = await fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/2', CREDS, { + fetch: fetchJson(page), + }) + assert.equal(item.excerpt.length, 800) + }) + + it('throws FetchError with kind unreachable on network error', async () => { + await assert.rejects( + () => + fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/1', CREDS, { + fetch: fetchThrows(new TypeError('boom')), + }), + (err) => /** @type {any} */ (err).kind === 'unreachable' + ) + }) + + it('throws FetchError with kind auth-error on 401', async () => { + await assert.rejects( + () => + fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/1', CREDS, { + fetch: fetchStatus(401), + }), + (err) => /** @type {any} */ (err).kind === 'auth-error' + ) + }) + + it('throws FetchError with kind auth-error on 403', async () => { + await assert.rejects( + () => + fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/1', CREDS, { + fetch: fetchStatus(403), + }), + (err) => /** @type {any} */ (err).kind === 'auth-error' + ) + }) + + it('throws FetchError with kind not-found on 404', async () => { + await assert.rejects( + () => + fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/1', CREDS, { + fetch: fetchStatus(404), + }), + (err) => /** @type {any} */ (err).kind === 'not-found' + ) + }) + + it('resolves relative wiki hrefs to absolute URLs using the credentials base', async () => { + const page = { + id: '999', + title: 'With Relative Link', + body: { storage: { value: '<a href="/wiki/spaces/Y/pages/9">link</a>' } }, + } + const item = await fetchConfluencePage('https://unic.atlassian.net/wiki/spaces/X/pages/999', CREDS, { + fetch: fetchJson(page), + }) + assert.deepEqual(item.linkedUrls, ['https://unic.atlassian.net/wiki/spaces/Y/pages/9']) + }) +}) + +describe('collectIntent - credential resolution', () => { + it('reports a credential error when neither env vars nor file are present', async () => { + const result = await collectIntent(['https://x.atlassian.net/wiki/spaces/X/pages/1'], { + homedir: tempDir(), + env: {}, + }) + assert.deepEqual(result.items, []) + assert.equal(result.errors.length, 1) + assert.equal(result.errors[0].kind, 'auth-error') + assert.equal(result.errors[0].url, '') + }) + + it('reads creds from ~/.unic-confluence.json and routes a Confluence URL', async () => { + const home = tempDir() + writeFileSync( + join(home, '.unic-confluence.json'), + JSON.stringify({ url: 'https://x.atlassian.net', username: 'u', token: 't' }) + ) + const page = { id: '5', title: 'P', body: { storage: { value: '<p>body</p>' } } } + const result = await collectIntent(['https://x.atlassian.net/wiki/spaces/X/pages/5'], { + homedir: home, + env: {}, + fetch: fetchJson(page), + }) + assert.equal(result.items.length, 1) + assert.equal(result.items[0].id, '5') + assert.deepEqual(result.errors, []) + }) + + it('resolves creds from env vars and routes a Confluence URL', async () => { + const env = { CONFLUENCE_URL: 'https://x.atlassian.net', CONFLUENCE_USER: 'u', CONFLUENCE_TOKEN: 't' } + const page = { id: '7', title: 'P', body: { storage: { value: '<p>body</p>' } } } + const result = await collectIntent(['https://x.atlassian.net/wiki/spaces/X/pages/7'], { + env, + fetch: fetchJson(page), + }) + assert.equal(result.items.length, 1) + assert.equal(result.items[0].id, '7') + assert.deepEqual(result.errors, []) + }) + + it('collects a 404 FetchError into the errors array without throwing', async () => { + const env = { CONFLUENCE_URL: 'https://x.atlassian.net', CONFLUENCE_USER: 'u', CONFLUENCE_TOKEN: 't' } + const result = await collectIntent(['https://x.atlassian.net/wiki/spaces/X/pages/1'], { + env, + fetch: fetchStatus(404), + }) + assert.deepEqual(result.items, []) + assert.equal(result.errors.length, 1) + assert.equal(result.errors[0].kind, 'not-found') + assert.equal(result.errors[0].url, 'https://x.atlassian.net/wiki/spaces/X/pages/1') + }) + + it('maps a 5xx response to a hard-stop `unreachable` error', async () => { + const env = { CONFLUENCE_URL: 'https://x.atlassian.net', CONFLUENCE_USER: 'u', CONFLUENCE_TOKEN: 't' } + const result = await collectIntent(['https://x.atlassian.net/wiki/spaces/X/pages/1'], { + env, + fetch: fetchStatus(500), + }) + assert.deepEqual(result.items, []) + assert.equal(result.errors.length, 1) + assert.equal(result.errors[0].kind, 'unreachable') + }) + + it('records an unsupported error for an unrecognised URL without throwing', async () => { + const env = { CONFLUENCE_URL: 'https://x.atlassian.net', CONFLUENCE_USER: 'u', CONFLUENCE_TOKEN: 't' } + const result = await collectIntent(['https://dev.azure.com/org/proj/_workitems/edit/123'], { env }) + assert.deepEqual(result.items, []) + assert.equal(result.errors.length, 1) + assert.equal(result.errors[0].kind, 'unsupported') + assert.equal(result.errors[0].url, 'https://dev.azure.com/org/proj/_workitems/edit/123') + }) + + it('converts a credential load exception into a global auth-error', async () => { + const result = await collectIntent(['https://x.atlassian.net/wiki/spaces/X/pages/1'], { + loadCreds: () => { + throw new Error('invalid JSON') + }, + }) + assert.deepEqual(result.items, []) + assert.equal(result.errors.length, 1) + assert.equal(result.errors[0].kind, 'auth-error') + assert.equal(result.errors[0].url, '') + assert.match(result.errors[0].message, /could not be read/) + }) +}) + +describe('mapFetchError', () => { + it('returns a human-readable message for TimeoutError', () => { + const err = Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' }) + assert.match(mapFetchError(err), /timed out/) + }) + + it('includes the configured timeout seconds in the message', () => { + const err = Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' }) + assert.match(mapFetchError(err), new RegExp(`${FETCH_TIMEOUT_MS / 1000}s`)) + }) + + it('returns err.message for a generic Error', () => { + assert.equal(mapFetchError(new Error('network failure')), 'network failure') + }) + + it('stringifies a non-Error value', () => { + assert.equal(mapFetchError('oops'), 'oops') + }) +}) diff --git a/apps/claude-code/unic-spec-review/tests/credentials.test.mjs b/apps/claude-code/unic-spec-review/tests/credentials.test.mjs new file mode 100644 index 00000000..06424224 --- /dev/null +++ b/apps/claude-code/unic-spec-review/tests/credentials.test.mjs @@ -0,0 +1,98 @@ +// @ts-check +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright © 2026 Unic + +import assert from 'node:assert/strict' +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' +import { loadAtlassianCreds } from '../scripts/lib/credentials.mjs' + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `creds-test-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +describe('loadAtlassianCreds', () => { + it('returns creds from env vars when all three are set', () => { + const env = { CONFLUENCE_URL: 'https://x.atlassian.net', CONFLUENCE_USER: 'u', CONFLUENCE_TOKEN: 't' } + const r = loadAtlassianCreds(undefined, env) + assert.ok(r) + assert.equal(r.url, 'https://x.atlassian.net') + assert.equal(r.jiraUrl, undefined) + }) + + it('includes jiraUrl from env when JIRA_URL is set', () => { + const env = { + CONFLUENCE_URL: 'https://x.atlassian.net', + CONFLUENCE_USER: 'u', + CONFLUENCE_TOKEN: 't', + JIRA_URL: 'https://jira.atlassian.net', + } + const r = loadAtlassianCreds(undefined, env) + assert.ok(r) + assert.equal(r.jiraUrl, 'https://jira.atlassian.net') + }) + + it('returns null when env is incomplete and file is absent', () => { + assert.equal(loadAtlassianCreds(tempDir(), {}), null) + }) + + it('returns creds from file when env is not set', () => { + const home = tempDir() + writeFileSync( + join(home, '.unic-confluence.json'), + JSON.stringify({ url: 'https://x.atlassian.net', username: 'u', token: 't' }) + ) + const r = loadAtlassianCreds(home, {}) + assert.ok(r) + assert.equal(r.url, 'https://x.atlassian.net') + }) + + it('returns null when file is present but missing required fields', () => { + const home = tempDir() + writeFileSync(join(home, '.unic-confluence.json'), JSON.stringify({ url: 'https://x.atlassian.net' })) + assert.equal(loadAtlassianCreds(home, {}), null) + }) + + it('throws a descriptive error on malformed JSON', () => { + const home = tempDir() + writeFileSync(join(home, '.unic-confluence.json'), 'not-valid-json') + assert.throws(() => loadAtlassianCreds(home, {}), /invalid JSON/) + }) + + it('prefers env vars over a present file when both are configured', () => { + const home = tempDir() + writeFileSync( + join(home, '.unic-confluence.json'), + JSON.stringify({ url: 'https://file.example.com', username: 'fileuser', token: 'filetoken' }) + ) + const env = { + CONFLUENCE_URL: 'https://env.example.com', + CONFLUENCE_USER: 'envuser', + CONFLUENCE_TOKEN: 'envtoken', + } + const r = loadAtlassianCreds(home, env) + assert.ok(r) + assert.equal(r.url, 'https://env.example.com') + }) + + it('includes jiraUrl from file when present', () => { + const home = tempDir() + writeFileSync( + join(home, '.unic-confluence.json'), + JSON.stringify({ + url: 'https://x.atlassian.net', + username: 'u', + token: 't', + jiraUrl: 'https://jira.atlassian.net', + }) + ) + const r = loadAtlassianCreds(home, {}) + assert.ok(r) + assert.equal(r.jiraUrl, 'https://jira.atlassian.net') + }) +}) diff --git a/apps/claude-code/unic-spec-review/tests/link-classifier.test.mjs b/apps/claude-code/unic-spec-review/tests/link-classifier.test.mjs new file mode 100644 index 00000000..051c630a --- /dev/null +++ b/apps/claude-code/unic-spec-review/tests/link-classifier.test.mjs @@ -0,0 +1,64 @@ +// @ts-check +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright © 2026 Unic + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { classifyUrl } from '../scripts/lib/link-classifier.mjs' + +describe('classifyUrl', () => { + it('routes a modern Confluence page URL to confluence and extracts the page id', () => { + const url = 'https://x.atlassian.net/wiki/spaces/X/pages/123456/Title' + assert.deepEqual(classifyUrl(url), { kind: 'confluence', pageId: '123456', url }) + }) + + it('routes a legacy Confluence URL (?pageId=) to confluence', () => { + const url = 'https://x.atlassian.net/wiki/pages/viewpage.action?pageId=789' + assert.deepEqual(classifyUrl(url), { kind: 'confluence', pageId: '789', url }) + }) + + it('returns unknown for a /wiki/ URL with no extractable page id', () => { + const url = 'https://x.atlassian.net/wiki/spaces/X/overview' + assert.deepEqual(classifyUrl(url), { kind: 'unknown', url }) + }) + + it('routes a Figma URL with a node-id param to figma-frame', () => { + const url = 'https://www.figma.com/design/abc/My-File?node-id=1-2' + assert.deepEqual(classifyUrl(url), { kind: 'figma-frame', url }) + }) + + it('routes a Figma URL without a node-id param to figma-page', () => { + const url = 'https://www.figma.com/design/abc/My-File' + assert.deepEqual(classifyUrl(url), { kind: 'figma-page', url }) + }) + + it('routes a bare figma.com URL (no www) to figma-page', () => { + const url = 'https://figma.com/design/abc/My-File' + assert.deepEqual(classifyUrl(url), { kind: 'figma-page', url }) + }) + + it('routes a generic HTTPS URL to live', () => { + const url = 'https://example.com/products/checkout' + assert.deepEqual(classifyUrl(url), { kind: 'live', url }) + }) + + it('returns unknown for a malformed URL string', () => { + const url = 'not a url at all' + assert.deepEqual(classifyUrl(url), { kind: 'unknown', url }) + }) + + it('returns unknown for a non-HTTP protocol', () => { + const url = 'ftp://files.example.com/spec.txt' + assert.deepEqual(classifyUrl(url), { kind: 'unknown', url }) + }) + + it('does not misclassify a file:// URL with /wiki/ as confluence', () => { + const url = 'file:///wiki/pages/123' + assert.deepEqual(classifyUrl(url), { kind: 'unknown', url }) + }) + + it('does not misclassify an ftp:// URL with /wiki/ as confluence', () => { + const url = 'ftp://host/wiki/pages/123' + assert.deepEqual(classifyUrl(url), { kind: 'unknown', url }) + }) +}) diff --git a/apps/claude-code/unic-spec-review/tests/report-renderer.test.mjs b/apps/claude-code/unic-spec-review/tests/report-renderer.test.mjs new file mode 100644 index 00000000..4d90d63f --- /dev/null +++ b/apps/claude-code/unic-spec-review/tests/report-renderer.test.mjs @@ -0,0 +1,167 @@ +// @ts-check +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright © 2026 Unic + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' +import { renderReport } from '../scripts/lib/report-renderer.mjs' + +const RENDERER_PATH = fileURLToPath(new URL('../scripts/lib/report-renderer.mjs', import.meta.url)) + +/** + * Build a renderer-deps stub that records the mkdir/write calls. + * @returns {{ deps: import('../scripts/lib/report-renderer.mjs').RendererDeps, calls: { mkdirDir: string, path: string, data: string } }} + */ +function stubDeps() { + const calls = { mkdirDir: '', path: '', data: '' } + const deps = { + mkdirSync: (/** @type {string} */ dir) => { + calls.mkdirDir = dir + }, + writeFileSync: (/** @type {string} */ p, /** @type {string} */ d) => { + calls.path = p + calls.data = d + }, + } + return { deps, calls } +} + +const BASE_INPUT = { + pageTitle: 'My Spec', + pageUrl: 'https://x.atlassian.net/wiki/spaces/X/pages/1/My-Spec', + timestamp: '2026-06-05T13:45:09.123Z', + findings: [], +} + +describe('renderReport', () => { + it('creates the output directory and writes to outputDir/spec-review-<slug>.md', () => { + const { deps, calls } = stubDeps() + const result = renderReport(BASE_INPUT, '/tmp/out', deps) + assert.equal(calls.mkdirDir, '/tmp/out') + assert.equal(result.path, join('/tmp/out', 'spec-review-2026-06-05-13-45-09.md')) + assert.equal(calls.path, result.path) + }) + + it('derives a filename slug with no colon or T character', () => { + const { deps, calls } = stubDeps() + renderReport(BASE_INPUT, '/tmp/out', deps) + const filename = basename(calls.path) + assert.ok(!filename.includes(':')) + assert.ok(!filename.includes('T')) + assert.match(filename, /^spec-review-2026-06-05-13-45-09\.md$/) + }) + + it('includes the page title, url, and timestamp in the markdown', () => { + const { deps, calls } = stubDeps() + const result = renderReport(BASE_INPUT, '/tmp/out', deps) + assert.ok(calls.data.includes('My Spec')) + assert.ok(calls.data.includes(BASE_INPUT.pageUrl)) + assert.ok(calls.data.includes(BASE_INPUT.timestamp)) + assert.equal(result.markdown, calls.data) + }) + + it('renders each finding with title, severity badge, and confidence', () => { + const { deps, calls } = stubDeps() + renderReport( + { + ...BASE_INPUT, + findings: [ + { title: 'Missing logout flow', description: 'No end state defined.', severity: 'critical', confidence: 92 }, + ], + }, + '/tmp/out', + deps + ) + assert.ok(calls.data.includes('Missing logout flow')) + assert.ok(calls.data.includes('`critical`')) + assert.ok(calls.data.includes('(92%)')) + assert.ok(calls.data.includes('No end state defined.')) + }) + + it('renders the no-findings message for an empty findings list', () => { + const { deps, calls } = stubDeps() + renderReport(BASE_INPUT, '/tmp/out', deps) + assert.ok(calls.data.includes('_No gaps or completeness findings._')) + }) + + it('returns { path, markdown } matching the written content', () => { + const { deps, calls } = stubDeps() + const result = renderReport(BASE_INPUT, '/tmp/out', deps) + assert.equal(result.path, calls.path) + assert.equal(result.markdown, calls.data) + }) + + it('renders the anchor quote block when anchor is present', () => { + const { deps, calls } = stubDeps() + renderReport( + { + ...BASE_INPUT, + findings: [ + { + title: 'Missing error state', + description: 'No failure outcome defined.', + severity: 'important', + confidence: 80, + anchor: 'The user clicks Submit', + }, + ], + }, + '/tmp/out', + deps + ) + assert.ok(calls.data.includes('> Anchor: `The user clicks Submit`')) + }) +}) + +/** + * Run the report-renderer CLI entry with a JSON file argument. + * @param {unknown} json + * @returns {{ status: number | null, stderr: string }} + */ +function runCli(json) { + const dir = mkdtempSync(join(tmpdir(), 'spec-review-cli-')) + const jsonFile = join(dir, 'input.json') + writeFileSync(jsonFile, JSON.stringify(json)) + const res = spawnSync(process.execPath, [RENDERER_PATH, jsonFile], { + encoding: 'utf8', + env: { ...process.env, REPORT_OUTPUT_DIR: join(dir, 'out') }, + }) + return { status: res.status, stderr: res.stderr } +} + +describe('report-renderer CLI validation', () => { + it('exits 1 and mentions pageTitle when pageTitle is missing', () => { + const { status, stderr } = runCli({ + pageUrl: 'https://x.atlassian.net/wiki/p/1', + timestamp: '2026-06-05T13:45:09.123Z', + findings: [], + }) + assert.equal(status, 1) + assert.match(stderr, /pageTitle/) + }) + + it('exits 1 and mentions pageUrl when pageUrl is missing', () => { + const { status, stderr } = runCli({ + pageTitle: 'My Spec', + timestamp: '2026-06-05T13:45:09.123Z', + findings: [], + }) + assert.equal(status, 1) + assert.match(stderr, /pageUrl/) + }) + + it('exits 0 when all required fields are present', () => { + const { status } = runCli({ + pageTitle: 'My Spec', + pageUrl: 'https://x.atlassian.net/wiki/p/1', + timestamp: '2026-06-05T13:45:09.123Z', + findings: [], + }) + assert.equal(status, 0) + }) +}) diff --git a/apps/claude-code/unic-spec-review/tsconfig.json b/apps/claude-code/unic-spec-review/tsconfig.json new file mode 100644 index 00000000..393fce01 --- /dev/null +++ b/apps/claude-code/unic-spec-review/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@unic/tsconfig/tsconfig.base.json", + "include": ["scripts/**/*.mjs", "tests/**/*.mjs"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48dafe4c..d2fe9a1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,9 +117,18 @@ importers: apps/claude-code/unic-spec-review: devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.2 '@unic/release-tools': specifier: workspace:* version: link:../../../packages/release-tools + '@unic/tsconfig': + specifier: workspace:* + version: link:../../../packages/tsconfig + typescript: + specifier: 'catalog:' + version: 5.8.3 packages/biome-config: {}