markback 0.1.2__tar.gz → 0.1.3__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 (55) hide show
  1. {markback-0.1.2 → markback-0.1.3}/.claude/settings.local.json +5 -1
  2. {markback-0.1.2 → markback-0.1.3}/PKG-INFO +1 -1
  3. {markback-0.1.2 → markback-0.1.3}/SPEC.md +85 -20
  4. {markback-0.1.2 → markback-0.1.3}/markback/linter.py +32 -7
  5. {markback-0.1.2 → markback-0.1.3}/markback/parser.py +6 -2
  6. {markback-0.1.2 → markback-0.1.3}/markback/types.py +34 -9
  7. {markback-0.1.2 → markback-0.1.3}/markback/writer.py +10 -3
  8. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/package.json +1 -1
  9. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/src/linter.ts +31 -6
  10. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/src/parser.ts +6 -2
  11. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/src/types.ts +42 -7
  12. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/src/writer.ts +6 -0
  13. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/test/linter.test.js +104 -0
  14. {markback-0.1.2 → markback-0.1.3}/pyproject.toml +1 -1
  15. markback-0.1.3/scripts/publish-npm.sh +11 -0
  16. markback-0.1.3/scripts/publish-pypi.sh +14 -0
  17. markback-0.1.3/scripts/publish.sh +16 -0
  18. {markback-0.1.2 → markback-0.1.3}/tests/test_linter.py +127 -0
  19. {markback-0.1.2 → markback-0.1.3}/.gitignore +0 -0
  20. {markback-0.1.2 → markback-0.1.3}/.ishipped/card.md +0 -0
  21. {markback-0.1.2 → markback-0.1.3}/IMPLEMENTATION_NOTES.md +0 -0
  22. {markback-0.1.2 → markback-0.1.3}/LICENSE +0 -0
  23. {markback-0.1.2 → markback-0.1.3}/README.md +0 -0
  24. {markback-0.1.2 → markback-0.1.3}/markback/__init__.py +0 -0
  25. {markback-0.1.2 → markback-0.1.3}/markback/cli.py +0 -0
  26. {markback-0.1.2 → markback-0.1.3}/markback/config.py +0 -0
  27. {markback-0.1.2 → markback-0.1.3}/markback/llm.py +0 -0
  28. {markback-0.1.2 → markback-0.1.3}/markback/workflow.py +0 -0
  29. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/LICENSE +0 -0
  30. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/README.md +0 -0
  31. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/package-lock.json +0 -0
  32. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/src/index.ts +0 -0
  33. {markback-0.1.2 → markback-0.1.3}/packages/markbackjs/tsconfig.json +0 -0
  34. {markback-0.1.2 → markback-0.1.3}/tests/__init__.py +0 -0
  35. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/compact_source.mb +0 -0
  36. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/errors/content_with_source.mb +0 -0
  37. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/errors/empty_feedback.mb +0 -0
  38. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/errors/malformed_uri.mb +0 -0
  39. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/errors/missing_feedback.mb +0 -0
  40. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/errors/multiple_feedback.mb +0 -0
  41. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/essay.label.txt +0 -0
  42. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/essay.txt +0 -0
  43. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/external_source.mb +0 -0
  44. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/freeform_feedback.mb +0 -0
  45. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/json_feedback.mb +0 -0
  46. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/label_list.mb +0 -0
  47. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/minimal.mb +0 -0
  48. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/multi_record.mb +0 -0
  49. {markback-0.1.2 → markback-0.1.3}/tests/fixtures/with_uri.mb +0 -0
  50. {markback-0.1.2 → markback-0.1.3}/tests/test_cli.py +0 -0
  51. {markback-0.1.2 → markback-0.1.3}/tests/test_config.py +0 -0
  52. {markback-0.1.2 → markback-0.1.3}/tests/test_parser.py +0 -0
  53. {markback-0.1.2 → markback-0.1.3}/tests/test_types.py +0 -0
  54. {markback-0.1.2 → markback-0.1.3}/tests/test_workflow.py +0 -0
  55. {markback-0.1.2 → markback-0.1.3}/tests/test_writer.py +0 -0
@@ -7,7 +7,11 @@
7
7
  "Bash(npm run build:*)",
8
8
  "Bash(echo:*)",
9
9
  "Bash(python -m markback lint:*)",
10
- "Bash(python:*)"
10
+ "Bash(python:*)",
11
+ "Bash(python3 -m pytest:*)",
12
+ "Bash(pip3 install:*)",
13
+ "Bash(.venv/bin/python -m pytest:*)",
14
+ "Bash(chmod:*)"
11
15
  ]
12
16
  }
13
17
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markback
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -27,6 +27,7 @@ A MarkBack **record** is the fundamental unit. Every record has:
27
27
  | `content` | Yes* | The content being labeled (inline or referenced) |
28
28
  | `feedback` | Yes | Text after the `<<<` delimiter (always one line) |
29
29
  | `uri` | No | Unique identifier for the item |
30
+ | `by` | No | Freeform identifier for who provided the feedback |
30
31
  | `source` | No | Reference to external content (when content is not inline) |
31
32
  | `prior` | No | Reference to an item that precedes the source (e.g., a prompt that generated the content) |
32
33
 
@@ -66,6 +67,7 @@ Header lines appear at the start of a record and begin with `@`. They define met
66
67
 
67
68
  ```
68
69
  @uri <uri-value>
70
+ @by <freeform-text>
69
71
  @source <path-or-uri>
70
72
  @prior <path-or-uri>
71
73
  ```
@@ -95,7 +97,23 @@ Defines the unique identifier for this record.
95
97
 
96
98
  **Validation:** URI MUST be valid per RFC 3986. Parsers MUST reject malformed URIs as errors.
97
99
 
98
- #### 3.1.2 `@source` Header
100
+ #### 3.1.2 `@by` Header
101
+
102
+ Identifies who provided the feedback. The value is freeform text.
103
+
104
+ ```
105
+ @by dan@example.com
106
+ @by Dan Driscoll
107
+ @by reviewer-42
108
+ ```
109
+
110
+ **Rules:**
111
+ - Value is freeform text extending to end of line (trailing whitespace trimmed)
112
+ - Can contain any characters including spaces, special characters, etc.
113
+ - Commonly used for email addresses, usernames, or full names
114
+ - Optional - records without `@by` are valid
115
+
116
+ #### 3.1.3 `@source` Header
99
117
 
100
118
  References external content instead of inline content.
101
119
 
@@ -111,7 +129,7 @@ References external content instead of inline content.
111
129
  - When `@source` is present, inline content MUST be empty (or contain only whitespace)
112
130
  - Parsers MUST verify referenced files exist (warning if missing)
113
131
 
114
- #### 3.1.3 `@prior` Header
132
+ #### 3.1.4 `@prior` Header
115
133
 
116
134
  References an item that precedes the source material. For example, if the source is an image generated by an LLM, the prior could be the prompt that was used to create it.
117
135
 
@@ -127,26 +145,36 @@ References an item that precedes the source material. For example, if the source
127
145
  - `@prior` does not affect content handling (inline content or `@source` rules still apply)
128
146
  - Parsers SHOULD verify referenced files exist (warning if missing)
129
147
 
130
- #### 3.1.4 Line Range Specification
148
+ #### 3.1.5 Line and Character Range Specification
131
149
 
132
- Both `@source` and `@prior` headers support optional line range specifications using colon notation. This allows referencing specific lines within a file.
150
+ Both `@source` and `@prior` headers support optional line and character range specifications using colon notation. This allows referencing specific positions within a file.
133
151
 
134
- **Syntax:** `<path-or-uri>:<start>` or `<path-or-uri>:<start>-<end>`
152
+ **Syntax:**
153
+ - Line only: `<path-or-uri>:<line>` or `<path-or-uri>:<start-line>-<end-line>`
154
+ - With columns: `<path-or-uri>:<line>:<col>` or `<path-or-uri>:<start-line>:<start-col>-<end-line>:<end-col>`
135
155
 
136
156
  ```
137
157
  @source ./code.py:42
138
158
  @source ./code.py:42-50
159
+ @source ./code.py:42:10
160
+ @source ./code.py:42:10-42:25
161
+ @source ./code.py:10:5-15:20
139
162
  @prior ./prompts/template.txt:1-20
140
163
  @source https://example.com/file.txt:100-150
141
164
  ```
142
165
 
143
166
  **Rules:**
144
- - Line numbers are 1-indexed (first line is line 1)
167
+ - Line and column numbers are 1-indexed (first line/column is 1)
145
168
  - Single line: `:N` references line N only
146
169
  - 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
170
+ - Single position: `:N:C` references line N, column C
171
+ - Character range: `:N:C-M:D` references from line N column C to line M column D (inclusive)
172
+ - End position must be greater than or equal to start position (E011 error otherwise)
173
+ - If on same line: end column must be >= start column
174
+ - If on different lines: end line must be >= start line
175
+ - Ranges are informational metadata; parsers do not validate that referenced positions exist in the file
149
176
  - Windows drive letters (e.g., `C:\path`) are not confused with line ranges because scheme detection requires length > 1
177
+ - Column specification is optional; you can specify `:10:5-20` (start with column, end without)
150
178
 
151
179
  ### 3.2 Content Block
152
180
 
@@ -481,7 +509,7 @@ Canonical form ensures consistent output for comparison and version control.
481
509
  ### 5.2 Canonicalization Rules
482
510
 
483
511
  1. **Line endings:** Normalize to `\n` (LF)
484
- 2. **Header order:** `@uri` before `@prior` before `@source` before unknown headers (alphabetical)
512
+ 2. **Header order:** `@uri` before `@by` before `@prior` before `@source` before unknown headers (alphabetical)
485
513
  3. **Header spacing:** Exactly one space after keyword
486
514
  4. **Trailing whitespace:** Remove from all lines
487
515
  5. **Content whitespace:** Preserve internal whitespace; trim leading/trailing blank lines
@@ -678,7 +706,42 @@ Spring whispers goodbye.
678
706
  <<< creative; follows haiku structure; quality=excellent
679
707
  ```
680
708
 
681
- ### 8.5 Single-File Example
709
+ ### 8.5 Record with Attribution
710
+
711
+ ```
712
+ @uri local:review-001
713
+ @by dan@example.com
714
+
715
+ This code needs better error handling.
716
+ <<< actionable; priority=high
717
+ ```
718
+
719
+ Or with a full name:
720
+ ```
721
+ @uri local:review-002
722
+ @by Dan Driscoll
723
+ @source ./src/app.py
724
+ <<< approved; good code quality
725
+ ```
726
+
727
+ ### 8.6 Character-Level References
728
+
729
+ Reference a specific position in a file:
730
+ ```
731
+ @source ./code.py:42:10 <<< potential bug at this position
732
+ ```
733
+
734
+ Reference a character range on a single line:
735
+ ```
736
+ @source ./code.py:42:10-42:25 <<< consider renaming this variable
737
+ ```
738
+
739
+ Reference a multi-line character range:
740
+ ```
741
+ @source ./code.py:10:5-15:20 <<< this function needs refactoring
742
+ ```
743
+
744
+ ### 8.7 Single-File Example
682
745
 
683
746
  **File:** `question.mb`
684
747
  ```
@@ -688,7 +751,7 @@ Explain quantum entanglement in simple terms.
688
751
  <<< quality=excellent; accuracy=high; clarity=good
689
752
  ```
690
753
 
691
- ### 8.6 Label List Example (Compact Format)
754
+ ### 8.8 Label List Example (Compact Format)
692
755
 
693
756
  **File:** `image-annotations.mb`
694
757
  ```
@@ -720,7 +783,7 @@ Explain quantum entanglement in simple terms.
720
783
  @source ./batch1/item3.txt <<< positive; excellent clarity
721
784
  ```
722
785
 
723
- ### 8.7 Multi-Record Example (Mixed Freeform and Structured)
786
+ ### 8.9 Multi-Record Example (Mixed Freeform and Structured)
724
787
 
725
788
  **File:** `training-data.mb`
726
789
  ```
@@ -751,7 +814,7 @@ Please write a formal letter requesting a meeting.
751
814
  @source ./audio/sample-005.wav <<< transcription="Hello world"; quality=clear; language=en
752
815
  ```
753
816
 
754
- ### 8.8 Paired-File Example
817
+ ### 8.10 Paired-File Example
755
818
 
756
819
  **Content file:** `essay.txt`
757
820
  ```
@@ -767,7 +830,7 @@ agriculture, manufacturing, mining, and transport.
767
830
  <<< good; grade=B+; well structured but needs more specific examples
768
831
  ```
769
832
 
770
- ### 8.9 Freeform Feedback Examples
833
+ ### 8.11 Freeform Feedback Examples
771
834
 
772
835
  Various styles of freeform feedback:
773
836
 
@@ -790,7 +853,7 @@ Explain machine learning to a child.
790
853
  <<< needs work; the explanation assumes too much prior knowledge
791
854
  ```
792
855
 
793
- ### 8.10 Complex Structured Feedback (JSON)
856
+ ### 8.12 Complex Structured Feedback (JSON)
794
857
 
795
858
  ```
796
859
  @uri local:complex-example
@@ -799,7 +862,7 @@ Multi-attribute content with special characters.
799
862
  <<< json:{"rating":4.5,"tags":["important","review"],"notes":"Contains \"quoted\" text and; semicolons","scores":{"accuracy":0.9,"relevance":0.85}}
800
863
  ```
801
864
 
802
- ### 8.11 Image with MarkBack Sidecar
865
+ ### 8.13 Image with MarkBack Sidecar
803
866
 
804
867
  **Content file:** `diagram.png` (binary)
805
868
 
@@ -882,13 +945,15 @@ feedback = "<<<" SP feedback-content LF
882
945
  feedback-content = *VCHAR ; no LF allowed
883
946
 
884
947
  ; Compact record (single line, external source only)
885
- compact-record = [uri-line] source-feedback-line
948
+ compact-record = [uri-line] [by-line] [prior-line] source-feedback-line
886
949
  compact-list = compact-record *(1*blank-line compact-record)
887
950
  uri-line = "@uri" SP value LF
951
+ by-line = "@by" SP value LF
952
+ prior-line = "@prior" SP path-with-range LF
888
953
  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]
954
+ path-with-range = path [position-range] ; path with optional position range
955
+ path = 1*VCHAR ; ends at space before <<< or position-range
956
+ position-range = ":" 1*DIGIT [":" 1*DIGIT] ["-" 1*DIGIT [":" 1*DIGIT]]
892
957
 
893
958
  LOWER = %x61-7A ; a-z
894
959
  SP = %x20 ; space
@@ -137,36 +137,61 @@ def lint_prior_exists(
137
137
  return diagnostics
138
138
 
139
139
 
140
+ def _is_position_invalid(source_ref) -> tuple[bool, str]:
141
+ """Check if a SourceRef has an invalid position range.
142
+
143
+ Returns (is_invalid, error_message).
144
+ Position is invalid if:
145
+ - end_line < start_line
146
+ - end_line == start_line and end_column < start_column
147
+ """
148
+ if source_ref.start_line is None or source_ref.end_line is None:
149
+ return False, ""
150
+
151
+ if source_ref.end_line < source_ref.start_line:
152
+ return True, f"end line {source_ref.end_line} is less than start line {source_ref.start_line}"
153
+
154
+ if source_ref.end_line == source_ref.start_line:
155
+ if (source_ref.start_column is not None and
156
+ source_ref.end_column is not None and
157
+ source_ref.end_column < source_ref.start_column):
158
+ return True, f"end column {source_ref.end_column} is less than start column {source_ref.start_column} on line {source_ref.start_line}"
159
+
160
+ return False, ""
161
+
162
+
140
163
  def lint_line_range(
141
164
  record: Record,
142
165
  record_idx: int,
143
166
  ) -> list[Diagnostic]:
144
- """Check if line ranges are valid (end >= start)."""
167
+ """Check if line/character ranges are valid (end position >= start position)."""
145
168
  diagnostics: list[Diagnostic] = []
146
169
 
147
- # Check @source line range
170
+ # Check @source range
148
171
  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:
172
+ is_invalid, error_msg = _is_position_invalid(record.source)
173
+ if is_invalid:
150
174
  diagnostics.append(Diagnostic(
151
175
  file=record._source_file,
152
176
  line=record._start_line,
153
177
  column=None,
154
178
  severity=Severity.ERROR,
155
179
  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}",
180
+ message=f"Invalid range in @source: {error_msg}",
157
181
  record_index=record_idx,
158
182
  ))
159
183
 
160
- # Check @prior line range
184
+ # Check @prior range
161
185
  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:
186
+ is_invalid, error_msg = _is_position_invalid(record.prior)
187
+ if is_invalid:
163
188
  diagnostics.append(Diagnostic(
164
189
  file=record._source_file,
165
190
  line=record._start_line,
166
191
  column=None,
167
192
  severity=Severity.ERROR,
168
193
  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}",
194
+ message=f"Invalid range in @prior: {error_msg}",
170
195
  record_index=record_idx,
171
196
  ))
172
197
 
@@ -17,7 +17,7 @@ from .types import (
17
17
 
18
18
 
19
19
  # Known header keywords
20
- KNOWN_HEADERS = {"uri", "source", "prior"}
20
+ KNOWN_HEADERS = {"uri", "by", "source", "prior"}
21
21
 
22
22
  # Patterns
23
23
  HEADER_PATTERN = re.compile(r"^@([a-z]+)\s+(.+)$")
@@ -145,6 +145,7 @@ def parse_string(
145
145
  nonlocal pending_uri, in_content, had_blank_line
146
146
 
147
147
  uri = current_headers.get("uri") or pending_uri
148
+ by = current_headers.get("by")
148
149
  source_str = current_headers.get("source")
149
150
  source = SourceRef(source_str) if source_str else None
150
151
  prior_str = current_headers.get("prior")
@@ -164,6 +165,7 @@ def parse_string(
164
165
  record = Record(
165
166
  feedback=feedback,
166
167
  uri=uri,
168
+ by=by,
167
169
  source=source,
168
170
  prior=prior,
169
171
  content=content,
@@ -242,14 +244,16 @@ def parse_string(
242
244
  line_num,
243
245
  )
244
246
 
245
- # Use any pending @uri from previous line and @prior if present
247
+ # Use any pending @uri from previous line and @by, @prior if present
246
248
  uri = pending_uri or current_headers.get("uri")
249
+ by = current_headers.get("by")
247
250
  prior_str = current_headers.get("prior")
248
251
  prior = SourceRef(prior_str) if prior_str else None
249
252
 
250
253
  record = Record(
251
254
  feedback=feedback or "",
252
255
  uri=uri,
256
+ by=by,
253
257
  source=source,
254
258
  prior=prior,
255
259
  content=None,
@@ -78,8 +78,9 @@ class Diagnostic:
78
78
  }
79
79
 
80
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+))?$')
81
+ # Regex to parse line/character range from a path
82
+ # Supports: path:line, path:line:col, path:line-line, path:line:col-line:col
83
+ _LINE_RANGE_PATTERN = re.compile(r'^(.+?):(\d+)(?::(\d+))?(?:-(\d+)(?::(\d+))?)?$')
83
84
 
84
85
 
85
86
  @dataclass
@@ -89,6 +90,8 @@ class SourceRef:
89
90
  is_uri: bool = False
90
91
  start_line: Optional[int] = None
91
92
  end_line: Optional[int] = None
93
+ start_column: Optional[int] = None
94
+ end_column: Optional[int] = None
92
95
  _path_only: str = ""
93
96
 
94
97
  def __post_init__(self):
@@ -102,16 +105,21 @@ class SourceRef:
102
105
  self.is_uri = bool(parsed.scheme) and len(parsed.scheme) > 1
103
106
 
104
107
  def _parse_line_range(self):
105
- """Parse optional line range from value."""
108
+ """Parse optional line/character range from value."""
106
109
  match = _LINE_RANGE_PATTERN.match(self.value)
107
110
  if match:
108
111
  self._path_only = match.group(1)
109
112
  self.start_line = int(match.group(2))
110
113
  if match.group(3):
111
- self.end_line = int(match.group(3))
114
+ self.start_column = int(match.group(3))
115
+ if match.group(4):
116
+ self.end_line = int(match.group(4))
117
+ if match.group(5):
118
+ self.end_column = int(match.group(5))
112
119
  else:
113
- # Single line reference: start and end are the same
120
+ # Single line/position reference: start and end are the same
114
121
  self.end_line = self.start_line
122
+ self.end_column = self.start_column
115
123
  else:
116
124
  self._path_only = self.value
117
125
 
@@ -122,12 +130,27 @@ class SourceRef:
122
130
 
123
131
  @property
124
132
  def line_range_str(self) -> Optional[str]:
125
- """Return formatted line range string, or None if no range."""
133
+ """Return formatted line/character range string, or None if no range."""
126
134
  if self.start_line is None:
127
135
  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}"
136
+
137
+ # Build start position
138
+ if self.start_column is not None:
139
+ start = f":{self.start_line}:{self.start_column}"
140
+ else:
141
+ start = f":{self.start_line}"
142
+
143
+ # Check if end is the same as start (single position)
144
+ if self.start_line == self.end_line and self.start_column == self.end_column:
145
+ return start
146
+
147
+ # Build end position
148
+ if self.end_column is not None:
149
+ end = f"-{self.end_line}:{self.end_column}"
150
+ else:
151
+ end = f"-{self.end_line}"
152
+
153
+ return f"{start}{end}"
131
154
 
132
155
  def resolve(self, base_path: Optional[Path] = None) -> Path:
133
156
  """Resolve to a file path (relative paths resolved against base_path)."""
@@ -162,6 +185,7 @@ class Record:
162
185
  """A MarkBack record containing content and feedback."""
163
186
  feedback: str
164
187
  uri: Optional[str] = None
188
+ by: Optional[str] = None
165
189
  source: Optional[SourceRef] = None
166
190
  prior: Optional[SourceRef] = None
167
191
  content: Optional[str] = None
@@ -195,6 +219,7 @@ class Record:
195
219
  """Convert to JSON-serializable dict."""
196
220
  return {
197
221
  "uri": self.uri,
222
+ "by": self.by,
198
223
  "source": str(self.source) if self.source else None,
199
224
  "prior": str(self.prior) if self.prior else None,
200
225
  "content": self.content,
@@ -38,17 +38,21 @@ def write_record_canonical(
38
38
  )
39
39
 
40
40
  if use_compact:
41
- # Compact format: @uri on its own line (if present), then @prior, then @source ... <<<
41
+ # Compact format: @uri, @by, @prior on own lines (if present), then @source ... <<<
42
42
  if record.uri:
43
43
  lines.append(f"@uri {record.uri}")
44
+ if record.by:
45
+ lines.append(f"@by {record.by}")
44
46
  if record.prior:
45
47
  lines.append(f"@prior {record.prior}")
46
48
  lines.append(f"@source {record.source} <<< {record.feedback}")
47
49
  else:
48
50
  # Full format
49
- # Headers: @uri first, then @prior, then @source
51
+ # Headers: @uri first, then @by, then @prior, then @source
50
52
  if record.uri:
51
53
  lines.append(f"@uri {record.uri}")
54
+ if record.by:
55
+ lines.append(f"@by {record.by}")
52
56
  if record.prior:
53
57
  lines.append(f"@prior {record.prior}")
54
58
  if record.source:
@@ -151,7 +155,10 @@ def write_label_file(record: Record) -> str:
151
155
 
152
156
  if record.uri:
153
157
  lines.append(f"@uri {record.uri}")
154
-
158
+
159
+ if record.by:
160
+ lines.append(f"@by {record.by}")
161
+
155
162
  if record.prior:
156
163
  lines.append(f"@prior {record.prior}")
157
164
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markbackjs",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "MarkBack tooling for JavaScript and TypeScript",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -138,12 +138,36 @@ function lintPriorExists(record: MarkbackRecord, basePath: string | null, record
138
138
  return diagnostics;
139
139
  }
140
140
 
141
+ interface PositionCheck {
142
+ isInvalid: boolean;
143
+ errorMsg: string;
144
+ }
145
+
146
+ function isPositionInvalid(sourceRef: { startLine: number | null; endLine: number | null; startColumn: number | null; endColumn: number | null }): PositionCheck {
147
+ if (sourceRef.startLine === null || sourceRef.endLine === null) {
148
+ return { isInvalid: false, errorMsg: "" };
149
+ }
150
+
151
+ if (sourceRef.endLine < sourceRef.startLine) {
152
+ return { isInvalid: true, errorMsg: `end line ${sourceRef.endLine} is less than start line ${sourceRef.startLine}` };
153
+ }
154
+
155
+ if (sourceRef.endLine === sourceRef.startLine) {
156
+ if (sourceRef.startColumn !== null && sourceRef.endColumn !== null && sourceRef.endColumn < sourceRef.startColumn) {
157
+ return { isInvalid: true, errorMsg: `end column ${sourceRef.endColumn} is less than start column ${sourceRef.startColumn} on line ${sourceRef.startLine}` };
158
+ }
159
+ }
160
+
161
+ return { isInvalid: false, errorMsg: "" };
162
+ }
163
+
141
164
  function lintLineRange(record: MarkbackRecord, recordIdx: number): Diagnostic[] {
142
165
  const diagnostics: Diagnostic[] = [];
143
166
 
144
- // Check @source line range
167
+ // Check @source range
145
168
  if (record.source && record.source.startLine !== null) {
146
- if (record.source.endLine !== null && record.source.endLine < record.source.startLine) {
169
+ const { isInvalid, errorMsg } = isPositionInvalid(record.source);
170
+ if (isInvalid) {
147
171
  diagnostics.push(
148
172
  new Diagnostic({
149
173
  file: record._sourceFile ?? null,
@@ -151,16 +175,17 @@ function lintLineRange(record: MarkbackRecord, recordIdx: number): Diagnostic[]
151
175
  column: null,
152
176
  severity: Severity.ERROR,
153
177
  code: ErrorCode.E011,
154
- message: `Invalid line range in @source: end line ${record.source.endLine} is less than start line ${record.source.startLine}`,
178
+ message: `Invalid range in @source: ${errorMsg}`,
155
179
  recordIndex: recordIdx,
156
180
  }),
157
181
  );
158
182
  }
159
183
  }
160
184
 
161
- // Check @prior line range
185
+ // Check @prior range
162
186
  if (record.prior && record.prior.startLine !== null) {
163
- if (record.prior.endLine !== null && record.prior.endLine < record.prior.startLine) {
187
+ const { isInvalid, errorMsg } = isPositionInvalid(record.prior);
188
+ if (isInvalid) {
164
189
  diagnostics.push(
165
190
  new Diagnostic({
166
191
  file: record._sourceFile ?? null,
@@ -168,7 +193,7 @@ function lintLineRange(record: MarkbackRecord, recordIdx: number): Diagnostic[]
168
193
  column: null,
169
194
  severity: Severity.ERROR,
170
195
  code: ErrorCode.E011,
171
- message: `Invalid line range in @prior: end line ${record.prior.endLine} is less than start line ${record.prior.startLine}`,
196
+ message: `Invalid range in @prior: ${errorMsg}`,
172
197
  recordIndex: recordIdx,
173
198
  }),
174
199
  );
@@ -1,6 +1,6 @@
1
1
  import { Diagnostic, ErrorCode, ParseResult, Record as MarkbackRecord, Severity, SourceRef, WarningCode } from "./types";
2
2
 
3
- const KNOWN_HEADERS = new Set(["uri", "source", "prior"]);
3
+ const KNOWN_HEADERS = new Set(["uri", "by", "source", "prior"]);
4
4
 
5
5
  const HEADER_PATTERN = /^@([a-z]+)\s+(.+)$/;
6
6
  const FEEDBACK_DELIMITER = "<<<";
@@ -114,6 +114,7 @@ export function parseString(text: string, sourceFile?: string | null): ParseResu
114
114
 
115
115
  const finalizeRecord = (feedback: string, endLine: number, isCompact = false) => {
116
116
  const uri = currentHeaders.uri ?? pendingUri;
117
+ const by = currentHeaders.by ?? null;
117
118
  const sourceStr = currentHeaders.source;
118
119
  const source = sourceStr ? new SourceRef(sourceStr) : null;
119
120
  const priorStr = currentHeaders.prior;
@@ -135,6 +136,7 @@ export function parseString(text: string, sourceFile?: string | null): ParseResu
135
136
  new MarkbackRecord({
136
137
  feedback,
137
138
  uri: uri ?? null,
139
+ by,
138
140
  source,
139
141
  prior,
140
142
  content,
@@ -202,13 +204,15 @@ export function parseString(text: string, sourceFile?: string | null): ParseResu
202
204
  }
203
205
 
204
206
  const uri = pendingUri ?? currentHeaders.uri ?? null;
207
+ const by = currentHeaders.by ?? null;
205
208
  const priorStr = currentHeaders.prior;
206
209
  const prior = priorStr ? new SourceRef(priorStr) : null;
207
210
 
208
211
  records.push(
209
- new MarkbackRecord({
212
+ new MarkbackRecord({
210
213
  feedback: feedback ?? "",
211
214
  uri,
215
+ by,
212
216
  source,
213
217
  prior,
214
218
  content: null,
@@ -100,20 +100,25 @@ function extractScheme(value: string): string | null {
100
100
  return match ? match[1] : null;
101
101
  }
102
102
 
103
- // Regex to parse line range from a path: path:start or path:start-end
104
- const LINE_RANGE_PATTERN = /^(.+?):(\d+)(?:-(\d+))?$/;
103
+ // Regex to parse line/character range from a path
104
+ // Supports: path:line, path:line:col, path:line-line, path:line:col-line:col
105
+ const LINE_RANGE_PATTERN = /^(.+?):(\d+)(?::(\d+))?(?:-(\d+)(?::(\d+))?)?$/;
105
106
 
106
107
  export class SourceRef {
107
108
  value: string;
108
109
  isUri: boolean;
109
110
  startLine: number | null;
110
111
  endLine: number | null;
112
+ startColumn: number | null;
113
+ endColumn: number | null;
111
114
  private _pathOnly: string;
112
115
 
113
116
  constructor(value: string, isUri = false) {
114
117
  this.value = value;
115
118
  this.startLine = null;
116
119
  this.endLine = null;
120
+ this.startColumn = null;
121
+ this.endColumn = null;
117
122
  this._pathOnly = value;
118
123
 
119
124
  // Parse line range if present
@@ -135,10 +140,17 @@ export class SourceRef {
135
140
  this._pathOnly = match[1];
136
141
  this.startLine = parseInt(match[2], 10);
137
142
  if (match[3]) {
138
- this.endLine = parseInt(match[3], 10);
143
+ this.startColumn = parseInt(match[3], 10);
144
+ }
145
+ if (match[4]) {
146
+ this.endLine = parseInt(match[4], 10);
147
+ if (match[5]) {
148
+ this.endColumn = parseInt(match[5], 10);
149
+ }
139
150
  } else {
140
- // Single line reference: start and end are the same
151
+ // Single line/position reference: start and end are the same
141
152
  this.endLine = this.startLine;
153
+ this.endColumn = this.startColumn;
142
154
  }
143
155
  }
144
156
  }
@@ -151,10 +163,29 @@ export class SourceRef {
151
163
  if (this.startLine === null) {
152
164
  return null;
153
165
  }
154
- if (this.startLine === this.endLine) {
155
- return `:${this.startLine}`;
166
+
167
+ // Build start position
168
+ let start: string;
169
+ if (this.startColumn !== null) {
170
+ start = `:${this.startLine}:${this.startColumn}`;
171
+ } else {
172
+ start = `:${this.startLine}`;
173
+ }
174
+
175
+ // Check if end is the same as start (single position)
176
+ if (this.startLine === this.endLine && this.startColumn === this.endColumn) {
177
+ return start;
156
178
  }
157
- return `:${this.startLine}-${this.endLine}`;
179
+
180
+ // Build end position
181
+ let end: string;
182
+ if (this.endColumn !== null) {
183
+ end = `-${this.endLine}:${this.endColumn}`;
184
+ } else {
185
+ end = `-${this.endLine}`;
186
+ }
187
+
188
+ return `${start}${end}`;
158
189
  }
159
190
 
160
191
  resolve(basePath?: string | null): string {
@@ -185,6 +216,7 @@ export class SourceRef {
185
216
  export interface RecordInit {
186
217
  feedback: string;
187
218
  uri?: string | null;
219
+ by?: string | null;
188
220
  source?: SourceRef | null;
189
221
  prior?: SourceRef | null;
190
222
  content?: string | null;
@@ -198,6 +230,7 @@ export interface RecordInit {
198
230
  export class Record {
199
231
  feedback: string;
200
232
  uri: string | null;
233
+ by: string | null;
201
234
  source: SourceRef | null;
202
235
  prior: SourceRef | null;
203
236
  content: string | null;
@@ -210,6 +243,7 @@ export class Record {
210
243
  constructor(init: RecordInit) {
211
244
  this.feedback = init.feedback;
212
245
  this.uri = init.uri ?? null;
246
+ this.by = init.by ?? null;
213
247
  this.source = init.source ?? null;
214
248
  this.prior = init.prior ?? null;
215
249
  this.content = init.content ?? null;
@@ -237,6 +271,7 @@ export class Record {
237
271
  toDict(): UnknownMap {
238
272
  return {
239
273
  uri: this.uri,
274
+ by: this.by,
240
275
  source: this.source ? this.source.toString() : null,
241
276
  prior: this.prior ? this.prior.toString() : null,
242
277
  content: this.content,
@@ -20,6 +20,9 @@ export function writeRecordCanonical(record: Record, preferCompact = true): stri
20
20
  if (record.uri) {
21
21
  lines.push(`@uri ${record.uri}`);
22
22
  }
23
+ if (record.by) {
24
+ lines.push(`@by ${record.by}`);
25
+ }
23
26
  if (record.prior) {
24
27
  lines.push(`@prior ${record.prior}`);
25
28
  }
@@ -28,6 +31,9 @@ export function writeRecordCanonical(record: Record, preferCompact = true): stri
28
31
  if (record.uri) {
29
32
  lines.push(`@uri ${record.uri}`);
30
33
  }
34
+ if (record.by) {
35
+ lines.push(`@by ${record.by}`);
36
+ }
31
37
  if (record.prior) {
32
38
  lines.push(`@prior ${record.prior}`);
33
39
  }
@@ -159,3 +159,107 @@ test("lintString: source without line range still works", () => {
159
159
  assert.equal(result.records[0].source.startLine, null);
160
160
  assert.equal(result.records[0].source.endLine, null);
161
161
  });
162
+
163
+ // @by header tests
164
+
165
+ test("lintString: @by header basic", () => {
166
+ const text = "@uri local:example\n@by dan@example.com\n\nContent.\n<<< good\n";
167
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
168
+ assert.equal(result.hasErrors, false);
169
+ assert.equal(result.records[0].by, "dan@example.com");
170
+ });
171
+
172
+ test("lintString: @by header with spaces", () => {
173
+ const text = "@uri local:example\n@by Dan Driscoll\n\nContent.\n<<< good\n";
174
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
175
+ assert.equal(result.hasErrors, false);
176
+ assert.equal(result.records[0].by, "Dan Driscoll");
177
+ });
178
+
179
+ test("lintString: @by header with compact record", () => {
180
+ const text = "@uri local:item-001\n@by reviewer@example.com\n@source ./file.txt <<< feedback\n";
181
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
182
+ assert.equal(result.hasErrors, false);
183
+ assert.equal(result.records[0].by, "reviewer@example.com");
184
+ });
185
+
186
+ test("lintString: @by header with @prior", () => {
187
+ const text = "@uri local:gen-001\n@by ai-trainer@example.com\n@prior ./prompts/prompt.txt\n@source ./output.txt\n<<< good\n";
188
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
189
+ assert.equal(result.hasErrors, false);
190
+ assert.equal(result.records[0].by, "ai-trainer@example.com");
191
+ assert.ok(result.records[0].prior !== null);
192
+ });
193
+
194
+ // Character-level referencing tests
195
+
196
+ test("lintString: @source with single position (line:col)", () => {
197
+ const text = "@source ./code.py:42:10 <<< good\n";
198
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
199
+ assert.equal(result.hasErrors, false);
200
+ assert.equal(result.records[0].source.path, "./code.py");
201
+ assert.equal(result.records[0].source.startLine, 42);
202
+ assert.equal(result.records[0].source.startColumn, 10);
203
+ assert.equal(result.records[0].source.endLine, 42);
204
+ assert.equal(result.records[0].source.endColumn, 10);
205
+ });
206
+
207
+ test("lintString: @source with character range same line", () => {
208
+ const text = "@source ./code.py:42:10-42:25 <<< good\n";
209
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
210
+ assert.equal(result.hasErrors, false);
211
+ assert.equal(result.records[0].source.path, "./code.py");
212
+ assert.equal(result.records[0].source.startLine, 42);
213
+ assert.equal(result.records[0].source.startColumn, 10);
214
+ assert.equal(result.records[0].source.endLine, 42);
215
+ assert.equal(result.records[0].source.endColumn, 25);
216
+ });
217
+
218
+ test("lintString: @source with character range multi-line", () => {
219
+ const text = "@source ./code.py:10:5-15:20 <<< good\n";
220
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
221
+ assert.equal(result.hasErrors, false);
222
+ assert.equal(result.records[0].source.path, "./code.py");
223
+ assert.equal(result.records[0].source.startLine, 10);
224
+ assert.equal(result.records[0].source.startColumn, 5);
225
+ assert.equal(result.records[0].source.endLine, 15);
226
+ assert.equal(result.records[0].source.endColumn, 20);
227
+ });
228
+
229
+ test("lintString: @prior with character range", () => {
230
+ const text = "@prior ./prompts/template.txt:1:1-20:50\n@source ./output.txt\n<<< good\n";
231
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
232
+ assert.equal(result.hasErrors, false);
233
+ assert.equal(result.records[0].prior.path, "./prompts/template.txt");
234
+ assert.equal(result.records[0].prior.startLine, 1);
235
+ assert.equal(result.records[0].prior.startColumn, 1);
236
+ assert.equal(result.records[0].prior.endLine, 20);
237
+ assert.equal(result.records[0].prior.endColumn, 50);
238
+ });
239
+
240
+ test("lintString: invalid character range end col < start col", () => {
241
+ const text = "@source ./code.py:42:25-42:10 <<< good\n";
242
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
243
+ assert.equal(result.hasErrors, true);
244
+ assert.equal(findCode(result.diagnostics, ErrorCode.E011).length, 1);
245
+ });
246
+
247
+ test("lintString: line range without columns still works", () => {
248
+ const text = "@source ./code.py:10-20 <<< good\n";
249
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
250
+ assert.equal(result.hasErrors, false);
251
+ assert.equal(result.records[0].source.startLine, 10);
252
+ assert.equal(result.records[0].source.startColumn, null);
253
+ assert.equal(result.records[0].source.endLine, 20);
254
+ assert.equal(result.records[0].source.endColumn, null);
255
+ });
256
+
257
+ test("lintString: mixed column specification", () => {
258
+ const text = "@source ./code.py:10:5-20 <<< good\n";
259
+ const result = lintString(text, { checkSources: false, checkCanonical: false });
260
+ assert.equal(result.hasErrors, false);
261
+ assert.equal(result.records[0].source.startLine, 10);
262
+ assert.equal(result.records[0].source.startColumn, 5);
263
+ assert.equal(result.records[0].source.endLine, 20);
264
+ assert.equal(result.records[0].source.endColumn, null);
265
+ });
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "markback"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "A compact, human-writable format for storing content paired with feedback/labels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Publish JavaScript package to npm
5
+
6
+ cd "$(dirname "$0")/../packages/markbackjs"
7
+
8
+ echo "Building and publishing to npm..."
9
+ npm publish
10
+
11
+ echo "Done! Package published to npm."
@@ -0,0 +1,14 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Publish Python package to PyPI
5
+
6
+ cd "$(dirname "$0")/.."
7
+
8
+ echo "Building Python package..."
9
+ python -m build
10
+
11
+ echo "Uploading to PyPI..."
12
+ python -m twine upload dist/*
13
+
14
+ echo "Done! Package published to PyPI."
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Publish both Python and JavaScript packages
5
+
6
+ SCRIPT_DIR="$(dirname "$0")"
7
+
8
+ echo "=== Publishing to PyPI ==="
9
+ "$SCRIPT_DIR/publish-pypi.sh"
10
+
11
+ echo ""
12
+ echo "=== Publishing to npm ==="
13
+ "$SCRIPT_DIR/publish-npm.sh"
14
+
15
+ echo ""
16
+ echo "All packages published successfully!"
@@ -326,3 +326,130 @@ class TestLineRangeSupport:
326
326
  assert result.records[0].source.path == "./code.py"
327
327
  assert result.records[0].source.start_line is None
328
328
  assert result.records[0].source.end_line is None
329
+
330
+
331
+ class TestByHeader:
332
+ """Tests for @by header support."""
333
+
334
+ def test_by_header_basic(self):
335
+ """Test basic @by header parsing."""
336
+ text = "@uri local:example\n@by dan@example.com\n\nContent.\n<<< good\n"
337
+ result = lint_string(text, check_sources=False, check_canonical=False)
338
+
339
+ assert not result.has_errors
340
+ assert result.records[0].by == "dan@example.com"
341
+
342
+ def test_by_header_with_spaces(self):
343
+ """Test @by header with freeform text."""
344
+ text = "@uri local:example\n@by Dan Driscoll\n\nContent.\n<<< good\n"
345
+ result = lint_string(text, check_sources=False, check_canonical=False)
346
+
347
+ assert not result.has_errors
348
+ assert result.records[0].by == "Dan Driscoll"
349
+
350
+ def test_by_header_compact_record(self):
351
+ """Test @by header with compact record."""
352
+ text = "@uri local:item-001\n@by reviewer@example.com\n@source ./file.txt <<< feedback\n"
353
+ result = lint_string(text, check_sources=False, check_canonical=False)
354
+
355
+ assert not result.has_errors
356
+ assert result.records[0].by == "reviewer@example.com"
357
+
358
+ def test_by_header_with_prior(self):
359
+ """Test @by header combined with @prior."""
360
+ text = "@uri local:gen-001\n@by ai-trainer@example.com\n@prior ./prompts/prompt.txt\n@source ./output.txt\n<<< good\n"
361
+ result = lint_string(text, check_sources=False, check_canonical=False)
362
+
363
+ assert not result.has_errors
364
+ assert result.records[0].by == "ai-trainer@example.com"
365
+ assert result.records[0].prior is not None
366
+
367
+
368
+ class TestCharacterLevelReferencing:
369
+ """Tests for character-level referencing syntax."""
370
+
371
+ def test_source_with_single_position(self):
372
+ """Test @source with single position (line:col)."""
373
+ text = "@source ./code.py:42:10 <<< good\n"
374
+ result = lint_string(text, check_sources=False, check_canonical=False)
375
+
376
+ assert not result.has_errors
377
+ assert result.records[0].source is not None
378
+ assert result.records[0].source.path == "./code.py"
379
+ assert result.records[0].source.start_line == 42
380
+ assert result.records[0].source.start_column == 10
381
+ assert result.records[0].source.end_line == 42
382
+ assert result.records[0].source.end_column == 10
383
+
384
+ def test_source_with_character_range_same_line(self):
385
+ """Test @source with character range on same line."""
386
+ text = "@source ./code.py:42:10-42:25 <<< good\n"
387
+ result = lint_string(text, check_sources=False, check_canonical=False)
388
+
389
+ assert not result.has_errors
390
+ assert result.records[0].source is not None
391
+ assert result.records[0].source.path == "./code.py"
392
+ assert result.records[0].source.start_line == 42
393
+ assert result.records[0].source.start_column == 10
394
+ assert result.records[0].source.end_line == 42
395
+ assert result.records[0].source.end_column == 25
396
+
397
+ def test_source_with_character_range_multi_line(self):
398
+ """Test @source with character range spanning multiple lines."""
399
+ text = "@source ./code.py:10:5-15:20 <<< good\n"
400
+ result = lint_string(text, check_sources=False, check_canonical=False)
401
+
402
+ assert not result.has_errors
403
+ assert result.records[0].source is not None
404
+ assert result.records[0].source.path == "./code.py"
405
+ assert result.records[0].source.start_line == 10
406
+ assert result.records[0].source.start_column == 5
407
+ assert result.records[0].source.end_line == 15
408
+ assert result.records[0].source.end_column == 20
409
+
410
+ def test_prior_with_character_range(self):
411
+ """Test @prior with character range."""
412
+ text = "@prior ./prompts/template.txt:1:1-20:50\n@source ./output.txt\n<<< good\n"
413
+ result = lint_string(text, check_sources=False, check_canonical=False)
414
+
415
+ assert not result.has_errors
416
+ assert result.records[0].prior is not None
417
+ assert result.records[0].prior.path == "./prompts/template.txt"
418
+ assert result.records[0].prior.start_line == 1
419
+ assert result.records[0].prior.start_column == 1
420
+ assert result.records[0].prior.end_line == 20
421
+ assert result.records[0].prior.end_column == 50
422
+
423
+ def test_invalid_character_range_end_column_less_than_start(self):
424
+ """Test E011: Invalid character range (end col < start col on same line)."""
425
+ text = "@source ./code.py:42:25-42:10 <<< good\n"
426
+ result = lint_string(text, check_sources=False, check_canonical=False)
427
+
428
+ assert result.has_errors
429
+ errors = [d for d in result.diagnostics if d.code == ErrorCode.E011]
430
+ assert len(errors) == 1
431
+ assert "end column 10 is less than start column 25" in errors[0].message
432
+
433
+ def test_line_range_without_columns_still_works(self):
434
+ """Test that line ranges without columns still work."""
435
+ text = "@source ./code.py:10-20 <<< good\n"
436
+ result = lint_string(text, check_sources=False, check_canonical=False)
437
+
438
+ assert not result.has_errors
439
+ assert result.records[0].source is not None
440
+ assert result.records[0].source.start_line == 10
441
+ assert result.records[0].source.start_column is None
442
+ assert result.records[0].source.end_line == 20
443
+ assert result.records[0].source.end_column is None
444
+
445
+ def test_mixed_column_specification(self):
446
+ """Test range with start column but no end column."""
447
+ text = "@source ./code.py:10:5-20 <<< good\n"
448
+ result = lint_string(text, check_sources=False, check_canonical=False)
449
+
450
+ assert not result.has_errors
451
+ assert result.records[0].source is not None
452
+ assert result.records[0].source.start_line == 10
453
+ assert result.records[0].source.start_column == 5
454
+ assert result.records[0].source.end_line == 20
455
+ assert result.records[0].source.end_column is None
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