Skip to content

Commit 809a866

Browse files
committed
fix(one-sentence-per-line): do not treat ellipsis (...) as sentence end mid-sentence
Treat ... as sentence boundary only when the next word is capitalized (First... Then). Ellipsis in the middle of one sentence (config... supplied) no longer triggers. Add processSentenceEnd helper for complexity; add trailing-space and ellipsis tests; add md_test_files fixtures and expected_errors.
1 parent b9949ac commit 809a866

7 files changed

Lines changed: 87 additions & 12 deletions

File tree

markdownlint-rules/one-sentence-per-line.js

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ function isSentenceEndChar(ch) {
7070
return ch === "." || ch === "?" || ch === "!";
7171
}
7272

73+
/** True when the period at i is part of an ellipsis (two or more consecutive dots). */
74+
function isPartOfEllipsis(scanned, i) {
75+
if (scanned[i] !== ".") return false;
76+
return (i > 0 && scanned[i - 1] === ".") || (i + 1 < scanned.length && scanned[i + 1] === ".");
77+
}
78+
79+
/** Index of the last character of a run of dots starting at i (i is a dot). */
80+
function endOfEllipsisRun(scanned, i) {
81+
let end = i;
82+
while (end < scanned.length && scanned[end] === ".") end++;
83+
return end;
84+
}
85+
86+
/** True when the content after the ellipsis (after optional space) starts with an uppercase letter. */
87+
function capitalAfterEllipsis(scanned, lastDotIndex) {
88+
const spaceStart = skipQuotesThenSpace(scanned, lastDotIndex);
89+
if (spaceStart === null) return false;
90+
const after = spaceStartAndNextWordPos(scanned, spaceStart);
91+
if (!after) return false;
92+
return /[A-Z]/.test(scanned[after.j]);
93+
}
94+
7395
function skipQuotesAndSpaces(scanned, j) {
7496
let pos = j;
7597
while (pos < scanned.length && (scanned[pos] === "*" || scanned[pos] === "_")) {
@@ -203,6 +225,33 @@ function shouldSkipForSentenceBoundary(ctx) {
203225
return false;
204226
}
205227

228+
/**
229+
* Process a potential sentence-end at i; return next index and optional boundary.
230+
* @returns {{ nextI: number, boundary: number|null }}
231+
*/
232+
function processSentenceEnd(scanned, i, abbreviations) {
233+
const ch = scanned[i];
234+
if (!isSentenceEndChar(ch)) return { nextI: i, boundary: null };
235+
if (ch === "." && isPartOfEllipsis(scanned, i)) {
236+
const end = endOfEllipsisRun(scanned, i);
237+
const lastDotIndex = end - 1;
238+
if (capitalAfterEllipsis(scanned, lastDotIndex)) {
239+
const boundary = trySentenceBoundary(scanned, lastDotIndex, abbreviations);
240+
if (boundary !== null) {
241+
const { nextPos: j } = skipQuotesAndSpaces(scanned, lastDotIndex + 1);
242+
return { nextI: j - 1, boundary };
243+
}
244+
}
245+
return { nextI: end - 1, boundary: null };
246+
}
247+
const boundary = trySentenceBoundary(scanned, i, abbreviations);
248+
if (boundary !== null) {
249+
const { nextPos: j } = skipQuotesAndSpaces(scanned, i + 1);
250+
return { nextI: j - 1, boundary };
251+
}
252+
return { nextI: i, boundary: null };
253+
}
254+
206255
/**
207256
* Find all sentence boundary indices (space before each sentence after the first).
208257
* @param {string} content - Prose content (no list marker)
@@ -237,17 +286,9 @@ function getAllSentenceBoundaries(content, opts) {
237286
i++;
238287
continue;
239288
}
240-
if (!isSentenceEndChar(ch)) {
241-
i++;
242-
continue;
243-
}
244-
const boundary = trySentenceBoundary(scanned, i, abbreviations);
245-
if (boundary !== null) {
246-
boundaries.push(boundary);
247-
const { nextPos: j } = skipQuotesAndSpaces(scanned, i + 1);
248-
i = j - 1;
249-
}
250-
i++;
289+
const { nextI, boundary } = processSentenceEnd(scanned, i, abbreviations);
290+
if (boundary !== null) boundaries.push(boundary);
291+
i = nextI + 1;
251292
}
252293
return boundaries;
253294
}

md_test_files/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ Each item: **filename** - custom rule(s) that fail; sub-bullet - what the fixtur
5555
- **negative_no_tables.md** - no-tables
5656
- GFM table reported; with default config (convert-to none) short message only; suppress via `<!-- no-tables allow -->`.
5757
- **negative_one_sentence_per_line.md** - one-sentence-per-line
58-
- Prose and list lines with multiple sentences (paragraph, bullet, numbered, nested); line with abbreviation (e.g.) not reported.
58+
- Prose and list lines with multiple sentences (paragraph, bullet, numbered, nested); line with abbreviation (e.g.) not reported; ellipsis then new sentence (First... Then) reported.
59+
- **positive_general.md** has a line with ellipsis in the middle of one sentence (not reported).
5960

6061
Note: some negative fixtures intentionally trigger built-in markdownlint rules in addition to custom rules (e.g. MD031/MD032/MD033), so the test suite can assert multiple errors on specific lines.
6162

md_test_files/expected_errors.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ negative_one_sentence_per_line.md:
299299
- line: 22
300300
rule: one-sentence-per-line
301301
message_contains: one sentence per line
302+
- line: 24
303+
rule: one-sentence-per-line
304+
message_contains: one sentence per line
302305

303306
negative_no_tables.md:
304307
errors:

md_test_files/negative_one_sentence_per_line.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ This should not catch; e.g.: here is some text.
2020
- **No command or path allowlists inside the container.** The sandbox agent runs in an already-sandboxed environment (the container).
2121

2222
This is the first sentence. **Bolded text** rest of the sentence.
23+
24+
First... Then the next sentence.

md_test_files/positive_general.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ It is intended to be linted explicitly via `npx markdownlint-cli2 md_test_files/
3939

4040
This paragraph has one sentence.
4141
This is another sentence on its own line.
42+
Configuration options... are supplied by the orchestrator in the **start bundle**.
4243

4344
## Lists
4445

test-scripts/test_fix_one_sentence_per_line.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ def test_no_split_within_filenames(self) -> None:
173173
msg = f"no one-sentence-per-line errors expected: {proc.stderr}"
174174
self.assertEqual(proc.returncode, 0, msg)
175175

176+
def test_no_split_on_ellipsis_in_sentence(self) -> None:
177+
"""Ellipsis (...) in the middle of a sentence does not trigger one-sentence-per-line."""
178+
content = """# Doc
179+
180+
Inference connectivity configuration... supplied by the orchestrator in the \
181+
**PMA managed service start bundle**.
182+
"""
183+
with tempfile.TemporaryDirectory(prefix="fix_one_sentence_") as tmp:
184+
path = Path(tmp) / "test.md"
185+
path.write_text(content, encoding="utf-8")
186+
overrides = {"default": False, RULE: True}
187+
proc = _run_markdownlint(path, fix=False, config_overrides=overrides)
188+
msg = f"no {RULE} errors expected for ellipsis in sentence: {proc.stderr}"
189+
self.assertEqual(proc.returncode, 0, msg)
190+
176191
def test_no_split_on_identifiers_with_periods(self) -> None:
177192
"""Periods in identifiers (e.g. CYNAI.PROJCT) with no space after do not trigger split."""
178193
content = """# Doc

test/markdownlint-rules/one-sentence-per-line.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,12 @@ describe("one-sentence-per-line", () => {
309309
assert.strictEqual(errors.length, 0);
310310
});
311311

312+
it("does not split on ellipsis within a single sentence", () => {
313+
const lines = ["Inference connectivity configuration... supplied by the orchestrator in the **PMA managed service start bundle**."];
314+
const errors = runRule(rule, lines);
315+
assert.strictEqual(errors.length, 0, "ellipsis in middle of sentence should not trigger");
316+
});
317+
312318
it("splits when ellipsis is not sentence end and real sentence end follows", () => {
313319
const lines = ["First... Then the next sentence."];
314320
const errors = runRule(rule, lines);
@@ -339,6 +345,12 @@ describe("one-sentence-per-line", () => {
339345
assert.strictEqual(errors.length, 0);
340346
});
341347

348+
it("does not split when period is followed only by trailing space (no next word)", () => {
349+
const lines = ["First. "];
350+
const errors = runRule(rule, lines);
351+
assert.strictEqual(errors.length, 0, "trailing space after period with no second sentence");
352+
});
353+
342354
it("version number 1. not treated as sentence end", () => {
343355
const lines = ["Use version 1. It is stable."];
344356
const errors = runRule(rule, lines);

0 commit comments

Comments
 (0)