markback 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. markback-0.1.2/.claude/settings.local.json +13 -0
  2. {markback-0.1.1 → markback-0.1.2}/PKG-INFO +1 -1
  3. {markback-0.1.1 → markback-0.1.2}/SPEC.md +26 -2
  4. {markback-0.1.1 → markback-0.1.2}/markback/linter.py +39 -0
  5. {markback-0.1.1 → markback-0.1.2}/markback/types.py +44 -4
  6. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/src/linter.ts +43 -0
  7. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/src/types.ts +50 -7
  8. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/test/linter.test.js +54 -0
  9. {markback-0.1.1 → markback-0.1.2}/pyproject.toml +1 -1
  10. {markback-0.1.1 → markback-0.1.2}/tests/test_linter.py +81 -0
  11. markback-0.1.1/.claude/settings.local.json +0 -9
  12. {markback-0.1.1 → markback-0.1.2}/.gitignore +0 -0
  13. {markback-0.1.1 → markback-0.1.2}/.ishipped/card.md +0 -0
  14. {markback-0.1.1 → markback-0.1.2}/IMPLEMENTATION_NOTES.md +0 -0
  15. {markback-0.1.1 → markback-0.1.2}/LICENSE +0 -0
  16. {markback-0.1.1 → markback-0.1.2}/README.md +0 -0
  17. {markback-0.1.1 → markback-0.1.2}/markback/__init__.py +0 -0
  18. {markback-0.1.1 → markback-0.1.2}/markback/cli.py +0 -0
  19. {markback-0.1.1 → markback-0.1.2}/markback/config.py +0 -0
  20. {markback-0.1.1 → markback-0.1.2}/markback/llm.py +0 -0
  21. {markback-0.1.1 → markback-0.1.2}/markback/parser.py +0 -0
  22. {markback-0.1.1 → markback-0.1.2}/markback/workflow.py +0 -0
  23. {markback-0.1.1 → markback-0.1.2}/markback/writer.py +0 -0
  24. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/LICENSE +0 -0
  25. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/README.md +0 -0
  26. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/package-lock.json +0 -0
  27. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/package.json +0 -0
  28. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/src/index.ts +0 -0
  29. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/src/parser.ts +0 -0
  30. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/src/writer.ts +0 -0
  31. {markback-0.1.1 → markback-0.1.2}/packages/markbackjs/tsconfig.json +0 -0
  32. {markback-0.1.1 → markback-0.1.2}/tests/__init__.py +0 -0
  33. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/compact_source.mb +0 -0
  34. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/errors/content_with_source.mb +0 -0
  35. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/errors/empty_feedback.mb +0 -0
  36. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/errors/malformed_uri.mb +0 -0
  37. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/errors/missing_feedback.mb +0 -0
  38. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/errors/multiple_feedback.mb +0 -0
  39. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/essay.label.txt +0 -0
  40. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/essay.txt +0 -0
  41. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/external_source.mb +0 -0
  42. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/freeform_feedback.mb +0 -0
  43. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/json_feedback.mb +0 -0
  44. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/label_list.mb +0 -0
  45. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/minimal.mb +0 -0
  46. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/multi_record.mb +0 -0
  47. {markback-0.1.1 → markback-0.1.2}/tests/fixtures/with_uri.mb +0 -0
  48. {markback-0.1.1 → markback-0.1.2}/tests/test_cli.py +0 -0
  49. {markback-0.1.1 → markback-0.1.2}/tests/test_config.py +0 -0
  50. {markback-0.1.1 → markback-0.1.2}/tests/test_parser.py +0 -0
  51. {markback-0.1.1 → markback-0.1.2}/tests/test_types.py +0 -0
  52. {markback-0.1.1 → markback-0.1.2}/tests/test_workflow.py +0 -0
  53. {markback-0.1.1 → markback-0.1.2}/tests/test_writer.py +0 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python -m pytest:*)",
5
+ "Bash(npm test:*)",
6
+ "Bash(npm install)",
7
+ "Bash(npm run build:*)",
8
+ "Bash(echo:*)",
9
+ "Bash(python -m markback lint:*)",
10
+ "Bash(python:*)"
11
+ ]
12
+ }
13
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markback
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A compact, human-writable format for storing content paired with feedback/labels
5
5
  Project-URL: Homepage, https://github.com/dandriscoll/markback
6
6
  Project-URL: Repository, https://github.com/dandriscoll/markback
@@ -127,6 +127,27 @@ References an item that precedes the source material. For example, if the source
127
127
  - `@prior` does not affect content handling (inline content or `@source` rules still apply)
128
128
  - Parsers SHOULD verify referenced files exist (warning if missing)
129
129
 
130
+ #### 3.1.4 Line Range Specification
131
+
132
+ Both `@source` and `@prior` headers support optional line range specifications using colon notation. This allows referencing specific lines within a file.
133
+
134
+ **Syntax:** `<path-or-uri>:<start>` or `<path-or-uri>:<start>-<end>`
135
+
136
+ ```
137
+ @source ./code.py:42
138
+ @source ./code.py:42-50
139
+ @prior ./prompts/template.txt:1-20
140
+ @source https://example.com/file.txt:100-150
141
+ ```
142
+
143
+ **Rules:**
144
+ - Line numbers are 1-indexed (first line is line 1)
145
+ - Single line: `:N` references line N only
146
+ - Line range: `:N-M` references lines N through M (inclusive)
147
+ - End line must be greater than or equal to start line (E011 error otherwise)
148
+ - Line ranges are informational metadata; parsers do not validate that referenced lines exist in the file
149
+ - Windows drive letters (e.g., `C:\path`) are not confused with line ranges because scheme detection requires length > 1
150
+
130
151
  ### 3.2 Content Block
131
152
 
132
153
  Content is everything between headers and the `<<<` feedback delimiter.
@@ -575,6 +596,7 @@ Each line is classified as one of:
575
596
  | E008 | Unclosed quote in structured attribute value (only in `structured` parse mode) |
576
597
  | E009 | Empty feedback (nothing after `<<< `) |
577
598
  | E010 | Missing blank line before inline content (content starts with `@`) |
599
+ | E011 | Invalid line range (end line less than start line) |
578
600
 
579
601
  ### 7.2 Warnings (SHOULD fix)
580
602
 
@@ -863,8 +885,10 @@ feedback-content = *VCHAR ; no LF allowed
863
885
  compact-record = [uri-line] source-feedback-line
864
886
  compact-list = compact-record *(1*blank-line compact-record)
865
887
  uri-line = "@uri" SP value LF
866
- source-feedback-line = "@source" SP path SP "<<<" SP feedback-content LF
867
- path = 1*VCHAR ; ends at space before <<<
888
+ source-feedback-line = "@source" SP path-with-range SP "<<<" SP feedback-content LF
889
+ path-with-range = path [line-range] ; path with optional line range
890
+ path = 1*VCHAR ; ends at space before <<< or line-range
891
+ line-range = ":" 1*DIGIT ["-" 1*DIGIT]
868
892
 
869
893
  LOWER = %x61-7A ; a-z
870
894
  SP = %x20 ; space
@@ -137,6 +137,42 @@ def lint_prior_exists(
137
137
  return diagnostics
138
138
 
139
139
 
140
+ def lint_line_range(
141
+ record: Record,
142
+ record_idx: int,
143
+ ) -> list[Diagnostic]:
144
+ """Check if line ranges are valid (end >= start)."""
145
+ diagnostics: list[Diagnostic] = []
146
+
147
+ # Check @source line range
148
+ if record.source and record.source.start_line is not None:
149
+ if record.source.end_line is not None and record.source.end_line < record.source.start_line:
150
+ diagnostics.append(Diagnostic(
151
+ file=record._source_file,
152
+ line=record._start_line,
153
+ column=None,
154
+ severity=Severity.ERROR,
155
+ code=ErrorCode.E011,
156
+ message=f"Invalid line range in @source: end line {record.source.end_line} is less than start line {record.source.start_line}",
157
+ record_index=record_idx,
158
+ ))
159
+
160
+ # Check @prior line range
161
+ if record.prior and record.prior.start_line is not None:
162
+ if record.prior.end_line is not None and record.prior.end_line < record.prior.start_line:
163
+ diagnostics.append(Diagnostic(
164
+ file=record._source_file,
165
+ line=record._start_line,
166
+ column=None,
167
+ severity=Severity.ERROR,
168
+ code=ErrorCode.E011,
169
+ message=f"Invalid line range in @prior: end line {record.prior.end_line} is less than start line {record.prior.start_line}",
170
+ record_index=record_idx,
171
+ ))
172
+
173
+ return diagnostics
174
+
175
+
140
176
  def lint_canonical_format(
141
177
  records: list[Record],
142
178
  original_text: str,
@@ -206,6 +242,9 @@ def lint_string(
206
242
  result.diagnostics.extend(lint_source_exists(record, base_path, idx))
207
243
  result.diagnostics.extend(lint_prior_exists(record, base_path, idx))
208
244
 
245
+ # Check line range validity
246
+ result.diagnostics.extend(lint_line_range(record, idx))
247
+
209
248
  # Check canonical format
210
249
  if check_canonical and result.records and not result.has_errors:
211
250
  result.diagnostics.extend(lint_canonical_format(
@@ -1,5 +1,6 @@
1
1
  """Core types for MarkBack format."""
2
2
 
3
+ import re
3
4
  from dataclasses import dataclass, field
4
5
  from enum import Enum
5
6
  from pathlib import Path
@@ -25,6 +26,7 @@ class ErrorCode(Enum):
25
26
  E008 = "E008" # Unclosed quote in structured attribute value
26
27
  E009 = "E009" # Empty feedback (nothing after <<< )
27
28
  E010 = "E010" # Missing blank line before inline content
29
+ E011 = "E011" # Invalid line range (end < start)
28
30
 
29
31
 
30
32
  class WarningCode(Enum):
@@ -76,29 +78,67 @@ class Diagnostic:
76
78
  }
77
79
 
78
80
 
81
+ # Regex to parse line range from a path: path:start or path:start-end
82
+ _LINE_RANGE_PATTERN = re.compile(r'^(.+?):(\d+)(?:-(\d+))?$')
83
+
84
+
79
85
  @dataclass
80
86
  class SourceRef:
81
87
  """Reference to external content (file path or URI)."""
82
88
  value: str
83
89
  is_uri: bool = False
90
+ start_line: Optional[int] = None
91
+ end_line: Optional[int] = None
92
+ _path_only: str = ""
84
93
 
85
94
  def __post_init__(self):
86
- # Determine if this is a URI or file path
95
+ # Parse line range if present
96
+ self._parse_line_range()
97
+
98
+ # Determine if this is a URI or file path (using path without line range)
87
99
  if not self.is_uri:
88
- parsed = urlparse(self.value)
100
+ parsed = urlparse(self._path_only)
89
101
  # Consider it a URI if it has a scheme that's not a Windows drive letter
90
102
  self.is_uri = bool(parsed.scheme) and len(parsed.scheme) > 1
91
103
 
104
+ def _parse_line_range(self):
105
+ """Parse optional line range from value."""
106
+ match = _LINE_RANGE_PATTERN.match(self.value)
107
+ if match:
108
+ self._path_only = match.group(1)
109
+ self.start_line = int(match.group(2))
110
+ if match.group(3):
111
+ self.end_line = int(match.group(3))
112
+ else:
113
+ # Single line reference: start and end are the same
114
+ self.end_line = self.start_line
115
+ else:
116
+ self._path_only = self.value
117
+
118
+ @property
119
+ def path(self) -> str:
120
+ """Return path without line range."""
121
+ return self._path_only
122
+
123
+ @property
124
+ def line_range_str(self) -> Optional[str]:
125
+ """Return formatted line range string, or None if no range."""
126
+ if self.start_line is None:
127
+ return None
128
+ if self.start_line == self.end_line:
129
+ return f":{self.start_line}"
130
+ return f":{self.start_line}-{self.end_line}"
131
+
92
132
  def resolve(self, base_path: Optional[Path] = None) -> Path:
93
133
  """Resolve to a file path (relative paths resolved against base_path)."""
94
134
  if self.is_uri:
95
- parsed = urlparse(self.value)
135
+ parsed = urlparse(self._path_only)
96
136
  if parsed.scheme == "file":
97
137
  # file:// URI
98
138
  return Path(parsed.path)
99
139
  raise ValueError(f"Cannot resolve non-file URI to path: {self.value}")
100
140
 
101
- path = Path(self.value)
141
+ path = Path(self._path_only)
102
142
  if path.is_absolute():
103
143
  return path
104
144
  if base_path:
@@ -138,6 +138,46 @@ function lintPriorExists(record: MarkbackRecord, basePath: string | null, record
138
138
  return diagnostics;
139
139
  }
140
140
 
141
+ function lintLineRange(record: MarkbackRecord, recordIdx: number): Diagnostic[] {
142
+ const diagnostics: Diagnostic[] = [];
143
+
144
+ // Check @source line range
145
+ if (record.source && record.source.startLine !== null) {
146
+ if (record.source.endLine !== null && record.source.endLine < record.source.startLine) {
147
+ diagnostics.push(
148
+ new Diagnostic({
149
+ file: record._sourceFile ?? null,
150
+ line: record._startLine ?? null,
151
+ column: null,
152
+ severity: Severity.ERROR,
153
+ code: ErrorCode.E011,
154
+ message: `Invalid line range in @source: end line ${record.source.endLine} is less than start line ${record.source.startLine}`,
155
+ recordIndex: recordIdx,
156
+ }),
157
+ );
158
+ }
159
+ }
160
+
161
+ // Check @prior line range
162
+ if (record.prior && record.prior.startLine !== null) {
163
+ if (record.prior.endLine !== null && record.prior.endLine < record.prior.startLine) {
164
+ diagnostics.push(
165
+ new Diagnostic({
166
+ file: record._sourceFile ?? null,
167
+ line: record._startLine ?? null,
168
+ column: null,
169
+ severity: Severity.ERROR,
170
+ code: ErrorCode.E011,
171
+ message: `Invalid line range in @prior: end line ${record.prior.endLine} is less than start line ${record.prior.startLine}`,
172
+ recordIndex: recordIdx,
173
+ }),
174
+ );
175
+ }
176
+ }
177
+
178
+ return diagnostics;
179
+ }
180
+
141
181
  function lintCanonicalFormat(records: MarkbackRecord[], originalText: string, file?: string | null): Diagnostic[] {
142
182
  const diagnostics: Diagnostic[] = [];
143
183
 
@@ -199,6 +239,9 @@ export function lintString(text: string, options: LintOptions = {}): ParseResult
199
239
  result.diagnostics.push(...lintSourceExists(record, basePath, idx));
200
240
  result.diagnostics.push(...lintPriorExists(record, basePath, idx));
201
241
  }
242
+
243
+ // Check line range validity
244
+ result.diagnostics.push(...lintLineRange(record, idx));
202
245
  });
203
246
 
204
247
  if (checkCanonical && result.records.length > 0 && !result.hasErrors) {
@@ -17,6 +17,7 @@ export enum ErrorCode {
17
17
  E008 = "E008",
18
18
  E009 = "E009",
19
19
  E010 = "E010",
20
+ E011 = "E011",
20
21
  }
21
22
 
22
23
  export enum WarningCode {
@@ -99,39 +100,81 @@ function extractScheme(value: string): string | null {
99
100
  return match ? match[1] : null;
100
101
  }
101
102
 
103
+ // Regex to parse line range from a path: path:start or path:start-end
104
+ const LINE_RANGE_PATTERN = /^(.+?):(\d+)(?:-(\d+))?$/;
105
+
102
106
  export class SourceRef {
103
107
  value: string;
104
108
  isUri: boolean;
109
+ startLine: number | null;
110
+ endLine: number | null;
111
+ private _pathOnly: string;
105
112
 
106
113
  constructor(value: string, isUri = false) {
107
114
  this.value = value;
115
+ this.startLine = null;
116
+ this.endLine = null;
117
+ this._pathOnly = value;
118
+
119
+ // Parse line range if present
120
+ this._parseLineRange();
121
+
108
122
  if (isUri) {
109
123
  this.isUri = true;
110
124
  return;
111
125
  }
112
126
 
113
- const scheme = extractScheme(value);
127
+ // Determine if this is a URI (using path without line range)
128
+ const scheme = extractScheme(this._pathOnly);
114
129
  this.isUri = !!scheme && scheme.length > 1;
115
130
  }
116
131
 
132
+ private _parseLineRange(): void {
133
+ const match = LINE_RANGE_PATTERN.exec(this.value);
134
+ if (match) {
135
+ this._pathOnly = match[1];
136
+ this.startLine = parseInt(match[2], 10);
137
+ if (match[3]) {
138
+ this.endLine = parseInt(match[3], 10);
139
+ } else {
140
+ // Single line reference: start and end are the same
141
+ this.endLine = this.startLine;
142
+ }
143
+ }
144
+ }
145
+
146
+ get path(): string {
147
+ return this._pathOnly;
148
+ }
149
+
150
+ get lineRangeStr(): string | null {
151
+ if (this.startLine === null) {
152
+ return null;
153
+ }
154
+ if (this.startLine === this.endLine) {
155
+ return `:${this.startLine}`;
156
+ }
157
+ return `:${this.startLine}-${this.endLine}`;
158
+ }
159
+
117
160
  resolve(basePath?: string | null): string {
118
161
  if (this.isUri) {
119
- const scheme = extractScheme(this.value);
162
+ const scheme = extractScheme(this._pathOnly);
120
163
  if (scheme && scheme.toLowerCase() === "file") {
121
- return fileURLToPath(new URL(this.value));
164
+ return fileURLToPath(new URL(this._pathOnly));
122
165
  }
123
166
  throw new Error(`Cannot resolve non-file URI to path: ${this.value}`);
124
167
  }
125
168
 
126
- if (path.isAbsolute(this.value)) {
127
- return this.value;
169
+ if (path.isAbsolute(this._pathOnly)) {
170
+ return this._pathOnly;
128
171
  }
129
172
 
130
173
  if (basePath) {
131
- return path.join(basePath, this.value);
174
+ return path.join(basePath, this._pathOnly);
132
175
  }
133
176
 
134
- return this.value;
177
+ return this._pathOnly;
135
178
  }
136
179
 
137
180
  toString(): string {
@@ -105,3 +105,57 @@ test("lintString: @prior URI not checked", () => {
105
105
  // Should not have W009 for URI-based @prior
106
106
  assert.equal(findCode(result.diagnostics, WarningCode.W009).length, 0);
107
107
  });
108
+
109
+ // Line range support tests
110
+
111
+ test("lintString: @source with single line", () => {
112
+ const text = "@source ./code.py:42 <<< good\n";
113
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
114
+ assert.equal(result.hasErrors, false);
115
+ assert.equal(result.records[0].source.path, "./code.py");
116
+ assert.equal(result.records[0].source.startLine, 42);
117
+ assert.equal(result.records[0].source.endLine, 42);
118
+ });
119
+
120
+ test("lintString: @source with line range", () => {
121
+ const text = "@source ./code.py:10-20 <<< good\n";
122
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
123
+ assert.equal(result.hasErrors, false);
124
+ assert.equal(result.records[0].source.path, "./code.py");
125
+ assert.equal(result.records[0].source.startLine, 10);
126
+ assert.equal(result.records[0].source.endLine, 20);
127
+ });
128
+
129
+ test("lintString: @prior with line range", () => {
130
+ const text = "@prior ./prompts/template.txt:1-20\n@source ./output.txt\n<<< good\n";
131
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
132
+ assert.equal(result.hasErrors, false);
133
+ assert.equal(result.records[0].prior.path, "./prompts/template.txt");
134
+ assert.equal(result.records[0].prior.startLine, 1);
135
+ assert.equal(result.records[0].prior.endLine, 20);
136
+ });
137
+
138
+ test("lintString: compact record with line range", () => {
139
+ const text = "@uri local:item-001\n@source ./file.txt:100-150 <<< feedback\n";
140
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
141
+ assert.equal(result.hasErrors, false);
142
+ assert.equal(result.records[0].source.path, "./file.txt");
143
+ assert.equal(result.records[0].source.startLine, 100);
144
+ assert.equal(result.records[0].source.endLine, 150);
145
+ });
146
+
147
+ test("lintString: invalid line range end < start", () => {
148
+ const text = "@source ./code.py:50-10 <<< good\n";
149
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
150
+ assert.equal(result.hasErrors, true);
151
+ assert.equal(findCode(result.diagnostics, ErrorCode.E011).length, 1);
152
+ });
153
+
154
+ test("lintString: source without line range still works", () => {
155
+ const text = "@source ./code.py <<< good\n";
156
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
157
+ assert.equal(result.hasErrors, false);
158
+ assert.equal(result.records[0].source.path, "./code.py");
159
+ assert.equal(result.records[0].source.startLine, null);
160
+ assert.equal(result.records[0].source.endLine, null);
161
+ });
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "markback"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "A compact, human-writable format for storing content paired with feedback/labels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -245,3 +245,84 @@ class TestSummarizeResults:
245
245
  assert "records" in summary
246
246
  assert "errors" in summary
247
247
  assert "warnings" in summary
248
+
249
+
250
+ class TestLineRangeSupport:
251
+ """Tests for line range support in @source and @prior."""
252
+
253
+ def test_source_with_single_line(self):
254
+ """Test @source with single line reference."""
255
+ text = "@source ./code.py:42 <<< good\n"
256
+ result = lint_string(text, check_sources=False, check_canonical=False)
257
+
258
+ assert not result.has_errors
259
+ assert result.records[0].source is not None
260
+ assert result.records[0].source.path == "./code.py"
261
+ assert result.records[0].source.start_line == 42
262
+ assert result.records[0].source.end_line == 42
263
+
264
+ def test_source_with_line_range(self):
265
+ """Test @source with line range reference."""
266
+ text = "@source ./code.py:10-20 <<< good\n"
267
+ result = lint_string(text, check_sources=False, check_canonical=False)
268
+
269
+ assert not result.has_errors
270
+ assert result.records[0].source is not None
271
+ assert result.records[0].source.path == "./code.py"
272
+ assert result.records[0].source.start_line == 10
273
+ assert result.records[0].source.end_line == 20
274
+
275
+ def test_prior_with_line_range(self):
276
+ """Test @prior with line range reference."""
277
+ text = "@prior ./prompts/template.txt:1-20\n@source ./output.txt\n<<< good\n"
278
+ result = lint_string(text, check_sources=False, check_canonical=False)
279
+
280
+ assert not result.has_errors
281
+ assert result.records[0].prior is not None
282
+ assert result.records[0].prior.path == "./prompts/template.txt"
283
+ assert result.records[0].prior.start_line == 1
284
+ assert result.records[0].prior.end_line == 20
285
+
286
+ def test_compact_record_with_line_range(self):
287
+ """Test compact record with line range in @source."""
288
+ text = "@uri local:item-001\n@source ./file.txt:100-150 <<< feedback\n"
289
+ result = lint_string(text, check_sources=False, check_canonical=False)
290
+
291
+ assert not result.has_errors
292
+ assert result.records[0].source is not None
293
+ assert result.records[0].source.path == "./file.txt"
294
+ assert result.records[0].source.start_line == 100
295
+ assert result.records[0].source.end_line == 150
296
+
297
+ def test_uri_with_line_range(self):
298
+ """Test URI source with line range."""
299
+ text = "@source https://example.com/file.txt:100-150 <<< good\n"
300
+ result = lint_string(text, check_sources=False, check_canonical=False)
301
+
302
+ assert not result.has_errors
303
+ assert result.records[0].source is not None
304
+ assert result.records[0].source.path == "https://example.com/file.txt"
305
+ assert result.records[0].source.start_line == 100
306
+ assert result.records[0].source.end_line == 150
307
+ assert result.records[0].source.is_uri
308
+
309
+ def test_invalid_line_range_end_less_than_start(self):
310
+ """Test E011: Invalid line range (end < start)."""
311
+ text = "@source ./code.py:50-10 <<< good\n"
312
+ result = lint_string(text, check_sources=False, check_canonical=False)
313
+
314
+ assert result.has_errors
315
+ errors = [d for d in result.diagnostics if d.code == ErrorCode.E011]
316
+ assert len(errors) == 1
317
+ assert "end line 10 is less than start line 50" in errors[0].message
318
+
319
+ def test_source_without_line_range(self):
320
+ """Test @source without line range still works."""
321
+ text = "@source ./code.py <<< good\n"
322
+ result = lint_string(text, check_sources=False, check_canonical=False)
323
+
324
+ assert not result.has_errors
325
+ assert result.records[0].source is not None
326
+ assert result.records[0].source.path == "./code.py"
327
+ assert result.records[0].source.start_line is None
328
+ assert result.records[0].source.end_line is None
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(python -m pytest:*)",
5
- "Bash(npm test:*)",
6
- "Bash(npm install)"
7
- ]
8
- }
9
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes