Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"name": "unic-spec-review",
"source": "./",
"tags": ["productivity", "quality"],
"version": "0.1.8"
"version": "0.1.9"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"keywords": ["spec-review", "confluence", "figma", "adversarial-review", "six-hats", "unic"],
"license": "LGPL-3.0-or-later",
"name": "unic-spec-review",
"version": "0.1.8"
"version": "0.1.9"
}
11 changes: 11 additions & 0 deletions apps/claude-code/unic-spec-review/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- (none)

## [0.1.9] — 2026-06-09

### Breaking
- (none)

### Added
- Gate dedup posts when the comparison basis is incomplete. The `dedup-matcher` CLI now emits a run-level `{ truncated, results }` envelope instead of a bare `DedupResult[]`; `matchDedup`'s signature and purity are unchanged. `/review-spec` computes `COMPARISON_INCOMPLETE = truncated OR (read-errors, excluding the hard auth-stop)`, converging two advisory warnings into one structural gate. In an incomplete run each clean `post` Finding shows a `[?incomplete]` badge and a single run-level confirmation precedes the first clean-post write; `skip`/`flag` keep their existing per-Finding gates. Implements ADR-0005 and resolves the silent-failure-hunter CRITICAL finding from PR #237.

### Fixed
- (none)

## [0.1.8] — 2026-06-09

### Breaking
Expand Down
49 changes: 43 additions & 6 deletions apps/claude-code/unic-spec-review/commands/review-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,17 @@ Parse the JSON output. Store it as `COMMENTS_RESULT`.
Run /unic-spec-review:setup-confluence to add them.
```

- If `errors` is non-empty (but not the global auth error above), warn `Warning: could not fully read existing comments (<kind>: <message>). Near-duplicate detection may be incomplete.` and continue with whatever comments were returned (`comments` may be empty).
- If `errors` is non-empty (but not the global auth error above), warn `Warning: could not fully read existing comments (<kind>: <message>).` and continue with whatever comments were returned (`comments` may be empty). (The incompleteness signal now drives `COMPARISON_INCOMPLETE` and is surfaced structurally in 10c/10d, not just as advisory prose.)

Record `COMMENTS_TRUNCATED` from `truncated`. When true, append `(comment list may be incomplete - deduplication is best-effort)` to the summary line in 10c.
Record `COMMENTS_TRUNCATED` from `truncated`.

Compute `COMPARISON_INCOMPLETE`:

```
COMPARISON_INCOMPLETE = COMMENTS_TRUNCATED OR (errors non-empty after the auth-stop check above)
```

Both truncation and partial read errors mean the same thing to the reviewer: the comparison ran against a partial comment set. The specific cause will be named in the preamble printed in Step 10c.

### 10b - Run dedup-matcher

Expand All @@ -416,9 +424,9 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/dedup-matcher.mjs" \
--comments-file ".spec-review/.existing-comments.json"
```

Parse the JSON array from stdout as `DEDUP_RESULTS` - one `DedupResult` per finding, in the same ranked order. Each entry has `decision` (`'post'`, `'skip'`, or `'flag'`) and `nearDuplicates` (sorted by similarity descending).
Parse the JSON object from stdout. Set `DEDUP_RESULTS = parsed.results` - one `DedupResult` per finding, in the same ranked order. Each entry has `decision` (`'post'`, `'skip'`, or `'flag'`) and `nearDuplicates` (sorted by similarity descending). The envelope also carries `truncated`, but `COMPARISON_INCOMPLETE` was already computed in Step 10a from the comments fetch result - do not re-read it from the envelope here.

If the command fails (non-zero exit or parse error), warn `Warning: dedup-matcher failed - posting without deduplication.` and treat every finding's decision as `'post'` (proceed without dedup rather than blocking the entire post flow).
If the command fails (non-zero exit, parse error, or `parsed.results` is not an array), warn `Warning: dedup-matcher failed - posting without deduplication.` and treat every finding's decision as `'post'` (proceed without dedup rather than blocking the entire post flow). The `COMPARISON_INCOMPLETE` flag computed in 10a remains in effect even on failure.

### 10c - Present the annotated Findings list

Expand All @@ -428,6 +436,15 @@ Print:
Existing page comments checked for near-duplicates. <N> findings ready for review.
```

When `COMPARISON_INCOMPLETE` is true, print a warning block before the numbered list:

```
⚠ Comparison incomplete - the existing comment set was [truncated (pagination cap hit) | partially unreadable (<kind>: <message>)].
Clean posts are marked [?incomplete]: the comparison could not rule out duplicates beyond what was loaded.
```

Use "truncated (pagination cap hit)" when `COMMENTS_TRUNCATED`, otherwise "partially unreadable (<kind>: <message>)" with the first non-auth error's details.

Present a numbered list of all findings in ranked order. For each:

```
Expand All @@ -437,7 +454,8 @@ N. [<severity>] <title> (dimension: <dimension>, confidence: <X>%, anchor: <anch

Where `<dedup_badge>` is:

- ``(empty) - decision is`'post'`
- _(no badge)_ - decision is `'post'` AND `COMPARISON_INCOMPLETE` is false (complete run, no duplicate found)
- ` [?incomplete]` - decision is `'post'` AND `COMPARISON_INCOMPLETE` is true (comparison was partial; no duplicate found in what was checked)
- ` [~near-dup]` - decision is `'flag'` (borderline; tiebreak required)
- ` [~likely-dup]` - decision is `'skip'` (likely duplicate; override required)

Expand All @@ -451,6 +469,17 @@ If the user enters `0` or an empty/blank response, print `Nothing posted.` and s

### 10d - Process each selected Finding (selection is not commitment)

**Run-level confirmation (incomplete runs only):** Before processing any selected Finding, when `COMPARISON_INCOMPLETE` is true AND the user selected at least one Finding whose `decision` is `'post'`, ask:

```
Comparison incomplete - post the selected [?incomplete] Findings anyway? [y/N]:
```

- If the user answers `y` or `Y`: proceed normally. All selected clean-post Findings will be written.
- If the user answers anything else: set `SKIP_CLEAN_POSTS = true`. Clean-post Findings will be skipped during processing below; `skip` and `flag` Findings keep their existing per-Finding gates and are unaffected.

This confirmation is asked **once** (run-level), not once per Finding. The reviewer already exercised per-Finding judgement at selection.

For each selected number, in ranked order, look up its `DedupResult`:

#### If decision is `'skip'`:
Expand Down Expand Up @@ -481,7 +510,15 @@ Post anyway? [y/N]:

If the user answers anything other than `y` or `Y`, print `Skipped.` and move to the next selected Finding.

#### If decision is `'post'` (or an override was approved above):
#### If an override was approved above (skip or flag, user said y):

Post the Finding using steps 1–3 below. The run-level `SKIP_CLEAN_POSTS` flag does not apply - the reviewer explicitly consented to post despite the near-duplicate.

#### If decision is `'post'` (clean post, no duplicate found):

When `SKIP_CLEAN_POSTS` is true (run-level confirm was declined), print `Skipped (incomplete comparison).` and move to the next selected Finding.

Otherwise:

1. Write the Finding object as JSON to `.spec-review/.post-finding.json` using the Write tool. Include all fields: `title`, `body`, `severity`, `confidence`, `dimension`, `hat`, `anchor`.

Expand Down
2 changes: 1 addition & 1 deletion apps/claude-code/unic-spec-review/docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ Plugin-scoped ADRs. Monorepo-wide decisions live in `../../../../../docs/adr/`.
- [0004](0004-inline-anchored-comments-footer-fallback.md): Inline-anchored Confluence comments with a footer fallback
- [0005](0005-gate-dedup-when-comparison-incomplete.md): Gate de-dup posts when the comparison basis is incomplete

> Status: these ADRs record decisions locked during the design grilling for the [PRD](../issues/unic-spec-review/PRD.md). ADR-0001 (vendor self-containment) and ADR-0003 (six-hats lens + all eleven review agents) are implemented as of S4. ADR-0004 (inline-anchored comments with footer fallback) is implemented as of S5 (`inline-anchor-resolver.mjs`, `attribution-footer.mjs`, `confluence-writer.mjs`). ADR-0002 (similarity de-dup) is implemented as of S8 (`dedup-matcher.mjs`). ADR-0005 (gate incomplete comparisons) refines ADR-0002 and is pending — scoped in #238.
> Status: these ADRs record decisions locked during the design grilling for the [PRD](../issues/unic-spec-review/PRD.md). ADR-0001 (vendor self-containment) and ADR-0003 (six-hats lens + all eleven review agents) are implemented as of S4. ADR-0004 (inline-anchored comments with footer fallback) is implemented as of S5 (`inline-anchor-resolver.mjs`, `attribution-footer.mjs`, `confluence-writer.mjs`). ADR-0002 (similarity de-dup) is implemented as of S8 (`dedup-matcher.mjs`). ADR-0005 (gate incomplete comparisons) refines ADR-0002 and is implemented as of v0.1.9 (`dedup-matcher.mjs` envelope + `review-spec.md` Steps 10a–10d).
2 changes: 1 addition & 1 deletion apps/claude-code/unic-spec-review/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unic-spec-review",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"license": "LGPL-3.0-or-later",
"type": "module",
Expand Down
18 changes: 13 additions & 5 deletions apps/claude-code/unic-spec-review/scripts/lib/dedup-matcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,17 @@ export function matchDedup(finding, existingComments) {
}

/**
* CLI entry: read findings and comments from JSON files, print a DedupResult[]
* (indexed by findings position) to stdout. Exits 1 on missing args or parse error.
* CLI entry: read findings and comments from JSON files, print a run-level
* envelope `{ truncated, results }` to stdout where `results` is a DedupResult[]
* (indexed by findings position). Exits 1 on missing args or parse error.
*
* Usage: node dedup-matcher.mjs --findings-file <path> --comments-file <path>
*
* The findings file is a JSON array of Finding objects. The comments file is the
* `{ comments: ConfluenceComment[] }` object emitted by `collectComments` (the CLI
* reads `.comments`), or a bare `ConfluenceComment[]` array; either shape is accepted.
* `{ comments: ConfluenceComment[], truncated: boolean }` object emitted by
* `collectComments` (the CLI reads `.comments` and `.truncated`), or a bare
* `ConfluenceComment[]` array; either shape is accepted. `truncated` is read from
* the object shape (a bare array reports `truncated: false`).
*/
function main() {
const argv = process.argv.slice(2)
Expand Down Expand Up @@ -158,10 +161,15 @@ function main() {
process.exit(1)
}

// Strict `=== true`: a missing/non-boolean `truncated` reports `false` here. The
// envelope flag is advisory; Step 10a of review-spec.md is the authoritative source
// for COMPARISON_INCOMPLETE (computed from the fetch result + read errors), so the
// CLI never has to fail toward the gate on an ambiguous shape.
const truncated = !Array.isArray(commentsRaw) && commentsRaw?.truncated === true
const comments = Array.isArray(commentsRaw) ? commentsRaw : (commentsRaw?.comments ?? [])
const findingsList = Array.isArray(findings) ? findings : []
const results = findingsList.map((finding) => matchDedup(finding, comments))
process.stdout.write(`${JSON.stringify(results)}\n`)
process.stdout.write(`${JSON.stringify({ truncated, results })}\n`)
process.exit(0)
}

Expand Down
113 changes: 113 additions & 0 deletions apps/claude-code/unic-spec-review/tests/dedup-matcher.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
// Copyright © 2026 Unic

import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it } from 'node:test'
import { fileURLToPath } from 'node:url'
import { FLAG_THRESHOLD, jaccard, matchDedup, SKIP_THRESHOLD, tokenize } from '../scripts/lib/dedup-matcher.mjs'

const DEDUP_PATH = fileURLToPath(new URL('../scripts/lib/dedup-matcher.mjs', import.meta.url))

/**
* Build a valid Finding, overriding selected fields.
* @param {Partial<import('../scripts/lib/finding.mjs').Finding>} overrides
Expand Down Expand Up @@ -263,3 +270,109 @@ describe('matchDedup - threshold boundaries', () => {
assert.equal(result.decision, 'flag')
})
})

/**
* Run the dedup-matcher CLI with injected JSON files.
* @param {unknown} findings
* @param {unknown} commentsObj
* @returns {{ status: number | null, stdout: string, stderr: string }}
*/
function runDedupCli(findings, commentsObj) {
const dir = mkdtempSync(join(tmpdir(), 'dedup-cli-'))
try {
const findingsFile = join(dir, 'findings.json')
const commentsFile = join(dir, 'comments.json')
writeFileSync(findingsFile, JSON.stringify(findings))
writeFileSync(commentsFile, JSON.stringify(commentsObj))
const res = spawnSync(
process.execPath,
[DEDUP_PATH, '--findings-file', findingsFile, '--comments-file', commentsFile],
{ encoding: 'utf8' }
)
return { status: res.status, stdout: res.stdout, stderr: res.stderr }
} finally {
rmSync(dir, { recursive: true, force: true })
}
}

describe('dedup-matcher CLI envelope', () => {
const FINDING = {
hat: 'black',
dimension: 'gaps',
title: 'Missing error handling',
body: 'The spec does not describe how errors are handled.',
severity: 'important',
confidence: 80,
anchor: null,
}

it('emits { truncated: false, results } when comments object has truncated: false', () => {
const { status, stdout } = runDedupCli([FINDING], { comments: [], truncated: false })
assert.equal(status, 0)
const envelope = JSON.parse(stdout)
assert.equal(envelope.truncated, false)
assert.ok(Array.isArray(envelope.results))
assert.equal(envelope.results.length, 1)
assert.equal(envelope.results[0].decision, 'post')
})

it('emits { truncated: true, results } when comments object has truncated: true', () => {
const { status, stdout } = runDedupCli([FINDING], { comments: [], truncated: true })
assert.equal(status, 0)
const envelope = JSON.parse(stdout)
assert.equal(envelope.truncated, true)
assert.ok(Array.isArray(envelope.results))
assert.equal(envelope.results.length, 1)
assert.equal(envelope.results[0].decision, 'post')
})

it('emits truncated: false when comments is a bare array (legacy shape)', () => {
const { status, stdout } = runDedupCli([FINDING], [])
assert.equal(status, 0)
const envelope = JSON.parse(stdout)
assert.equal(envelope.truncated, false)
assert.ok(Array.isArray(envelope.results))
})

it('emits truncated: false when the comments object omits the truncated key', () => {
// Realistic payload from an older/partial collectComments: object with comments but no truncated field.
const { status, stdout } = runDedupCli([FINDING], { comments: [] })
assert.equal(status, 0)
const envelope = JSON.parse(stdout)
assert.equal(envelope.truncated, false)
assert.ok(Array.isArray(envelope.results))
assert.equal(envelope.results.length, 1)
})

it('still runs matchDedup against injected comments inside the envelope', () => {
const comment = {
id: 'c1',
type: 'footer',
body: 'Missing error handling the spec does not describe how errors are handled',
author: 'reviewer',
created: '',
}
const { status, stdout } = runDedupCli([FINDING], { comments: [comment], truncated: true })
assert.equal(status, 0)
const envelope = JSON.parse(stdout)
assert.equal(envelope.truncated, true)
// High similarity - expect skip or flag, not post
assert.ok(envelope.results[0].decision === 'skip' || envelope.results[0].decision === 'flag')
assert.ok(envelope.results[0].nearDuplicates.length > 0)
})

it('exits 1 with an error JSON on stderr when --findings-file is missing', () => {
// CLI validates both flags before reading files, so the file path need not exist.
const res = spawnSync(process.execPath, [DEDUP_PATH, '--comments-file', 'dummy.json'], { encoding: 'utf8' })
assert.equal(res.status, 1)
const err = JSON.parse(res.stderr)
assert.ok(typeof err.error === 'string' && err.error.includes('Usage'))
})

it('exits 1 with an error JSON on stderr when --comments-file is missing', () => {
const res = spawnSync(process.execPath, [DEDUP_PATH, '--findings-file', 'dummy.json'], { encoding: 'utf8' })
assert.equal(res.status, 1)
const err = JSON.parse(res.stderr)
assert.ok(typeof err.error === 'string' && err.error.includes('Usage'))
})
})
Loading