Skip to content

Commit 2b2dfc2

Browse files
committed
Merge origin/main into node-subprocess-handling
Both sides appended a job to the E2E workflow: main added notify-on-failure, this branch added e2e-macos. Keep both, and let the notification cover the macOS lane too — a lane that can fail silently is worse than no lane. Read each job result from an env var and fold the platform list in a loop, so the step survives `bash -e`: the previous `[ cond ] && var=...` form exits non-zero the moment a platform passed, aborting the step before it could report the platforms that failed.
2 parents 7c83f53 + a61f500 commit 2b2dfc2

6 files changed

Lines changed: 186 additions & 9 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,35 @@ jobs:
9090
# in, where nothing upstream of the script has a terminal to lend it.
9191
- name: Run Node through the installed execute_script()
9292
run: nohup python tests/e2e/macos_node_smoke.py < /dev/null
93+
94+
notify-on-failure:
95+
name: Notify Slack on failure
96+
needs: [e2e-linux, e2e-windows, e2e-macos]
97+
if: failure()
98+
runs-on: ubuntu-latest
99+
env:
100+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_E2E_WEBHOOK_URL }}
101+
LINUX_RESULT: ${{ needs.e2e-linux.result }}
102+
WINDOWS_RESULT: ${{ needs.e2e-windows.result }}
103+
MACOS_RESULT: ${{ needs.e2e-macos.result }}
104+
steps:
105+
- name: Determine failed platforms
106+
id: platforms
107+
run: |
108+
failed=""
109+
for entry in "Linux:$LINUX_RESULT" "Windows:$WINDOWS_RESULT" "macOS:$MACOS_RESULT"; do
110+
if [ "${entry#*:}" = "failure" ]; then
111+
name="${entry%%:*}"
112+
if [ -n "$failed" ]; then failed="$failed and $name"; else failed="$name"; fi
113+
fi
114+
done
115+
echo "failed=$failed" >> "$GITHUB_OUTPUT"
116+
117+
- name: Send failure notification to Slack
118+
if: ${{ env.SLACK_WEBHOOK_URL != '' }}
119+
uses: slackapi/slack-github-action@v1.24.0
120+
with:
121+
payload: |
122+
{
123+
"text": ${{ toJSON(format('[Codeplain Client] E2E tests FAILED on {0}. See <{1}/{2}/actions/runs/{3}|the run log>.', steps.platforms.outputs.failed, github.server_url, github.repository, github.run_id)) }}
124+
}

CONTRIBUTING.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Contributing to Codeplain
2+
3+
Thanks for contributing to Codeplain.
4+
5+
## Before You Start
6+
7+
Check existing issues and pull requests before starting work.
8+
9+
Keep each contribution focused on one issue or a closely related set of changes.
10+
11+
## Contribution Workflow
12+
13+
1. Fork the Codeplain repository.
14+
2. Make your changes on a branch in your fork.
15+
3. Commit and push the changes to your fork.
16+
4. Open a pull request against the Codeplain `main` branch.
17+
18+
## Configure Codeplain
19+
20+
Use your own Codeplain API key for local development and testing.
21+
22+
Never commit API keys, credentials, or other secrets.
23+
24+
## Test with a Plain Example
25+
26+
When relevant, test your change with an existing example from the `plainlang-examples` repository.
27+
28+
This provides a real `.plain` project for verifying the change in an actual Codeplain workflow.
29+
30+
## Run Tests
31+
32+
Run the tests relevant to your change before opening a pull request.
33+
34+
For changes that affect the CLI, rendering, or test execution, also test the change by running the relevant Codeplain command from the terminal.
35+
36+
Check that:
37+
38+
* the command returns the expected exit code,
39+
* errors are clear and do not expose unintended tracebacks,
40+
* existing Codeplain behavior still works.
41+
42+
For platform-specific changes, test the relevant `.sh` or `.ps1` workflow.
43+
44+
## Keep the Change Clean
45+
46+
Before submitting:
47+
48+
* remove temporary files and debugging output,
49+
* avoid unrelated changes,
50+
* add or update tests where needed,
51+
* update documentation when behavior changes,
52+
* verify that no secrets are included.
53+
54+
Review your changes with:
55+
56+
```bash
57+
git status
58+
git diff
59+
```
60+
61+
## Pull Request
62+
63+
Include:
64+
65+
* what changed,
66+
* why it changed,
67+
* the related issue, when applicable,
68+
* how the change was tested,
69+
* the `plainlang-examples` example used, when relevant.

install/powershell/install.ps1

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,14 @@ function Test-GitAvailable {
129129
}
130130
}
131131

132-
# Print install instructions for the current platform and exit. codeplain uses
132+
# `exit` under `irm ... | iex` closes the user's shell, so exit only for file-based
133+
# ($PSCommandPath) or unattended runs. Callers must `return` right after this.
134+
function Stop-Install {
135+
$global:LASTEXITCODE = 1
136+
if ($PSCommandPath -or $nonInteractive) { exit 1 }
137+
}
138+
139+
# Print install instructions for the current platform. codeplain uses
133140
# git to checkpoint the code it renders, and GitPython fails at import time
134141
# when the git executable is missing, so 'codeplain --status' would die with a
135142
# raw traceback that says nothing about the actual cause.
@@ -148,7 +155,7 @@ function Assert-Git {
148155
Write-Host ""
149156
Write-Host " ${GRAY}Once Git is installed, restart your terminal and run this installer again.${NC}"
150157
Write-Host ""
151-
exit 1
158+
Stop-Install
152159
}
153160

154161
# Verify an API key against the Codeplain API's /status endpoint.
@@ -193,9 +200,10 @@ Write-Host ""
193200
# before the user answers any prompts rather than at the --status check below.
194201
if (-not (Test-GitAvailable)) {
195202
Assert-Git
203+
return
196204
}
197205

198-
Write-Host "${GREEN}${NC} git detected"
206+
Write-Host "${GREEN}${CHECK}${NC} git detected"
199207
Write-Host ""
200208

201209
try {
@@ -545,7 +553,8 @@ if ($env:CODEPLAIN_API_KEY) {
545553
Write-Host $verifyOutput
546554
Write-Host "${GRAY}Please restart your terminal and try again, or reinstall with:${NC}"
547555
Write-Host " uv tool install --force codeplain"
548-
exit 1
556+
Stop-Install
557+
return
549558
}
550559
}
551560

plain_file.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,19 @@ def process_imports(
455455
return required_concepts
456456

457457

458+
def normalize_line_endings(plain_source_text: str) -> str:
459+
return plain_source_text.replace("\r\n", "\n").replace("\r", "\n")
460+
461+
462+
def restore_stripped_lines(plain_source_text: str, content: str) -> str:
463+
stripped_source = plain_source_text.rstrip()
464+
if not content or not stripped_source.endswith(content):
465+
return content
466+
467+
stripped_line_count = stripped_source[: len(stripped_source) - len(content)].count("\n")
468+
return "\n" * stripped_line_count + content
469+
470+
458471
def read_plain_source_metadata(plain_source_text):
459472
try:
460473
plain_source_obj = frontmatter.loads(plain_source_text)
@@ -490,6 +503,8 @@ def parse_plain_source( # noqa: C901
490503
imported_modules: list[str],
491504
modules_trace: list[str],
492505
) -> PlainFileParseResult:
506+
plain_source_text = normalize_line_endings(plain_source_text)
507+
493508
plain_source_obj = read_plain_source_metadata(plain_source_text)
494509

495510
plain_source = PLAIN_SOURCE_TEMPLATE.copy()
@@ -508,7 +523,9 @@ def parse_plain_source( # noqa: C901
508523

509524
[_, loaded_templates] = file_utils.get_loaded_templates(template_dirs, plain_source_text)
510525

511-
plain_source_full_text = render_plain_source(plain_source_obj.content, loaded_templates, code_variables)
526+
plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content)
527+
528+
plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables)
512529

513530
plain_file = mistletoe.Document(io.StringIO(plain_source_full_text))
514531

tests/test_cli_output.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for cli_output module."""
22

3+
from datetime import datetime, timezone
34
from unittest.mock import Mock, patch
45

56
from cli_output.status import (
@@ -10,6 +11,14 @@
1011
print_status,
1112
)
1213

14+
FROZEN_NOW = datetime(2025, 6, 15, 12, 0, tzinfo=timezone.utc)
15+
16+
17+
class FrozenDatetime(datetime):
18+
@classmethod
19+
def now(cls, tz=None):
20+
return FROZEN_NOW if tz is None else FROZEN_NOW.astimezone(tz)
21+
1322

1423
class TestProgressBar:
1524
"""Tests for _create_progress_bar function."""
@@ -52,6 +61,7 @@ def test_custom_width(self):
5261
assert bar == "█" * 5 + "░" * 5
5362

5463

64+
@patch("cli_output.status.datetime", FrozenDatetime)
5565
class TestDisplayCreditLine:
5666
"""Tests for _display_credit_line function."""
5767

@@ -132,6 +142,7 @@ def test_timezone_naive_datetime(self, mock_console):
132142
mock_console.print.assert_called_once()
133143

134144

145+
@patch("cli_output.status.datetime", FrozenDatetime)
135146
class TestDisplayBucketCreditLine:
136147
"""Tests for _display_bucket_credit_line function."""
137148

@@ -192,6 +203,7 @@ def test_timezone_naive_datetime(self, mock_console):
192203
mock_console.print.assert_called_once()
193204

194205

206+
@patch("cli_output.status.datetime", FrozenDatetime)
195207
class TestDisplayStatusMessage:
196208
"""Tests for _display_status_message function."""
197209

@@ -271,6 +283,7 @@ def test_null_plan_credits_and_empty_purchased(self, mock_console):
271283
assert "No rendering credits remaining" in call_args
272284

273285

286+
@patch("cli_output.status.datetime", FrozenDatetime)
274287
class TestPrintStatus:
275288
"""Tests for print_status function."""
276289

tests/test_plainfileparser.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_unknown_section():
3636
with pytest.raises(
3737
Exception,
3838
match=re.escape(
39-
"Syntax error at line 3: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests"
39+
"Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests"
4040
),
4141
):
4242
plain_file.parse_plain_source(plain_source, {}, [], [], [])
@@ -50,7 +50,7 @@ def test_duplicate_section():
5050
"""
5151
with pytest.raises(
5252
Exception,
53-
match=re.escape("Syntax error at line 3: Duplicate specification heading (`definitions`)"),
53+
match=re.escape("Syntax error at line 4: Duplicate specification heading (`definitions`)"),
5454
):
5555
plain_file.parse_plain_source(plain_source, {}, [], [], [])
5656

@@ -64,11 +64,48 @@ def test_invalid_top_level_element():
6464
"""
6565
with pytest.raises(
6666
Exception,
67-
match=re.escape("Syntax error at line 2: Invalid source structure (`code block`)"),
67+
match=re.escape("Syntax error at line 3: Invalid source structure (`code block`)"),
6868
):
6969
plain_file.parse_plain_source(plain_source, {}, [], [], [])
7070

7171

72+
def test_syntax_error_line_number_accounts_for_frontmatter():
73+
plain_source = """---
74+
description: 'Plain file with frontmatter'
75+
---
76+
77+
***definitions***
78+
79+
***Unknown Section:***
80+
"""
81+
with pytest.raises(
82+
Exception,
83+
match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"),
84+
):
85+
plain_file.parse_plain_source(plain_source, {}, [], [], [])
86+
87+
88+
def test_syntax_error_line_number_with_windows_line_endings():
89+
plain_source = (
90+
"---\r\n"
91+
"description: 'Plain file with frontmatter'\r\n"
92+
"---\r\n"
93+
"\r\n"
94+
"***definitions***\r\n"
95+
"\r\n"
96+
"***Unknown Section:***\r\n"
97+
)
98+
with pytest.raises(
99+
Exception,
100+
match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"),
101+
):
102+
plain_file.parse_plain_source(plain_source, {}, [], [], [])
103+
104+
105+
def test_normalize_line_endings_does_not_duplicate_newlines():
106+
assert plain_file.normalize_line_endings("a\r\nb\rc\nd") == "a\nb\nc\nd"
107+
108+
72109
def test_plain_file_parser_with_comments(get_test_data_path):
73110
_, plain_sections, _ = plain_file.plain_file_parser(
74111
"plain_file_parser_with_comments.plain",
@@ -423,7 +460,7 @@ def test_acceptance_tests_top_level_rejected():
423460
with pytest.raises(
424461
PlainSyntaxError,
425462
match=re.escape(
426-
"Syntax error at line 1: acceptance tests heading should be nested under specific functional spec."
463+
"Syntax error at line 2: acceptance tests heading should be nested under specific functional spec."
427464
),
428465
):
429466
plain_file.parse_plain_source(plain_source, {}, [], [], [])

0 commit comments

Comments
 (0)