Skip to content

Commit fcc6c8f

Browse files
authored
Merge pull request #62 from upstat-io/dev
feat(eval): complete COW parity and extract bloated modules
2 parents 369ed7e + ebd2254 commit fcc6c8f

22 files changed

Lines changed: 1393 additions & 480 deletions

File tree

.github/workflows/auto-release.yml

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -88,28 +88,41 @@ jobs:
8888
git tag "${{ steps.tag.outputs.tag }}"
8989
git push origin "${{ steps.tag.outputs.tag }}"
9090
91-
- name: Gather commit log
91+
- name: Gather release context
9292
if: steps.tag.outputs.skip != 'true'
9393
id: commits
94+
env:
95+
GH_TOKEN: ${{ secrets.ORILANG_RELEASE_TOKEN }}
9496
run: |
9597
TAG="${{ steps.tag.outputs.tag }}"
9698
9799
# Find previous tag for release notes range
98100
PREV_TAG=$(git tag --sort=-creatordate | grep '^v' | sed -n '2p')
99101
100102
if [[ -n "$PREV_TAG" ]]; then
103+
PREV_DATE=$(git log -1 --format=%aI "$PREV_TAG")
101104
LOG=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges)
102-
DIFF_STAT=$(git diff "${PREV_TAG}..HEAD" --stat | tail -1)
103105
else
106+
PREV_DATE=""
104107
LOG=$(git log --pretty=format:"- %s (%h)" --no-merges -20)
105-
DIFF_STAT=""
108+
fi
109+
110+
# Fetch merged PR descriptions — the richest context available
111+
if [[ -n "$PREV_DATE" ]]; then
112+
PR_BODIES=$(gh pr list --state merged --base master --limit 20 \
113+
--json number,title,body,mergedAt \
114+
--jq "[.[] | select(.mergedAt >= \"$PREV_DATE\")] | .[] | \"## PR #\\(.number): \\(.title)\\n\\(.body // \"(no description)\")\\n\"")
115+
else
116+
PR_BODIES=$(gh pr list --state merged --base master --limit 5 \
117+
--json number,title,body \
118+
--jq '.[] | "## PR #\(.number): \(.title)\n\(.body // "(no description)")\n"')
106119
fi
107120
108121
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
109122
110-
# Write to file to avoid shell quoting issues
123+
# Write to files to avoid shell quoting issues
111124
echo "$LOG" > /tmp/commit-log.txt
112-
echo "$DIFF_STAT" > /tmp/diff-stat.txt
125+
echo "$PR_BODIES" > /tmp/pr-bodies.txt
113126
114127
- name: Generate AI release notes
115128
if: steps.tag.outputs.skip != 'true'
@@ -118,7 +131,7 @@ jobs:
118131
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
119132
run: |
120133
COMMIT_LOG=$(cat /tmp/commit-log.txt)
121-
DIFF_STAT=$(cat /tmp/diff-stat.txt)
134+
PR_BODIES=$(cat /tmp/pr-bodies.txt)
122135
TAG="${{ steps.tag.outputs.tag }}"
123136
PREV_TAG="${{ steps.commits.outputs.prev_tag }}"
124137
@@ -127,25 +140,43 @@ jobs:
127140
import asyncio, sys, os
128141
129142
COMMIT_LOG = """${COMMIT_LOG}"""
130-
DIFF_STAT = """${DIFF_STAT}"""
143+
PR_BODIES = """${PR_BODIES}"""
131144
TAG = "${TAG}"
132145
PREV_TAG = "${PREV_TAG}" or "beginning"
133146
134-
PROMPT = f"""You are writing release notes for "Ori", a statically-typed, expression-based programming language compiler. Given the commit log below, write concise, user-friendly release notes in markdown.
147+
PROMPT = f"""You are writing release notes for **Ori** ({TAG}), an alpha-stage statically-typed, expression-based programming language with HM type inference, ARC memory management, and capability-based effects. The audience is developers and language enthusiasts following the project.
148+
149+
Write detailed, informative release notes in markdown. Do NOT wrap in \`\`\`markdown fences.
150+
151+
## Format
135152
136-
Rules:
137-
- Group changes into sections: **Features**, **Bug Fixes**, **Improvements**, **Internal** (only if meaningful)
138-
- Omit empty sections
153+
Start with a 1-2 sentence summary blurb describing the theme of this release.
154+
155+
Then group changes into sections (omit empty ones):
156+
- **Features** — new user-facing capabilities
157+
- **Bug Fixes** — corrected behavior
158+
- **Improvements** — enhancements to existing features, performance, error messages
159+
- **Compiler Internals** — refactoring, architecture changes, code quality (always include if applicable — compiler development IS the product at alpha stage)
160+
161+
For each bullet:
162+
- **Bold title** followed by 1-2 sentences explaining what changed and why it matters
139163
- Use past tense ("Added", "Fixed", "Improved")
140-
- Keep each bullet to one line
141-
- Skip merge commits and CI-only changes
142-
- If there are no user-facing changes, just say "Internal improvements and maintenance."
143-
- Do NOT wrap in \`\`\`markdown fences
164+
- Reference affected areas (e.g., type checker, evaluator, LLVM codegen, parser)
144165
145-
Commit log ({PREV_TAG}..{TAG}):
146-
{COMMIT_LOG}
166+
## Rules
167+
- The PR descriptions are your PRIMARY source — they contain human-written summaries of what changed and why
168+
- The commit log is supplementary — use it to catch anything the PRs missed
169+
- Never say "Internal improvements and maintenance" — every change gets a meaningful description
170+
- Skip "nightly" automation PRs — focus on substantive changes
171+
- Do not reproduce test plan checklists — focus on what changed, not how it was tested
172+
173+
## Input
147174
148-
Diff stats: {DIFF_STAT}"""
175+
Pull request descriptions (primary source):
176+
{PR_BODIES}
177+
178+
Commit log ({PREV_TAG}..{TAG}):
179+
{COMMIT_LOG}"""
149180
150181
async def main():
151182
from copilot import CopilotClient, PermissionHandler
@@ -187,8 +218,29 @@ jobs:
187218
asyncio.run(main())
188219
except Exception as e:
189220
print(f"::warning::AI release notes failed ({e}), falling back to commit log")
221+
# Categorize commits by conventional commit prefix
222+
sections = {"Features": [], "Bug Fixes": [], "Improvements": [], "Other": []}
223+
for line in COMMIT_LOG.strip().split("\n"):
224+
line = line.strip()
225+
if not line or not line.startswith("- "):
226+
continue
227+
subject = line[2:] # strip "- " prefix
228+
if subject.startswith("feat"):
229+
sections["Features"].append(line)
230+
elif subject.startswith("fix"):
231+
sections["Bug Fixes"].append(line)
232+
elif subject.startswith("refactor") or subject.startswith("perf"):
233+
sections["Improvements"].append(line)
234+
else:
235+
sections["Other"].append(line)
236+
body = ""
237+
for section, items in sections.items():
238+
if items:
239+
body += f"## {section}\n\n" + "\n".join(items) + "\n\n"
240+
if not body.strip():
241+
body = "## Changes\n\n" + COMMIT_LOG
190242
with open("/tmp/release-notes.txt", "w") as f:
191-
f.write(f"## Changes\n\n{COMMIT_LOG}")
243+
f.write(body.strip())
192244
PYEOF
193245
194246
- name: Create release

compiler/ori_eval/src/interpreter/can_eval/mod.rs

Lines changed: 3 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
mod control_flow;
1919
mod function_exp;
2020
mod operators;
21+
mod trace;
2122

2223
use ori_ir::canon::{CanExpr, CanId, CanRange, CanonResult};
2324
use ori_ir::{Name, Span};
24-
use ori_patterns::{ControlAction, EvalError, EvalResult, TraceEntryData, Value};
25+
use ori_patterns::{ControlAction, EvalError, EvalResult, Value};
2526
use ori_stack::ensure_sufficient_stack;
2627
use smallvec::SmallVec;
2728

@@ -64,86 +65,6 @@ impl Interpreter<'_> {
6465
self.canon_ref().arena.span(can_id)
6566
}
6667

67-
/// Inject a trace entry into an error value at a `?` operator site.
68-
///
69-
/// If the value is `Value::Err(Value::Error(...))`, appends a `TraceEntryData`
70-
/// recording the current function name and source location. Non-error values
71-
/// are returned unchanged.
72-
///
73-
/// Uses `Heap::make_mut` for copy-on-write: when the error is uniquely owned
74-
/// (common case — errors propagate linearly through `?`), the trace entry
75-
/// is appended in place with no cloning.
76-
fn inject_trace_entry(&self, mut value: Value, can_id: CanId) -> Value {
77-
// Guard: only Err(Error(...)) values carry traces
78-
if !matches!(&value, Value::Err(inner) if matches!(&**inner, Value::Error(_))) {
79-
return value;
80-
}
81-
82-
// Build the function name from the call stack
83-
let function_name = self.call_stack.current_frame().map_or_else(
84-
|| "<top-level>".to_string(),
85-
|f| self.interner.lookup(f.name).to_string(),
86-
);
87-
88-
// Compute line/column from span byte offset
89-
let span = self.can_span(can_id);
90-
let (line, column) = self.line_col_from_offset(span.start);
91-
92-
let file = self
93-
.source_file_path
94-
.as_deref()
95-
.cloned()
96-
.unwrap_or_else(|| "<unknown>".to_string());
97-
98-
let entry = TraceEntryData {
99-
function: function_name,
100-
file,
101-
line,
102-
column,
103-
};
104-
105-
// Copy-on-write through two Heap layers: Err(Heap<Value>) → Error(Heap<ErrorValue>)
106-
if let Value::Err(ref mut outer) = value {
107-
if let Value::Error(ref mut ev_heap) = *outer.make_mut() {
108-
ev_heap.make_mut().push_trace(entry);
109-
}
110-
}
111-
value
112-
}
113-
114-
/// Compute 1-based line and column from a byte offset in the source text.
115-
///
116-
/// Counts newlines in `source_text[..offset]` to determine line number,
117-
/// then computes column from the last newline position. If no source text
118-
/// is available, returns `(0, 0)`.
119-
#[expect(
120-
clippy::cast_possible_truncation,
121-
reason = "u32↔usize: source offsets and line/column numbers fit in u32"
122-
)]
123-
#[expect(
124-
clippy::arithmetic_side_effects,
125-
reason = "column = (end - last_newline) + 1: last_newline ≤ end by construction"
126-
)]
127-
fn line_col_from_offset(&self, offset: u32) -> (u32, u32) {
128-
let Some(src) = &self.source_text else {
129-
return (0, 0);
130-
};
131-
let offset = offset as usize;
132-
let bytes = src.as_bytes();
133-
let end = offset.min(bytes.len());
134-
135-
let mut line: u32 = 1;
136-
let mut last_newline: usize = 0;
137-
for (i, &b) in bytes[..end].iter().enumerate() {
138-
if b == b'\n' {
139-
line = line.wrapping_add(1);
140-
last_newline = i.wrapping_add(1);
141-
}
142-
}
143-
let column = (end - last_newline) as u32 + 1;
144-
(line, column)
145-
}
146-
14768
/// Evaluate a list of canonical expressions from a `CanRange`.
14869
fn eval_can_expr_list(&mut self, range: CanRange) -> Result<Vec<Value>, ControlAction> {
14970
let ids: SmallVec<[CanId; 8]> =
@@ -284,7 +205,7 @@ impl Interpreter<'_> {
284205
let value = self.eval_can(receiver)?;
285206

286207
// Built-in types: fast path with # (hash length) support
287-
if super::is_builtin_indexable(&value) {
208+
if super::operator_dispatch::is_builtin_indexable(&value) {
288209
let length = expr::get_collection_length(&value)
289210
.map_err(|e| Self::attach_span(e.into(), span))?;
290211
let idx = self.eval_can_with_hash_length(index, length)?;

compiler/ori_eval/src/interpreter/can_eval/operators.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,17 @@ impl Interpreter<'_> {
7272
let right_val = self.eval_can(right)?;
7373

7474
// Primitive types use direct evaluation
75-
if super::super::is_primitive_value(&left_val)
76-
&& super::super::is_primitive_value(&right_val)
75+
if super::super::operator_dispatch::is_primitive_value(&left_val)
76+
&& super::super::operator_dispatch::is_primitive_value(&right_val)
7777
{
7878
return evaluate_binary(left_val, right_val, op)
7979
.map_err(|e| Self::attach_span(e, span));
8080
}
8181

8282
// User-defined types: dispatch through method system
83-
if let Some(method) = super::super::binary_op_to_method(op, self.op_names) {
83+
if let Some(method) =
84+
super::super::operator_dispatch::binary_op_to_method(op, self.op_names)
85+
{
8486
return self.eval_method_call(left_val, method, vec![right_val]);
8587
}
8688

@@ -97,11 +99,12 @@ impl Interpreter<'_> {
9799
let value = self.eval_can(operand)?;
98100
let span = self.can_span(expr_id);
99101

100-
if super::super::is_primitive_value(&value) {
102+
if super::super::operator_dispatch::is_primitive_value(&value) {
101103
return evaluate_unary(value, op).map_err(|e| Self::attach_span(e, span));
102104
}
103105

104-
if let Some(method) = super::super::unary_op_to_method(op, self.op_names) {
106+
if let Some(method) = super::super::operator_dispatch::unary_op_to_method(op, self.op_names)
107+
{
105108
return self.eval_method_call(value, method, vec![]);
106109
}
107110

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! Error trace injection for the `?` operator.
2+
//!
3+
//! When `?` propagates an `Err(Error(...))`, these helpers append a
4+
//! `TraceEntryData` with the current source location (function, file,
5+
//! line, column). Extracted from `can_eval/mod.rs` to keep the main
6+
//! dispatch module focused.
7+
8+
use ori_ir::canon::CanId;
9+
use ori_patterns::{TraceEntryData, Value};
10+
11+
use super::Interpreter;
12+
13+
impl Interpreter<'_> {
14+
/// Inject a trace entry into an error value at a `?` operator site.
15+
///
16+
/// If the value is `Value::Err(Value::Error(...))`, appends a `TraceEntryData`
17+
/// recording the current function name and source location. Non-error values
18+
/// are returned unchanged.
19+
///
20+
/// Uses `Heap::make_mut` for copy-on-write: when the error is uniquely owned
21+
/// (common case — errors propagate linearly through `?`), the trace entry
22+
/// is appended in place with no cloning.
23+
pub(super) fn inject_trace_entry(&self, mut value: Value, can_id: CanId) -> Value {
24+
// Guard: only Err(Error(...)) values carry traces
25+
if !matches!(&value, Value::Err(inner) if matches!(&**inner, Value::Error(_))) {
26+
return value;
27+
}
28+
29+
// Build the function name from the call stack
30+
let function_name = self.call_stack.current_frame().map_or_else(
31+
|| "<top-level>".to_string(),
32+
|f| self.interner.lookup(f.name).to_string(),
33+
);
34+
35+
// Compute line/column from span byte offset
36+
let span = self.can_span(can_id);
37+
let (line, column) = self.line_col_from_offset(span.start);
38+
39+
let file = self
40+
.source_file_path
41+
.as_deref()
42+
.cloned()
43+
.unwrap_or_else(|| "<unknown>".to_string());
44+
45+
let entry = TraceEntryData {
46+
function: function_name,
47+
file,
48+
line,
49+
column,
50+
};
51+
52+
// Copy-on-write through two Heap layers: Err(Heap<Value>) → Error(Heap<ErrorValue>)
53+
if let Value::Err(ref mut outer) = value {
54+
if let Value::Error(ref mut ev_heap) = *outer.make_mut() {
55+
ev_heap.make_mut().push_trace(entry);
56+
}
57+
}
58+
value
59+
}
60+
61+
/// Compute 1-based line and column from a byte offset in the source text.
62+
///
63+
/// Counts newlines in `source_text[..offset]` to determine line number,
64+
/// then computes column from the last newline position. If no source text
65+
/// is available, returns `(0, 0)`.
66+
#[expect(
67+
clippy::cast_possible_truncation,
68+
reason = "u32↔usize: source offsets and line/column numbers fit in u32"
69+
)]
70+
#[expect(
71+
clippy::arithmetic_side_effects,
72+
reason = "column = (end - last_newline) + 1: last_newline ≤ end by construction"
73+
)]
74+
pub(super) fn line_col_from_offset(&self, offset: u32) -> (u32, u32) {
75+
let Some(src) = &self.source_text else {
76+
return (0, 0);
77+
};
78+
let offset = offset as usize;
79+
let bytes = src.as_bytes();
80+
let end = offset.min(bytes.len());
81+
82+
let mut line: u32 = 1;
83+
let mut last_newline: usize = 0;
84+
for (i, &b) in bytes[..end].iter().enumerate() {
85+
if b == b'\n' {
86+
line = line.wrapping_add(1);
87+
last_newline = i.wrapping_add(1);
88+
}
89+
}
90+
let column = (end - last_newline) as u32 + 1;
91+
(line, column)
92+
}
93+
}

compiler/ori_eval/src/interpreter/method_dispatch/iterator/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
mod consumers;
1313
mod next;
14+
mod stateful;
1415

1516
use ori_patterns::IteratorValue;
1617

0 commit comments

Comments
 (0)