Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@ jobs:
- name: Install Node.js dependencies
run: npm ci

- name: Check documentation pages
run: npm run check:docs

- name: Build CSS
run: npm run build-css-prod

Expand All @@ -284,3 +287,7 @@ jobs:

- name: Check internal links
run: zola check --skip-external-links

# zola check only follows @/ links; this site writes absolute ones.
- name: Check absolute links and anchors
run: npm run check:links
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,5 @@ cache/
/agnostic-ai.local.yaml
/content/AGENTS.md
/content/practice/AGENTS.md
/.agnostic-ai/.command-lock
# <<< agnostic-ai (managed) <<<
110 changes: 96 additions & 14 deletions build/run-doc-snippets.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

/**
* Executes every ```phel snippet in content/ against the real Phel runtime,
* so documentation cannot silently rot when the language changes.
* so documentation cannot silently rot when the language changes. Documented
* results (`; => value`) are checked too (see ExpectedValueRewriter); a
* mismatch fails like a broken snippet.
*
* Each snippet runs in an isolated `phel run` subprocess under a wall-clock cap
* enforced here in PHP, so a hanging or memory-heavy snippet cannot take down
Expand All @@ -15,7 +17,8 @@
* templates and narrative continuations exist by design. Those are either
* auto-skipped (see SnippetExtractor) or recorded in a checked-in baseline
* (build/doc-snippets-baseline.json), keyed by snippet content so the entry
* survives line moves. The suite fails on:
* survives line moves. A mismatch entry adds the expectation's text to that
* key. The suite fails on:
* - a NEW failure not present in the baseline (a regression), or
* - a baseline entry that now passes (ratchet: remove it).
*
Expand All @@ -27,6 +30,7 @@

require __DIR__ . '/../vendor/autoload.php';

use PhelWeb\DocSnippet\ExpectedValueRewriter;
use PhelWeb\DocSnippet\SnippetExtractor;

$root = dirname(__DIR__);
Expand All @@ -47,13 +51,19 @@
$CONCURRENCY = 8;

$extractor = new SnippetExtractor();
$rewriter = new ExpectedValueRewriter();

/** Stable per-snippet key, independent of its line number. */
$keyOf = static function (string $relFile, string $code): string {
$normalized = preg_replace('/\s+/', ' ', trim($code));
return $relFile . '#' . substr(sha1((string) $normalized), 0, 12);
};

$expectationKeyOf = static function (string $snippetKey, string $text): string {
$normalized = preg_replace('/\s+/', ' ', trim($text));
return $snippetKey . '=>' . substr(sha1((string) $normalized), 0, 8);
};

// Collect markdown files.
$files = [];
foreach ($paths as $path) {
Expand All @@ -77,6 +87,7 @@

$jobs = [];
$skipped = 0;
$skippedExpectations = [];
$index = 0;
foreach ($files as $file) {
$rel = str_replace($root . '/', '', $file);
Expand All @@ -88,16 +99,28 @@
$index++;
$code = $snippet['code'];
$hasNs = preg_match('/^\s*\(ns\s/', $code) === 1;
// Dot separator, not backslash: Phel deprecated `\` in namespaces, and
// the notice it prints carries a full PHP stack trace (~150KB of stderr
// per snippet), which both slows the run and floods the pipes.
$source = $hasNs ? $code : "(ns doctest.s{$index})\n{$code}";
// `user`, as in the REPL, so documented values such as `(user.point 1 2)` match.
$header = $hasNs ? '' : "(ns user)\n";
$rewrite = $rewriter->rewrite($code, $rel, $snippet['startLine']);
array_push($skippedExpectations, ...$rewrite['skipped']);
$key = $keyOf($rel, $code);
$tmpFile = "{$tmpDir}/s{$index}.phel";
file_put_contents($tmpFile, $source);
file_put_contents($tmpFile, $header . $rewrite['code']);
$originalFile = null;
if ($rewrite['expectations'] !== []) {
$originalFile = "{$tmpDir}/s{$index}-original.phel";
file_put_contents($originalFile, $header . $code);
}
$expectations = [];
foreach ($rewrite['expectations'] as $expectation) {
$expectations[$expectation['location']] = $expectation + ['key' => $expectationKeyOf($key, $expectation['text'])];
}
$jobs[] = [
'key' => $keyOf($rel, $code),
'key' => $key,
'location' => "{$rel}:{$snippet['startLine']}",
'file' => $tmpFile,
'originalFile' => $originalFile,
'expectations' => $expectations,
];
}
}
Expand Down Expand Up @@ -220,6 +243,28 @@
$passed = 0;
$queue = $jobs;

// Expectations of a snippet whose instrumented run passed: a PHEL-DOC-MISMATCH
// line in stdout fails that expectation, its absence passes it.
$mismatches = []; // key => ['location' => ..., 'note' => ...]
$checked = 0;
$unverifiable = [];
$recordExpectations = static function (array $job, string $out) use (&$mismatches, &$checked, &$passedKeys): void {
$marker = preg_quote(ExpectedValueRewriter::MISMATCH_MARKER, '/');
preg_match_all('/^' . $marker . ' (\S+) (expected .*)$/m', $out, $found, PREG_SET_ORDER);
Comment thread
Chemaclass marked this conversation as resolved.
Resolved
$notes = [];
foreach ($found as $m) {
$notes[$m[1]] = $m[2];
}
foreach ($job['expectations'] as $location => $expectation) {
++$checked;
if (isset($notes[$location])) {
$mismatches[$expectation['key']] = ['location' => $location, 'note' => $notes[$location]];
} else {
$passedKeys[$expectation['key']] = true;
}
}
};

while ($queue !== [] || $running !== []) {
while (count($running) < $CONCURRENCY && $queue !== []) {
$running[] = $startProc(array_shift($queue));
Expand Down Expand Up @@ -247,6 +292,7 @@
if ($exit === 0 && !$r['timedOut']) {
$passed++;
$passedKeys[$job['key']] = true;
$recordExpectations($job, $r['out']);
} else {
$failed[$job['key']] = [
'location' => $job['location'],
Expand Down Expand Up @@ -286,6 +332,23 @@
unset($failed[$key]);
$passed++;
$passedKeys[$key] = true;
$recordExpectations($job, $out);
continue;
}
$failed[$key]['note'] = $noteOf($exit, $out, $err, $timedOut);
if ($job['originalFile'] === null) {
continue;
}
// The checks must never break a snippet that runs on its own: if the
// original passes, its expectations are reported as unverifiable instead.
[$exit, $out, $err, $timedOut] = $reap($startProc(['file' => $job['originalFile']] + $job, 'original-'));
if ($exit === 0 && !$timedOut) {
unset($failed[$key]);
$passed++;
$passedKeys[$key] = true;
foreach ($job['expectations'] as $expectation) {
$unverifiable[] = $expectation;
}
} else {
$failed[$key]['note'] = $noteOf($exit, $out, $err, $timedOut);
}
Expand All @@ -305,18 +368,22 @@
$rmTree($tmpDir);

// --- Baseline handling ---
$failures = $failed + $mismatches;
$expectationSummary = "Expectations: checked {$checked}, mismatched " . count($mismatches) . ', unverifiable ' . count($unverifiable);

if ($updateBaseline) {
$entries = [];
foreach ($failed as $key => $info) {
foreach ($failures as $key => $info) {
$entries[$key] = $info['location'] . ' :: ' . $info['note'];
}
ksort($entries);
file_put_contents(
$baselinePath,
json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n",
);
echo "\nWrote baseline with " . count($entries) . " known-failing snippets to build/doc-snippets-baseline.json\n";
echo "\nWrote baseline with " . count($entries) . " known failures to build/doc-snippets-baseline.json\n";
echo "Snippets: {$total} Passed: {$passed} Failed: " . count($failed) . " Skipped: {$skipped}\n";
echo "{$expectationSummary}\n";
exit(0);
}

Expand All @@ -325,12 +392,13 @@
$baseline = (array) json_decode((string) file_get_contents($baselinePath), true);
}

$regressions = array_diff_key($failed, $baseline); // failing now, not in baseline
$regressions = array_diff_key($failures, $baseline); // failing now, not in baseline
$fixed = array_intersect_key($passedKeys, $baseline); // in baseline but now passing

echo "\n";
echo "Snippets: {$total} Passed: {$passed} Failed: " . count($failed)
. " Skipped: {$skipped} Baseline: " . count($baseline) . "\n";
echo "{$expectationSummary}\n";

$exit = 0;

Expand All @@ -350,15 +418,29 @@
}
}

if ($verbose && $failed !== []) {
if ($verbose && $failures !== []) {
echo "\nAll current failures:\n";
foreach ($failed as $info) {
foreach ($failures as $info) {
echo " {$info['location']} {$info['note']}\n";
}
}

if ($verbose && $unverifiable !== []) {
echo "\nUnverifiable expectations (the instrumented snippet fails, the original passes):\n";
foreach ($unverifiable as $expectation) {
echo " {$expectation['location']} {$expectation['text']}\n";
}
}

if ($verbose && $skippedExpectations !== []) {
echo "\nSkipped expectations:\n";
foreach ($skippedExpectations as $expectation) {
echo " {$expectation['location']} [{$expectation['reason']}] {$expectation['text']}\n";
}
}

if ($exit === 0) {
echo "\nNo regressions. " . count($baseline) . " known-unrunnable snippets tracked in baseline.\n";
echo "\nNo regressions. " . count($baseline) . " known failures tracked in baseline.\n";
}

exit($exit);
Loading
Loading