@hawkeyexl/manni 2.4.0 → 2.5.1

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.
@@ -71,7 +71,7 @@ import {
71
71
  withValueAt,
72
72
  writeEncryptionKey,
73
73
  writeFileAtomic
74
- } from "./chunk-LYLNOJ32.js";
74
+ } from "./chunk-22WVCFP4.js";
75
75
 
76
76
  // src/meta/core/reencrypt.ts
77
77
  import { extname } from "node:path";
@@ -220,16 +220,24 @@ __export(cite_exports, {
220
220
  historyOf: () => historyOf,
221
221
  insertStatementBefore: () => insertStatementBefore,
222
222
  insideFence: () => insideFence,
223
+ isBoundLine: () => isBoundLine,
223
224
  isCiteRule: () => isCiteRule,
224
225
  isKeyedPin: () => isKeyedPin,
226
+ isUnitText: () => isUnitText,
225
227
  itemLine: () => itemLine,
226
228
  lineAt: () => lineAt,
227
229
  lineSpec: () => lineSpec,
228
230
  loadCitationSidecars: () => loadCitationSidecars,
229
231
  loadCiteConfig: () => loadCiteConfig,
232
+ markerClaimEnd: () => markerClaimEnd,
233
+ markerRunAt: () => markerRunAt,
230
234
  markerUnit: () => markerUnit,
231
235
  messageFor: () => messageFor,
232
236
  mintCitation: () => mintCitation,
237
+ misplacedMarkerAt: () => misplacedMarkerAt,
238
+ misplacedMarkers: () => misplacedMarkers,
239
+ misplacedMessageFor: () => misplacedMessageFor,
240
+ movedUnit: () => movedUnit,
233
241
  noGit: () => noGit,
234
242
  normalizeText: () => normalizeText,
235
243
  normalizeWhitespace: () => normalizeWhitespace,
@@ -243,6 +251,7 @@ __export(cite_exports, {
243
251
  parseStatements: () => parseStatements,
244
252
  pickExtractor: () => pickExtractor,
245
253
  pinOfLines: () => pinOfLines,
254
+ pre43Span: () => pre43Span,
246
255
  rangeLines: () => rangeLines,
247
256
  readCiteConfigFile: () => readCiteConfigFile,
248
257
  readPage: () => readPage,
@@ -281,6 +290,7 @@ __export(cite_exports, {
281
290
  splice: () => splice,
282
291
  spliceEntryField: () => spliceEntryField,
283
292
  splitLines: () => splitLines,
293
+ splitUnit: () => splitUnit,
284
294
  statementForms: () => statementForms,
285
295
  toBodyLines: () => toBodyLines,
286
296
  toFileLines: () => toFileLines,
@@ -288,6 +298,7 @@ __export(cite_exports, {
288
298
  twoManifestsRefusal: () => twoManifestsRefusal,
289
299
  unifiedDiff: () => unifiedDiff,
290
300
  unitAt: () => unitAt,
301
+ unitText: () => unitText,
291
302
  urlManifestRefusal: () => urlManifestRefusal,
292
303
  validateEntry: () => validateEntry,
293
304
  withClaimLines: () => withClaimLines
@@ -306,6 +317,7 @@ var CITE_RULES = [
306
317
  "marker-orphan",
307
318
  "marker-invalid",
308
319
  "marker-repeated",
320
+ "marker-misplaced",
309
321
  "anchor-invalid",
310
322
  "entry-invalid",
311
323
  "quote-drift"
@@ -340,6 +352,7 @@ var DEFAULT_SEVERITY = {
340
352
  "marker-orphan": "error",
341
353
  "marker-invalid": "error",
342
354
  "marker-repeated": "warning",
355
+ "marker-misplaced": "warning",
343
356
  "anchor-invalid": "error",
344
357
  "entry-invalid": "error",
345
358
  "quote-drift": "error"
@@ -437,6 +450,13 @@ function shortLine(text) {
437
450
  const one = text.replace(/\s+/g, " ").trim();
438
451
  return one.length <= LINE_WIDTH ? one : `${one.slice(0, LINE_WIDTH)}\u2026`;
439
452
  }
453
+ function spellAt(lines) {
454
+ return lines.start === lines.end ? `line ${String(lines.start)}` : `lines ${String(lines.start)}-${String(lines.end)}`;
455
+ }
456
+ function listOf(values) {
457
+ if (values.length <= 1) return values.join("");
458
+ return `${values.slice(0, -1).join(", ")} and ${values[values.length - 1] ?? ""}`;
459
+ }
440
460
 
441
461
  // src/cite/core/sources.ts
442
462
  import fg from "fast-glob";
@@ -524,6 +544,127 @@ async function readSource(root, index, range, key) {
524
544
  }
525
545
  }
526
546
 
547
+ // src/cite/core/page.ts
548
+ import { extname as extname2 } from "node:path";
549
+ import * as Ajv2020Ns from "ajv/dist/2020.js";
550
+
551
+ // src/cite/schema/citations.json
552
+ var citations_default = {
553
+ $schema: "https://json-schema.org/draft/2020-12/schema",
554
+ $id: "manni:citations:1.0.0-proposal.4",
555
+ title: "manni citations vocabulary (1.0.0-proposal.4)",
556
+ description: "What do this page's claims rest on? For each claim, the page lines that make it, the source lines it was written from, and a hash of each. A manni common vocabulary: any drift check can implement it, and other schemas may compose on top of it. A pin is a record of the past, not a copy of the present. It says what the lines were when the sentence was written. The check compares that record with the files as they are now. Recompute it on every run and there is nothing to compare against. `source-of-truth` (manni:stewardship) names the file a page answers to; a citation names the lines a sentence answers to. The page root stays open so sibling tools' keys pass untouched. The `citation-` prefix is reserved, so an unrecognized `citation-*` key is rejected and a typo fails loudly.",
557
+ type: "object",
558
+ additionalProperties: true,
559
+ properties: {
560
+ citations: {
561
+ type: "array",
562
+ minItems: 1,
563
+ items: {
564
+ $ref: "#/$defs/citationEntry"
565
+ },
566
+ description: "The page's citations, one entry per claim or pin. An empty list is not a declaration; a page with no citations omits the key. Ids are unique within a page, and two entries claiming one id is an error. JSON Schema cannot say that over a list whose members may omit the id. So it is a requirement on implementations, stated here so every tool agrees.",
567
+ "x-manni-location": "external"
568
+ }
569
+ },
570
+ patternProperties: {
571
+ "^citation-": false
572
+ },
573
+ $defs: {
574
+ citationEntry: {
575
+ type: "object",
576
+ additionalProperties: false,
577
+ required: [
578
+ "source"
579
+ ],
580
+ description: "One citation: the page text it supports, the source lines it rests on, and how the two are anchored. Both ends share one shape, a line range and a hash.",
581
+ properties: {
582
+ id: {
583
+ type: "string",
584
+ pattern: "^[a-z0-9][a-z0-9-]*$",
585
+ description: "A kebab-case name for this citation, unique within the page. A body marker names an entry by it, and findings are reported under it. Optional for an entry anchored by its claim lines or pinned bare. Required when a marker names the entry."
586
+ },
587
+ claim: {
588
+ $ref: "#/$defs/claim"
589
+ },
590
+ source: {
591
+ $ref: "#/$defs/source"
592
+ },
593
+ quote: {
594
+ type: "boolean",
595
+ default: false,
596
+ description: "The claim is a fenced block that reproduces the source lines. The pinned claim covers the whole block, fences included. An implementer compares the lines inside the fences with the source under the pin's rule. So a quoted snippet that drifts from its source is a finding even while the pin still holds. Under a marker, the block is the next fence after the marker. Default false."
597
+ }
598
+ }
599
+ },
600
+ claim: {
601
+ type: "object",
602
+ additionalProperties: false,
603
+ required: [
604
+ "integrity"
605
+ ],
606
+ description: "The page text the citation supports, pinned by lines and a hash. Absent, the entry is a bare pin, or a marker with no drift check on its sentence. Without `lines`, the text pinned is what a body marker naming this entry anchors.",
607
+ properties: {
608
+ lines: {
609
+ $ref: "#/$defs/lines",
610
+ description: "Lines of the page body. The body starts on the first line after the closing frontmatter fence, or on line 1 with no frontmatter. So editing the frontmatter never moves a claim. Required unless a marker names the entry, and never inside the frontmatter."
611
+ },
612
+ integrity: {
613
+ type: "string",
614
+ pattern: "^sha256-[0-9a-f]{64}$",
615
+ description: "`sha256-` and sixty-four lowercase hex digits over the claimed lines, under the rule `source.integrity` states. Always plain, never keyed: the page is public, so there is nothing to hide. With a marker, the lines hashed are the ones the marker anchors."
616
+ }
617
+ }
618
+ },
619
+ source: {
620
+ type: "object",
621
+ additionalProperties: false,
622
+ required: [
623
+ "file",
624
+ "integrity"
625
+ ],
626
+ description: "The lines the claim rests on: a file, an optional line range, a pin, and the commit the pin was taken at.",
627
+ properties: {
628
+ file: {
629
+ $ref: "#/$defs/fileRef"
630
+ },
631
+ lines: {
632
+ $ref: "#/$defs/lines",
633
+ description: "File lines. Absent, the whole file is pinned. The lines stay readable even when `file` is encrypted."
634
+ },
635
+ integrity: {
636
+ type: "string",
637
+ pattern: "^(?:sha256|hmac-sha256)-[0-9a-f]{64}$",
638
+ description: "The pin: an algorithm prefix and sixty-four lowercase hex digits over the cited lines under one rule. Decode as UTF-8 and drop one leading byte-order mark. Turn CRLF into LF, split on LF, and discard the empty element a trailing LF leaves. Take lines L1 to L2 inclusive, or every line for a whole file. Join with LF and no trailing LF, and keep trailing whitespace. A plain `file` is pinned `sha256-` over that text. An encrypted `file` is pinned `hmac-sha256-`: the HMAC-SHA256 of that text under a key derived from the encryption key. So a public page carries no verifier a reader could run against a guessed private line. The prefix follows `file`, and any other pairing is an invalid entry."
639
+ },
640
+ "commit-sha": {
641
+ type: "string",
642
+ pattern: "^[0-9a-f]{7,64}$",
643
+ description: "The git commit hash the source pin was taken at, as lowercase hex. Seven digits is what git abbreviates to; sixty-four covers a SHA-256 repository. A tool writes the full hash, because an abbreviation that is unique today need not be next year. Without it, a check can still say the lines changed. With it, the check can say since when, and whether the pin was ever true."
644
+ }
645
+ }
646
+ },
647
+ lines: {
648
+ oneOf: [
649
+ {
650
+ type: "integer",
651
+ minimum: 1
652
+ },
653
+ {
654
+ type: "string",
655
+ pattern: "^[1-9][0-9]*-[1-9][0-9]*$"
656
+ }
657
+ ],
658
+ description: 'A line range, 1-based and inclusive: an integer for one line, or `"L1-L2"` for several. `L1 <= L2` is a rule on implementations, since a pattern cannot compare two numbers. There is no line 0 to cite.'
659
+ },
660
+ fileRef: {
661
+ type: "string",
662
+ pattern: "^(?:~[A-Za-z0-9_-]{82,}|(?!~)(?:(?!\\.\\.?(?:/|$))[^/\\\\:\\r\\n\\t]+)(?:/(?:(?!\\.\\.?(?:/|$))[^/\\\\:\\r\\n\\t]+))*)$",
663
+ description: "The file: a path relative to the repository root, posix-separated, and free of `.` and `..` segments. Segments may hold spaces and dots but not `/`, `\\`, `:` or a line break. So a URL, a drive letter, a Windows path and an absolute path all fail here. In place of the path, `~` and at least 82 base64url characters is the path encrypted with the family key. Proposal 0045 states the construction. Only a checkout with the key and the files can decrypt the path and read the lines. So a public docs repo can cite a private code repo without publishing its paths. The value carries no encryption mark, because the citation tool encrypts it, and a plain path stays valid."
664
+ }
665
+ }
666
+ };
667
+
527
668
  // src/shared/code-regions.ts
528
669
  var SKIP_MARKDOWN_FENCE = /^[ \t]*(`{3,}|~{3,})/;
529
670
  var SKIP_ASCIIDOC_FENCE = /^(-{4,})/;
@@ -690,6 +831,25 @@ function isMarkerLine(line, format) {
690
831
  return isCite(text.slice(form.open.length, closeAt).trim());
691
832
  });
692
833
  }
834
+ var ATX_HEADING = /^[ \t]*#{1,6}([ \t]|$)/;
835
+ var ASCIIDOC_TITLE = /^={1,6}[ \t]/;
836
+ var ADORNMENT = /^[ \t]*([-=~^"'*+#_:<>])(?:[ \t]*\1){2,}[ \t]*$/;
837
+ var TAG_LINE = /^[ \t]*<\/?[A-Za-z][^<>]*>[ \t]*$/;
838
+ function isBoundLine(line, format) {
839
+ if (ADORNMENT.test(line)) return true;
840
+ switch (format) {
841
+ case "markdown":
842
+ case "mdx":
843
+ return ATX_HEADING.test(line) || TAG_LINE.test(line);
844
+ case "html":
845
+ case "xml":
846
+ return TAG_LINE.test(line);
847
+ case "asciidoc":
848
+ return ASCIIDOC_TITLE.test(line);
849
+ default:
850
+ return false;
851
+ }
852
+ }
693
853
  function parseStatements(body, format, from = { offset: 0, line: 1 }) {
694
854
  const out = [];
695
855
  const fileLine = (bodyOffset) => from.line + lineAt(body, bodyOffset) - 1;
@@ -732,6 +892,7 @@ function detectEol(text) {
732
892
  }
733
893
  function paragraphAfter(content, offset, format) {
734
894
  const marker = (text) => format !== void 0 && isMarkerLine(text, format);
895
+ const bound = (text) => format !== void 0 && isBoundLine(text, format);
735
896
  let pos = offset;
736
897
  const firstEnd = lineEnd(content, pos);
737
898
  if (lineText(content, pos, firstEnd).trim() === "") pos = firstEnd + 1;
@@ -745,11 +906,11 @@ function paragraphAfter(content, offset, format) {
745
906
  if (ANY_FENCE.test(text)) return void 0;
746
907
  const start = pos;
747
908
  let last = end;
748
- let cursor = end + 1;
909
+ let cursor = bound(text) ? content.length : end + 1;
749
910
  while (cursor < content.length) {
750
911
  const e = lineEnd(content, cursor);
751
912
  const t = lineText(content, cursor, e);
752
- if (t.trim() === "" || ANY_FENCE.test(t) || marker(t)) break;
913
+ if (t.trim() === "" || ANY_FENCE.test(t) || marker(t) || bound(t)) break;
753
914
  last = e;
754
915
  cursor = e + 1;
755
916
  }
@@ -864,9 +1025,11 @@ function unitHolding(text, line, format, body) {
864
1025
  }
865
1026
  if (ANY_FENCE.test(own)) return void 0;
866
1027
  let start = line;
867
- while (start - 1 >= body.line) {
1028
+ while (start - 1 >= body.line && !isBoundLine(own, format)) {
868
1029
  const above = textOf(start - 1);
869
- if (above.trim() === "" || ANY_FENCE.test(above) || isMarkerLine(above, format)) break;
1030
+ if (above.trim() === "" || ANY_FENCE.test(above) || isMarkerLine(above, format) || isBoundLine(above, format)) {
1031
+ break;
1032
+ }
870
1033
  start--;
871
1034
  }
872
1035
  const paragraph = paragraphAfter(text, offsetOfLine(text, start), format);
@@ -905,7 +1068,9 @@ function continuesList(text, line, format, bodyLine, column) {
905
1068
  if (columnOf(indentOf(textOfLine(text, n))) > column) return true;
906
1069
  for (; n >= bodyLine; n--) {
907
1070
  const above = textOfLine(text, n);
908
- if (above.trim() === "" || ANY_FENCE.test(above) || isMarkerLine(above, format)) break;
1071
+ if (above.trim() === "" || ANY_FENCE.test(above) || isMarkerLine(above, format) || isBoundLine(above, format)) {
1072
+ break;
1073
+ }
909
1074
  const item = listItemOf(above);
910
1075
  if (item !== void 0 && columnOf(item.lead) === column) return true;
911
1076
  }
@@ -958,730 +1123,764 @@ function offsetOfLine(content, line) {
958
1123
  return pos;
959
1124
  }
960
1125
 
961
- // src/cite/core/claims.ts
962
- function normalizeWhitespace(text) {
963
- return text.replace(/\s+/g, " ").trim();
964
- }
965
- function normalizeBlock(text) {
966
- let out = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
967
- out = out.replace(/\r\n/g, "\n");
968
- return out.endsWith("\n") ? out.slice(0, -1) : out;
969
- }
970
- function blockMatches(blockText, citedJoined) {
971
- return normalizeBlock(blockText) === normalizeBlock(citedJoined);
972
- }
973
- function textAt(lines, range) {
974
- return lines.slice(range.start - 1, Math.min(range.end, lines.length));
1126
+ // src/cite/core/page.ts
1127
+ var MAX_MARKERS_PER_PAGE = 500;
1128
+ var ELEMENT_BACKED = /* @__PURE__ */ new Set(["html", "xml"]);
1129
+ var FENCE_ONLY = /* @__PURE__ */ new Set(["markdown", "mdx"]);
1130
+ var MARKER_JSON = "A marker names an entry by id. Write the entry in frontmatter or the sidecar.";
1131
+ function ownedMessage(owner) {
1132
+ return `"citations" is owned by manifest ${owner.file} (collection ${owner.collection}); remove it from the document`;
975
1133
  }
976
- function claimLine(claim) {
977
- const at2 = claim.fileLines === void 0 ? void 0 : parseLines(claim.fileLines);
978
- return at2?.start;
1134
+ var Ajv2020 = Ajv2020Ns.default;
1135
+ var entryValidator;
1136
+ function compileEntryValidator() {
1137
+ if (entryValidator) return entryValidator;
1138
+ const ajv = new Ajv2020({ allErrors: false, strict: false });
1139
+ ajv.addSchema(citations_default);
1140
+ entryValidator = ajv.compile({
1141
+ $ref: `${citations_default.$id}#/$defs/citationEntry`
1142
+ });
1143
+ return entryValidator;
979
1144
  }
980
- function claimLineNow(claim) {
981
- const spec = claim.newFileLines ?? claim.candidateFileLines?.[0] ?? claim.fileLines;
982
- const at2 = spec === void 0 ? void 0 : parseLines(spec);
983
- return at2?.start;
1145
+ function validateEntry(entry) {
1146
+ const validate = compileEntryValidator();
1147
+ if (validate(entry)) return void 0;
1148
+ const first = validate.errors?.[0];
1149
+ if (!first) return "invalid entry";
1150
+ return `${first.instancePath} ${first.message ?? "is invalid"}`.trim();
984
1151
  }
985
- function claimLinesNow(claim) {
986
- const spec = claim.newFileLines ?? claim.candidateFileLines?.[0] ?? claim.fileLines;
987
- return spec === void 0 ? void 0 : parseLines(spec);
1152
+ function isRecord(value) {
1153
+ return typeof value === "object" && value !== null && !Array.isArray(value);
988
1154
  }
989
- function claimEnd(page, entry, lines) {
990
- const { citation, marker } = entry;
991
- const claim = citation.claim;
992
- if (claim === void 0) return null;
993
- const quote = citation.quote === true;
994
- const body = page.bodyLine;
995
- if (claim.lines !== void 0) {
996
- const recorded = parseLines(claim.lines);
997
- if (recorded === void 0) return { status: "skipped" };
998
- const file = toFileLines(recorded, body);
999
- const end = {
1000
- lines: spellLines(recorded),
1001
- fileLines: spellLines(file),
1002
- status: "changed"
1003
- };
1004
- if (marker !== void 0) return { ...end, status: "skipped" };
1005
- if (pinOfLines(lines, file) === claim.integrity) return { ...end, status: "current" };
1006
- const width = recorded.end - recorded.start + 1;
1007
- const found = findWindows(lines.slice(body - 1), width, claim.integrity, void 0, {
1008
- around: recorded.start
1009
- });
1010
- const spans = found.starts.map((start) => ({ start, end: start + width - 1 }));
1011
- const [only] = spans;
1012
- if (spans.length === 1 && only !== void 0) {
1013
- return {
1014
- ...end,
1015
- status: "moved",
1016
- newLines: spellLines(only),
1017
- newFileLines: spellLines(toFileLines(only, body))
1018
- };
1019
- }
1020
- if (spans.length > 1) {
1021
- return {
1022
- ...end,
1023
- status: "moved-ambiguous",
1024
- candidates: spans.map(spellLines),
1025
- candidateFileLines: spans.map((span) => spellLines(toFileLines(span, body)))
1026
- };
1155
+ function toCitation(entry) {
1156
+ const source = isRecord(entry.source) ? entry.source : {};
1157
+ const citation = {
1158
+ source: {
1159
+ file: String(source.file),
1160
+ integrity: String(source.integrity)
1027
1161
  }
1028
- return { ...end, text: textAt(lines, file) };
1162
+ };
1163
+ if (typeof source.lines === "number" || typeof source.lines === "string") {
1164
+ citation.source.lines = source.lines;
1029
1165
  }
1030
- if (marker !== void 0) {
1031
- const unit = anchoredLines(page.content, marker.end, page.format, quote);
1032
- if (unit === void 0) return { status: "changed" };
1033
- const end = { fileLines: spellLines(unit), status: "changed" };
1034
- if (pinOfLines(lines, unit) === claim.integrity) return { ...end, status: "current" };
1035
- return { ...end, text: textAt(lines, unit) };
1166
+ if (typeof source["commit-sha"] === "string") {
1167
+ citation.source["commit-sha"] = source["commit-sha"];
1036
1168
  }
1037
- return { status: "changed" };
1038
- }
1039
- function unitAt(page, line, lines) {
1040
- if (line < page.bodyLine || line > lines.length) return void 0;
1041
- if ((lines[line - 1] ?? "").trim() === "") return void 0;
1042
- const block = fenceSpanAt(page.content, line, page.format);
1043
- if (block !== void 0) {
1044
- return { lines: block, kind: "block", text: lines.slice(block.start - 1, block.end) };
1169
+ if (typeof entry.id === "string") citation.id = entry.id;
1170
+ if (isRecord(entry.claim)) {
1171
+ const claim = entry.claim;
1172
+ citation.claim = { integrity: String(claim.integrity) };
1173
+ if (typeof claim.lines === "number" || typeof claim.lines === "string") {
1174
+ citation.claim.lines = claim.lines;
1175
+ }
1045
1176
  }
1046
- if (insideFence(page.content, page.bodyOffset, line, page.format)) return void 0;
1047
- const paragraph = paragraphAfter(page.content, offsetOfLine(page.content, line));
1048
- if (paragraph === void 0 || paragraph.line !== line) return void 0;
1049
- const span = { start: line, end: lineAt(page.content, paragraph.end) };
1050
- return { lines: span, kind: "paragraph", text: lines.slice(span.start - 1, span.end) };
1177
+ if (typeof entry.quote === "boolean") citation.quote = entry.quote;
1178
+ return citation;
1051
1179
  }
1052
- function markerUnit(page, entry, lines) {
1053
- const { marker, citation } = entry;
1054
- if (marker === void 0) return void 0;
1055
- const span = anchoredLines(page.content, marker.end, page.format, citation.quote === true);
1056
- if (span === void 0 || span.end > lines.length) return void 0;
1057
- return {
1058
- lines: span,
1059
- kind: citation.quote === true ? "block" : "paragraph",
1060
- text: lines.slice(span.start - 1, span.end)
1180
+ function finding(rule, message, extra) {
1181
+ const level = DEFAULT_SEVERITY[rule];
1182
+ const out = {
1183
+ rule,
1184
+ ruleId: ruleId(rule),
1185
+ // The caller re-applies the configured severity table; no page-side rule
1186
+ // defaults to `off`, so there is nothing to collapse.
1187
+ severity: level === "off" ? "error" : level,
1188
+ message
1061
1189
  };
1190
+ if (extra.line !== void 0) out.line = extra.line;
1191
+ if (extra.file !== void 0) out.file = extra.file;
1192
+ if (extra.id !== void 0) out.id = extra.id;
1193
+ if (extra.src !== void 0) out.src = extra.src;
1194
+ if (extra.index !== void 0) out.index = extra.index;
1195
+ return out;
1062
1196
  }
1063
-
1064
- // src/cite/core/git.ts
1065
- import { execFile } from "node:child_process";
1066
- var MAX_BUFFER = 32 * 1024 * 1024;
1067
- var NO_COMMIT = /invalid object name|bad revision|unknown revision|bad object/i;
1068
- var NO_PATH = /does not exist in|exists on disk, but not in|path .* does not exist/i;
1069
- function run2(root, args) {
1070
- return new Promise((resolve9) => {
1071
- execFile(
1072
- "git",
1073
- ["-c", "core.quotepath=false", ...args],
1074
- { cwd: root, windowsHide: true, maxBuffer: MAX_BUFFER, encoding: "utf8" },
1075
- (error, stdout, stderr) => {
1076
- if (error) {
1077
- const failure = { ok: false, stderr, message: error.message };
1078
- if (typeof error.code === "number") failure.code = error.code;
1079
- resolve9(failure);
1080
- } else resolve9({ ok: true, stdout });
1081
- }
1082
- );
1083
- });
1084
- }
1085
- function gitClient(root) {
1086
- const memo = /* @__PURE__ */ new Map();
1087
- const once = (key, compute) => {
1088
- const held = memo.get(key);
1089
- if (held !== void 0) return held;
1090
- const made = compute();
1091
- memo.set(key, made);
1092
- return made;
1093
- };
1094
- const available = () => once("available", async () => (await run2(root, ["rev-parse", "--show-toplevel"])).ok);
1095
- const failed = (verb, outcome) => new CiteError(`git ${verb} failed: ${outcome.stderr.trim() || outcome.message}`);
1096
- const hasCommit = (commit) => once(`has\0${commit}`, async () => {
1097
- const out = await run2(root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}^{commit}`]);
1098
- if (out.ok) return true;
1099
- if (out.code === 1) return false;
1100
- throw failed("rev-parse", out);
1101
- });
1102
- return {
1103
- available,
1104
- head: () => once("head", async () => {
1105
- if (!await available()) return null;
1106
- const out = await run2(root, ["rev-parse", "HEAD"]);
1107
- return out.ok ? out.stdout.trim() : null;
1108
- }),
1109
- lsFiles: () => once("ls-files", async () => {
1110
- if (!await available()) return [];
1111
- const out = await run2(root, ["ls-files", "-z"]);
1112
- if (!out.ok) throw failed("ls-files", out);
1113
- return out.stdout.split("\0").filter((entry) => entry !== "");
1114
- }),
1115
- showFile: (commit, path) => once(`show\0${commit}\0${path}`, async () => {
1116
- const out = await run2(root, ["show", "--end-of-options", `${commit}:./${path}`]);
1117
- if (out.ok) return { text: out.stdout };
1118
- if (NO_COMMIT.test(out.stderr)) return { missing: "commit" };
1119
- if (NO_PATH.test(out.stderr)) {
1120
- return await hasCommit(commit) ? { missing: "path" } : { missing: "commit" };
1121
- }
1122
- throw failed("show", out);
1123
- }),
1124
- subjectsSince: (commit, path) => once(`log\0${commit}\0${path}`, async () => {
1125
- const out = await run2(root, ["log", "--format=%s", "--end-of-options", `${commit}..HEAD`, "--", path]);
1126
- if (!out.ok) throw failed("log", out);
1127
- return out.stdout.split(/\r?\n/).filter((line) => line !== "");
1128
- }),
1129
- diffSince: (commit, path) => once(`diff\0${commit}\0${path}`, async () => {
1130
- const out = await run2(root, ["diff", "--end-of-options", commit, "--", path]);
1131
- if (!out.ok) throw failed("diff", out);
1132
- return out.stdout;
1133
- })
1134
- };
1197
+ function named(id, text) {
1198
+ return id === void 0 ? text : `${id}: ${text}`;
1135
1199
  }
1136
- var GIT_UNAVAILABLE_HISTORY = "git is not available here, so citations are checked without history: no never-true, no diffs, no commit subjects.";
1137
- var GIT_UNAVAILABLE_COMMIT = "git is not available here, so the citation records no commit.";
1138
- function noGit() {
1139
- return {
1140
- available: () => Promise.resolve(false),
1141
- head: () => Promise.resolve(null),
1142
- lsFiles: () => Promise.resolve([]),
1143
- showFile: () => Promise.resolve({ missing: "commit" }),
1144
- subjectsSince: () => Promise.resolve([]),
1145
- diffSince: () => Promise.resolve("")
1146
- };
1200
+ function subjectOf(entry) {
1201
+ const out = {};
1202
+ if (!isRecord(entry)) return out;
1203
+ if (typeof entry.id === "string") out.id = entry.id;
1204
+ if (isRecord(entry.source) && typeof entry.source.file === "string") out.src = entry.source.file;
1205
+ return out;
1147
1206
  }
1148
-
1149
- // src/cite/core/classify.ts
1150
- async function historyOf(git, commit, path, range, pin, key, budget) {
1151
- const shown = await git.showFile(commit, path);
1152
- if ("missing" in shown) {
1153
- return shown.missing === "commit" ? { kind: "unavailable" } : { kind: "never-true" };
1154
- }
1155
- const then = splitLines(shown.text);
1156
- let joined;
1157
- try {
1158
- joined = sliceLines2(then, range);
1159
- } catch {
1160
- joined = void 0;
1161
- }
1162
- if (joined !== void 0 && hashLines(joined, key) === pin) {
1163
- return { kind: "original", lines: joined.split("\n") };
1207
+ function pickExtractor(file, format) {
1208
+ if (format !== void 0) {
1209
+ const forced = extractorByName(format);
1210
+ if (forced === void 0) {
1211
+ throw new CiteError(
1212
+ `Unknown format "${format}". Supported extensions: ${supportedExtensions().join(", ")}.`
1213
+ );
1214
+ }
1215
+ return forced;
1164
1216
  }
1165
- if (range.start === void 0) return { kind: "never-true" };
1166
- const length = (range.end ?? range.start) - range.start + 1;
1167
- const search = { around: range.start };
1168
- if (budget !== void 0) search.budget = budget;
1169
- const found = findWindows(then, length, pin, key, search);
1170
- const [first] = found.starts;
1171
- if (first !== void 0) {
1172
- return { kind: "original", lines: then.slice(first - 1, first - 1 + length) };
1217
+ const extension = extname2(file);
1218
+ const byExt = extractorForExtension(extension);
1219
+ if (!byExt) {
1220
+ throw new CiteError(
1221
+ `Unsupported file type "${extension}" for "${file}". Supported: ${supportedExtensions().join(", ")}. Use --as to override.`
1222
+ );
1173
1223
  }
1174
- return found.truncated ? { kind: "unknown" } : { kind: "never-true" };
1224
+ return byExt;
1175
1225
  }
1176
- async function classifyCitation(entry, opts) {
1177
- const { citation } = entry;
1178
- const range = sourceRange(citation.source);
1179
- const commit = citation.source["commit-sha"];
1180
- const result = { src: spellSource(citation.source), status: "missing" };
1181
- if (commit !== void 0) result.commitSha = commit;
1182
- const source = await readSource(opts.root, opts.index, range, opts.key);
1183
- if (source.kind === "missing") {
1184
- result.missingReason = source.reason;
1185
- return result;
1226
+ function byLine(a, b) {
1227
+ return (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER);
1228
+ }
1229
+ function bodyLineOf(content, bodyOffset) {
1230
+ if (bodyOffset <= 0) return 1;
1231
+ const line = lineAt(content, bodyOffset);
1232
+ return content.charCodeAt(bodyOffset - 1) === 10 ? line : line + 1;
1233
+ }
1234
+ function citationInputs(file, extracted, injected) {
1235
+ if (injected !== void 0) return [...injected];
1236
+ const raw = extracted.data.citations;
1237
+ const list = Array.isArray(raw) ? raw : [];
1238
+ return list.map((entry, index) => {
1239
+ const origin = { kind: "frontmatter", file };
1240
+ const line = extracted.lineFor(`/citations/${String(index)}`);
1241
+ if (line !== void 0) origin.line = line;
1242
+ return { entry, origin };
1243
+ });
1244
+ }
1245
+ function readPage(file, content, opts) {
1246
+ const extractor = pickExtractor(file, opts?.format);
1247
+ const format = extractor.name;
1248
+ if (FENCE_ONLY.has(format) && hasFrontmatterFence(content) && locateFrontmatter(content) === null) {
1249
+ throw new CiteError(
1250
+ `${file}: Unterminated front matter fence: the opening fence has no matching close, so the page's citations cannot be read. Add a closing fence.`
1251
+ );
1186
1252
  }
1187
- result.resolvedPath = source.resolvedPath;
1188
- const key = range.encrypted ? opts.key : void 0;
1189
- const pin = citation.source.integrity;
1190
- const lines = splitLines(source.text);
1191
- let here;
1192
- try {
1193
- here = hashLines(sliceLines2(lines, range), key);
1194
- } catch {
1195
- here = void 0;
1253
+ const extracted = extractor.extract(content, file);
1254
+ const data = extracted.data;
1255
+ const bodyOffset = ELEMENT_BACKED.has(format) ? 0 : locateFrontmatter(content)?.closeEnd ?? 0;
1256
+ const bodyLine = bodyLineOf(content, bodyOffset);
1257
+ const findings = [];
1258
+ const citations = [];
1259
+ const byId = /* @__PURE__ */ new Map();
1260
+ const ids = /* @__PURE__ */ new Set();
1261
+ const markedAt = /* @__PURE__ */ new Map();
1262
+ for (const key of Object.keys(data)) {
1263
+ if (!key.startsWith("citation-")) continue;
1264
+ findings.push(
1265
+ finding(
1266
+ "entry-invalid",
1267
+ `"${key}" is not a citations key; the page root reserves the citation- prefix`,
1268
+ { line: extracted.lineFor(`/${key}`) }
1269
+ )
1270
+ );
1196
1271
  }
1197
- if (here === pin) {
1198
- result.status = "current";
1199
- return result;
1272
+ const injected = opts?.citations;
1273
+ const rawCitations = data.citations;
1274
+ if (opts?.owned !== void 0 && rawCitations !== void 0) {
1275
+ findings.push(
1276
+ finding("entry-invalid", ownedMessage(opts.owned), {
1277
+ line: extracted.lineFor("/citations")
1278
+ })
1279
+ );
1200
1280
  }
1201
- let history = { kind: "none" };
1202
- if (commit !== void 0 && await opts.git.available()) {
1203
- history = await historyOf(opts.git, commit, source.resolvedPath, range, pin, key, opts.budget);
1281
+ if (injected === void 0 && rawCitations !== void 0 && !Array.isArray(rawCitations)) {
1282
+ findings.push(
1283
+ finding("entry-invalid", "citations must be an array of entries", {
1284
+ line: extracted.lineFor("/citations")
1285
+ })
1286
+ );
1204
1287
  }
1205
- if (range.start !== void 0) {
1206
- const start = range.start;
1207
- const end = range.end ?? start;
1208
- const search = { around: start };
1209
- if (history.kind === "original") search.original = history.lines;
1210
- if (opts.budget !== void 0) search.budget = opts.budget;
1211
- const found = findWindows(lines, end - start + 1, pin, key, search);
1212
- if (found.truncated) result.truncatedSearch = true;
1213
- const spell = (at2) => formatSrc({ ...range, start: at2, end: at2 + (end - start) });
1214
- const [only] = found.starts;
1215
- if (found.starts.length === 1 && only !== void 0) {
1216
- result.status = "moved";
1217
- result.newSrc = spell(only);
1218
- result.newLines = String(lineSpec({ start: only, end: only + (end - start) }));
1219
- return result;
1288
+ const inputs = citationInputs(file, extracted, injected);
1289
+ inputs.forEach((input, index) => {
1290
+ const origin = { ...input.origin, index };
1291
+ const where = { index };
1292
+ if (origin.line !== void 0) where.line = origin.line;
1293
+ if (origin.kind === "manifest") where.file = origin.file;
1294
+ const entry = input.entry;
1295
+ const error = validateEntry(entry);
1296
+ if (error !== void 0 || !isRecord(entry)) {
1297
+ findings.push(finding("entry-invalid", error ?? "must be object", { ...where, ...subjectOf(entry) }));
1298
+ return;
1220
1299
  }
1221
- if (found.starts.length > 1) {
1222
- result.status = "moved-ambiguous";
1223
- result.candidates = found.starts.map(spell);
1224
- return result;
1300
+ const citation = toCitation(entry);
1301
+ const subject = { ...where, src: spellSource(citation.source) };
1302
+ if (citation.id !== void 0) subject.id = citation.id;
1303
+ const bad = ["claim", "source"].find((end) => {
1304
+ const lines = end === "claim" ? citation.claim?.lines : citation.source.lines;
1305
+ return lines !== void 0 && parseLines(lines) === void 0;
1306
+ });
1307
+ if (bad !== void 0) {
1308
+ const lines = bad === "claim" ? citation.claim?.lines : citation.source.lines;
1309
+ findings.push(
1310
+ finding(
1311
+ "entry-invalid",
1312
+ named(citation.id, `${bad}.lines "${String(lines)}" ends before it starts`),
1313
+ subject
1314
+ )
1315
+ );
1316
+ return;
1317
+ }
1318
+ const wide = ["claim", "source"].map((end) => {
1319
+ const lines = end === "claim" ? citation.claim?.lines : citation.source.lines;
1320
+ const parsed = lines === void 0 ? void 0 : parseLines(lines);
1321
+ const reason = parsed === void 0 ? void 0 : tooWide(parsed);
1322
+ return reason === void 0 ? void 0 : `${end}.lines "${String(lines)}" ${reason}`;
1323
+ }).find((message) => message !== void 0);
1324
+ if (wide !== void 0) {
1325
+ findings.push(finding("entry-invalid", named(citation.id, wide), subject));
1326
+ return;
1327
+ }
1328
+ const encrypted = isEncryptedValue(citation.source.file);
1329
+ if (encrypted !== isKeyedPin(citation.source.integrity)) {
1330
+ findings.push(
1331
+ finding(
1332
+ "entry-invalid",
1333
+ named(
1334
+ citation.id,
1335
+ encrypted ? "an encrypted source is pinned with hmac-sha256-, not sha256-." : "a plain source is pinned with sha256-, not hmac-sha256-."
1336
+ ),
1337
+ subject
1338
+ )
1339
+ );
1340
+ return;
1341
+ }
1342
+ const page = { citation, origin };
1343
+ citations.push(page);
1344
+ if (citation.id !== void 0) {
1345
+ if (ids.has(citation.id)) {
1346
+ findings.push(finding("entry-invalid", `duplicate id "${citation.id}"`, subject));
1347
+ } else {
1348
+ ids.add(citation.id);
1349
+ byId.set(citation.id, page);
1350
+ }
1225
1351
  }
1352
+ });
1353
+ const body = content.slice(bodyOffset);
1354
+ const statements = parseStatements(body, format, {
1355
+ offset: bodyOffset,
1356
+ line: bodyLine
1357
+ });
1358
+ if (statements.length > MAX_MARKERS_PER_PAGE) {
1359
+ findings.push(
1360
+ finding(
1361
+ "marker-invalid",
1362
+ `more than ${String(MAX_MARKERS_PER_PAGE)} markers on one page (${String(statements.length)}); the rest are not read`,
1363
+ { line: statements[MAX_MARKERS_PER_PAGE]?.line }
1364
+ )
1365
+ );
1226
1366
  }
1227
- if (history.kind === "never-true") {
1228
- result.status = "never-true";
1229
- return result;
1367
+ for (const statement of statements.slice(0, MAX_MARKERS_PER_PAGE)) {
1368
+ const { payload } = statement;
1369
+ if (payload.kind === "bad") {
1370
+ findings.push(
1371
+ finding(
1372
+ "marker-invalid",
1373
+ payload.json === true ? MARKER_JSON : `invalid marker: ${payload.reason}`,
1374
+ { line: statement.line }
1375
+ )
1376
+ );
1377
+ continue;
1378
+ }
1379
+ const target = byId.get(payload.id);
1380
+ if (target === void 0) {
1381
+ findings.push(
1382
+ finding("marker-orphan", `no entry has id "${payload.id}"`, {
1383
+ line: statement.line,
1384
+ id: payload.id
1385
+ })
1386
+ );
1387
+ continue;
1388
+ }
1389
+ const first = markedAt.get(target);
1390
+ if (first !== void 0) {
1391
+ findings.push(
1392
+ finding(
1393
+ "marker-repeated",
1394
+ `${payload.id} is named by markers at lines ${String(first)} and ${String(statement.line)}; the first anchors it.`,
1395
+ {
1396
+ line: statement.line,
1397
+ id: payload.id,
1398
+ index: target.origin.index,
1399
+ src: spellSource(target.citation.source)
1400
+ }
1401
+ )
1402
+ );
1403
+ continue;
1404
+ }
1405
+ markedAt.set(target, statement.line);
1406
+ target.marker = statement;
1230
1407
  }
1231
- result.status = "changed";
1232
- if (history.kind === "unavailable") result.historyAvailable = false;
1233
- if (history.kind === "unknown") result.truncatedSearch = true;
1234
- if (history.kind === "original" && commit !== void 0) {
1235
- result.historyAvailable = true;
1236
- result.commitsSince = await opts.git.subjectsSince(commit, source.resolvedPath);
1237
- result.diff = await opts.git.diffSince(commit, source.resolvedPath);
1408
+ for (const entry of citations) {
1409
+ const { citation, origin, marker } = entry;
1410
+ const subject = { index: origin.index, src: spellSource(citation.source) };
1411
+ if (citation.id !== void 0) subject.id = citation.id;
1412
+ if (origin.line !== void 0) subject.line = origin.line;
1413
+ if (origin.kind === "manifest") subject.file = origin.file;
1414
+ if (citation.claim?.lines !== void 0 && marker !== void 0) {
1415
+ const onPage = { index: origin.index, line: marker.line, src: subject.src };
1416
+ if (citation.id !== void 0) onPage.id = citation.id;
1417
+ findings.push(
1418
+ finding(
1419
+ "anchor-invalid",
1420
+ `${citation.id ?? "the entry"} has claim lines and a marker. Keep one.`,
1421
+ onPage
1422
+ )
1423
+ );
1424
+ continue;
1425
+ }
1426
+ if (citation.quote === true && citation.claim?.lines === void 0 && marker === void 0) {
1427
+ findings.push(
1428
+ finding("anchor-invalid", named(citation.id, "quote needs a claim or a marker."), subject)
1429
+ );
1430
+ }
1238
1431
  }
1239
- return result;
1432
+ return {
1433
+ file,
1434
+ format,
1435
+ content,
1436
+ bodyOffset,
1437
+ bodyLine,
1438
+ citations,
1439
+ statements,
1440
+ findings: findings.sort(byLine)
1441
+ };
1240
1442
  }
1241
1443
 
1242
- // src/cite/core/page.ts
1243
- import { extname as extname2 } from "node:path";
1244
- import * as Ajv2020Ns from "ajv/dist/2020.js";
1245
-
1246
- // src/cite/schema/citations.json
1247
- var citations_default = {
1248
- $schema: "https://json-schema.org/draft/2020-12/schema",
1249
- $id: "manni:citations:1.0.0-proposal.4",
1250
- title: "manni citations vocabulary (1.0.0-proposal.4)",
1251
- description: "What do this page's claims rest on? For each claim, the page lines that make it, the source lines it was written from, and a hash of each. A manni common vocabulary: any drift check can implement it, and other schemas may compose on top of it. A pin is a record of the past, not a copy of the present. It says what the lines were when the sentence was written. The check compares that record with the files as they are now. Recompute it on every run and there is nothing to compare against. `source-of-truth` (manni:stewardship) names the file a page answers to; a citation names the lines a sentence answers to. The page root stays open so sibling tools' keys pass untouched. The `citation-` prefix is reserved, so an unrecognized `citation-*` key is rejected and a typo fails loudly.",
1252
- type: "object",
1253
- additionalProperties: true,
1254
- properties: {
1255
- citations: {
1256
- type: "array",
1257
- minItems: 1,
1258
- items: {
1259
- $ref: "#/$defs/citationEntry"
1260
- },
1261
- description: "The page's citations, one entry per claim or pin. An empty list is not a declaration; a page with no citations omits the key. Ids are unique within a page, and two entries claiming one id is an error. JSON Schema cannot say that over a list whose members may omit the id. So it is a requirement on implementations, stated here so every tool agrees.",
1262
- "x-manni-location": "external"
1263
- }
1264
- },
1265
- patternProperties: {
1266
- "^citation-": false
1267
- },
1268
- $defs: {
1269
- citationEntry: {
1270
- type: "object",
1271
- additionalProperties: false,
1272
- required: [
1273
- "source"
1274
- ],
1275
- description: "One citation: the page text it supports, the source lines it rests on, and how the two are anchored. Both ends share one shape, a line range and a hash.",
1276
- properties: {
1277
- id: {
1278
- type: "string",
1279
- pattern: "^[a-z0-9][a-z0-9-]*$",
1280
- description: "A kebab-case name for this citation, unique within the page. A body marker names an entry by it, and findings are reported under it. Optional for an entry anchored by its claim lines or pinned bare. Required when a marker names the entry."
1281
- },
1282
- claim: {
1283
- $ref: "#/$defs/claim"
1284
- },
1285
- source: {
1286
- $ref: "#/$defs/source"
1287
- },
1288
- quote: {
1289
- type: "boolean",
1290
- default: false,
1291
- description: "The claim is a fenced block that reproduces the source lines. The pinned claim covers the whole block, fences included. An implementer compares the lines inside the fences with the source under the pin's rule. So a quoted snippet that drifts from its source is a finding even while the pin still holds. Under a marker, the block is the next fence after the marker. Default false."
1292
- }
1293
- }
1294
- },
1295
- claim: {
1296
- type: "object",
1297
- additionalProperties: false,
1298
- required: [
1299
- "integrity"
1300
- ],
1301
- description: "The page text the citation supports, pinned by lines and a hash. Absent, the entry is a bare pin, or a marker with no drift check on its sentence. Without `lines`, the text pinned is what a body marker naming this entry anchors.",
1302
- properties: {
1303
- lines: {
1304
- $ref: "#/$defs/lines",
1305
- description: "Lines of the page body. The body starts on the first line after the closing frontmatter fence, or on line 1 with no frontmatter. So editing the frontmatter never moves a claim. Required unless a marker names the entry, and never inside the frontmatter."
1306
- },
1307
- integrity: {
1308
- type: "string",
1309
- pattern: "^sha256-[0-9a-f]{64}$",
1310
- description: "`sha256-` and sixty-four lowercase hex digits over the claimed lines, under the rule `source.integrity` states. Always plain, never keyed: the page is public, so there is nothing to hide. With a marker, the lines hashed are the ones the marker anchors."
1311
- }
1312
- }
1313
- },
1314
- source: {
1315
- type: "object",
1316
- additionalProperties: false,
1317
- required: [
1318
- "file",
1319
- "integrity"
1320
- ],
1321
- description: "The lines the claim rests on: a file, an optional line range, a pin, and the commit the pin was taken at.",
1322
- properties: {
1323
- file: {
1324
- $ref: "#/$defs/fileRef"
1325
- },
1326
- lines: {
1327
- $ref: "#/$defs/lines",
1328
- description: "File lines. Absent, the whole file is pinned. The lines stay readable even when `file` is encrypted."
1329
- },
1330
- integrity: {
1331
- type: "string",
1332
- pattern: "^(?:sha256|hmac-sha256)-[0-9a-f]{64}$",
1333
- description: "The pin: an algorithm prefix and sixty-four lowercase hex digits over the cited lines under one rule. Decode as UTF-8 and drop one leading byte-order mark. Turn CRLF into LF, split on LF, and discard the empty element a trailing LF leaves. Take lines L1 to L2 inclusive, or every line for a whole file. Join with LF and no trailing LF, and keep trailing whitespace. A plain `file` is pinned `sha256-` over that text. An encrypted `file` is pinned `hmac-sha256-`: the HMAC-SHA256 of that text under a key derived from the encryption key. So a public page carries no verifier a reader could run against a guessed private line. The prefix follows `file`, and any other pairing is an invalid entry."
1334
- },
1335
- "commit-sha": {
1336
- type: "string",
1337
- pattern: "^[0-9a-f]{7,64}$",
1338
- description: "The git commit hash the source pin was taken at, as lowercase hex. Seven digits is what git abbreviates to; sixty-four covers a SHA-256 repository. A tool writes the full hash, because an abbreviation that is unique today need not be next year. Without it, a check can still say the lines changed. With it, the check can say since when, and whether the pin was ever true."
1339
- }
1340
- }
1341
- },
1342
- lines: {
1343
- oneOf: [
1344
- {
1345
- type: "integer",
1346
- minimum: 1
1347
- },
1348
- {
1349
- type: "string",
1350
- pattern: "^[1-9][0-9]*-[1-9][0-9]*$"
1351
- }
1352
- ],
1353
- description: 'A line range, 1-based and inclusive: an integer for one line, or `"L1-L2"` for several. `L1 <= L2` is a rule on implementations, since a pattern cannot compare two numbers. There is no line 0 to cite.'
1354
- },
1355
- fileRef: {
1356
- type: "string",
1357
- pattern: "^(?:~[A-Za-z0-9_-]{82,}|(?!~)(?:(?!\\.\\.?(?:/|$))[^/\\\\:\\r\\n\\t]+)(?:/(?:(?!\\.\\.?(?:/|$))[^/\\\\:\\r\\n\\t]+))*)$",
1358
- description: "The file: a path relative to the repository root, posix-separated, and free of `.` and `..` segments. Segments may hold spaces and dots but not `/`, `\\`, `:` or a line break. So a URL, a drive letter, a Windows path and an absolute path all fail here. In place of the path, `~` and at least 82 base64url characters is the path encrypted with the family key. Proposal 0045 states the construction. Only a checkout with the key and the files can decrypt the path and read the lines. So a public docs repo can cite a private code repo without publishing its paths. The value carries no encryption mark, because the citation tool encrypts it, and a plain path stays valid."
1444
+ // src/cite/core/reanchor.ts
1445
+ function isUnitText(line, format) {
1446
+ if (line === void 0) return false;
1447
+ if (line.trim() === "") return false;
1448
+ if (ANY_FENCE.test(line)) return false;
1449
+ if (isMarkerLine(line, format)) return false;
1450
+ return !isBoundLine(line, format);
1451
+ }
1452
+ function markerAt(lines, n, format) {
1453
+ const line = lines[n - 1];
1454
+ return line !== void 0 && isMarkerLine(line, format);
1455
+ }
1456
+ function markerRunAt(lines, line, format, bodyLine) {
1457
+ let start = line;
1458
+ while (start - 1 >= bodyLine && markerAt(lines, start - 1, format)) start--;
1459
+ let end = line;
1460
+ while (end + 1 <= lines.length && markerAt(lines, end + 1, format)) end++;
1461
+ return { start, end };
1462
+ }
1463
+ function splitUnit(lines, run3, format, bodyLine) {
1464
+ if (run3.start - 1 < bodyLine) return void 0;
1465
+ if (!isUnitText(lines[run3.start - 2], format)) return void 0;
1466
+ if (!isUnitText(lines[run3.end], format)) return void 0;
1467
+ let start = run3.start - 1;
1468
+ while (start - 1 >= bodyLine && isUnitText(lines[start - 2], format)) start--;
1469
+ let end = run3.end + 1;
1470
+ while (end + 1 <= lines.length && isUnitText(lines[end], format)) end++;
1471
+ return { start, end };
1472
+ }
1473
+ function pre43Span(lines, markerLine) {
1474
+ const start = markerLine + 1;
1475
+ const first = lines[start - 1];
1476
+ if (first === void 0 || first.trim() === "" || ANY_FENCE.test(first)) return void 0;
1477
+ let end = start;
1478
+ while (end + 1 <= lines.length) {
1479
+ const next = lines[end];
1480
+ if (next === void 0 || next.trim() === "" || ANY_FENCE.test(next)) break;
1481
+ end++;
1482
+ }
1483
+ return { start, end };
1484
+ }
1485
+ function runLength(run3) {
1486
+ return run3.end - run3.start + 1;
1487
+ }
1488
+ function misplacedMarkerAt(page, lines, line, entry) {
1489
+ if (!markerAt(lines, line, page.format)) return void 0;
1490
+ const run3 = markerRunAt(lines, line, page.format, page.bodyLine);
1491
+ const unit = splitUnit(lines, run3, page.format, page.bodyLine);
1492
+ if (unit === void 0) return void 0;
1493
+ const order = line - run3.start;
1494
+ const quote = entry?.citation.quote === true;
1495
+ const marker = entry?.marker;
1496
+ const blockAt = quote && marker !== void 0 ? anchoredLines(page.content, marker.end, page.format, true)?.start : void 0;
1497
+ const out = blockAt === void 0 ? { line, run: run3, order, unit, place: unit.start, block: false, to: unit.start + order } : {
1498
+ line,
1499
+ run: run3,
1500
+ order,
1501
+ unit,
1502
+ place: blockAt,
1503
+ block: true,
1504
+ to: blockAt - runLength(run3) + order
1505
+ };
1506
+ if (entry !== void 0) out.entry = entry;
1507
+ return out;
1508
+ }
1509
+ function misplacedMarkers(page, lines) {
1510
+ const out = [];
1511
+ for (const statement of page.statements.slice(0, MAX_MARKERS_PER_PAGE)) {
1512
+ const entry = page.citations.find((c) => c.marker?.line === statement.line);
1513
+ const found = misplacedMarkerAt(page, lines, statement.line, entry);
1514
+ if (found !== void 0) out.push(found);
1515
+ }
1516
+ return out;
1517
+ }
1518
+ function movedUnit(misplaced) {
1519
+ return { start: misplaced.unit.start + runLength(misplaced.run), end: misplaced.unit.end };
1520
+ }
1521
+ function unitText(lines, misplaced) {
1522
+ const { unit, run: run3 } = misplaced;
1523
+ const out = [];
1524
+ for (let n = unit.start; n <= unit.end; n++) {
1525
+ if (n >= run3.start && n <= run3.end) continue;
1526
+ const line = lines[n - 1];
1527
+ if (line !== void 0) out.push(line);
1528
+ }
1529
+ return out;
1530
+ }
1531
+ function splitKeepingEol(content) {
1532
+ const parts = content.match(/[^\n]*\n|[^\n]+$/g) ?? [];
1533
+ const texts = [];
1534
+ const eols = [];
1535
+ for (const part of parts) {
1536
+ const eol = /\r?\n$/.exec(part)?.[0] ?? "";
1537
+ texts.push(part.slice(0, part.length - eol.length));
1538
+ eols.push(eol);
1539
+ }
1540
+ return { texts, eols };
1541
+ }
1542
+ function applyMarkerMoves(content, moves, indentAt) {
1543
+ const { texts, eols } = splitKeepingEol(content);
1544
+ const moving = new Set(moves.map((move) => move.from));
1545
+ const inserts = /* @__PURE__ */ new Map();
1546
+ for (const move of moves) {
1547
+ const list = inserts.get(move.place) ?? [];
1548
+ list.push(move.from);
1549
+ inserts.set(move.place, list);
1550
+ }
1551
+ const order = [];
1552
+ for (let n = 1; n <= texts.length; n++) {
1553
+ for (const from of inserts.get(n) ?? []) order.push(from);
1554
+ if (!moving.has(n)) order.push(n);
1555
+ }
1556
+ const moved = /* @__PURE__ */ new Map();
1557
+ for (const move of moves) {
1558
+ moved.set(move.from, indentAt(move.place) + (texts[move.from - 1] ?? "").trim());
1559
+ }
1560
+ const at2 = /* @__PURE__ */ new Map();
1561
+ order.forEach((old, index) => at2.set(old, index + 1));
1562
+ const out = order.map((old, index) => (moved.get(old) ?? texts[old - 1] ?? "") + (eols[index] ?? "")).join("");
1563
+ return { content: out, line: (old) => at2.get(old) ?? old };
1564
+ }
1565
+ function pinOf(lines, span) {
1566
+ if (span.start < 1 || span.end > lines.length || span.end < span.start) return void 0;
1567
+ return hashLines(lines.slice(span.start - 1, span.end).join("\n"));
1568
+ }
1569
+ function markerClaimEnd(page, entry, lines, misplaced) {
1570
+ const { citation, marker } = entry;
1571
+ const pin = citation.claim?.integrity;
1572
+ const quote = citation.quote === true;
1573
+ if (marker === void 0 || pin === void 0) return { status: "changed" };
1574
+ const current = anchoredLines(page.content, marker.end, page.format, quote);
1575
+ if (current === void 0) return { status: "changed" };
1576
+ const claimAt = (span, status, to) => {
1577
+ const end = { fileLines: spellLines(span), status };
1578
+ if (to !== void 0) {
1579
+ end.newLines = spellLines(to);
1580
+ end.newFileLines = end.newLines;
1359
1581
  }
1582
+ if (status === "changed") end.text = lines.slice(span.start - 1, Math.min(span.end, lines.length));
1583
+ return end;
1584
+ };
1585
+ if (quote) {
1586
+ return claimAt(current, pinOf(lines, current) === pin ? "current" : "changed");
1360
1587
  }
1361
- };
1588
+ if (misplaced !== void 0) {
1589
+ const to = movedUnit(misplaced);
1590
+ if (hashLines(unitText(lines, misplaced).join("\n")) === pin) return claimAt(to, "current");
1591
+ if (pinOf(lines, current) === pin) return claimAt(current, "moved", to);
1592
+ const pre432 = pre43Span(lines, marker.line);
1593
+ if (pre432 !== void 0 && pinOf(lines, pre432) === pin) return claimAt(pre432, "moved", to);
1594
+ return claimAt(current, "changed");
1595
+ }
1596
+ if (pinOf(lines, current) === pin) return claimAt(current, "current");
1597
+ const pre43 = pre43Span(lines, marker.line);
1598
+ if (pre43 !== void 0 && pinOf(lines, pre43) === pin) return claimAt(pre43, "moved", current);
1599
+ return claimAt(current, "changed");
1600
+ }
1362
1601
 
1363
- // src/cite/core/page.ts
1364
- var MAX_MARKERS_PER_PAGE = 500;
1365
- var ELEMENT_BACKED = /* @__PURE__ */ new Set(["html", "xml"]);
1366
- var FENCE_ONLY = /* @__PURE__ */ new Set(["markdown", "mdx"]);
1367
- var MARKER_JSON = "A marker names an entry by id. Write the entry in frontmatter or the sidecar.";
1368
- function ownedMessage(owner) {
1369
- return `"citations" is owned by manifest ${owner.file} (collection ${owner.collection}); remove it from the document`;
1602
+ // src/cite/core/claims.ts
1603
+ function normalizeWhitespace(text) {
1604
+ return text.replace(/\s+/g, " ").trim();
1370
1605
  }
1371
- var Ajv2020 = Ajv2020Ns.default;
1372
- var entryValidator;
1373
- function compileEntryValidator() {
1374
- if (entryValidator) return entryValidator;
1375
- const ajv = new Ajv2020({ allErrors: false, strict: false });
1376
- ajv.addSchema(citations_default);
1377
- entryValidator = ajv.compile({
1378
- $ref: `${citations_default.$id}#/$defs/citationEntry`
1379
- });
1380
- return entryValidator;
1606
+ function normalizeBlock(text) {
1607
+ let out = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1608
+ out = out.replace(/\r\n/g, "\n");
1609
+ return out.endsWith("\n") ? out.slice(0, -1) : out;
1381
1610
  }
1382
- function validateEntry(entry) {
1383
- const validate = compileEntryValidator();
1384
- if (validate(entry)) return void 0;
1385
- const first = validate.errors?.[0];
1386
- if (!first) return "invalid entry";
1387
- return `${first.instancePath} ${first.message ?? "is invalid"}`.trim();
1611
+ function blockMatches(blockText, citedJoined) {
1612
+ return normalizeBlock(blockText) === normalizeBlock(citedJoined);
1388
1613
  }
1389
- function isRecord(value) {
1390
- return typeof value === "object" && value !== null && !Array.isArray(value);
1614
+ function textAt(lines, range) {
1615
+ return lines.slice(range.start - 1, Math.min(range.end, lines.length));
1391
1616
  }
1392
- function toCitation(entry) {
1393
- const source = isRecord(entry.source) ? entry.source : {};
1394
- const citation = {
1395
- source: {
1396
- file: String(source.file),
1397
- integrity: String(source.integrity)
1617
+ function claimLine(claim) {
1618
+ const at2 = claim.fileLines === void 0 ? void 0 : parseLines(claim.fileLines);
1619
+ return at2?.start;
1620
+ }
1621
+ function claimLineNow(claim) {
1622
+ const spec = claim.newFileLines ?? claim.candidateFileLines?.[0] ?? claim.fileLines;
1623
+ const at2 = spec === void 0 ? void 0 : parseLines(spec);
1624
+ return at2?.start;
1625
+ }
1626
+ function claimLinesNow(claim) {
1627
+ const spec = claim.newFileLines ?? claim.candidateFileLines?.[0] ?? claim.fileLines;
1628
+ return spec === void 0 ? void 0 : parseLines(spec);
1629
+ }
1630
+ function claimEnd(page, entry, lines, misplaced) {
1631
+ const { citation, marker } = entry;
1632
+ const claim = citation.claim;
1633
+ if (claim === void 0) return null;
1634
+ const body = page.bodyLine;
1635
+ if (claim.lines !== void 0) {
1636
+ const recorded = parseLines(claim.lines);
1637
+ if (recorded === void 0) return { status: "skipped" };
1638
+ const file = toFileLines(recorded, body);
1639
+ const end = {
1640
+ lines: spellLines(recorded),
1641
+ fileLines: spellLines(file),
1642
+ status: "changed"
1643
+ };
1644
+ if (marker !== void 0) return { ...end, status: "skipped" };
1645
+ if (pinOfLines(lines, file) === claim.integrity) return { ...end, status: "current" };
1646
+ const width = recorded.end - recorded.start + 1;
1647
+ const found = findWindows(lines.slice(body - 1), width, claim.integrity, void 0, {
1648
+ around: recorded.start
1649
+ });
1650
+ const spans = found.starts.map((start) => ({ start, end: start + width - 1 }));
1651
+ const [only] = spans;
1652
+ if (spans.length === 1 && only !== void 0) {
1653
+ return {
1654
+ ...end,
1655
+ status: "moved",
1656
+ newLines: spellLines(only),
1657
+ newFileLines: spellLines(toFileLines(only, body))
1658
+ };
1398
1659
  }
1660
+ if (spans.length > 1) {
1661
+ return {
1662
+ ...end,
1663
+ status: "moved-ambiguous",
1664
+ candidates: spans.map(spellLines),
1665
+ candidateFileLines: spans.map((span) => spellLines(toFileLines(span, body)))
1666
+ };
1667
+ }
1668
+ return { ...end, text: textAt(lines, file) };
1669
+ }
1670
+ if (marker !== void 0) {
1671
+ return markerClaimEnd(
1672
+ page,
1673
+ entry,
1674
+ lines,
1675
+ misplaced ?? misplacedMarkerAt(page, lines, marker.line, entry)
1676
+ );
1677
+ }
1678
+ return { status: "changed" };
1679
+ }
1680
+ function unitAt(page, line, lines) {
1681
+ if (line < page.bodyLine || line > lines.length) return void 0;
1682
+ if ((lines[line - 1] ?? "").trim() === "") return void 0;
1683
+ const block = fenceSpanAt(page.content, line, page.format);
1684
+ if (block !== void 0) {
1685
+ return { lines: block, kind: "block", text: lines.slice(block.start - 1, block.end) };
1686
+ }
1687
+ if (insideFence(page.content, page.bodyOffset, line, page.format)) return void 0;
1688
+ const paragraph = paragraphAfter(page.content, offsetOfLine(page.content, line));
1689
+ if (paragraph === void 0 || paragraph.line !== line) return void 0;
1690
+ const span = { start: line, end: lineAt(page.content, paragraph.end) };
1691
+ return { lines: span, kind: "paragraph", text: lines.slice(span.start - 1, span.end) };
1692
+ }
1693
+ function markerUnit(page, entry, lines) {
1694
+ const { marker, citation } = entry;
1695
+ if (marker === void 0) return void 0;
1696
+ const span = anchoredLines(page.content, marker.end, page.format, citation.quote === true);
1697
+ if (span === void 0 || span.end > lines.length) return void 0;
1698
+ return {
1699
+ lines: span,
1700
+ kind: citation.quote === true ? "block" : "paragraph",
1701
+ text: lines.slice(span.start - 1, span.end)
1702
+ };
1703
+ }
1704
+
1705
+ // src/cite/core/git.ts
1706
+ import { execFile } from "node:child_process";
1707
+ var MAX_BUFFER = 32 * 1024 * 1024;
1708
+ var NO_COMMIT = /invalid object name|bad revision|unknown revision|bad object/i;
1709
+ var NO_PATH = /does not exist in|exists on disk, but not in|path .* does not exist/i;
1710
+ function run2(root, args) {
1711
+ return new Promise((resolve9) => {
1712
+ execFile(
1713
+ "git",
1714
+ ["-c", "core.quotepath=false", ...args],
1715
+ { cwd: root, windowsHide: true, maxBuffer: MAX_BUFFER, encoding: "utf8" },
1716
+ (error, stdout, stderr) => {
1717
+ if (error) {
1718
+ const failure = { ok: false, stderr, message: error.message };
1719
+ if (typeof error.code === "number") failure.code = error.code;
1720
+ resolve9(failure);
1721
+ } else resolve9({ ok: true, stdout });
1722
+ }
1723
+ );
1724
+ });
1725
+ }
1726
+ function gitClient(root) {
1727
+ const memo = /* @__PURE__ */ new Map();
1728
+ const once = (key, compute) => {
1729
+ const held = memo.get(key);
1730
+ if (held !== void 0) return held;
1731
+ const made = compute();
1732
+ memo.set(key, made);
1733
+ return made;
1734
+ };
1735
+ const available = () => once("available", async () => (await run2(root, ["rev-parse", "--show-toplevel"])).ok);
1736
+ const failed = (verb, outcome) => new CiteError(`git ${verb} failed: ${outcome.stderr.trim() || outcome.message}`);
1737
+ const hasCommit = (commit) => once(`has\0${commit}`, async () => {
1738
+ const out = await run2(root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}^{commit}`]);
1739
+ if (out.ok) return true;
1740
+ if (out.code === 1) return false;
1741
+ throw failed("rev-parse", out);
1742
+ });
1743
+ return {
1744
+ available,
1745
+ head: () => once("head", async () => {
1746
+ if (!await available()) return null;
1747
+ const out = await run2(root, ["rev-parse", "HEAD"]);
1748
+ return out.ok ? out.stdout.trim() : null;
1749
+ }),
1750
+ lsFiles: () => once("ls-files", async () => {
1751
+ if (!await available()) return [];
1752
+ const out = await run2(root, ["ls-files", "-z"]);
1753
+ if (!out.ok) throw failed("ls-files", out);
1754
+ return out.stdout.split("\0").filter((entry) => entry !== "");
1755
+ }),
1756
+ showFile: (commit, path) => once(`show\0${commit}\0${path}`, async () => {
1757
+ const out = await run2(root, ["show", "--end-of-options", `${commit}:./${path}`]);
1758
+ if (out.ok) return { text: out.stdout };
1759
+ if (NO_COMMIT.test(out.stderr)) return { missing: "commit" };
1760
+ if (NO_PATH.test(out.stderr)) {
1761
+ return await hasCommit(commit) ? { missing: "path" } : { missing: "commit" };
1762
+ }
1763
+ throw failed("show", out);
1764
+ }),
1765
+ subjectsSince: (commit, path) => once(`log\0${commit}\0${path}`, async () => {
1766
+ const out = await run2(root, ["log", "--format=%s", "--end-of-options", `${commit}..HEAD`, "--", path]);
1767
+ if (!out.ok) throw failed("log", out);
1768
+ return out.stdout.split(/\r?\n/).filter((line) => line !== "");
1769
+ }),
1770
+ diffSince: (commit, path) => once(`diff\0${commit}\0${path}`, async () => {
1771
+ const out = await run2(root, ["diff", "--end-of-options", commit, "--", path]);
1772
+ if (!out.ok) throw failed("diff", out);
1773
+ return out.stdout;
1774
+ })
1399
1775
  };
1400
- if (typeof source.lines === "number" || typeof source.lines === "string") {
1401
- citation.source.lines = source.lines;
1402
- }
1403
- if (typeof source["commit-sha"] === "string") {
1404
- citation.source["commit-sha"] = source["commit-sha"];
1405
- }
1406
- if (typeof entry.id === "string") citation.id = entry.id;
1407
- if (isRecord(entry.claim)) {
1408
- const claim = entry.claim;
1409
- citation.claim = { integrity: String(claim.integrity) };
1410
- if (typeof claim.lines === "number" || typeof claim.lines === "string") {
1411
- citation.claim.lines = claim.lines;
1412
- }
1413
- }
1414
- if (typeof entry.quote === "boolean") citation.quote = entry.quote;
1415
- return citation;
1416
1776
  }
1417
- function finding(rule, message, extra) {
1418
- const level = DEFAULT_SEVERITY[rule];
1419
- const out = {
1420
- rule,
1421
- ruleId: ruleId(rule),
1422
- // The caller re-applies the configured severity table; no page-side rule
1423
- // defaults to `off`, so there is nothing to collapse.
1424
- severity: level === "off" ? "error" : level,
1425
- message
1777
+ var GIT_UNAVAILABLE_HISTORY = "git is not available here, so citations are checked without history: no never-true, no diffs, no commit subjects.";
1778
+ var GIT_UNAVAILABLE_COMMIT = "git is not available here, so the citation records no commit.";
1779
+ function noGit() {
1780
+ return {
1781
+ available: () => Promise.resolve(false),
1782
+ head: () => Promise.resolve(null),
1783
+ lsFiles: () => Promise.resolve([]),
1784
+ showFile: () => Promise.resolve({ missing: "commit" }),
1785
+ subjectsSince: () => Promise.resolve([]),
1786
+ diffSince: () => Promise.resolve("")
1426
1787
  };
1427
- if (extra.line !== void 0) out.line = extra.line;
1428
- if (extra.file !== void 0) out.file = extra.file;
1429
- if (extra.id !== void 0) out.id = extra.id;
1430
- if (extra.src !== void 0) out.src = extra.src;
1431
- if (extra.index !== void 0) out.index = extra.index;
1432
- return out;
1433
- }
1434
- function named(id, text) {
1435
- return id === void 0 ? text : `${id}: ${text}`;
1436
- }
1437
- function subjectOf(entry) {
1438
- const out = {};
1439
- if (!isRecord(entry)) return out;
1440
- if (typeof entry.id === "string") out.id = entry.id;
1441
- if (isRecord(entry.source) && typeof entry.source.file === "string") out.src = entry.source.file;
1442
- return out;
1443
1788
  }
1444
- function pickExtractor(file, format) {
1445
- if (format !== void 0) {
1446
- const forced = extractorByName(format);
1447
- if (forced === void 0) {
1448
- throw new CiteError(
1449
- `Unknown format "${format}". Supported extensions: ${supportedExtensions().join(", ")}.`
1450
- );
1451
- }
1452
- return forced;
1453
- }
1454
- const extension = extname2(file);
1455
- const byExt = extractorForExtension(extension);
1456
- if (!byExt) {
1457
- throw new CiteError(
1458
- `Unsupported file type "${extension}" for "${file}". Supported: ${supportedExtensions().join(", ")}. Use --as to override.`
1459
- );
1789
+
1790
+ // src/cite/core/classify.ts
1791
+ async function historyOf(git, commit, path, range, pin, key, budget) {
1792
+ const shown = await git.showFile(commit, path);
1793
+ if ("missing" in shown) {
1794
+ return shown.missing === "commit" ? { kind: "unavailable" } : { kind: "never-true" };
1460
1795
  }
1461
- return byExt;
1462
- }
1463
- function byLine(a, b) {
1464
- return (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER);
1465
- }
1466
- function bodyLineOf(content, bodyOffset) {
1467
- if (bodyOffset <= 0) return 1;
1468
- const line = lineAt(content, bodyOffset);
1469
- return content.charCodeAt(bodyOffset - 1) === 10 ? line : line + 1;
1470
- }
1471
- function citationInputs(file, extracted, injected) {
1472
- if (injected !== void 0) return [...injected];
1473
- const raw = extracted.data.citations;
1474
- const list = Array.isArray(raw) ? raw : [];
1475
- return list.map((entry, index) => {
1476
- const origin = { kind: "frontmatter", file };
1477
- const line = extracted.lineFor(`/citations/${String(index)}`);
1478
- if (line !== void 0) origin.line = line;
1479
- return { entry, origin };
1480
- });
1481
- }
1482
- function readPage(file, content, opts) {
1483
- const extractor = pickExtractor(file, opts?.format);
1484
- const format = extractor.name;
1485
- if (FENCE_ONLY.has(format) && hasFrontmatterFence(content) && locateFrontmatter(content) === null) {
1486
- throw new CiteError(
1487
- `${file}: Unterminated front matter fence: the opening fence has no matching close, so the page's citations cannot be read. Add a closing fence.`
1488
- );
1796
+ const then = splitLines(shown.text);
1797
+ let joined;
1798
+ try {
1799
+ joined = sliceLines2(then, range);
1800
+ } catch {
1801
+ joined = void 0;
1489
1802
  }
1490
- const extracted = extractor.extract(content, file);
1491
- const data = extracted.data;
1492
- const bodyOffset = ELEMENT_BACKED.has(format) ? 0 : locateFrontmatter(content)?.closeEnd ?? 0;
1493
- const bodyLine = bodyLineOf(content, bodyOffset);
1494
- const findings = [];
1495
- const citations = [];
1496
- const byId = /* @__PURE__ */ new Map();
1497
- const ids = /* @__PURE__ */ new Set();
1498
- const markedAt = /* @__PURE__ */ new Map();
1499
- for (const key of Object.keys(data)) {
1500
- if (!key.startsWith("citation-")) continue;
1501
- findings.push(
1502
- finding(
1503
- "entry-invalid",
1504
- `"${key}" is not a citations key; the page root reserves the citation- prefix`,
1505
- { line: extracted.lineFor(`/${key}`) }
1506
- )
1507
- );
1803
+ if (joined !== void 0 && hashLines(joined, key) === pin) {
1804
+ return { kind: "original", lines: joined.split("\n") };
1508
1805
  }
1509
- const injected = opts?.citations;
1510
- const rawCitations = data.citations;
1511
- if (opts?.owned !== void 0 && rawCitations !== void 0) {
1512
- findings.push(
1513
- finding("entry-invalid", ownedMessage(opts.owned), {
1514
- line: extracted.lineFor("/citations")
1515
- })
1516
- );
1806
+ if (range.start === void 0) return { kind: "never-true" };
1807
+ const length = (range.end ?? range.start) - range.start + 1;
1808
+ const search = { around: range.start };
1809
+ if (budget !== void 0) search.budget = budget;
1810
+ const found = findWindows(then, length, pin, key, search);
1811
+ const [first] = found.starts;
1812
+ if (first !== void 0) {
1813
+ return { kind: "original", lines: then.slice(first - 1, first - 1 + length) };
1517
1814
  }
1518
- if (injected === void 0 && rawCitations !== void 0 && !Array.isArray(rawCitations)) {
1519
- findings.push(
1520
- finding("entry-invalid", "citations must be an array of entries", {
1521
- line: extracted.lineFor("/citations")
1522
- })
1523
- );
1815
+ return found.truncated ? { kind: "unknown" } : { kind: "never-true" };
1816
+ }
1817
+ async function classifyCitation(entry, opts) {
1818
+ const { citation } = entry;
1819
+ const range = sourceRange(citation.source);
1820
+ const commit = citation.source["commit-sha"];
1821
+ const result = { src: spellSource(citation.source), status: "missing" };
1822
+ if (commit !== void 0) result.commitSha = commit;
1823
+ const source = await readSource(opts.root, opts.index, range, opts.key);
1824
+ if (source.kind === "missing") {
1825
+ result.missingReason = source.reason;
1826
+ return result;
1524
1827
  }
1525
- const inputs = citationInputs(file, extracted, injected);
1526
- inputs.forEach((input, index) => {
1527
- const origin = { ...input.origin, index };
1528
- const where = { index };
1529
- if (origin.line !== void 0) where.line = origin.line;
1530
- if (origin.kind === "manifest") where.file = origin.file;
1531
- const entry = input.entry;
1532
- const error = validateEntry(entry);
1533
- if (error !== void 0 || !isRecord(entry)) {
1534
- findings.push(finding("entry-invalid", error ?? "must be object", { ...where, ...subjectOf(entry) }));
1535
- return;
1536
- }
1537
- const citation = toCitation(entry);
1538
- const subject = { ...where, src: spellSource(citation.source) };
1539
- if (citation.id !== void 0) subject.id = citation.id;
1540
- const bad = ["claim", "source"].find((end) => {
1541
- const lines = end === "claim" ? citation.claim?.lines : citation.source.lines;
1542
- return lines !== void 0 && parseLines(lines) === void 0;
1543
- });
1544
- if (bad !== void 0) {
1545
- const lines = bad === "claim" ? citation.claim?.lines : citation.source.lines;
1546
- findings.push(
1547
- finding(
1548
- "entry-invalid",
1549
- named(citation.id, `${bad}.lines "${String(lines)}" ends before it starts`),
1550
- subject
1551
- )
1552
- );
1553
- return;
1554
- }
1555
- const wide = ["claim", "source"].map((end) => {
1556
- const lines = end === "claim" ? citation.claim?.lines : citation.source.lines;
1557
- const parsed = lines === void 0 ? void 0 : parseLines(lines);
1558
- const reason = parsed === void 0 ? void 0 : tooWide(parsed);
1559
- return reason === void 0 ? void 0 : `${end}.lines "${String(lines)}" ${reason}`;
1560
- }).find((message) => message !== void 0);
1561
- if (wide !== void 0) {
1562
- findings.push(finding("entry-invalid", named(citation.id, wide), subject));
1563
- return;
1564
- }
1565
- const encrypted = isEncryptedValue(citation.source.file);
1566
- if (encrypted !== isKeyedPin(citation.source.integrity)) {
1567
- findings.push(
1568
- finding(
1569
- "entry-invalid",
1570
- named(
1571
- citation.id,
1572
- encrypted ? "an encrypted source is pinned with hmac-sha256-, not sha256-." : "a plain source is pinned with sha256-, not hmac-sha256-."
1573
- ),
1574
- subject
1575
- )
1576
- );
1577
- return;
1578
- }
1579
- const page = { citation, origin };
1580
- citations.push(page);
1581
- if (citation.id !== void 0) {
1582
- if (ids.has(citation.id)) {
1583
- findings.push(finding("entry-invalid", `duplicate id "${citation.id}"`, subject));
1584
- } else {
1585
- ids.add(citation.id);
1586
- byId.set(citation.id, page);
1587
- }
1588
- }
1589
- });
1590
- const body = content.slice(bodyOffset);
1591
- const statements = parseStatements(body, format, {
1592
- offset: bodyOffset,
1593
- line: bodyLine
1594
- });
1595
- if (statements.length > MAX_MARKERS_PER_PAGE) {
1596
- findings.push(
1597
- finding(
1598
- "marker-invalid",
1599
- `more than ${String(MAX_MARKERS_PER_PAGE)} markers on one page (${String(statements.length)}); the rest are not read`,
1600
- { line: statements[MAX_MARKERS_PER_PAGE]?.line }
1601
- )
1602
- );
1828
+ result.resolvedPath = source.resolvedPath;
1829
+ const key = range.encrypted ? opts.key : void 0;
1830
+ const pin = citation.source.integrity;
1831
+ const lines = splitLines(source.text);
1832
+ let here;
1833
+ try {
1834
+ here = hashLines(sliceLines2(lines, range), key);
1835
+ } catch {
1836
+ here = void 0;
1603
1837
  }
1604
- for (const statement of statements.slice(0, MAX_MARKERS_PER_PAGE)) {
1605
- const { payload } = statement;
1606
- if (payload.kind === "bad") {
1607
- findings.push(
1608
- finding(
1609
- "marker-invalid",
1610
- payload.json === true ? MARKER_JSON : `invalid marker: ${payload.reason}`,
1611
- { line: statement.line }
1612
- )
1613
- );
1614
- continue;
1615
- }
1616
- const target = byId.get(payload.id);
1617
- if (target === void 0) {
1618
- findings.push(
1619
- finding("marker-orphan", `no entry has id "${payload.id}"`, {
1620
- line: statement.line,
1621
- id: payload.id
1622
- })
1623
- );
1624
- continue;
1625
- }
1626
- const first = markedAt.get(target);
1627
- if (first !== void 0) {
1628
- findings.push(
1629
- finding(
1630
- "marker-repeated",
1631
- `${payload.id} is named by markers at lines ${String(first)} and ${String(statement.line)}; the first anchors it.`,
1632
- {
1633
- line: statement.line,
1634
- id: payload.id,
1635
- index: target.origin.index,
1636
- src: spellSource(target.citation.source)
1637
- }
1638
- )
1639
- );
1640
- continue;
1641
- }
1642
- markedAt.set(target, statement.line);
1643
- target.marker = statement;
1838
+ if (here === pin) {
1839
+ result.status = "current";
1840
+ return result;
1644
1841
  }
1645
- for (const entry of citations) {
1646
- const { citation, origin, marker } = entry;
1647
- const subject = { index: origin.index, src: spellSource(citation.source) };
1648
- if (citation.id !== void 0) subject.id = citation.id;
1649
- if (origin.line !== void 0) subject.line = origin.line;
1650
- if (origin.kind === "manifest") subject.file = origin.file;
1651
- if (citation.claim?.lines !== void 0 && marker !== void 0) {
1652
- const onPage = { index: origin.index, line: marker.line, src: subject.src };
1653
- if (citation.id !== void 0) onPage.id = citation.id;
1654
- findings.push(
1655
- finding(
1656
- "anchor-invalid",
1657
- `${citation.id ?? "the entry"} has claim lines and a marker. Keep one.`,
1658
- onPage
1659
- )
1660
- );
1661
- continue;
1842
+ let history = { kind: "none" };
1843
+ if (commit !== void 0 && await opts.git.available()) {
1844
+ history = await historyOf(opts.git, commit, source.resolvedPath, range, pin, key, opts.budget);
1845
+ }
1846
+ if (range.start !== void 0) {
1847
+ const start = range.start;
1848
+ const end = range.end ?? start;
1849
+ const search = { around: start };
1850
+ if (history.kind === "original") search.original = history.lines;
1851
+ if (opts.budget !== void 0) search.budget = opts.budget;
1852
+ const found = findWindows(lines, end - start + 1, pin, key, search);
1853
+ if (found.truncated) result.truncatedSearch = true;
1854
+ const spell = (at2) => formatSrc({ ...range, start: at2, end: at2 + (end - start) });
1855
+ const [only] = found.starts;
1856
+ if (found.starts.length === 1 && only !== void 0) {
1857
+ result.status = "moved";
1858
+ result.newSrc = spell(only);
1859
+ result.newLines = String(lineSpec({ start: only, end: only + (end - start) }));
1860
+ return result;
1662
1861
  }
1663
- if (citation.quote === true && citation.claim?.lines === void 0 && marker === void 0) {
1664
- findings.push(
1665
- finding("anchor-invalid", named(citation.id, "quote needs a claim or a marker."), subject)
1666
- );
1862
+ if (found.starts.length > 1) {
1863
+ result.status = "moved-ambiguous";
1864
+ result.candidates = found.starts.map(spell);
1865
+ return result;
1667
1866
  }
1668
1867
  }
1669
- return {
1670
- file,
1671
- format,
1672
- content,
1673
- bodyOffset,
1674
- bodyLine,
1675
- citations,
1676
- statements,
1677
- findings: findings.sort(byLine)
1678
- };
1868
+ if (history.kind === "never-true") {
1869
+ result.status = "never-true";
1870
+ return result;
1871
+ }
1872
+ result.status = "changed";
1873
+ if (history.kind === "unavailable") result.historyAvailable = false;
1874
+ if (history.kind === "unknown") result.truncatedSearch = true;
1875
+ if (history.kind === "original" && commit !== void 0) {
1876
+ result.historyAvailable = true;
1877
+ result.commitsSince = await opts.git.subjectsSince(commit, source.resolvedPath);
1878
+ result.diff = await opts.git.diffSince(commit, source.resolvedPath);
1879
+ }
1880
+ return result;
1679
1881
  }
1680
1882
 
1681
1883
  // src/cite/core/shift.ts
1682
- function spellAt(lines) {
1683
- return lines.start === lines.end ? `line ${String(lines.start)}` : `lines ${String(lines.start)}-${String(lines.end)}`;
1684
- }
1685
1884
  function shiftedEntries(opts) {
1686
1885
  const { citations, at: at2, delta, bodyLine, label } = opts;
1687
1886
  const out = { frontmatter: [], manifest: /* @__PURE__ */ new Map() };
@@ -2168,10 +2367,6 @@ function short(commit) {
2168
2367
  function plural(n, noun) {
2169
2368
  return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
2170
2369
  }
2171
- function listOf(values) {
2172
- if (values.length <= 1) return values.join("");
2173
- return `${values.slice(0, -1).join(", ")} and ${values[values.length - 1] ?? ""}`;
2174
- }
2175
2370
  function citesEncrypted(src) {
2176
2371
  try {
2177
2372
  return parseSrc(src).encrypted;
@@ -2249,6 +2444,13 @@ function claimMessageFor(result) {
2249
2444
  return "";
2250
2445
  }
2251
2446
  }
2447
+ function misplacedMessageFor(id, marker) {
2448
+ const place = marker.block ? `above the block at line ${String(marker.place)}` : `above line ${String(marker.place)}`;
2449
+ return named2(
2450
+ id,
2451
+ `the marker at line ${String(marker.line)} splits the paragraph at ${at(marker.unit)}. Its place is ${place}.`
2452
+ );
2453
+ }
2252
2454
  function sourceRuleOf(status) {
2253
2455
  if (status === "skipped" || status === "current") return void 0;
2254
2456
  return `source-${status}`;
@@ -2372,7 +2574,7 @@ async function quoteFindings(input, entry, result) {
2372
2574
  const { citation } = entry;
2373
2575
  if (citation.quote !== true || result.anchor === null) return [];
2374
2576
  const id = citation.id;
2375
- const named3 = (text) => id === void 0 ? text : `${id}: ${text}`;
2577
+ const named4 = (text) => id === void 0 ? text : `${id}: ${text}`;
2376
2578
  const { page } = input;
2377
2579
  let block;
2378
2580
  if (result.anchor === "marker" && entry.marker !== void 0) {
@@ -2394,7 +2596,7 @@ async function quoteFindings(input, entry, result) {
2394
2596
  return [
2395
2597
  {
2396
2598
  rule: "anchor-invalid",
2397
- message: named3(
2599
+ message: named4(
2398
2600
  `the quote's claim lines ${at2.start === at2.end ? String(at2.start) : `${String(at2.start)}-${String(at2.end)}`} are no longer a fenced block.`
2399
2601
  ),
2400
2602
  line: at2.start
@@ -2416,6 +2618,17 @@ async function quoteFindings(input, entry, result) {
2416
2618
  }
2417
2619
  ];
2418
2620
  }
2621
+ function markerEnd(entry, misplaced) {
2622
+ const { marker } = entry;
2623
+ if (marker === void 0) return {};
2624
+ if (misplaced === void 0) return { marker: { line: marker.line } };
2625
+ return {
2626
+ marker: {
2627
+ line: marker.line,
2628
+ misplaced: { unit: spellLines(misplaced.unit), to: misplaced.to }
2629
+ }
2630
+ };
2631
+ }
2419
2632
  function anchorOf(entry, claim, page) {
2420
2633
  const { citation, marker } = entry;
2421
2634
  const hasLines = citation.claim?.lines !== void 0;
@@ -2462,13 +2675,17 @@ async function checkCitations(page, opts) {
2462
2675
  checkSources,
2463
2676
  ...opts.key === void 0 ? {} : { key: opts.key }
2464
2677
  };
2678
+ const misplaced = misplacedMarkers(read, lines);
2679
+ const misplacedAt = new Map(misplaced.map((found) => [found.line, found]));
2465
2680
  for (const entry of read.citations) {
2466
- const claim = claimEnd(read, entry, lines);
2681
+ const split = entry.marker === void 0 ? void 0 : misplacedAt.get(entry.marker.line);
2682
+ const claim = claimEnd(read, entry, lines, split);
2467
2683
  const source = classifyOpts === void 0 ? skippedSource(entry) : await classifyCitation(entry, classifyOpts);
2468
2684
  const result = {
2469
2685
  citation: entry.citation,
2470
2686
  origin: entry.origin,
2471
2687
  ...anchorOf(entry, claim, read),
2688
+ ...markerEnd(entry, split),
2472
2689
  claim,
2473
2690
  source
2474
2691
  };
@@ -2489,6 +2706,30 @@ async function checkCitations(page, opts) {
2489
2706
  findings.push(finding2);
2490
2707
  }
2491
2708
  }
2709
+ const misplacedLevel = severity["marker-misplaced"];
2710
+ if (misplacedLevel !== "off") {
2711
+ for (const found of misplaced) {
2712
+ const entry = found.entry;
2713
+ const finding2 = {
2714
+ rule: "marker-misplaced",
2715
+ ruleId: ruleId("marker-misplaced"),
2716
+ severity: misplacedLevel,
2717
+ message: misplacedMessageFor(entry?.citation.id, {
2718
+ line: found.line,
2719
+ unit: spellLines(found.unit),
2720
+ place: found.place,
2721
+ block: found.block
2722
+ }),
2723
+ line: found.line
2724
+ };
2725
+ if (entry !== void 0) {
2726
+ finding2.index = entry.origin.index;
2727
+ finding2.src = spellSource(entry.citation.source);
2728
+ if (entry.citation.id !== void 0) finding2.id = entry.citation.id;
2729
+ }
2730
+ findings.push(finding2);
2731
+ }
2732
+ }
2492
2733
  findings.push(...findingsFor(results, severity));
2493
2734
  const wantsHistory = checkSources && read.citations.some(({ citation }) => citation.source["commit-sha"] !== void 0);
2494
2735
  if (wantsHistory && !await client.available()) notices.push(GIT_UNAVAILABLE_HISTORY);
@@ -3022,11 +3263,11 @@ var NAMED_SPANS = 3;
3022
3263
  function spellElsewhere(spans) {
3023
3264
  const first = spans[0];
3024
3265
  const noun = spans.length === 1 && first !== void 0 && first.start === first.end ? "line" : "lines";
3025
- const named3 = spans.slice(0, NAMED_SPANS).map((span) => spellLines(span));
3026
- const rest = spans.length - named3.length;
3027
- if (rest > 0) return `${noun} ${named3.join(", ")} and ${String(rest)} more`;
3028
- const last = named3.pop() ?? "";
3029
- return named3.length === 0 ? `${noun} ${last}` : `${noun} ${named3.join(", ")} and ${last}`;
3266
+ const named4 = spans.slice(0, NAMED_SPANS).map((span) => spellLines(span));
3267
+ const rest = spans.length - named4.length;
3268
+ if (rest > 0) return `${noun} ${named4.join(", ")} and ${String(rest)} more`;
3269
+ const last = named4.pop() ?? "";
3270
+ return named4.length === 0 ? `${noun} ${last}` : `${noun} ${named4.join(", ")} and ${last}`;
3030
3271
  }
3031
3272
  async function runAdd(opts) {
3032
3273
  const cwd = resolve4(opts.cwd ?? process.cwd());
@@ -3123,11 +3364,28 @@ async function runAdd(opts) {
3123
3364
  const empty = emptyClaimRefusal(lines, pageLines, at2);
3124
3365
  if (empty !== void 0) throw new CiteError(empty);
3125
3366
  }
3367
+ const split = misplacedMarkers(page, lines);
3368
+ const held = split.find(
3369
+ (found) => found.line >= pageLines.start && found.line <= pageLines.end
3370
+ );
3371
+ if (held !== void 0) {
3372
+ throw new CiteError(
3373
+ `${at2} holds the marker at line ${String(held.line)}, which splits its paragraph. Run manni cite update first.`
3374
+ );
3375
+ }
3376
+ const inside = marker ? split.find(
3377
+ (found) => pageLines.start >= found.unit.start && pageLines.start <= found.unit.end
3378
+ ) : void 0;
3379
+ if (inside !== void 0) {
3380
+ throw new CiteError(
3381
+ `${at2} is in the paragraph at ${spellAt(inside.unit)}, which the marker at line ${String(inside.line)} splits. Run manni cite update first.`
3382
+ );
3383
+ }
3126
3384
  }
3127
3385
  const client = opts.gitClient ?? gitClient(root);
3128
3386
  const sourceIndex = await sourceIndexFor(root, client);
3129
3387
  let body = content;
3130
- let markerAt;
3388
+ let markerAt2;
3131
3389
  let claimSpan;
3132
3390
  let claim;
3133
3391
  let elsewhere = [];
@@ -3161,7 +3419,7 @@ async function runAdd(opts) {
3161
3419
  const statement = markerIndent(content, holding.start, format, page.bodyLine) + formatStatement(format, { kind: "ref", id: opts.id ?? "" });
3162
3420
  const insertAt = offsetOfLine(content, holding.start);
3163
3421
  body = insertStatementBefore(content, insertAt, statement);
3164
- markerAt = holding.start;
3422
+ markerAt2 = holding.start;
3165
3423
  const unit = anchoredLines(body, insertAt + statement.length, format, quote);
3166
3424
  const pin = unit === void 0 ? void 0 : pinOfLines(splitLines(body), unit);
3167
3425
  if (unit === void 0 || pin === void 0) {
@@ -3257,7 +3515,7 @@ async function runAdd(opts) {
3257
3515
  ...sourceLine === void 0 ? {} : { sourceLine },
3258
3516
  ...placed === void 0 ? {} : { manifest: placed }
3259
3517
  };
3260
- if (markerAt !== void 0) result.markerLine = markerAt + shift;
3518
+ if (markerAt2 !== void 0) result.markerLine = markerAt2 + shift;
3261
3519
  if (claimSpan !== void 0) {
3262
3520
  result.claimLines = { start: claimSpan.start + shift, end: claimSpan.end + shift };
3263
3521
  }
@@ -3268,10 +3526,10 @@ async function runAdd(opts) {
3268
3526
  result.manifest.written = true;
3269
3527
  }
3270
3528
  if (elsewhere.length > 0) {
3271
- const named3 = elsewhere.map((span) => ({ start: span.start + shift, end: span.end + shift }));
3529
+ const named4 = elsewhere.map((span) => ({ start: span.start + shift, end: span.end + shift }));
3272
3530
  const who = citation.id === void 0 ? "" : `${citation.id}: `;
3273
3531
  opts.onNotice?.(
3274
- `${who}the claim's text also appears at ${spellElsewhere(named3)}, so a move would be ambiguous. Use --marker, or pin more lines.`
3532
+ `${who}the claim's text also appears at ${spellElsewhere(named4)}, so a move would be ambiguous. Use --marker, or pin more lines.`
3275
3533
  );
3276
3534
  }
3277
3535
  if (opts.commitSha !== false && !await client.available()) {
@@ -3374,9 +3632,9 @@ async function runRemove(opts) {
3374
3632
  for (const line of ordered) {
3375
3633
  if (!isMarkerLine(lines[line - 1] ?? "", format)) {
3376
3634
  const id = page.statements.find((s) => s.line === line)?.payload;
3377
- const named3 = id?.kind === "ref" ? ` ${id.id}` : "";
3635
+ const named4 = id?.kind === "ref" ? ` ${id.id}` : "";
3378
3636
  throw new CiteError(
3379
- `the marker${named3} at ${label}:${String(line)} shares its line with text; remove it by hand.`
3637
+ `the marker${named4} at ${label}:${String(line)} shares its line with text; remove it by hand.`
3380
3638
  );
3381
3639
  }
3382
3640
  }
@@ -3459,13 +3717,26 @@ function settles(plan) {
3459
3717
  return "claim-changed";
3460
3718
  case "source-accepted":
3461
3719
  return plan.result.source.status === "never-true" ? "source-never-true" : "source-changed";
3720
+ case "marker-moved":
3721
+ return "marker-misplaced";
3722
+ case "claim-reanchored":
3723
+ return "claim-moved";
3724
+ // A shifted entry was `current`, so it had no finding to settle.
3725
+ case "claim-shifted":
3726
+ return "";
3462
3727
  }
3463
3728
  }
3464
3729
  function apply(content, format, plan) {
3465
3730
  const index = plan.result.origin.index;
3466
3731
  switch (plan.kind) {
3467
3732
  case "claim-moved":
3733
+ case "claim-shifted":
3468
3734
  return spliceEntryField(content, format, index, ["claim", "lines"], plan.lines);
3735
+ case "claim-reanchored":
3736
+ return spliceEntryField(content, format, index, ["claim", "integrity"], plan.pin);
3737
+ // The move is a page rewrite, applied to the whole page before any splice.
3738
+ case "marker-moved":
3739
+ return content;
3469
3740
  case "source-moved":
3470
3741
  return spliceEntryField(content, format, index, ["source", "lines"], plan.lines);
3471
3742
  case "claim-accepted": {
@@ -3498,12 +3769,22 @@ function applyToEntry(entry, plan) {
3498
3769
  if (!isRecord3(entry)) return false;
3499
3770
  const end = (name) => isRecord3(entry[name]) ? entry[name] : void 0;
3500
3771
  switch (plan.kind) {
3501
- case "claim-moved": {
3772
+ case "claim-moved":
3773
+ case "claim-shifted": {
3502
3774
  const claim = end("claim");
3503
3775
  if (claim === void 0) return false;
3504
3776
  claim.lines = plan.lines;
3505
3777
  return true;
3506
3778
  }
3779
+ case "claim-reanchored": {
3780
+ const claim = end("claim");
3781
+ if (claim === void 0) return false;
3782
+ claim.integrity = plan.pin;
3783
+ return true;
3784
+ }
3785
+ // The page holds the marker, so the manifest has nothing to change.
3786
+ case "marker-moved":
3787
+ return true;
3507
3788
  case "source-moved": {
3508
3789
  const source = end("source");
3509
3790
  if (source === void 0) return false;
@@ -3534,7 +3815,56 @@ function rewriteOf(plan) {
3534
3815
  if (origin.line !== void 0) base.line = origin.line;
3535
3816
  switch (plan.kind) {
3536
3817
  case "claim-moved":
3537
- return { ...base, end: "claim", reason: "moved", status: "moved", from: plan.from, to: plan.to };
3818
+ return {
3819
+ ...base,
3820
+ end: "claim",
3821
+ reason: "moved",
3822
+ status: "moved",
3823
+ from: plan.from,
3824
+ to: plan.to,
3825
+ fromLines: plan.from,
3826
+ toLines: plan.to
3827
+ };
3828
+ case "marker-moved": {
3829
+ const from = String(plan.from);
3830
+ const to = String(plan.to);
3831
+ return {
3832
+ ...base,
3833
+ end: "marker",
3834
+ reason: "re-anchored",
3835
+ status: "misplaced",
3836
+ from,
3837
+ to,
3838
+ fromLines: from,
3839
+ toLines: to
3840
+ };
3841
+ }
3842
+ case "claim-reanchored": {
3843
+ const was = citation.claim?.integrity ?? "";
3844
+ return {
3845
+ ...base,
3846
+ end: "claim",
3847
+ reason: "re-anchored",
3848
+ status: "moved",
3849
+ from: was,
3850
+ to: plan.pin,
3851
+ fromPin: was,
3852
+ toPin: plan.pin,
3853
+ lines: plan.held,
3854
+ newLines: plan.now
3855
+ };
3856
+ }
3857
+ case "claim-shifted":
3858
+ return {
3859
+ ...base,
3860
+ end: "claim",
3861
+ reason: "shifted",
3862
+ status: "current",
3863
+ from: plan.from,
3864
+ to: plan.to,
3865
+ fromLines: plan.from,
3866
+ toLines: plan.to
3867
+ };
3538
3868
  case "source-moved":
3539
3869
  return {
3540
3870
  ...base,
@@ -3545,19 +3875,21 @@ function rewriteOf(plan) {
3545
3875
  to: plan.to
3546
3876
  };
3547
3877
  case "claim-accepted": {
3878
+ const was = citation.claim?.integrity ?? "";
3548
3879
  const out = {
3549
3880
  ...base,
3550
3881
  end: "claim",
3551
3882
  reason: "accepted",
3552
3883
  status: "changed",
3553
- from: citation.claim?.integrity ?? "",
3884
+ from: was,
3554
3885
  to: plan.pin,
3886
+ fromPin: was,
3887
+ toPin: plan.pin,
3555
3888
  at: plan.unit.lines.start,
3556
3889
  text: normalizeWhitespace(plan.unit.text.join("\n"))
3557
3890
  };
3558
- if (plan.result.anchor === "marker" && plan.result.markerLine !== void 0) {
3559
- out.markerLine = plan.result.markerLine;
3560
- }
3891
+ const markerLine = plan.markerLine ?? (plan.result.anchor === "marker" ? plan.result.markerLine : void 0);
3892
+ if (markerLine !== void 0) out.markerLine = markerLine;
3561
3893
  return out;
3562
3894
  }
3563
3895
  case "source-accepted": {
@@ -3568,6 +3900,8 @@ function rewriteOf(plan) {
3568
3900
  status: source.status === "never-true" ? "never-true" : "changed",
3569
3901
  from: citation.source.integrity,
3570
3902
  to: plan.minted.source.integrity,
3903
+ fromPin: citation.source.integrity,
3904
+ toPin: plan.minted.source.integrity,
3571
3905
  src: source.src
3572
3906
  };
3573
3907
  const commit = plan.minted.source["commit-sha"];
@@ -3576,26 +3910,157 @@ function rewriteOf(plan) {
3576
3910
  }
3577
3911
  }
3578
3912
  }
3913
+ function named3(id, text) {
3914
+ return id === void 0 ? text : `${id}: ${text}`;
3915
+ }
3916
+ function crossing(page, misplaced) {
3917
+ for (const other of page.citations) {
3918
+ const spec = other.citation.claim?.lines;
3919
+ const recorded = spec === void 0 ? void 0 : parseLines(spec);
3920
+ if (recorded === void 0) continue;
3921
+ const file = toFileLines(recorded, page.bodyLine);
3922
+ const holdsMarker = file.start <= misplaced.line && misplaced.line <= file.end;
3923
+ const holdsPlace = file.start < misplaced.place && misplaced.place <= file.end;
3924
+ if (!holdsMarker && !holdsPlace) continue;
3925
+ return other.citation.id === void 0 ? `the claim at ${spellAt(file)}` : `the claim of ${other.citation.id} (${spellAt(file)})`;
3926
+ }
3927
+ return void 0;
3928
+ }
3929
+ function decideMarkers(input) {
3930
+ const { page, report, lines, accept, only } = input;
3931
+ const out = { moves: [], stays: [] };
3932
+ for (const misplaced of misplacedMarkers(page, lines)) {
3933
+ const entry = misplaced.entry;
3934
+ const line = misplaced.line;
3935
+ const stay = (rule, message) => {
3936
+ out.stays.push({ line, rule, message });
3937
+ };
3938
+ if (entry === void 0) {
3939
+ if (only !== void 0) continue;
3940
+ const own = report.findings.find(
3941
+ (finding2) => finding2.line === line && finding2.rule.startsWith("marker-") && finding2.rule !== "marker-misplaced"
3942
+ );
3943
+ stay(
3944
+ own?.rule ?? "marker-orphan",
3945
+ `the marker at line ${String(line)} stays, because it names no entry update can check.`
3946
+ );
3947
+ continue;
3948
+ }
3949
+ const id = entry.citation.id;
3950
+ if (only !== void 0 && (id === void 0 || !only.has(id))) continue;
3951
+ const result = report.citations.find((r) => r.origin.index === entry.origin.index);
3952
+ if (result === void 0) continue;
3953
+ if (entry.citation.claim?.lines !== void 0) {
3954
+ stay(
3955
+ "anchor-invalid",
3956
+ named3(id, `the marker at line ${String(line)} stays, because the entry also has claim lines. Keep one.`)
3957
+ );
3958
+ continue;
3959
+ }
3960
+ const across = crossing(page, misplaced);
3961
+ if (across !== void 0) {
3962
+ stay(
3963
+ "marker-misplaced",
3964
+ named3(id, `the marker at line ${String(line)} stays, because moving it would change ${across}.`)
3965
+ );
3966
+ continue;
3967
+ }
3968
+ if (result.claim !== null && result.claim.status === "changed" && !accept) {
3969
+ stay(
3970
+ "marker-misplaced",
3971
+ named3(
3972
+ id,
3973
+ `the marker at line ${String(line)} stays, because its claim changed since it was pinned. update --accept moves it and re-pins.`
3974
+ )
3975
+ );
3976
+ continue;
3977
+ }
3978
+ out.moves.push({ result, entry, misplaced });
3979
+ }
3980
+ return out;
3981
+ }
3982
+ function anchoredNow(content, format, lines, markerLine, quote) {
3983
+ const at2 = offsetOfLine(content, markerLine) + (lines[markerLine - 1]?.length ?? 0);
3984
+ const span = anchoredLines(content, at2, format, quote);
3985
+ if (span === void 0) return void 0;
3986
+ const pin = pinOfLines(lines, span);
3987
+ return pin === void 0 ? void 0 : { span, pin };
3988
+ }
3989
+ function codeOf(error) {
3990
+ if (typeof error !== "object" || error === null || !("code" in error)) return void 0;
3991
+ const code = error.code;
3992
+ return typeof code === "string" ? code : void 0;
3993
+ }
3994
+ var WrittenFiles = class {
3995
+ before = /* @__PURE__ */ new Map();
3996
+ labels = /* @__PURE__ */ new Map();
3997
+ /** Remember what `path` held before this run wrote it. */
3998
+ record(path, label, content) {
3999
+ if (!this.before.has(path)) {
4000
+ this.before.set(path, content);
4001
+ this.labels.set(path, label);
4002
+ }
4003
+ }
4004
+ /**
4005
+ * Put every file back, and say what happened: `<file> could not be written
4006
+ * (<code>). <pages> was restored.`
4007
+ */
4008
+ async restore(file, error) {
4009
+ const restored = [];
4010
+ const failed = [];
4011
+ for (const [path, content] of this.before) {
4012
+ const label = this.labels.get(path) ?? path;
4013
+ try {
4014
+ await writeFileAtomic(path, content);
4015
+ restored.push(label);
4016
+ } catch {
4017
+ failed.push(label);
4018
+ }
4019
+ }
4020
+ const code = codeOf(error);
4021
+ const why = code === void 0 ? "" : ` (${code})`;
4022
+ const parts = [`${file} could not be written${why}.`];
4023
+ if (restored.length > 0) {
4024
+ parts.push(`${listOf(restored)} ${restored.length === 1 ? "was" : "were"} restored.`);
4025
+ }
4026
+ if (failed.length > 0) {
4027
+ parts.push(
4028
+ `${listOf(failed)} could not be restored, and ${failed.length === 1 ? "holds" : "hold"} an intermediate state.`
4029
+ );
4030
+ }
4031
+ return new CiteError(parts.join(" "));
4032
+ }
4033
+ };
4034
+ function citationsIn(rows) {
4035
+ const withMarker = new Set(rows.filter((row) => row.end === "marker").map((row) => row.index));
4036
+ return withMarker.size + rows.filter((row) => !withMarker.has(row.index)).length;
4037
+ }
3579
4038
  async function runUpdate(opts) {
3580
4039
  const prepared = await prepareRun(opts, "updated", "update", { require: true });
3581
4040
  const { run: run3, files, usingStdin, forced, pageOptions, git } = prepared;
3582
4041
  assertNoOrphans(prepared);
3583
4042
  const hits = joinHits();
3584
4043
  const manifests = new ManifestSet();
4044
+ const writes = new WrittenFiles();
3585
4045
  const only = opts.only !== void 0 && opts.only.length > 0 ? new Set(opts.only) : void 0;
3586
4046
  const accept = opts.accept === true;
3587
- const plansFor = async (result, entry, page, lines, declined) => {
4047
+ const plansFor = async (result, entry, page, lines, lineNow, declined) => {
3588
4048
  const out = [];
3589
4049
  const claim = result.claim;
3590
- if (claim !== null && claim.status === "moved" && claim.newLines !== void 0) {
4050
+ if (result.anchor !== "marker" && claim !== null && claim.status === "moved" && claim.newLines !== void 0) {
3591
4051
  const at2 = parseLines(claim.newLines);
3592
- if (at2 !== void 0) {
4052
+ const shifted = at2 === void 0 ? void 0 : (() => {
4053
+ const file = toFileLines(at2, page.bodyLine);
4054
+ const to = { start: lineNow(file.start), end: lineNow(file.end) };
4055
+ return to.end - to.start === file.end - file.start ? toBodyLines(to, page.bodyLine) : void 0;
4056
+ })();
4057
+ if (at2 !== void 0 && shifted !== void 0) {
3593
4058
  out.push({
3594
4059
  kind: "claim-moved",
3595
4060
  result,
3596
- lines: lineSpec(at2),
4061
+ lines: lineSpec(shifted),
3597
4062
  from: claim.fileLines ?? "",
3598
- to: claim.newFileLines ?? ""
4063
+ to: spellLines(toFileLines(shifted, page.bodyLine))
3599
4064
  });
3600
4065
  }
3601
4066
  }
@@ -3606,8 +4071,8 @@ async function runUpdate(opts) {
3606
4071
  }
3607
4072
  }
3608
4073
  if (!accept) return out;
3609
- if (claim !== null && claim.status === "changed" && entry !== void 0) {
3610
- const unit = result.anchor === "marker" ? markerUnit(page, entry, lines) : (() => {
4074
+ if (claim !== null && claim.status === "changed" && entry !== void 0 && result.anchor !== "marker") {
4075
+ const unit = (() => {
3611
4076
  const at2 = claimLine(claim);
3612
4077
  return at2 === void 0 ? void 0 : unitAt(page, at2, lines);
3613
4078
  })();
@@ -3615,7 +4080,10 @@ async function runUpdate(opts) {
3615
4080
  const pin = unit === void 0 ? void 0 : pinOfLines(lines, unit.lines);
3616
4081
  const wide = unit === void 0 ? void 0 : tooWide(unit.lines);
3617
4082
  if (unit !== void 0 && wide !== void 0) {
3618
- declined.set(result.origin.index, `Not re-pinned: the ${unit.kind} ${wide}.`);
4083
+ declined.set(result.origin.index, {
4084
+ rule: "claim-changed",
4085
+ why: `Not re-pinned: the ${unit.kind} ${wide}.`
4086
+ });
3619
4087
  } else if (unit !== void 0 && pin !== void 0 && (!wantsBlock || unit.kind === "block")) {
3620
4088
  const plan = { kind: "claim-accepted", result, unit, pin };
3621
4089
  if (entry.citation.claim?.lines !== void 0) {
@@ -3651,6 +4119,7 @@ async function runUpdate(opts) {
3651
4119
  if (setup.sidecar !== void 0) hits.record(setup.sidecar, label);
3652
4120
  const report = await checkCitations({ file: label, content, format: forced?.name }, setup.options);
3653
4121
  const { format } = report;
4122
+ const severity = resolveSeverity(setup.options.severity);
3654
4123
  const page = readPage(label, content, {
3655
4124
  ...forced === void 0 ? {} : { format: forced.name },
3656
4125
  ...setup.options.citations === void 0 ? {} : { citations: setup.options.citations },
@@ -3660,18 +4129,105 @@ async function runUpdate(opts) {
3660
4129
  const rewritten2 = [];
3661
4130
  const settled = /* @__PURE__ */ new Set();
3662
4131
  const declined = /* @__PURE__ */ new Map();
3663
- let after = content;
4132
+ const markers = decideMarkers({ page, report, lines, accept, only });
4133
+ const movedPage = markers.moves.length === 0 ? void 0 : applyMarkerMoves(
4134
+ content,
4135
+ markers.moves.map(({ misplaced }) => ({
4136
+ from: misplaced.line,
4137
+ place: misplaced.place
4138
+ })),
4139
+ (line) => markerIndent(content, line, format, page.bodyLine)
4140
+ );
4141
+ let after = movedPage?.content ?? content;
4142
+ const afterLines = movedPage === void 0 ? lines : splitLines(after);
4143
+ const movedIndexes = new Set(markers.moves.map(({ entry }) => entry.origin.index));
4144
+ const lineNow = (line) => movedPage?.line(line) ?? line;
3664
4145
  const owner = setup.sidecar?.owner;
3665
4146
  const entries = setup.sidecar?.citations === void 0 ? void 0 : JSON.parse(JSON.stringify(setup.sidecar.citations.map((c) => c.entry)));
3666
4147
  let manifestDirty = false;
3667
4148
  for (const result of report.citations) {
3668
- if (only !== void 0 && (result.citation.id === void 0 || !only.has(result.citation.id))) {
3669
- continue;
3670
- }
4149
+ const selected = only === void 0 || result.citation.id !== void 0 && only.has(result.citation.id);
3671
4150
  const entry = page.citations.find((c) => c.origin.index === result.origin.index);
3672
- for (const plan of await plansFor(result, entry, page, lines, declined)) {
3673
- if (result.origin.kind === "manifest") {
3674
- if (entries === void 0 || !applyToEntry(entries[result.origin.index], plan)) continue;
4151
+ const plans = [];
4152
+ const move = selected ? markers.moves.find((m) => m.entry.origin.index === result.origin.index) : void 0;
4153
+ if (move !== void 0) {
4154
+ plans.push({
4155
+ kind: "marker-moved",
4156
+ result,
4157
+ from: move.misplaced.line,
4158
+ to: lineNow(move.misplaced.line)
4159
+ });
4160
+ }
4161
+ const marker = entry?.marker;
4162
+ const recorded = entry?.citation.claim?.integrity;
4163
+ if (selected && result.anchor === "marker" && marker !== void 0 && recorded !== void 0 && result.claim !== null) {
4164
+ const quote = entry?.citation.quote === true;
4165
+ const now = anchoredNow(after, format, afterLines, lineNow(marker.line), quote);
4166
+ const status = result.claim.status;
4167
+ if (now !== void 0 && now.pin !== recorded) {
4168
+ if (status === "moved" || status === "current" && move !== void 0) {
4169
+ plans.push({
4170
+ kind: "claim-reanchored",
4171
+ result,
4172
+ pin: now.pin,
4173
+ held: result.claim.fileLines ?? "",
4174
+ now: spellLines(now.span)
4175
+ });
4176
+ } else if (status === "changed" && accept) {
4177
+ const kind = quote ? "block" : "paragraph";
4178
+ const wide = tooWide(now.span);
4179
+ if (wide !== void 0) {
4180
+ declined.set(result.origin.index, {
4181
+ rule: "claim-changed",
4182
+ why: `Not re-pinned: the ${kind} ${wide}.`
4183
+ });
4184
+ } else {
4185
+ plans.push({
4186
+ kind: "claim-accepted",
4187
+ result,
4188
+ pin: now.pin,
4189
+ markerLine: lineNow(marker.line),
4190
+ unit: {
4191
+ lines: now.span,
4192
+ kind,
4193
+ text: afterLines.slice(now.span.start - 1, now.span.end)
4194
+ }
4195
+ });
4196
+ }
4197
+ }
4198
+ }
4199
+ }
4200
+ if (selected) {
4201
+ plans.push(...await plansFor(result, entry, page, lines, lineNow, declined));
4202
+ }
4203
+ const spec = entry?.citation.claim?.lines;
4204
+ const wentElsewhere = result.claim?.status === "moved";
4205
+ if (movedPage !== void 0 && spec !== void 0 && !wentElsewhere && !plans.some((p) => p.kind === "claim-moved")) {
4206
+ const at2 = parseLines(spec);
4207
+ const file = at2 === void 0 ? void 0 : toFileLines(at2, page.bodyLine);
4208
+ const to = file === void 0 ? void 0 : { start: lineNow(file.start), end: lineNow(file.end) };
4209
+ if (file !== void 0 && to !== void 0 && to.end - to.start === file.end - file.start && (to.start !== file.start || to.end !== file.end)) {
4210
+ plans.push({
4211
+ kind: "claim-shifted",
4212
+ result,
4213
+ lines: lineSpec(toBodyLines(to, page.bodyLine)),
4214
+ from: spellLines(file),
4215
+ to: spellLines(to)
4216
+ });
4217
+ }
4218
+ }
4219
+ for (const plan of plans) {
4220
+ if (plan.kind === "marker-moved") {
4221
+ } else if (result.origin.kind === "manifest") {
4222
+ const held = entries?.[result.origin.index];
4223
+ if (held === void 0) {
4224
+ declined.set(result.origin.index, {
4225
+ rule: settles(plan),
4226
+ why: `Not rewritten: ${owner?.file ?? "the manifest"} has no entry at index ${String(result.origin.index)}.`
4227
+ });
4228
+ continue;
4229
+ }
4230
+ if (!applyToEntry(held, plan)) continue;
3675
4231
  manifestDirty = true;
3676
4232
  } else {
3677
4233
  after = apply(after, format, plan);
@@ -3680,6 +4236,7 @@ async function runUpdate(opts) {
3680
4236
  settled.add(`${String(result.origin.index)}\0${settles(plan)}`);
3681
4237
  }
3682
4238
  }
4239
+ for (const index of movedIndexes) settled.add(`${String(index)}\0marker-misplaced`);
3683
4240
  if (manifestDirty && owner !== void 0 && entries !== void 0) {
3684
4241
  if (setup.sidecar?.entry === void 0) {
3685
4242
  throw new CiteError(
@@ -3688,15 +4245,34 @@ async function runUpdate(opts) {
3688
4245
  }
3689
4246
  await manifests.write(owner, setup.sidecar.entry, entries, 0);
3690
4247
  }
4248
+ const stayAt = new Map(markers.stays.map((stay) => [stay.line, stay]));
3691
4249
  const skipped2 = report.findings.filter(
3692
4250
  (finding2) => (only === void 0 || finding2.id !== void 0 && only.has(finding2.id)) && !settled.has(`${String(finding2.index ?? -1)}\0${finding2.rule}`)
3693
4251
  ).map((finding2) => {
3694
- const why = finding2.rule === "claim-changed" && finding2.index !== void 0 ? declined.get(finding2.index) : void 0;
4252
+ if (finding2.rule === "marker-misplaced") {
4253
+ const stay = stayAt.get(finding2.line ?? -1);
4254
+ if (stay === void 0) return finding2;
4255
+ const level = severity[stay.rule];
4256
+ return {
4257
+ ...finding2,
4258
+ message: stay.message,
4259
+ ...level === "off" ? {} : { severity: level }
4260
+ };
4261
+ }
4262
+ const turned = finding2.index === void 0 ? void 0 : declined.get(finding2.index);
4263
+ const why = turned?.rule === finding2.rule ? turned.why : void 0;
3695
4264
  return why === void 0 ? finding2 : { ...finding2, message: `${finding2.message} ${why}` };
3696
4265
  });
3697
4266
  const diff = after === content ? "" : unifiedDiff(label, content, after);
3698
4267
  const written = after !== content && path !== void 0 && opts.dryRun !== true;
3699
- if (written) await writeFileAtomic(path, after);
4268
+ if (written) {
4269
+ try {
4270
+ await writeFileAtomic(path, after);
4271
+ } catch (error) {
4272
+ throw await writes.restore(label, error);
4273
+ }
4274
+ writes.record(path, label, content);
4275
+ }
3700
4276
  const out = { file: label, rewritten: rewritten2, skipped: skipped2, diff, written };
3701
4277
  if (path === void 0) out.content = after;
3702
4278
  pages.push(out);
@@ -3712,14 +4288,21 @@ async function runUpdate(opts) {
3712
4288
  const rewrittenManifests = [];
3713
4289
  for (const changed of changedManifests) {
3714
4290
  const write = opts.dryRun !== true;
3715
- if (write) await writeFileAtomic(changed.path, changed.text);
4291
+ if (write) {
4292
+ try {
4293
+ await writeFileAtomic(changed.path, changed.text);
4294
+ } catch (error) {
4295
+ throw await writes.restore(changed.file, error);
4296
+ }
4297
+ writes.record(changed.path, changed.file, changed.before);
4298
+ }
3716
4299
  rewrittenManifests.push({ file: changed.file, diff: changed.diff, written: write });
3717
4300
  }
3718
4301
  const reminted = pages.some(
3719
4302
  (page) => page.rewritten.some((r) => r.reason === "accepted" && r.end === "source")
3720
4303
  );
3721
4304
  sayNotices(reports, opts.onNotice, reminted && !await git.available() ? [GIT_UNAVAILABLE_COMMIT] : []);
3722
- const rewritten = pages.reduce((n, page) => n + page.rewritten.length, 0);
4305
+ const rewritten = pages.reduce((n, page) => n + citationsIn(page.rewritten), 0);
3723
4306
  const skipped = pages.reduce((n, page) => n + page.skipped.length, 0);
3724
4307
  const undone = pages.some((page) => page.skipped.some((finding2) => finding2.severity === "error"));
3725
4308
  return {
@@ -3907,13 +4490,15 @@ function citesEncrypted2(src) {
3907
4490
  }
3908
4491
  }
3909
4492
  function claimColumn(result) {
3910
- const { claim } = result;
3911
- const where = result.anchor === "marker" && result.markerLine !== void 0 ? `marker :${String(result.markerLine)}` : claim?.fileLines === void 0 ? "" : `:${claim.fileLines}`;
4493
+ const { claim, marker } = result;
4494
+ const anchoredByMarker = result.anchor === "marker" && result.markerLine !== void 0;
4495
+ const moves = marker?.misplaced === void 0 ? "" : ` -> :${String(marker.misplaced.to)}`;
4496
+ const where = anchoredByMarker ? `marker :${String(result.markerLine ?? 0)}${moves}` : claim?.fileLines === void 0 ? "" : `:${claim.fileLines}`;
3912
4497
  if (claim === null) return where;
3913
4498
  let status;
3914
4499
  switch (claim.status) {
3915
4500
  case "moved":
3916
- status = `moved -> :${claim.newFileLines ?? "?"}`;
4501
+ status = anchoredByMarker ? "moved" : `moved -> :${claim.newFileLines ?? "?"}`;
3917
4502
  break;
3918
4503
  case "moved-ambiguous": {
3919
4504
  const at2 = (claim.candidateFileLines ?? []).map((lines) => `:${lines}`);
@@ -4003,7 +4588,7 @@ function renderCheckPretty(run3, opts) {
4003
4588
  if (own.length === 0 && quiet) continue;
4004
4589
  const worst = live.find((f) => f.severity === "error") ?? live.find((f) => f.severity === "warning") ?? live[0];
4005
4590
  const mark = worst !== void 0 ? severityMark(worst.severity, c) : own.length > 0 ? c.dim("\xB7") : result.source.status === "skipped" && result.claim === null ? c.dim("\xB7") : c.green("\u2713");
4006
- const endRules = /* @__PURE__ */ new Set(["claim-", "source-"]);
4591
+ const endRules = /* @__PURE__ */ new Set(["claim-", "source-", "marker-misplaced"]);
4007
4592
  const under = [];
4008
4593
  for (const finding2 of own) {
4009
4594
  const isEnd = [...endRules].some((prefix) => finding2.rule.startsWith(prefix));
@@ -4062,9 +4647,45 @@ function renderCheckPretty(run3, opts) {
4062
4647
  }
4063
4648
  return lines.join("\n");
4064
4649
  }
4650
+ function spellAtSpec(spec) {
4651
+ const span = spanOf(spec);
4652
+ return span === void 0 ? `lines ${spec}` : spellAt(span);
4653
+ }
4654
+ function spanOf(spec) {
4655
+ const [first, second] = spec.split("-");
4656
+ const start = Number(first);
4657
+ const end = second === void 0 ? start : Number(second);
4658
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return void 0;
4659
+ return { start, end };
4660
+ }
4661
+ function widening(held, now) {
4662
+ const was = spanOf(held);
4663
+ const is = spanOf(now);
4664
+ if (was === void 0 || is === void 0) return "";
4665
+ const parts = [];
4666
+ if (is.start < was.start) {
4667
+ parts.push(spellAt({ start: is.start, end: Math.min(was.start - 1, is.end) }));
4668
+ }
4669
+ if (is.end > was.end) {
4670
+ parts.push(spellAt({ start: Math.max(was.end + 1, is.start), end: is.end }));
4671
+ }
4672
+ if (parts.length === 0) return ", which held a marker line";
4673
+ return `, ${parts.join(" and ")} newly pinned`;
4674
+ }
4065
4675
  function rewriteLine(rewrite) {
4066
4676
  const word = rewrite.from.includes("-") ? "lines" : "line";
4067
4677
  const status = rewrite.status === "never-true" ? "never true" : rewrite.status;
4678
+ if (rewrite.end === "marker") {
4679
+ return `marker line ${rewrite.from} -> ${rewrite.to} (misplaced)`;
4680
+ }
4681
+ if (rewrite.reason === "shifted") {
4682
+ return `claim ${word} ${rewrite.from} -> ${rewrite.to} (shifted by a marker)`;
4683
+ }
4684
+ if (rewrite.reason === "re-anchored") {
4685
+ const held = rewrite.lines ?? "";
4686
+ const now = rewrite.newLines ?? "";
4687
+ return `claim re-pinned over ${spellAtSpec(now)} (moved; was ${spellAtSpec(held)}${widening(held, now)})`;
4688
+ }
4068
4689
  if (rewrite.reason === "moved") {
4069
4690
  return rewrite.end === "claim" ? `claim ${word} ${rewrite.from} -> ${rewrite.to} (moved)` : `source ${shortSrc(rewrite.from)} -> ${shortSrc(rewrite.to)} (moved)`;
4070
4691
  }
@@ -4118,8 +4739,8 @@ function spellLineList(at2, noun) {
4118
4739
  function removalLine(removal, c) {
4119
4740
  const markers = spellLineList(removal.markerLines, "line");
4120
4741
  if (removal.origin === void 0) {
4121
- const named3 = removal.markerLines.length === 1 ? "the marker" : "the markers";
4122
- return `removed ${named3} ${c.cyan(removal.id ?? "")} at ${markers}, which named no entry`;
4742
+ const named4 = removal.markerLines.length === 1 ? "the marker" : "the markers";
4743
+ return `removed ${named4} ${c.cyan(removal.id ?? "")} at ${markers}, which named no entry`;
4123
4744
  }
4124
4745
  const name = removal.id ?? `/citations/${String(removal.index ?? 0)}`;
4125
4746
  const where = removal.origin.kind === "manifest" ? `${removal.origin.file}${removal.origin.line === void 0 ? "" : `:${String(removal.origin.line)}`}` : "frontmatter";
@@ -4183,6 +4804,7 @@ function publicCitation(result) {
4183
4804
  ...result.citation.id === void 0 ? {} : { id: result.citation.id },
4184
4805
  origin,
4185
4806
  anchor: result.anchor,
4807
+ ...result.marker === void 0 ? {} : { marker: result.marker },
4186
4808
  claim,
4187
4809
  source
4188
4810
  };
@@ -4664,13 +5286,13 @@ export {
4664
5286
  isSeverity,
4665
5287
  meetsSeverity,
4666
5288
  reencryptMetadata,
4667
- codeRegions,
4668
- codeEndAt,
4669
5289
  CiteError,
4670
5290
  asCiteError,
5291
+ spellSource,
4671
5292
  RULE_ID_PREFIX,
4672
5293
  isCiteRule,
4673
- spellSource,
5294
+ codeRegions,
5295
+ codeEndAt,
4674
5296
  shortPin,
4675
5297
  shortCommit,
4676
5298
  shortSrc,
@@ -4693,4 +5315,4 @@ export {
4693
5315
  runKeyRotate,
4694
5316
  runKeySet
4695
5317
  };
4696
- //# sourceMappingURL=chunk-P4KR3XFD.js.map
5318
+ //# sourceMappingURL=chunk-NFCH7HCA.js.map