@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.
Files changed (151) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/agents/apt-planner.md +12 -0
  3. package/agents/apt-pr-review-fixer.md +13 -9
  4. package/bin/apt-tools.mjs +7 -0
  5. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  6. package/dist/cli/ci-watch/stop-matrix.mjs +16 -0
  7. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  8. package/dist/cli/commands/ci-watch.d.mts +11 -0
  9. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  10. package/dist/cli/commands/ci-watch.mjs +108 -2
  11. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  12. package/dist/cli/commands/features-audit.d.mts +24 -0
  13. package/dist/cli/commands/features-audit.d.mts.map +1 -1
  14. package/dist/cli/commands/features-audit.mjs +159 -5
  15. package/dist/cli/commands/features-audit.mjs.map +1 -1
  16. package/dist/cli/commands/health-check.d.mts +16 -0
  17. package/dist/cli/commands/health-check.d.mts.map +1 -1
  18. package/dist/cli/commands/health-check.mjs +118 -2
  19. package/dist/cli/commands/health-check.mjs.map +1 -1
  20. package/dist/cli/commands/init.d.mts +9 -0
  21. package/dist/cli/commands/init.d.mts.map +1 -1
  22. package/dist/cli/commands/init.mjs +49 -4
  23. package/dist/cli/commands/init.mjs.map +1 -1
  24. package/dist/cli/commands/modes.d.mts.map +1 -1
  25. package/dist/cli/commands/modes.mjs +11 -0
  26. package/dist/cli/commands/modes.mjs.map +1 -1
  27. package/dist/cli/commands/pr-review-audit-fixer.d.mts +13 -0
  28. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  29. package/dist/cli/commands/pr-review-audit-fixer.mjs +18 -5
  30. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  31. package/dist/cli/commands/task.d.mts.map +1 -1
  32. package/dist/cli/commands/task.mjs +84 -5
  33. package/dist/cli/commands/task.mjs.map +1 -1
  34. package/dist/cli/commands/validate-evidence.d.mts +24 -2
  35. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  36. package/dist/cli/commands/validate-evidence.mjs +154 -17
  37. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  38. package/dist/cli/commands/vitest-doctor.d.mts +2 -0
  39. package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
  40. package/dist/cli/commands/vitest-doctor.mjs +168 -0
  41. package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
  42. package/dist/cli/config/gitignore-drift.d.mts +23 -1
  43. package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
  44. package/dist/cli/config/gitignore-drift.mjs +81 -3
  45. package/dist/cli/config/gitignore-drift.mjs.map +1 -1
  46. package/dist/cli/config/load.d.mts +56 -2
  47. package/dist/cli/config/load.d.mts.map +1 -1
  48. package/dist/cli/config/load.mjs +192 -2
  49. package/dist/cli/config/load.mjs.map +1 -1
  50. package/dist/cli/consistency/parse-review.mjs +7 -0
  51. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  52. package/dist/cli/dispatch.d.mts.map +1 -1
  53. package/dist/cli/dispatch.mjs +24 -2
  54. package/dist/cli/dispatch.mjs.map +1 -1
  55. package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
  56. package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
  57. package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  58. package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
  59. package/dist/cli/gate/gates/review-clean.d.mts +5 -1
  60. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  61. package/dist/cli/gate/gates/review-clean.mjs +23 -18
  62. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  63. package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
  64. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  65. package/dist/cli/gate/gates/verify-approved.mjs +93 -14
  66. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  67. package/dist/cli/help.d.mts.map +1 -1
  68. package/dist/cli/help.mjs +7 -2
  69. package/dist/cli/help.mjs.map +1 -1
  70. package/dist/cli/install/runtime-detect.d.mts +13 -0
  71. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  72. package/dist/cli/install/runtime-detect.mjs +9 -0
  73. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  74. package/dist/cli/task/index-md.d.mts.map +1 -1
  75. package/dist/cli/task/index-md.mjs +14 -2
  76. package/dist/cli/task/index-md.mjs.map +1 -1
  77. package/dist/plugin/.claude-plugin/plugin.json +11 -2
  78. package/dist/plugin/agents/apt-improver.md +99 -0
  79. package/dist/plugin/agents/apt-planner.md +127 -10
  80. package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
  81. package/dist/plugin/skills/apt/SKILL.md +1 -0
  82. package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
  83. package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
  84. package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  85. package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
  86. package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
  87. package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
  88. package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  89. package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  90. package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
  91. package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
  92. package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
  93. package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  94. package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
  95. package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
  96. package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
  97. package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
  98. package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
  99. package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
  100. package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
  101. package/dist/plugin/skills/apt-prototype/UI.md +90 -0
  102. package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
  103. package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
  104. package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  105. package/dist/plugin/skills/apt-review/SKILL.md +2 -0
  106. package/dist/plugin/skills/apt-run/SKILL.md +32 -4
  107. package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
  108. package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
  109. package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
  110. package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
  111. package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  112. package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
  113. package/dist/plugin/skills/apt-update/SKILL.md +77 -10
  114. package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
  115. package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
  116. package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
  117. package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
  118. package/package.json +133 -133
  119. package/prompts/conductor-framework-context.md +63 -0
  120. package/prompts/conductor-system.md +11 -0
  121. package/skills/apt-close-task/SKILL.md +1 -0
  122. package/skills/apt-discuss/SKILL.md +47 -5
  123. package/skills/apt-execute/SKILL.md +9 -0
  124. package/skills/apt-plan/SKILL.md +12 -0
  125. package/skills/apt-pr-review/SKILL.md +11 -2
  126. package/skills/apt-quick/SKILL.md +19 -8
  127. package/skills/apt-researcher.md +1 -0
  128. package/skills/apt-setup/SKILL.md +33 -2
  129. package/skills/apt-verify-proof/SKILL.md +7 -5
  130. package/src/cli/ci-watch/stop-matrix.mjs +17 -0
  131. package/src/cli/commands/ci-watch.mjs +113 -2
  132. package/src/cli/commands/features-audit.mjs +164 -5
  133. package/src/cli/commands/health-check.mjs +115 -2
  134. package/src/cli/commands/init.mjs +52 -4
  135. package/src/cli/commands/modes.mjs +11 -0
  136. package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
  137. package/src/cli/commands/task.mjs +83 -5
  138. package/src/cli/commands/validate-evidence.mjs +158 -17
  139. package/src/cli/commands/vitest-doctor.mjs +173 -0
  140. package/src/cli/config/gitignore-drift.mjs +74 -3
  141. package/src/cli/config/load.mjs +188 -2
  142. package/src/cli/consistency/parse-review.mjs +6 -0
  143. package/src/cli/dispatch.mjs +23 -2
  144. package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  145. package/src/cli/gate/gates/review-clean.mjs +24 -19
  146. package/src/cli/gate/gates/verify-approved.mjs +97 -14
  147. package/src/cli/help.mjs +7 -2
  148. package/src/cli/install/runtime-detect.mjs +9 -0
  149. package/src/cli/task/index-md.mjs +15 -2
  150. package/templates/config.json +2 -7
  151. 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
- // Try the group-specific naming first (fixes-applied-group{N}.md) for
673
- // fixer-{N}, then fall back to the unscoped fixes-applied.md which the
674
- // self-reviewer writes to self-review.md separately.
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
- const worktree = resolve(worktreeArg)
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 { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs'
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
- // Load config for date format preference
131
- const configPath = join(targetDir, '.aperant', 'config.json')
132
- const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}
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
- ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
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
- const headerRx = /^## Finding:\s*([A-Z]+-\d+)\s*--\s*(.+)$/gm
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
- if (fields.file) {
208
- const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-\d+)?)?`?\s*$/.exec(fields.file)
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
- const evidence = fenced ? fenced[1] : evidenceSection
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
- : line
235
- ? [line, line]
236
- : null
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
- return {
379
- status: 'dismissed_evidence_not_literal',
380
- note: `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`,
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
- summary.processed_files.push(f)
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
+ }