executable-stories-formatters 1.3.0 → 1.4.0
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/dist/cli.js +470 -12
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +446 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -1
- package/dist/index.d.ts +233 -1
- package/dist/index.js +441 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7539,6 +7539,157 @@ function renderTriage(report, format) {
|
|
|
7539
7539
|
return lines.join("\n").trimEnd();
|
|
7540
7540
|
}
|
|
7541
7541
|
|
|
7542
|
+
// src/review/diff-anchor.ts
|
|
7543
|
+
import { createHash as createHash6 } from "crypto";
|
|
7544
|
+
var CONTEXT_WINDOW = 3;
|
|
7545
|
+
var MAX_FUZZ = 2;
|
|
7546
|
+
var normalize = (line) => line.trim();
|
|
7547
|
+
var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
|
|
7548
|
+
function stripPathPrefix(raw) {
|
|
7549
|
+
const path17 = raw.split(" ")[0].trim();
|
|
7550
|
+
if (path17 === "/dev/null") return void 0;
|
|
7551
|
+
return path17.replace(/^[ab]\//, "");
|
|
7552
|
+
}
|
|
7553
|
+
function parseUnifiedDiff(patch) {
|
|
7554
|
+
const files = [];
|
|
7555
|
+
let current;
|
|
7556
|
+
let hunk;
|
|
7557
|
+
let oldRemaining = 0;
|
|
7558
|
+
let newRemaining = 0;
|
|
7559
|
+
for (const line of patch.split("\n")) {
|
|
7560
|
+
if (hunk && (oldRemaining > 0 || newRemaining > 0)) {
|
|
7561
|
+
if (line.startsWith("\\")) continue;
|
|
7562
|
+
const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "context";
|
|
7563
|
+
hunk.lines.push({ kind, text: line.slice(1) });
|
|
7564
|
+
if (kind !== "add") oldRemaining--;
|
|
7565
|
+
if (kind !== "del") newRemaining--;
|
|
7566
|
+
continue;
|
|
7567
|
+
}
|
|
7568
|
+
hunk = void 0;
|
|
7569
|
+
if (line.startsWith("diff --git ")) {
|
|
7570
|
+
current = { hunks: [] };
|
|
7571
|
+
files.push(current);
|
|
7572
|
+
continue;
|
|
7573
|
+
}
|
|
7574
|
+
if (line.startsWith("--- ")) {
|
|
7575
|
+
if (!current || current.oldPath !== void 0 || current.hunks.length > 0) {
|
|
7576
|
+
current = { hunks: [] };
|
|
7577
|
+
files.push(current);
|
|
7578
|
+
}
|
|
7579
|
+
current.oldPath = stripPathPrefix(line.slice(4));
|
|
7580
|
+
continue;
|
|
7581
|
+
}
|
|
7582
|
+
if (line.startsWith("+++ ") && current) {
|
|
7583
|
+
current.newPath = stripPathPrefix(line.slice(4));
|
|
7584
|
+
continue;
|
|
7585
|
+
}
|
|
7586
|
+
const match = HUNK_HEADER.exec(line);
|
|
7587
|
+
if (match && current) {
|
|
7588
|
+
hunk = {
|
|
7589
|
+
oldStart: Number(match[1]),
|
|
7590
|
+
newStart: Number(match[3]),
|
|
7591
|
+
header: match[5] ?? "",
|
|
7592
|
+
lines: []
|
|
7593
|
+
};
|
|
7594
|
+
oldRemaining = match[2] === void 0 ? 1 : Number(match[2]);
|
|
7595
|
+
newRemaining = match[4] === void 0 ? 1 : Number(match[4]);
|
|
7596
|
+
current.hunks.push(hunk);
|
|
7597
|
+
}
|
|
7598
|
+
}
|
|
7599
|
+
return files;
|
|
7600
|
+
}
|
|
7601
|
+
function createAnchor(args) {
|
|
7602
|
+
const { file, hunkIndex, lineIndex } = args;
|
|
7603
|
+
const lines = file.hunks[hunkIndex].lines;
|
|
7604
|
+
if (lines[lineIndex].kind === "context") {
|
|
7605
|
+
throw new Error(`Line ${lineIndex} is a context line; anchors target changed lines`);
|
|
7606
|
+
}
|
|
7607
|
+
const changed = [];
|
|
7608
|
+
let end = lineIndex;
|
|
7609
|
+
while (end < lines.length && lines[end].kind !== "context") {
|
|
7610
|
+
changed.push({ kind: lines[end].kind, text: lines[end].text });
|
|
7611
|
+
end++;
|
|
7612
|
+
}
|
|
7613
|
+
const contextBefore = lines.slice(Math.max(0, lineIndex - CONTEXT_WINDOW), lineIndex).map((l) => l.text);
|
|
7614
|
+
const contextAfter = lines.slice(end, end + CONTEXT_WINDOW).map((l) => l.text);
|
|
7615
|
+
const hash = createHash6("sha256").update(
|
|
7616
|
+
JSON.stringify({
|
|
7617
|
+
changed: changed.map((l) => `${l.kind}:${normalize(l.text)}`),
|
|
7618
|
+
before: contextBefore.map(normalize),
|
|
7619
|
+
after: contextAfter.map(normalize)
|
|
7620
|
+
})
|
|
7621
|
+
).digest("hex");
|
|
7622
|
+
return {
|
|
7623
|
+
hash,
|
|
7624
|
+
file: file.newPath ?? file.oldPath ?? "",
|
|
7625
|
+
changed,
|
|
7626
|
+
contextBefore,
|
|
7627
|
+
contextAfter
|
|
7628
|
+
};
|
|
7629
|
+
}
|
|
7630
|
+
function changedRunCandidates(anchor, file, fileIndex) {
|
|
7631
|
+
const path17 = file.newPath ?? file.oldPath ?? "";
|
|
7632
|
+
const out = [];
|
|
7633
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
7634
|
+
outer: for (let i = 0; i + anchor.changed.length <= hunk.lines.length; i++) {
|
|
7635
|
+
for (let j = 0; j < anchor.changed.length; j++) {
|
|
7636
|
+
const line = hunk.lines[i + j];
|
|
7637
|
+
const want = anchor.changed[j];
|
|
7638
|
+
if (line.kind !== want.kind || normalize(line.text) !== normalize(want.text)) {
|
|
7639
|
+
continue outer;
|
|
7640
|
+
}
|
|
7641
|
+
}
|
|
7642
|
+
out.push({ fileIndex, file: path17, hunkIndex, lineIndex: i, lines: hunk.lines });
|
|
7643
|
+
}
|
|
7644
|
+
});
|
|
7645
|
+
return out;
|
|
7646
|
+
}
|
|
7647
|
+
function contextMatches(anchor, candidate, fuzz) {
|
|
7648
|
+
const before = anchor.contextBefore.slice(Math.min(fuzz, anchor.contextBefore.length));
|
|
7649
|
+
const after = anchor.contextAfter.slice(
|
|
7650
|
+
0,
|
|
7651
|
+
Math.max(0, anchor.contextAfter.length - fuzz)
|
|
7652
|
+
);
|
|
7653
|
+
for (let i = 0; i < before.length; i++) {
|
|
7654
|
+
const line = candidate.lines[candidate.lineIndex - before.length + i];
|
|
7655
|
+
if (line === void 0 || normalize(line.text) !== normalize(before[i])) return false;
|
|
7656
|
+
}
|
|
7657
|
+
const end = candidate.lineIndex + anchor.changed.length;
|
|
7658
|
+
for (let i = 0; i < after.length; i++) {
|
|
7659
|
+
const line = candidate.lines[end + i];
|
|
7660
|
+
if (line === void 0 || normalize(line.text) !== normalize(after[i])) return false;
|
|
7661
|
+
}
|
|
7662
|
+
return true;
|
|
7663
|
+
}
|
|
7664
|
+
function resolveCandidates(anchor, candidates) {
|
|
7665
|
+
for (let fuzz = 0; fuzz <= MAX_FUZZ; fuzz++) {
|
|
7666
|
+
const matches = candidates.filter((c) => contextMatches(anchor, c, fuzz));
|
|
7667
|
+
if (matches.length === 1) {
|
|
7668
|
+
const m = matches[0];
|
|
7669
|
+
return {
|
|
7670
|
+
state: "anchored",
|
|
7671
|
+
fileIndex: m.fileIndex,
|
|
7672
|
+
file: m.file,
|
|
7673
|
+
hunkIndex: m.hunkIndex,
|
|
7674
|
+
lineIndex: m.lineIndex,
|
|
7675
|
+
lineCount: anchor.changed.length,
|
|
7676
|
+
fuzz
|
|
7677
|
+
};
|
|
7678
|
+
}
|
|
7679
|
+
if (matches.length > 1) return { state: "ambiguous" };
|
|
7680
|
+
}
|
|
7681
|
+
return void 0;
|
|
7682
|
+
}
|
|
7683
|
+
function relocateAnchor(anchor, files) {
|
|
7684
|
+
const own = [];
|
|
7685
|
+
const others = [];
|
|
7686
|
+
files.forEach((file, fileIndex) => {
|
|
7687
|
+
const isOwn = file.newPath === anchor.file || file.oldPath === anchor.file;
|
|
7688
|
+
(isOwn ? own : others).push(...changedRunCandidates(anchor, file, fileIndex));
|
|
7689
|
+
});
|
|
7690
|
+
return resolveCandidates(anchor, own) ?? resolveCandidates(anchor, others) ?? { state: "orphaned" };
|
|
7691
|
+
}
|
|
7692
|
+
|
|
7542
7693
|
// src/review/conventions.ts
|
|
7543
7694
|
var CHANGE_TAG_PREFIX = "change:";
|
|
7544
7695
|
var AUDIENCE_TAG_PREFIX = "audience:";
|
|
@@ -7726,6 +7877,33 @@ var AUDIENCE_ORDER = {
|
|
|
7726
7877
|
stakeholder: 0,
|
|
7727
7878
|
engineer: 1
|
|
7728
7879
|
};
|
|
7880
|
+
function buildCodeDiff(input, run, context) {
|
|
7881
|
+
const files = parseUnifiedDiff(input.patch);
|
|
7882
|
+
const byId = new Map(run.testCases.map((tc) => [tc.id, tc]));
|
|
7883
|
+
return {
|
|
7884
|
+
title: input.title,
|
|
7885
|
+
patch: input.patch,
|
|
7886
|
+
patchUrl: input.patchUrl,
|
|
7887
|
+
baseLabel: input.baseLabel ?? context.baseRef,
|
|
7888
|
+
headLabel: input.headLabel ?? context.headRef,
|
|
7889
|
+
files,
|
|
7890
|
+
annotations: input.annotations.map((annotation) => ({
|
|
7891
|
+
anchorHash: annotation.anchor?.hash,
|
|
7892
|
+
text: annotation.text,
|
|
7893
|
+
label: annotation.label,
|
|
7894
|
+
resolution: annotation.anchor ? relocateAnchor(annotation.anchor, files) : { state: annotation.unresolved ?? "orphaned" },
|
|
7895
|
+
scenarios: (annotation.scenarioIds ?? []).map((id) => {
|
|
7896
|
+
const testCase = byId.get(id);
|
|
7897
|
+
return testCase ? {
|
|
7898
|
+
id,
|
|
7899
|
+
resolved: true,
|
|
7900
|
+
scenario: testCase.story.scenario,
|
|
7901
|
+
status: testCase.status
|
|
7902
|
+
} : { id, resolved: false };
|
|
7903
|
+
})
|
|
7904
|
+
}))
|
|
7905
|
+
};
|
|
7906
|
+
}
|
|
7729
7907
|
function buildReview(run, context = { changedFiles: [] }) {
|
|
7730
7908
|
const changedSource = context.changedFiles.filter(
|
|
7731
7909
|
(f) => isReviewableSource(f.path)
|
|
@@ -7770,9 +7948,31 @@ function buildReview(run, context = { changedFiles: [] }) {
|
|
|
7770
7948
|
context,
|
|
7771
7949
|
summary,
|
|
7772
7950
|
claims: sortedClaims,
|
|
7773
|
-
changedFiles: sortedFiles
|
|
7951
|
+
changedFiles: sortedFiles,
|
|
7952
|
+
codeDiffs: (context.codeDiffs ?? []).map((d) => buildCodeDiff(d, run, context))
|
|
7774
7953
|
};
|
|
7775
7954
|
}
|
|
7955
|
+
function codeDiffDiagnostics(review) {
|
|
7956
|
+
const issues = [];
|
|
7957
|
+
for (const evidence of review.codeDiffs) {
|
|
7958
|
+
evidence.annotations.forEach((annotation, i) => {
|
|
7959
|
+
const name = annotation.label ?? `annotation ${i + 1}`;
|
|
7960
|
+
if (annotation.resolution.state !== "anchored") {
|
|
7961
|
+
issues.push(
|
|
7962
|
+
`"${evidence.title}" ${name}: anchor is ${annotation.resolution.state} in the current patch`
|
|
7963
|
+
);
|
|
7964
|
+
}
|
|
7965
|
+
for (const ref of annotation.scenarios) {
|
|
7966
|
+
if (!ref.resolved) {
|
|
7967
|
+
issues.push(
|
|
7968
|
+
`"${evidence.title}" ${name}: cites scenario "${ref.id}" which is not in this run`
|
|
7969
|
+
);
|
|
7970
|
+
}
|
|
7971
|
+
}
|
|
7972
|
+
});
|
|
7973
|
+
}
|
|
7974
|
+
return issues;
|
|
7975
|
+
}
|
|
7776
7976
|
function buildSummary2(claims, changedFiles) {
|
|
7777
7977
|
const byAudience = {
|
|
7778
7978
|
stakeholder: 0,
|
|
@@ -7799,6 +7999,75 @@ function buildSummary2(claims, changedFiles) {
|
|
|
7799
7999
|
};
|
|
7800
8000
|
}
|
|
7801
8001
|
|
|
8002
|
+
// src/review/code-diff-sidecar.ts
|
|
8003
|
+
function locateMatches(file, match) {
|
|
8004
|
+
const out = [];
|
|
8005
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
8006
|
+
hunk.lines.forEach((line, lineIndex) => {
|
|
8007
|
+
if (line.kind !== "context" && line.text.includes(match)) {
|
|
8008
|
+
out.push({ hunkIndex, lineIndex });
|
|
8009
|
+
}
|
|
8010
|
+
});
|
|
8011
|
+
});
|
|
8012
|
+
return out;
|
|
8013
|
+
}
|
|
8014
|
+
function runStart(file, hunkIndex, lineIndex) {
|
|
8015
|
+
const lines = file.hunks[hunkIndex].lines;
|
|
8016
|
+
let start = lineIndex;
|
|
8017
|
+
while (start > 0 && lines[start - 1].kind !== "context") start--;
|
|
8018
|
+
return start;
|
|
8019
|
+
}
|
|
8020
|
+
function assembleCodeDiff(args) {
|
|
8021
|
+
const { sidecar, patch } = args;
|
|
8022
|
+
const files = parseUnifiedDiff(patch);
|
|
8023
|
+
const warnings = [];
|
|
8024
|
+
if (sidecar.patchUrl !== void 0 && !sidecar.patchUrl.startsWith("https://")) {
|
|
8025
|
+
warnings.push(
|
|
8026
|
+
`patchUrl "${sidecar.patchUrl}" is not https: \u2014 it will render as inert text, not a link`
|
|
8027
|
+
);
|
|
8028
|
+
}
|
|
8029
|
+
const annotations = sidecar.annotations.map(
|
|
8030
|
+
(entry) => {
|
|
8031
|
+
const base = {
|
|
8032
|
+
text: entry.text,
|
|
8033
|
+
label: entry.label,
|
|
8034
|
+
scenarioIds: entry.scenarioIds
|
|
8035
|
+
};
|
|
8036
|
+
const file = files.find(
|
|
8037
|
+
(f) => f.newPath === entry.file || f.oldPath === entry.file
|
|
8038
|
+
);
|
|
8039
|
+
const matches = file ? locateMatches(file, entry.match) : [];
|
|
8040
|
+
if (file && matches.length === 1) {
|
|
8041
|
+
const { hunkIndex, lineIndex } = matches[0];
|
|
8042
|
+
const anchor = createAnchor({
|
|
8043
|
+
file,
|
|
8044
|
+
hunkIndex,
|
|
8045
|
+
lineIndex: runStart(file, hunkIndex, lineIndex)
|
|
8046
|
+
});
|
|
8047
|
+
return { ...base, anchor };
|
|
8048
|
+
}
|
|
8049
|
+
warnings.push(
|
|
8050
|
+
!file ? `annotation "${entry.label ?? entry.match}": file "${entry.file}" is not in the patch` : matches.length === 0 ? `annotation "${entry.label ?? entry.match}": no changed line in "${entry.file}" contains "${entry.match}"` : `annotation "${entry.label ?? entry.match}": "${entry.match}" matches ${matches.length} changed lines in "${entry.file}" \u2014 make it unique`
|
|
8051
|
+
);
|
|
8052
|
+
return {
|
|
8053
|
+
...base,
|
|
8054
|
+
unresolved: file && matches.length > 1 ? "ambiguous" : "orphaned"
|
|
8055
|
+
};
|
|
8056
|
+
}
|
|
8057
|
+
);
|
|
8058
|
+
return {
|
|
8059
|
+
input: {
|
|
8060
|
+
title: sidecar.title,
|
|
8061
|
+
patch,
|
|
8062
|
+
patchUrl: sidecar.patchUrl,
|
|
8063
|
+
baseLabel: sidecar.baseLabel,
|
|
8064
|
+
headLabel: sidecar.headLabel,
|
|
8065
|
+
annotations
|
|
8066
|
+
},
|
|
8067
|
+
warnings
|
|
8068
|
+
};
|
|
8069
|
+
}
|
|
8070
|
+
|
|
7802
8071
|
// src/formatters/review-markdown.ts
|
|
7803
8072
|
var STRENGTH_BADGE = {
|
|
7804
8073
|
strong: "\u{1F7E2} strong",
|
|
@@ -7884,6 +8153,73 @@ function renderAudienceSection(lines, title, claims) {
|
|
|
7884
8153
|
renderClaim(lines, claim);
|
|
7885
8154
|
}
|
|
7886
8155
|
}
|
|
8156
|
+
var MAX_PATCH_EMBED_BYTES = 64 * 1024;
|
|
8157
|
+
function fenceFor(content) {
|
|
8158
|
+
const runs = content.match(/`{3,}/g);
|
|
8159
|
+
const length = runs ? Math.max(...runs.map((r) => r.length)) + 1 : 3;
|
|
8160
|
+
return "`".repeat(length);
|
|
8161
|
+
}
|
|
8162
|
+
function renderCodeDiff(lines, evidence) {
|
|
8163
|
+
lines.push(`## Code diff evidence: ${evidence.title}`);
|
|
8164
|
+
lines.push("");
|
|
8165
|
+
if (evidence.baseLabel || evidence.headLabel) {
|
|
8166
|
+
lines.push(
|
|
8167
|
+
`Comparing \`${evidence.baseLabel ?? "base"}\` \u2192 \`${evidence.headLabel ?? "head"}\`.`
|
|
8168
|
+
);
|
|
8169
|
+
lines.push("");
|
|
8170
|
+
}
|
|
8171
|
+
if (evidence.patchUrl !== void 0) {
|
|
8172
|
+
lines.push(
|
|
8173
|
+
evidence.patchUrl.startsWith("https://") ? `Canonical patch: ${evidence.patchUrl}` : `Canonical patch: \`${evidence.patchUrl.replace(/`/g, "")}\``
|
|
8174
|
+
);
|
|
8175
|
+
lines.push("");
|
|
8176
|
+
}
|
|
8177
|
+
evidence.annotations.forEach((annotation, i) => {
|
|
8178
|
+
lines.push(`### ${annotation.label ?? `Annotation ${i + 1}`}`);
|
|
8179
|
+
lines.push("");
|
|
8180
|
+
lines.push(annotation.text);
|
|
8181
|
+
lines.push("");
|
|
8182
|
+
if (annotation.resolution.state === "orphaned") {
|
|
8183
|
+
lines.push(
|
|
8184
|
+
"> \u26A0\uFE0F Orphaned annotation \u2014 could not locate these lines in the current patch."
|
|
8185
|
+
);
|
|
8186
|
+
lines.push("");
|
|
8187
|
+
} else if (annotation.resolution.state === "ambiguous") {
|
|
8188
|
+
lines.push(
|
|
8189
|
+
"> \u26A0\uFE0F Ambiguous anchor \u2014 these lines appear in more than one place in the current patch."
|
|
8190
|
+
);
|
|
8191
|
+
lines.push("");
|
|
8192
|
+
} else if (annotation.resolution.file !== void 0) {
|
|
8193
|
+
lines.push(`Location: \`${annotation.resolution.file}\``);
|
|
8194
|
+
lines.push("");
|
|
8195
|
+
}
|
|
8196
|
+
if (annotation.scenarios.length === 0) {
|
|
8197
|
+
lines.push("_Not covered by a scenario._");
|
|
8198
|
+
} else {
|
|
8199
|
+
for (const ref of annotation.scenarios) {
|
|
8200
|
+
lines.push(
|
|
8201
|
+
ref.resolved && ref.status ? `- ${statusIcon2(ref.status)} ${escapeCell2(ref.scenario ?? ref.id)} (\`${ref.id}\`)` : `- \u26A0\uFE0F \`${ref.id}\` \u2014 unverified reference (scenario not in this run)`
|
|
8202
|
+
);
|
|
8203
|
+
}
|
|
8204
|
+
}
|
|
8205
|
+
lines.push("");
|
|
8206
|
+
});
|
|
8207
|
+
if (Buffer.byteLength(evidence.patch, "utf8") <= MAX_PATCH_EMBED_BYTES) {
|
|
8208
|
+
const fence = fenceFor(evidence.patch);
|
|
8209
|
+
lines.push("<details><summary>Raw patch (audit)</summary>");
|
|
8210
|
+
lines.push("");
|
|
8211
|
+
lines.push(`${fence}diff`);
|
|
8212
|
+
lines.push(evidence.patch.trimEnd());
|
|
8213
|
+
lines.push(fence);
|
|
8214
|
+
lines.push("");
|
|
8215
|
+
lines.push("</details>");
|
|
8216
|
+
} else {
|
|
8217
|
+
lines.push(
|
|
8218
|
+
`_Patch too large to embed${evidence.patchUrl ? " \u2014 see the canonical patch link above" : ""}._`
|
|
8219
|
+
);
|
|
8220
|
+
}
|
|
8221
|
+
lines.push("");
|
|
8222
|
+
}
|
|
7887
8223
|
var ReviewMarkdownFormatter = class {
|
|
7888
8224
|
title;
|
|
7889
8225
|
constructor(options = {}) {
|
|
@@ -7944,6 +8280,9 @@ var ReviewMarkdownFormatter = class {
|
|
|
7944
8280
|
"Engineer changes",
|
|
7945
8281
|
review.claims.filter((c) => c.audience === "engineer")
|
|
7946
8282
|
);
|
|
8283
|
+
for (const evidence of review.codeDiffs) {
|
|
8284
|
+
renderCodeDiff(lines, evidence);
|
|
8285
|
+
}
|
|
7947
8286
|
return lines.join("\n").trimEnd();
|
|
7948
8287
|
}
|
|
7949
8288
|
};
|
|
@@ -8030,7 +8369,7 @@ function renderClaimCard(claim) {
|
|
|
8030
8369
|
);
|
|
8031
8370
|
const extraDocs = docs.length > 0 && claim.intent === void 0 ? `<div class="intent">${docs.map(inlineDoc).join("<br>")}</div>` : "";
|
|
8032
8371
|
return `
|
|
8033
|
-
<article class="claim-card" data-audience="${claim.audience}" data-strength="${claim.strength}" data-search="${search}">
|
|
8372
|
+
<article class="claim-card" id="claim-${escapeHtml2(claim.id)}" data-audience="${claim.audience}" data-strength="${claim.strength}" data-search="${search}">
|
|
8034
8373
|
<header class="claim-header">
|
|
8035
8374
|
<div>
|
|
8036
8375
|
<span class="strength-badge strength-${claim.strength}">${STRENGTH_LABEL[claim.strength]}</span>
|
|
@@ -8059,6 +8398,66 @@ function renderChangedFileRow(file) {
|
|
|
8059
8398
|
<td>${claims}</td>
|
|
8060
8399
|
</tr>`;
|
|
8061
8400
|
}
|
|
8401
|
+
var MAX_PATCH_EMBED_BYTES2 = 256 * 1024;
|
|
8402
|
+
function renderScenarioRef(ref) {
|
|
8403
|
+
if (!ref.resolved) {
|
|
8404
|
+
return `<span class="scenario-ref unverified">\u26A0\uFE0F ${escapeHtml2(ref.id)} (unverified reference)</span>`;
|
|
8405
|
+
}
|
|
8406
|
+
const icon = ref.status === "passed" ? "\u2705" : ref.status === "failed" ? "\u274C" : "\u2298";
|
|
8407
|
+
return `<a class="scenario-ref" href="#claim-${escapeHtml2(ref.id)}">${icon} ${escapeHtml2(ref.scenario ?? ref.id)}</a>`;
|
|
8408
|
+
}
|
|
8409
|
+
function renderDiffHunk(file, hunk, anchoredStart, anchoredCount) {
|
|
8410
|
+
let oldLn = hunk.oldStart;
|
|
8411
|
+
let newLn = hunk.newStart;
|
|
8412
|
+
const rows = hunk.lines.map((line, i) => {
|
|
8413
|
+
const anchored = anchoredStart !== void 0 && anchoredCount !== void 0 && i >= anchoredStart && i < anchoredStart + anchoredCount;
|
|
8414
|
+
const cls = `diff-${line.kind}${anchored ? " diff-anchored" : ""}`;
|
|
8415
|
+
const o = line.kind === "add" ? "" : String(oldLn++);
|
|
8416
|
+
const n = line.kind === "del" ? "" : String(newLn++);
|
|
8417
|
+
const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
|
|
8418
|
+
return `<tr class="${cls}"><td class="diff-ln">${o}</td><td class="diff-ln">${n}</td><td class="diff-sign">${sign}</td><td class="diff-code">${escapeHtml2(line.text)}</td></tr>`;
|
|
8419
|
+
});
|
|
8420
|
+
const path17 = file.newPath ?? file.oldPath ?? "";
|
|
8421
|
+
return `<div class="diff-hunk">
|
|
8422
|
+
<div class="diff-file-header"><code>${escapeHtml2(path17)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml2(hunk.header)}</span></div>
|
|
8423
|
+
<table class="diff-table"><tbody>${rows.join("")}</tbody></table>
|
|
8424
|
+
</div>`;
|
|
8425
|
+
}
|
|
8426
|
+
function renderAnnotation(evidence, annotation, diffIndex, index) {
|
|
8427
|
+
const scenarios = annotation.scenarios.length > 0 ? `<div class="scenario-row">${annotation.scenarios.map(renderScenarioRef).join("")}</div>` : `<p class="no-scenario">Not covered by a scenario \u2014 implementation claim without behavioural evidence.</p>`;
|
|
8428
|
+
const res = annotation.resolution;
|
|
8429
|
+
let body;
|
|
8430
|
+
if (res.state === "anchored" && res.fileIndex !== void 0 && res.hunkIndex !== void 0) {
|
|
8431
|
+
const file = evidence.files[res.fileIndex];
|
|
8432
|
+
body = renderDiffHunk(file, file.hunks[res.hunkIndex], res.lineIndex, res.lineCount);
|
|
8433
|
+
} else if (res.state === "ambiguous") {
|
|
8434
|
+
body = `<p class="anchor-notice">\u26A0\uFE0F Ambiguous anchor \u2014 these lines appear in more than one place in the current patch, so the annotation is not attached to any of them.</p>`;
|
|
8435
|
+
} else {
|
|
8436
|
+
body = `<p class="anchor-notice">\u26A0\uFE0F Orphaned annotation \u2014 could not locate these lines in the current patch. The prose below may describe code that has since changed.</p>`;
|
|
8437
|
+
}
|
|
8438
|
+
return `<article class="annotation" id="code-diff-${diffIndex}-a${index}" data-anchor-state="${res.state}">
|
|
8439
|
+
<h4>${escapeHtml2(annotation.label ?? `Annotation ${index + 1}`)}</h4>
|
|
8440
|
+
<p class="annotation-text">${escapeHtml2(annotation.text)}</p>
|
|
8441
|
+
${scenarios}
|
|
8442
|
+
${body}
|
|
8443
|
+
</article>`;
|
|
8444
|
+
}
|
|
8445
|
+
function renderCodeDiffSection(evidence, diffIndex) {
|
|
8446
|
+
const comparing = evidence.baseLabel || evidence.headLabel ? `<p class="subtle">Comparing ${escapeHtml2(evidence.baseLabel ?? "base")} \u2192 ${escapeHtml2(evidence.headLabel ?? "head")}</p>` : "";
|
|
8447
|
+
const audit = evidence.patchUrl === void 0 ? "" : evidence.patchUrl.startsWith("https://") ? `<p class="subtle">Canonical patch: <a href="${escapeHtml2(evidence.patchUrl)}" rel="noopener noreferrer">${escapeHtml2(evidence.patchUrl)}</a></p>` : `<p class="subtle">Canonical patch: <code>${escapeHtml2(evidence.patchUrl)}</code></p>`;
|
|
8448
|
+
const outline = evidence.annotations.length > 1 ? `<ol class="diff-outline">${evidence.annotations.map(
|
|
8449
|
+
(a, i) => `<li><a href="#code-diff-${diffIndex}-a${i}">${escapeHtml2(a.label ?? `Annotation ${i + 1}`)}</a></li>`
|
|
8450
|
+
).join("")}</ol>` : "";
|
|
8451
|
+
const rawPatch = Buffer.byteLength(evidence.patch, "utf8") <= MAX_PATCH_EMBED_BYTES2 ? `<details class="raw-patch"><summary>Raw patch (audit)</summary><pre><code>${escapeHtml2(evidence.patch)}</code></pre></details>` : `<p class="anchor-notice">Patch too large to embed${evidence.patchUrl ? " \u2014 use the canonical patch link above" : ""}.</p>`;
|
|
8452
|
+
return `<section class="panel code-diff" id="code-diff-${diffIndex}">
|
|
8453
|
+
<h2>Code diff: ${escapeHtml2(evidence.title)}</h2>
|
|
8454
|
+
${comparing}
|
|
8455
|
+
${audit}
|
|
8456
|
+
${outline}
|
|
8457
|
+
${evidence.annotations.map((a, i) => renderAnnotation(evidence, a, diffIndex, i)).join("\n")}
|
|
8458
|
+
${rawPatch}
|
|
8459
|
+
</section>`;
|
|
8460
|
+
}
|
|
8062
8461
|
function renderAudienceSection2(title, claims) {
|
|
8063
8462
|
if (claims.length === 0) return "";
|
|
8064
8463
|
return `<section class="audience-section">
|
|
@@ -8118,6 +8517,29 @@ var REVIEW_CSS = `
|
|
|
8118
8517
|
.shot { max-width: 280px; max-height: 200px; border: 1px solid var(--border); border-radius: 8px; }
|
|
8119
8518
|
.trace-note { color: var(--muted-foreground); }
|
|
8120
8519
|
.step-list { margin: 12px 0 0; padding-left: 18px; color: var(--muted-foreground); }
|
|
8520
|
+
.code-diff h2 { margin-bottom: 6px; }
|
|
8521
|
+
.code-diff a { color: var(--foreground); }
|
|
8522
|
+
.diff-outline { margin: 12px 0; padding-left: 20px; }
|
|
8523
|
+
.annotation { margin: 18px 0; padding: 14px 16px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--card) 60%, var(--background)); }
|
|
8524
|
+
.annotation h4 { margin: 0 0 6px; }
|
|
8525
|
+
.annotation-text { margin: 0 0 10px; }
|
|
8526
|
+
.scenario-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
|
|
8527
|
+
.scenario-ref { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; font-size: 0.82rem; text-decoration: none; color: var(--foreground); background: var(--background); }
|
|
8528
|
+
.scenario-ref.unverified { color: var(--warning, #b58900); border-color: var(--warning, #b58900); }
|
|
8529
|
+
.no-scenario { color: var(--warning, #b58900); font-size: 0.9rem; margin: 0 0 10px; }
|
|
8530
|
+
.anchor-notice { color: var(--warning, #b58900); background: color-mix(in srgb, var(--warning, #b58900) 10%, transparent); border-radius: 6px; padding: 8px 10px; margin: 0; }
|
|
8531
|
+
.diff-hunk { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
8532
|
+
.diff-file-header { padding: 6px 10px; background: var(--secondary); border-bottom: 1px solid var(--border); font-size: 0.85rem; }
|
|
8533
|
+
.diff-table { font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.82rem; }
|
|
8534
|
+
.diff-table td { padding: 1px 8px; border-bottom: none; }
|
|
8535
|
+
.diff-code { white-space: pre-wrap; word-break: break-all; }
|
|
8536
|
+
.diff-ln { color: var(--muted-foreground); text-align: right; user-select: none; width: 1%; white-space: nowrap; }
|
|
8537
|
+
.diff-sign { user-select: none; width: 1%; }
|
|
8538
|
+
.diff-add { background: color-mix(in srgb, var(--success, #2e7d32) 12%, transparent); }
|
|
8539
|
+
.diff-del { background: color-mix(in srgb, var(--destructive) 10%, transparent); }
|
|
8540
|
+
.diff-anchored .diff-code { box-shadow: inset 3px 0 0 var(--primary, #2e7d32); }
|
|
8541
|
+
.raw-patch { margin-top: 16px; }
|
|
8542
|
+
.raw-patch pre { overflow-x: auto; background: var(--secondary); border-radius: 8px; padding: 12px; }
|
|
8121
8543
|
`;
|
|
8122
8544
|
var JS_THEME_TOGGLE2 = `
|
|
8123
8545
|
function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
|
|
@@ -8184,15 +8606,18 @@ applyTheme(getEffectiveTheme());` : "";
|
|
|
8184
8606
|
<p class="subtle">${escapeHtml2(priority)}</p>
|
|
8185
8607
|
</section>
|
|
8186
8608
|
${changedFilesPanel}
|
|
8187
|
-
<
|
|
8188
|
-
<
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8609
|
+
<div class="claims-region">
|
|
8610
|
+
<section class="toolbar">
|
|
8611
|
+
<input type="search" placeholder="Filter claims by scenario, file, change-type" aria-label="Filter claims" />
|
|
8612
|
+
<button type="button" class="active" data-filter="all">All</button>
|
|
8613
|
+
<button type="button" data-filter="stakeholder">Stakeholder</button>
|
|
8614
|
+
<button type="button" data-filter="engineer">Engineer</button>
|
|
8615
|
+
<button type="button" data-filter="weak">Weak/None</button>
|
|
8616
|
+
</section>
|
|
8617
|
+
${renderAudienceSection2("Stakeholder behaviour", review.claims.filter((c) => c.audience === "stakeholder"))}
|
|
8618
|
+
${renderAudienceSection2("Engineer changes", review.claims.filter((c) => c.audience === "engineer"))}
|
|
8619
|
+
</div>
|
|
8620
|
+
${review.codeDiffs.map((d, i) => renderCodeDiffSection(d, i)).join("\n")}
|
|
8196
8621
|
</main>
|
|
8197
8622
|
<script>
|
|
8198
8623
|
${themeInitJs}
|
|
@@ -9885,6 +10310,9 @@ OPTIONS
|
|
|
9885
10310
|
--head-ref <ref> (review) Head ref label shown in the report (informational)
|
|
9886
10311
|
--fail-on <band> (review) Gate: "uncovered" or "weak" \u2014 exit non-zero when changed code lacks evidence (default: off)
|
|
9887
10312
|
--min-evidence <strength> (review) Gate: "weak"|"moderate"|"strong" \u2014 exit non-zero when any claim is below this strength (default: off)
|
|
10313
|
+
--code-diff <path> (review) Code Diff annotation sidecar (JSON: {title, annotations: [{file, match, text, label?, scenarioIds?}]})
|
|
10314
|
+
--patch <path> (review) Unified patch for --code-diff; generate with "git diff --histogram"
|
|
10315
|
+
--strict-code-diff (review) Gate: exit non-zero on orphaned/ambiguous anchors or unverified scenario references (default: off)
|
|
9888
10316
|
--emit-canonical <path> Write canonical JSON to given path
|
|
9889
10317
|
--help Show this help message
|
|
9890
10318
|
|
|
@@ -10167,6 +10595,9 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
|
|
|
10167
10595
|
"head-ref": { type: "string" },
|
|
10168
10596
|
"fail-on": { type: "string" },
|
|
10169
10597
|
"min-evidence": { type: "string" },
|
|
10598
|
+
"code-diff": { type: "string" },
|
|
10599
|
+
"patch": { type: "string" },
|
|
10600
|
+
"strict-code-diff": { type: "boolean", default: false },
|
|
10170
10601
|
"config": { type: "string" },
|
|
10171
10602
|
help: { type: "boolean", default: false }
|
|
10172
10603
|
},
|
|
@@ -10380,6 +10811,9 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
|
|
|
10380
10811
|
headRef: values["head-ref"],
|
|
10381
10812
|
failOn: failOnRaw,
|
|
10382
10813
|
minEvidence: minEvidenceRaw,
|
|
10814
|
+
codeDiffPath: values["code-diff"],
|
|
10815
|
+
patchPath: values["patch"],
|
|
10816
|
+
strictCodeDiff: values["strict-code-diff"],
|
|
10383
10817
|
config: values["config"]
|
|
10384
10818
|
};
|
|
10385
10819
|
return { args: cliArgs, pluginConfig, customRequested };
|
|
@@ -10682,7 +11116,14 @@ async function runReview(ctx) {
|
|
|
10682
11116
|
for (const f of files) {
|
|
10683
11117
|
console.log(f);
|
|
10684
11118
|
}
|
|
11119
|
+
const diffIssues = codeDiffDiagnostics(review);
|
|
11120
|
+
for (const issue of diffIssues) {
|
|
11121
|
+
console.error(`Code diff warning: ${issue}`);
|
|
11122
|
+
}
|
|
10685
11123
|
const gateFailures = evaluateReviewGate(review, args);
|
|
11124
|
+
if (args.strictCodeDiff) {
|
|
11125
|
+
gateFailures.push(...diffIssues);
|
|
11126
|
+
}
|
|
10686
11127
|
if (gateFailures.length > 0) {
|
|
10687
11128
|
for (const failure of gateFailures) {
|
|
10688
11129
|
console.error(`Review gate failed: ${failure}`);
|
|
@@ -11211,7 +11652,23 @@ function loadReviewContext(args) {
|
|
|
11211
11652
|
changedFiles = parseNameStatus(text2);
|
|
11212
11653
|
}
|
|
11213
11654
|
}
|
|
11214
|
-
|
|
11655
|
+
let codeDiffs;
|
|
11656
|
+
if (args.codeDiffPath) {
|
|
11657
|
+
if (!args.patchPath) {
|
|
11658
|
+
console.error(
|
|
11659
|
+
"Error: --code-diff requires --patch <file> (generate it with: git diff --histogram > changes.patch)."
|
|
11660
|
+
);
|
|
11661
|
+
process.exit(EXIT_USAGE);
|
|
11662
|
+
}
|
|
11663
|
+
const sidecar = JSON.parse(readFileInput(args.codeDiffPath));
|
|
11664
|
+
const patch = readFileInput(args.patchPath);
|
|
11665
|
+
const { input, warnings } = assembleCodeDiff({ sidecar, patch });
|
|
11666
|
+
for (const warning of warnings) {
|
|
11667
|
+
console.error(`Code diff warning: ${warning}`);
|
|
11668
|
+
}
|
|
11669
|
+
codeDiffs = [input];
|
|
11670
|
+
}
|
|
11671
|
+
return { changedFiles, baseRef, headRef, codeDiffs };
|
|
11215
11672
|
}
|
|
11216
11673
|
function writeReviewReport(review, args) {
|
|
11217
11674
|
const title = args.htmlTitle && args.htmlTitle !== "Test Results" ? args.htmlTitle : void 0;
|
|
@@ -11896,6 +12353,7 @@ function createDefaultCliArgs() {
|
|
|
11896
12353
|
failOnAddedFailures: false,
|
|
11897
12354
|
failOnRemoval: false,
|
|
11898
12355
|
failOnNew: false,
|
|
12356
|
+
strictCodeDiff: false,
|
|
11899
12357
|
baselineMode: "explicit"
|
|
11900
12358
|
};
|
|
11901
12359
|
}
|