@anselmdk/feature-spec-md 0.5.0 → 0.6.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.
Files changed (65) hide show
  1. package/README.md +59 -78
  2. package/SPEC_FORMAT.md +38 -78
  3. package/dist/cli.js +194 -86
  4. package/dist/cli.js.map +1 -1
  5. package/dist/featureSpecs.d.ts +1 -1
  6. package/dist/featureSpecs.d.ts.map +1 -1
  7. package/dist/featureSpecs.js +57 -10
  8. package/dist/featureSpecs.js.map +1 -1
  9. package/dist/githubActionDiffReport.d.ts +61 -0
  10. package/dist/githubActionDiffReport.d.ts.map +1 -1
  11. package/dist/githubActionDiffReport.js +343 -119
  12. package/dist/githubActionDiffReport.js.map +1 -1
  13. package/dist/index.d.ts +8 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/mockReports.d.ts +36 -0
  18. package/dist/mockReports.d.ts.map +1 -0
  19. package/dist/mockReports.js +250 -0
  20. package/dist/mockReports.js.map +1 -0
  21. package/dist/mocks/current/app/supportDesk.d.ts +12 -0
  22. package/dist/mocks/current/app/supportDesk.d.ts.map +1 -0
  23. package/dist/mocks/current/app/supportDesk.js +19 -0
  24. package/dist/mocks/current/app/supportDesk.js.map +1 -0
  25. package/dist/mocks/current/tests/supportDesk.spec.d.ts +3 -0
  26. package/dist/mocks/current/tests/supportDesk.spec.d.ts.map +1 -0
  27. package/dist/mocks/current/tests/supportDesk.spec.js +15 -0
  28. package/dist/mocks/current/tests/supportDesk.spec.js.map +1 -0
  29. package/dist/mocks/previous/app/supportDesk.d.ts +10 -0
  30. package/dist/mocks/previous/app/supportDesk.d.ts.map +1 -0
  31. package/dist/mocks/previous/app/supportDesk.js +9 -0
  32. package/dist/mocks/previous/app/supportDesk.js.map +1 -0
  33. package/dist/mocks/previous/tests/supportDesk.spec.d.ts +3 -0
  34. package/dist/mocks/previous/tests/supportDesk.spec.d.ts.map +1 -0
  35. package/dist/mocks/previous/tests/supportDesk.spec.js +15 -0
  36. package/dist/mocks/previous/tests/supportDesk.spec.js.map +1 -0
  37. package/dist/reportMetadata.d.ts +9 -0
  38. package/dist/reportMetadata.d.ts.map +1 -0
  39. package/dist/reportMetadata.js +28 -0
  40. package/dist/reportMetadata.js.map +1 -0
  41. package/dist/screenshots.d.ts +3 -1
  42. package/dist/screenshots.d.ts.map +1 -1
  43. package/dist/screenshots.js +23 -0
  44. package/dist/screenshots.js.map +1 -1
  45. package/dist/types.d.ts +11 -1
  46. package/dist/types.d.ts.map +1 -1
  47. package/docs/evidence-policy.md +63 -0
  48. package/docs/report-development.md +102 -0
  49. package/docs/spec-driven-flow.md +67 -8
  50. package/package.json +3 -1
  51. package/src/mocks/current/app/supportDesk.ts +27 -0
  52. package/src/mocks/current/screenshots/SUPPORT-INBOX-S001-line-27-current.svg +10 -0
  53. package/src/mocks/current/screenshots/SUPPORT-REPLY-S001-line-27-current.svg +10 -0
  54. package/src/mocks/current/screenshots/screenshots.json +18 -0
  55. package/src/mocks/current/specs/support-desk.model.md +29 -0
  56. package/src/mocks/current/specs/ticket-inbox.feature.md +27 -0
  57. package/src/mocks/current/specs/ticket-reply.feature.md +33 -0
  58. package/src/mocks/current/tests/supportDesk.spec.ts +17 -0
  59. package/src/mocks/previous/app/supportDesk.ts +17 -0
  60. package/src/mocks/previous/screenshots/SUPPORT-INBOX-S001-line-27-previous.svg +10 -0
  61. package/src/mocks/previous/screenshots/screenshots.json +11 -0
  62. package/src/mocks/previous/specs/support-desk.model.md +29 -0
  63. package/src/mocks/previous/specs/ticket-inbox.feature.md +26 -0
  64. package/src/mocks/previous/specs/ticket-reply.feature.md +26 -0
  65. package/src/mocks/previous/tests/supportDesk.spec.ts +17 -0
@@ -1,4 +1,65 @@
1
1
  import { type GithubActionOptions } from "./githubActionFtp.js";
2
2
  export type GithubActionDiffReportOptions = GithubActionOptions;
3
+ export type LocalDiffReportOptions = {
4
+ previousDir: string;
5
+ currentDir: string;
6
+ prNumber?: string;
7
+ baseBuild?: string;
8
+ currentBuild?: string;
9
+ baseBuildUrl?: string;
10
+ currentBuildUrl?: string;
11
+ baseLabel?: DiffReport["baseLabel"];
12
+ previousAssetUrlPrefix?: string;
13
+ currentAssetUrlPrefix?: string;
14
+ };
15
+ type ComparedFile = {
16
+ path: string;
17
+ kind: "report" | "screenshot" | "asset";
18
+ status: "added" | "removed" | "changed" | "unchanged";
19
+ previousHash?: string;
20
+ currentHash?: string;
21
+ previousSize?: number;
22
+ currentSize?: number;
23
+ };
24
+ type DiffLine = {
25
+ kind: "context" | "added" | "removed";
26
+ previousLine?: number;
27
+ currentLine?: number;
28
+ text: string;
29
+ };
30
+ type SpecDiff = {
31
+ key: string;
32
+ title: string;
33
+ filePath?: string;
34
+ status: "added" | "removed" | "changed";
35
+ lines: DiffLine[];
36
+ };
37
+ type ScreenshotDiffItem = {
38
+ path: string;
39
+ title: string;
40
+ status: ComparedFile["status"];
41
+ previousUrl?: string;
42
+ currentUrl?: string;
43
+ previousSize?: number;
44
+ currentSize?: number;
45
+ };
46
+ type ScreenshotDiffGroup = {
47
+ specLabel: string;
48
+ specPath?: string;
49
+ items: ScreenshotDiffItem[];
50
+ };
51
+ type DiffReport = {
52
+ prNumber: string;
53
+ baseBuild?: string;
54
+ currentBuild: string;
55
+ baseBuildUrl?: string;
56
+ currentBuildUrl: string;
57
+ baseLabel: "main" | "previous" | "none";
58
+ files: ComparedFile[];
59
+ specDiffs: SpecDiff[];
60
+ screenshotDiffs: ScreenshotDiffGroup[];
61
+ };
3
62
  export declare function publishGithubActionDiffReport(options: GithubActionDiffReportOptions): Promise<void>;
63
+ export declare function renderLocalDiffReport(options: LocalDiffReportOptions): Promise<string>;
64
+ export {};
4
65
  //# sourceMappingURL=githubActionDiffReport.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"githubActionDiffReport.d.ts","sourceRoot":"","sources":["../src/githubActionDiffReport.ts"],"names":[],"mappings":"AAKA,OAAO,EAQL,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAC;AAG9B,MAAM,MAAM,6BAA6B,GAAG,mBAAmB,CAAC;AAqBhE,wBAAsB,6BAA6B,CACjD,OAAO,EAAE,6BAA6B,iBA4DvC"}
1
+ {"version":3,"file":"githubActionDiffReport.d.ts","sourceRoot":"","sources":["../src/githubActionDiffReport.ts"],"names":[],"mappings":"AAKA,OAAO,EAQL,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAC;AAG9B,MAAM,MAAM,6BAA6B,GAAG,mBAAmB,CAAC;AAEhE,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;IACpC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC,CAAC;AAEF,KAAK,YAAY,GAAG;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,QAAQ,GAAG,YAAY,GAAG,OAAO,CAAC;IACxC,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,KAAK,QAAQ,GAAG;IACd,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAUF,KAAK,QAAQ,GAAG;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;IACxC,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,CAAC;AAEF,KAAK,kBAAkB,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,kBAAkB,EAAE,CAAC;CAC7B,CAAC;AAEF,KAAK,UAAU,GAAG;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IACxC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,eAAe,EAAE,mBAAmB,EAAE,CAAC;CACxC,CAAC;AAEF,wBAAsB,6BAA6B,CACjD,OAAO,EAAE,6BAA6B,iBAyCvC;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,mBAE1E"}
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { basename, join, relative } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { html } from "./html.js";
@@ -8,29 +8,18 @@ import { writeGithubOutput, writeGithubSummary } from "./githubActionOutput.js";
8
8
  export async function publishGithubActionDiffReport(options) {
9
9
  const config = ftpConfig(options);
10
10
  const prNumber = config.prNumber;
11
- if (!prNumber) {
11
+ if (!prNumber)
12
12
  throw new Error("Missing required option --pr-number or FEATURE_SPEC_PR_NUMBER.");
13
- }
14
13
  const buildsRoot = pathJoin(config.remoteDir, "build");
15
14
  const builds = await listBuildNumbers(buildsRoot, config);
16
15
  const currentBuild = config.buildNumber;
17
- const previousBuild = previousBuildNumber(builds, currentBuild);
18
- const currentBuildUrl = publicUrl(config.baseUrl, "build", currentBuild, "");
19
- const previousBuildUrl = previousBuild
20
- ? publicUrl(config.baseUrl, "build", previousBuild, "")
21
- : undefined;
22
- const report = previousBuild
23
- ? await compareBuilds(config, previousBuild, currentBuild, prNumber)
24
- : {
25
- prNumber,
26
- currentBuild,
27
- currentBuildUrl,
28
- files: [],
29
- };
30
- if (previousBuild) {
31
- report.previousBuild = previousBuild;
32
- report.previousBuildUrl = previousBuildUrl;
33
- }
16
+ const requestedBaseBuild = optionValue(options, "base-build-number", "FEATURE_SPEC_BASE_BUILD_NUMBER");
17
+ const hasRequestedBaseBuild = Boolean(requestedBaseBuild && builds.includes(requestedBaseBuild));
18
+ const baseBuild = hasRequestedBaseBuild ? requestedBaseBuild : previousBuildNumber(builds, currentBuild);
19
+ const baseLabel = hasRequestedBaseBuild ? "main" : baseBuild ? "previous" : "none";
20
+ const report = baseBuild
21
+ ? await compareBuilds(config, baseBuild, currentBuild, prNumber, baseLabel)
22
+ : emptyReport(config, prNumber, currentBuild);
34
23
  const localReportDir = join(tmpdir(), `feature-spec-md-pr-diff-${process.pid}-${prNumber}-${currentBuild}`);
35
24
  await mkdir(localReportDir, { recursive: true });
36
25
  await writeFile(join(localReportDir, "index.html"), renderDiffReport(report), "utf8");
@@ -41,145 +30,368 @@ export async function publishGithubActionDiffReport(options) {
41
30
  "## Feature Spec PR Diff",
42
31
  "",
43
32
  `<p><strong>Diff report:</strong> <a href=\"${html(reportUrl)}\">PR #${html(prNumber)} build ${html(currentBuild)}</a></p>`,
44
- report.previousBuild
45
- ? `<p>Compared build <strong>${html(report.previousBuild)}</strong> with build <strong>${html(currentBuild)}</strong>.</p>`
46
- : `<p>No earlier build was found, so this report establishes the first comparison baseline.</p>`,
33
+ summaryComparisonSentence(report),
47
34
  "",
48
35
  ].join("\n"));
49
36
  await writeGithubOutput({
50
37
  "diff-report-url": reportUrl,
51
38
  "diff-comment-body": commentBody(report, reportUrl),
52
- "previous-build": report.previousBuild ?? "",
39
+ "base-build": report.baseLabel === "main" ? report.baseBuild ?? "" : "",
40
+ "previous-build": report.baseLabel === "previous" ? report.baseBuild ?? "" : "",
53
41
  "current-build": currentBuild,
54
42
  });
55
43
  console.log(`Feature spec PR diff report uploaded to ${reportUrl}`);
56
44
  }
57
- async function compareBuilds(config, previousBuild, currentBuild, prNumber) {
58
- const previousRoot = pathJoin(config.remoteDir, "build", previousBuild);
45
+ export async function renderLocalDiffReport(options) {
46
+ return renderDiffReport(await compareLocalBuilds(options));
47
+ }
48
+ async function compareLocalBuilds(options) {
49
+ const baseBuild = options.baseBuild ?? "127";
50
+ const currentBuild = options.currentBuild ?? "128";
51
+ const files = await compareLocalFiles(options.previousDir, options.currentDir);
52
+ const previousIndex = await readFileMaybe(join(options.previousDir, "index.html"));
53
+ const currentIndex = await readFileMaybe(join(options.currentDir, "index.html"));
54
+ const previousSpecs = extractSpecSections(previousIndex ?? "");
55
+ const currentSpecs = extractSpecSections(currentIndex ?? "");
56
+ const specDiffs = compareSpecSections(previousSpecs, currentSpecs);
57
+ const scenarioToSpec = scenarioSpecMap([...previousSpecs, ...currentSpecs]);
58
+ const screenshotDiffs = groupScreenshotDiffs(files, scenarioToSpec, {
59
+ previousUrl: (filePath) => relativeAssetUrl(options.previousAssetUrlPrefix ?? "previous", filePath),
60
+ currentUrl: (filePath) => relativeAssetUrl(options.currentAssetUrlPrefix ?? "current", filePath),
61
+ });
62
+ return {
63
+ prNumber: options.prNumber ?? "42",
64
+ baseBuild,
65
+ currentBuild,
66
+ baseBuildUrl: options.baseBuildUrl ?? "previous/",
67
+ currentBuildUrl: options.currentBuildUrl ?? "current/",
68
+ baseLabel: options.baseLabel ?? "previous",
69
+ files,
70
+ specDiffs,
71
+ screenshotDiffs,
72
+ };
73
+ }
74
+ function emptyReport(config, prNumber, currentBuild) {
75
+ return {
76
+ prNumber,
77
+ currentBuild,
78
+ currentBuildUrl: publicUrl(config.baseUrl, "build", currentBuild, ""),
79
+ baseLabel: "none",
80
+ files: [],
81
+ specDiffs: [],
82
+ screenshotDiffs: [],
83
+ };
84
+ }
85
+ async function compareBuilds(config, baseBuild, currentBuild, prNumber, baseLabel) {
86
+ const baseRoot = pathJoin(config.remoteDir, "build", baseBuild);
59
87
  const currentRoot = pathJoin(config.remoteDir, "build", currentBuild);
60
- const previousFiles = await listRemoteFilesRecursive(previousRoot, config);
88
+ const baseFiles = await listRemoteFilesRecursive(baseRoot, config);
61
89
  const currentFiles = await listRemoteFilesRecursive(currentRoot, config);
62
- const previousRelative = new Set(previousFiles.map((file) => relativeRemotePath(previousRoot, file)));
90
+ const baseRelative = new Set(baseFiles.map((file) => relativeRemotePath(baseRoot, file)));
63
91
  const currentRelative = new Set(currentFiles.map((file) => relativeRemotePath(currentRoot, file)));
64
- const allPaths = Array.from(new Set([...previousRelative, ...currentRelative])).sort();
65
- const localRoot = join(tmpdir(), `feature-spec-md-build-compare-${process.pid}-${previousBuild}-${currentBuild}`);
92
+ const allPaths = Array.from(new Set([...baseRelative, ...currentRelative])).sort();
93
+ const localRoot = join(tmpdir(), `feature-spec-md-build-compare-${process.pid}-${baseBuild}-${currentBuild}`);
66
94
  const files = [];
67
95
  for (const filePath of allPaths) {
68
- const previousRemote = previousRelative.has(filePath)
69
- ? pathJoin(previousRoot, filePath)
70
- : undefined;
71
- const currentRemote = currentRelative.has(filePath)
72
- ? pathJoin(currentRoot, filePath)
73
- : undefined;
74
- const previousLocal = previousRemote
75
- ? join(localRoot, "previous", filePath)
76
- : undefined;
96
+ const baseRemote = baseRelative.has(filePath) ? pathJoin(baseRoot, filePath) : undefined;
97
+ const currentRemote = currentRelative.has(filePath) ? pathJoin(currentRoot, filePath) : undefined;
98
+ const baseLocal = baseRemote ? join(localRoot, "base", filePath) : undefined;
77
99
  const currentLocal = currentRemote ? join(localRoot, "current", filePath) : undefined;
78
- if (previousRemote && previousLocal) {
79
- await downloadRemoteFile(previousRemote, previousLocal, config);
80
- }
81
- if (currentRemote && currentLocal) {
100
+ if (baseRemote && baseLocal)
101
+ await downloadRemoteFile(baseRemote, baseLocal, config);
102
+ if (currentRemote && currentLocal)
82
103
  await downloadRemoteFile(currentRemote, currentLocal, config);
83
- }
84
- const previousInfo = previousLocal ? await fileInfo(previousLocal) : undefined;
104
+ const baseInfo = baseLocal ? await fileInfo(baseLocal) : undefined;
85
105
  const currentInfo = currentLocal ? await fileInfo(currentLocal) : undefined;
86
- const status = !previousInfo
87
- ? "added"
88
- : !currentInfo
89
- ? "removed"
90
- : previousInfo.hash === currentInfo.hash
91
- ? "unchanged"
92
- : "changed";
106
+ const status = !baseInfo ? "added" : !currentInfo ? "removed" : baseInfo.hash === currentInfo.hash ? "unchanged" : "changed";
93
107
  files.push({
94
108
  path: filePath,
95
109
  kind: fileKind(filePath),
96
110
  status,
97
- previousHash: previousInfo?.hash,
111
+ previousHash: baseInfo?.hash,
98
112
  currentHash: currentInfo?.hash,
99
- previousSize: previousInfo?.size,
113
+ previousSize: baseInfo?.size,
100
114
  currentSize: currentInfo?.size,
101
115
  });
102
116
  }
117
+ const previousIndex = await readFileMaybe(join(localRoot, "base", "index.html"));
118
+ const currentIndex = await readFileMaybe(join(localRoot, "current", "index.html"));
119
+ const previousSpecs = extractSpecSections(previousIndex ?? "");
120
+ const currentSpecs = extractSpecSections(currentIndex ?? "");
121
+ const specDiffs = compareSpecSections(previousSpecs, currentSpecs);
122
+ const scenarioToSpec = scenarioSpecMap([...previousSpecs, ...currentSpecs]);
123
+ const screenshotDiffs = groupScreenshotDiffs(files, scenarioToSpec, {
124
+ previousUrl: (filePath) => publicUrl(config.baseUrl, "build", baseBuild, filePath),
125
+ currentUrl: (filePath) => publicUrl(config.baseUrl, "build", currentBuild, filePath),
126
+ });
103
127
  return {
104
128
  prNumber,
105
- previousBuild,
129
+ baseBuild,
106
130
  currentBuild,
107
- previousBuildUrl: publicUrl(config.baseUrl, "build", previousBuild, ""),
131
+ baseBuildUrl: publicUrl(config.baseUrl, "build", baseBuild, ""),
108
132
  currentBuildUrl: publicUrl(config.baseUrl, "build", currentBuild, ""),
133
+ baseLabel,
109
134
  files,
135
+ specDiffs,
136
+ screenshotDiffs,
110
137
  };
111
138
  }
139
+ async function compareLocalFiles(previousDir, currentDir) {
140
+ const previousFiles = await listLocalFilesRecursive(previousDir);
141
+ const currentFiles = await listLocalFilesRecursive(currentDir);
142
+ const previousSet = new Set(previousFiles);
143
+ const currentSet = new Set(currentFiles);
144
+ const allPaths = Array.from(new Set([...previousSet, ...currentSet])).sort();
145
+ const files = [];
146
+ for (const filePath of allPaths) {
147
+ const previousInfo = previousSet.has(filePath) ? await fileInfo(join(previousDir, filePath)) : undefined;
148
+ const currentInfo = currentSet.has(filePath) ? await fileInfo(join(currentDir, filePath)) : undefined;
149
+ const status = !previousInfo ? "added" : !currentInfo ? "removed" : previousInfo.hash === currentInfo.hash ? "unchanged" : "changed";
150
+ files.push({
151
+ path: filePath,
152
+ kind: fileKind(filePath),
153
+ status,
154
+ previousHash: previousInfo?.hash,
155
+ currentHash: currentInfo?.hash,
156
+ previousSize: previousInfo?.size,
157
+ currentSize: currentInfo?.size,
158
+ });
159
+ }
160
+ return files;
161
+ }
162
+ async function listLocalFilesRecursive(root) {
163
+ const files = [];
164
+ await visit("");
165
+ return files.sort();
166
+ async function visit(relativeDir) {
167
+ const dir = relativeDir ? join(root, relativeDir) : root;
168
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
169
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
170
+ if (entry.isDirectory()) {
171
+ await visit(relativePath);
172
+ }
173
+ else if (entry.isFile()) {
174
+ files.push(relativePath);
175
+ }
176
+ }
177
+ }
178
+ }
112
179
  function previousBuildNumber(builds, currentBuild) {
113
180
  const current = Number(currentBuild);
114
- return builds
115
- .map(Number)
116
- .filter((build) => Number.isFinite(build) && build < current)
117
- .sort((a, b) => b - a)
118
- .at(0)
119
- ?.toString();
181
+ return builds.map(Number).filter((build) => Number.isFinite(build) && build < current).sort((a, b) => b - a).at(0)?.toString();
182
+ }
183
+ function optionValue(options, key, envKey) {
184
+ return options[key] ?? process.env[envKey];
120
185
  }
121
186
  async function fileInfo(filePath) {
122
187
  const buffer = await readFile(filePath);
123
- return {
124
- hash: createHash("sha256").update(buffer).digest("hex"),
125
- size: buffer.byteLength,
126
- };
188
+ return { hash: createHash("sha256").update(buffer).digest("hex"), size: buffer.byteLength };
189
+ }
190
+ async function readFileMaybe(filePath) {
191
+ try {
192
+ return await readFile(filePath, "utf8");
193
+ }
194
+ catch {
195
+ return undefined;
196
+ }
127
197
  }
128
198
  function relativeRemotePath(root, filePath) {
129
199
  return relative(root, filePath).split("\\").join("/");
130
200
  }
131
201
  function fileKind(filePath) {
132
202
  const name = basename(filePath).toLowerCase();
133
- if (name === "index.html" || name.endsWith(".html") || name.endsWith(".json")) {
203
+ if (name === "index.html" || name.endsWith(".html") || name.endsWith(".json"))
134
204
  return "report";
135
- }
136
- if (/\.(png|jpe?g|webp|gif)$/i.test(name))
205
+ if (/\.(png|jpe?g|webp|gif|svg)$/i.test(name))
137
206
  return "screenshot";
138
207
  return "asset";
139
208
  }
209
+ function extractSpecSections(source) {
210
+ if (!source)
211
+ return [];
212
+ const sections = [];
213
+ const matches = source.matchAll(/<section class="panel">([\s\S]*?)(?=<section class="panel">|<script>|<\/body>)/g);
214
+ for (const match of matches) {
215
+ const fragment = match[1] ?? "";
216
+ const heading = fragment.match(/<h2[^>]*>([\s\S]*?)<\/h2>/)?.[1];
217
+ if (!heading)
218
+ continue;
219
+ const title = textContent(heading).trim();
220
+ if (!title || title === "Validation" || title === "Models")
221
+ continue;
222
+ const filePaths = Array.from(fragment.matchAll(/title="([^":]+\.md):\d+"/g), (item) => item[1]);
223
+ const scenarioIds = Array.from(fragment.matchAll(/<summary><code>([^<]+)<\/code>/g), (item) => textContent(item[1] ?? ""));
224
+ const text = specText(fragment);
225
+ const filePath = filePaths[0];
226
+ sections.push({
227
+ key: filePath ?? title,
228
+ title,
229
+ filePath,
230
+ scenarioIds,
231
+ text,
232
+ });
233
+ }
234
+ return sections;
235
+ }
236
+ function specText(fragment) {
237
+ return textContent(fragment)
238
+ .split(/\n+/)
239
+ .map((line) => line.trim())
240
+ .filter(Boolean)
241
+ .join("\n");
242
+ }
243
+ function textContent(fragment) {
244
+ return decodeEntities(fragment
245
+ .replace(/<br\s*\/?>/gi, "\n")
246
+ .replace(/<\/p>|<\/li>|<\/summary>|<\/h[1-6]>|<\/div>/gi, "\n")
247
+ .replace(/<[^>]+>/g, ""));
248
+ }
249
+ function decodeEntities(value) {
250
+ return value
251
+ .replace(/&lt;/g, "<")
252
+ .replace(/&gt;/g, ">")
253
+ .replace(/&quot;/g, '"')
254
+ .replace(/&#39;/g, "'")
255
+ .replace(/&amp;/g, "&");
256
+ }
257
+ function compareSpecSections(previous, current) {
258
+ const previousByKey = new Map(previous.map((spec) => [spec.key, spec]));
259
+ const currentByKey = new Map(current.map((spec) => [spec.key, spec]));
260
+ const keys = Array.from(new Set([...previousByKey.keys(), ...currentByKey.keys()])).sort();
261
+ const diffs = [];
262
+ for (const key of keys) {
263
+ const before = previousByKey.get(key);
264
+ const after = currentByKey.get(key);
265
+ const status = !before ? "added" : !after ? "removed" : before.text === after.text ? undefined : "changed";
266
+ if (!status)
267
+ continue;
268
+ diffs.push({
269
+ key,
270
+ title: after?.title ?? before?.title ?? key,
271
+ filePath: after?.filePath ?? before?.filePath,
272
+ status,
273
+ lines: diffLines(before?.text ?? "", after?.text ?? ""),
274
+ });
275
+ }
276
+ return diffs;
277
+ }
278
+ function diffLines(previous, current) {
279
+ const a = previous.split("\n");
280
+ const b = current.split("\n");
281
+ const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
282
+ for (let i = a.length - 1; i >= 0; i -= 1) {
283
+ for (let j = b.length - 1; j >= 0; j -= 1) {
284
+ matrix[i][j] = a[i] === b[j] ? matrix[i + 1][j + 1] + 1 : Math.max(matrix[i + 1][j], matrix[i][j + 1]);
285
+ }
286
+ }
287
+ const lines = [];
288
+ let i = 0;
289
+ let j = 0;
290
+ while (i < a.length || j < b.length) {
291
+ if (i < a.length && j < b.length && a[i] === b[j]) {
292
+ lines.push({ kind: "context", previousLine: i + 1, currentLine: j + 1, text: a[i] });
293
+ i += 1;
294
+ j += 1;
295
+ }
296
+ else if (j < b.length && (i === a.length || matrix[i][j + 1] >= matrix[i + 1][j])) {
297
+ lines.push({ kind: "added", currentLine: j + 1, text: b[j] });
298
+ j += 1;
299
+ }
300
+ else if (i < a.length) {
301
+ lines.push({ kind: "removed", previousLine: i + 1, text: a[i] });
302
+ i += 1;
303
+ }
304
+ }
305
+ return compactContext(lines);
306
+ }
307
+ function compactContext(lines) {
308
+ const keep = new Set();
309
+ lines.forEach((line, index) => {
310
+ if (line.kind === "context")
311
+ return;
312
+ for (let offset = -3; offset <= 3; offset += 1) {
313
+ const candidate = index + offset;
314
+ if (candidate >= 0 && candidate < lines.length)
315
+ keep.add(candidate);
316
+ }
317
+ });
318
+ return lines.filter((line, index) => line.kind !== "context" || keep.has(index));
319
+ }
320
+ function scenarioSpecMap(specs) {
321
+ const map = new Map();
322
+ for (const spec of specs) {
323
+ for (const scenarioId of spec.scenarioIds) {
324
+ map.set(scenarioId, spec);
325
+ }
326
+ }
327
+ return map;
328
+ }
329
+ function groupScreenshotDiffs(files, scenarioMap, urls) {
330
+ const groups = new Map();
331
+ for (const file of files.filter((item) => item.kind === "screenshot" && item.status !== "unchanged")) {
332
+ const scenarioId = scenarioIdFromScreenshotPath(file.path);
333
+ const spec = scenarioId ? scenarioMap.get(scenarioId) : undefined;
334
+ const specLabel = spec?.filePath ?? spec?.title ?? scenarioId ?? "Unmapped screenshots";
335
+ const group = groups.get(specLabel) ?? { specLabel, specPath: spec?.filePath, items: [] };
336
+ group.items.push({
337
+ path: file.path,
338
+ title: screenshotTitle(file.path, scenarioId),
339
+ status: file.status,
340
+ previousUrl: file.status !== "added" ? urls.previousUrl(file.path) : undefined,
341
+ currentUrl: file.status !== "removed" ? urls.currentUrl(file.path) : undefined,
342
+ previousSize: file.previousSize,
343
+ currentSize: file.currentSize,
344
+ });
345
+ groups.set(specLabel, group);
346
+ }
347
+ return Array.from(groups.values()).sort((a, b) => a.specLabel.localeCompare(b.specLabel));
348
+ }
349
+ function scenarioIdFromScreenshotPath(filePath) {
350
+ return basename(filePath).match(/^(.+?)-line-\d+-/)?.[1];
351
+ }
352
+ function screenshotTitle(filePath, scenarioId) {
353
+ const name = basename(filePath).replace(/\.(png|jpe?g|webp|gif|svg)$/i, "");
354
+ return scenarioId ? name.replace(`${scenarioId}-`, `${scenarioId} `) : name;
355
+ }
356
+ function relativeAssetUrl(prefix, filePath) {
357
+ return [prefix.replace(/\/+$/, ""), ...filePath.split("/").filter(Boolean)]
358
+ .filter(Boolean)
359
+ .join("/");
360
+ }
140
361
  function renderDiffReport(report) {
141
- const changed = report.files.filter((file) => file.status !== "unchanged");
142
- const byKind = (kind) => changed.filter((file) => file.kind === kind);
143
- const sections = [
144
- renderFileSection("Report output", byKind("report")),
145
- renderFileSection("Screenshots", byKind("screenshot")),
146
- renderFileSection("Other assets", byKind("asset")),
147
- ].join("\n");
148
- return `<!doctype html>
149
- <html lang="en">
150
- <head>
151
- <meta charset="utf-8">
152
- <meta name="viewport" content="width=device-width,initial-scale=1">
153
- <title>Feature spec PR diff</title>
154
- <style>
155
- body{font-family:system-ui,sans-serif;max-width:1180px;margin:0 auto;padding:40px 24px;color:#1f2328}
156
- .panel{border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:18px 0}
157
- .badge{border:1px solid #d0d7de;border-radius:999px;padding:2px 8px;font-size:12px;white-space:nowrap}
158
- .added{color:#1a7f37}.removed{color:#cf222e}.changed{color:#9a6700}.muted{color:#57606a}
159
- table{border-collapse:collapse;width:100%;font-size:14px} th,td{border:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top} th{background:#f6f8fa}
160
- a{color:#0969da}
161
- </style>
162
- </head>
163
- <body>
164
- <h1>Feature spec PR diff for PR #${html(report.prNumber)}</h1>
165
- <p>Generated ${html(new Date().toISOString())}.</p>
166
- <section class="panel">
167
- <h2>Compared builds</h2>
168
- <p>${report.previousBuild ? `Previous: <a href="${html(report.previousBuildUrl ?? "")}">build ${html(report.previousBuild)}</a>` : "No previous build found."}</p>
169
- <p>Current: <a href="${html(report.currentBuildUrl)}">build ${html(report.currentBuild)}</a></p>
170
- <p><span class="badge">${changed.length} changed file${changed.length === 1 ? "" : "s"}</span> <span class="muted">${report.files.length} file${report.files.length === 1 ? "" : "s"} checked</span></p>
171
- </section>
172
- ${sections}
173
- </body>
174
- </html>`;
362
+ const changed = report.files.filter((file) => file.status !== "unchanged" && file.kind !== "report");
363
+ const assetChanges = changed.filter((file) => file.kind === "asset");
364
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Feature spec PR diff</title><style>body{font-family:system-ui,sans-serif;max-width:1180px;margin:0 auto;padding:40px 24px;color:#1f2328}.panel{border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:18px 0}.badge{border:1px solid #d0d7de;border-radius:999px;padding:2px 8px;font-size:12px}.added{color:#1a7f37}.removed{color:#cf222e}.changed{color:#9a6700}.muted{color:#57606a}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f6f8fa}a{color:#0969da}.diff{width:100%;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.diff td{padding:2px 8px}.line-no{width:1%;color:#57606a;background:#f6f8fa;text-align:right;user-select:none}.diff-added td{background:#dafbe1}.diff-removed td{background:#ffebe9}.diff-context td{background:#fff}.image-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}.image-card{border:1px solid #d0d7de;border-radius:8px;background:#f6f8fa;overflow:hidden}.image-card h4{margin:0;padding:8px 10px;background:#fff;border-bottom:1px solid #d0d7de}.image-card img{display:block;width:100%;height:auto}</style></head><body><h1>Feature spec PR diff for PR #${html(report.prNumber)}</h1><p>Generated ${html(new Date().toISOString())}.</p><section class="panel"><h2>Compared builds</h2><p>${report.baseBuild ? `${baseLabel(report)}: <a href="${html(report.baseBuildUrl ?? "")}">build ${html(report.baseBuild)}</a>` : "No base build found."}</p><p>PR: <a href="${html(report.currentBuildUrl)}">build ${html(report.currentBuild)}</a></p><p><span class="badge">${report.specDiffs.length} spec change${report.specDiffs.length === 1 ? "" : "s"}</span> <span class="badge">${screenshotChangeCount(report)} screenshot change${screenshotChangeCount(report) === 1 ? "" : "s"}</span></p></section>${renderSpecDiffs(report.specDiffs)}${renderScreenshotDiffs(report.screenshotDiffs)}${renderFileSection("Other assets", assetChanges)}</body></html>`;
365
+ }
366
+ function renderSpecDiffs(specDiffs) {
367
+ if (!specDiffs.length)
368
+ return `<section class="panel"><h2>Spec changes</h2><p class="muted">No spec text changes detected in the published report.</p></section>`;
369
+ return `<section class="panel"><h2>Spec changes</h2>${specDiffs.map(renderSpecDiff).join("\n")}</section>`;
370
+ }
371
+ function renderSpecDiff(diff) {
372
+ return `<details open><summary><strong>${html(diff.filePath ?? diff.title)}</strong> <span class="badge ${diff.status}">${html(diff.status)}</span></summary><table class="diff"><tbody>${diff.lines.map(renderDiffLine).join("")}</tbody></table></details>`;
373
+ }
374
+ function renderDiffLine(line) {
375
+ const marker = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : "";
376
+ return `<tr class="diff-${line.kind}"><td class="line-no">${line.previousLine ?? ""}</td><td class="line-no">${line.currentLine ?? ""}</td><td>${html(marker)} ${html(line.text)}</td></tr>`;
377
+ }
378
+ function renderScreenshotDiffs(groups) {
379
+ if (!groups.length)
380
+ return `<section class="panel"><h2>Screenshots</h2><p class="muted">No screenshot changes.</p></section>`;
381
+ return `<section class="panel"><h2>Screenshots</h2>${groups.map(renderScreenshotGroup).join("\n")}</section>`;
382
+ }
383
+ function renderScreenshotGroup(group) {
384
+ return `<section><h3>${html(group.specPath ?? group.specLabel)}</h3>${group.items.map(renderScreenshotItem).join("\n")}</section>`;
385
+ }
386
+ function renderScreenshotItem(item) {
387
+ const before = item.previousUrl ? `<div class="image-card"><h4>Before</h4><img src="${html(item.previousUrl)}" alt="Before ${html(item.title)}"></div>` : "";
388
+ const after = item.currentUrl ? `<div class="image-card"><h4>After</h4><img src="${html(item.currentUrl)}" alt="After ${html(item.title)}"></div>` : "";
389
+ return `<details open><summary><code>${html(item.path)}</code> <span class="badge ${item.status}">${html(item.status)}</span> <span class="muted">${html(sizeChange({ previousSize: item.previousSize, currentSize: item.currentSize }))}</span></summary><div class="image-pair">${before}${after}</div></details>`;
175
390
  }
176
391
  function renderFileSection(title, files) {
177
- if (!files.length) {
392
+ if (!files.length)
178
393
  return `<section class="panel"><h2>${html(title)}</h2><p class="muted">No changes.</p></section>`;
179
- }
180
- return `<section class="panel"><h2>${html(title)}</h2><table><thead><tr><th>Status</th><th>File</th><th>Size change</th></tr></thead><tbody>${files
181
- .map((file) => `<tr><td class="${file.status}">${html(file.status)}</td><td><code>${html(file.path)}</code></td><td>${html(sizeChange(file))}</td></tr>`)
182
- .join("")}</tbody></table></section>`;
394
+ return `<section class="panel"><h2>${html(title)}</h2><table><thead><tr><th>Status</th><th>File</th><th>Size change</th></tr></thead><tbody>${files.map((file) => `<tr><td class="${file.status}">${html(file.status)}</td><td><code>${html(file.path)}</code></td><td>${html(sizeChange(file))}</td></tr>`).join("")}</tbody></table></section>`;
183
395
  }
184
396
  function sizeChange(file) {
185
397
  if (file.previousSize === undefined)
@@ -187,23 +399,35 @@ function sizeChange(file) {
187
399
  if (file.currentSize === undefined)
188
400
  return `-${file.previousSize} bytes`;
189
401
  const diff = file.currentSize - file.previousSize;
190
- if (diff === 0)
191
- return "same size";
192
- return `${diff > 0 ? "+" : ""}${diff} bytes`;
402
+ return diff === 0 ? "same size" : `${diff > 0 ? "+" : ""}${diff} bytes`;
403
+ }
404
+ function screenshotChangeCount(report) {
405
+ return report.screenshotDiffs.reduce((sum, group) => sum + group.items.length, 0);
193
406
  }
194
407
  function commentBody(report, reportUrl) {
195
- const changed = report.files.filter((file) => file.status !== "unchanged");
196
- const reportChanges = changed.filter((file) => file.kind === "report").length;
197
- const screenshotChanges = changed.filter((file) => file.kind === "screenshot").length;
198
- const previous = report.previousBuild
199
- ? `build ${report.previousBuild} → build ${report.currentBuild}`
200
- : `build ${report.currentBuild}`;
201
408
  return [
202
409
  "## Feature spec PR diff",
203
410
  "",
204
- `[Open the diff report](${reportUrl}) for ${previous}.`,
411
+ `[Open the diff report](${reportUrl}) for ${comparisonText(report)}.`,
205
412
  "",
206
- `Changed files: **${changed.length}** total, **${reportChanges}** report output, **${screenshotChanges}** screenshots.`,
413
+ `Changed: **${report.specDiffs.length}** spec file(s), **${screenshotChangeCount(report)}** screenshot(s).`,
207
414
  ].join("\n");
208
415
  }
416
+ function comparisonText(report) {
417
+ if (!report.baseBuild)
418
+ return `PR build ${report.currentBuild}`;
419
+ return `${baseLabel(report).toLowerCase()} build ${report.baseBuild} → PR build ${report.currentBuild}`;
420
+ }
421
+ function summaryComparisonSentence(report) {
422
+ if (!report.baseBuild)
423
+ return `<p>No base build was found, so this report establishes the first comparison baseline.</p>`;
424
+ return `<p>Compared ${html(baseLabel(report).toLowerCase())} build <strong>${html(report.baseBuild)}</strong> with PR build <strong>${html(report.currentBuild)}</strong>.</p>`;
425
+ }
426
+ function baseLabel(report) {
427
+ if (report.baseLabel === "main")
428
+ return "Main";
429
+ if (report.baseLabel === "previous")
430
+ return "Previous";
431
+ return "Base";
432
+ }
209
433
  //# sourceMappingURL=githubActionDiffReport.js.map