@aperant/framework 0.7.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/agents/apt-planner.md +12 -0
- package/agents/apt-pr-review-fixer.md +13 -9
- package/bin/apt-tools.mjs +7 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
- package/dist/cli/ci-watch/stop-matrix.mjs +16 -0
- package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
- package/dist/cli/commands/ci-watch.d.mts +11 -0
- package/dist/cli/commands/ci-watch.d.mts.map +1 -1
- package/dist/cli/commands/ci-watch.mjs +108 -2
- package/dist/cli/commands/ci-watch.mjs.map +1 -1
- package/dist/cli/commands/features-audit.d.mts +24 -0
- package/dist/cli/commands/features-audit.d.mts.map +1 -1
- package/dist/cli/commands/features-audit.mjs +159 -5
- package/dist/cli/commands/features-audit.mjs.map +1 -1
- package/dist/cli/commands/health-check.d.mts +16 -0
- package/dist/cli/commands/health-check.d.mts.map +1 -1
- package/dist/cli/commands/health-check.mjs +118 -2
- package/dist/cli/commands/health-check.mjs.map +1 -1
- package/dist/cli/commands/init.d.mts +9 -0
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +49 -4
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/modes.d.mts.map +1 -1
- package/dist/cli/commands/modes.mjs +11 -0
- package/dist/cli/commands/modes.mjs.map +1 -1
- package/dist/cli/commands/pr-review-audit-fixer.d.mts +13 -0
- package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
- package/dist/cli/commands/pr-review-audit-fixer.mjs +18 -5
- package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +84 -5
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/validate-evidence.d.mts +24 -2
- package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
- package/dist/cli/commands/validate-evidence.mjs +154 -17
- package/dist/cli/commands/validate-evidence.mjs.map +1 -1
- package/dist/cli/commands/vitest-doctor.d.mts +2 -0
- package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
- package/dist/cli/commands/vitest-doctor.mjs +168 -0
- package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
- package/dist/cli/config/gitignore-drift.d.mts +23 -1
- package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
- package/dist/cli/config/gitignore-drift.mjs +81 -3
- package/dist/cli/config/gitignore-drift.mjs.map +1 -1
- package/dist/cli/config/load.d.mts +56 -2
- package/dist/cli/config/load.d.mts.map +1 -1
- package/dist/cli/config/load.mjs +192 -2
- package/dist/cli/config/load.mjs.map +1 -1
- package/dist/cli/consistency/parse-review.mjs +7 -0
- package/dist/cli/consistency/parse-review.mjs.map +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +24 -2
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
- package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
- package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
- package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
- package/dist/cli/gate/gates/review-clean.d.mts +5 -1
- package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
- package/dist/cli/gate/gates/review-clean.mjs +23 -18
- package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
- package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
- package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
- package/dist/cli/gate/gates/verify-approved.mjs +93 -14
- package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +7 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/install/runtime-detect.d.mts +13 -0
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +9 -0
- package/dist/cli/install/runtime-detect.mjs.map +1 -1
- package/dist/cli/task/index-md.d.mts.map +1 -1
- package/dist/cli/task/index-md.mjs +14 -2
- package/dist/cli/task/index-md.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +11 -2
- package/dist/plugin/agents/apt-improver.md +99 -0
- package/dist/plugin/agents/apt-planner.md +127 -10
- package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
- package/dist/plugin/skills/apt/SKILL.md +1 -0
- package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
- package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
- package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
- package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
- package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
- package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
- package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
- package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
- package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
- package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
- package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
- package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
- package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
- package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
- package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
- package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
- package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
- package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
- package/dist/plugin/skills/apt-prototype/UI.md +90 -0
- package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
- package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
- package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
- package/dist/plugin/skills/apt-review/SKILL.md +2 -0
- package/dist/plugin/skills/apt-run/SKILL.md +32 -4
- package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
- package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
- package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
- package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
- package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
- package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
- package/dist/plugin/skills/apt-update/SKILL.md +77 -10
- package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
- package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
- package/package.json +133 -133
- package/prompts/conductor-framework-context.md +63 -0
- package/prompts/conductor-system.md +11 -0
- package/skills/apt-close-task/SKILL.md +1 -0
- package/skills/apt-discuss/SKILL.md +47 -5
- package/skills/apt-execute/SKILL.md +9 -0
- package/skills/apt-plan/SKILL.md +12 -0
- package/skills/apt-pr-review/SKILL.md +11 -2
- package/skills/apt-quick/SKILL.md +19 -8
- package/skills/apt-researcher.md +1 -0
- package/skills/apt-setup/SKILL.md +33 -2
- package/skills/apt-verify-proof/SKILL.md +7 -5
- package/src/cli/ci-watch/stop-matrix.mjs +17 -0
- package/src/cli/commands/ci-watch.mjs +113 -2
- package/src/cli/commands/features-audit.mjs +164 -5
- package/src/cli/commands/health-check.mjs +115 -2
- package/src/cli/commands/init.mjs +52 -4
- package/src/cli/commands/modes.mjs +11 -0
- package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
- package/src/cli/commands/task.mjs +83 -5
- package/src/cli/commands/validate-evidence.mjs +158 -17
- package/src/cli/commands/vitest-doctor.mjs +173 -0
- package/src/cli/config/gitignore-drift.mjs +74 -3
- package/src/cli/config/load.mjs +188 -2
- package/src/cli/consistency/parse-review.mjs +6 -0
- package/src/cli/dispatch.mjs +23 -2
- package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
- package/src/cli/gate/gates/review-clean.mjs +24 -19
- package/src/cli/gate/gates/verify-approved.mjs +97 -14
- package/src/cli/help.mjs +7 -2
- package/src/cli/install/runtime-detect.mjs +9 -0
- package/src/cli/task/index-md.mjs +15 -2
- package/templates/config.json +2 -7
- package/workflows/verify-proof.md +8 -3
|
@@ -668,10 +668,14 @@ export function decideVerdict({ statusLine, fixesApplied, diffFiles }) {
|
|
|
668
668
|
}
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
-
function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
|
|
672
|
-
//
|
|
673
|
-
// fixer-
|
|
674
|
-
//
|
|
671
|
+
export function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
|
|
672
|
+
// BUG-030: strict-ordered candidate list. Parallel fixers write to
|
|
673
|
+
// distinct fixer-named markdowns (fixes-applied-fixer-{N}.md AND/OR
|
|
674
|
+
// fixes-applied-{fixer}.md); the legacy group-named form remains for
|
|
675
|
+
// backward-compat with reviews started before this fix. fixes-applied.md
|
|
676
|
+
// (unscoped) stays last as the legacy single-fixer fallback. NO broad
|
|
677
|
+
// glob — false-positives on the wrong fixer's file are what caused the
|
|
678
|
+
// `hallucinated` flake (4 sightings in 3 days per spec-gaps).
|
|
675
679
|
const iterDir = join(reviewDir, 'iterations', String(iteration))
|
|
676
680
|
const candidates = []
|
|
677
681
|
if (fixer === 'self-reviewer') {
|
|
@@ -681,7 +685,9 @@ function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
|
|
|
681
685
|
const match = /^fixer-(\d+)$/.exec(fixer)
|
|
682
686
|
if (match) {
|
|
683
687
|
candidates.push(join(iterDir, `fixes-applied-group${match[1]}.md`))
|
|
688
|
+
candidates.push(join(iterDir, `fixes-applied-fixer-${match[1]}.md`))
|
|
684
689
|
}
|
|
690
|
+
candidates.push(join(iterDir, `fixes-applied-${fixer}.md`))
|
|
685
691
|
candidates.push(join(iterDir, 'fixes-applied.md'))
|
|
686
692
|
}
|
|
687
693
|
for (const c of candidates) {
|
|
@@ -802,7 +808,14 @@ export function cmdPrReviewAuditFixer(projectDir, extraArgs) {
|
|
|
802
808
|
|
|
803
809
|
const worktreeArg = flags.get('worktree')
|
|
804
810
|
if (!worktreeArg) return err('Required: --worktree <path>')
|
|
805
|
-
|
|
811
|
+
// BUG-015: resolve relative to the passed projectDir (not process.cwd()).
|
|
812
|
+
// Callers invoke audit-fixer from a non-worktree cwd (the main repo) with
|
|
813
|
+
// a relative --worktree like `../worktrees/foo`; resolving against cwd
|
|
814
|
+
// returned a bogus path that didn't exist, causing the audit to see zero
|
|
815
|
+
// staged files and false-positive `hallucinated`. The existing `cwd:
|
|
816
|
+
// worktree` at the spawn site (line 437-473) already scopes git
|
|
817
|
+
// invocations correctly, so no `git -C` change is needed.
|
|
818
|
+
const worktree = resolve(projectDir, worktreeArg)
|
|
806
819
|
|
|
807
820
|
const statusLineArg = flags.get('status-line')
|
|
808
821
|
// Missing / empty status line is fail-closed to hallucinated, not a
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
import { spawnSync } from 'node:child_process'
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
16
16
|
import { join, resolve } from 'node:path'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getTaskIsolationConfig,
|
|
19
|
+
loadMergedProjectConfig,
|
|
20
|
+
loadProjectConfig,
|
|
21
|
+
} from '../config/load.mjs'
|
|
18
22
|
import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
|
|
19
23
|
import { createCoordinationStore } from '../coordination/store.mjs'
|
|
20
24
|
import { getGitIdentity } from '../git/identity.mjs'
|
|
@@ -127,9 +131,16 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
127
131
|
// a later roadmap add-milestone/add-phase may populate it.
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
// AUDIT-001: read through the merged loader so local-only
|
|
135
|
+
// overrides for preferences.date_format / preferences.time_format
|
|
136
|
+
// win over the shared config.json fallback.
|
|
137
|
+
// Guard: malformed config.local.json must not crash task creation.
|
|
138
|
+
let config
|
|
139
|
+
try {
|
|
140
|
+
config = loadMergedProjectConfig(targetDir) ?? {}
|
|
141
|
+
} catch {
|
|
142
|
+
config = {}
|
|
143
|
+
}
|
|
133
144
|
// User-supplied --id bypasses the collision guard (caller owns the
|
|
134
145
|
// uniqueness contract). Generated ids run through a numeric-counter
|
|
135
146
|
// disambiguator that appends -2, -3, … when the candidate already
|
|
@@ -982,7 +993,50 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
982
993
|
state.last_activity = new Date().toISOString()
|
|
983
994
|
atomicWriteJson(statePath, state)
|
|
984
995
|
if (prUrlForCiWatch) {
|
|
985
|
-
|
|
996
|
+
// BUG-027 (a): pre-arm gh check. If the PR is already
|
|
997
|
+
// MERGED/CLOSED, skip arming so we don't leak a new
|
|
998
|
+
// watcher state file we'll immediately have to GC. gh
|
|
999
|
+
// rate-limit / offline / ENOENT falls back to arm (mirror
|
|
1000
|
+
// the existing rate_limited/offline patterns at lines
|
|
1001
|
+
// 1322/1326) — non-fatal so a transient gh failure
|
|
1002
|
+
// doesn't prevent legitimate watcher arming.
|
|
1003
|
+
const ghFixture = process.env.APT_GH_FIXTURE_JSON
|
|
1004
|
+
let terminalState = null
|
|
1005
|
+
if (ghFixture) {
|
|
1006
|
+
try {
|
|
1007
|
+
const parsed = JSON.parse(ghFixture)
|
|
1008
|
+
if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
|
|
1009
|
+
terminalState = parsed.state
|
|
1010
|
+
}
|
|
1011
|
+
} catch {
|
|
1012
|
+
/* malformed fixture → fall through to arm */
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
try {
|
|
1016
|
+
const res = spawnSync('gh', ['pr', 'view', prUrlForCiWatch, '--json', 'state'], {
|
|
1017
|
+
cwd: targetDir,
|
|
1018
|
+
encoding: 'utf-8',
|
|
1019
|
+
})
|
|
1020
|
+
if (res.status === 0 && res.stdout) {
|
|
1021
|
+
const parsed = JSON.parse(res.stdout)
|
|
1022
|
+
if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
|
|
1023
|
+
terminalState = parsed.state
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/* non-zero exit / ENOENT / parse error → fall through to arm */
|
|
1027
|
+
} catch {
|
|
1028
|
+
/* never block arming on gh transport error */
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (terminalState) {
|
|
1032
|
+
ciWatchResult = {
|
|
1033
|
+
ci_watch_armed: false,
|
|
1034
|
+
ci_watch_skip_reason: 'pr-terminal-state',
|
|
1035
|
+
ci_watch: null,
|
|
1036
|
+
}
|
|
1037
|
+
} else {
|
|
1038
|
+
ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
|
|
1039
|
+
}
|
|
986
1040
|
}
|
|
987
1041
|
|
|
988
1042
|
regenerateTaskIndex(targetDir)
|
|
@@ -1479,6 +1533,30 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1479
1533
|
mergedAt: parsed.mergedAt ?? null,
|
|
1480
1534
|
})
|
|
1481
1535
|
|
|
1536
|
+
// BUG-027 (b): tear down any armed ci-watch state for this PR.
|
|
1537
|
+
// `doStop` is idempotent (returns `not-found` for missing state
|
|
1538
|
+
// files), so calling unconditionally here is safe. Without this
|
|
1539
|
+
// the watcher state file leaks past PR-terminal-state and
|
|
1540
|
+
// downstream operations (ci-watch sweep, health-check) flag it
|
|
1541
|
+
// as orphaned. Failure is logged into errors[] as a warning;
|
|
1542
|
+
// the merge close itself has already succeeded.
|
|
1543
|
+
try {
|
|
1544
|
+
const prNumber = parsed.number ?? extractPrNumber(task.pr_url)
|
|
1545
|
+
if (prNumber !== null) {
|
|
1546
|
+
cmdCiWatch('stop', targetDir, [
|
|
1547
|
+
'--pr',
|
|
1548
|
+
String(prNumber),
|
|
1549
|
+
'--reason',
|
|
1550
|
+
'task-closed-merged',
|
|
1551
|
+
])
|
|
1552
|
+
}
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
errors.push({
|
|
1555
|
+
id,
|
|
1556
|
+
reason: `ci-watch-stop-failed: ${e?.message ?? String(e)}`,
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1482
1560
|
// Append a pending_narration[] ledger entry on successful merged close
|
|
1483
1561
|
// so /apt:close-task --narrate-only can drain it later. The ledger is
|
|
1484
1562
|
// additive-only; failure to write must never undo the close above
|
|
@@ -166,7 +166,12 @@ export function isAnnotationLine(line, sourceWindow) {
|
|
|
166
166
|
*/
|
|
167
167
|
export function parseFindingsMarkdown(markdown) {
|
|
168
168
|
const findings = []
|
|
169
|
-
|
|
169
|
+
// BUG-032: widen to accept H2|H3, optional `Finding:` prefix, and any of
|
|
170
|
+
// ASCII `--`, em-dash `—`, or en-dash `–` as the id/title separator. The
|
|
171
|
+
// validator-agent's consolidated `validated.md` shape uses H3 + em-dash
|
|
172
|
+
// with no `Finding:` prefix; the specialist shape uses H2 + `Finding:` +
|
|
173
|
+
// ASCII `--`. Both must round-trip through the same parser.
|
|
174
|
+
const headerRx = /^#{2,3}\s+(?:Finding:\s*)?([A-Z]+-\d+)\s*(?:--|—|–)\s*(.+)$/gm
|
|
170
175
|
const matches = [...markdown.matchAll(headerRx)]
|
|
171
176
|
for (let i = 0; i < matches.length; i++) {
|
|
172
177
|
const m = matches[i]
|
|
@@ -202,23 +207,45 @@ function parseFindingBlock(body, id, title, start, end) {
|
|
|
202
207
|
// FRAMEWORK-BUG-001 surfaced by /apt:pr-review on PR #68 in 2026-04).
|
|
203
208
|
// `(?:-\d+)?` consumes the trailing range so it doesn't leak into the
|
|
204
209
|
// file capture.
|
|
210
|
+
// BUG-032: the validator-agent's `validated.md` carries the cited path
|
|
211
|
+
// in `- **Original**:` instead of `- **File**:`. Map either spelling
|
|
212
|
+
// onto `file`/`line`; specialist shape continues to write to `file`.
|
|
213
|
+
const fileField = fields.file ?? fields.original
|
|
205
214
|
let file = null
|
|
206
215
|
let line = null
|
|
207
|
-
|
|
208
|
-
|
|
216
|
+
let lineRangeFromField = null
|
|
217
|
+
if (fileField) {
|
|
218
|
+
const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-(\d+))?)?`?\s*$/.exec(fileField)
|
|
209
219
|
if (fileMatch) {
|
|
210
220
|
file = fileMatch[1].trim()
|
|
211
221
|
line = fileMatch[2] ? Number(fileMatch[2]) : null
|
|
222
|
+
if (fileMatch[2] && fileMatch[3]) {
|
|
223
|
+
lineRangeFromField = [Number(fileMatch[2]), Number(fileMatch[3])]
|
|
224
|
+
}
|
|
212
225
|
}
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
// Description block: everything between `### Description` and the next `###`.
|
|
216
229
|
const description = extractSection(body, 'Description') ?? ''
|
|
217
230
|
|
|
218
|
-
// Evidence code block: first triple-backticked block after `### Evidence
|
|
231
|
+
// Evidence code block: first triple-backticked block after `### Evidence`
|
|
232
|
+
// (specialist shape). BUG-032: validator-consolidated `validated.md`
|
|
233
|
+
// shape carries evidence as a `- **Evidence**:` field whose value is the
|
|
234
|
+
// fenced block immediately following the field line. Fall back to the
|
|
235
|
+
// first fenced block after that field marker when the section heading
|
|
236
|
+
// form yields nothing.
|
|
219
237
|
const evidenceSection = extractSection(body, 'Evidence') ?? ''
|
|
238
|
+
let evidence
|
|
220
239
|
const fenced = /```[^\n]*\n([\s\S]*?)```/.exec(evidenceSection)
|
|
221
|
-
|
|
240
|
+
if (fenced) {
|
|
241
|
+
evidence = fenced[1]
|
|
242
|
+
} else if (evidenceSection) {
|
|
243
|
+
evidence = evidenceSection
|
|
244
|
+
} else {
|
|
245
|
+
const fieldEvidenceRx = /-\s*\*\*Evidence\*\*:\s*(?:\n+)```[^\n]*\n([\s\S]*?)```/i
|
|
246
|
+
const fieldFenced = fieldEvidenceRx.exec(body)
|
|
247
|
+
evidence = fieldFenced ? fieldFenced[1] : (fields.evidence ?? '')
|
|
248
|
+
}
|
|
222
249
|
|
|
223
250
|
// Suggested Fix section (prose + optional fenced snippets). Unlike
|
|
224
251
|
// Evidence we keep the whole section — snippets AND surrounding
|
|
@@ -231,9 +258,11 @@ function parseFindingBlock(body, id, title, start, end) {
|
|
|
231
258
|
const rangeMatch = /Lines? examined[^\d]*(\d+)\s*-\s*(\d+)/i.exec(contextSection)
|
|
232
259
|
const lineRange = rangeMatch
|
|
233
260
|
? [Number(rangeMatch[1]), Number(rangeMatch[2])]
|
|
234
|
-
:
|
|
235
|
-
?
|
|
236
|
-
:
|
|
261
|
+
: lineRangeFromField
|
|
262
|
+
? lineRangeFromField
|
|
263
|
+
: line
|
|
264
|
+
? [line, line]
|
|
265
|
+
: null
|
|
237
266
|
|
|
238
267
|
// Cross-file evidence (optional): `### Cross-File Evidence` block.
|
|
239
268
|
// Each file gets a `- **File**: path` + fenced snippet.
|
|
@@ -294,11 +323,64 @@ export function detectReferencedFiles(finding) {
|
|
|
294
323
|
return [...tokens].sort()
|
|
295
324
|
}
|
|
296
325
|
|
|
326
|
+
/**
|
|
327
|
+
* BUG-014 fuzzy-evidence helpers — token-set Jaccard + line-anchor count.
|
|
328
|
+
*
|
|
329
|
+
* Tokenize on word characters so whitespace, punctuation, and operator
|
|
330
|
+
* spacing don't perturb similarity. Same tokenizer runs on both sides so
|
|
331
|
+
* the ratio is symmetric.
|
|
332
|
+
*/
|
|
333
|
+
export function tokenizeForJaccard(text) {
|
|
334
|
+
if (!text) return []
|
|
335
|
+
return (
|
|
336
|
+
String(text)
|
|
337
|
+
.toLowerCase()
|
|
338
|
+
.match(/[a-z0-9_$]+/g) ?? []
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function jaccardSimilarity(a, b) {
|
|
343
|
+
const setA = new Set(tokenizeForJaccard(a))
|
|
344
|
+
const setB = new Set(tokenizeForJaccard(b))
|
|
345
|
+
if (setA.size === 0 && setB.size === 0) return 1
|
|
346
|
+
if (setA.size === 0 || setB.size === 0) return 0
|
|
347
|
+
let intersection = 0
|
|
348
|
+
for (const t of setA) if (setB.has(t)) intersection++
|
|
349
|
+
const union = setA.size + setB.size - intersection
|
|
350
|
+
return intersection / union
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Count evidence lines that appear as literal substrings of the cited
|
|
355
|
+
* file range (after `normalizeForLiteralMatch`). Used as the
|
|
356
|
+
* anchor-count mitigation against token-stuffing under fuzzy mode.
|
|
357
|
+
*/
|
|
358
|
+
export function countExactLineAnchors(evidenceLines, target) {
|
|
359
|
+
const normTarget = normalizeForLiteralMatch(target)
|
|
360
|
+
let count = 0
|
|
361
|
+
for (const line of evidenceLines) {
|
|
362
|
+
const normLine = normalizeForLiteralMatch(line)
|
|
363
|
+
if (!normLine) continue
|
|
364
|
+
if (normTarget.includes(normLine)) count++
|
|
365
|
+
}
|
|
366
|
+
return count
|
|
367
|
+
}
|
|
368
|
+
|
|
297
369
|
/**
|
|
298
370
|
* Check a single finding against the literal-substring rule. Returns
|
|
299
371
|
* a decision object; caller applies it to the markdown.
|
|
372
|
+
*
|
|
373
|
+
* Optional `opts.allowApprox` enables BUG-014 opt-in fuzzy fallback —
|
|
374
|
+
* when the literal check fails, compute Jaccard ≥ `approxFloor` on the
|
|
375
|
+
* cited range AND require ≥2 exact line anchors in the cited range
|
|
376
|
+
* (token-stuffing mitigation). On success, emit downgraded status
|
|
377
|
+
* `dismissed_evidence_approx_confirmed` unless `opts.llmValidatorConfirmed`
|
|
378
|
+
* is also set, in which case status becomes `confirmed_valid_approx`.
|
|
300
379
|
*/
|
|
301
|
-
export function checkFinding(finding, worktreeRoot) {
|
|
380
|
+
export function checkFinding(finding, worktreeRoot, opts = {}) {
|
|
381
|
+
const allowApprox = Boolean(opts.allowApprox)
|
|
382
|
+
const approxFloor = typeof opts.approxFloor === 'number' ? opts.approxFloor : 0.85
|
|
383
|
+
const llmValidatorConfirmed = Boolean(opts.llmValidatorConfirmed)
|
|
302
384
|
// Rule 1: evidence must be a literal substring of the cited file range.
|
|
303
385
|
if (!finding.file) {
|
|
304
386
|
return {
|
|
@@ -361,6 +443,10 @@ export function checkFinding(finding, worktreeRoot) {
|
|
|
361
443
|
const normTarget = normalizeForLiteralMatch(target)
|
|
362
444
|
const normWholeEvidence = normalizeForLiteralMatch(keptLines.join('\n'))
|
|
363
445
|
|
|
446
|
+
// Capture the first literal-mismatch reason so the fuzzy fallback can
|
|
447
|
+
// reuse it as the rejection note when fuzzy ALSO fails.
|
|
448
|
+
let literalRejectNote = null
|
|
449
|
+
|
|
364
450
|
if (normTarget.includes(normWholeEvidence)) {
|
|
365
451
|
// Fast path: the kept evidence (joined back) is still a literal
|
|
366
452
|
// contiguous substring of the cited range. This is the common
|
|
@@ -370,17 +456,43 @@ export function checkFinding(finding, worktreeRoot) {
|
|
|
370
456
|
// (e.g. `code line A\n// ... 10 more\ncode line B`). Match each
|
|
371
457
|
// kept line individually against the cited range. Every kept
|
|
372
458
|
// line must appear; any miss is a paraphrase / fabrication and
|
|
373
|
-
// the whole finding is rejected
|
|
459
|
+
// the whole finding is rejected — unless the caller opted into
|
|
460
|
+
// fuzzy fallback (BUG-014).
|
|
374
461
|
for (const line of keptLines) {
|
|
375
462
|
const normLine = normalizeForLiteralMatch(line)
|
|
376
463
|
if (!normLine) continue
|
|
377
464
|
if (!normTarget.includes(normLine)) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
465
|
+
literalRejectNote = `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`
|
|
466
|
+
break
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// BUG-014: opt-in fuzzy fallback. Default behaviour (allowApprox=false)
|
|
472
|
+
// preserves the anti-fabrication guarantee — literal mismatch returns
|
|
473
|
+
// `dismissed_evidence_not_literal` unchanged. When the caller passes
|
|
474
|
+
// `--allow-approx-evidence`, attempt Jaccard ≥ floor AND ≥2 exact
|
|
475
|
+
// line anchors in the cited range (mitigates token-stuffing). On
|
|
476
|
+
// success, downgrade status to `dismissed_evidence_approx_confirmed`
|
|
477
|
+
// unless the caller also passed `--llm-validator-confirmed`, in
|
|
478
|
+
// which case promote to `confirmed_valid_approx`.
|
|
479
|
+
if (literalRejectNote) {
|
|
480
|
+
if (!allowApprox) {
|
|
481
|
+
return { status: 'dismissed_evidence_not_literal', note: literalRejectNote }
|
|
482
|
+
}
|
|
483
|
+
const similarity = jaccardSimilarity(keptLines.join('\n'), target)
|
|
484
|
+
const anchors = countExactLineAnchors(keptLines, target)
|
|
485
|
+
if (similarity < approxFloor || anchors < 2) {
|
|
486
|
+
return {
|
|
487
|
+
status: 'dismissed_evidence_not_literal',
|
|
488
|
+
note: `${literalRejectNote} Fuzzy fallback also rejected (jaccard=${similarity.toFixed(3)} floor=${approxFloor}, anchors=${anchors}/2).`,
|
|
382
489
|
}
|
|
383
490
|
}
|
|
491
|
+
const approxNote = `Approximate match accepted under --allow-approx-evidence (jaccard=${similarity.toFixed(3)} ≥ ${approxFloor}, anchors=${anchors} ≥ 2).`
|
|
492
|
+
if (llmValidatorConfirmed) {
|
|
493
|
+
return { status: 'confirmed_valid_approx', note: approxNote }
|
|
494
|
+
}
|
|
495
|
+
return { status: 'dismissed_evidence_approx_confirmed', note: approxNote }
|
|
384
496
|
}
|
|
385
497
|
|
|
386
498
|
// Rule 2: multi-file references require Cross-File Evidence.
|
|
@@ -490,6 +602,18 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
|
|
|
490
602
|
const worktreeRoot = resolve(worktree)
|
|
491
603
|
if (!existsSync(worktreeRoot)) return err(`Worktree not found: ${worktreeRoot}`)
|
|
492
604
|
|
|
605
|
+
// BUG-014: opt-in fuzzy-evidence flags. Defaults preserve the
|
|
606
|
+
// anti-fabrication guarantee — literal substring is still the only
|
|
607
|
+
// matcher when no flag is set.
|
|
608
|
+
const allowApprox = flags.get('allow-approx-evidence') === true
|
|
609
|
+
const approxFloorRaw = flags.get('approx-floor')
|
|
610
|
+
const approxFloor = typeof approxFloorRaw === 'string' ? Number(approxFloorRaw) : 0.85
|
|
611
|
+
if (allowApprox && (!Number.isFinite(approxFloor) || approxFloor <= 0 || approxFloor > 1)) {
|
|
612
|
+
return err(`Invalid --approx-floor value: ${approxFloorRaw} (expected 0 < n ≤ 1)`)
|
|
613
|
+
}
|
|
614
|
+
const llmValidatorConfirmed = flags.get('llm-validator-confirmed') === true
|
|
615
|
+
const checkOpts = { allowApprox, approxFloor, llmValidatorConfirmed }
|
|
616
|
+
|
|
493
617
|
const files = readdirSync(dir).filter((f) => f.endsWith('.md'))
|
|
494
618
|
const summary = {
|
|
495
619
|
status: 'ok',
|
|
@@ -506,13 +630,21 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
|
|
|
506
630
|
const full = join(dir, f)
|
|
507
631
|
const md = readFileSync(full, 'utf-8')
|
|
508
632
|
const findings = parseFindingsMarkdown(md)
|
|
633
|
+
|
|
634
|
+
// BUG-032: always record the file in `processed_files`. The
|
|
635
|
+
// previous `continue` on empty findings caused validated.md to
|
|
636
|
+
// silently no-op when the header regex didn't match, leaving
|
|
637
|
+
// downstream gates with `processed_files: []` and no audit trail
|
|
638
|
+
// that the file was even read.
|
|
639
|
+
summary.processed_files.push({ file: f, findings_count: findings.length })
|
|
640
|
+
|
|
509
641
|
if (findings.length === 0) continue
|
|
510
642
|
|
|
511
643
|
const decisions = []
|
|
512
644
|
for (const finding of findings) {
|
|
513
|
-
const decision = checkFinding(finding, worktreeRoot)
|
|
645
|
+
const decision = checkFinding(finding, worktreeRoot, checkOpts)
|
|
514
646
|
decisions.push({ finding, decision })
|
|
515
|
-
if (decision.status === 'confirmed_valid') {
|
|
647
|
+
if (decision.status === 'confirmed_valid' || decision.status === 'confirmed_valid_approx') {
|
|
516
648
|
summary.validated++
|
|
517
649
|
} else {
|
|
518
650
|
summary.rejected++
|
|
@@ -530,7 +662,16 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
|
|
|
530
662
|
if (rewritten !== md) {
|
|
531
663
|
writeFileSync(full, rewritten, 'utf-8')
|
|
532
664
|
}
|
|
533
|
-
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// BUG-032: surface parser-shape drift early. If we read N markdown
|
|
668
|
+
// files but extracted zero findings, warn on stderr so future schema
|
|
669
|
+
// changes don't silently no-op the gate.
|
|
670
|
+
const totalFindings = summary.processed_files.reduce((acc, e) => acc + (e.findings_count ?? 0), 0)
|
|
671
|
+
if (files.length > 0 && totalFindings === 0) {
|
|
672
|
+
process.stderr.write(
|
|
673
|
+
`WARNING: validate-evidence read ${files.length} markdown file(s) but extracted 0 findings — parser shape mismatch?\n`,
|
|
674
|
+
)
|
|
534
675
|
}
|
|
535
676
|
|
|
536
677
|
return ok(summary)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/vitest-doctor.mjs — FRAMEWORK-BUG-026 diagnostic subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Globs every `vitest.config.{ts,js,mjs}` under the project root (skipping
|
|
5
|
+
* node_modules), parses each for the two shim signals the framework
|
|
6
|
+
* centralizes in `packages/framework/test-utils/vitest.shared.mjs`:
|
|
7
|
+
*
|
|
8
|
+
* - `NODE_ENV='test'` (process.env.NODE_ENV set OR defineVitestShim active)
|
|
9
|
+
* - `oxc.jsx.runtime='automatic'` (jsxAutomaticShim active)
|
|
10
|
+
*
|
|
11
|
+
* Reports a matrix and warns when a workspace has JSX-rendering tests
|
|
12
|
+
* (any `*.test.tsx` under `src/`) but no jsxAutomaticShim coverage —
|
|
13
|
+
* that's the BUG-026 first-test paper cut.
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* 0 — no warnings (or only informational matrix output)
|
|
17
|
+
* 1 — at least one workspace has JSX-rendering tests without
|
|
18
|
+
* jsxAutomaticShim coverage
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
+
import { join, relative, resolve } from 'node:path'
|
|
23
|
+
import { parseFlags } from '../util/args.mjs'
|
|
24
|
+
import { err, exitWith, ok } from '../util/result.mjs'
|
|
25
|
+
|
|
26
|
+
const VITEST_CONFIG_NAMES = new Set(['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'])
|
|
27
|
+
|
|
28
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'out', '.next', '.aperant'])
|
|
29
|
+
|
|
30
|
+
export function cmdVitestDoctor(projectDir, extraArgs) {
|
|
31
|
+
if (!projectDir) return err('Usage: apt-tools vitest doctor <project-dir> [--format json|md]')
|
|
32
|
+
const root = resolve(projectDir)
|
|
33
|
+
if (!existsSync(root)) return err(`project-dir not found: ${root}`)
|
|
34
|
+
const flags = parseFlags(extraArgs ?? [])
|
|
35
|
+
const format = (flags.get('format') || 'json').toLowerCase()
|
|
36
|
+
|
|
37
|
+
const configs = findVitestConfigs(root)
|
|
38
|
+
const matrix = configs.map((configPath) => inspectConfig(root, configPath))
|
|
39
|
+
const warnings = matrix
|
|
40
|
+
.filter((row) => row.has_jsx_rendering_tests && !row.jsx_automatic_shim)
|
|
41
|
+
.map((row) => ({
|
|
42
|
+
workspace: row.workspace,
|
|
43
|
+
reason:
|
|
44
|
+
'has JSX-rendering tests under src/ but vitest.config does not enable jsxAutomaticShim',
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
const envelope = {
|
|
48
|
+
status: 'ok',
|
|
49
|
+
command: 'vitest-doctor',
|
|
50
|
+
project_dir: root,
|
|
51
|
+
configs_found: matrix.length,
|
|
52
|
+
matrix,
|
|
53
|
+
warnings,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (format === 'md') {
|
|
57
|
+
envelope.markdown = renderMarkdown(matrix, warnings)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (warnings.length > 0) {
|
|
61
|
+
// Exit code 1 — surface drift so the user knows to wire the shim.
|
|
62
|
+
return exitWith(envelope, 1)
|
|
63
|
+
}
|
|
64
|
+
return ok(envelope)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findVitestConfigs(root) {
|
|
68
|
+
const out = []
|
|
69
|
+
walk(root, root, out)
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walk(rootAbs, dirAbs, out) {
|
|
74
|
+
let names
|
|
75
|
+
try {
|
|
76
|
+
names = readdirSync(dirAbs)
|
|
77
|
+
} catch {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (SKIP_DIRS.has(name)) continue
|
|
82
|
+
const abs = join(dirAbs, name)
|
|
83
|
+
let st
|
|
84
|
+
try {
|
|
85
|
+
st = statSync(abs)
|
|
86
|
+
} catch {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (st.isDirectory()) {
|
|
90
|
+
walk(rootAbs, abs, out)
|
|
91
|
+
} else if (st.isFile() && VITEST_CONFIG_NAMES.has(name)) {
|
|
92
|
+
out.push(abs)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function inspectConfig(rootAbs, configPath) {
|
|
98
|
+
const workspace = relative(rootAbs, configPath)
|
|
99
|
+
const dir = join(configPath, '..')
|
|
100
|
+
let body = ''
|
|
101
|
+
try {
|
|
102
|
+
body = readFileSync(configPath, 'utf-8')
|
|
103
|
+
} catch {
|
|
104
|
+
return {
|
|
105
|
+
workspace,
|
|
106
|
+
node_env_shim: false,
|
|
107
|
+
jsx_automatic_shim: false,
|
|
108
|
+
has_jsx_rendering_tests: false,
|
|
109
|
+
error: 'read-failed',
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// NODE_ENV shim: either the literal `process.env.NODE_ENV = 'test'`
|
|
113
|
+
// pattern OR `defineVitestShim(` (any non-explicit-false arg).
|
|
114
|
+
const node_env_shim =
|
|
115
|
+
/process\.env\.NODE_ENV\s*=\s*['"`]test['"`]/.test(body) ||
|
|
116
|
+
/defineVitestShim\([^)]*\)/.test(body)
|
|
117
|
+
// jsxAutomaticShim: literal `oxc.jsx.runtime` = 'automatic' OR
|
|
118
|
+
// `jsxAutomaticShim: true` inside a defineVitestShim() arg.
|
|
119
|
+
const jsx_automatic_shim =
|
|
120
|
+
/oxc\s*:\s*\{[^}]*jsx[^}]*runtime\s*:\s*['"`]automatic['"`]/s.test(body) ||
|
|
121
|
+
/jsxAutomaticShim\s*:\s*true/.test(body)
|
|
122
|
+
const has_jsx_rendering_tests = workspaceHasJsxRenderingTests(dir)
|
|
123
|
+
return { workspace, node_env_shim, jsx_automatic_shim, has_jsx_rendering_tests }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function workspaceHasJsxRenderingTests(workspaceDir) {
|
|
127
|
+
const srcDir = join(workspaceDir, 'src')
|
|
128
|
+
if (!existsSync(srcDir)) return false
|
|
129
|
+
return scanForTsxTest(srcDir)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function scanForTsxTest(dirAbs) {
|
|
133
|
+
let names
|
|
134
|
+
try {
|
|
135
|
+
names = readdirSync(dirAbs)
|
|
136
|
+
} catch {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
if (SKIP_DIRS.has(name)) continue
|
|
141
|
+
const abs = join(dirAbs, name)
|
|
142
|
+
let st
|
|
143
|
+
try {
|
|
144
|
+
st = statSync(abs)
|
|
145
|
+
} catch {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
if (scanForTsxTest(abs)) return true
|
|
150
|
+
} else if (st.isFile() && /\.test\.tsx$/.test(name)) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderMarkdown(matrix, warnings) {
|
|
158
|
+
const lines = ['## vitest-doctor matrix', '']
|
|
159
|
+
lines.push('| workspace | NODE_ENV shim | jsxAutomaticShim | has JSX tests |')
|
|
160
|
+
lines.push('|-----------|---------------|------------------|---------------|')
|
|
161
|
+
for (const r of matrix) {
|
|
162
|
+
lines.push(
|
|
163
|
+
`| \`${r.workspace}\` | ${r.node_env_shim ? '✓' : '✗'} | ${r.jsx_automatic_shim ? '✓' : '✗'} | ${r.has_jsx_rendering_tests ? '✓' : '—'} |`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
if (warnings.length > 0) {
|
|
167
|
+
lines.push('', '## Warnings', '')
|
|
168
|
+
for (const w of warnings) {
|
|
169
|
+
lines.push(`- \`${w.workspace}\` — ${w.reason}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return lines.join('\n')
|
|
173
|
+
}
|