@asrunjs/asrun 0.0.0-stage → 0.5.2
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/commands/explore-case.md +18 -0
- package/.claude/commands/gen-spec.md +16 -0
- package/.claude/commands/heal-spec.md +26 -0
- package/.claude/commands/make-case.md +11 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +146 -0
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -0
- package/.claude/skills/exploring-cases/references/readiness.md +146 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
- package/.claude/skills/generating-specs/SKILL.md +219 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +72 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +39 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
- package/AGENTS.md +159 -0
- package/CLAUDE.md +2 -0
- package/LICENSE +21 -0
- package/README.md +511 -2
- package/bin/asrun.mjs +49 -0
- package/dist/lib/account-lock.d.ts +6 -0
- package/dist/lib/account-lock.js +52 -0
- package/dist/lib/assertions.d.ts +14 -0
- package/dist/lib/assertions.js +39 -0
- package/dist/lib/baselines.d.ts +43 -0
- package/dist/lib/baselines.js +108 -0
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/case-id.d.ts +11 -0
- package/dist/lib/case-id.js +17 -0
- package/dist/lib/case-ops.d.ts +26 -0
- package/dist/lib/case-ops.js +110 -0
- package/dist/lib/cases.d.ts +16 -0
- package/dist/lib/cases.js +51 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/downloads.d.ts +42 -0
- package/dist/lib/downloads.js +84 -0
- package/dist/lib/email.d.ts +10 -0
- package/dist/lib/email.js +19 -0
- package/dist/lib/ffmpeg.d.ts +8 -0
- package/dist/lib/ffmpeg.js +50 -0
- package/dist/lib/fixtures.d.ts +40 -0
- package/dist/lib/fixtures.js +149 -0
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +15 -0
- package/dist/lib/node-cli.js +36 -0
- package/dist/lib/paths.d.ts +25 -0
- package/dist/lib/paths.js +88 -0
- package/dist/lib/pdf.d.ts +71 -0
- package/dist/lib/pdf.js +140 -0
- package/dist/lib/profiles.d.ts +62 -0
- package/dist/lib/profiles.js +174 -0
- package/dist/lib/ready.d.ts +36 -0
- package/dist/lib/ready.js +53 -0
- package/dist/lib/records.d.ts +33 -0
- package/dist/lib/records.js +61 -0
- package/dist/lib/run-filters.d.ts +19 -0
- package/dist/lib/run-filters.js +38 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +30 -0
- package/dist/lib/session.js +111 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +19 -0
- package/dist/lib/targets.js +26 -0
- package/dist/lib/ui-build.d.ts +10 -0
- package/dist/lib/ui-build.js +30 -0
- package/dist/lib/values.d.ts +29 -0
- package/dist/lib/values.js +85 -0
- package/dist/lib/write-gate.d.ts +41 -0
- package/dist/lib/write-gate.js +129 -0
- package/dist/playwright.config.d.ts +2 -0
- package/dist/playwright.config.js +92 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +21 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +149 -0
- package/dist/scripts/cleanup.d.ts +1 -0
- package/dist/scripts/cleanup.js +25 -0
- package/dist/scripts/cli.d.ts +1 -0
- package/dist/scripts/cli.js +28 -0
- package/dist/scripts/doctor.d.ts +1 -0
- package/dist/scripts/doctor.js +296 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +75 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +22 -0
- package/dist/scripts/init.js +332 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +141 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +75 -0
- package/dist/scripts/new-case.d.ts +1 -0
- package/dist/scripts/new-case.js +19 -0
- package/dist/scripts/render-pdf.d.ts +1 -0
- package/dist/scripts/render-pdf.js +44 -0
- package/dist/scripts/report.d.ts +1 -0
- package/dist/scripts/report.js +14 -0
- package/dist/scripts/review-baselines.d.ts +1 -0
- package/dist/scripts/review-baselines.js +30 -0
- package/dist/scripts/run.d.ts +1 -0
- package/dist/scripts/run.js +423 -0
- package/dist/scripts/status.d.ts +1 -0
- package/dist/scripts/status.js +31 -0
- package/dist/scripts/ui-build.d.ts +1 -0
- package/dist/scripts/ui-build.js +7 -0
- package/dist/scripts/ui-dev.d.ts +1 -0
- package/dist/scripts/ui-dev.js +3 -0
- package/dist/scripts/ui.d.ts +1 -0
- package/dist/scripts/ui.js +13 -0
- package/dist/ui/dev.d.ts +1 -0
- package/dist/ui/dev.js +36 -0
- package/dist/ui/server/actions.d.ts +95 -0
- package/dist/ui/server/actions.js +365 -0
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +66 -0
- package/dist/ui/server/ai-stream.js +408 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +395 -0
- package/dist/ui/server/cases.d.ts +23 -0
- package/dist/ui/server/cases.js +170 -0
- package/dist/ui/server/files.d.ts +5 -0
- package/dist/ui/server/files.js +67 -0
- package/dist/ui/server/index.d.ts +1 -0
- package/dist/ui/server/index.js +33 -0
- package/dist/ui/server/jobs.d.ts +80 -0
- package/dist/ui/server/jobs.js +317 -0
- package/dist/ui/server/profiles.d.ts +35 -0
- package/dist/ui/server/profiles.js +371 -0
- package/dist/ui/server/watch-files.d.ts +4 -0
- package/dist/ui/server/watch-files.js +36 -0
- package/dist/ui/shared/ai-commands.d.ts +88 -0
- package/dist/ui/shared/ai-commands.js +177 -0
- package/dist/ui/shared/case-id.d.ts +1 -0
- package/dist/ui/shared/case-id.js +1 -0
- package/dist/ui/shared/jobs.d.ts +6 -0
- package/dist/ui/shared/jobs.js +7 -0
- package/dist/ui/shared/product.d.ts +3 -0
- package/dist/ui/shared/product.js +3 -0
- package/dist/ui/shared/run-filters.d.ts +1 -0
- package/dist/ui/shared/run-filters.js +1 -0
- package/dist/ui/shared/site-address.d.ts +6 -0
- package/dist/ui/shared/site-address.js +11 -0
- package/dist/ui/shared/types.d.ts +371 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/agents/framework-dev.md +79 -0
- package/docs/theme-guide.html +922 -0
- package/package.json +109 -3
- package/templates/.claude/commands/explore-case.md +22 -0
- package/templates/.claude/commands/gen-spec.md +21 -0
- package/templates/.claude/commands/heal-spec.md +31 -0
- package/templates/.claude/commands/make-case.md +13 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +64 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +57 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +16 -0
- package/templates/package.json +31 -0
- package/templates/playwright.config.ts +3 -0
- package/templates/pnpm-workspace.yaml +5 -0
- package/templates/profiles.yaml +42 -0
- package/templates/seed.spec.ts +9 -0
- package/templates/tsconfig.json +14 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
- package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
- package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
- package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
- package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
- package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
- package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
- package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
- package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
- package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
- package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
- package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
- package/ui/dist/assets/css-CnaYoQib.js +1 -0
- package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
- package/ui/dist/assets/d-DWkQyNnU.js +1 -0
- package/ui/dist/assets/diff-ChtP43wD.js +1 -0
- package/ui/dist/assets/dist-BGbe0-Aa.js +1 -0
- package/ui/dist/assets/dist-Ba-HC662.js +2 -0
- package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
- package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
- package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-CmpteMYY.js +9 -0
- package/ui/dist/assets/dist-DDJNvivM.js +1 -0
- package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
- package/ui/dist/assets/dist-DSy3dVf4.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-jjxQutEm.js +1 -0
- package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
- package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
- package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
- package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
- package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
- package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
- package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
- package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
- package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
- package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
- package/ui/dist/assets/forth-D6RzAl64.js +1 -0
- package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
- package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
- package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
- package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
- package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
- package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
- package/ui/dist/assets/http-CLVgA2GD.js +1 -0
- package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
- package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
- package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
- package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
- package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
- package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
- package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
- package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
- package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
- package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
- package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
- package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
- package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
- package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
- package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
- package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
- package/ui/dist/assets/oz-BwTct_5T.js +1 -0
- package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
- package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
- package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
- package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
- package/ui/dist/assets/properties-C357ku5U.js +1 -0
- package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
- package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
- package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
- package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
- package/ui/dist/assets/q-B9NhS7L_.js +1 -0
- package/ui/dist/assets/r-CxDmU1I4.js +1 -0
- package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
- package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
- package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
- package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
- package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
- package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
- package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
- package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
- package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
- package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
- package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
- package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
- package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
- package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
- package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
- package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
- package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
- package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
- package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
- package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
- package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
- package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
- package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
- package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
- package/ui/dist/assets/vb-BthdM_4N.js +1 -0
- package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
- package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
- package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
- package/ui/dist/assets/vhdl-kS471soi.js +1 -0
- package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
- package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
- package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
- package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.svg +11 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
import { NAME_PATTERN } from './case-id.js';
|
|
3
|
+
export const STEP_TAGS = ['writes', 'input', 'email'];
|
|
4
|
+
/** The fields every card must have; `pnpm case:check` fails without them. Kept here (pure) for the web panel. */
|
|
5
|
+
export const TARGET_REQUIRED_FIELDS = ['what', 'where', 'identified by'];
|
|
6
|
+
/* ------------------------------------------------------------------ parsing */
|
|
7
|
+
/**
|
|
8
|
+
* The body of `## <name>`: exactly the regex every reader used before this module existed
|
|
9
|
+
* (`lib/values.ts`, `lib/targets.ts`, `scripts/check-cases.ts`), so behaviour does not change.
|
|
10
|
+
*/
|
|
11
|
+
export function caseSectionText(text, name) {
|
|
12
|
+
return caseSectionMatch(text, name)?.body ?? '';
|
|
13
|
+
}
|
|
14
|
+
/** The body of `## <name>` plus the offset it starts at, for parsers that report file line numbers. */
|
|
15
|
+
function caseSectionMatch(text, name) {
|
|
16
|
+
const match = new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text);
|
|
17
|
+
if (!match)
|
|
18
|
+
return null;
|
|
19
|
+
return { body: match[1], bodyStart: match.index + match[0].length - match[1].length };
|
|
20
|
+
}
|
|
21
|
+
/** The 1-based line number of one offset of the document. */
|
|
22
|
+
function lineNumberAt(text, offset) {
|
|
23
|
+
let line = 1;
|
|
24
|
+
for (let at = text.indexOf('\n'); at >= 0 && at < offset; at = text.indexOf('\n', at + 1))
|
|
25
|
+
line++;
|
|
26
|
+
return line;
|
|
27
|
+
}
|
|
28
|
+
export function splitLines(text) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
const breaks = /\r?\n/g;
|
|
31
|
+
let start = 0;
|
|
32
|
+
let number = 1;
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = breaks.exec(text)) !== null) {
|
|
35
|
+
lines.push({ number: number++, start, end: match.index, text: text.slice(start, match.index) });
|
|
36
|
+
start = match.index + match[0].length;
|
|
37
|
+
}
|
|
38
|
+
lines.push({ number, start, end: text.length, text: text.slice(start) });
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
/** Offset just after the line break that starts at `end` (or `end` itself at the end of the file). */
|
|
42
|
+
export function endWithBreak(text, end) {
|
|
43
|
+
if (text.startsWith('\r\n', end))
|
|
44
|
+
return end + 2;
|
|
45
|
+
if (text.startsWith('\n', end))
|
|
46
|
+
return end + 1;
|
|
47
|
+
return end;
|
|
48
|
+
}
|
|
49
|
+
/** Offset just after the line break of `line` (also when the line is the last one, without a break). */
|
|
50
|
+
export const afterLine = (text, line) => endWithBreak(text, line.end);
|
|
51
|
+
const isBlank = (text) => text.trim() === '';
|
|
52
|
+
const indentOf = (text) => /^[ \t]*/.exec(text)?.[0].length ?? 0;
|
|
53
|
+
/** Does the line open or close a fenced code block? */
|
|
54
|
+
const isFence = (text) => /^[ \t]*```/.test(text);
|
|
55
|
+
function sectionsOf(text, lines) {
|
|
56
|
+
const headings = lines
|
|
57
|
+
.map((line, index) => ({ line, index }))
|
|
58
|
+
.filter(({ line }) => /^##[ \t]+\S/.test(line.text));
|
|
59
|
+
return headings.map(({ line }, index) => ({
|
|
60
|
+
name: line.text.replace(/^##[ \t]+/, '').trim(),
|
|
61
|
+
headingStart: line.start,
|
|
62
|
+
body: {
|
|
63
|
+
start: afterLine(text, line),
|
|
64
|
+
end: headings[index + 1]?.line.start ?? text.length,
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
const sectionAt = (sections, offset) => sections.find((section) => offset >= section.body.start && offset < section.body.end)?.name ?? '';
|
|
69
|
+
/** The lines of one section, and whether they are inside a fenced code block. */
|
|
70
|
+
function sectionLines(text, section) {
|
|
71
|
+
const result = [];
|
|
72
|
+
let fenced = false;
|
|
73
|
+
for (const line of splitLines(text)) {
|
|
74
|
+
if (line.start < section.body.start)
|
|
75
|
+
continue;
|
|
76
|
+
if (line.start >= section.body.end)
|
|
77
|
+
break;
|
|
78
|
+
result.push({ line, fenced });
|
|
79
|
+
if (isFence(line.text))
|
|
80
|
+
fenced = !fenced;
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
/** The front matter block: its parsed data plus the range of every key, so the panel can edit one key at a time. */
|
|
85
|
+
export function parseCaseFrontMatter(text) {
|
|
86
|
+
const lines = splitLines(text);
|
|
87
|
+
if (lines.length === 0 || lines[0].text.trim() !== '---')
|
|
88
|
+
return null;
|
|
89
|
+
const closeIndex = lines.findIndex((line, index) => index > 0 && line.text.trim() === '---');
|
|
90
|
+
if (closeIndex < 0)
|
|
91
|
+
return null;
|
|
92
|
+
const raw = text.slice(afterLine(text, lines[0]), lines[closeIndex].start);
|
|
93
|
+
let data = {};
|
|
94
|
+
let problem = null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = parseYaml(raw);
|
|
97
|
+
if (parsed === null || parsed === undefined) {
|
|
98
|
+
data = {};
|
|
99
|
+
}
|
|
100
|
+
else if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
101
|
+
// The old reader treated a non-map front matter as "no front matter"; keep that reading.
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
data = parsed;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
problem = `the front matter is not valid yaml: ${error.message}`;
|
|
110
|
+
}
|
|
111
|
+
const entries = [];
|
|
112
|
+
let index = 1;
|
|
113
|
+
while (index < closeIndex) {
|
|
114
|
+
const line = lines[index];
|
|
115
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:(.*)$/.exec(line.text);
|
|
116
|
+
if (!match) {
|
|
117
|
+
index++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let last = index;
|
|
121
|
+
for (let next = index + 1; next < closeIndex; next++) {
|
|
122
|
+
const candidate = lines[next];
|
|
123
|
+
if (candidate.text.trim() !== '' && indentOf(candidate.text) === 0)
|
|
124
|
+
break;
|
|
125
|
+
if (candidate.text.trim() !== '')
|
|
126
|
+
last = next;
|
|
127
|
+
}
|
|
128
|
+
const block = last > index;
|
|
129
|
+
const colon = line.text.indexOf(':');
|
|
130
|
+
let valueStart = colon + 1;
|
|
131
|
+
while (line.text[valueStart] === ' ' || line.text[valueStart] === '\t')
|
|
132
|
+
valueStart++;
|
|
133
|
+
let valueRange;
|
|
134
|
+
if (block) {
|
|
135
|
+
valueRange = { start: afterLine(text, line), end: lines[last].end };
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
valueRange = { start: line.start + valueStart, end: scalarEnd(line, valueStart) };
|
|
139
|
+
}
|
|
140
|
+
entries.push({
|
|
141
|
+
key: match[1],
|
|
142
|
+
value: data[match[1]],
|
|
143
|
+
range: { start: line.start, end: lines[last].end },
|
|
144
|
+
valueRange,
|
|
145
|
+
line: line.number,
|
|
146
|
+
block,
|
|
147
|
+
});
|
|
148
|
+
index = last + 1;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
data,
|
|
152
|
+
entries,
|
|
153
|
+
range: { start: lines[0].start, end: lines[closeIndex].end },
|
|
154
|
+
insertAt: afterLine(text, lines[0]),
|
|
155
|
+
problem,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** End of a scalar or flow value on its line: before a ` #` comment, or after the closing quote/bracket. */
|
|
159
|
+
function scalarEnd(line, from) {
|
|
160
|
+
const text = line.text;
|
|
161
|
+
const quote = text[from];
|
|
162
|
+
if (quote === '"' || quote === "'") {
|
|
163
|
+
for (let index = from + 1; index < text.length; index++) {
|
|
164
|
+
if (text[index] === '\\' && quote === '"')
|
|
165
|
+
index++;
|
|
166
|
+
else if (text[index] === quote)
|
|
167
|
+
return line.start + index + 1;
|
|
168
|
+
}
|
|
169
|
+
return line.end;
|
|
170
|
+
}
|
|
171
|
+
if (quote === '[' || quote === '{') {
|
|
172
|
+
const close = quote === '[' ? ']' : '}';
|
|
173
|
+
let depth = 0;
|
|
174
|
+
let inner = '';
|
|
175
|
+
for (let index = from; index < text.length; index++) {
|
|
176
|
+
const character = text[index];
|
|
177
|
+
if (inner === '' && (character === '"' || character === "'"))
|
|
178
|
+
inner = character;
|
|
179
|
+
else if (inner !== '' && character === inner)
|
|
180
|
+
inner = '';
|
|
181
|
+
else if (inner === '' && character === quote)
|
|
182
|
+
depth++;
|
|
183
|
+
else if (inner === '' && character === close) {
|
|
184
|
+
depth--;
|
|
185
|
+
if (depth === 0)
|
|
186
|
+
return line.start + index + 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return line.end;
|
|
190
|
+
}
|
|
191
|
+
const comment = text.indexOf(' #', from);
|
|
192
|
+
let end = comment >= 0 ? comment : text.length;
|
|
193
|
+
while (end > from && (text[end - 1] === ' ' || text[end - 1] === '\t'))
|
|
194
|
+
end--;
|
|
195
|
+
return line.start + end;
|
|
196
|
+
}
|
|
197
|
+
const STEP_RE = /^(\d+)\.([ \t]+)(.*)$/;
|
|
198
|
+
const STEP_TAIL_RE = /(?:[ \t]*\[(writes|input|email)\])+[ \t]*$/;
|
|
199
|
+
/** A recipe line under a step: `- Recipe:` or `- Recipe (start): ...` (case-insensitive on the word). */
|
|
200
|
+
const RECIPE_RE = /^([ \t]*)[-*+][ \t]+recipe(?:[ \t]*\(([^)]*)\))?[ \t]*:(.*)$/i;
|
|
201
|
+
/** A readiness line under a step: `- Ready when: ...` (case-insensitive on the words). */
|
|
202
|
+
const READY_RE = /^([ \t]*)[-*+][ \t]+ready\s+when[ \t]*:(.*)$/i;
|
|
203
|
+
function parseSteps(text, section) {
|
|
204
|
+
if (!section)
|
|
205
|
+
return [];
|
|
206
|
+
const lines = sectionLines(text, section);
|
|
207
|
+
const steps = [];
|
|
208
|
+
for (let index = 0; index < lines.length; index++) {
|
|
209
|
+
const { line, fenced } = lines[index];
|
|
210
|
+
if (fenced)
|
|
211
|
+
continue;
|
|
212
|
+
const match = STEP_RE.exec(line.text);
|
|
213
|
+
if (!match)
|
|
214
|
+
continue;
|
|
215
|
+
// A wrapped step continues on the indented lines under it, up to its first nested bullet (a recipe or TODO block).
|
|
216
|
+
let last = index;
|
|
217
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
218
|
+
const nextLine = lines[next].line;
|
|
219
|
+
if (isBlank(nextLine.text) || indentOf(nextLine.text) <= indentOf(line.text))
|
|
220
|
+
break;
|
|
221
|
+
if (isListItem(nextLine.text))
|
|
222
|
+
break;
|
|
223
|
+
last = next;
|
|
224
|
+
}
|
|
225
|
+
// The nested block (recipes and TODO blocks) runs through the indented lines that follow, up to a blank line or a
|
|
226
|
+
// line at the step's own indent.
|
|
227
|
+
let blockLast = last;
|
|
228
|
+
for (let next = last + 1; next < lines.length; next++) {
|
|
229
|
+
const nextLine = lines[next].line;
|
|
230
|
+
if (isBlank(nextLine.text) || indentOf(nextLine.text) <= indentOf(line.text))
|
|
231
|
+
break;
|
|
232
|
+
blockLast = next;
|
|
233
|
+
}
|
|
234
|
+
const bodyStart = match[1].length + 1 + match[2].length;
|
|
235
|
+
const raw = [line.text.slice(bodyStart), ...lines.slice(index + 1, last + 1).map(({ line: next }) => next.text.trim())]
|
|
236
|
+
.filter((part) => part !== '')
|
|
237
|
+
.join(' ');
|
|
238
|
+
const tags = [];
|
|
239
|
+
let textEnd = raw.length;
|
|
240
|
+
const tail = STEP_TAIL_RE.exec(raw);
|
|
241
|
+
if (tail) {
|
|
242
|
+
for (const tag of tail[0].matchAll(/\[(writes|input|email)\]/g))
|
|
243
|
+
tags.push(tag[1]);
|
|
244
|
+
textEnd = tail.index;
|
|
245
|
+
}
|
|
246
|
+
const lastLine = lines[last].line;
|
|
247
|
+
const insertAt = lastLine.start + lastLine.text.replace(/[ \t]+$/, '').length;
|
|
248
|
+
const tailLength = tail ? tail[0].length : 0;
|
|
249
|
+
const nested = parseStepBullets(lines, last + 1, blockLast);
|
|
250
|
+
steps.push({
|
|
251
|
+
number: Number(match[1]),
|
|
252
|
+
text: raw.slice(0, textEnd).replace(/[ \t]+$/, ''),
|
|
253
|
+
tags,
|
|
254
|
+
range: { start: line.start, end: lastLine.end },
|
|
255
|
+
textRange: { start: line.start + bodyStart, end: Math.max(line.start + bodyStart, insertAt - tailLength) },
|
|
256
|
+
insertAt,
|
|
257
|
+
line: line.number,
|
|
258
|
+
recipes: nested.recipes,
|
|
259
|
+
readiness: nested.readiness,
|
|
260
|
+
});
|
|
261
|
+
index = blockLast;
|
|
262
|
+
}
|
|
263
|
+
return steps;
|
|
264
|
+
}
|
|
265
|
+
/** One nested bullet under a step: its line, its last continuation line and the text after the colon. */
|
|
266
|
+
function readNestedBullet(lines, at, to) {
|
|
267
|
+
const line = lines[at].line;
|
|
268
|
+
const indent = indentOf(line.text);
|
|
269
|
+
let last = at;
|
|
270
|
+
for (let next = at + 1; next <= to; next++) {
|
|
271
|
+
const nextLine = lines[next].line;
|
|
272
|
+
if (isBlank(nextLine.text) || isListItem(nextLine.text))
|
|
273
|
+
break;
|
|
274
|
+
if (indentOf(nextLine.text) <= indent)
|
|
275
|
+
break;
|
|
276
|
+
last = next;
|
|
277
|
+
}
|
|
278
|
+
const continuation = lines
|
|
279
|
+
.slice(at + 1, last + 1)
|
|
280
|
+
.map(({ line: nextLine }) => nextLine.text.trim())
|
|
281
|
+
.filter((part) => part !== '');
|
|
282
|
+
return {
|
|
283
|
+
line,
|
|
284
|
+
last,
|
|
285
|
+
/** Offset of the first character of `raw` (the text after the colon) in the document. */
|
|
286
|
+
textStart: (raw) => line.start + line.text.length - raw.length + (raw.length - raw.trimStart().length),
|
|
287
|
+
/** `raw` with its wrapped lines joined. */
|
|
288
|
+
text: (raw) => [raw.trim(), ...continuation].filter((part) => part !== '').join(' '),
|
|
289
|
+
end: lines[last].line.end,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** The `- Recipe (...): ...` and `- Ready when: ...` bullets of one step, from the first nested line to the last one. */
|
|
293
|
+
function parseStepBullets(lines, from, to) {
|
|
294
|
+
const recipes = [];
|
|
295
|
+
const readiness = [];
|
|
296
|
+
for (let at = from; at <= to; at++) {
|
|
297
|
+
if (lines[at].fenced)
|
|
298
|
+
continue;
|
|
299
|
+
const recipe = RECIPE_RE.exec(lines[at].line.text);
|
|
300
|
+
const ready = recipe ? null : READY_RE.exec(lines[at].line.text);
|
|
301
|
+
if (!recipe && !ready)
|
|
302
|
+
continue;
|
|
303
|
+
const bullet = readNestedBullet(lines, at, to);
|
|
304
|
+
if (recipe) {
|
|
305
|
+
const label = (recipe[2] ?? '').trim();
|
|
306
|
+
recipes.push({
|
|
307
|
+
what: label === '' ? null : label,
|
|
308
|
+
text: bullet.text(recipe[3]),
|
|
309
|
+
line: bullet.line.number,
|
|
310
|
+
range: { start: bullet.line.start, end: bullet.end },
|
|
311
|
+
textRange: { start: bullet.textStart(recipe[3]), end: bullet.end },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
else if (ready) {
|
|
315
|
+
readiness.push({
|
|
316
|
+
text: bullet.text(ready[2]),
|
|
317
|
+
line: bullet.line.number,
|
|
318
|
+
range: { start: bullet.line.start, end: bullet.end },
|
|
319
|
+
textRange: { start: bullet.textStart(ready[2]), end: bullet.end },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
at = bullet.last;
|
|
323
|
+
}
|
|
324
|
+
return { recipes, readiness };
|
|
325
|
+
}
|
|
326
|
+
/* A question block: `- ❓ TODO [non-blocking]: <question> (evidence)` with `- ✍️ Answer:` one level deeper. The emoji is
|
|
327
|
+
* part of the marker; a block without a tag is the old form and counts as blocking (see the open-questions reference).
|
|
328
|
+
* A question may wrap over several lines; its block ends at the first blank line or at a line that is not indented
|
|
329
|
+
* deeper than the question. */
|
|
330
|
+
const QUESTION_RE = /^([ \t]*)[-*+][ \t]+(?:(\u{1F6AB}|\u{2753})\uFE0F?[ \t]*)?to[-_]?dos?\b[ \t]*(?:\[(blocking|non-blocking)\])?[ \t]*:?[ \t]*(.*)$/iu;
|
|
331
|
+
const ANSWER_RE = /^([ \t]*)[-*+][ \t]+(?:\u270D\uFE0F?[ \t]*)?answer[ \t]*:(.*)$/iu;
|
|
332
|
+
const EVIDENCE_RE = /^(.*?)[ \t]*\(([^()]*)\)[ \t]*$/;
|
|
333
|
+
const HEADING_RE = /^##[ \t]+\S/;
|
|
334
|
+
const LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+\.)[ \t]+/;
|
|
335
|
+
const isListItem = (text) => LIST_ITEM_RE.test(text);
|
|
336
|
+
/** A bullet's text without its marker (`- `, `* `, `+ `); a numbered item keeps its number, and other lines come back unchanged. */
|
|
337
|
+
const withoutMarker = (text) => text.replace(/^[ \t]*[-*+][ \t]+/, '');
|
|
338
|
+
/**
|
|
339
|
+
* The text a question hangs under: one list item or one paragraph above it, wrapped lines joined. The nearest line with
|
|
340
|
+
* the same or a smaller indent is the anchor; a heading, a code fence or a blank line above the question means there is
|
|
341
|
+
* none (an accept would then delete a claim nobody sees, so the panel refuses it).
|
|
342
|
+
*/
|
|
343
|
+
function parentOf(lines, index, indent) {
|
|
344
|
+
// The nearest line above that can start the text: a heading, a fence or a blank line means there is none.
|
|
345
|
+
let candidate = -1;
|
|
346
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
347
|
+
const line = lines[i];
|
|
348
|
+
if (isBlank(line.text) || HEADING_RE.test(line.text) || isFence(line.text))
|
|
349
|
+
return null;
|
|
350
|
+
if (indentOf(line.text) > indent)
|
|
351
|
+
continue; // a wrapped line or a nested block above: look higher
|
|
352
|
+
if (QUESTION_RE.test(line.text) || ANSWER_RE.test(line.text))
|
|
353
|
+
continue; // another question at this level
|
|
354
|
+
candidate = i;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
if (candidate < 0)
|
|
358
|
+
return null;
|
|
359
|
+
// Extend to the outermost list item that holds it, or to the start of the paragraph.
|
|
360
|
+
let start = candidate;
|
|
361
|
+
if (isListItem(lines[start].text)) {
|
|
362
|
+
while (start > 0) {
|
|
363
|
+
const previous = lines[start - 1];
|
|
364
|
+
if (isBlank(previous.text) || HEADING_RE.test(previous.text) || isFence(previous.text))
|
|
365
|
+
break;
|
|
366
|
+
if (!isListItem(previous.text))
|
|
367
|
+
break; // a paragraph above a list is another block
|
|
368
|
+
if (indentOf(previous.text) >= indentOf(lines[start].text))
|
|
369
|
+
break; // a sibling item
|
|
370
|
+
start--;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
while (start > 0) {
|
|
375
|
+
const previous = lines[start - 1];
|
|
376
|
+
if (isBlank(previous.text) || HEADING_RE.test(previous.text) || isFence(previous.text))
|
|
377
|
+
break;
|
|
378
|
+
if (QUESTION_RE.test(previous.text) || ANSWER_RE.test(previous.text))
|
|
379
|
+
break;
|
|
380
|
+
if (isListItem(previous.text)) {
|
|
381
|
+
if (indentOf(previous.text) < indentOf(lines[start].text))
|
|
382
|
+
start--;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
start--;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Wrapped lines between the start and the question belong to the same item/paragraph; a nested bullet does not.
|
|
389
|
+
const parts = [withoutMarker(lines[start].text).trim()];
|
|
390
|
+
for (let i = start + 1; i < index; i++) {
|
|
391
|
+
if (isListItem(lines[i].text))
|
|
392
|
+
continue;
|
|
393
|
+
if (QUESTION_RE.test(lines[i].text) || ANSWER_RE.test(lines[i].text))
|
|
394
|
+
continue;
|
|
395
|
+
parts.push(lines[i].text.trim());
|
|
396
|
+
}
|
|
397
|
+
const text = parts.filter((part) => part !== '').join(' ').trim();
|
|
398
|
+
return text === '' ? null : { text, line: lines[start].number, from: lines[start].start };
|
|
399
|
+
}
|
|
400
|
+
function parseQuestions(text, sections) {
|
|
401
|
+
const lines = splitLines(text);
|
|
402
|
+
const questions = [];
|
|
403
|
+
let fenced = false;
|
|
404
|
+
for (let index = 0; index < lines.length; index++) {
|
|
405
|
+
const line = lines[index];
|
|
406
|
+
if (isFence(line.text)) {
|
|
407
|
+
fenced = !fenced;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (fenced)
|
|
411
|
+
continue;
|
|
412
|
+
const match = QUESTION_RE.exec(line.text);
|
|
413
|
+
if (!match)
|
|
414
|
+
continue;
|
|
415
|
+
const indent = indentOf(line.text);
|
|
416
|
+
const kind = match[3] === 'non-blocking' || (match[3] === undefined && match[2] === '\u2753') ? 'non-blocking' : 'blocking';
|
|
417
|
+
// The block runs through the following lines that are indented deeper: the answer line and any wrapped lines.
|
|
418
|
+
let last = index;
|
|
419
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
420
|
+
if (isBlank(lines[next].text) || indentOf(lines[next].text) <= indent)
|
|
421
|
+
break;
|
|
422
|
+
last = next;
|
|
423
|
+
}
|
|
424
|
+
// The question is the marker line plus its plain continuation lines, up to the answer line or a nested bullet.
|
|
425
|
+
const parts = [match[4].trim()];
|
|
426
|
+
let answer = null;
|
|
427
|
+
let answerRange = null;
|
|
428
|
+
let answerLine = null;
|
|
429
|
+
for (let next = index + 1; next <= last; next++) {
|
|
430
|
+
const nextLine = lines[next];
|
|
431
|
+
const answerMatch = ANSWER_RE.exec(nextLine.text);
|
|
432
|
+
if (answerMatch) {
|
|
433
|
+
answer = answerMatch[2].trim();
|
|
434
|
+
answerRange = { start: nextLine.start + nextLine.text.length - answerMatch[2].length, end: nextLine.end };
|
|
435
|
+
answerLine = nextLine.number;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
if (isListItem(nextLine.text))
|
|
439
|
+
break;
|
|
440
|
+
parts.push(nextLine.text.trim());
|
|
441
|
+
}
|
|
442
|
+
const raw = parts.filter((part) => part !== '').join(' ').trim();
|
|
443
|
+
const evidenceMatch = EVIDENCE_RE.exec(raw);
|
|
444
|
+
const question = (evidenceMatch ? evidenceMatch[1] : raw).trim();
|
|
445
|
+
const evidence = evidenceMatch ? evidenceMatch[2].trim() : null;
|
|
446
|
+
const parent = parentOf(lines, index, indent);
|
|
447
|
+
questions.push({
|
|
448
|
+
kind,
|
|
449
|
+
question,
|
|
450
|
+
evidence,
|
|
451
|
+
parent,
|
|
452
|
+
section: sectionAt(sections, line.start),
|
|
453
|
+
line: line.number,
|
|
454
|
+
indent,
|
|
455
|
+
answer: answer ?? '',
|
|
456
|
+
answerRange: answerRange ?? { start: lines[last].end, end: lines[last].end },
|
|
457
|
+
hasAnswerLine: answerLine !== null,
|
|
458
|
+
answerLine,
|
|
459
|
+
lastLine: lines[last].number,
|
|
460
|
+
blockRange: { start: line.start, end: lines[last].end },
|
|
461
|
+
region: { start: parent?.from ?? line.start, end: lines[last].end },
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return questions;
|
|
465
|
+
}
|
|
466
|
+
/* Kept in step with `ui/web/src/lib/highlight-keywords.ts` (same words, same emoji prefixes). */
|
|
467
|
+
const MARKER_RE = /(?:\u{1F50D}\uFE0F?[ \t]*)?(?:\bto be confirmed(?:\s+by\s+QA)?\b|\bTBC\b|\bTBD\b|\bneeds?\s+confirmation\b)|(?:\u{1F441}\uFE0F?[ \t]*)?\bnot observed\b/giu;
|
|
468
|
+
function parseMarkers(text, sections) {
|
|
469
|
+
const markers = [];
|
|
470
|
+
let fenced = false;
|
|
471
|
+
for (const line of splitLines(text)) {
|
|
472
|
+
if (isFence(line.text)) {
|
|
473
|
+
fenced = !fenced;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (fenced)
|
|
477
|
+
continue;
|
|
478
|
+
for (const match of line.text.matchAll(MARKER_RE)) {
|
|
479
|
+
if (match[0] === '')
|
|
480
|
+
continue;
|
|
481
|
+
markers.push({
|
|
482
|
+
kind: /not observed/i.test(match[0]) ? 'unseen' : 'confirm',
|
|
483
|
+
text: match[0],
|
|
484
|
+
range: { start: line.start + (match.index ?? 0), end: line.start + (match.index ?? 0) + match[0].length },
|
|
485
|
+
sentence: line.text.trim(),
|
|
486
|
+
section: sectionAt(sections, line.start),
|
|
487
|
+
line: line.number,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return markers;
|
|
492
|
+
}
|
|
493
|
+
const VALUES_OPEN_RE = /^[ \t]*```ya?ml[ \t]*$/i;
|
|
494
|
+
const VALUES_CLOSE_RE = /^[ \t]*```[ \t]*$/;
|
|
495
|
+
const isPlaceholderValue = (value) => value === null || value === undefined || value === '' || (typeof value === 'string' && /^\s*TODO\b/i.test(value));
|
|
496
|
+
function placeholdersOf(values) {
|
|
497
|
+
const found = [];
|
|
498
|
+
for (const [profile, entries] of Object.entries(values)) {
|
|
499
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
500
|
+
if (isPlaceholderValue(value))
|
|
501
|
+
found.push(`${profile}.${key}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return found;
|
|
505
|
+
}
|
|
506
|
+
function parseValues(text, section) {
|
|
507
|
+
if (!section)
|
|
508
|
+
return null;
|
|
509
|
+
const noBlock = (problem) => ({
|
|
510
|
+
hasBlock: false,
|
|
511
|
+
range: { start: section.body.start, end: section.body.start },
|
|
512
|
+
values: {},
|
|
513
|
+
problem,
|
|
514
|
+
placeholders: [],
|
|
515
|
+
});
|
|
516
|
+
const lines = sectionLines(text, section);
|
|
517
|
+
const open = lines.findIndex(({ line }) => VALUES_OPEN_RE.test(line.text));
|
|
518
|
+
if (open < 0)
|
|
519
|
+
return noBlock('"## Values" must hold one fenced yaml block (```yaml ... ```)');
|
|
520
|
+
const close = lines.findIndex(({ line }, index) => index > open && VALUES_CLOSE_RE.test(line.text));
|
|
521
|
+
if (close < 0)
|
|
522
|
+
return noBlock('the yaml block of "## Values" is not closed with ```');
|
|
523
|
+
const range = { start: afterLine(text, lines[open].line), end: lines[close].line.start };
|
|
524
|
+
const raw = text.slice(range.start, range.end);
|
|
525
|
+
let parsed;
|
|
526
|
+
try {
|
|
527
|
+
parsed = parseYaml(raw);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
return { ...noBlock(`the yaml in "## Values" is not valid: ${error.message}`), range };
|
|
531
|
+
}
|
|
532
|
+
if (parsed === null || parsed === undefined) {
|
|
533
|
+
return { hasBlock: true, range, values: {}, problem: null, placeholders: [] };
|
|
534
|
+
}
|
|
535
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
536
|
+
return { ...noBlock('"## Values" must be a map of profile id (or "default") to a map of values'), range };
|
|
537
|
+
}
|
|
538
|
+
const values = {};
|
|
539
|
+
for (const [profile, entries] of Object.entries(parsed)) {
|
|
540
|
+
if (entries === null || typeof entries !== 'object' || Array.isArray(entries)) {
|
|
541
|
+
return { ...noBlock(`"## Values" entry "${profile}" must be a map of keys to values`), range };
|
|
542
|
+
}
|
|
543
|
+
values[profile] = entries;
|
|
544
|
+
}
|
|
545
|
+
return { hasBlock: true, range, values, problem: null, placeholders: placeholdersOf(values) };
|
|
546
|
+
}
|
|
547
|
+
const INPUT_RE = /^[-*+][ \t]+step[ \t]+(\d+)\b([^:]*):(.*)$/i;
|
|
548
|
+
/**
|
|
549
|
+
* The lines of the old `## Inputs` section (kept for cases written before the recipes moved under their steps): one
|
|
550
|
+
* top-level bullet per recipe (`- Step 3 (start): ...`, several per step allowed) or per free note, with wrapped lines
|
|
551
|
+
* joined. Nested bullets are the TODO blocks that hang under a recipe and are not read here.
|
|
552
|
+
*/
|
|
553
|
+
function parseInputs(text, section) {
|
|
554
|
+
if (!section)
|
|
555
|
+
return [];
|
|
556
|
+
const lines = sectionLines(text, section);
|
|
557
|
+
const inputs = [];
|
|
558
|
+
let fenced = false;
|
|
559
|
+
for (let index = 0; index < lines.length; index++) {
|
|
560
|
+
const { line } = lines[index];
|
|
561
|
+
if (isFence(line.text)) {
|
|
562
|
+
fenced = !fenced;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (fenced)
|
|
566
|
+
continue;
|
|
567
|
+
if (indentOf(line.text) > 0 || !isListItem(line.text))
|
|
568
|
+
continue;
|
|
569
|
+
if (QUESTION_RE.test(line.text) || ANSWER_RE.test(line.text))
|
|
570
|
+
continue;
|
|
571
|
+
let last = index;
|
|
572
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
573
|
+
const nextLine = lines[next].line;
|
|
574
|
+
if (isBlank(nextLine.text) || indentOf(nextLine.text) === 0)
|
|
575
|
+
break;
|
|
576
|
+
if (isListItem(nextLine.text) || QUESTION_RE.test(nextLine.text) || ANSWER_RE.test(nextLine.text))
|
|
577
|
+
break;
|
|
578
|
+
last = next;
|
|
579
|
+
}
|
|
580
|
+
const continuation = lines
|
|
581
|
+
.slice(index + 1, last + 1)
|
|
582
|
+
.map(({ line: nextLine }) => nextLine.text.trim())
|
|
583
|
+
.filter((part) => part !== '');
|
|
584
|
+
const match = INPUT_RE.exec(line.text);
|
|
585
|
+
if (match) {
|
|
586
|
+
const raw = match[3];
|
|
587
|
+
const label = match[2].trim().replace(/^\((.*)\)$/, '$1').trim();
|
|
588
|
+
inputs.push({
|
|
589
|
+
step: Number(match[1]),
|
|
590
|
+
what: label === '' ? null : label,
|
|
591
|
+
recipe: [raw.trim(), ...continuation].filter((part) => part !== '').join(' '),
|
|
592
|
+
line: line.number,
|
|
593
|
+
range: { start: line.start, end: lines[last].line.end },
|
|
594
|
+
recipeRange: {
|
|
595
|
+
start: line.start + line.text.length - raw.length + (raw.length - raw.trimStart().length),
|
|
596
|
+
end: lines[last].line.end,
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
const withoutListMarker = withoutMarker(line.text);
|
|
602
|
+
inputs.push({
|
|
603
|
+
step: null,
|
|
604
|
+
what: null,
|
|
605
|
+
recipe: [withoutListMarker.trim(), ...continuation].filter((part) => part !== '').join(' '),
|
|
606
|
+
line: line.number,
|
|
607
|
+
range: { start: line.start, end: lines[last].line.end },
|
|
608
|
+
recipeRange: { start: line.start + (line.text.length - withoutListMarker.length), end: lines[last].line.end },
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
index = last;
|
|
612
|
+
}
|
|
613
|
+
return inputs;
|
|
614
|
+
}
|
|
615
|
+
/** A prose section (`## Goal`, `## Notes`): the panel renders it read-only and reveals a line on click. */
|
|
616
|
+
export function parseProse(text, name) {
|
|
617
|
+
const section = sectionsOf(text, splitLines(text)).find((candidate) => candidate.name === name);
|
|
618
|
+
if (!section)
|
|
619
|
+
return null;
|
|
620
|
+
const body = text.slice(section.body.start, section.body.end);
|
|
621
|
+
const before = text.slice(0, section.body.start);
|
|
622
|
+
return {
|
|
623
|
+
name,
|
|
624
|
+
body,
|
|
625
|
+
range: section.body,
|
|
626
|
+
line: (before.match(/\n/g)?.length ?? 0) + 1,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/** The top-level bullets of a list section, with the blocks (a wrapped text, a TODO block) that belong to each. */
|
|
630
|
+
export function parseBullets(text, name) {
|
|
631
|
+
const section = sectionsOf(text, splitLines(text)).find((candidate) => candidate.name === name);
|
|
632
|
+
if (!section)
|
|
633
|
+
return [];
|
|
634
|
+
const lines = sectionLines(text, section);
|
|
635
|
+
const bullets = [];
|
|
636
|
+
for (let index = 0; index < lines.length; index++) {
|
|
637
|
+
const { line, fenced } = lines[index];
|
|
638
|
+
if (fenced)
|
|
639
|
+
continue;
|
|
640
|
+
if (indentOf(line.text) > 0 || !isListItem(line.text))
|
|
641
|
+
continue;
|
|
642
|
+
if (QUESTION_RE.test(line.text) || ANSWER_RE.test(line.text))
|
|
643
|
+
continue;
|
|
644
|
+
// The block runs to the next top-level line; trailing blank lines are not part of it.
|
|
645
|
+
let last = index;
|
|
646
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
647
|
+
const nextLine = lines[next].line;
|
|
648
|
+
if (!isBlank(nextLine.text) && indentOf(nextLine.text) === 0)
|
|
649
|
+
break;
|
|
650
|
+
last = next;
|
|
651
|
+
}
|
|
652
|
+
while (last > index && isBlank(lines[last].line.text))
|
|
653
|
+
last--;
|
|
654
|
+
// The bullet's own text stops at its first nested bullet (a TODO block) or blank line.
|
|
655
|
+
let textLast = index;
|
|
656
|
+
for (let next = index + 1; next <= last; next++) {
|
|
657
|
+
const nextLine = lines[next].line;
|
|
658
|
+
if (isBlank(nextLine.text) || isListItem(nextLine.text))
|
|
659
|
+
break;
|
|
660
|
+
if (QUESTION_RE.test(nextLine.text) || ANSWER_RE.test(nextLine.text))
|
|
661
|
+
break;
|
|
662
|
+
textLast = next;
|
|
663
|
+
}
|
|
664
|
+
const marker = line.text.length - withoutMarker(line.text).length;
|
|
665
|
+
const parts = [withoutMarker(line.text).trim()];
|
|
666
|
+
for (let next = index + 1; next <= textLast; next++)
|
|
667
|
+
parts.push(lines[next].line.text.trim());
|
|
668
|
+
bullets.push({
|
|
669
|
+
text: parts.filter((part) => part !== '').join(' '),
|
|
670
|
+
range: { start: line.start, end: lines[last].line.end },
|
|
671
|
+
textRange: { start: line.start + marker, end: lines[textLast].line.end },
|
|
672
|
+
line: line.number,
|
|
673
|
+
questionLines: lines
|
|
674
|
+
.slice(index, last + 1)
|
|
675
|
+
.filter(({ line: candidate }) => QUESTION_RE.test(candidate.text))
|
|
676
|
+
.map(({ line: candidate }) => candidate.number),
|
|
677
|
+
});
|
|
678
|
+
index = last;
|
|
679
|
+
}
|
|
680
|
+
return bullets;
|
|
681
|
+
}
|
|
682
|
+
/** The `### T<n>` cards of `## Targets`. Pure, so the web panel can show a card name next to a visual check. */
|
|
683
|
+
export function parseTargetCards(text) {
|
|
684
|
+
const section = caseSectionMatch(text, 'Targets');
|
|
685
|
+
if (!section)
|
|
686
|
+
return [];
|
|
687
|
+
const lines = splitLines(section.body);
|
|
688
|
+
const cards = [];
|
|
689
|
+
for (let index = 0; index < lines.length; index++) {
|
|
690
|
+
const heading = /^###\s+(T\d+)\b[\s:.-]*(.*)$/.exec(lines[index].text);
|
|
691
|
+
if (!heading)
|
|
692
|
+
continue;
|
|
693
|
+
let last = index;
|
|
694
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
695
|
+
if (/^###\s/.test(lines[next].text))
|
|
696
|
+
break;
|
|
697
|
+
last = next;
|
|
698
|
+
}
|
|
699
|
+
const card = {
|
|
700
|
+
id: heading[1],
|
|
701
|
+
name: heading[2].trim(),
|
|
702
|
+
fields: {},
|
|
703
|
+
references: {},
|
|
704
|
+
blocking: false,
|
|
705
|
+
line: lineNumberAt(text, section.bodyStart + lines[index].start),
|
|
706
|
+
range: { start: section.bodyStart + lines[index].start, end: section.bodyStart + lines[last].end },
|
|
707
|
+
};
|
|
708
|
+
for (let next = index + 1; next <= last; next++) {
|
|
709
|
+
const line = lines[next].text;
|
|
710
|
+
if (/^\s*-\s*\u{1F6AB} TODO \[blocking\]/u.test(line))
|
|
711
|
+
card.blocking = true;
|
|
712
|
+
const reference = /^-\s+Reference\s+([^:]+):\s*(.*)$/.exec(line);
|
|
713
|
+
if (reference) {
|
|
714
|
+
card.references[reference[1].trim()] = reference[2].trim();
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const field = /^-\s+([A-Za-z][A-Za-z ]*?):\s*(.*)$/.exec(line);
|
|
718
|
+
if (field)
|
|
719
|
+
card.fields[field[1].trim().toLowerCase()] = field[2].trim();
|
|
720
|
+
}
|
|
721
|
+
cards.push(card);
|
|
722
|
+
}
|
|
723
|
+
return cards;
|
|
724
|
+
}
|
|
725
|
+
/* ------------------------------------------------------------- front matter */
|
|
726
|
+
const META_ORDER = ['id', 'title', 'login', 'suites', 'writes', 'writes-note', 'envs'];
|
|
727
|
+
function nameList(value, key, label) {
|
|
728
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !NAME_PATTERN.test(item))) {
|
|
729
|
+
throw new Error(`${label}: "${key}" must be a list of lowercase words (letters, digits, dashes), e.g. "${key}: [smoke, regression]"`);
|
|
730
|
+
}
|
|
731
|
+
const duplicate = value.find((item, i) => value.indexOf(item) !== i);
|
|
732
|
+
if (duplicate)
|
|
733
|
+
throw new Error(`${label}: "${key}" lists "${duplicate}" twice`);
|
|
734
|
+
return value;
|
|
735
|
+
}
|
|
736
|
+
function parseWrites(value, label, knownEnvs) {
|
|
737
|
+
if (value === undefined || value === null || value === 'none')
|
|
738
|
+
return [];
|
|
739
|
+
if (value === 'test-data') {
|
|
740
|
+
throw new Error(`${label}: "writes: test-data" is the old form. List the environments instead, e.g. "writes: [staging]"`);
|
|
741
|
+
}
|
|
742
|
+
if (Array.isArray(value) && value.length === 0)
|
|
743
|
+
return [];
|
|
744
|
+
if (!Array.isArray(value)) {
|
|
745
|
+
throw new Error(`${label}: "writes" must be a list of environments, e.g. "writes: [staging]" (or omit it for read-only)`);
|
|
746
|
+
}
|
|
747
|
+
const writes = nameList(value, 'writes', label);
|
|
748
|
+
const unknown = writes.filter((env) => !knownEnvs.includes(env));
|
|
749
|
+
if (unknown.length > 0) {
|
|
750
|
+
throw new Error(`${label}: unknown environment(s) in "writes": ${unknown.join(', ')}. Known: ${knownEnvs.join(', ')}`);
|
|
751
|
+
}
|
|
752
|
+
return writes;
|
|
753
|
+
}
|
|
754
|
+
/** Parses and validates the front matter. Throws an Error that starts with `label` and says what to fix. */
|
|
755
|
+
export function validateCaseMeta(id, data, knownEnvs, label = 'case document') {
|
|
756
|
+
if (data.suites !== undefined && data.priority !== undefined) {
|
|
757
|
+
throw new Error(`${label}: use "suites: [...]" only; "priority" is the old name of the same setting`);
|
|
758
|
+
}
|
|
759
|
+
const suites = data.suites !== undefined
|
|
760
|
+
? nameList(data.suites, 'suites', label)
|
|
761
|
+
: data.priority !== undefined
|
|
762
|
+
? nameList([data.priority], 'priority', label)
|
|
763
|
+
: (() => {
|
|
764
|
+
throw new Error(`${label}: front matter needs "suites: [<suite>, ...]", e.g. "suites: [smoke, regression]"`);
|
|
765
|
+
})();
|
|
766
|
+
const note = data['writes-note'];
|
|
767
|
+
const writes = parseWrites(data.writes, label, knownEnvs);
|
|
768
|
+
if (writes.length > 0 && (typeof note !== 'string' || note.trim() === '')) {
|
|
769
|
+
throw new Error(`${label}: "writes: [${writes.join(', ')}]" needs a "writes-note" that says what the case creates or changes`);
|
|
770
|
+
}
|
|
771
|
+
let envs = null;
|
|
772
|
+
if (data.envs !== undefined) {
|
|
773
|
+
envs = nameList(data.envs, 'envs', label);
|
|
774
|
+
const unknown = envs.filter((env) => !knownEnvs.includes(env));
|
|
775
|
+
if (unknown.length > 0) {
|
|
776
|
+
throw new Error(`${label}: unknown environment(s) in "envs": ${unknown.join(', ')}. Known: ${knownEnvs.join(', ')}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const unreachable = writes.filter((env) => envs !== null && !envs.includes(env));
|
|
780
|
+
if (unreachable.length > 0) {
|
|
781
|
+
throw new Error(`${label}: "writes" lists ${unreachable.join(', ')}, but "envs" does not run the case there`);
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
id,
|
|
785
|
+
suites,
|
|
786
|
+
writes,
|
|
787
|
+
writesNote: typeof note === 'string' && note.trim() !== '' ? note.trim() : null,
|
|
788
|
+
envs,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** The front matter problems a form can show without throwing; empty when it is valid. */
|
|
792
|
+
export function caseMetaProblems(id, data, knownEnvs, label = 'case document') {
|
|
793
|
+
try {
|
|
794
|
+
validateCaseMeta(id, data, knownEnvs, label);
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
return [error.message];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
export function parseCaseDocument(text) {
|
|
802
|
+
const lines = splitLines(text);
|
|
803
|
+
const sections = sectionsOf(text, lines);
|
|
804
|
+
return {
|
|
805
|
+
text,
|
|
806
|
+
frontMatter: parseCaseFrontMatter(text),
|
|
807
|
+
sections,
|
|
808
|
+
steps: parseSteps(text, sections.find((section) => section.name === 'Steps')),
|
|
809
|
+
questions: parseQuestions(text, sections),
|
|
810
|
+
markers: parseMarkers(text, sections),
|
|
811
|
+
values: parseValues(text, sections.find((section) => section.name === 'Values')),
|
|
812
|
+
targets: parseTargetCards(text),
|
|
813
|
+
inputs: parseInputs(text, sections.find((section) => section.name === 'Inputs')),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/* -------------------------------------------------------------------- edits */
|
|
817
|
+
/** Applies replacements that do not overlap. Positions refer to `text`, the text they were parsed from. */
|
|
818
|
+
export function patchText(text, edits) {
|
|
819
|
+
const ordered = [...edits].sort((a, b) => b.start - a.start);
|
|
820
|
+
let result = text;
|
|
821
|
+
let limit = text.length;
|
|
822
|
+
for (const edit of ordered) {
|
|
823
|
+
if (edit.start < 0 || edit.end < edit.start || edit.end > limit) {
|
|
824
|
+
throw new Error('The case changed since it was read: reload it and try again.');
|
|
825
|
+
}
|
|
826
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
827
|
+
limit = edit.start;
|
|
828
|
+
}
|
|
829
|
+
return result;
|
|
830
|
+
}
|
|
831
|
+
/** Adds or removes one `[writes]` / `[input]` / `[email]` tag at the end of a step. */
|
|
832
|
+
export function setStepTag(text, stepNumber, tag, on) {
|
|
833
|
+
const step = parseCaseDocument(text).steps.find((candidate) => candidate.number === stepNumber);
|
|
834
|
+
if (!step)
|
|
835
|
+
throw new Error(`The case has no step ${stepNumber}.`);
|
|
836
|
+
if (step.tags.includes(tag) === on)
|
|
837
|
+
return text;
|
|
838
|
+
if (on) {
|
|
839
|
+
const before = step.insertAt > step.range.start ? ' ' : '';
|
|
840
|
+
return patchText(text, [{ start: step.insertAt, end: step.insertAt, text: `${before}[${tag}]` }]);
|
|
841
|
+
}
|
|
842
|
+
const lineText = text.slice(step.range.start, step.range.end);
|
|
843
|
+
const at = lineText.lastIndexOf(`[${tag}]`);
|
|
844
|
+
if (at < 0)
|
|
845
|
+
return text;
|
|
846
|
+
const tagStart = step.range.start + at;
|
|
847
|
+
const separator = text[tagStart - 1] === ' ' || text[tagStart - 1] === '\t' ? 1 : 0;
|
|
848
|
+
return patchText(text, [{ start: tagStart - separator, end: tagStart + tag.length + 2, text: '' }]);
|
|
849
|
+
}
|
|
850
|
+
const singleLine = (value) => value.replace(/[\r\n]+/g, ' ').trim();
|
|
851
|
+
/** The line break the document already uses, so an inserted line never mixes LF into a CRLF file. */
|
|
852
|
+
const newlineOf = (text) => (text.includes('\r\n') ? '\r\n' : '\n');
|
|
853
|
+
/** Writes an answer after `Answer:`; an empty answer clears the slot and keeps the line. */
|
|
854
|
+
export function setQuestionAnswer(text, question, answer) {
|
|
855
|
+
const value = singleLine(answer);
|
|
856
|
+
if (question.hasAnswerLine) {
|
|
857
|
+
if (question.answer === value)
|
|
858
|
+
return text;
|
|
859
|
+
return patchText(text, [
|
|
860
|
+
{ start: question.answerRange.start, end: question.answerRange.end, text: value === '' ? '' : ` ${value}` },
|
|
861
|
+
]);
|
|
862
|
+
}
|
|
863
|
+
if (value === '')
|
|
864
|
+
return text;
|
|
865
|
+
const indent = ' '.repeat(question.indent + 2);
|
|
866
|
+
const newline = newlineOf(text);
|
|
867
|
+
return patchText(text, [
|
|
868
|
+
{
|
|
869
|
+
start: question.blockRange.end,
|
|
870
|
+
end: question.blockRange.end,
|
|
871
|
+
text: `${newline}${indent}- \u270D\uFE0F Answer: ${value}`,
|
|
872
|
+
},
|
|
873
|
+
]);
|
|
874
|
+
}
|
|
875
|
+
/** Accepts the suggestion: deletes the whole question block, the answer line included. */
|
|
876
|
+
export function acceptQuestion(text, question) {
|
|
877
|
+
return patchText(text, [{ start: question.blockRange.start, end: endWithBreak(text, question.blockRange.end), text: '' }]);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Removes a `🔍 to be confirmed by QA` / `👁️ not observed` marker. When the marker is the only content of its
|
|
881
|
+
* parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
|
|
882
|
+
* `(observed, 🔍 to be confirmed by QA)` as `(observed)`.
|
|
883
|
+
*/
|
|
884
|
+
export function removeMarker(text, marker) {
|
|
885
|
+
const open = text.lastIndexOf('(', marker.range.start);
|
|
886
|
+
const close = text.indexOf(')', marker.range.end);
|
|
887
|
+
if (open >= 0 && close >= 0 && !/[\r\n]/.test(text.slice(open, close))) {
|
|
888
|
+
const rest = text.slice(open + 1, marker.range.start) + text.slice(marker.range.end, close);
|
|
889
|
+
if (/^[\s,;:—-]*$/.test(rest)) {
|
|
890
|
+
const before = text[open - 1] === ' ' && /[.,;:!?]/.test(text[close + 1] ?? '') ? open - 1 : open;
|
|
891
|
+
return patchText(text, [{ start: before, end: close + 1, text: '' }]);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const lead = /(?:[ \t]*[,;][ \t]*|[ \t]+)$/.exec(text.slice(0, marker.range.start));
|
|
895
|
+
if (lead) {
|
|
896
|
+
return patchText(text, [{ start: marker.range.start - lead[0].length, end: marker.range.end, text: '' }]);
|
|
897
|
+
}
|
|
898
|
+
const trail = /^[ \t]+/.exec(text.slice(marker.range.end));
|
|
899
|
+
const end = trail ? marker.range.end + trail[0].length : marker.range.end;
|
|
900
|
+
return patchText(text, [{ start: marker.range.start, end, text: '' }]);
|
|
901
|
+
}
|
|
902
|
+
function replaceValues(text, block, values) {
|
|
903
|
+
const yaml = stringifyYaml(values, { lineWidth: 0 }).replace(/\r?\n/g, newlineOf(text));
|
|
904
|
+
const suffix = yaml.endsWith('\n') ? '' : newlineOf(text);
|
|
905
|
+
return patchText(text, [{ start: block.range.start, end: block.range.end, text: `${yaml}${suffix}` }]);
|
|
906
|
+
}
|
|
907
|
+
const editValues = (text, change) => {
|
|
908
|
+
const block = parseCaseDocument(text).values;
|
|
909
|
+
if (!block?.hasBlock)
|
|
910
|
+
throw new Error('The case has no "## Values" yaml block to edit.');
|
|
911
|
+
if (block.problem)
|
|
912
|
+
throw new Error(block.problem);
|
|
913
|
+
const values = Object.fromEntries(Object.entries(block.values).map(([profile, entries]) => [profile, { ...entries }]));
|
|
914
|
+
change(values);
|
|
915
|
+
return replaceValues(text, block, values);
|
|
916
|
+
};
|
|
917
|
+
/** Sets one value of one profile; `null` removes it (an empty cell in the panel means "not set here"). */
|
|
918
|
+
export function setCaseValue(text, profile, key, value) {
|
|
919
|
+
const block = parseCaseDocument(text).values;
|
|
920
|
+
const current = block?.values[profile]?.[key];
|
|
921
|
+
const unchanged = value === null ? current === undefined : current === value;
|
|
922
|
+
if (unchanged)
|
|
923
|
+
return text;
|
|
924
|
+
return editValues(text, (values) => {
|
|
925
|
+
if (value === null) {
|
|
926
|
+
const entries = values[profile];
|
|
927
|
+
if (!entries)
|
|
928
|
+
return;
|
|
929
|
+
delete entries[key];
|
|
930
|
+
if (Object.keys(entries).length === 0)
|
|
931
|
+
delete values[profile];
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
(values[profile] ??= {})[key] = value;
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
/** Adds a key to the `default` values of the case. */
|
|
938
|
+
export function addCaseValueKey(text, key, value) {
|
|
939
|
+
return editValues(text, (values) => {
|
|
940
|
+
(values.default ??= {})[key] = value;
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
/** Writes the text of one `## Inputs` line (a recipe or a note); an empty text deletes the line. */
|
|
944
|
+
export function setInputText(text, input, value) {
|
|
945
|
+
const clean = singleLine(value);
|
|
946
|
+
if (clean === input.recipe)
|
|
947
|
+
return text;
|
|
948
|
+
if (clean === '')
|
|
949
|
+
return removeInputLine(text, input);
|
|
950
|
+
return patchText(text, [{ start: input.recipeRange.start, end: input.recipeRange.end, text: clean }]);
|
|
951
|
+
}
|
|
952
|
+
/** Deletes one `## Inputs` bullet (a recipe line or a note), its wrapped lines included. */
|
|
953
|
+
export function removeInputLine(text, input) {
|
|
954
|
+
return patchText(text, [{ start: input.range.start, end: endWithBreak(text, input.range.end), text: '' }]);
|
|
955
|
+
}
|
|
956
|
+
/** Writes the text of one recipe line under a step; an empty text deletes the line. */
|
|
957
|
+
export function setRecipeText(text, recipe, value) {
|
|
958
|
+
const clean = singleLine(value);
|
|
959
|
+
if (clean === recipe.text)
|
|
960
|
+
return text;
|
|
961
|
+
if (clean === '')
|
|
962
|
+
return removeRecipe(text, recipe);
|
|
963
|
+
return patchText(text, [{ start: recipe.textRange.start, end: recipe.textRange.end, text: clean }]);
|
|
964
|
+
}
|
|
965
|
+
/** Deletes one recipe line under a step, its wrapped lines included. */
|
|
966
|
+
export function removeRecipe(text, recipe) {
|
|
967
|
+
return patchText(text, [{ start: recipe.range.start, end: endWithBreak(text, recipe.range.end), text: '' }]);
|
|
968
|
+
}
|
|
969
|
+
const cleanLabel = (value) => (value ?? '')
|
|
970
|
+
.replace(/[\r\n()]+/g, ' ')
|
|
971
|
+
.trim();
|
|
972
|
+
/** Writes the text of one `- Ready when:` line under a step; an empty text deletes the line. */
|
|
973
|
+
export function setStepReady(text, signal, value) {
|
|
974
|
+
const clean = singleLine(value);
|
|
975
|
+
if (clean === signal.text)
|
|
976
|
+
return text;
|
|
977
|
+
if (clean === '')
|
|
978
|
+
return removeStepReady(text, signal);
|
|
979
|
+
return patchText(text, [{ start: signal.textRange.start, end: signal.textRange.end, text: clean }]);
|
|
980
|
+
}
|
|
981
|
+
/** Deletes one `- Ready when:` line under a step, its wrapped lines included. */
|
|
982
|
+
export function removeStepReady(text, signal) {
|
|
983
|
+
return patchText(text, [{ start: signal.range.start, end: endWithBreak(text, signal.range.end), text: '' }]);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Adds a `- Ready when: ...` line under a step, right under the step sentence and above its recipes: readiness is read
|
|
987
|
+
* first, then what a person did. A second line goes under the first.
|
|
988
|
+
*/
|
|
989
|
+
export function addStepReady(text, stepNumber, value) {
|
|
990
|
+
const signal = singleLine(value);
|
|
991
|
+
if (signal === '')
|
|
992
|
+
return text;
|
|
993
|
+
const step = parseCaseDocument(text).steps.find((candidate) => candidate.number === stepNumber);
|
|
994
|
+
if (!step)
|
|
995
|
+
throw new Error(`The case has no step ${stepNumber}.`);
|
|
996
|
+
const bullet = ` - Ready when: ${signal}`;
|
|
997
|
+
const newline = newlineOf(text);
|
|
998
|
+
const anchor = step.readiness.at(-1);
|
|
999
|
+
const at = endWithBreak(text, anchor ? anchor.range.end : step.range.end);
|
|
1000
|
+
const prefix = at > 0 && text[at - 1] !== '\n' ? newline : '';
|
|
1001
|
+
return patchText(text, [{ start: at, end: at, text: `${prefix}${bullet}${newline}` }]);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Adds a `- Recipe (phase): ...` line under a step. It goes after the step's last recipe, or after its last readiness
|
|
1005
|
+
* line, or directly under the step sentence, so a step keeps its readiness and recipes together and no separate input
|
|
1006
|
+
* section is needed.
|
|
1007
|
+
*/
|
|
1008
|
+
export function addStepRecipe(text, stepNumber, what, value) {
|
|
1009
|
+
const recipe = singleLine(value);
|
|
1010
|
+
if (recipe === '')
|
|
1011
|
+
return text;
|
|
1012
|
+
const step = parseCaseDocument(text).steps.find((candidate) => candidate.number === stepNumber);
|
|
1013
|
+
if (!step)
|
|
1014
|
+
throw new Error(`The case has no step ${stepNumber}.`);
|
|
1015
|
+
const label = cleanLabel(what);
|
|
1016
|
+
const bullet = ` - Recipe${label === '' ? '' : ` (${label})`}: ${recipe}`;
|
|
1017
|
+
const newline = newlineOf(text);
|
|
1018
|
+
const anchor = step.recipes.at(-1) ?? step.readiness.at(-1);
|
|
1019
|
+
const at = endWithBreak(text, anchor ? anchor.range.end : step.range.end);
|
|
1020
|
+
const prefix = at > 0 && text[at - 1] !== '\n' ? newline : '';
|
|
1021
|
+
return patchText(text, [{ start: at, end: at, text: `${prefix}${bullet}${newline}` }]);
|
|
1022
|
+
}
|
|
1023
|
+
/* ------------------------------------------------ front matter, steps, bullets */
|
|
1024
|
+
const scalarFor = (value) => stringifyYaml(value, { lineWidth: 0 }).replace(/\r?\n$/, '');
|
|
1025
|
+
const flowList = (values) => `[${values.map((value) => scalarFor(value)).join(", ")}]`;
|
|
1026
|
+
/** Sets one front matter key (a scalar or a list); `null` or an empty list removes it. */
|
|
1027
|
+
export function setFrontMatterValue(text, key, value) {
|
|
1028
|
+
const front = parseCaseFrontMatter(text);
|
|
1029
|
+
if (!front)
|
|
1030
|
+
throw new Error("The case has no front matter block.");
|
|
1031
|
+
if (front.problem)
|
|
1032
|
+
throw new Error(front.problem);
|
|
1033
|
+
if (value === null || (Array.isArray(value) && value.length === 0))
|
|
1034
|
+
return removeFrontMatterKey(text, key);
|
|
1035
|
+
const raw = Array.isArray(value) ? flowList(value) : scalarFor(value);
|
|
1036
|
+
const entry = front.entries.find((candidate) => candidate.key === key);
|
|
1037
|
+
if (entry) {
|
|
1038
|
+
if (entry.block)
|
|
1039
|
+
return patchText(text, [{ start: entry.range.start, end: entry.range.end, text: `${key}: ${raw}` }]);
|
|
1040
|
+
return patchText(text, [{ start: entry.valueRange.start, end: entry.valueRange.end, text: raw }]);
|
|
1041
|
+
}
|
|
1042
|
+
const position = META_ORDER.indexOf(key);
|
|
1043
|
+
const before = front.entries
|
|
1044
|
+
.filter((candidate) => META_ORDER.includes(candidate.key) && META_ORDER.indexOf(candidate.key) < position)
|
|
1045
|
+
.at(-1);
|
|
1046
|
+
const at = before ? endWithBreak(text, before.range.end) : front.insertAt;
|
|
1047
|
+
const prefix = at > 0 && text[at - 1] !== "\n" ? newlineOf(text) : "";
|
|
1048
|
+
return patchText(text, [{ start: at, end: at, text: `${prefix}${key}: ${raw}${newlineOf(text)}` }]);
|
|
1049
|
+
}
|
|
1050
|
+
/** Removes one front matter key and its lines. */
|
|
1051
|
+
export function removeFrontMatterKey(text, key) {
|
|
1052
|
+
const front = parseCaseFrontMatter(text);
|
|
1053
|
+
const entry = front?.entries.find((candidate) => candidate.key === key);
|
|
1054
|
+
if (!entry)
|
|
1055
|
+
return text;
|
|
1056
|
+
return patchText(text, [{ start: entry.range.start, end: endWithBreak(text, entry.range.end), text: "" }]);
|
|
1057
|
+
}
|
|
1058
|
+
/** Sets the sentence of a step (a multi-line value keeps its wrapping, indented under the number). */
|
|
1059
|
+
export function setStepText(text, step, value) {
|
|
1060
|
+
const indent = " ".repeat(String(step.number).length + 2);
|
|
1061
|
+
const clean = value
|
|
1062
|
+
.replace(/\r\n/g, "\n")
|
|
1063
|
+
.split("\n")
|
|
1064
|
+
.map((line, index) => (index === 0 ? line.replace(/[ \t]+$/, "") : `${indent}${line.trim()}`))
|
|
1065
|
+
.join("\n");
|
|
1066
|
+
if (clean === text.slice(step.textRange.start, step.textRange.end))
|
|
1067
|
+
return text;
|
|
1068
|
+
return patchText(text, [{ start: step.textRange.start, end: step.textRange.end, text: clean }]);
|
|
1069
|
+
}
|
|
1070
|
+
/** Sets the text of one bullet (`## Expected`, `## Visual checks`); a nested TODO block is not touched. */
|
|
1071
|
+
export function setBulletText(text, bullet, value) {
|
|
1072
|
+
const clean = value
|
|
1073
|
+
.replace(/\r\n/g, "\n")
|
|
1074
|
+
.split("\n")
|
|
1075
|
+
.map((line, index) => (index === 0 ? line.replace(/[ \t]+$/, "") : ` ${line.trim()}`))
|
|
1076
|
+
.join("\n");
|
|
1077
|
+
if (clean === text.slice(bullet.textRange.start, bullet.textRange.end))
|
|
1078
|
+
return text;
|
|
1079
|
+
return patchText(text, [{ start: bullet.textRange.start, end: bullet.textRange.end, text: clean }]);
|
|
1080
|
+
}
|
|
1081
|
+
/** Appends a bullet to a list section. Returns the text unchanged when the section does not exist. */
|
|
1082
|
+
export function addBullet(text, name, value) {
|
|
1083
|
+
const content = value
|
|
1084
|
+
.replace(/\r\n/g, "\n")
|
|
1085
|
+
.split("\n")
|
|
1086
|
+
.map((line, index) => (index === 0 ? line.trim() : ` ${line.trim()}`))
|
|
1087
|
+
.join("\n");
|
|
1088
|
+
if (content === "")
|
|
1089
|
+
return text;
|
|
1090
|
+
const section = sectionsOf(text, splitLines(text)).find((candidate) => candidate.name === name);
|
|
1091
|
+
if (!section)
|
|
1092
|
+
return text;
|
|
1093
|
+
const lines = sectionLines(text, section).map(({ line }) => line);
|
|
1094
|
+
const last = [...lines].reverse().find((line) => !isBlank(line.text));
|
|
1095
|
+
const at = last ? afterLine(text, last) : section.body.start;
|
|
1096
|
+
const prefix = at > 0 && text[at - 1] !== "\n" ? newlineOf(text) : "";
|
|
1097
|
+
return patchText(text, [{ start: at, end: at, text: `${prefix}- ${content}${newlineOf(text)}` }]);
|
|
1098
|
+
}
|
|
1099
|
+
/** Deletes one bullet and everything under it (a nested question block included). */
|
|
1100
|
+
export function removeBullet(text, bullet) {
|
|
1101
|
+
return patchText(text, [{ start: bullet.range.start, end: endWithBreak(text, bullet.range.end), text: "" }]);
|
|
1102
|
+
}
|
|
1103
|
+
/** Moves one bullet block (and everything under it) to another position, with `arrayMove` semantics. */
|
|
1104
|
+
export function moveBullet(text, name, from, to) {
|
|
1105
|
+
const bullets = parseBullets(text, name);
|
|
1106
|
+
if (from === to || from < 0 || to < 0 || from >= bullets.length || to >= bullets.length)
|
|
1107
|
+
return text;
|
|
1108
|
+
const source = bullets[from];
|
|
1109
|
+
const target = bullets[to];
|
|
1110
|
+
const end = endWithBreak(text, source.range.end);
|
|
1111
|
+
const block = text.slice(source.range.start, end);
|
|
1112
|
+
const insertAt = from < to ? endWithBreak(text, target.range.end) : target.range.start;
|
|
1113
|
+
return patchText(text, [
|
|
1114
|
+
{ start: source.range.start, end, text: "" },
|
|
1115
|
+
{ start: insertAt, end: insertAt, text: block },
|
|
1116
|
+
]);
|
|
1117
|
+
}
|