@hawkeyexl/manni 2.3.10 → 2.5.0

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.
@@ -55,6 +55,7 @@ import {
55
55
  pinOfLines,
56
56
  readConfigFile,
57
57
  readFamilyConfigFile,
58
+ removeManifestKey,
58
59
  resolveBaselineRequest,
59
60
  resolveEncryptionKey,
60
61
  resolveTargetSet,
@@ -70,7 +71,7 @@ import {
70
71
  withValueAt,
71
72
  writeEncryptionKey,
72
73
  writeFileAtomic
73
- } from "./chunk-TNT3ZROC.js";
74
+ } from "./chunk-SXYSP33V.js";
74
75
 
75
76
  // src/meta/core/reencrypt.ts
76
77
  import { extname } from "node:path";
@@ -196,6 +197,7 @@ __export(cite_exports, {
196
197
  bodyLineOf: () => bodyLineOf,
197
198
  buildSourceIndex: () => buildSourceIndex,
198
199
  checkCitations: () => checkCitations,
200
+ citationInputs: () => citationInputs,
199
201
  claimEnd: () => claimEnd,
200
202
  claimLine: () => claimLine,
201
203
  claimMessageFor: () => claimMessageFor,
@@ -218,16 +220,24 @@ __export(cite_exports, {
218
220
  historyOf: () => historyOf,
219
221
  insertStatementBefore: () => insertStatementBefore,
220
222
  insideFence: () => insideFence,
223
+ isBoundLine: () => isBoundLine,
221
224
  isCiteRule: () => isCiteRule,
222
225
  isKeyedPin: () => isKeyedPin,
226
+ isUnitText: () => isUnitText,
223
227
  itemLine: () => itemLine,
224
228
  lineAt: () => lineAt,
225
229
  lineSpec: () => lineSpec,
226
230
  loadCitationSidecars: () => loadCitationSidecars,
227
231
  loadCiteConfig: () => loadCiteConfig,
232
+ markerClaimEnd: () => markerClaimEnd,
233
+ markerRunAt: () => markerRunAt,
228
234
  markerUnit: () => markerUnit,
229
235
  messageFor: () => messageFor,
230
236
  mintCitation: () => mintCitation,
237
+ misplacedMarkerAt: () => misplacedMarkerAt,
238
+ misplacedMarkers: () => misplacedMarkers,
239
+ misplacedMessageFor: () => misplacedMessageFor,
240
+ movedUnit: () => movedUnit,
231
241
  noGit: () => noGit,
232
242
  normalizeText: () => normalizeText,
233
243
  normalizeWhitespace: () => normalizeWhitespace,
@@ -241,15 +251,21 @@ __export(cite_exports, {
241
251
  parseStatements: () => parseStatements,
242
252
  pickExtractor: () => pickExtractor,
243
253
  pinOfLines: () => pinOfLines,
254
+ pre43Span: () => pre43Span,
244
255
  rangeLines: () => rangeLines,
245
256
  readCiteConfigFile: () => readCiteConfigFile,
246
257
  readPage: () => readPage,
247
258
  readSource: () => readSource,
248
259
  reencryptCitationEntries: () => reencryptCitationEntries,
249
260
  reencryptCitations: () => reencryptCitations,
261
+ removalLine: () => removalLine,
262
+ removeFrontmatterCitations: () => removeFrontmatterCitations,
263
+ removeLine: () => removeLine,
250
264
  renderCheckGithub: () => renderCheckGithub,
251
265
  renderCheckJson: () => renderCheckJson,
252
266
  renderCheckPretty: () => renderCheckPretty,
267
+ renderRemoveJson: () => renderRemoveJson,
268
+ renderRemovePretty: () => renderRemovePretty,
253
269
  renderUpdateJson: () => renderUpdateJson,
254
270
  renderUpdatePretty: () => renderUpdatePretty,
255
271
  resolveCiteRun: () => resolveCiteRun,
@@ -258,7 +274,9 @@ __export(cite_exports, {
258
274
  ruleId: () => ruleId,
259
275
  runAdd: () => runAdd,
260
276
  runCheck: () => runCheck,
277
+ runRemove: () => runRemove,
261
278
  runUpdate: () => runUpdate,
279
+ shiftedEntries: () => shiftedEntries,
262
280
  shortCommit: () => shortCommit,
263
281
  shortLine: () => shortLine,
264
282
  shortPin: () => shortPin,
@@ -266,11 +284,13 @@ __export(cite_exports, {
266
284
  sidecarsFor: () => sidecarsFor,
267
285
  sliceLines: () => sliceLines2,
268
286
  sourceRange: () => sourceRange,
287
+ spellAt: () => spellAt,
269
288
  spellLines: () => spellLines,
270
289
  spellSource: () => spellSource,
271
290
  splice: () => splice,
272
291
  spliceEntryField: () => spliceEntryField,
273
292
  splitLines: () => splitLines,
293
+ splitUnit: () => splitUnit,
274
294
  statementForms: () => statementForms,
275
295
  toBodyLines: () => toBodyLines,
276
296
  toFileLines: () => toFileLines,
@@ -278,8 +298,10 @@ __export(cite_exports, {
278
298
  twoManifestsRefusal: () => twoManifestsRefusal,
279
299
  unifiedDiff: () => unifiedDiff,
280
300
  unitAt: () => unitAt,
301
+ unitText: () => unitText,
281
302
  urlManifestRefusal: () => urlManifestRefusal,
282
- validateEntry: () => validateEntry
303
+ validateEntry: () => validateEntry,
304
+ withClaimLines: () => withClaimLines
283
305
  });
284
306
 
285
307
  // src/cite/types.ts
@@ -295,6 +317,7 @@ var CITE_RULES = [
295
317
  "marker-orphan",
296
318
  "marker-invalid",
297
319
  "marker-repeated",
320
+ "marker-misplaced",
298
321
  "anchor-invalid",
299
322
  "entry-invalid",
300
323
  "quote-drift"
@@ -329,6 +352,7 @@ var DEFAULT_SEVERITY = {
329
352
  "marker-orphan": "error",
330
353
  "marker-invalid": "error",
331
354
  "marker-repeated": "warning",
355
+ "marker-misplaced": "warning",
332
356
  "anchor-invalid": "error",
333
357
  "entry-invalid": "error",
334
358
  "quote-drift": "error"
@@ -426,6 +450,13 @@ function shortLine(text) {
426
450
  const one = text.replace(/\s+/g, " ").trim();
427
451
  return one.length <= LINE_WIDTH ? one : `${one.slice(0, LINE_WIDTH)}\u2026`;
428
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
+ }
429
460
 
430
461
  // src/cite/core/sources.ts
431
462
  import fg from "fast-glob";
@@ -513,6 +544,127 @@ async function readSource(root, index, range, key) {
513
544
  }
514
545
  }
515
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
+
516
668
  // src/shared/code-regions.ts
517
669
  var SKIP_MARKDOWN_FENCE = /^[ \t]*(`{3,}|~{3,})/;
518
670
  var SKIP_ASCIIDOC_FENCE = /^(-{4,})/;
@@ -679,6 +831,25 @@ function isMarkerLine(line, format) {
679
831
  return isCite(text.slice(form.open.length, closeAt).trim());
680
832
  });
681
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
+ }
682
853
  function parseStatements(body, format, from = { offset: 0, line: 1 }) {
683
854
  const out = [];
684
855
  const fileLine = (bodyOffset) => from.line + lineAt(body, bodyOffset) - 1;
@@ -721,6 +892,7 @@ function detectEol(text) {
721
892
  }
722
893
  function paragraphAfter(content, offset, format) {
723
894
  const marker = (text) => format !== void 0 && isMarkerLine(text, format);
895
+ const bound = (text) => format !== void 0 && isBoundLine(text, format);
724
896
  let pos = offset;
725
897
  const firstEnd = lineEnd(content, pos);
726
898
  if (lineText(content, pos, firstEnd).trim() === "") pos = firstEnd + 1;
@@ -734,11 +906,11 @@ function paragraphAfter(content, offset, format) {
734
906
  if (ANY_FENCE.test(text)) return void 0;
735
907
  const start = pos;
736
908
  let last = end;
737
- let cursor = end + 1;
909
+ let cursor = bound(text) ? content.length : end + 1;
738
910
  while (cursor < content.length) {
739
911
  const e = lineEnd(content, cursor);
740
912
  const t = lineText(content, cursor, e);
741
- if (t.trim() === "" || ANY_FENCE.test(t) || marker(t)) break;
913
+ if (t.trim() === "" || ANY_FENCE.test(t) || marker(t) || bound(t)) break;
742
914
  last = e;
743
915
  cursor = e + 1;
744
916
  }
@@ -853,9 +1025,11 @@ function unitHolding(text, line, format, body) {
853
1025
  }
854
1026
  if (ANY_FENCE.test(own)) return void 0;
855
1027
  let start = line;
856
- while (start - 1 >= body.line) {
1028
+ while (start - 1 >= body.line && !isBoundLine(own, format)) {
857
1029
  const above = textOf(start - 1);
858
- 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
+ }
859
1033
  start--;
860
1034
  }
861
1035
  const paragraph = paragraphAfter(text, offsetOfLine(text, start), format);
@@ -894,7 +1068,9 @@ function continuesList(text, line, format, bodyLine, column) {
894
1068
  if (columnOf(indentOf(textOfLine(text, n))) > column) return true;
895
1069
  for (; n >= bodyLine; n--) {
896
1070
  const above = textOfLine(text, n);
897
- 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
+ }
898
1074
  const item = listItemOf(above);
899
1075
  if (item !== void 0 && columnOf(item.lead) === column) return true;
900
1076
  }
@@ -947,718 +1123,797 @@ function offsetOfLine(content, line) {
947
1123
  return pos;
948
1124
  }
949
1125
 
950
- // src/cite/core/claims.ts
951
- function normalizeWhitespace(text) {
952
- return text.replace(/\s+/g, " ").trim();
953
- }
954
- function normalizeBlock(text) {
955
- let out = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
956
- out = out.replace(/\r\n/g, "\n");
957
- return out.endsWith("\n") ? out.slice(0, -1) : out;
958
- }
959
- function blockMatches(blockText, citedJoined) {
960
- return normalizeBlock(blockText) === normalizeBlock(citedJoined);
961
- }
962
- function textAt(lines, range) {
963
- 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`;
964
1133
  }
965
- function claimLine(claim) {
966
- const at2 = claim.fileLines === void 0 ? void 0 : parseLines(claim.fileLines);
967
- 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;
968
1144
  }
969
- function claimLineNow(claim) {
970
- const spec = claim.newFileLines ?? claim.candidateFileLines?.[0] ?? claim.fileLines;
971
- const at2 = spec === void 0 ? void 0 : parseLines(spec);
972
- 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();
973
1151
  }
974
- function claimLinesNow(claim) {
975
- const spec = claim.newFileLines ?? claim.candidateFileLines?.[0] ?? claim.fileLines;
976
- return spec === void 0 ? void 0 : parseLines(spec);
1152
+ function isRecord(value) {
1153
+ return typeof value === "object" && value !== null && !Array.isArray(value);
977
1154
  }
978
- function claimEnd(page, entry, lines) {
979
- const { citation, marker } = entry;
980
- const claim = citation.claim;
981
- if (claim === void 0) return null;
982
- const quote = citation.quote === true;
983
- const body = page.bodyLine;
984
- if (claim.lines !== void 0) {
985
- const recorded = parseLines(claim.lines);
986
- if (recorded === void 0) return { status: "skipped" };
987
- const file = toFileLines(recorded, body);
988
- const end = {
989
- lines: spellLines(recorded),
990
- fileLines: spellLines(file),
991
- status: "changed"
992
- };
993
- if (marker !== void 0) return { ...end, status: "skipped" };
994
- if (pinOfLines(lines, file) === claim.integrity) return { ...end, status: "current" };
995
- const width = recorded.end - recorded.start + 1;
996
- const found = findWindows(lines.slice(body - 1), width, claim.integrity, void 0, {
997
- around: recorded.start
998
- });
999
- const spans = found.starts.map((start) => ({ start, end: start + width - 1 }));
1000
- const [only] = spans;
1001
- if (spans.length === 1 && only !== void 0) {
1002
- return {
1003
- ...end,
1004
- status: "moved",
1005
- newLines: spellLines(only),
1006
- newFileLines: spellLines(toFileLines(only, body))
1007
- };
1008
- }
1009
- if (spans.length > 1) {
1010
- return {
1011
- ...end,
1012
- status: "moved-ambiguous",
1013
- candidates: spans.map(spellLines),
1014
- candidateFileLines: spans.map((span) => spellLines(toFileLines(span, body)))
1015
- };
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)
1016
1161
  }
1017
- return { ...end, text: textAt(lines, file) };
1162
+ };
1163
+ if (typeof source.lines === "number" || typeof source.lines === "string") {
1164
+ citation.source.lines = source.lines;
1018
1165
  }
1019
- if (marker !== void 0) {
1020
- const unit = anchoredLines(page.content, marker.end, page.format, quote);
1021
- if (unit === void 0) return { status: "changed" };
1022
- const end = { fileLines: spellLines(unit), status: "changed" };
1023
- if (pinOfLines(lines, unit) === claim.integrity) return { ...end, status: "current" };
1024
- return { ...end, text: textAt(lines, unit) };
1166
+ if (typeof source["commit-sha"] === "string") {
1167
+ citation.source["commit-sha"] = source["commit-sha"];
1025
1168
  }
1026
- return { status: "changed" };
1027
- }
1028
- function unitAt(page, line, lines) {
1029
- if (line < page.bodyLine || line > lines.length) return void 0;
1030
- if ((lines[line - 1] ?? "").trim() === "") return void 0;
1031
- const block = fenceSpanAt(page.content, line, page.format);
1032
- if (block !== void 0) {
1033
- 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
+ }
1034
1176
  }
1035
- if (insideFence(page.content, page.bodyOffset, line, page.format)) return void 0;
1036
- const paragraph = paragraphAfter(page.content, offsetOfLine(page.content, line));
1037
- if (paragraph === void 0 || paragraph.line !== line) return void 0;
1038
- const span = { start: line, end: lineAt(page.content, paragraph.end) };
1039
- 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;
1040
1179
  }
1041
- function markerUnit(page, entry, lines) {
1042
- const { marker, citation } = entry;
1043
- if (marker === void 0) return void 0;
1044
- const span = anchoredLines(page.content, marker.end, page.format, citation.quote === true);
1045
- if (span === void 0 || span.end > lines.length) return void 0;
1046
- return {
1047
- lines: span,
1048
- kind: citation.quote === true ? "block" : "paragraph",
1049
- 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
1050
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;
1051
1196
  }
1052
-
1053
- // src/cite/core/git.ts
1054
- import { execFile } from "node:child_process";
1055
- var MAX_BUFFER = 32 * 1024 * 1024;
1056
- var NO_COMMIT = /invalid object name|bad revision|unknown revision|bad object/i;
1057
- var NO_PATH = /does not exist in|exists on disk, but not in|path .* does not exist/i;
1058
- function run2(root, args) {
1059
- return new Promise((resolve8) => {
1060
- execFile(
1061
- "git",
1062
- ["-c", "core.quotepath=false", ...args],
1063
- { cwd: root, windowsHide: true, maxBuffer: MAX_BUFFER, encoding: "utf8" },
1064
- (error, stdout, stderr) => {
1065
- if (error) {
1066
- const failure = { ok: false, stderr, message: error.message };
1067
- if (typeof error.code === "number") failure.code = error.code;
1068
- resolve8(failure);
1069
- } else resolve8({ ok: true, stdout });
1070
- }
1071
- );
1072
- });
1073
- }
1074
- function gitClient(root) {
1075
- const memo = /* @__PURE__ */ new Map();
1076
- const once = (key, compute) => {
1077
- const held = memo.get(key);
1078
- if (held !== void 0) return held;
1079
- const made = compute();
1080
- memo.set(key, made);
1081
- return made;
1082
- };
1083
- const available = () => once("available", async () => (await run2(root, ["rev-parse", "--show-toplevel"])).ok);
1084
- const failed = (verb, outcome) => new CiteError(`git ${verb} failed: ${outcome.stderr.trim() || outcome.message}`);
1085
- const hasCommit = (commit) => once(`has\0${commit}`, async () => {
1086
- const out = await run2(root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}^{commit}`]);
1087
- if (out.ok) return true;
1088
- if (out.code === 1) return false;
1089
- throw failed("rev-parse", out);
1090
- });
1091
- return {
1092
- available,
1093
- head: () => once("head", async () => {
1094
- if (!await available()) return null;
1095
- const out = await run2(root, ["rev-parse", "HEAD"]);
1096
- return out.ok ? out.stdout.trim() : null;
1097
- }),
1098
- lsFiles: () => once("ls-files", async () => {
1099
- if (!await available()) return [];
1100
- const out = await run2(root, ["ls-files", "-z"]);
1101
- if (!out.ok) throw failed("ls-files", out);
1102
- return out.stdout.split("\0").filter((entry) => entry !== "");
1103
- }),
1104
- showFile: (commit, path) => once(`show\0${commit}\0${path}`, async () => {
1105
- const out = await run2(root, ["show", "--end-of-options", `${commit}:./${path}`]);
1106
- if (out.ok) return { text: out.stdout };
1107
- if (NO_COMMIT.test(out.stderr)) return { missing: "commit" };
1108
- if (NO_PATH.test(out.stderr)) {
1109
- return await hasCommit(commit) ? { missing: "path" } : { missing: "commit" };
1110
- }
1111
- throw failed("show", out);
1112
- }),
1113
- subjectsSince: (commit, path) => once(`log\0${commit}\0${path}`, async () => {
1114
- const out = await run2(root, ["log", "--format=%s", "--end-of-options", `${commit}..HEAD`, "--", path]);
1115
- if (!out.ok) throw failed("log", out);
1116
- return out.stdout.split(/\r?\n/).filter((line) => line !== "");
1117
- }),
1118
- diffSince: (commit, path) => once(`diff\0${commit}\0${path}`, async () => {
1119
- const out = await run2(root, ["diff", "--end-of-options", commit, "--", path]);
1120
- if (!out.ok) throw failed("diff", out);
1121
- return out.stdout;
1122
- })
1123
- };
1197
+ function named(id, text) {
1198
+ return id === void 0 ? text : `${id}: ${text}`;
1124
1199
  }
1125
- var GIT_UNAVAILABLE_HISTORY = "git is not available here, so citations are checked without history: no never-true, no diffs, no commit subjects.";
1126
- var GIT_UNAVAILABLE_COMMIT = "git is not available here, so the citation records no commit.";
1127
- function noGit() {
1128
- return {
1129
- available: () => Promise.resolve(false),
1130
- head: () => Promise.resolve(null),
1131
- lsFiles: () => Promise.resolve([]),
1132
- showFile: () => Promise.resolve({ missing: "commit" }),
1133
- subjectsSince: () => Promise.resolve([]),
1134
- diffSince: () => Promise.resolve("")
1135
- };
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;
1136
1206
  }
1137
-
1138
- // src/cite/core/classify.ts
1139
- async function historyOf(git, commit, path, range, pin, key, budget) {
1140
- const shown = await git.showFile(commit, path);
1141
- if ("missing" in shown) {
1142
- return shown.missing === "commit" ? { kind: "unavailable" } : { kind: "never-true" };
1143
- }
1144
- const then = splitLines(shown.text);
1145
- let joined;
1146
- try {
1147
- joined = sliceLines2(then, range);
1148
- } catch {
1149
- joined = void 0;
1150
- }
1151
- if (joined !== void 0 && hashLines(joined, key) === pin) {
1152
- 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;
1153
1216
  }
1154
- if (range.start === void 0) return { kind: "never-true" };
1155
- const length = (range.end ?? range.start) - range.start + 1;
1156
- const search = { around: range.start };
1157
- if (budget !== void 0) search.budget = budget;
1158
- const found = findWindows(then, length, pin, key, search);
1159
- const [first] = found.starts;
1160
- if (first !== void 0) {
1161
- 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
+ );
1162
1223
  }
1163
- return found.truncated ? { kind: "unknown" } : { kind: "never-true" };
1224
+ return byExt;
1164
1225
  }
1165
- async function classifyCitation(entry, opts) {
1166
- const { citation } = entry;
1167
- const range = sourceRange(citation.source);
1168
- const commit = citation.source["commit-sha"];
1169
- const result = { src: spellSource(citation.source), status: "missing" };
1170
- if (commit !== void 0) result.commitSha = commit;
1171
- const source = await readSource(opts.root, opts.index, range, opts.key);
1172
- if (source.kind === "missing") {
1173
- result.missingReason = source.reason;
1174
- 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
+ );
1175
1252
  }
1176
- result.resolvedPath = source.resolvedPath;
1177
- const key = range.encrypted ? opts.key : void 0;
1178
- const pin = citation.source.integrity;
1179
- const lines = splitLines(source.text);
1180
- let here;
1181
- try {
1182
- here = hashLines(sliceLines2(lines, range), key);
1183
- } catch {
1184
- 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
+ );
1185
1271
  }
1186
- if (here === pin) {
1187
- result.status = "current";
1188
- 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
+ );
1189
1280
  }
1190
- let history = { kind: "none" };
1191
- if (commit !== void 0 && await opts.git.available()) {
1192
- 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
+ );
1193
1287
  }
1194
- if (range.start !== void 0) {
1195
- const start = range.start;
1196
- const end = range.end ?? start;
1197
- const search = { around: start };
1198
- if (history.kind === "original") search.original = history.lines;
1199
- if (opts.budget !== void 0) search.budget = opts.budget;
1200
- const found = findWindows(lines, end - start + 1, pin, key, search);
1201
- if (found.truncated) result.truncatedSearch = true;
1202
- const spell = (at2) => formatSrc({ ...range, start: at2, end: at2 + (end - start) });
1203
- const [only] = found.starts;
1204
- if (found.starts.length === 1 && only !== void 0) {
1205
- result.status = "moved";
1206
- result.newSrc = spell(only);
1207
- result.newLines = String(lineSpec({ start: only, end: only + (end - start) }));
1208
- 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;
1209
1299
  }
1210
- if (found.starts.length > 1) {
1211
- result.status = "moved-ambiguous";
1212
- result.candidates = found.starts.map(spell);
1213
- 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
+ }
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
+ );
1366
+ }
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;
1214
1404
  }
1405
+ markedAt.set(target, statement.line);
1406
+ target.marker = statement;
1215
1407
  }
1216
- if (history.kind === "never-true") {
1217
- result.status = "never-true";
1218
- return result;
1219
- }
1220
- result.status = "changed";
1221
- if (history.kind === "unavailable") result.historyAvailable = false;
1222
- if (history.kind === "unknown") result.truncatedSearch = true;
1223
- if (history.kind === "original" && commit !== void 0) {
1224
- result.historyAvailable = true;
1225
- result.commitsSince = await opts.git.subjectsSince(commit, source.resolvedPath);
1226
- result.diff = await opts.git.diffSince(commit, source.resolvedPath);
1227
- }
1228
- return result;
1229
- }
1230
-
1231
- // src/cite/core/page.ts
1232
- import { extname as extname2 } from "node:path";
1233
- import * as Ajv2020Ns from "ajv/dist/2020.js";
1234
-
1235
- // src/cite/schema/citations.json
1236
- var citations_default = {
1237
- $schema: "https://json-schema.org/draft/2020-12/schema",
1238
- $id: "manni:citations:1.0.0-proposal.4",
1239
- title: "manni citations vocabulary (1.0.0-proposal.4)",
1240
- 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.",
1241
- type: "object",
1242
- additionalProperties: true,
1243
- properties: {
1244
- citations: {
1245
- type: "array",
1246
- minItems: 1,
1247
- items: {
1248
- $ref: "#/$defs/citationEntry"
1249
- },
1250
- 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.",
1251
- "x-manni-location": "external"
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;
1252
1425
  }
1253
- },
1254
- patternProperties: {
1255
- "^citation-": false
1256
- },
1257
- $defs: {
1258
- citationEntry: {
1259
- type: "object",
1260
- additionalProperties: false,
1261
- required: [
1262
- "source"
1263
- ],
1264
- 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.",
1265
- properties: {
1266
- id: {
1267
- type: "string",
1268
- pattern: "^[a-z0-9][a-z0-9-]*$",
1269
- 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."
1270
- },
1271
- claim: {
1272
- $ref: "#/$defs/claim"
1273
- },
1274
- source: {
1275
- $ref: "#/$defs/source"
1276
- },
1277
- quote: {
1278
- type: "boolean",
1279
- default: false,
1280
- 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."
1281
- }
1282
- }
1283
- },
1284
- claim: {
1285
- type: "object",
1286
- additionalProperties: false,
1287
- required: [
1288
- "integrity"
1289
- ],
1290
- 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.",
1291
- properties: {
1292
- lines: {
1293
- $ref: "#/$defs/lines",
1294
- 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."
1295
- },
1296
- integrity: {
1297
- type: "string",
1298
- pattern: "^sha256-[0-9a-f]{64}$",
1299
- 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."
1300
- }
1301
- }
1302
- },
1303
- source: {
1304
- type: "object",
1305
- additionalProperties: false,
1306
- required: [
1307
- "file",
1308
- "integrity"
1309
- ],
1310
- description: "The lines the claim rests on: a file, an optional line range, a pin, and the commit the pin was taken at.",
1311
- properties: {
1312
- file: {
1313
- $ref: "#/$defs/fileRef"
1314
- },
1315
- lines: {
1316
- $ref: "#/$defs/lines",
1317
- description: "File lines. Absent, the whole file is pinned. The lines stay readable even when `file` is encrypted."
1318
- },
1319
- integrity: {
1320
- type: "string",
1321
- pattern: "^(?:sha256|hmac-sha256)-[0-9a-f]{64}$",
1322
- 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."
1323
- },
1324
- "commit-sha": {
1325
- type: "string",
1326
- pattern: "^[0-9a-f]{7,64}$",
1327
- 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."
1328
- }
1329
- }
1330
- },
1331
- lines: {
1332
- oneOf: [
1333
- {
1334
- type: "integer",
1335
- minimum: 1
1336
- },
1337
- {
1338
- type: "string",
1339
- pattern: "^[1-9][0-9]*-[1-9][0-9]*$"
1340
- }
1341
- ],
1342
- 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.'
1343
- },
1344
- fileRef: {
1345
- type: "string",
1346
- pattern: "^(?:~[A-Za-z0-9_-]{82,}|(?!~)(?:(?!\\.\\.?(?:/|$))[^/\\\\:\\r\\n\\t]+)(?:/(?:(?!\\.\\.?(?:/|$))[^/\\\\:\\r\\n\\t]+))*)$",
1347
- 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."
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
+ );
1348
1430
  }
1349
1431
  }
1350
- };
1432
+ return {
1433
+ file,
1434
+ format,
1435
+ content,
1436
+ bodyOffset,
1437
+ bodyLine,
1438
+ citations,
1439
+ statements,
1440
+ findings: findings.sort(byLine)
1441
+ };
1442
+ }
1351
1443
 
1352
- // src/cite/core/page.ts
1353
- var MAX_MARKERS_PER_PAGE = 500;
1354
- var ELEMENT_BACKED = /* @__PURE__ */ new Set(["html", "xml"]);
1355
- var FENCE_ONLY = /* @__PURE__ */ new Set(["markdown", "mdx"]);
1356
- var MARKER_JSON = "A marker names an entry by id. Write the entry in frontmatter or the sidecar.";
1357
- function ownedMessage(owner) {
1358
- return `"citations" is owned by manifest ${owner.file} (collection ${owner.collection}); remove it from the document`;
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;
1359
1508
  }
1360
- var Ajv2020 = Ajv2020Ns.default;
1361
- var entryValidator;
1362
- function compileEntryValidator() {
1363
- if (entryValidator) return entryValidator;
1364
- const ajv = new Ajv2020({ allErrors: false, strict: false });
1365
- ajv.addSchema(citations_default);
1366
- entryValidator = ajv.compile({
1367
- $ref: `${citations_default.$id}#/$defs/citationEntry`
1368
- });
1369
- return entryValidator;
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;
1370
1517
  }
1371
- function validateEntry(entry) {
1372
- const validate = compileEntryValidator();
1373
- if (validate(entry)) return void 0;
1374
- const first = validate.errors?.[0];
1375
- if (!first) return "invalid entry";
1376
- return `${first.instancePath} ${first.message ?? "is invalid"}`.trim();
1518
+ function movedUnit(misplaced) {
1519
+ return { start: misplaced.unit.start + runLength(misplaced.run), end: misplaced.unit.end };
1377
1520
  }
1378
- function isRecord(value) {
1379
- return typeof value === "object" && value !== null && !Array.isArray(value);
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;
1380
1530
  }
1381
- function toCitation(entry) {
1382
- const source = isRecord(entry.source) ? entry.source : {};
1383
- const citation = {
1384
- source: {
1385
- file: String(source.file),
1386
- integrity: String(source.integrity)
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;
1387
1581
  }
1582
+ if (status === "changed") end.text = lines.slice(span.start - 1, Math.min(span.end, lines.length));
1583
+ return end;
1388
1584
  };
1389
- if (typeof source.lines === "number" || typeof source.lines === "string") {
1390
- citation.source.lines = source.lines;
1391
- }
1392
- if (typeof source["commit-sha"] === "string") {
1393
- citation.source["commit-sha"] = source["commit-sha"];
1585
+ if (quote) {
1586
+ return claimAt(current, pinOf(lines, current) === pin ? "current" : "changed");
1394
1587
  }
1395
- if (typeof entry.id === "string") citation.id = entry.id;
1396
- if (isRecord(entry.claim)) {
1397
- const claim = entry.claim;
1398
- citation.claim = { integrity: String(claim.integrity) };
1399
- if (typeof claim.lines === "number" || typeof claim.lines === "string") {
1400
- citation.claim.lines = claim.lines;
1401
- }
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");
1402
1595
  }
1403
- if (typeof entry.quote === "boolean") citation.quote = entry.quote;
1404
- return citation;
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");
1405
1600
  }
1406
- function finding(rule, message, extra) {
1407
- const level = DEFAULT_SEVERITY[rule];
1408
- const out = {
1409
- rule,
1410
- ruleId: ruleId(rule),
1411
- // The caller re-applies the configured severity table; no page-side rule
1412
- // defaults to `off`, so there is nothing to collapse.
1413
- severity: level === "off" ? "error" : level,
1414
- message
1415
- };
1416
- if (extra.line !== void 0) out.line = extra.line;
1417
- if (extra.file !== void 0) out.file = extra.file;
1418
- if (extra.id !== void 0) out.id = extra.id;
1419
- if (extra.src !== void 0) out.src = extra.src;
1420
- if (extra.index !== void 0) out.index = extra.index;
1421
- return out;
1601
+
1602
+ // src/cite/core/claims.ts
1603
+ function normalizeWhitespace(text) {
1604
+ return text.replace(/\s+/g, " ").trim();
1422
1605
  }
1423
- function named(id, text) {
1424
- return id === void 0 ? text : `${id}: ${text}`;
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;
1425
1610
  }
1426
- function subjectOf(entry) {
1427
- const out = {};
1428
- if (!isRecord(entry)) return out;
1429
- if (typeof entry.id === "string") out.id = entry.id;
1430
- if (isRecord(entry.source) && typeof entry.source.file === "string") out.src = entry.source.file;
1431
- return out;
1611
+ function blockMatches(blockText, citedJoined) {
1612
+ return normalizeBlock(blockText) === normalizeBlock(citedJoined);
1432
1613
  }
1433
- function pickExtractor(file, format) {
1434
- if (format !== void 0) {
1435
- const forced = extractorByName(format);
1436
- if (forced === void 0) {
1437
- throw new CiteError(
1438
- `Unknown format "${format}". Supported extensions: ${supportedExtensions().join(", ")}.`
1439
- );
1614
+ function textAt(lines, range) {
1615
+ return lines.slice(range.start - 1, Math.min(range.end, lines.length));
1616
+ }
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
+ };
1440
1659
  }
1441
- return forced;
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) };
1442
1669
  }
1443
- const extension = extname2(file);
1444
- const byExt = extractorForExtension(extension);
1445
- if (!byExt) {
1446
- throw new CiteError(
1447
- `Unsupported file type "${extension}" for "${file}". Supported: ${supportedExtensions().join(", ")}. Use --as to override.`
1670
+ if (marker !== void 0) {
1671
+ return markerClaimEnd(
1672
+ page,
1673
+ entry,
1674
+ lines,
1675
+ misplaced ?? misplacedMarkerAt(page, lines, marker.line, entry)
1448
1676
  );
1449
1677
  }
1450
- return byExt;
1678
+ return { status: "changed" };
1451
1679
  }
1452
- function byLine(a, b) {
1453
- return (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER);
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) };
1454
1692
  }
1455
- function bodyLineOf(content, bodyOffset) {
1456
- if (bodyOffset <= 0) return 1;
1457
- const line = lineAt(content, bodyOffset);
1458
- return content.charCodeAt(bodyOffset - 1) === 10 ? line : line + 1;
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
+ };
1459
1703
  }
1460
- function readPage(file, content, opts) {
1461
- const extractor = pickExtractor(file, opts?.format);
1462
- const format = extractor.name;
1463
- if (FENCE_ONLY.has(format) && hasFrontmatterFence(content) && locateFrontmatter(content) === null) {
1464
- throw new CiteError(
1465
- `${file}: Unterminated front matter fence: the opening fence has no matching close, so the page's citations cannot be read. Add a closing fence.`
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
+ }
1466
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
+ })
1775
+ };
1776
+ }
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("")
1787
+ };
1788
+ }
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" };
1467
1795
  }
1468
- const extracted = extractor.extract(content, file);
1469
- const data = extracted.data;
1470
- const bodyOffset = ELEMENT_BACKED.has(format) ? 0 : locateFrontmatter(content)?.closeEnd ?? 0;
1471
- const bodyLine = bodyLineOf(content, bodyOffset);
1472
- const findings = [];
1473
- const citations = [];
1474
- const byId = /* @__PURE__ */ new Map();
1475
- const ids = /* @__PURE__ */ new Set();
1476
- const markedAt = /* @__PURE__ */ new Map();
1477
- for (const key of Object.keys(data)) {
1478
- if (!key.startsWith("citation-")) continue;
1479
- findings.push(
1480
- finding(
1481
- "entry-invalid",
1482
- `"${key}" is not a citations key; the page root reserves the citation- prefix`,
1483
- { line: extracted.lineFor(`/${key}`) }
1484
- )
1485
- );
1796
+ const then = splitLines(shown.text);
1797
+ let joined;
1798
+ try {
1799
+ joined = sliceLines2(then, range);
1800
+ } catch {
1801
+ joined = void 0;
1802
+ }
1803
+ if (joined !== void 0 && hashLines(joined, key) === pin) {
1804
+ return { kind: "original", lines: joined.split("\n") };
1805
+ }
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) };
1814
+ }
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;
1486
1827
  }
1487
- const injected = opts?.citations;
1488
- const rawCitations = data.citations;
1489
- if (opts?.owned !== void 0 && rawCitations !== void 0) {
1490
- findings.push(
1491
- finding("entry-invalid", ownedMessage(opts.owned), {
1492
- line: extracted.lineFor("/citations")
1493
- })
1494
- );
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;
1495
1837
  }
1496
- if (injected === void 0 && rawCitations !== void 0 && !Array.isArray(rawCitations)) {
1497
- findings.push(
1498
- finding("entry-invalid", "citations must be an array of entries", {
1499
- line: extracted.lineFor("/citations")
1500
- })
1501
- );
1838
+ if (here === pin) {
1839
+ result.status = "current";
1840
+ return result;
1502
1841
  }
1503
- const inputs = injected !== void 0 ? [...injected] : (Array.isArray(rawCitations) ? rawCitations : []).map((entry, index) => {
1504
- const origin = { kind: "frontmatter", file };
1505
- const line = extracted.lineFor(`/citations/${String(index)}`);
1506
- if (line !== void 0) origin.line = line;
1507
- return { entry, origin };
1508
- });
1509
- inputs.forEach((input, index) => {
1510
- const origin = { ...input.origin, index };
1511
- const where = { index };
1512
- if (origin.line !== void 0) where.line = origin.line;
1513
- if (origin.kind === "manifest") where.file = origin.file;
1514
- const entry = input.entry;
1515
- const error = validateEntry(entry);
1516
- if (error !== void 0 || !isRecord(entry)) {
1517
- findings.push(finding("entry-invalid", error ?? "must be object", { ...where, ...subjectOf(entry) }));
1518
- return;
1519
- }
1520
- const citation = toCitation(entry);
1521
- const subject = { ...where, src: spellSource(citation.source) };
1522
- if (citation.id !== void 0) subject.id = citation.id;
1523
- const bad = ["claim", "source"].find((end) => {
1524
- const lines = end === "claim" ? citation.claim?.lines : citation.source.lines;
1525
- return lines !== void 0 && parseLines(lines) === void 0;
1526
- });
1527
- if (bad !== void 0) {
1528
- const lines = bad === "claim" ? citation.claim?.lines : citation.source.lines;
1529
- findings.push(
1530
- finding(
1531
- "entry-invalid",
1532
- named(citation.id, `${bad}.lines "${String(lines)}" ends before it starts`),
1533
- subject
1534
- )
1535
- );
1536
- return;
1537
- }
1538
- const wide = ["claim", "source"].map((end) => {
1539
- const lines = end === "claim" ? citation.claim?.lines : citation.source.lines;
1540
- const parsed = lines === void 0 ? void 0 : parseLines(lines);
1541
- const reason = parsed === void 0 ? void 0 : tooWide(parsed);
1542
- return reason === void 0 ? void 0 : `${end}.lines "${String(lines)}" ${reason}`;
1543
- }).find((message) => message !== void 0);
1544
- if (wide !== void 0) {
1545
- findings.push(finding("entry-invalid", named(citation.id, wide), subject));
1546
- return;
1547
- }
1548
- const encrypted = isEncryptedValue(citation.source.file);
1549
- if (encrypted !== isKeyedPin(citation.source.integrity)) {
1550
- findings.push(
1551
- finding(
1552
- "entry-invalid",
1553
- named(
1554
- citation.id,
1555
- encrypted ? "an encrypted source is pinned with hmac-sha256-, not sha256-." : "a plain source is pinned with sha256-, not hmac-sha256-."
1556
- ),
1557
- subject
1558
- )
1559
- );
1560
- return;
1561
- }
1562
- const page = { citation, origin };
1563
- citations.push(page);
1564
- if (citation.id !== void 0) {
1565
- if (ids.has(citation.id)) {
1566
- findings.push(finding("entry-invalid", `duplicate id "${citation.id}"`, subject));
1567
- } else {
1568
- ids.add(citation.id);
1569
- byId.set(citation.id, page);
1570
- }
1571
- }
1572
- });
1573
- const body = content.slice(bodyOffset);
1574
- const statements = parseStatements(body, format, {
1575
- offset: bodyOffset,
1576
- line: bodyLine
1577
- });
1578
- if (statements.length > MAX_MARKERS_PER_PAGE) {
1579
- findings.push(
1580
- finding(
1581
- "marker-invalid",
1582
- `more than ${String(MAX_MARKERS_PER_PAGE)} markers on one page (${String(statements.length)}); the rest are not read`,
1583
- { line: statements[MAX_MARKERS_PER_PAGE]?.line }
1584
- )
1585
- );
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);
1586
1845
  }
1587
- for (const statement of statements.slice(0, MAX_MARKERS_PER_PAGE)) {
1588
- const { payload } = statement;
1589
- if (payload.kind === "bad") {
1590
- findings.push(
1591
- finding(
1592
- "marker-invalid",
1593
- payload.json === true ? MARKER_JSON : `invalid marker: ${payload.reason}`,
1594
- { line: statement.line }
1595
- )
1596
- );
1597
- continue;
1598
- }
1599
- const target = byId.get(payload.id);
1600
- if (target === void 0) {
1601
- findings.push(
1602
- finding("marker-orphan", `no entry has id "${payload.id}"`, {
1603
- line: statement.line,
1604
- id: payload.id
1605
- })
1606
- );
1607
- continue;
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;
1608
1861
  }
1609
- const first = markedAt.get(target);
1610
- if (first !== void 0) {
1611
- findings.push(
1612
- finding(
1613
- "marker-repeated",
1614
- `${payload.id} is named by markers at lines ${String(first)} and ${String(statement.line)}; the first anchors it.`,
1615
- {
1616
- line: statement.line,
1617
- id: payload.id,
1618
- index: target.origin.index,
1619
- src: spellSource(target.citation.source)
1620
- }
1621
- )
1622
- );
1623
- continue;
1862
+ if (found.starts.length > 1) {
1863
+ result.status = "moved-ambiguous";
1864
+ result.candidates = found.starts.map(spell);
1865
+ return result;
1624
1866
  }
1625
- markedAt.set(target, statement.line);
1626
- target.marker = statement;
1627
1867
  }
1628
- for (const entry of citations) {
1629
- const { citation, origin, marker } = entry;
1630
- const subject = { index: origin.index, src: spellSource(citation.source) };
1631
- if (citation.id !== void 0) subject.id = citation.id;
1632
- if (origin.line !== void 0) subject.line = origin.line;
1633
- if (origin.kind === "manifest") subject.file = origin.file;
1634
- if (citation.claim?.lines !== void 0 && marker !== void 0) {
1635
- const onPage = { index: origin.index, line: marker.line, src: subject.src };
1636
- if (citation.id !== void 0) onPage.id = citation.id;
1637
- findings.push(
1638
- finding(
1639
- "anchor-invalid",
1640
- `${citation.id ?? "the entry"} has claim lines and a marker. Keep one.`,
1641
- onPage
1642
- )
1643
- );
1644
- continue;
1645
- }
1646
- if (citation.quote === true && citation.claim?.lines === void 0 && marker === void 0) {
1647
- findings.push(
1648
- finding("anchor-invalid", named(citation.id, "quote needs a claim or a marker."), subject)
1649
- );
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;
1881
+ }
1882
+
1883
+ // src/cite/core/shift.ts
1884
+ function shiftedEntries(opts) {
1885
+ const { citations, at: at2, delta, bodyLine, label } = opts;
1886
+ const out = { frontmatter: [], manifest: /* @__PURE__ */ new Map() };
1887
+ if (at2.length === 0) return out;
1888
+ for (const { citation, origin } of citations) {
1889
+ const spec = citation.claim?.lines;
1890
+ const recorded = spec === void 0 ? void 0 : parseLines(spec);
1891
+ if (recorded === void 0) continue;
1892
+ const file = toFileLines(recorded, bodyLine);
1893
+ const inside = at2.find(
1894
+ (line) => delta === 1 ? line > file.start && line <= file.end : line >= file.start && line <= file.end
1895
+ );
1896
+ if (inside !== void 0) {
1897
+ const whose = citation.id === void 0 ? `the claim at ${spellAt(file)}` : `the claim of ${citation.id} (${spellAt(file)})`;
1898
+ const why = delta === 1 ? "A marker there would change its pin." : "Removing the marker there would change its pin.";
1899
+ throw new CiteError(`${label}:${String(inside)} is inside ${whose}. ${why}`);
1650
1900
  }
1901
+ const lines = at2.filter((line) => delta === 1 ? line <= file.start : line < file.start).length;
1902
+ if (lines === 0) continue;
1903
+ const moved = lineSpec({
1904
+ start: recorded.start + lines * delta,
1905
+ end: recorded.end + lines * delta
1906
+ });
1907
+ if (origin.kind === "manifest") out.manifest.set(origin.index, moved);
1908
+ else out.frontmatter.push({ index: origin.index, lines: moved });
1651
1909
  }
1652
- return {
1653
- file,
1654
- format,
1655
- content,
1656
- bodyOffset,
1657
- bodyLine,
1658
- citations,
1659
- statements,
1660
- findings: findings.sort(byLine)
1661
- };
1910
+ return out;
1911
+ }
1912
+ function withClaimLines(entry, lines) {
1913
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return entry;
1914
+ const claim = entry.claim;
1915
+ if (typeof claim !== "object" || claim === null || Array.isArray(claim)) return entry;
1916
+ return { ...entry, claim: { ...claim, lines } };
1662
1917
  }
1663
1918
 
1664
1919
  // src/cite/core/sidecar.ts
@@ -1816,12 +2071,48 @@ function appendFrontmatterCitation(content, format, citation, filePath) {
1816
2071
  const existing = raw;
1817
2072
  const entry = entryObject(citation);
1818
2073
  try {
1819
- return extractor.apply(content, { citations: [...existing, entry] });
2074
+ return extractor.apply(content, { citations: [...existing, entry] });
2075
+ } catch (e) {
2076
+ if (e instanceof DocmetaError) throw new CiteError(`${label}: ${e.message}`);
2077
+ throw e;
2078
+ }
2079
+ }
2080
+ function removeFrontmatterCitations(content, format, drop, filePath) {
2081
+ const label = filePath ?? "the page";
2082
+ const extractor = extractorFor(format);
2083
+ if (!extractor.apply || !readsFencedFrontmatter(extractor, label)) {
2084
+ throw new CiteError(
2085
+ `${label} has no frontmatter to write to; keep its citations in a manifest instead.`
2086
+ );
2087
+ }
2088
+ if (locateFrontmatter(content)?.flavor === "toml") {
2089
+ throw new CiteError(
2090
+ `${label} has TOML frontmatter; manni cannot rewrite a citations table without re-emitting the whole block, which would drop its comments. Remove the entry by hand.`
2091
+ );
2092
+ }
2093
+ const raw = extractor.extract(content, label).data.citations ?? [];
2094
+ if (!Array.isArray(raw)) {
2095
+ throw new CiteError(`${label}: \`citations\` is not a list; edit it by hand.`);
2096
+ }
2097
+ const existing = raw;
2098
+ const kept = existing.filter((_entry, index) => !drop.has(index));
2099
+ try {
2100
+ return kept.length === 0 ? extractor.apply(content, {}, { deletions: ["citations"] }) : extractor.apply(content, { citations: kept });
1820
2101
  } catch (e) {
1821
2102
  if (e instanceof DocmetaError) throw new CiteError(`${label}: ${e.message}`);
1822
2103
  throw e;
1823
2104
  }
1824
2105
  }
2106
+ function removeLine(content, line) {
2107
+ const start = offsetOfLine(content, line);
2108
+ const nl = content.indexOf("\n", start);
2109
+ if (nl === -1) {
2110
+ let above = start > 0 ? content.lastIndexOf("\n", start - 1) : -1;
2111
+ if (above > 0 && content.charAt(above - 1) === "\r") above--;
2112
+ return content.slice(0, above === -1 ? 0 : above);
2113
+ }
2114
+ return content.slice(0, start) + content.slice(nl + 1);
2115
+ }
1825
2116
  function entryObject(citation) {
1826
2117
  const source = { file: citation.source.file };
1827
2118
  if (citation.source.lines !== void 0) source.lines = citation.source.lines;
@@ -2013,6 +2304,25 @@ var ManifestSet = class {
2013
2304
  held.text = spliced.text;
2014
2305
  return { file: manifest.file, line: itemLine(spliced.text, entry, index, manifest.join) ?? spliced.line };
2015
2306
  }
2307
+ /**
2308
+ * Take a page's `citations` key out of the manifest, for the removal that
2309
+ * leaves it with no entries. The entry itself goes when that key was all it
2310
+ * had, so a manifest never keeps a document nothing is recorded about.
2311
+ */
2312
+ async remove(manifest, entry) {
2313
+ const held = await this.hold(manifest);
2314
+ try {
2315
+ held.text = removeManifestKey(held.text, {
2316
+ entry,
2317
+ key: CITATIONS_KEY2,
2318
+ join: manifest.join,
2319
+ file: manifest.file
2320
+ }).text;
2321
+ } catch (error) {
2322
+ if (error instanceof DocmetaError) throw new CiteError(error.message);
2323
+ throw error;
2324
+ }
2325
+ }
2016
2326
  /** The manifests this run rewrote, with the diff of each. */
2017
2327
  changed() {
2018
2328
  return [...this.held.values()].filter((m) => m.text !== m.before).map((m) => ({ ...m, diff: unifiedDiff(m.file, m.before, m.text) }));
@@ -2057,10 +2367,6 @@ function short(commit) {
2057
2367
  function plural(n, noun) {
2058
2368
  return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
2059
2369
  }
2060
- function listOf(values) {
2061
- if (values.length <= 1) return values.join("");
2062
- return `${values.slice(0, -1).join(", ")} and ${values[values.length - 1] ?? ""}`;
2063
- }
2064
2370
  function citesEncrypted(src) {
2065
2371
  try {
2066
2372
  return parseSrc(src).encrypted;
@@ -2138,6 +2444,13 @@ function claimMessageFor(result) {
2138
2444
  return "";
2139
2445
  }
2140
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
+ }
2141
2454
  function sourceRuleOf(status) {
2142
2455
  if (status === "skipped" || status === "current") return void 0;
2143
2456
  return `source-${status}`;
@@ -2261,7 +2574,7 @@ async function quoteFindings(input, entry, result) {
2261
2574
  const { citation } = entry;
2262
2575
  if (citation.quote !== true || result.anchor === null) return [];
2263
2576
  const id = citation.id;
2264
- const named3 = (text) => id === void 0 ? text : `${id}: ${text}`;
2577
+ const named4 = (text) => id === void 0 ? text : `${id}: ${text}`;
2265
2578
  const { page } = input;
2266
2579
  let block;
2267
2580
  if (result.anchor === "marker" && entry.marker !== void 0) {
@@ -2283,7 +2596,7 @@ async function quoteFindings(input, entry, result) {
2283
2596
  return [
2284
2597
  {
2285
2598
  rule: "anchor-invalid",
2286
- message: named3(
2599
+ message: named4(
2287
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.`
2288
2601
  ),
2289
2602
  line: at2.start
@@ -2305,6 +2618,17 @@ async function quoteFindings(input, entry, result) {
2305
2618
  }
2306
2619
  ];
2307
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
+ }
2308
2632
  function anchorOf(entry, claim, page) {
2309
2633
  const { citation, marker } = entry;
2310
2634
  const hasLines = citation.claim?.lines !== void 0;
@@ -2351,13 +2675,17 @@ async function checkCitations(page, opts) {
2351
2675
  checkSources,
2352
2676
  ...opts.key === void 0 ? {} : { key: opts.key }
2353
2677
  };
2678
+ const misplaced = misplacedMarkers(read, lines);
2679
+ const misplacedAt = new Map(misplaced.map((found) => [found.line, found]));
2354
2680
  for (const entry of read.citations) {
2355
- 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);
2356
2683
  const source = classifyOpts === void 0 ? skippedSource(entry) : await classifyCitation(entry, classifyOpts);
2357
2684
  const result = {
2358
2685
  citation: entry.citation,
2359
2686
  origin: entry.origin,
2360
2687
  ...anchorOf(entry, claim, read),
2688
+ ...markerEnd(entry, split),
2361
2689
  claim,
2362
2690
  source
2363
2691
  };
@@ -2378,6 +2706,30 @@ async function checkCitations(page, opts) {
2378
2706
  findings.push(finding2);
2379
2707
  }
2380
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
+ }
2381
2733
  findings.push(...findingsFor(results, severity));
2382
2734
  const wantsHistory = checkSources && read.citations.some(({ citation }) => citation.source["commit-sha"] !== void 0);
2383
2735
  if (wantsHistory && !await client.available()) notices.push(GIT_UNAVAILABLE_HISTORY);
@@ -2629,7 +2981,9 @@ async function resolveCiteRun(opts) {
2629
2981
  root = gitRoot;
2630
2982
  } else {
2631
2983
  root = cwd;
2632
- opts.onNotice?.(`No git root found; resolving src: paths from ${cwd}`);
2984
+ if (opts.resolvesSources !== false) {
2985
+ opts.onNotice?.(`No git root found; resolving src: paths from ${cwd}`);
2986
+ }
2633
2987
  }
2634
2988
  }
2635
2989
  const { key, source: keySource } = resolveEncryptionKey({
@@ -2678,7 +3032,8 @@ function joinHits() {
2678
3032
  }
2679
3033
  };
2680
3034
  }
2681
- async function prepareRun(opts, action, verb, requireSources = false) {
3035
+ async function prepareRun(opts, action, verb, sources = {}) {
3036
+ const requireSources = sources.require === true;
2682
3037
  const cwd = resolve3(opts.cwd ?? process.cwd());
2683
3038
  const run3 = await resolveCiteRun({
2684
3039
  cwd,
@@ -2689,7 +3044,8 @@ async function prepareRun(opts, action, verb, requireSources = false) {
2689
3044
  root: opts.root,
2690
3045
  onConfigLoaded: opts.onConfigLoaded,
2691
3046
  onNotice: opts.onNotice,
2692
- env: opts.env
3047
+ env: opts.env,
3048
+ ...sources.resolve === false ? { resolvesSources: false } : {}
2693
3049
  });
2694
3050
  const { config, inputs, base } = run3;
2695
3051
  const checkSources = opts.checkSources ?? config?.checkSources ?? true;
@@ -2869,9 +3225,6 @@ async function citedText(root, index, src, key) {
2869
3225
  }
2870
3226
  return sliceLines2(splitLines(source.text), range, range.path);
2871
3227
  }
2872
- function spellAt(lines) {
2873
- return lines.start === lines.end ? `line ${String(lines.start)}` : `lines ${String(lines.start)}-${String(lines.end)}`;
2874
- }
2875
3228
  function entryName(entry) {
2876
3229
  return entry.citation.id ?? `/citations/${String(entry.origin.index)}`;
2877
3230
  }
@@ -2910,37 +3263,11 @@ var NAMED_SPANS = 3;
2910
3263
  function spellElsewhere(spans) {
2911
3264
  const first = spans[0];
2912
3265
  const noun = spans.length === 1 && first !== void 0 && first.start === first.end ? "line" : "lines";
2913
- const named3 = spans.slice(0, NAMED_SPANS).map((span) => spellLines(span));
2914
- const rest = spans.length - named3.length;
2915
- if (rest > 0) return `${noun} ${named3.join(", ")} and ${String(rest)} more`;
2916
- const last = named3.pop() ?? "";
2917
- return named3.length === 0 ? `${noun} ${last}` : `${noun} ${named3.join(", ")} and ${last}`;
2918
- }
2919
- function shiftedEntries(citations, insertLine, bodyLine, label) {
2920
- const out = { frontmatter: [], manifest: /* @__PURE__ */ new Map() };
2921
- for (const { citation, origin } of citations) {
2922
- const spec = citation.claim?.lines;
2923
- const recorded = spec === void 0 ? void 0 : parseLines(spec);
2924
- if (recorded === void 0) continue;
2925
- const file = toFileLines(recorded, bodyLine);
2926
- if (file.start < insertLine) {
2927
- if (file.end < insertLine) continue;
2928
- const whose = citation.id === void 0 ? `the claim at ${spellAt(file)}` : `the claim of ${citation.id} (${spellAt(file)})`;
2929
- throw new CiteError(
2930
- `${label}:${String(insertLine)} is inside ${whose}. A marker there would change its pin.`
2931
- );
2932
- }
2933
- const moved = lineSpec({ start: recorded.start + 1, end: recorded.end + 1 });
2934
- if (origin.kind === "manifest") out.manifest.set(origin.index, moved);
2935
- else out.frontmatter.push({ index: origin.index, lines: moved });
2936
- }
2937
- return out;
2938
- }
2939
- function withClaimLines(entry, lines) {
2940
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return entry;
2941
- const claim = entry.claim;
2942
- if (typeof claim !== "object" || claim === null || Array.isArray(claim)) return entry;
2943
- return { ...entry, claim: { ...claim, lines } };
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}`;
2944
3271
  }
2945
3272
  async function runAdd(opts) {
2946
3273
  const cwd = resolve4(opts.cwd ?? process.cwd());
@@ -3037,11 +3364,28 @@ async function runAdd(opts) {
3037
3364
  const empty = emptyClaimRefusal(lines, pageLines, at2);
3038
3365
  if (empty !== void 0) throw new CiteError(empty);
3039
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
+ }
3040
3384
  }
3041
3385
  const client = opts.gitClient ?? gitClient(root);
3042
3386
  const sourceIndex = await sourceIndexFor(root, client);
3043
3387
  let body = content;
3044
- let markerAt;
3388
+ let markerAt2;
3045
3389
  let claimSpan;
3046
3390
  let claim;
3047
3391
  let elsewhere = [];
@@ -3065,11 +3409,17 @@ async function runAdd(opts) {
3065
3409
  `${at2} is in a ${holding.kind} at ${spellAt(holding)} that ${unitWide}. A marker anchors the whole ${holding.kind}.`
3066
3410
  );
3067
3411
  }
3068
- shifted = shiftedEntries(page.citations, holding.start, page.bodyLine, label);
3412
+ shifted = shiftedEntries({
3413
+ citations: page.citations,
3414
+ at: [holding.start],
3415
+ delta: 1,
3416
+ bodyLine: page.bodyLine,
3417
+ label
3418
+ });
3069
3419
  const statement = markerIndent(content, holding.start, format, page.bodyLine) + formatStatement(format, { kind: "ref", id: opts.id ?? "" });
3070
3420
  const insertAt = offsetOfLine(content, holding.start);
3071
3421
  body = insertStatementBefore(content, insertAt, statement);
3072
- markerAt = holding.start;
3422
+ markerAt2 = holding.start;
3073
3423
  const unit = anchoredLines(body, insertAt + statement.length, format, quote);
3074
3424
  const pin = unit === void 0 ? void 0 : pinOfLines(splitLines(body), unit);
3075
3425
  if (unit === void 0 || pin === void 0) {
@@ -3165,7 +3515,7 @@ async function runAdd(opts) {
3165
3515
  ...sourceLine === void 0 ? {} : { sourceLine },
3166
3516
  ...placed === void 0 ? {} : { manifest: placed }
3167
3517
  };
3168
- if (markerAt !== void 0) result.markerLine = markerAt + shift;
3518
+ if (markerAt2 !== void 0) result.markerLine = markerAt2 + shift;
3169
3519
  if (claimSpan !== void 0) {
3170
3520
  result.claimLines = { start: claimSpan.start + shift, end: claimSpan.end + shift };
3171
3521
  }
@@ -3176,10 +3526,10 @@ async function runAdd(opts) {
3176
3526
  result.manifest.written = true;
3177
3527
  }
3178
3528
  if (elsewhere.length > 0) {
3179
- 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 }));
3180
3530
  const who = citation.id === void 0 ? "" : `${citation.id}: `;
3181
3531
  opts.onNotice?.(
3182
- `${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.`
3183
3533
  );
3184
3534
  }
3185
3535
  if (opts.commitSha !== false && !await client.available()) {
@@ -3188,8 +3538,175 @@ async function runAdd(opts) {
3188
3538
  return result;
3189
3539
  }
3190
3540
 
3191
- // src/cite/commands/update.ts
3541
+ // src/cite/commands/remove.ts
3192
3542
  import { resolve as resolve5 } from "node:path";
3543
+ var POINTER = /^\/citations\/(0|[1-9][0-9]*)$/;
3544
+ function entryCount(n) {
3545
+ return `${String(n)} ${n === 1 ? "entry" : "entries"}`;
3546
+ }
3547
+ function idOf(entry) {
3548
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return void 0;
3549
+ const id = entry.id;
3550
+ return typeof id === "string" ? id : void 0;
3551
+ }
3552
+ async function runRemove(opts) {
3553
+ const only = [...new Set(opts.only)];
3554
+ if (only.length === 0) {
3555
+ throw new CiteError("remove needs --only <id>; it never removes every citation on a page.");
3556
+ }
3557
+ const prepared = await prepareRun({ ...opts, checkSources: false }, "removed", "remove", {
3558
+ resolve: false
3559
+ });
3560
+ const { run: run3, files, usingStdin, forced } = prepared;
3561
+ assertNoOrphans(prepared);
3562
+ const hits = joinHits();
3563
+ const manifests = new ManifestSet();
3564
+ const matched = /* @__PURE__ */ new Set();
3565
+ const pageCount = files.length + (usingStdin ? 1 : 0);
3566
+ const planned = [];
3567
+ const removeOne = async (label, content, path) => {
3568
+ const setup = prepared.setupFor(label, content);
3569
+ if (setup.sidecar !== void 0) hits.record(setup.sidecar, label);
3570
+ const owner = setup.sidecar?.owner;
3571
+ const page = readPage(label, content, {
3572
+ ...forced === void 0 ? {} : { format: forced.name },
3573
+ ...setup.options.citations === void 0 ? {} : { citations: setup.options.citations },
3574
+ ...owner === void 0 ? {} : { owned: { file: owner.file, collection: owner.collection } }
3575
+ });
3576
+ const { format } = page;
3577
+ const extractor = pickExtractor(label, forced?.name);
3578
+ const inputs = citationInputs(
3579
+ label,
3580
+ extractor.extract(content, label),
3581
+ setup.sidecar?.citations
3582
+ );
3583
+ const lines = splitLines(content);
3584
+ const entriesFor = (value) => {
3585
+ const [, digits] = POINTER.exec(value) ?? [];
3586
+ if (digits === void 0) {
3587
+ return inputs.flatMap((input, index2) => idOf(input.entry) === value ? [index2] : []);
3588
+ }
3589
+ const index = Number(digits);
3590
+ if (index < inputs.length) return [index];
3591
+ if (pageCount === 1) {
3592
+ throw new CiteError(
3593
+ `"${value}" is past the last entry of ${label} (${entryCount(inputs.length)}).`
3594
+ );
3595
+ }
3596
+ return [];
3597
+ };
3598
+ const markersFor = (id) => id === void 0 ? [] : page.statements.filter((s) => s.payload.kind === "ref" && s.payload.id === id).map((s) => s.line);
3599
+ const removed = [];
3600
+ const indices = /* @__PURE__ */ new Set();
3601
+ const markerLines = [];
3602
+ for (const value of only) {
3603
+ const found = entriesFor(value);
3604
+ for (const index of found) {
3605
+ const input = inputs[index];
3606
+ const id = idOf(input?.entry);
3607
+ const markers2 = markersFor(id);
3608
+ indices.add(index);
3609
+ markerLines.push(...markers2);
3610
+ removed.push({
3611
+ ...id === void 0 ? {} : { id },
3612
+ index,
3613
+ ...input === void 0 ? {} : { origin: input.origin },
3614
+ markerLines: markers2
3615
+ });
3616
+ matched.add(value);
3617
+ }
3618
+ if (found.length > 0) continue;
3619
+ const markers = markersFor(value);
3620
+ if (markers.length === 0) continue;
3621
+ markerLines.push(...markers);
3622
+ removed.push({ id: value, markerLines: markers });
3623
+ matched.add(value);
3624
+ }
3625
+ const nothing = { file: label, removed: [], diff: "", written: false };
3626
+ if (removed.length === 0) {
3627
+ if (path === void 0) nothing.content = content;
3628
+ planned.push({ page: nothing, ...path === void 0 ? {} : { path } });
3629
+ return;
3630
+ }
3631
+ const ordered = [...new Set(markerLines)].sort((a, b) => a - b);
3632
+ for (const line of ordered) {
3633
+ if (!isMarkerLine(lines[line - 1] ?? "", format)) {
3634
+ const id = page.statements.find((s) => s.line === line)?.payload;
3635
+ const named4 = id?.kind === "ref" ? ` ${id.id}` : "";
3636
+ throw new CiteError(
3637
+ `the marker${named4} at ${label}:${String(line)} shares its line with text; remove it by hand.`
3638
+ );
3639
+ }
3640
+ }
3641
+ const survivors = page.citations.filter((c) => !indices.has(c.origin.index));
3642
+ const shifted = shiftedEntries({
3643
+ citations: survivors,
3644
+ at: ordered,
3645
+ delta: -1,
3646
+ bodyLine: page.bodyLine,
3647
+ label
3648
+ });
3649
+ let after = content;
3650
+ for (const { index, lines: moved } of shifted.frontmatter) {
3651
+ after = spliceEntryField(after, format, index, ["claim", "lines"], moved);
3652
+ }
3653
+ for (const line of [...ordered].sort((a, b) => b - a)) after = removeLine(after, line);
3654
+ if (owner === void 0) {
3655
+ after = removeFrontmatterCitations(after, format, indices, label);
3656
+ } else {
3657
+ if (setup.sidecar?.entry === void 0) {
3658
+ throw new CiteError(
3659
+ `${label} carries no ${owner.join}: value, so its citations cannot be keyed in ${owner.file}.`
3660
+ );
3661
+ }
3662
+ const kept = inputs.map((input, index) => {
3663
+ const moved = shifted.manifest.get(index);
3664
+ return moved === void 0 ? input.entry : withClaimLines(input.entry, moved);
3665
+ }).filter((_entry, index) => !indices.has(index));
3666
+ if (kept.length === 0) await manifests.remove(owner, setup.sidecar.entry);
3667
+ else void await manifests.write(owner, setup.sidecar.entry, kept, 0);
3668
+ }
3669
+ const out = {
3670
+ file: label,
3671
+ removed,
3672
+ diff: after === content ? "" : unifiedDiff(label, content, after),
3673
+ written: false
3674
+ };
3675
+ if (path === void 0) out.content = after;
3676
+ planned.push({ page: out, ...after === content ? {} : { content: after }, ...path === void 0 ? {} : { path } });
3677
+ };
3678
+ if (usingStdin) await removeOne(STDIN_LABEL, opts.stdinContent ?? "");
3679
+ for (const file of files) {
3680
+ await removeOne(file, await readTarget(run3, file), resolve5(run3.base, file));
3681
+ }
3682
+ assertNoOrphanJoins(prepared, hits);
3683
+ const missing = only.find((value) => !matched.has(value));
3684
+ if (missing !== void 0) {
3685
+ const where = pageCount === 1 ? `${planned[0]?.page.file ?? "the page"} has no` : `none of ${String(pageCount)} pages has an`;
3686
+ const what = POINTER.test(missing) ? `entry ${missing}` : `entry or marker ${missing}`;
3687
+ throw new CiteError(`${where} ${what}.`);
3688
+ }
3689
+ const write = opts.dryRun !== true;
3690
+ for (const item of planned) {
3691
+ if (item.content === void 0 || item.path === void 0) continue;
3692
+ if (write) await writeFileAtomic(item.path, item.content);
3693
+ item.page.written = write;
3694
+ }
3695
+ const rewritten = [];
3696
+ for (const changed of manifests.changed()) {
3697
+ if (write) await writeFileAtomic(changed.path, changed.text);
3698
+ rewritten.push({ file: changed.file, diff: changed.diff, written: write });
3699
+ }
3700
+ const pages = planned.map((item) => item.page);
3701
+ return {
3702
+ pages,
3703
+ removed: pages.reduce((n, page) => n + page.removed.length, 0),
3704
+ ...rewritten.length > 0 ? { manifests: rewritten } : {}
3705
+ };
3706
+ }
3707
+
3708
+ // src/cite/commands/update.ts
3709
+ import { resolve as resolve6 } from "node:path";
3193
3710
  function settles(plan) {
3194
3711
  switch (plan.kind) {
3195
3712
  case "claim-moved":
@@ -3200,13 +3717,26 @@ function settles(plan) {
3200
3717
  return "claim-changed";
3201
3718
  case "source-accepted":
3202
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 "";
3203
3727
  }
3204
3728
  }
3205
3729
  function apply(content, format, plan) {
3206
3730
  const index = plan.result.origin.index;
3207
3731
  switch (plan.kind) {
3208
3732
  case "claim-moved":
3733
+ case "claim-shifted":
3209
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;
3210
3740
  case "source-moved":
3211
3741
  return spliceEntryField(content, format, index, ["source", "lines"], plan.lines);
3212
3742
  case "claim-accepted": {
@@ -3239,12 +3769,22 @@ function applyToEntry(entry, plan) {
3239
3769
  if (!isRecord3(entry)) return false;
3240
3770
  const end = (name) => isRecord3(entry[name]) ? entry[name] : void 0;
3241
3771
  switch (plan.kind) {
3242
- case "claim-moved": {
3772
+ case "claim-moved":
3773
+ case "claim-shifted": {
3243
3774
  const claim = end("claim");
3244
3775
  if (claim === void 0) return false;
3245
3776
  claim.lines = plan.lines;
3246
3777
  return true;
3247
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;
3248
3788
  case "source-moved": {
3249
3789
  const source = end("source");
3250
3790
  if (source === void 0) return false;
@@ -3275,7 +3815,56 @@ function rewriteOf(plan) {
3275
3815
  if (origin.line !== void 0) base.line = origin.line;
3276
3816
  switch (plan.kind) {
3277
3817
  case "claim-moved":
3278
- 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
+ };
3279
3868
  case "source-moved":
3280
3869
  return {
3281
3870
  ...base,
@@ -3286,19 +3875,21 @@ function rewriteOf(plan) {
3286
3875
  to: plan.to
3287
3876
  };
3288
3877
  case "claim-accepted": {
3878
+ const was = citation.claim?.integrity ?? "";
3289
3879
  const out = {
3290
3880
  ...base,
3291
3881
  end: "claim",
3292
3882
  reason: "accepted",
3293
3883
  status: "changed",
3294
- from: citation.claim?.integrity ?? "",
3884
+ from: was,
3295
3885
  to: plan.pin,
3886
+ fromPin: was,
3887
+ toPin: plan.pin,
3296
3888
  at: plan.unit.lines.start,
3297
3889
  text: normalizeWhitespace(plan.unit.text.join("\n"))
3298
3890
  };
3299
- if (plan.result.anchor === "marker" && plan.result.markerLine !== void 0) {
3300
- out.markerLine = plan.result.markerLine;
3301
- }
3891
+ const markerLine = plan.markerLine ?? (plan.result.anchor === "marker" ? plan.result.markerLine : void 0);
3892
+ if (markerLine !== void 0) out.markerLine = markerLine;
3302
3893
  return out;
3303
3894
  }
3304
3895
  case "source-accepted": {
@@ -3309,6 +3900,8 @@ function rewriteOf(plan) {
3309
3900
  status: source.status === "never-true" ? "never-true" : "changed",
3310
3901
  from: citation.source.integrity,
3311
3902
  to: plan.minted.source.integrity,
3903
+ fromPin: citation.source.integrity,
3904
+ toPin: plan.minted.source.integrity,
3312
3905
  src: source.src
3313
3906
  };
3314
3907
  const commit = plan.minted.source["commit-sha"];
@@ -3317,26 +3910,157 @@ function rewriteOf(plan) {
3317
3910
  }
3318
3911
  }
3319
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
+ }
3320
4038
  async function runUpdate(opts) {
3321
- const prepared = await prepareRun(opts, "updated", "update", true);
4039
+ const prepared = await prepareRun(opts, "updated", "update", { require: true });
3322
4040
  const { run: run3, files, usingStdin, forced, pageOptions, git } = prepared;
3323
4041
  assertNoOrphans(prepared);
3324
4042
  const hits = joinHits();
3325
4043
  const manifests = new ManifestSet();
4044
+ const writes = new WrittenFiles();
3326
4045
  const only = opts.only !== void 0 && opts.only.length > 0 ? new Set(opts.only) : void 0;
3327
4046
  const accept = opts.accept === true;
3328
- const plansFor = async (result, entry, page, lines, declined) => {
4047
+ const plansFor = async (result, entry, page, lines, lineNow, declined) => {
3329
4048
  const out = [];
3330
4049
  const claim = result.claim;
3331
- if (claim !== null && claim.status === "moved" && claim.newLines !== void 0) {
4050
+ if (result.anchor !== "marker" && claim !== null && claim.status === "moved" && claim.newLines !== void 0) {
3332
4051
  const at2 = parseLines(claim.newLines);
3333
- 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) {
3334
4058
  out.push({
3335
4059
  kind: "claim-moved",
3336
4060
  result,
3337
- lines: lineSpec(at2),
4061
+ lines: lineSpec(shifted),
3338
4062
  from: claim.fileLines ?? "",
3339
- to: claim.newFileLines ?? ""
4063
+ to: spellLines(toFileLines(shifted, page.bodyLine))
3340
4064
  });
3341
4065
  }
3342
4066
  }
@@ -3347,8 +4071,8 @@ async function runUpdate(opts) {
3347
4071
  }
3348
4072
  }
3349
4073
  if (!accept) return out;
3350
- if (claim !== null && claim.status === "changed" && entry !== void 0) {
3351
- 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 = (() => {
3352
4076
  const at2 = claimLine(claim);
3353
4077
  return at2 === void 0 ? void 0 : unitAt(page, at2, lines);
3354
4078
  })();
@@ -3356,7 +4080,10 @@ async function runUpdate(opts) {
3356
4080
  const pin = unit === void 0 ? void 0 : pinOfLines(lines, unit.lines);
3357
4081
  const wide = unit === void 0 ? void 0 : tooWide(unit.lines);
3358
4082
  if (unit !== void 0 && wide !== void 0) {
3359
- 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
+ });
3360
4087
  } else if (unit !== void 0 && pin !== void 0 && (!wantsBlock || unit.kind === "block")) {
3361
4088
  const plan = { kind: "claim-accepted", result, unit, pin };
3362
4089
  if (entry.citation.claim?.lines !== void 0) {
@@ -3392,6 +4119,7 @@ async function runUpdate(opts) {
3392
4119
  if (setup.sidecar !== void 0) hits.record(setup.sidecar, label);
3393
4120
  const report = await checkCitations({ file: label, content, format: forced?.name }, setup.options);
3394
4121
  const { format } = report;
4122
+ const severity = resolveSeverity(setup.options.severity);
3395
4123
  const page = readPage(label, content, {
3396
4124
  ...forced === void 0 ? {} : { format: forced.name },
3397
4125
  ...setup.options.citations === void 0 ? {} : { citations: setup.options.citations },
@@ -3401,18 +4129,105 @@ async function runUpdate(opts) {
3401
4129
  const rewritten2 = [];
3402
4130
  const settled = /* @__PURE__ */ new Set();
3403
4131
  const declined = /* @__PURE__ */ new Map();
3404
- 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;
3405
4145
  const owner = setup.sidecar?.owner;
3406
4146
  const entries = setup.sidecar?.citations === void 0 ? void 0 : JSON.parse(JSON.stringify(setup.sidecar.citations.map((c) => c.entry)));
3407
4147
  let manifestDirty = false;
3408
4148
  for (const result of report.citations) {
3409
- if (only !== void 0 && (result.citation.id === void 0 || !only.has(result.citation.id))) {
3410
- continue;
3411
- }
4149
+ const selected = only === void 0 || result.citation.id !== void 0 && only.has(result.citation.id);
3412
4150
  const entry = page.citations.find((c) => c.origin.index === result.origin.index);
3413
- for (const plan of await plansFor(result, entry, page, lines, declined)) {
3414
- if (result.origin.kind === "manifest") {
3415
- 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;
3416
4231
  manifestDirty = true;
3417
4232
  } else {
3418
4233
  after = apply(after, format, plan);
@@ -3421,6 +4236,7 @@ async function runUpdate(opts) {
3421
4236
  settled.add(`${String(result.origin.index)}\0${settles(plan)}`);
3422
4237
  }
3423
4238
  }
4239
+ for (const index of movedIndexes) settled.add(`${String(index)}\0marker-misplaced`);
3424
4240
  if (manifestDirty && owner !== void 0 && entries !== void 0) {
3425
4241
  if (setup.sidecar?.entry === void 0) {
3426
4242
  throw new CiteError(
@@ -3429,15 +4245,34 @@ async function runUpdate(opts) {
3429
4245
  }
3430
4246
  await manifests.write(owner, setup.sidecar.entry, entries, 0);
3431
4247
  }
4248
+ const stayAt = new Map(markers.stays.map((stay) => [stay.line, stay]));
3432
4249
  const skipped2 = report.findings.filter(
3433
4250
  (finding2) => (only === void 0 || finding2.id !== void 0 && only.has(finding2.id)) && !settled.has(`${String(finding2.index ?? -1)}\0${finding2.rule}`)
3434
4251
  ).map((finding2) => {
3435
- 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;
3436
4264
  return why === void 0 ? finding2 : { ...finding2, message: `${finding2.message} ${why}` };
3437
4265
  });
3438
4266
  const diff = after === content ? "" : unifiedDiff(label, content, after);
3439
4267
  const written = after !== content && path !== void 0 && opts.dryRun !== true;
3440
- 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
+ }
3441
4276
  const out = { file: label, rewritten: rewritten2, skipped: skipped2, diff, written };
3442
4277
  if (path === void 0) out.content = after;
3443
4278
  pages.push(out);
@@ -3446,21 +4281,28 @@ async function runUpdate(opts) {
3446
4281
  const reports = [];
3447
4282
  if (usingStdin) reports.push(await updateOne(STDIN_LABEL, opts.stdinContent ?? ""));
3448
4283
  for (const file of files) {
3449
- reports.push(await updateOne(file, await readTarget(run3, file), resolve5(run3.base, file)));
4284
+ reports.push(await updateOne(file, await readTarget(run3, file), resolve6(run3.base, file)));
3450
4285
  }
3451
4286
  assertNoOrphanJoins(prepared, hits);
3452
4287
  const changedManifests = manifests.changed();
3453
4288
  const rewrittenManifests = [];
3454
4289
  for (const changed of changedManifests) {
3455
4290
  const write = opts.dryRun !== true;
3456
- 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
+ }
3457
4299
  rewrittenManifests.push({ file: changed.file, diff: changed.diff, written: write });
3458
4300
  }
3459
4301
  const reminted = pages.some(
3460
4302
  (page) => page.rewritten.some((r) => r.reason === "accepted" && r.end === "source")
3461
4303
  );
3462
4304
  sayNotices(reports, opts.onNotice, reminted && !await git.available() ? [GIT_UNAVAILABLE_COMMIT] : []);
3463
- const rewritten = pages.reduce((n, page) => n + page.rewritten.length, 0);
4305
+ const rewritten = pages.reduce((n, page) => n + citationsIn(page.rewritten), 0);
3464
4306
  const skipped = pages.reduce((n, page) => n + page.skipped.length, 0);
3465
4307
  const undone = pages.some((page) => page.skipped.some((finding2) => finding2.severity === "error"));
3466
4308
  return {
@@ -3648,13 +4490,15 @@ function citesEncrypted2(src) {
3648
4490
  }
3649
4491
  }
3650
4492
  function claimColumn(result) {
3651
- const { claim } = result;
3652
- 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}`;
3653
4497
  if (claim === null) return where;
3654
4498
  let status;
3655
4499
  switch (claim.status) {
3656
4500
  case "moved":
3657
- status = `moved -> :${claim.newFileLines ?? "?"}`;
4501
+ status = anchoredByMarker ? "moved" : `moved -> :${claim.newFileLines ?? "?"}`;
3658
4502
  break;
3659
4503
  case "moved-ambiguous": {
3660
4504
  const at2 = (claim.candidateFileLines ?? []).map((lines) => `:${lines}`);
@@ -3744,7 +4588,7 @@ function renderCheckPretty(run3, opts) {
3744
4588
  if (own.length === 0 && quiet) continue;
3745
4589
  const worst = live.find((f) => f.severity === "error") ?? live.find((f) => f.severity === "warning") ?? live[0];
3746
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");
3747
- const endRules = /* @__PURE__ */ new Set(["claim-", "source-"]);
4591
+ const endRules = /* @__PURE__ */ new Set(["claim-", "source-", "marker-misplaced"]);
3748
4592
  const under = [];
3749
4593
  for (const finding2 of own) {
3750
4594
  const isEnd = [...endRules].some((prefix) => finding2.rule.startsWith(prefix));
@@ -3803,9 +4647,45 @@ function renderCheckPretty(run3, opts) {
3803
4647
  }
3804
4648
  return lines.join("\n");
3805
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
+ }
3806
4675
  function rewriteLine(rewrite) {
3807
4676
  const word = rewrite.from.includes("-") ? "lines" : "line";
3808
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
+ }
3809
4689
  if (rewrite.reason === "moved") {
3810
4690
  return rewrite.end === "claim" ? `claim ${word} ${rewrite.from} -> ${rewrite.to} (moved)` : `source ${shortSrc(rewrite.from)} -> ${shortSrc(rewrite.to)} (moved)`;
3811
4691
  }
@@ -3849,6 +4729,50 @@ function renderUpdatePretty(run3, opts) {
3849
4729
  lines.push(run3.exitCode === 0 ? c.green(summary) : c.red(summary));
3850
4730
  return lines.join("\n");
3851
4731
  }
4732
+ function spellLineList(at2, noun) {
4733
+ if (at2.length === 0) return noun;
4734
+ const spelled = at2.map((line) => String(line));
4735
+ if (spelled.length === 1) return `${noun} ${spelled[0] ?? ""}`;
4736
+ const last = spelled[spelled.length - 1] ?? "";
4737
+ return `${noun}s ${spelled.slice(0, -1).join(", ")} and ${last}`;
4738
+ }
4739
+ function removalLine(removal, c) {
4740
+ const markers = spellLineList(removal.markerLines, "line");
4741
+ if (removal.origin === void 0) {
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`;
4744
+ }
4745
+ const name = removal.id ?? `/citations/${String(removal.index ?? 0)}`;
4746
+ const where = removal.origin.kind === "manifest" ? `${removal.origin.file}${removal.origin.line === void 0 ? "" : `:${String(removal.origin.line)}`}` : "frontmatter";
4747
+ const also = removal.markerLines.length === 0 ? "" : `, and ${removal.markerLines.length === 1 ? "its marker" : "its markers"} at ${markers}`;
4748
+ return `removed ${c.cyan(name)} from ${where}${also}`;
4749
+ }
4750
+ function renderRemovePretty(run3, opts) {
4751
+ const c = palette(opts.color);
4752
+ const lines = [];
4753
+ let files = 0;
4754
+ for (const page of run3.pages) {
4755
+ if (page.removed.length > 0) files += 1;
4756
+ if (opts.showDiff && page.diff !== "") {
4757
+ for (const line of page.diff.split(/\r?\n/)) {
4758
+ if (line !== "") lines.push(c.dim(line));
4759
+ }
4760
+ }
4761
+ for (const removal of page.removed) {
4762
+ lines.push(`${page.file}: ${removalLine(removal, c)}`);
4763
+ }
4764
+ }
4765
+ if (opts.showDiff) {
4766
+ for (const manifest of run3.manifests ?? []) {
4767
+ for (const line of manifest.diff.split(/\r?\n/)) {
4768
+ if (line !== "") lines.push(c.dim(line));
4769
+ }
4770
+ }
4771
+ }
4772
+ const verb = opts.dryRun === true ? "would be removed" : "removed";
4773
+ lines.push(c.green(`${plural2(run3.removed, "citation")} ${verb} from ${plural2(files, "file")}`));
4774
+ return lines.join("\n");
4775
+ }
3852
4776
 
3853
4777
  // src/cite/reporters/json.ts
3854
4778
  function publicCitation(result) {
@@ -3880,6 +4804,7 @@ function publicCitation(result) {
3880
4804
  ...result.citation.id === void 0 ? {} : { id: result.citation.id },
3881
4805
  origin,
3882
4806
  anchor: result.anchor,
4807
+ ...result.marker === void 0 ? {} : { marker: result.marker },
3883
4808
  claim,
3884
4809
  source
3885
4810
  };
@@ -3890,6 +4815,9 @@ function publicPage(page) {
3890
4815
  function renderCheckJson(run3) {
3891
4816
  return JSON.stringify({ summary: run3.summary, pages: run3.pages.map(publicPage) }, null, 2);
3892
4817
  }
4818
+ function renderRemoveJson(run3) {
4819
+ return JSON.stringify(run3, null, 2);
4820
+ }
3893
4821
  function renderUpdateJson(run3) {
3894
4822
  const pages = run3.pages.map((page) => ({
3895
4823
  ...page,
@@ -3944,7 +4872,7 @@ var KeyError = class extends ToolError {
3944
4872
  // src/key/commands/rotate.ts
3945
4873
  import { existsSync as existsSync2 } from "node:fs";
3946
4874
  import { readFile as readFile5 } from "node:fs/promises";
3947
- import { isAbsolute as isAbsolute3, join as join2, relative as relative3, resolve as resolve6, sep as sep3 } from "node:path";
4875
+ import { isAbsolute as isAbsolute3, join as join2, relative as relative3, resolve as resolve7, sep as sep3 } from "node:path";
3948
4876
  import { parse as parseYaml } from "yaml";
3949
4877
 
3950
4878
  // src/key/core/config.ts
@@ -3980,9 +4908,9 @@ function citeSection(file) {
3980
4908
  return parseCiteConfig(doc.cite, file.source);
3981
4909
  }
3982
4910
  function rootFor(flag, cite, file, cwd) {
3983
- if (flag !== void 0) return { root: resolve6(cwd, flag), fellBack: false };
4911
+ if (flag !== void 0) return { root: resolve7(cwd, flag), fellBack: false };
3984
4912
  if (file !== null && cite.root !== void 0) {
3985
- return { root: resolve6(file.dir, cite.root), fellBack: false };
4913
+ return { root: resolve7(file.dir, cite.root), fellBack: false };
3986
4914
  }
3987
4915
  const gitRoot = findGitRoot(cwd);
3988
4916
  return gitRoot === null ? { root: cwd, fellBack: true } : { root: gitRoot, fellBack: false };
@@ -4044,7 +4972,7 @@ function reportedPath2(abs, base) {
4044
4972
  function manifestCollections(collections, opts) {
4045
4973
  if (opts.fromCollections) return [...collections];
4046
4974
  const rels = opts.files.map(
4047
- (rel) => reportedPath2(resolve6(opts.base, rel), opts.configDir)
4975
+ (rel) => reportedPath2(resolve7(opts.base, rel), opts.configDir)
4048
4976
  );
4049
4977
  return collections.filter((c) => rels.some((rel) => isMember(c, rel)));
4050
4978
  }
@@ -4073,7 +5001,7 @@ async function planManifests(opts) {
4073
5001
  for (const c of local) {
4074
5002
  for (const m of c.externalMetadata) {
4075
5003
  const join4 = externalMetadataJoin(m);
4076
- const abs = isAbsolute3(m.file) ? m.file : resolve6(opts.configDir, m.file);
5004
+ const abs = isAbsolute3(m.file) ? m.file : resolve7(opts.configDir, m.file);
4077
5005
  const file = reportedPath2(abs, opts.base);
4078
5006
  located.set(`${c.name}\0${file}\0${join4}`, abs);
4079
5007
  if (plans.has(abs)) continue;
@@ -4118,7 +5046,7 @@ async function planManifests(opts) {
4118
5046
  );
4119
5047
  }
4120
5048
  async function runKeyRotate(opts) {
4121
- const cwd = resolve6(opts.cwd ?? process.cwd());
5049
+ const cwd = resolve7(opts.cwd ?? process.cwd());
4122
5050
  const env = opts.env ?? process.env;
4123
5051
  if (opts.to !== void 0 && !isValidEncryptionKey(opts.to)) throw new KeyError(BAD_TO);
4124
5052
  if (opts.inputs.includes(STDIN_TOKEN)) throw new KeyError(NO_STDIN);
@@ -4202,7 +5130,7 @@ async function runKeyRotate(opts) {
4202
5130
  const planned = [];
4203
5131
  let wantsHistory = false;
4204
5132
  for (const rel of files) {
4205
- const path = resolve6(base, rel);
5133
+ const path = resolve7(base, rel);
4206
5134
  const before = await readFile5(path, "utf8");
4207
5135
  const page = { file: rel, content: before, ...format === void 0 ? {} : { format } };
4208
5136
  const sidecarWork = await rotateManifestCitations(sidecars2, manifests, page, {
@@ -4293,7 +5221,7 @@ async function runKeyRotate(opts) {
4293
5221
  else outcome = previous === void 0 ? "written" : "finished";
4294
5222
  }
4295
5223
  const wrote = outcome !== "skipped" && outcome !== "dry-run";
4296
- const baseline = file !== null && cite.baseline !== void 0 ? resolve6(file.dir, cite.baseline) : join2(file?.dir ?? cwd, DEFAULT_CITE_BASELINE_PATH);
5224
+ const baseline = file !== null && cite.baseline !== void 0 ? resolve7(file.dir, cite.baseline) : join2(file?.dir ?? cwd, DEFAULT_CITE_BASELINE_PATH);
4297
5225
  return {
4298
5226
  pages: planned.map((p) => p.page),
4299
5227
  manifests: valueManifests.map((m) => m.manifest),
@@ -4309,7 +5237,7 @@ async function runKeyRotate(opts) {
4309
5237
 
4310
5238
  // src/key/commands/set.ts
4311
5239
  import { existsSync as existsSync3 } from "node:fs";
4312
- import { basename, dirname, join as join3, relative as relative4, resolve as resolve7 } from "node:path";
5240
+ import { basename, dirname, join as join3, relative as relative4, resolve as resolve8 } from "node:path";
4313
5241
  var BAD_VALUE = "The key must be at least 32 hex or base64url characters. Run `manni key set` with no value to generate one.";
4314
5242
  var ENV_SET = `${ENCRYPTION_KEY_ENV} is set, so a key written to config would never be read. Unset it, or keep the key in the secret.`;
4315
5243
  var alreadyConfigured = (source) => `An encryption key is already configured in ${source}. Run \`manni key rotate\` to replace it and re-encrypt every value.`;
@@ -4326,7 +5254,7 @@ function refuseBesideLegacy(path, cwd) {
4326
5254
  }
4327
5255
  }
4328
5256
  async function runKeySet(opts) {
4329
- const cwd = resolve7(opts.cwd ?? process.cwd());
5257
+ const cwd = resolve8(opts.cwd ?? process.cwd());
4330
5258
  const env = opts.env ?? process.env;
4331
5259
  if (opts.value !== void 0 && !isValidEncryptionKey(opts.value)) {
4332
5260
  throw new KeyError(BAD_VALUE);
@@ -4358,13 +5286,13 @@ export {
4358
5286
  isSeverity,
4359
5287
  meetsSeverity,
4360
5288
  reencryptMetadata,
4361
- codeRegions,
4362
- codeEndAt,
4363
5289
  CiteError,
4364
5290
  asCiteError,
5291
+ spellSource,
4365
5292
  RULE_ID_PREFIX,
4366
5293
  isCiteRule,
4367
- spellSource,
5294
+ codeRegions,
5295
+ codeEndAt,
4368
5296
  shortPin,
4369
5297
  shortCommit,
4370
5298
  shortSrc,
@@ -4372,16 +5300,19 @@ export {
4372
5300
  runAdd,
4373
5301
  errorSite,
4374
5302
  runCheck,
5303
+ runRemove,
4375
5304
  runUpdate,
4376
5305
  renderCheckPretty,
4377
5306
  renderUpdatePretty,
5307
+ renderRemovePretty,
4378
5308
  findingMessage,
4379
5309
  renderCheckGithub,
4380
5310
  renderCheckJson,
5311
+ renderRemoveJson,
4381
5312
  renderUpdateJson,
4382
5313
  cite_exports,
4383
5314
  KeyError,
4384
5315
  runKeyRotate,
4385
5316
  runKeySet
4386
5317
  };
4387
- //# sourceMappingURL=chunk-LORLZO3J.js.map
5318
+ //# sourceMappingURL=chunk-3V4EIH7H.js.map