@gobing-ai/knowledge-kit 0.0.13 → 0.0.14
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/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +20 -0
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +20 -0
- package/plugins/generations/core-facts-gen/dist/index.js +20 -0
- package/plugins/generations/daily-article-gen/dist/index.js +29 -1
- package/plugins/generations/daily-article-gen/src/index.ts +13 -1
- package/plugins/generations/dailynews-gen/dist/index.js +20 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +24 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
- package/plugins/generations/image-gen/dist/index.js +2296 -53
- package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
- package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
- package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
- package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
- package/plugins/generations/image-gen/src/providers/google.ts +268 -0
- package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
- package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
- package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
- package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
- package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
- package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
- package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
- package/plugins/generations/image-gen/src/providers/seedream.ts +1 -1
- package/plugins/generations/image-gen/src/providers/types.ts +52 -51
- package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
- package/plugins/generations/news-report-gen/dist/index.js +22193 -0
- package/plugins/generations/news-report-gen/package.json +17 -0
- package/plugins/generations/news-report-gen/plugin.json +7 -0
- package/plugins/generations/news-report-gen/src/index.ts +308 -0
- package/plugins/generations/news-report-gen/tsconfig.json +4 -0
- package/plugins/generations/omni-voice-gen/Makefile +14 -0
- package/plugins/generations/omni-voice-gen/README.md +112 -0
- package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
- package/plugins/generations/omni-voice-gen/plugin.json +6 -0
- package/plugins/generations/omni-voice-gen/profiles.json +12 -0
- package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
- package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
- package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
- package/plugins/generations/voice-gen/dist/index.js +30 -2
- package/plugins/generations/voice-gen/src/index.ts +16 -1
- package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +20 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +20 -0
- package/plugins/ingestions/last30days-ingest/dist/index.js +20 -0
- package/plugins/ingestions/web-search/dist/index.js +20 -0
- package/plugins/kk/commands/image-extract.md +40 -0
- package/plugins/kk/commands/image-generate.md +18 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/skills/image-authoring/SKILL.md +10 -3
- package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
- package/plugins/kk/skills/image-authoring/references/style-extraction.md +14 -9
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
- package/plugins/publishings/emdash-pub/dist/index.js +20 -0
- package/plugins/publishings/podcast-pub/dist/index.js +57 -3
- package/plugins/publishings/podcast-pub/src/index.ts +18 -2
- package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
- package/plugins/publishings/qiita-pub/dist/index.js +20 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +96 -6
- package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
- package/plugins/publishings/zenn-pub/dist/index.js +20 -0
- package/plugins/sp/scripts/batch-preflight.mjs +346 -0
- package/plugins/sp/scripts/batch-preflight.ts +459 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
- package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
- package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
- package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
- package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
- package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
- package/plugins/sp/scripts/idea-handoff.mjs +22 -0
- package/plugins/sp/scripts/idea-handoff.ts +44 -0
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
- package/plugins/sp/scripts/inline-run-setup.ts +198 -0
- package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
- package/plugins/sp/scripts/pr-reviewing.ts +925 -0
- package/plugins/sp/scripts/quality-gate.mjs +179 -0
- package/plugins/sp/scripts/quality-gate.ts +217 -0
- package/plugins/sp/scripts/script-contract-check.ts +319 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
- package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
- package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
- package/plugins/sp/scripts/task-size-precheck.ts +175 -0
- package/plugins/sp/scripts/transition-shim-check.ts +238 -0
- package/plugins/sp/scripts/validate-commands.ts +689 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
- package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
- package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
- package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
- package/plugins/sp/scripts/wrapup-steps.ts +466 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* verify-answer-lint — deterministic pre-verdict answer lint (0726 R3).
|
|
4
|
+
*
|
|
5
|
+
* Runs AFTER the verify agent exits and BEFORE `spur task verdict --from-answer`.
|
|
6
|
+
* The verifier owns the answer file (`.spur/run/<wbs>-verify-answer.txt`): it creates
|
|
7
|
+
* it with `Verdict: PARTIAL`, appends one complete requirement/AC row at a time, and
|
|
8
|
+
* only replaces the first verdict line once every row is certified. Because the file
|
|
9
|
+
* is now append-progress instead of a single captured blob, malformed rows can reach
|
|
10
|
+
* the verdict step — this lint rejects each invalid class with a row-level message:
|
|
11
|
+
*
|
|
12
|
+
* - missing, duplicate, or unknown requirement IDs (vs the task's Requirements)
|
|
13
|
+
* - AC IDs that do not exactly match the task's AC checklist label or a linked
|
|
14
|
+
* feature scenario title
|
|
15
|
+
* - status / evidence-type values the verdict parser would drop
|
|
16
|
+
* - empty evidence
|
|
17
|
+
*
|
|
18
|
+
* Compound evidence types (`test + command`) stay valid — normalization mirrors
|
|
19
|
+
* `packages/app/src/services/task-verdict.ts` exactly, so anything this lint accepts
|
|
20
|
+
* is also accepted by `spur task verdict --from-answer` (and vice versa).
|
|
21
|
+
*
|
|
22
|
+
* Exits non-zero on any finding, with bounded diagnostics (first 10). Writes nothing.
|
|
23
|
+
*
|
|
24
|
+
* Ships with the plugin to arbitrary projects; node-builtin only — no workspace imports.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* bun plugins/sp/scripts/verify-answer-lint.ts <wbs> --answer <path> [--spur-bin <path>]
|
|
28
|
+
*
|
|
29
|
+
* Env: SPUR_BIN
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFileSync } from 'node:child_process';
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
// ─── CLI (same spur-bin chain as task-evidence-precheck.ts) ─────────────────
|
|
37
|
+
|
|
38
|
+
function usage(): never {
|
|
39
|
+
console.error('Usage: bun plugins/sp/scripts/verify-answer-lint.ts <wbs> --answer <path> [--spur-bin <path>]');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultSpurBin(): string {
|
|
44
|
+
if (process.env.SPUR_BIN) return process.env.SPUR_BIN;
|
|
45
|
+
const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
|
|
46
|
+
if (existsSync(local)) return `bun ${local}`;
|
|
47
|
+
return 'spur';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv: string[]): { wbs: string; answer: string; spurBin: string } {
|
|
51
|
+
let spurBin = defaultSpurBin();
|
|
52
|
+
let wbs = '';
|
|
53
|
+
let answer = '';
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < argv.length) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
if (arg === '--spur-bin') {
|
|
58
|
+
spurBin = argv[i + 1] ?? defaultSpurBin();
|
|
59
|
+
i += 2;
|
|
60
|
+
} else if (arg === '--answer') {
|
|
61
|
+
answer = argv[i + 1] ?? '';
|
|
62
|
+
i += 2;
|
|
63
|
+
} else if (!arg.startsWith('--')) {
|
|
64
|
+
wbs = arg;
|
|
65
|
+
i++;
|
|
66
|
+
} else {
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!wbs || !answer) usage();
|
|
71
|
+
return { wbs, answer, spurBin };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runSpur(spurBin: string, args: string[]): string {
|
|
75
|
+
const [file = 'spur', ...lead] = spurBin.split(/\s+/).filter(Boolean);
|
|
76
|
+
return execFileSync(file, [...lead, ...args], {
|
|
77
|
+
encoding: 'utf-8',
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Answer parsing — mirrors packages/app/src/services/task-verdict.ts ─────
|
|
83
|
+
|
|
84
|
+
interface ReqRow {
|
|
85
|
+
id: string;
|
|
86
|
+
status: string;
|
|
87
|
+
evidence: string;
|
|
88
|
+
line: number;
|
|
89
|
+
}
|
|
90
|
+
interface AcRow {
|
|
91
|
+
id: string;
|
|
92
|
+
status: string;
|
|
93
|
+
evidenceType: string;
|
|
94
|
+
evidence: string;
|
|
95
|
+
line: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function splitTableCells(line: string): string[] {
|
|
99
|
+
return line
|
|
100
|
+
.split(/(?<!\\)\|/)
|
|
101
|
+
.map((c) => c.replace(/\\\|/g, '|').trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeReqStatus(raw: string): string | null {
|
|
106
|
+
if (/\bMET\b/.test(raw)) return 'MET';
|
|
107
|
+
if (/\bPARTIAL\b/.test(raw)) return 'PARTIAL';
|
|
108
|
+
if (/\bUNMET\b/.test(raw)) return 'UNMET';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeAcStatus(raw: string): string | null {
|
|
113
|
+
if (/\bMET\b/.test(raw)) return 'MET';
|
|
114
|
+
if (/\bPARTIAL\b/.test(raw)) return 'PARTIAL';
|
|
115
|
+
if (/\bUNMET\b/.test(raw)) return 'UNMET';
|
|
116
|
+
if (/\bN\/A\b/.test(raw) || /\bNA\b/.test(raw)) return 'N/A';
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeEvidenceTypeToken(normalized: string): string | null {
|
|
121
|
+
if (normalized === 'test') return 'test';
|
|
122
|
+
if (normalized === 'command') return 'command';
|
|
123
|
+
if (
|
|
124
|
+
normalized === 'static-ref' ||
|
|
125
|
+
normalized === 'static' ||
|
|
126
|
+
normalized === 'doc' ||
|
|
127
|
+
normalized === 'docs' ||
|
|
128
|
+
normalized === 'documentation'
|
|
129
|
+
) {
|
|
130
|
+
return 'static-ref';
|
|
131
|
+
}
|
|
132
|
+
if (normalized === 'manual-review' || normalized === 'manual') return 'manual-review';
|
|
133
|
+
if (normalized === 'llm-judge' || normalized === 'judge') return 'llm-judge';
|
|
134
|
+
if (normalized === 'n/a' || normalized === 'na') return 'n/a';
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const EVIDENCE_TYPE_PRECEDENCE = ['test', 'command', 'static-ref', 'manual-review', 'llm-judge', 'n/a'] as const;
|
|
139
|
+
|
|
140
|
+
function normalizeEvidenceType(raw: string): string | null {
|
|
141
|
+
const normalized = raw.toLowerCase().trim();
|
|
142
|
+
const single = normalizeEvidenceTypeToken(normalized);
|
|
143
|
+
if (single !== null) return single;
|
|
144
|
+
const parts = normalized.split(/[+,/]/).filter((p) => p.trim());
|
|
145
|
+
const tokens = parts.map((part) => normalizeEvidenceTypeToken(part.trim())).filter((t) => t !== null);
|
|
146
|
+
if (tokens.length < 2 || tokens.length !== parts.length) return null;
|
|
147
|
+
return EVIDENCE_TYPE_PRECEDENCE.find((candidate) => tokens.includes(candidate)) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface AnswerTables {
|
|
151
|
+
verdict: { value: string; line: number } | null;
|
|
152
|
+
reqs: ReqRow[];
|
|
153
|
+
acs: AcRow[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseAnswer(text: string): AnswerTables {
|
|
157
|
+
const out: AnswerTables = { verdict: null, reqs: [], acs: [] };
|
|
158
|
+
const lines = text.split('\n');
|
|
159
|
+
let reqTable = false;
|
|
160
|
+
let acTable = false;
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < lines.length; i++) {
|
|
163
|
+
const trimmed = lines[i]?.trim() ?? '';
|
|
164
|
+
const lineNo = i + 1;
|
|
165
|
+
|
|
166
|
+
// A markdown heading closes whichever table is open (mirrors the verdict parser).
|
|
167
|
+
if ((reqTable || acTable) && /^#{1,6}\s/.test(trimmed)) {
|
|
168
|
+
reqTable = false;
|
|
169
|
+
acTable = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!trimmed.startsWith('|')) continue;
|
|
173
|
+
const cells = splitTableCells(trimmed);
|
|
174
|
+
if (/^[-:]+$/.test(cells[0] ?? '')) continue;
|
|
175
|
+
|
|
176
|
+
const h0 = (cells[0] ?? '').toLowerCase();
|
|
177
|
+
const h1 = (cells[1] ?? '').toLowerCase();
|
|
178
|
+
|
|
179
|
+
// Requirement header: `| Req | Status | Evidence |` (id-like first cell + status column).
|
|
180
|
+
if (!reqTable && !acTable && cells.length >= 2) {
|
|
181
|
+
const idLike = h0.includes('req') || h0 === 'requirement' || h0 === 'r#' || h0 === 'r' || /^r\d+$/.test(h0);
|
|
182
|
+
if (idLike && (h1.includes('status') || h1 === 'verdict')) {
|
|
183
|
+
reqTable = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
(h0 === 'ac' || h0.includes('acceptance')) &&
|
|
188
|
+
h1.includes('status') &&
|
|
189
|
+
(cells[2] ?? '').toLowerCase().includes('evidence')
|
|
190
|
+
) {
|
|
191
|
+
acTable = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (reqTable && cells.length >= 2) {
|
|
197
|
+
// An AC header following the requirement table closes it (mirrors the parser).
|
|
198
|
+
if ((h0 === 'ac' || h0.includes('acceptance')) && h1.includes('status')) {
|
|
199
|
+
reqTable = false;
|
|
200
|
+
acTable = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
out.reqs.push({
|
|
204
|
+
id: cells[0] ?? '',
|
|
205
|
+
status: (cells[1] ?? '').toUpperCase(),
|
|
206
|
+
evidence: cells[2] ?? '',
|
|
207
|
+
line: lineNo,
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (acTable && cells.length >= 3) {
|
|
212
|
+
out.acs.push({
|
|
213
|
+
id: cells[0] ?? '',
|
|
214
|
+
status: cells[1] ?? '',
|
|
215
|
+
evidenceType: cells[2] ?? '',
|
|
216
|
+
evidence: cells[3] ?? '',
|
|
217
|
+
line: lineNo,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const verdictMatches = [...text.matchAll(/^\s*Verdict:\s*(\S+)\s*$/gim)];
|
|
223
|
+
if (verdictMatches.length === 1) {
|
|
224
|
+
out.verdict = {
|
|
225
|
+
value: verdictMatches[0]?.[1] ?? '',
|
|
226
|
+
line: text.slice(0, verdictMatches[0]?.index ?? 0).split('\n').length,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Task-side identity extraction ───────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function sectionBetween(text: string, heading: string): string {
|
|
235
|
+
const marker = new RegExp(`^#{1,6}\\s+${heading}\\s*$`, 'im');
|
|
236
|
+
const match = marker.exec(text);
|
|
237
|
+
if (!match || match.index === undefined) return '';
|
|
238
|
+
const rest = text.slice(match.index);
|
|
239
|
+
const lineEnd = rest.indexOf('\n');
|
|
240
|
+
const afterHeading = lineEnd === -1 ? '' : rest.slice(lineEnd + 1);
|
|
241
|
+
const next = afterHeading.search(/^#{1,6}\s/m);
|
|
242
|
+
return next === -1 ? afterHeading : afterHeading.slice(0, next);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function extractRequirementIds(taskContent: string): string[] {
|
|
246
|
+
const section = sectionBetween(taskContent, 'Requirements');
|
|
247
|
+
const ids = new Set<string>();
|
|
248
|
+
// Corpus forms: bold-wrapped (`**R1. Title.**`, bare `**R1**`); right after a list
|
|
249
|
+
// marker with optional checkbox (`- [ ] R1.` — the dominant corpus form, `- R1. Title.`,
|
|
250
|
+
// `- R1:`) or a bare checkbox with the marker omitted (`[x] R1.`); line-start (`R1:`).
|
|
251
|
+
// The marker and the checkbox are never both optional — that would match bare prose
|
|
252
|
+
// (`R1 is …`) and fabricate declarations. Sub-IDs (`R1.1`) match in every form.
|
|
253
|
+
for (const m of section.matchAll(/\*\*(R\d+(?:\.\d+)*)\b/g)) ids.add(m[1] ?? '');
|
|
254
|
+
for (const m of section.matchAll(/^(?:[-*]\s+(?:\[[ xX]\]\s+)?|\[[ xX]\]\s+)(R\d+(?:\.\d+)*)/gm))
|
|
255
|
+
ids.add(m[1] ?? '');
|
|
256
|
+
for (const m of section.matchAll(/^(R\d+(?:\.\d+)*)\s*[.:]/gm)) ids.add(m[1] ?? '');
|
|
257
|
+
return [...ids];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Canonical AC identity resolution (task 0804 R4) ─────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Strip the tolerated AC-id wrappers (bracket tags, `Scenario:` prefix) until
|
|
264
|
+
* fixpoint — the shared first step of `normalizeAcTitle` and the `AC-N` alias
|
|
265
|
+
* path in `resolveAcIdentity`.
|
|
266
|
+
*/
|
|
267
|
+
function stripAcWrappers(title: string): string {
|
|
268
|
+
let out = title.trim();
|
|
269
|
+
let prev: string;
|
|
270
|
+
do {
|
|
271
|
+
prev = out;
|
|
272
|
+
out = out
|
|
273
|
+
.replace(/^\[[^\]]*\]\s*/, '')
|
|
274
|
+
.replace(/\s*\[[^\]]*\]\s*$/, '')
|
|
275
|
+
.replace(/^Scenario:\s*/i, '')
|
|
276
|
+
.trim();
|
|
277
|
+
} while (out !== prev);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Normalize an AC identity to its canonical key, mirroring the documented
|
|
283
|
+
* matching behavior of feature-check `rowMatchesScenario` + ac-style-guide
|
|
284
|
+
* "Four accepted id forms" (exact/bare title, `Scenario:` prefix, bracket
|
|
285
|
+
* tags, `AC-N` ordinal) plus the task-side bold-trajectory paragraph
|
|
286
|
+
* (0817 R3), without importing that private matcher or adopting
|
|
287
|
+
* its permissive trailing-Gherkin fallback (0804 R4). Comparison is
|
|
288
|
+
* case/quote/whitespace-insensitive. A paraphrase normalizes differently
|
|
289
|
+
* and still fails.
|
|
290
|
+
*/
|
|
291
|
+
function normalizeAcTitle(title: string): string {
|
|
292
|
+
return (
|
|
293
|
+
stripAcWrappers(title)
|
|
294
|
+
.replace(/^R\d+\s*[:\-—]?\s*/, '')
|
|
295
|
+
.toLowerCase()
|
|
296
|
+
// Exact pre-refactor removal set (0809 R5): ASCII apostrophe + the four curly
|
|
297
|
+
// quotes. Escaped form keeps U+0027 visible next to lookalike curly glyphs;
|
|
298
|
+
// U+02BC stays a meaningful character, never removable punctuation.
|
|
299
|
+
.replace(/[\u0027\u2018\u2019\u201c\u201d]/g, '')
|
|
300
|
+
.replace(/\s+/g, ' ')
|
|
301
|
+
.trim()
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Declared AC identities keyed by canonical normalized title, plus the two
|
|
307
|
+
* AC-N ordinal sources (task scenario list and linked-feature scenario list).
|
|
308
|
+
* A checklist-declared spelling always wins over the positional alias.
|
|
309
|
+
*/
|
|
310
|
+
interface AcIdentityIndex {
|
|
311
|
+
/** normalized canonical title → a declared spelling (label, token, title, or bold trajectory). */
|
|
312
|
+
readonly byTitle: Map<string, string>;
|
|
313
|
+
/** AC-N → task scenario title at that 1-based ordinal. */
|
|
314
|
+
readonly taskScenarios: string[];
|
|
315
|
+
/** AC-N → feature scenario title at that 1-based ordinal. */
|
|
316
|
+
readonly featureScenarios: string[];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildAcIdentityIndex(taskContent: string, featureContent: string | null): AcIdentityIndex {
|
|
320
|
+
const byTitle = new Map<string, string>();
|
|
321
|
+
// Not named `declare`: Bun's TS transpiler treats a call to an identifier
|
|
322
|
+
// named `declare` as an ambient-declaration modifier and drops it.
|
|
323
|
+
const declareIdentity = (spelling: string): void => {
|
|
324
|
+
const key = normalizeAcTitle(spelling);
|
|
325
|
+
if (key !== '' && !byTitle.has(key)) byTitle.set(key, spelling);
|
|
326
|
+
};
|
|
327
|
+
const section = sectionBetween(taskContent, 'Acceptance Criteria');
|
|
328
|
+
for (const m of section.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?(.+?)\s*(?::|$)/gm)) {
|
|
329
|
+
const label = (m[1] ?? '').trim();
|
|
330
|
+
if (!label) continue;
|
|
331
|
+
declareIdentity(label);
|
|
332
|
+
const leading = label.split(/\s+/)[0] ?? '';
|
|
333
|
+
if (leading && leading !== label) declareIdentity(leading);
|
|
334
|
+
}
|
|
335
|
+
// Bold-trajectory form (task 0817 R3): answers may cite an AC by a bare
|
|
336
|
+
// `**AC id**` paragraph (house style for long/complex ids). Only whole-line
|
|
337
|
+
// bold spans count — the line-anchored lazy regex rejects lines with two
|
|
338
|
+
// spans, keeping interpolated bold text out of the index. The head (text
|
|
339
|
+
// before the first colon) is declared like a checklist label, mirroring
|
|
340
|
+
// the `(?::|$)` label extraction above.
|
|
341
|
+
for (const m of section.matchAll(/^\*\*(.+?)\*\*\s*$/gm)) {
|
|
342
|
+
const inner = (m[1] ?? '').trim();
|
|
343
|
+
if (!inner) continue;
|
|
344
|
+
declareIdentity(inner);
|
|
345
|
+
const head = inner.split(':')[0]?.trim() ?? '';
|
|
346
|
+
if (head && head !== inner) declareIdentity(head);
|
|
347
|
+
}
|
|
348
|
+
const scenarioTitles = (content: string): string[] =>
|
|
349
|
+
[...content.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)].map((m) => (m[1] ?? '').trim()).filter((t) => t !== '');
|
|
350
|
+
const taskScenarios = scenarioTitles(sectionBetween(taskContent, 'Acceptance Criteria'));
|
|
351
|
+
const featureScenarios = featureContent !== null ? scenarioTitles(featureContent) : [];
|
|
352
|
+
for (const title of [...taskScenarios, ...featureScenarios]) declareIdentity(title);
|
|
353
|
+
return { byTitle, taskScenarios, featureScenarios };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Resolution outcome for one AC row id. */
|
|
357
|
+
type AcIdentityResolution = { ok: true; canonical: string } | { ok: false; error: string };
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Resolve an answer-file AC row id to one canonical task identity (0804 R4):
|
|
361
|
+
* exact/declared title forms first, then the documented `AC-N` positional
|
|
362
|
+
* alias — accepted only against a real scenario ordinal, and refused with an
|
|
363
|
+
* actionable diagnostic when task and feature ordinals disagree. Undeclared
|
|
364
|
+
* `ACn` tokens, paraphrases and invented ordinals never resolve.
|
|
365
|
+
* `ACn` tokens, paraphrases and invented ordinals never resolve.
|
|
366
|
+
*
|
|
367
|
+
* Declared forms recognized in the index include the bold-trajectory
|
|
368
|
+
* `**AC id**` paragraph (task 0817 R3); the AC-N failure hint names it.
|
|
369
|
+
*/
|
|
370
|
+
function resolveAcIdentity(rowId: string, index: AcIdentityIndex): AcIdentityResolution {
|
|
371
|
+
const canonical = index.byTitle.get(normalizeAcTitle(rowId));
|
|
372
|
+
if (canonical !== undefined) return { ok: true, canonical };
|
|
373
|
+
// Strip the tolerated wrappers, then try the documented `AC-N` alias.
|
|
374
|
+
const stripped = stripAcWrappers(rowId);
|
|
375
|
+
const ordinal = /^AC-(\d+)$/i.exec(stripped);
|
|
376
|
+
if (ordinal !== null) {
|
|
377
|
+
const n = Number(ordinal[1]);
|
|
378
|
+
const taskTitle = index.taskScenarios[n - 1];
|
|
379
|
+
const featureTitle = index.featureScenarios[n - 1];
|
|
380
|
+
const candidates = [...new Set([taskTitle, featureTitle].filter((t): t is string => t !== undefined))];
|
|
381
|
+
if (candidates.length === 0) {
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
error:
|
|
385
|
+
`AC id "${rowId}" uses the AC-${n} positional alias but no scenario exists at that ordinal ` +
|
|
386
|
+
'(task scenario list and linked-feature scenario list) — cite the exact scenario title, checklist label, or a bare `**AC id**` paragraph',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (candidates.length > 1) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
error:
|
|
393
|
+
`AC id "${rowId}" is ambiguous: task AC #${n} ("${taskTitle}") and feature scenario #${n} ` +
|
|
394
|
+
`("${featureTitle}") are different scenarios with different ordering — cite the exact title`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const resolved = index.byTitle.get(normalizeAcTitle(candidates[0] ?? ''));
|
|
398
|
+
if (resolved !== undefined) return { ok: true, canonical: resolved };
|
|
399
|
+
}
|
|
400
|
+
return { ok: false, error: '' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
function main(): void {
|
|
406
|
+
const { wbs, answer, spurBin } = parseArgs(process.argv.slice(2));
|
|
407
|
+
const findings: string[] = [];
|
|
408
|
+
const add = (msg: string): void => {
|
|
409
|
+
if (findings.length < 10) findings.push(msg);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
if (!existsSync(answer)) {
|
|
413
|
+
console.error(`verify-answer-lint: FAIL — answer file not found: ${answer}`);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
const raw = readFileSync(answer, 'utf8');
|
|
417
|
+
if (!raw.trim()) {
|
|
418
|
+
console.error(`verify-answer-lint: FAIL — answer file is empty: ${answer}`);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const tables = parseAnswer(raw);
|
|
423
|
+
if (tables.verdict === null) {
|
|
424
|
+
add('no `Verdict:` line (expected exactly one `Verdict: PASS|PARTIAL|FAIL` line)');
|
|
425
|
+
} else if (!/^(PASS|PARTIAL|FAIL)$/i.test(tables.verdict.value)) {
|
|
426
|
+
add(`line ${tables.verdict.line}: invalid Verdict value "${tables.verdict.value}" (PASS | PARTIAL | FAIL)`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let taskContent = '';
|
|
430
|
+
let featureId = '';
|
|
431
|
+
try {
|
|
432
|
+
const task = JSON.parse(runSpur(spurBin, ['task', 'show', wbs, '--json'])) as {
|
|
433
|
+
content?: string;
|
|
434
|
+
body?: string;
|
|
435
|
+
feature_id?: string;
|
|
436
|
+
frontmatter?: { feature_id?: string };
|
|
437
|
+
};
|
|
438
|
+
taskContent = task.content ?? task.body ?? '';
|
|
439
|
+
featureId = task.feature_id ?? task.frontmatter?.feature_id ?? '';
|
|
440
|
+
} catch {
|
|
441
|
+
console.error(`verify-answer-lint: FAIL — could not fetch task ${wbs} via ${spurBin}`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
if (!taskContent) {
|
|
445
|
+
console.error(`verify-answer-lint: FAIL — task ${wbs} returned no content via ${spurBin}`);
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let featureContent: string | null = null;
|
|
450
|
+
if (featureId) {
|
|
451
|
+
try {
|
|
452
|
+
const feature = JSON.parse(runSpur(spurBin, ['feature', 'show', featureId, '--json'])) as {
|
|
453
|
+
content?: string;
|
|
454
|
+
};
|
|
455
|
+
featureContent = feature.content ?? '';
|
|
456
|
+
} catch {
|
|
457
|
+
featureContent = null; // checklist labels still apply; scenario titles unavailable
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const reqIds = extractRequirementIds(taskContent);
|
|
462
|
+
const acIndex = buildAcIdentityIndex(taskContent, featureContent);
|
|
463
|
+
|
|
464
|
+
// Requirement rows: completeness, no unknowns, no duplicates, valid status, non-empty evidence.
|
|
465
|
+
const seenReq = new Set<string>();
|
|
466
|
+
for (const row of tables.reqs) {
|
|
467
|
+
if (!reqIds.includes(row.id))
|
|
468
|
+
add(`line ${row.line}: unknown requirement ID "${row.id}" (task declares: ${reqIds.join(', ') || 'none'})`);
|
|
469
|
+
else if (seenReq.has(row.id)) add(`line ${row.line}: duplicate requirement row "${row.id}"`);
|
|
470
|
+
seenReq.add(row.id);
|
|
471
|
+
if (normalizeReqStatus(row.status) === null)
|
|
472
|
+
add(`line ${row.line}: "${row.id}" invalid status "${row.status}" (MET | PARTIAL | UNMET)`);
|
|
473
|
+
if (!row.evidence.trim()) add(`line ${row.line}: "${row.id}" has empty evidence`);
|
|
474
|
+
}
|
|
475
|
+
for (const id of reqIds) {
|
|
476
|
+
if (!seenReq.has(id)) add(`missing requirement row for "${id}"`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// AC rows: identity must resolve to ONE canonical task AC identity — a
|
|
480
|
+
// checklist label/token or a scenario title in any ac-style-guide form
|
|
481
|
+
// (exact/bare title, `Scenario:` prefix, bracket tags, declared AC-N
|
|
482
|
+
// alias; 0804 R4). Alias-equivalent spellings of the same identity are
|
|
483
|
+
// duplicates even when the raw strings differ. Status and evidence type
|
|
484
|
+
// must normalize; evidence non-empty. AC completeness is the verifier's
|
|
485
|
+
// authoring contract, not a lint rejection class (0726 R3).
|
|
486
|
+
const seenAc = new Map<string, string>(); // canonical key → first raw row id
|
|
487
|
+
for (const row of tables.acs) {
|
|
488
|
+
const resolution = resolveAcIdentity(row.id, acIndex);
|
|
489
|
+
const canonicalKey = resolution.ok ? normalizeAcTitle(resolution.canonical) : null;
|
|
490
|
+
if (!resolution.ok) {
|
|
491
|
+
if (resolution.error !== '') add(`line ${row.line}: ${resolution.error}`);
|
|
492
|
+
else
|
|
493
|
+
add(
|
|
494
|
+
`line ${row.line}: AC ID "${row.id.slice(0, 60)}" matches no task AC checklist label or scenario title ` +
|
|
495
|
+
'(accepted forms: exact title, bare title, `Scenario:` prefix, bracket tags, declared AC-N alias)',
|
|
496
|
+
);
|
|
497
|
+
} else if (canonicalKey !== null && seenAc.has(canonicalKey)) {
|
|
498
|
+
const first = seenAc.get(canonicalKey) ?? '';
|
|
499
|
+
add(
|
|
500
|
+
`line ${row.line}: duplicate AC row "${row.id.slice(0, 60)}" — alias-equivalent to "${first.slice(0, 60)}"`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (canonicalKey !== null && !seenAc.has(canonicalKey)) seenAc.set(canonicalKey, row.id);
|
|
504
|
+
if (normalizeAcStatus(row.status) === null)
|
|
505
|
+
add(`line ${row.line}: invalid AC status "${row.status}" (MET | PARTIAL | UNMET | N/A)`);
|
|
506
|
+
if (normalizeEvidenceType(row.evidenceType) === null)
|
|
507
|
+
add(
|
|
508
|
+
`line ${row.line}: invalid evidence type "${row.evidenceType}" (test | command | static-ref | manual-review | llm-judge | n/a, or a + compound)`,
|
|
509
|
+
);
|
|
510
|
+
if (!row.evidence.trim()) add(`line ${row.line}: AC "${row.id.slice(0, 40)}" has empty evidence`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (findings.length > 0) {
|
|
514
|
+
console.error(
|
|
515
|
+
`verify-answer-lint: FAIL — ${findings.length}${findings.length >= 10 ? '+' : ''} finding(s) in ${answer}`,
|
|
516
|
+
);
|
|
517
|
+
for (const f of findings) console.error(` ${f}`);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const reqCount = tables.reqs.length;
|
|
522
|
+
const acCount = tables.acs.length;
|
|
523
|
+
console.error(
|
|
524
|
+
`verify-answer-lint: PASS — ${reqCount} requirement row(s), ${acCount} AC row(s), verdict ${tables.verdict?.value ?? '?'}`,
|
|
525
|
+
);
|
|
526
|
+
process.exit(0);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// CLI entry (guarded so the helpers stay importable for focused tests).
|
|
530
|
+
if (import.meta.main) main();
|