@aperant/framework 0.7.0 → 0.7.4
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 +205 -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 +1 -1
- 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 +19 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, readFileSync } from 'node:fs'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
+
import { detectWorktree } from '../git/worktree-detect.mjs'
|
|
17
18
|
import { resolveSharePolicy } from './share-policy.mjs'
|
|
18
19
|
import { buildAperantGitignoreTemplate } from './upgrade-gitignore.mjs'
|
|
19
20
|
|
|
@@ -35,11 +36,66 @@ const SKIP_COMMANDS = new Set([
|
|
|
35
36
|
'-h',
|
|
36
37
|
])
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* BUG-028 — resolve the shared (main-repo) root for the dispatch hook.
|
|
41
|
+
*
|
|
42
|
+
* Mirrors `coordination/agent-identity.mjs::resolveSharedRoot` but does NOT
|
|
43
|
+
* depend on that module. The CoordinationStore seam is `@internal` (R8) —
|
|
44
|
+
* `bin/apt-tools.mjs` and its dispatch hooks must reach git through
|
|
45
|
+
* `git/worktree-detect.mjs` directly, not through `coordination/*`.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
function resolveSharedRootForDrift(projectDir) {
|
|
50
|
+
try {
|
|
51
|
+
const wt = detectWorktree(projectDir)
|
|
52
|
+
return wt.is_worktree && wt.main_repo_root ? wt.main_repo_root : projectDir
|
|
53
|
+
} catch {
|
|
54
|
+
return projectDir
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* BUG-028 — pretty-print the first few missing / extra lines as a
|
|
60
|
+
* unified-diff snippet for the gate's `reason` field. Capped at
|
|
61
|
+
* `maxLines` per side so the reason stays short enough for terminal
|
|
62
|
+
* output. Returns the empty string when both sides match.
|
|
63
|
+
*/
|
|
64
|
+
export function buildGitignoreDiffSummary(expected, actual, maxLines = 3) {
|
|
65
|
+
const expSet = new Set(String(expected).split('\n'))
|
|
66
|
+
const actSet = new Set(String(actual).split('\n'))
|
|
67
|
+
const missing = []
|
|
68
|
+
for (const line of expSet) {
|
|
69
|
+
if (line === '') continue
|
|
70
|
+
if (!actSet.has(line)) {
|
|
71
|
+
missing.push(line)
|
|
72
|
+
if (missing.length >= maxLines) break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const extra = []
|
|
76
|
+
for (const line of actSet) {
|
|
77
|
+
if (line === '') continue
|
|
78
|
+
if (!expSet.has(line)) {
|
|
79
|
+
extra.push(line)
|
|
80
|
+
if (extra.length >= maxLines) break
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (missing.length === 0 && extra.length === 0) return ''
|
|
84
|
+
const parts = []
|
|
85
|
+
for (const line of missing) parts.push(`+ ${line}`)
|
|
86
|
+
for (const line of extra) parts.push(`- ${line}`)
|
|
87
|
+
return parts.join('\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
38
90
|
/**
|
|
39
91
|
* Pure comparison primitive — returns whether the on-disk `.aperant/.gitignore`
|
|
40
92
|
* differs from the policy-expected template. Returns `{ drifted: false }` when
|
|
41
93
|
* the `.aperant/` dir or file is absent (nothing to compare).
|
|
42
94
|
*
|
|
95
|
+
* BUG-028: when drifted, embeds a unified-diff summary (≤3 missing/extra
|
|
96
|
+
* lines per side) in `reason` so the user sees exactly what drifted
|
|
97
|
+
* without re-running `apt-tools config upgrade-gitignore --dry-run`.
|
|
98
|
+
*
|
|
43
99
|
* @internal
|
|
44
100
|
*/
|
|
45
101
|
export function checkGitignoreDrift(projectRoot) {
|
|
@@ -65,7 +121,16 @@ export function checkGitignoreDrift(projectRoot) {
|
|
|
65
121
|
} catch {
|
|
66
122
|
return { drifted: false, reason: 'read-failed' }
|
|
67
123
|
}
|
|
68
|
-
|
|
124
|
+
const drifted = actual !== expected
|
|
125
|
+
if (!drifted) return { drifted: false, reason: null }
|
|
126
|
+
const diff = buildGitignoreDiffSummary(expected, actual)
|
|
127
|
+
return {
|
|
128
|
+
drifted: true,
|
|
129
|
+
reason: diff
|
|
130
|
+
? `.aperant/.gitignore drift detected:\n${diff}`
|
|
131
|
+
: '.aperant/.gitignore drift detected (whitespace-only)',
|
|
132
|
+
diff,
|
|
133
|
+
}
|
|
69
134
|
}
|
|
70
135
|
|
|
71
136
|
/**
|
|
@@ -74,15 +139,21 @@ export function checkGitignoreDrift(projectRoot) {
|
|
|
74
139
|
* - `command` is not in the hot-path allowlist
|
|
75
140
|
* - `.aperant/.gitignore` exists AND does not match the expected template
|
|
76
141
|
*
|
|
142
|
+
* BUG-028: resolves the shared (main-repo) root internally so the same drift
|
|
143
|
+
* case produces the same verdict regardless of cwd context. Pre-fix this
|
|
144
|
+
* inspected `cwd` directly, producing a warning from one cwd context and a
|
|
145
|
+
* clean envelope from another for the same on-disk drift.
|
|
146
|
+
*
|
|
77
147
|
* Never throws.
|
|
78
148
|
*
|
|
79
149
|
* @internal
|
|
80
150
|
*/
|
|
81
|
-
export function emitDriftWarningIfAny(
|
|
151
|
+
export function emitDriftWarningIfAny(cwd, command) {
|
|
82
152
|
try {
|
|
83
153
|
if (process.env.APT_SUPPRESS_GITIGNORE_DRIFT === '1') return
|
|
84
154
|
if (!command || SKIP_COMMANDS.has(command)) return
|
|
85
|
-
const
|
|
155
|
+
const sharedRoot = resolveSharedRootForDrift(cwd)
|
|
156
|
+
const { drifted } = checkGitignoreDrift(sharedRoot)
|
|
86
157
|
if (drifted) {
|
|
87
158
|
process.stderr.write(
|
|
88
159
|
'[apt-tools] warning: .aperant/.gitignore is out of sync with share policy — run `apt-tools config upgrade-gitignore . --dry-run` to see the diff.\n',
|