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
66 changes: 66 additions & 0 deletions .github/scripts/__tests__/issue_scope_parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,69 @@ test('handles nested bullets', () => {
].join('\n')
);
});

test('preserves code blocks but does not add checkboxes inside them', () => {
const issue = [
'## Tasks',
'- Implement the feature',
'- Add documentation',
'',
'```python',
'def example():',
' # Code example with bullet that should NOT get checkbox',
' tasks = ["- item one", "- item two"]',
'```',
'',
'## Acceptance Criteria',
'- Feature works correctly',
].join('\n');

const result = extractScopeTasksAcceptanceSections(issue);

// Real tasks should get checkboxes
assert.ok(result.includes('- [ ] Implement the feature'));
assert.ok(result.includes('- [ ] Add documentation'));
assert.ok(result.includes('- [ ] Feature works correctly'));

// Code block should be preserved but items inside should NOT have checkboxes
assert.ok(result.includes('```python'));
assert.ok(result.includes('def example():'));
// The bullet inside the code should NOT have [ ] added
assert.ok(!result.includes('- [ ] item one'));
});

test('handles multiple code blocks - preserves content without adding checkboxes', () => {
const issue = [
'## Tasks',
'- Real task one',
'',
'```yaml',
'tasks:',
' - YAML example task',
'```',
'',
'- Real task two',
'',
'~~~markdown',
'- Markdown example in tilde fence',
'~~~',
'',
'## Acceptance Criteria',
'- Done',
].join('\n');

const result = extractScopeTasksAcceptanceSections(issue);

// Real tasks outside code blocks get checkboxes
assert.ok(result.includes('- [ ] Real task one'));
assert.ok(result.includes('- [ ] Real task two'));
assert.ok(result.includes('- [ ] Done'));

// Code blocks are preserved
assert.ok(result.includes('```yaml'));
assert.ok(result.includes('~~~markdown'));

// But items inside code blocks do NOT get checkboxes added
assert.ok(result.includes(' - YAML example task')); // preserved as-is
assert.ok(!result.includes('- [ ] YAML example task')); // no checkbox added
});
101 changes: 86 additions & 15 deletions .github/scripts/issue_scope_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
const normalizeNewlines = (value) => String(value || '').replace(/\r\n/g, '\n');
const stripBlockquotePrefixes = (value) =>
String(value || '').replace(/^[ \t]*>+[ \t]?/gm, '');

/**
* Check if a line is a code fence delimiter (``` or ~~~).
* Used to track code block boundaries when processing content.
*/
const isCodeFenceLine = (line) => /^(`{3,}|~{3,})/.test(line.trim());

const LIST_ITEM_REGEX = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;

const SECTION_DEFS = [
Expand Down Expand Up @@ -84,7 +91,20 @@ function normaliseChecklist(content) {

const lines = raw.split('\n');
let mutated = false;
let insideCodeBlock = false;

const updated = lines.map((line) => {
// Track code fence boundaries - don't add checkboxes inside code blocks
if (isCodeFenceLine(line)) {
insideCodeBlock = !insideCodeBlock;
return line;
}

// Skip checkbox normalization for lines inside code blocks
if (insideCodeBlock) {
return line;
}

const match = line.match(LIST_ITEM_REGEX);
if (!match) {
return line;
Expand Down Expand Up @@ -155,6 +175,7 @@ function isExplicitHeadingLine(rawLine) {
function extractListBlocks(lines) {
const blocks = [];
let current = [];
let insideCodeBlock = false;

const flush = () => {
if (current.length) {
Expand All @@ -167,6 +188,24 @@ function extractListBlocks(lines) {
};

for (const line of lines) {
// Track code fence boundaries
if (isCodeFenceLine(line)) {
insideCodeBlock = !insideCodeBlock;
// Include code fence lines in current block if we're building one
if (current.length) {
current.push(line);
}
continue;
}

// Lines inside code blocks are treated as continuation (don't break the block)
if (insideCodeBlock) {
if (current.length) {
current.push(line);
}
continue;
}

if (LIST_ITEM_REGEX.test(line)) {
current.push(line);
continue;
Expand All @@ -190,6 +229,7 @@ function extractListBlocksWithOffsets(lines) {
let blockStart = null;
let blockEnd = null;
let offset = 0;
let insideCodeBlock = false;

const flush = () => {
if (!current.length) {
Expand All @@ -205,6 +245,27 @@ function extractListBlocksWithOffsets(lines) {
};

for (const line of lines) {
// Track code fence boundaries
if (isCodeFenceLine(line)) {
insideCodeBlock = !insideCodeBlock;
if (current.length) {
current.push(line);
blockEnd = offset + line.length;
}
offset += line.length + 1;
continue;
}

// Lines inside code blocks continue the current block
if (insideCodeBlock) {
if (current.length) {
current.push(line);
blockEnd = offset + line.length;
}
offset += line.length + 1;
continue;
}

const isList = LIST_ITEM_REGEX.test(line);
if (isList) {
if (!current.length) {
Expand Down Expand Up @@ -377,23 +438,33 @@ function collectSections(source) {
const lines = segment.split('\n');
const listBlocks = extractListBlocksWithOffsets(lines);
let offset = 0;
let insideCodeBlock = false;
for (const line of lines) {
const matchedLabel = extractHeadingLabel(line);
if (matchedLabel) {
const title = matchedLabel.toLowerCase();
if (aliasLookup[title]) {
const section = aliasLookup[title];
headings.push({
title: section.key,
label: section.label,
index: offset,
length: line.length,
matchedLabel,
});
}
// Track code fence boundaries - skip heading detection inside code blocks
if (isCodeFenceLine(line)) {
insideCodeBlock = !insideCodeBlock;
offset += line.length + 1;
continue;
}
if (isExplicitHeadingLine(line)) {
allHeadings.push({ index: offset, length: line.length });

if (!insideCodeBlock) {
const matchedLabel = extractHeadingLabel(line);
if (matchedLabel) {
const title = matchedLabel.toLowerCase();
if (aliasLookup[title]) {
const section = aliasLookup[title];
headings.push({
title: section.key,
label: section.label,
index: offset,
length: line.length,
matchedLabel,
});
}
}
if (isExplicitHeadingLine(line)) {
allHeadings.push({ index: offset, length: line.length });
}
}
offset += line.length + 1;
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/langchain/capability_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def _is_multi_action_task(task: str) -> bool:
return True
if any(sep in lowered for sep in (" and ", " + ", " & ", " then ", "; ")):
return True
return bool("," in task or "/" in task or re.search(r"\s\+\s", lowered))
return bool("," in task or " / " in task or re.search(r"\s\+\s", lowered))


def _requires_admin_access(task: str) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion scripts/langchain/issue_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ def _is_large_task(task: str) -> bool:
return True
if any(sep in lowered for sep in (" and ", " + ", " & ", " then ", "; ")):
return True
return bool(re.search(r"\s\+\s", lowered) or ", " in task or "/" in task)
return bool(re.search(r"\s\+\s", lowered) or ", " in task or " / " in task)
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The capability_check.py module contains a similar function _is_multi_action_task (line 180) that also checks for "/" in task. This will have the same issue as the functions fixed in this PR - it will incorrectly flag compound words like "additions/removals" and paths like "src/utils" as multi-action tasks. Consider updating that function to check for " / " in task instead for consistency with this fix.

Copilot uses AI. Check for mistakes.


def _detect_task_splitting(tasks: list[str], *, use_llm: bool = False) -> list[dict[str, Any]]:
Expand Down
6 changes: 4 additions & 2 deletions scripts/langchain/task_decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,10 @@ def _split_task_parts(task: str) -> list[str]:
parts = [part.strip() for part in task.split(";") if part.strip()]
elif ", " in task:
parts = [part.strip() for part in task.split(",") if part.strip()]
elif " / " in task or "/" in task:
parts = [part.strip() for part in re.split(r"\s*/\s*", task) if part.strip()]
elif " / " in task:
# Only split on spaced slashes to avoid splitting compound words
# like "additions/removals" or paths like "src/utils"
parts = [part.strip() for part in task.split(" / ") if part.strip()]
else:
parts = [task]
return [part for part in parts if part]
Expand Down
32 changes: 32 additions & 0 deletions tests/scripts/test_capability_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,35 @@ def test_prompt_includes_output_format(self) -> None:
assert "partial_tasks" in AGENT_CAPABILITY_CHECK_PROMPT
assert "blocked_tasks" in AGENT_CAPABILITY_CHECK_PROMPT
assert "recommendation" in AGENT_CAPABILITY_CHECK_PROMPT


class TestIsMultiActionTask:
"""Tests for _is_multi_action_task function."""

def test_spaced_slashes_detected_as_multi_action(self) -> None:
"""Spaced slashes (alternatives) should be detected as multi-action."""
from scripts.langchain.capability_check import _is_multi_action_task

assert _is_multi_action_task("Option A / Option B")
assert _is_multi_action_task("Run lint / format / typecheck")
assert _is_multi_action_task("Create issue / PR for changes")

def test_compound_slashes_not_detected_as_multi_action(self) -> None:
"""Compound words with unspaced slashes should NOT be flagged."""
from scripts.langchain.capability_check import _is_multi_action_task

# Compound words - NOT multi-action just due to slash
assert not _is_multi_action_task("Color-coded additions/removals")
assert not _is_multi_action_task("Update src/utils module")
assert not _is_multi_action_task("Fix path/to/file.py")
assert not _is_multi_action_task("Handle read/write operations")

def test_other_separators_detected(self) -> None:
"""Other separators should still be detected."""
from scripts.langchain.capability_check import _is_multi_action_task

assert _is_multi_action_task("Do this and do that")
assert _is_multi_action_task("Task A, Task B")
assert _is_multi_action_task("First + Second")
assert _is_multi_action_task("Step 1; Step 2")
assert _is_multi_action_task("Do X then Y")
19 changes: 19 additions & 0 deletions tests/scripts/test_issue_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,22 @@ def test_extract_json_payload_with_wrapped_text() -> None:
def test_formatted_output_validates_sections() -> None:
assert issue_optimizer._formatted_output_valid("## Tasks\n- x\n## Acceptance Criteria\n- y")
assert not issue_optimizer._formatted_output_valid("## Tasks only")


def test_is_large_task_ignores_compound_slashes() -> None:
"""_is_large_task should NOT flag compound words with unspaced slashes."""
# Compound words should NOT be flagged as large
assert not issue_optimizer._is_large_task("Color-coded additions/removals")
assert not issue_optimizer._is_large_task("Update src/utils module")

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line has trailing whitespace. Consider removing it to maintain consistent code formatting.

Copilot uses AI. Check for mistakes.
# But spaced slashes still indicate alternatives (large task)
assert issue_optimizer._is_large_task("Option A / Option B")
assert issue_optimizer._is_large_task("Run lint / format / typecheck")


def test_is_large_task_detects_other_separators() -> None:
"""_is_large_task should still detect other multi-action patterns."""
assert issue_optimizer._is_large_task("Update docs and add tests")
assert issue_optimizer._is_large_task("Lint, format, typecheck")
assert issue_optimizer._is_large_task("Fix bug; write tests")
assert issue_optimizer._is_large_task("Run A + B")
22 changes: 19 additions & 3 deletions tests/scripts/test_task_decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,26 @@ def test_split_task_parts_with_with_list() -> None:
assert all(part.startswith("Build user dashboard with") for part in parts)


def test_split_task_parts_with_slash() -> None:
"""_split_task_parts splits on slashes."""
parts = task_decomposer._split_task_parts("config/settings")
def test_split_task_parts_with_spaced_slash() -> None:
"""_split_task_parts splits on spaced slashes ' / '."""
parts = task_decomposer._split_task_parts("option A / option B")
assert len(parts) == 2
assert "option A" in parts
assert "option B" in parts


def test_split_task_parts_preserves_compound_words() -> None:
"""_split_task_parts does NOT split compound words with unspaced slashes."""
# Compound words like "additions/removals" should NOT be split
parts = task_decomposer._split_task_parts("config/settings")
assert parts == ["config/settings"]

parts = task_decomposer._split_task_parts("additions/removals")
assert parts == ["additions/removals"]

# Paths like "src/utils/helpers" should NOT be split
parts = task_decomposer._split_task_parts("Update src/utils/helpers module")
assert parts == ["Update src/utils/helpers module"]


def test_split_task_parts_single_task() -> None:
Expand Down
Loading