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
9 changes: 7 additions & 2 deletions .claude/skills/prepare-vortex-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,16 @@ Work through each checklist item from the release process doc:
project to work against.
- To re-record a subset, pass the names:
`ahoy update-videos installer build lint`. Allowed names: `installer`,
`build`, `provision`, `lint`, `test`, `test-bdd`. Default is all six.
- Heavy step: ~15-20 minutes wall-clock when running all six; requires
`build`, `provision`, `lint`, `test`, `test-bdd`, `info`, `doctor`,
`doctor-info`. Default is all nine.
- Heavy step: ~15-20 minutes wall-clock when running all nine; requires
Docker. `ahoy update-videos installer` is fast (no Docker).
- The command does NOT auto-commit; review the artifact diff under
`.vortex/docs/static/img/` and stage manually.
- A recording whose output reports an error, a warning or a failure is not
rendered, and the command stops naming the offending lines. Read them in
`.artifacts/videos/<name>.txt`, fix the command that emits them, and record
again - there is no flag that renders anyway.

## Step 5: Generate release notes

Expand Down
3 changes: 2 additions & 1 deletion .docker/cli.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ RUN mkdir -p -m 2775 "/app/${WEBROOT}/${DRUPAL_PUBLIC_FILES}" "/app/${WEBROOT}/$
#;< DRUPAL_THEME
RUN if [ "${VORTEX_FRONTEND_BUILD_SKIP}" != "1" ]; then \
theme_path="/app/${WEBROOT}/themes/custom/${DRUPAL_THEME}"; \
export npm_config_cache=/tmp/npm-cache; \
npm --prefix="${theme_path}" ci --no-progress --no-audit --no-fund && \
npm --prefix="${theme_path}" run build && \
npm cache clean --force; \
rm -rf /tmp/npm-cache; \
fi
#;> DRUPAL_THEME

Expand Down
15 changes: 11 additions & 4 deletions .vortex/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,22 @@ commit its output manually. Run both after the code change is committed.

## Documentation videos

Six terminal demo videos live in `.vortex/docs/static/img/` (`installer.*`,
`build.*`, `provision.*`, `lint.*`, `test.*`, `test-bdd.*`). Regenerate from
`.vortex/` with `ahoy update-videos [names]`. A video goes stale when the
command it records changes behavior:
Nine terminal demo videos live in `.vortex/docs/static/img/` (`installer.*`,
`build.*`, `provision.*`, `lint.*`, `test.*`, `test-bdd.*`, `info.*`,
`doctor.*`, `doctor-info.*`). Regenerate from `.vortex/` with
`ahoy update-videos [names]`. A video goes stale when the command it records
changes behavior:

- `installer` - any prompt flow change.
- `build`, `provision` - changes to `.ahoy.yml` build/provision targets or
`scripts/vortex/provision*`.
- `lint`, `test`, `test-bdd` - changes to the linter or test-runner setup.
- `info`, `doctor`, `doctor-info` - changes to the reported environment.

A published video is a claim that the command it records runs clean, so each
recording is decoded to plain text at `.artifacts/videos/<name>.txt` and is not
rendered when that text reports an error, a warning or a failure. There is no
override flag: fix the output at the command that emits it and record again.

`update-videos` does not commit - review the diff under `.vortex/docs/static/img/`
and commit manually. See `.vortex/docs/CLAUDE.md` for the pipeline internals
Expand Down
183 changes: 183 additions & 0 deletions .vortex/docs/.utils/VideoRecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ final class VideoRecorder {
*/
public const FONT_SIZE = '1.67';

/**
* Recorded output markers, keyed by the category they are reported under.
*
* A command that fails inside a recording still leaves asciinema exiting
* zero, so a failure is only visible in the text the command printed.
*/
public const ISSUE_PATTERNS = [
'error' => '#\berrors?\b|✖|✗#iu',
'warning' => '#\bwarn(?:ing|ings|s)?\b#i',
'failure' => '#\bfail(?:ed|ing|s|ure|ures)?\b#i',
];

/**
* Fragments removed from a line before it is matched against ISSUE_PATTERNS.
*
* Paths, URLs, package names and command line options embed the marker words
* in identifiers ('symfony/error-handler',
* '--no-error-on-unmatched-pattern'), and tools report a clean run with an
* explicit zero count ('0 errors', 'warnings: 0').
*/
public const ISSUE_EXEMPT_PATTERNS = [
'#https?://\S+#',
'#\S+/\S+#',
'#(?<!\S)-{1,2}[A-Za-z0-9][\w.=-]*#',
'#\b(?:0|no)\s+(?:new\s+)?(?:errors?|warn(?:ing|ings|s)?|problems?|notices?|fail(?:ed|ures?)?)\b#i',
'#\b(?:errors?|warn(?:ing|ings|s)?|problems?|notices?|fail(?:ed|ures?)?)\s*[:=]\s*0\b#i',
];

/**
* Return the rows that give a terminal of the given width a wanted shape.
*
Expand Down Expand Up @@ -429,6 +457,161 @@ public function applyTimeScale(string $cast_path, float $factor): void {
$this->pass("Cast time-scaled by {$factor}x");
}

/**
* Decode a cast into the plain text the recorded terminal displayed.
*/
public function castToText(string $cast_path): string {
if (!is_file($cast_path)) {
throw new RuntimeException("Cast file not found: $cast_path");
}

$lines = file($cast_path, FILE_IGNORE_NEW_LINES);
if ($lines === FALSE || count($lines) < 2) {
throw new RuntimeException("Cast file is empty or malformed: $cast_path");
}

// The whole stream is joined before it is stripped, because an escape
// sequence can be split across two recorded events and is only contiguous
// once they are concatenated.
$text = '';
foreach (array_slice($lines, 1) as $line) {
$line = trim($line);

if ($line === '') {
continue;
}

$event = json_decode($line, TRUE);

// Only 'o' events carry terminal output; 'i' and 'r' events carry input
// and resizes.
if (is_array($event) && ($event[1] ?? '') === 'o' && isset($event[2])) {
$text .= (string) $event[2];
}
}

return $this->stripAnsi($text);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Remove the escape sequences that color and position recorded output.
*/
protected function stripAnsi(string $text): string {
$patterns = [
// Operating system commands, such as the window title.
'#\x1b\][^\x07\x1b]*(?:\x07|\x1b\\\\)#',
// Control sequence introducer, covering colors and cursor movement.
'#\x1b\[[0-9;?]*[ -/]*[@-~]#',
// Two-character escapes left between the sequences above.
'#\x1b[@-Z\\\\-_]#',
];

$stripped = preg_replace($patterns, '', $text);
if ($stripped === NULL) {
throw new RuntimeException('Failed to strip ANSI sequences from cast text');
}

// ISSUE_PATTERNS needs the 'u' modifier to carry '✖', and a 'u' pattern
// returns FALSE rather than no-match on invalid UTF-8, which would hide
// every marker on the offending line.
$stripped = mb_scrub($stripped, 'UTF-8');

// A carriage return redraws the current line, so each redraw becomes its
// own line rather than overwriting the text that was already recorded.
return str_replace(["\r\n", "\r"], "\n", $stripped);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Write the plain text transcript of a cast and return its contents.
*/
public function writeTranscript(string $cast_path, string $transcript_path): string {
$text = $this->castToText($cast_path);

$dir = dirname($transcript_path);
if (!is_dir($dir) && !mkdir($dir, 0o755, TRUE) && !is_dir($dir)) {
throw new RuntimeException("Failed to create transcript directory: $dir");
}

if (file_put_contents($transcript_path, $text) === FALSE) {
throw new RuntimeException("Failed to write transcript: $transcript_path");
}

$this->pass("Transcript written: $transcript_path");

return $text;
}

/**
* Find the lines of recorded output that report an error or a warning.
*
* @return array<int,array{line:int,category:string,text:string}>
*/
public function findIssues(string $text): array {
$issues = [];

foreach (explode("\n", $text) as $index => $line) {
$line = trim($line);

if ($line === '') {
continue;
}

$probe = preg_replace(self::ISSUE_EXEMPT_PATTERNS, ' ', $line);
if ($probe === NULL) {
throw new RuntimeException('Failed to apply issue exemptions to recorded output');
}

foreach (self::ISSUE_PATTERNS as $category => $pattern) {
if (preg_match($pattern, $probe) === 1) {
$issues[] = ['line' => $index + 1, 'category' => $category, 'text' => $line];

break;
}
}
}

return $issues;
}

/**
* Report the issues found in recorded output and fail when there are any.
*
* A published demo is a claim that the command runs clean, so a recording
* that reports an error, a warning or a failure is not rendered.
*/
public function assertNoIssues(string $name, string $text): void {
$issues = $this->findIssues($text);

if ($issues === []) {
$this->pass("No errors, warnings or failures recorded in '$name'");

return;
}

$grouped = [];
foreach ($issues as $issue) {
$key = $issue['category'] . '|' . $issue['text'];
$grouped[$key] ??= ['line' => $issue['line'], 'category' => $issue['category'], 'text' => $issue['text'], 'count' => 0];
$grouped[$key]['count']++;
}

$this->fail(sprintf("Recorded '%s' output reports %d issue line(s), %d unique:", $name, count($issues), count($grouped)));

foreach ($grouped as $issue) {
$repeat = $issue['count'] > 1 ? sprintf(' (x%d)', $issue['count']) : '';
$this->note(sprintf('line %d [%s]%s %s', $issue['line'], $issue['category'], $repeat, $this->truncate($issue['text'])));
}

throw new RuntimeException(sprintf("Refusing to render '%s': fix the reported lines at the command that emits them and re-record", $name));
}

/**
* Shorten a reported line to keep the issue report readable.
*/
protected function truncate(string $text, int $length = 160): string {
return mb_strlen($text) > $length ? mb_substr($text, 0, $length - 3) . '...' : $text;
}

/**
* Render the cast to an animated SVG via svg-term-render.js.
*/
Expand Down
23 changes: 22 additions & 1 deletion .vortex/docs/.utils/update-videos.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
*
* Output is hardcoded to .vortex/docs/static/img/<name>.{json,svg,png,gif}.
*
* Every cast is decoded to plain text at .artifacts/videos/<name>.txt and is
* not rendered when that text reports an error, a warning or a failure.
*
* Usage:
* php update-videos.php # wipe + bootstrap + record all six
* php update-videos.php # wipe + bootstrap + record all
* php update-videos.php lint provision # wipe + bootstrap + record lint, provision
* php update-videos.php lint,test # commas also accepted
* php update-videos.php --keep lint # reuse workspace, record lint only
Expand All @@ -39,6 +42,15 @@

const WORKSPACE_REL = '.artifacts/tmp/videos-workspace';

/**
* Where the plain text decoded from each cast is written.
*
* The output check reports line numbers against this text, so it is kept for
* inspection. It is working material and not a published artifact, so it lives
* outside the docs static directory.
*/
const TRANSCRIPT_REL = '.artifacts/videos';

const COMPOSE_PROJECT = 'vortex_videos';

/**
Expand Down Expand Up @@ -133,6 +145,10 @@ function usage(): void {
fwrite(STDERR, "--keep reuses the existing workspace and skips the bootstrap. Requires the\n");
fwrite(STDERR, "Docker stack to be running (the script probes and exits cleanly otherwise).\n");
fwrite(STDERR, "\n");
fwrite(STDERR, "Each recording is decoded to '.artifacts/videos/<name>.txt' and is not\n");
fwrite(STDERR, "rendered when that text reports an error, a warning or a failure. There is\n");
fwrite(STDERR, "no override: fix the command that emits the output and record again.\n");
fwrite(STDERR, "\n");
fwrite(STDERR, "Video names may be space or comma separated (lint test = lint,test).\n");
}

Expand Down Expand Up @@ -287,6 +303,11 @@ function render_video(VideoRecorder $recorder, string $name, string $workspace,
$recorder->applyTimeScale($cast, 1.0 / (float) $cfg['speed']);
}

// Decoded from the post-processed cast, so the transcript carries the
// anonymised paths and masked credentials rather than what was recorded.
$text = $recorder->writeTranscript($cast, $recorder->project_root . '/' . TRANSCRIPT_REL . "/$name.txt");
$recorder->assertNoIssues($name, $text);

$recorder->renderSvg($cast, $docs_static_dir . "/$name.svg");
$recorder->renderPng($cast, $docs_static_dir . "/$name.png", $cfg['poster_ms'] === NULL ? NULL : (int) $cfg['poster_ms']);
}
Expand Down
24 changes: 14 additions & 10 deletions .vortex/docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,17 @@ Categories defined in `sidebars.js`. For subdirectories:

## Documentation videos

Six terminal demo videos live in `static/img/` (`installer.*`, `build.*`,
`provision.*`, `lint.*`, `test.*`, `test-bdd.*`), each as a `.json` asciicast +
`.svg` + `.png` poster. They are generated by a single PHP script
(`.utils/update-videos.php`) driven by the shared `VideoRecorder` class. Run
`ahoy update-videos [names]` from `.vortex/`. See `.vortex/CLAUDE.md` for the
staleness triggers (which code change invalidates which video).
Nine terminal demo videos live in `static/img/` (`installer.*`, `build.*`,
`provision.*`, `lint.*`, `test.*`, `test-bdd.*`, `info.*`, `doctor.*`,
`doctor-info.*`), each as a `.json` asciicast + `.svg` + `.png` poster. They are
generated by a single PHP script (`.utils/update-videos.php`) driven by the
shared `VideoRecorder` class. Run `ahoy update-videos [names]` from `.vortex/`.
See `.vortex/CLAUDE.md` for the staleness triggers (which code change
invalidates which video).

| Command | Regenerates |
|----------------------------------------|------------------------------------------------|
| `ahoy update-videos` | Wipe workspace + bootstrap + record all six |
| `ahoy update-videos` | Wipe workspace + bootstrap + record all |
| `ahoy update-videos lint provision` | Wipe + bootstrap + record only lint, provision |
| `ahoy update-videos lint,test` | Comma-separated list also accepted |
| `ahoy update-videos --keep lint` | Skip bootstrap, re-record lint only |
Expand All @@ -87,9 +88,12 @@ timestamp, typer on/off) live in the `VIDEOS` array at the top of
**Pipeline**:

1. Runs the installer non-interactively (or via `expect` when `installer` is in the requested set) using `--uri=<project_root>`, producing `$workspace/star_wars`.
2. If any of `build`, `provision`, `lint`, `test`, `test-bdd` is requested, `ahoy build` runs **once** in `$workspace/star_wars` (either as the recorded `build` video or silently).
3. Remaining requested commands (`provision`, `lint`, `test`, `test-bdd`) are recorded in that same `star_wars` directory, in fixed order.
4. The workspace and Docker stack are preserved at exit so the next `--keep` invocation can reuse them; a stale workspace from a previous run is torn down at the **start** of the next non-`--keep` run.
2. If any of `build`, `provision`, `lint`, `test`, `test-bdd`, `info`, `doctor`, `doctor-info` is requested, `ahoy build` runs **once** in `$workspace/star_wars` (either as the recorded `build` video or silently).
3. Remaining requested commands (`info`, `doctor`, `doctor-info`, `provision`, `lint`, `test`, `test-bdd`) are recorded in that same `star_wars` directory, in fixed order.
4. Each cast is decoded to plain text at `.artifacts/videos/<name>.txt` and checked before it is rendered; a recording whose output reports an error, a warning or a failure is not turned into an SVG.
5. The workspace and Docker stack are preserved at exit so the next `--keep` invocation can reuse them; a stale workspace from a previous run is torn down at the **start** of the next non-`--keep` run.

**Output check**: a published demo is a claim that the command it records runs clean, so the decoded transcript is matched against `VideoRecorder::ISSUE_PATTERNS`, with `VideoRecorder::ISSUE_EXEMPT_PATTERNS` removing the fragments that carry a marker word without reporting anything (paths, URLs, package names, command line options, and zero counts such as `0 errors` or `warnings: 0`). The report names every offending line with the line number it holds in the transcript, and groups repeated identical lines. There is no override flag - output that cannot be fixed here is fixed at the command that emits it. The transcript is working material rather than a published artifact, so `.artifacts/` is gitignored and nothing under it is committed.

**Iterating on one video** - use `--keep` so the install + build happens only
once, then replay the recording against the preserved project:
Expand Down
Loading