();
+ for (i++; i < lines.Length && !IsFence(lines[i], out _); i++)
+ body.Add(lines[i]);
+
+ if (language is not ("cs" or "csharp" or "fs" or "fsharp"))
+ {
+ // A bare fence outside a sample's expectation is prose -- output of a shell
+ // command, a rendered form. Only `cs` blocks are code we can check.
+ pendingMode = null; pendingReason = ""; pendingContinues = false; pendingNoOutput = false;
+ continue;
+ }
+
+ var expected = pendingNoOutput ? null : FollowingOutputBlock(lines, i);
+ if (expected is null && !pendingNoOutput)
+ expected = OutputFromComments(body);
+
+ if (pendingContinues && snippets.Count > 0)
+ {
+ var previous = snippets[^1];
+ previous.Body.Add("");
+ previous.Body.AddRange(body);
+ // A continuation prints after everything before it, so its stated output
+ // extends the expectation rather than replacing it.
+ if (expected is not null)
+ previous.Expected = previous.Expected is null
+ ? expected
+ : previous.Expected + "\n" + expected;
+ if (pendingMode is not null) previous.Mode = pendingMode.Value;
+ }
+ else
+ {
+ snippets.Add(new Snippet
+ {
+ Page = page,
+ Index = ++index,
+ Line = open + 1,
+ Language = language,
+ Mode = pendingMode ?? Mode.Run,
+ Reason = pendingReason,
+ Body = body,
+ Expected = expected,
+ });
+ }
+
+ pendingMode = null; pendingReason = ""; pendingContinues = false; pendingNoOutput = false;
+ }
+
+ return snippets;
+ }
+
+ static readonly Regex HtmlBlock = new(
+ @"\s*(?.*?)
",
+ RegexOptions.Compiled | RegexOptions.Singleline);
+ static readonly Regex AnyHtmlBlock = new(@"", RegexOptions.Compiled);
+
+ ///
+ /// The website's pages are HTML, and a `pre code` block there could be C#, F#, shell or
+ /// CMake with nothing to say which. So a block is checked only where the page says to,
+ /// with a preceding `<!-- amcheck:cs -->` or `<!-- amcheck:fs -->`, and the count of
+ /// unannotated blocks is reported rather than passed over.
+ ///
+ public static List FromHtmlDirectory(string dir, out int unannotated)
+ {
+ var all = new List();
+ var annotated = 0;
+ var total = 0;
+ foreach (var file in Directory.GetFiles(dir, "*.html", SearchOption.AllDirectories)
+ .OrderBy(f => f, StringComparer.Ordinal))
+ {
+ var text = File.ReadAllText(file);
+ total += AnyHtmlBlock.Matches(text).Count;
+ var page = PageName(dir, file);
+ var index = 0;
+ foreach (Match m in HtmlBlock.Matches(text))
+ {
+ annotated++;
+ all.Add(new Snippet
+ {
+ Page = page,
+ Index = ++index,
+ Line = text.Take(m.Index).Count(c => c == '\n') + 1,
+ Language = m.Groups["lang"].Value,
+ Mode = m.Groups["rest"].Value.Contains("compile") ? Mode.CompileOnly : Mode.Run,
+ Reason = m.Groups["rest"].Value.Trim(),
+ Body = Decode(m.Groups["body"].Value).Split('\n').Select(l => l.TrimEnd()).ToList(),
+ });
+ }
+ }
+ unannotated = total - annotated;
+ return all;
+ }
+
+ /// The page as a reader reaches it: `quickstart/index.html` is `quickstart`.
+ static string PageName(string root, string file)
+ {
+ var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
+ if (relative.EndsWith("/index.html")) relative = relative[..^"/index.html".Length];
+ return "site:" + relative;
+ }
+
+ static string Decode(string body) => body
+ .Replace("<", "<").Replace(">", ">").Replace(""", "\"")
+ .Replace("'", "'").Replace(" ", " ").Replace("&", "&")
+ .Replace("\r\n", "\n").Trim('\n');
+
+ static bool IsFence(string line, out string language)
+ {
+ language = null;
+ var t = line.TrimStart();
+ if (!t.StartsWith("```")) return false;
+ language = t.Substring(3).Trim().ToLowerInvariant();
+ return true;
+ }
+
+ /// The bare fence after the sample, when the page introduces it with `Output:`.
+ static string FollowingOutputBlock(string[] lines, int afterCloseFence)
+ {
+ var lead = -1;
+ for (var i = afterCloseFence + 1; i < lines.Length; i++)
+ {
+ var t = lines[i].Trim();
+ if (t.Length == 0) continue;
+ if (OutputLead.IsMatch(t)) { lead = i; continue; }
+ if (!IsFence(lines[i], out var language)) return null;
+ if (lead < 0) return null;
+ if (language.Length != 0) return null;
+
+ var body = new List();
+ for (i++; i < lines.Length && !IsFence(lines[i], out _); i++)
+ body.Add(lines[i].TrimEnd());
+ return string.Join("\n", body).Trim('\n');
+ }
+ return null;
+ }
+
+ /// `Console.WriteLine(expr); // what it prints`, which the wiki uses for short outputs.
+ /// Read only when every printing line carries one, so a partially annotated sample is
+ /// reported as unchecked rather than checked against half its output.
+ static string OutputFromComments(List body)
+ {
+ var texts = new List();
+ var printers = 0;
+ foreach (var line in body)
+ {
+ if (!WriteLineAny.IsMatch(line)) continue;
+ printers++;
+ var m = WriteLineWithComment.Match(line);
+ if (!m.Success) return null;
+ texts.Add(m.Groups["text"].Value);
+ }
+ return printers > 0 && texts.Count == printers ? string.Join("\n", texts) : null;
+ }
+}
diff --git a/Sources/Tests/Harnesses/README.md b/Sources/Tests/Harnesses/README.md
index 7b28169c3..0ed966ee7 100644
--- a/Sources/Tests/Harnesses/README.md
+++ b/Sources/Tests/Harnesses/README.md
@@ -16,6 +16,7 @@ Each exits non-zero when it finds a defect, or when a list of findings it keeps
| `PropCheck` | does each transformation satisfy a property it must: `Simplify`, `Expand` and `Factorize` keep the value, `Differentiate` agrees with a difference quotient, `Integrate` differentiates back | any property that does not hold |
| `CanonCheck` | is there a **canonical form**: idempotence, order independence over commutative operators, and agreement between writings, for `InnerSimplified` and `Simplify` alike | a change to its findings, listed in `canoncheck-baseline.tsv` |
| `Confluence` | where two arms of one rule set both fire at a node, do they agree, or is the order of the arms load-bearing | a change to its conflicting pairs, listed in `confluence-baseline.tsv` |
+| `DocSamples` | does every code sample in the wiki, and every annotated one on the website, compile, run, and print what its page says | a sample that does not compile, throws, or prints something else |
A timeout fails none of them: a shared runner is slower than the machine a budget was set on.
diff --git a/Sources/Tests/Harnesses/Shared/MeasuredCommit.cs b/Sources/Tests/Harnesses/Shared/MeasuredCommit.cs
index 5779128b3..776a50478 100644
--- a/Sources/Tests/Harnesses/Shared/MeasuredCommit.cs
+++ b/Sources/Tests/Harnesses/Shared/MeasuredCommit.cs
@@ -55,6 +55,12 @@ internal static string Commit()
catch { return "unknown build"; }
}
+ ///
+ /// The commit of the repository that contains , for a harness
+ /// that builds the library itself rather than referencing it.
+ ///
+ internal static string Commit(string path) => At(path);
+
///
/// The commit of the repository that contains .
///