@saasontools/strauss-kb 0.1.14 → 0.1.15
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.
- package/ARCHITECTURE.md +20 -2
- package/README.md +27 -17
- package/dist/{chunk-43KALLFU.js → chunk-KNIUBCZY.js} +849 -312
- package/dist/chunk-KNIUBCZY.js.map +1 -0
- package/dist/{chunk-PYA5E7FL.js → chunk-LACCRB2Y.js} +2 -2
- package/dist/{chunk-MBXNCZ4V.js → chunk-MK7GU4DX.js} +2 -2
- package/dist/cli-main.cjs +878 -343
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +887 -343
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +144 -67
- package/dist/index.d.ts +144 -67
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +876 -341
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +2 -2
- package/dist/chunk-43KALLFU.js.map +0 -1
- /package/dist/{chunk-PYA5E7FL.js.map → chunk-LACCRB2Y.js.map} +0 -0
- /package/dist/{chunk-MBXNCZ4V.js.map → chunk-MK7GU4DX.js.map} +0 -0
package/dist/mcp-main.cjs
CHANGED
|
@@ -59,16 +59,17 @@ var kbAnchorSchema = import_zod.z.object({
|
|
|
59
59
|
* (`https://github.com/org/name`) or a short name. Absent means the base's
|
|
60
60
|
* own repository, which is what nearly every anchor means.
|
|
61
61
|
*
|
|
62
|
-
* Unvalidated beyond not-blank: one repository has many spellings
|
|
63
|
-
*
|
|
62
|
+
* Unvalidated beyond not-blank: one repository has many spellings, matched
|
|
63
|
+
* after normalisation. Only a full URL can be fetched from, so `validate`
|
|
64
|
+
* warns on a short one; see ARCHITECTURE.
|
|
64
65
|
*/
|
|
65
66
|
repo: import_zod.z.string().trim().min(1).optional(),
|
|
66
67
|
/**
|
|
67
68
|
* The git rev the evidence was taken at. Prefer a commit SHA: a branch
|
|
68
69
|
* name is a moving pointer, so an anchor pinned to one says the evidence
|
|
69
70
|
* came from wherever that branch happens to be now, which is not a
|
|
70
|
-
* baseline.
|
|
71
|
-
*
|
|
71
|
+
* baseline. A foreign anchor is checked at this rev, and compared against
|
|
72
|
+
* the remote's default branch on top of it.
|
|
72
73
|
*/
|
|
73
74
|
ref: import_zod.z.string().trim().min(1).optional(),
|
|
74
75
|
hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
|
|
@@ -443,13 +444,6 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
|
|
|
443
444
|
// src/commands/anchor-resolve.ts
|
|
444
445
|
var import_zod6 = require("zod");
|
|
445
446
|
|
|
446
|
-
// src/anchor-resolver.ts
|
|
447
|
-
var import_node_child_process = require("child_process");
|
|
448
|
-
var import_node_crypto = require("crypto");
|
|
449
|
-
var import_promises = require("fs/promises");
|
|
450
|
-
var import_node_path = require("path");
|
|
451
|
-
var import_node_util = require("util");
|
|
452
|
-
|
|
453
447
|
// src/concurrency.ts
|
|
454
448
|
var DEFAULT_IO_CONCURRENCY = 16;
|
|
455
449
|
async function mapLimit(items, limit, fn) {
|
|
@@ -479,9 +473,528 @@ async function mapLimit(items, limit, fn) {
|
|
|
479
473
|
return out;
|
|
480
474
|
}
|
|
481
475
|
|
|
482
|
-
// src/
|
|
476
|
+
// src/remote-repo/cache.ts
|
|
477
|
+
var import_node_os = require("os");
|
|
478
|
+
var import_node_path = require("path");
|
|
479
|
+
|
|
480
|
+
// src/anchor-resolver/repo-identity.ts
|
|
481
|
+
var import_node_child_process = require("child_process");
|
|
482
|
+
var import_node_util = require("util");
|
|
483
483
|
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
484
|
+
function normalizeRepoUrl(value) {
|
|
485
|
+
let url = value.trim().replace(/^git\+/, "");
|
|
486
|
+
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
487
|
+
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
488
|
+
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
489
|
+
url = trimTrailingSlashes(url);
|
|
490
|
+
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
491
|
+
return trimTrailingSlashes(url).toLowerCase();
|
|
492
|
+
}
|
|
493
|
+
function trimTrailingSlashes(value) {
|
|
494
|
+
let end = value.length;
|
|
495
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
496
|
+
return value.slice(0, end);
|
|
497
|
+
}
|
|
498
|
+
function isCanonicalRepoUrl(value) {
|
|
499
|
+
return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
|
|
500
|
+
}
|
|
501
|
+
function repoPath(normalized) {
|
|
502
|
+
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
503
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
504
|
+
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
505
|
+
}
|
|
506
|
+
function repoIdentifies(declared, originUrl) {
|
|
507
|
+
if (!originUrl) return false;
|
|
508
|
+
const origin = normalizeRepoUrl(originUrl);
|
|
509
|
+
const want = normalizeRepoUrl(declared);
|
|
510
|
+
if (!want || !origin) return false;
|
|
511
|
+
if (want === origin) return true;
|
|
512
|
+
const path = repoPath(origin);
|
|
513
|
+
if (!path) return false;
|
|
514
|
+
return want === path || want === (path.split("/").pop() ?? "");
|
|
515
|
+
}
|
|
516
|
+
async function repoOriginUrl(repoRoot) {
|
|
517
|
+
try {
|
|
518
|
+
const { stdout } = await execFileAsync(
|
|
519
|
+
"git",
|
|
520
|
+
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
521
|
+
{ timeout: 5e3 }
|
|
522
|
+
);
|
|
523
|
+
return stdout.trim() || null;
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
var LazyOrigin = class {
|
|
529
|
+
constructor(repoRoot) {
|
|
530
|
+
this.repoRoot = repoRoot;
|
|
531
|
+
}
|
|
532
|
+
repoRoot;
|
|
533
|
+
url = null;
|
|
534
|
+
asked = false;
|
|
535
|
+
/** Asks git once, so later `isForeign` calls need no await. */
|
|
536
|
+
async prime() {
|
|
537
|
+
if (this.asked) return;
|
|
538
|
+
this.url = await repoOriginUrl(this.repoRoot);
|
|
539
|
+
this.asked = true;
|
|
540
|
+
}
|
|
541
|
+
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
542
|
+
isForeign(anchor) {
|
|
543
|
+
if (!anchor.repo) return false;
|
|
544
|
+
return !repoIdentifies(anchor.repo, this.url);
|
|
545
|
+
}
|
|
546
|
+
async foreign(anchor) {
|
|
547
|
+
if (!anchor.repo) return false;
|
|
548
|
+
await this.prime();
|
|
549
|
+
return this.isForeign(anchor);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// src/remote-repo/cache.ts
|
|
554
|
+
function repoCacheDir(override) {
|
|
555
|
+
return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
|
|
556
|
+
}
|
|
557
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
|
|
558
|
+
function fetchTimeoutMs(override) {
|
|
559
|
+
if (override !== void 0) return override;
|
|
560
|
+
const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
|
|
561
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
|
|
562
|
+
}
|
|
563
|
+
function cachePathFor(repo, cacheDir) {
|
|
564
|
+
const normalized = normalizeRepoUrl(repo);
|
|
565
|
+
const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
|
|
566
|
+
if (!scheme) return null;
|
|
567
|
+
const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
|
|
568
|
+
if (segments.length < 2 || segments.some((segment) => segment === null)) {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
const path = segments;
|
|
572
|
+
return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
|
|
573
|
+
}
|
|
574
|
+
function safeSegment(value) {
|
|
575
|
+
return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
|
|
576
|
+
}
|
|
577
|
+
function revRef(rev) {
|
|
578
|
+
const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
|
|
579
|
+
let hash = 5381;
|
|
580
|
+
for (let at = 0; at < rev.length; at++) {
|
|
581
|
+
hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
|
|
582
|
+
}
|
|
583
|
+
return `refs/strauss/${safe}-${hash.toString(16)}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/remote-repo/model.ts
|
|
587
|
+
var UNCHECKED_REASONS = [
|
|
588
|
+
"remote-unreachable",
|
|
589
|
+
"repo-unauthorized",
|
|
590
|
+
"default-branch-unknown"
|
|
591
|
+
];
|
|
592
|
+
function isUncheckedReason(reason) {
|
|
593
|
+
return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
|
|
594
|
+
}
|
|
595
|
+
function wantKey(repo, ref, file) {
|
|
596
|
+
return `${repo}\0${ref ?? ""}\0${file}`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/remote-repo/read.ts
|
|
600
|
+
var import_promises = require("fs/promises");
|
|
601
|
+
|
|
602
|
+
// src/remote-repo/git.ts
|
|
603
|
+
var import_node_child_process2 = require("child_process");
|
|
604
|
+
var import_node_util2 = require("util");
|
|
605
|
+
|
|
606
|
+
// src/anchor-resolver/model.ts
|
|
484
607
|
var MAX_ANCHOR_FILE_BYTES = 1048576;
|
|
608
|
+
|
|
609
|
+
// src/remote-repo/git.ts
|
|
610
|
+
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
611
|
+
function childEnv() {
|
|
612
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
613
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
|
|
614
|
+
delete env[name];
|
|
615
|
+
}
|
|
616
|
+
return env;
|
|
617
|
+
}
|
|
618
|
+
async function git(args, options = {}) {
|
|
619
|
+
try {
|
|
620
|
+
const { stdout, stderr } = await execFileAsync2("git", args, {
|
|
621
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
622
|
+
timeout: options.timeoutMs ?? 3e4,
|
|
623
|
+
maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
|
|
624
|
+
encoding: "utf8",
|
|
625
|
+
windowsHide: true,
|
|
626
|
+
env: childEnv()
|
|
627
|
+
});
|
|
628
|
+
return { ok: true, stdout, stderr, overflowed: false };
|
|
629
|
+
} catch (error) {
|
|
630
|
+
const failure = error;
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
stdout: failure.stdout ?? "",
|
|
634
|
+
stderr: failure.stderr ?? "",
|
|
635
|
+
overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function transportReason(stderr) {
|
|
640
|
+
const text = stderr.toLowerCase();
|
|
641
|
+
if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
|
|
642
|
+
return "repo-unauthorized";
|
|
643
|
+
}
|
|
644
|
+
if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
|
|
645
|
+
return "ref-not-found";
|
|
646
|
+
}
|
|
647
|
+
return "remote-unreachable";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/remote-repo/validate.ts
|
|
651
|
+
var MAX_REF_LENGTH = 200;
|
|
652
|
+
var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
653
|
+
function refShapeIsSafe(ref) {
|
|
654
|
+
if (!ref || ref.length > MAX_REF_LENGTH) return false;
|
|
655
|
+
if (ref.includes("..")) return false;
|
|
656
|
+
return REF_SHAPE.test(ref);
|
|
657
|
+
}
|
|
658
|
+
async function refIsWellFormed(ref) {
|
|
659
|
+
if (!refShapeIsSafe(ref)) return false;
|
|
660
|
+
const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
|
|
661
|
+
return checked.ok;
|
|
662
|
+
}
|
|
663
|
+
function filePathIsSafe(file) {
|
|
664
|
+
const path = file.replace(/^\.\//, "");
|
|
665
|
+
if (!path || path.startsWith("-") || path.includes("\0")) return false;
|
|
666
|
+
return !path.split("/").includes("..");
|
|
667
|
+
}
|
|
668
|
+
var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
|
|
669
|
+
function allowedProtocols() {
|
|
670
|
+
const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
|
|
671
|
+
if (raw === void 0) return [...DEFAULT_PROTOCOLS];
|
|
672
|
+
const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
673
|
+
return listed.length ? listed : [...DEFAULT_PROTOCOLS];
|
|
674
|
+
}
|
|
675
|
+
function isShortRepoName(repo) {
|
|
676
|
+
return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
|
|
677
|
+
}
|
|
678
|
+
var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
|
|
679
|
+
var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
|
|
680
|
+
var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
681
|
+
function repoUrlIsSafe(repo) {
|
|
682
|
+
const url = repo.trim();
|
|
683
|
+
if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
|
|
684
|
+
const allowed = allowedProtocols();
|
|
685
|
+
if (SCP_LIKE.test(url)) return allowed.includes("ssh");
|
|
686
|
+
const scheme = URL_SCHEME.exec(url);
|
|
687
|
+
if (!scheme?.[1]) return false;
|
|
688
|
+
if (!allowed.includes(scheme[1].toLowerCase())) return false;
|
|
689
|
+
const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
|
|
690
|
+
const at = authority.lastIndexOf("@");
|
|
691
|
+
return at < 0 || !authority.slice(0, at).includes(":");
|
|
692
|
+
}
|
|
693
|
+
function protocolArgs() {
|
|
694
|
+
const allowed = allowedProtocols();
|
|
695
|
+
return [
|
|
696
|
+
"-c",
|
|
697
|
+
"protocol.ext.allow=never",
|
|
698
|
+
"-c",
|
|
699
|
+
`protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
|
|
700
|
+
];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/remote-repo/read.ts
|
|
704
|
+
var DEFAULT_REPO_CONCURRENCY = 4;
|
|
705
|
+
var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
|
|
706
|
+
async function readRemoteAnchors(wants, options = {}) {
|
|
707
|
+
const out = /* @__PURE__ */ new Map();
|
|
708
|
+
if (!wants.length) return out;
|
|
709
|
+
const cacheDir = repoCacheDir(options.cacheDir);
|
|
710
|
+
const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
|
|
711
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
712
|
+
for (const want of wants) {
|
|
713
|
+
const key = normalizeRepoUrl(want.repo);
|
|
714
|
+
const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
|
|
715
|
+
group2.wants.push(want);
|
|
716
|
+
byRepo.set(key, group2);
|
|
717
|
+
}
|
|
718
|
+
const groups = [...byRepo.entries()];
|
|
719
|
+
const results = await mapLimit(
|
|
720
|
+
groups,
|
|
721
|
+
Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
|
|
722
|
+
([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
|
|
723
|
+
cacheDir,
|
|
724
|
+
timeoutMs,
|
|
725
|
+
offline: options.offline === true
|
|
726
|
+
})
|
|
727
|
+
);
|
|
728
|
+
for (const result of results) {
|
|
729
|
+
for (const [key, read] of result) out.set(key, read);
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
async function readOneRepo(repo, url, declared, context) {
|
|
734
|
+
let wants = declared;
|
|
735
|
+
const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
|
|
736
|
+
if (isShortRepoName(url)) {
|
|
737
|
+
return all({ ok: false, reason: "remote-unreachable" });
|
|
738
|
+
}
|
|
739
|
+
if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
|
|
740
|
+
const cache = cachePathFor(repo, context.cacheDir);
|
|
741
|
+
if (!cache) return all({ ok: false, reason: "remote-unreachable" });
|
|
742
|
+
const rejected = /* @__PURE__ */ new Map();
|
|
743
|
+
const usable = [];
|
|
744
|
+
for (const want of wants) {
|
|
745
|
+
const reason = wantReason(want);
|
|
746
|
+
if (reason)
|
|
747
|
+
rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
|
|
748
|
+
else usable.push(want);
|
|
749
|
+
}
|
|
750
|
+
if (!usable.length) return rejected;
|
|
751
|
+
wants = usable;
|
|
752
|
+
const opened = await openCache(cache, url, context);
|
|
753
|
+
if (opened) return new Map([...rejected, ...all(opened)]);
|
|
754
|
+
const wantsDefault = wants.some((want) => want.ref === void 0);
|
|
755
|
+
const branch = wantsDefault ? await defaultBranch(cache, context) : {};
|
|
756
|
+
const revs = /* @__PURE__ */ new Map();
|
|
757
|
+
for (const rev of distinctRevs(wants, branch.name)) {
|
|
758
|
+
revs.set(
|
|
759
|
+
rev,
|
|
760
|
+
await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
const reads = await mapLimit(
|
|
764
|
+
wants,
|
|
765
|
+
DEFAULT_IO_CONCURRENCY,
|
|
766
|
+
async (want) => {
|
|
767
|
+
const rev = want.ref ?? branch.name;
|
|
768
|
+
if (rev === void 0) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
reason: branch.reason ?? "default-branch-unknown"
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const failed = revs.get(rev);
|
|
775
|
+
if (failed) return failed;
|
|
776
|
+
return readBlob(cache, rev, want.file, context);
|
|
777
|
+
}
|
|
778
|
+
);
|
|
779
|
+
return new Map([
|
|
780
|
+
...rejected,
|
|
781
|
+
...wants.map(
|
|
782
|
+
(want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
|
|
783
|
+
)
|
|
784
|
+
]);
|
|
785
|
+
}
|
|
786
|
+
function wantReason(want) {
|
|
787
|
+
if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
|
|
788
|
+
return filePathIsSafe(want.file) ? void 0 : "outside-repo";
|
|
789
|
+
}
|
|
790
|
+
function distinctRevs(wants, branch) {
|
|
791
|
+
const revs = /* @__PURE__ */ new Set();
|
|
792
|
+
for (const want of wants) {
|
|
793
|
+
if (want.ref !== void 0) revs.add(want.ref);
|
|
794
|
+
else if (branch) revs.add(branch);
|
|
795
|
+
}
|
|
796
|
+
return [...revs];
|
|
797
|
+
}
|
|
798
|
+
async function openCache(cache, url, context) {
|
|
799
|
+
try {
|
|
800
|
+
await (0, import_promises.mkdir)(cache, { recursive: true });
|
|
801
|
+
} catch {
|
|
802
|
+
return { ok: false, reason: "remote-unreachable" };
|
|
803
|
+
}
|
|
804
|
+
const init = await git(["init", "--bare", "--quiet", cache], {
|
|
805
|
+
timeoutMs: context.timeoutMs
|
|
806
|
+
});
|
|
807
|
+
if (!init.ok) return { ok: false, reason: "remote-unreachable" };
|
|
808
|
+
const remote = await git(["config", "remote.origin.url", url], {
|
|
809
|
+
cwd: cache,
|
|
810
|
+
timeoutMs: context.timeoutMs
|
|
811
|
+
});
|
|
812
|
+
return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
|
|
813
|
+
}
|
|
814
|
+
async function defaultBranch(cache, context) {
|
|
815
|
+
if (!context.offline) {
|
|
816
|
+
const listed = await git(
|
|
817
|
+
[...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
|
|
818
|
+
{
|
|
819
|
+
cwd: cache,
|
|
820
|
+
timeoutMs: context.timeoutMs
|
|
821
|
+
}
|
|
822
|
+
);
|
|
823
|
+
const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
|
|
824
|
+
if (listed.ok && found?.[1]) {
|
|
825
|
+
const name = found[1];
|
|
826
|
+
await git(["config", "strauss.defaultBranch", name], { cwd: cache });
|
|
827
|
+
return { name };
|
|
828
|
+
}
|
|
829
|
+
if (!listed.ok) {
|
|
830
|
+
const reason = transportReason(listed.stderr);
|
|
831
|
+
if (reason !== "ref-not-found") {
|
|
832
|
+
const cached2 = await cachedBranch(cache);
|
|
833
|
+
return cached2 ? { name: cached2 } : { reason };
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const cached = await cachedBranch(cache);
|
|
838
|
+
if (cached) return { name: cached };
|
|
839
|
+
return {
|
|
840
|
+
reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
async function cachedBranch(cache) {
|
|
844
|
+
const stored = await git(["config", "--get", "strauss.defaultBranch"], {
|
|
845
|
+
cwd: cache
|
|
846
|
+
});
|
|
847
|
+
if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
|
|
848
|
+
const head = await git(
|
|
849
|
+
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
850
|
+
{
|
|
851
|
+
cwd: cache
|
|
852
|
+
}
|
|
853
|
+
);
|
|
854
|
+
const name = head.stdout.trim().replace(/^origin\//, "");
|
|
855
|
+
return head.ok && name ? name : void 0;
|
|
856
|
+
}
|
|
857
|
+
async function ensureRev(cache, rev, context) {
|
|
858
|
+
const ref = revRef(rev);
|
|
859
|
+
const have = await git(
|
|
860
|
+
["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
|
|
861
|
+
{
|
|
862
|
+
cwd: cache
|
|
863
|
+
}
|
|
864
|
+
);
|
|
865
|
+
const cached = have.ok && have.stdout.trim().length > 0;
|
|
866
|
+
if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
|
|
867
|
+
if (context.offline) return { ok: false, reason: "remote-unreachable" };
|
|
868
|
+
const fetched = await git(
|
|
869
|
+
[
|
|
870
|
+
...protocolArgs(),
|
|
871
|
+
"fetch",
|
|
872
|
+
"--depth",
|
|
873
|
+
"1",
|
|
874
|
+
"origin",
|
|
875
|
+
"--end-of-options",
|
|
876
|
+
rev
|
|
877
|
+
],
|
|
878
|
+
{ cwd: cache, timeoutMs: context.timeoutMs }
|
|
879
|
+
);
|
|
880
|
+
if (!fetched.ok) {
|
|
881
|
+
const reason = transportReason(fetched.stderr);
|
|
882
|
+
if (cached && reason !== "ref-not-found") return void 0;
|
|
883
|
+
return { ok: false, reason };
|
|
884
|
+
}
|
|
885
|
+
const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
|
|
886
|
+
const sha = head.stdout.trim();
|
|
887
|
+
if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
|
|
888
|
+
const updated = await git(["update-ref", ref, sha], { cwd: cache });
|
|
889
|
+
return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
|
|
890
|
+
}
|
|
891
|
+
async function readBlob(cache, rev, file, context) {
|
|
892
|
+
const path = file.replace(/^\.\//, "");
|
|
893
|
+
const blob = await git(
|
|
894
|
+
["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
|
|
895
|
+
{
|
|
896
|
+
cwd: cache,
|
|
897
|
+
timeoutMs: context.timeoutMs
|
|
898
|
+
}
|
|
899
|
+
);
|
|
900
|
+
if (blob.ok) return { ok: true, source: blob.stdout };
|
|
901
|
+
if (blob.overflowed) return { ok: false, reason: "file-too-large" };
|
|
902
|
+
const text = blob.stderr.toLowerCase();
|
|
903
|
+
return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// src/anchor-resolver/read.ts
|
|
907
|
+
var import_promises2 = require("fs/promises");
|
|
908
|
+
var import_node_path2 = require("path");
|
|
909
|
+
function anchorFilePath(repoRoot, file) {
|
|
910
|
+
const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
|
|
911
|
+
const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
|
|
912
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
return path;
|
|
916
|
+
}
|
|
917
|
+
function contains(root, path) {
|
|
918
|
+
const rel = (0, import_node_path2.relative)(root, path);
|
|
919
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
|
|
920
|
+
}
|
|
921
|
+
function errorCode(error) {
|
|
922
|
+
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
923
|
+
}
|
|
924
|
+
function anchorFileReader(repoRoot) {
|
|
925
|
+
let rootOnce;
|
|
926
|
+
const realRoot = () => {
|
|
927
|
+
rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
|
|
928
|
+
rootOnce = void 0;
|
|
929
|
+
throw error;
|
|
930
|
+
});
|
|
931
|
+
return rootOnce;
|
|
932
|
+
};
|
|
933
|
+
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
934
|
+
}
|
|
935
|
+
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
936
|
+
const lexical = anchorFilePath(repoRoot, file);
|
|
937
|
+
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
938
|
+
let root;
|
|
939
|
+
let path;
|
|
940
|
+
try {
|
|
941
|
+
root = await realRoot();
|
|
942
|
+
path = await (0, import_promises2.realpath)(lexical);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
const code = errorCode(error);
|
|
945
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
946
|
+
return { ok: false, reason: "file-missing" };
|
|
947
|
+
}
|
|
948
|
+
return { ok: false, reason: "file-unreadable" };
|
|
949
|
+
}
|
|
950
|
+
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
951
|
+
try {
|
|
952
|
+
const stats = await (0, import_promises2.stat)(path);
|
|
953
|
+
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
954
|
+
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
955
|
+
return { ok: false, reason: "file-too-large" };
|
|
956
|
+
}
|
|
957
|
+
return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const code = errorCode(error);
|
|
960
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
961
|
+
return { ok: false, reason: "file-missing" };
|
|
962
|
+
}
|
|
963
|
+
return { ok: false, reason: "file-unreadable" };
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function looksLikeWrongRepoRoot(drift) {
|
|
967
|
+
let checked = 0;
|
|
968
|
+
for (const entries of drift.values()) {
|
|
969
|
+
for (const entry of entries) {
|
|
970
|
+
if (entry.repo !== void 0) continue;
|
|
971
|
+
checked += 1;
|
|
972
|
+
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return checked > 0;
|
|
978
|
+
}
|
|
979
|
+
async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
|
|
980
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
981
|
+
throw new RangeError(
|
|
982
|
+
`readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
const wanted = [...new Set(files)];
|
|
986
|
+
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
987
|
+
try {
|
|
988
|
+
return await read(file);
|
|
989
|
+
} catch {
|
|
990
|
+
return { ok: false, reason: "file-unreadable" };
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// src/anchor-resolver/resolver.ts
|
|
997
|
+
var import_node_crypto = require("crypto");
|
|
485
998
|
var PARENT_SCOPE_LINES = 50;
|
|
486
999
|
var CLEAN_STATE = { blockComment: false, template: false };
|
|
487
1000
|
function stripLine(line, state) {
|
|
@@ -657,157 +1170,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
|
|
|
657
1170
|
}
|
|
658
1171
|
return resolver.resolve(normalized, anchor.symbol);
|
|
659
1172
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
|
|
663
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
|
|
664
|
-
return null;
|
|
665
|
-
}
|
|
666
|
-
return path;
|
|
667
|
-
}
|
|
668
|
-
function contains(root, path) {
|
|
669
|
-
const rel = (0, import_node_path.relative)(root, path);
|
|
670
|
-
return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
|
|
671
|
-
}
|
|
672
|
-
function normalizeRepoUrl(value) {
|
|
673
|
-
let url = value.trim().replace(/^git\+/, "");
|
|
674
|
-
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
675
|
-
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
676
|
-
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
677
|
-
url = trimTrailingSlashes(url);
|
|
678
|
-
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
679
|
-
return trimTrailingSlashes(url).toLowerCase();
|
|
680
|
-
}
|
|
681
|
-
function trimTrailingSlashes(value) {
|
|
682
|
-
let end = value.length;
|
|
683
|
-
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
684
|
-
return value.slice(0, end);
|
|
685
|
-
}
|
|
686
|
-
function repoPath(normalized) {
|
|
687
|
-
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
688
|
-
const segments = withoutScheme.split("/").filter(Boolean);
|
|
689
|
-
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
690
|
-
}
|
|
691
|
-
function repoIdentifies(declared, originUrl) {
|
|
692
|
-
if (!originUrl) return false;
|
|
693
|
-
const origin = normalizeRepoUrl(originUrl);
|
|
694
|
-
const want = normalizeRepoUrl(declared);
|
|
695
|
-
if (!want || !origin) return false;
|
|
696
|
-
if (want === origin) return true;
|
|
697
|
-
const path = repoPath(origin);
|
|
698
|
-
if (!path) return false;
|
|
699
|
-
return want === path || want === (path.split("/").pop() ?? "");
|
|
700
|
-
}
|
|
701
|
-
async function repoOriginUrl(repoRoot) {
|
|
702
|
-
try {
|
|
703
|
-
const { stdout } = await execFileAsync(
|
|
704
|
-
"git",
|
|
705
|
-
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
706
|
-
{ timeout: 5e3 }
|
|
707
|
-
);
|
|
708
|
-
return stdout.trim() || null;
|
|
709
|
-
} catch {
|
|
710
|
-
return null;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
var LazyOrigin = class {
|
|
714
|
-
constructor(repoRoot) {
|
|
715
|
-
this.repoRoot = repoRoot;
|
|
716
|
-
}
|
|
717
|
-
repoRoot;
|
|
718
|
-
url = null;
|
|
719
|
-
asked = false;
|
|
720
|
-
/** Asks git once, so later `isForeign` calls need no await. */
|
|
721
|
-
async prime() {
|
|
722
|
-
if (this.asked) return;
|
|
723
|
-
this.url = await repoOriginUrl(this.repoRoot);
|
|
724
|
-
this.asked = true;
|
|
725
|
-
}
|
|
726
|
-
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
727
|
-
isForeign(anchor) {
|
|
728
|
-
if (!anchor.repo) return false;
|
|
729
|
-
return !repoIdentifies(anchor.repo, this.url);
|
|
730
|
-
}
|
|
731
|
-
async foreign(anchor) {
|
|
732
|
-
if (!anchor.repo) return false;
|
|
733
|
-
await this.prime();
|
|
734
|
-
return this.isForeign(anchor);
|
|
735
|
-
}
|
|
736
|
-
};
|
|
737
|
-
function errorCode(error) {
|
|
738
|
-
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
739
|
-
}
|
|
740
|
-
function anchorFileReader(repoRoot) {
|
|
741
|
-
let rootOnce;
|
|
742
|
-
const realRoot = () => {
|
|
743
|
-
rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
|
|
744
|
-
rootOnce = void 0;
|
|
745
|
-
throw error;
|
|
746
|
-
});
|
|
747
|
-
return rootOnce;
|
|
748
|
-
};
|
|
749
|
-
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
750
|
-
}
|
|
751
|
-
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
752
|
-
const lexical = anchorFilePath(repoRoot, file);
|
|
753
|
-
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
754
|
-
let root;
|
|
755
|
-
let path;
|
|
756
|
-
try {
|
|
757
|
-
root = await realRoot();
|
|
758
|
-
path = await (0, import_promises.realpath)(lexical);
|
|
759
|
-
} catch (error) {
|
|
760
|
-
const code = errorCode(error);
|
|
761
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
762
|
-
return { ok: false, reason: "file-missing" };
|
|
763
|
-
}
|
|
764
|
-
return { ok: false, reason: "file-unreadable" };
|
|
765
|
-
}
|
|
766
|
-
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
767
|
-
try {
|
|
768
|
-
const stats = await (0, import_promises.stat)(path);
|
|
769
|
-
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
770
|
-
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
771
|
-
return { ok: false, reason: "file-too-large" };
|
|
772
|
-
}
|
|
773
|
-
return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
|
|
774
|
-
} catch (error) {
|
|
775
|
-
const code = errorCode(error);
|
|
776
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
777
|
-
return { ok: false, reason: "file-missing" };
|
|
778
|
-
}
|
|
779
|
-
return { ok: false, reason: "file-unreadable" };
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
function looksLikeWrongRepoRoot(drift) {
|
|
783
|
-
let checked = 0;
|
|
784
|
-
for (const entries of drift.values()) {
|
|
785
|
-
for (const entry of entries) {
|
|
786
|
-
if (entry.reason === "foreign-repo") continue;
|
|
787
|
-
checked += 1;
|
|
788
|
-
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
789
|
-
return false;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
return checked > 0;
|
|
794
|
-
}
|
|
795
|
-
async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
|
|
796
|
-
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
797
|
-
throw new RangeError(
|
|
798
|
-
`readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
|
|
799
|
-
);
|
|
800
|
-
}
|
|
801
|
-
const wanted = [...new Set(files)];
|
|
802
|
-
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
803
|
-
try {
|
|
804
|
-
return await read(file);
|
|
805
|
-
} catch {
|
|
806
|
-
return { ok: false, reason: "file-unreadable" };
|
|
807
|
-
}
|
|
808
|
-
});
|
|
809
|
-
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
810
|
-
}
|
|
1173
|
+
|
|
1174
|
+
// src/anchor-resolver/drift.ts
|
|
811
1175
|
async function detectAnchorDrift(records, options = {}) {
|
|
812
1176
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
813
1177
|
const resolver = options.resolver ?? regexResolver;
|
|
@@ -833,67 +1197,97 @@ async function detectAnchorDrift(records, options = {}) {
|
|
|
833
1197
|
}
|
|
834
1198
|
}
|
|
835
1199
|
const files = [];
|
|
1200
|
+
const wants = [];
|
|
836
1201
|
for (const entries of planned.values()) {
|
|
837
|
-
for (const
|
|
838
|
-
if (!
|
|
1202
|
+
for (const { anchor, foreign } of entries) {
|
|
1203
|
+
if (!foreign) files.push(anchor.file);
|
|
1204
|
+
else wants.push(...remoteWants(anchor));
|
|
839
1205
|
}
|
|
840
1206
|
}
|
|
841
|
-
const reads = await
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1207
|
+
const [reads, remote] = await Promise.all([
|
|
1208
|
+
readAnchorFiles(
|
|
1209
|
+
files,
|
|
1210
|
+
options.reader ?? anchorFileReader(repoRoot),
|
|
1211
|
+
options.concurrency ?? DEFAULT_IO_CONCURRENCY
|
|
1212
|
+
),
|
|
1213
|
+
(options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
|
|
1214
|
+
]);
|
|
846
1215
|
const drift = /* @__PURE__ */ new Map();
|
|
847
1216
|
for (const record of records) {
|
|
848
1217
|
const entries = [];
|
|
849
1218
|
for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
storedHash: anchor.hash
|
|
854
|
-
};
|
|
855
|
-
if (foreign) {
|
|
856
|
-
entries.push({
|
|
857
|
-
...base,
|
|
858
|
-
state: "unresolved",
|
|
859
|
-
diffSize: null,
|
|
860
|
-
reason: "foreign-repo"
|
|
861
|
-
});
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
const read = reads.get(anchor.file);
|
|
865
|
-
if (!read.ok) {
|
|
866
|
-
entries.push({
|
|
867
|
-
...base,
|
|
868
|
-
state: "unresolved",
|
|
869
|
-
diffSize: null,
|
|
870
|
-
reason: read.reason
|
|
871
|
-
});
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
const resolved = resolveAnchor(read.source, anchor, resolver);
|
|
875
|
-
if (!resolved) {
|
|
876
|
-
entries.push({
|
|
877
|
-
...base,
|
|
878
|
-
state: "unresolved",
|
|
879
|
-
diffSize: null,
|
|
880
|
-
reason: "symbol-not-found"
|
|
881
|
-
});
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
const currentHash = hashAnchorText(resolved.text);
|
|
885
|
-
const currentLines = resolved.endLine - resolved.startLine + 1;
|
|
886
|
-
entries.push({
|
|
887
|
-
...base,
|
|
888
|
-
state: currentHash === anchor.hash ? "match" : "drifted",
|
|
889
|
-
currentHash,
|
|
890
|
-
diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
|
|
891
|
-
});
|
|
1219
|
+
entries.push(
|
|
1220
|
+
foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
|
|
1221
|
+
);
|
|
892
1222
|
}
|
|
893
1223
|
if (entries.length) drift.set(record.conceptId, entries);
|
|
894
1224
|
}
|
|
895
1225
|
return drift;
|
|
896
1226
|
}
|
|
1227
|
+
function remoteWants(anchor) {
|
|
1228
|
+
const repo = anchor.repo;
|
|
1229
|
+
const wants = [{ repo, file: anchor.file }];
|
|
1230
|
+
if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
|
|
1231
|
+
return wants;
|
|
1232
|
+
}
|
|
1233
|
+
function base(anchor) {
|
|
1234
|
+
return {
|
|
1235
|
+
file: anchor.file,
|
|
1236
|
+
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
1237
|
+
storedHash: anchor.hash
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function unresolved(anchor, reason, repo) {
|
|
1241
|
+
return {
|
|
1242
|
+
...base(anchor),
|
|
1243
|
+
state: "unresolved",
|
|
1244
|
+
diffSize: null,
|
|
1245
|
+
...reason ? { reason } : {},
|
|
1246
|
+
...repo ? { repo } : {}
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
function hashIn(source, anchor, resolver) {
|
|
1250
|
+
const resolved = resolveAnchor(source, anchor, resolver);
|
|
1251
|
+
if (!resolved) return null;
|
|
1252
|
+
return {
|
|
1253
|
+
hash: hashAnchorText(resolved.text),
|
|
1254
|
+
lines: resolved.endLine - resolved.startLine + 1
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function compared(anchor, current, extra = {}) {
|
|
1258
|
+
return {
|
|
1259
|
+
...base(anchor),
|
|
1260
|
+
state: current.hash === anchor.hash ? "match" : "drifted",
|
|
1261
|
+
currentHash: current.hash,
|
|
1262
|
+
diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
|
|
1263
|
+
...extra
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function localEntry(anchor, read, resolver) {
|
|
1267
|
+
if (!read.ok) return unresolved(anchor, read.reason);
|
|
1268
|
+
const current = hashIn(read.source, anchor, resolver);
|
|
1269
|
+
return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
|
|
1270
|
+
}
|
|
1271
|
+
function remoteEntry(anchor, remote, resolver) {
|
|
1272
|
+
const repo = anchor.repo;
|
|
1273
|
+
const key = normalizeRepoUrl(repo);
|
|
1274
|
+
const atDefault = remote.get(wantKey(key, void 0, anchor.file));
|
|
1275
|
+
const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
|
|
1276
|
+
if (!primary) return unresolved(anchor, "remote-unreachable", repo);
|
|
1277
|
+
if (!primary.ok) return unresolved(anchor, primary.reason, repo);
|
|
1278
|
+
const current = hashIn(primary.source, anchor, resolver);
|
|
1279
|
+
if (!current) return unresolved(anchor, "symbol-not-found", repo);
|
|
1280
|
+
if (!anchor.ref) return compared(anchor, current, { repo });
|
|
1281
|
+
if (current.hash !== anchor.hash) {
|
|
1282
|
+
return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
|
|
1283
|
+
}
|
|
1284
|
+
const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
|
|
1285
|
+
return head && head.hash !== anchor.hash ? {
|
|
1286
|
+
...compared(anchor, head, { repo }),
|
|
1287
|
+
state: "drifted",
|
|
1288
|
+
remoteState: "drifted-on-default"
|
|
1289
|
+
} : compared(anchor, current, { repo, remoteState: "matches-ref" });
|
|
1290
|
+
}
|
|
897
1291
|
|
|
898
1292
|
// src/errors.ts
|
|
899
1293
|
var BaseError = class extends Error {
|
|
@@ -1096,18 +1490,18 @@ var KbBaseFrozenError = class extends Error {
|
|
|
1096
1490
|
};
|
|
1097
1491
|
|
|
1098
1492
|
// src/kb-pins/frozen.ts
|
|
1099
|
-
var
|
|
1493
|
+
var import_node_path5 = require("path");
|
|
1100
1494
|
|
|
1101
1495
|
// src/kb-pins/layers.ts
|
|
1102
|
-
var
|
|
1103
|
-
var
|
|
1104
|
-
var
|
|
1496
|
+
var import_promises3 = require("fs/promises");
|
|
1497
|
+
var import_node_os2 = require("os");
|
|
1498
|
+
var import_node_path4 = require("path");
|
|
1105
1499
|
|
|
1106
1500
|
// src/kb-pins/model.ts
|
|
1107
|
-
var
|
|
1501
|
+
var import_node_path3 = require("path");
|
|
1108
1502
|
var import_zod4 = require("zod");
|
|
1109
|
-
var PINS_FILE = (0,
|
|
1110
|
-
var PINS_LOCAL_FILE = (0,
|
|
1503
|
+
var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
|
|
1504
|
+
var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
|
|
1111
1505
|
var PIN_LAYERS = ["project", "local", "user"];
|
|
1112
1506
|
var pinSchema = import_zod4.z.object({
|
|
1113
1507
|
/** Relative to the manifest's root, so the file is committable. */
|
|
@@ -1154,13 +1548,13 @@ var pinsManifestSchema = import_zod4.z.object({
|
|
|
1154
1548
|
|
|
1155
1549
|
// src/kb-pins/layers.ts
|
|
1156
1550
|
function userRoot() {
|
|
1157
|
-
return process.env.STRAUSS_KB_USER_ROOT || (0,
|
|
1551
|
+
return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
|
|
1158
1552
|
}
|
|
1159
1553
|
function layerRoot(workspaceDir, layer) {
|
|
1160
|
-
return layer === "user" ? userRoot() : (0,
|
|
1554
|
+
return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
|
|
1161
1555
|
}
|
|
1162
1556
|
function layerFile(workspaceDir, layer) {
|
|
1163
|
-
return (0,
|
|
1557
|
+
return (0, import_node_path4.join)(
|
|
1164
1558
|
layerRoot(workspaceDir, layer),
|
|
1165
1559
|
layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
|
|
1166
1560
|
);
|
|
@@ -1169,7 +1563,7 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
1169
1563
|
const file = layerFile(workspaceDir, layer);
|
|
1170
1564
|
let raw;
|
|
1171
1565
|
try {
|
|
1172
|
-
raw = await (0,
|
|
1566
|
+
raw = await (0, import_promises3.readFile)(file, "utf8");
|
|
1173
1567
|
} catch {
|
|
1174
1568
|
return { pins: [] };
|
|
1175
1569
|
}
|
|
@@ -1193,16 +1587,16 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
1193
1587
|
}
|
|
1194
1588
|
async function writePinsLayer(workspaceDir, layer, manifest) {
|
|
1195
1589
|
const file = layerFile(workspaceDir, layer);
|
|
1196
|
-
await (0,
|
|
1197
|
-
await (0,
|
|
1590
|
+
await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
|
|
1591
|
+
await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
|
|
1198
1592
|
`, "utf8");
|
|
1199
1593
|
}
|
|
1200
1594
|
function resolvePinPath(rootDir, path) {
|
|
1201
|
-
return (0,
|
|
1595
|
+
return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
|
|
1202
1596
|
}
|
|
1203
1597
|
function storablePath(rootDir, bundlePath2) {
|
|
1204
|
-
const rel = (0,
|
|
1205
|
-
return (rel === "" ? "." : rel).split(
|
|
1598
|
+
const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
|
|
1599
|
+
return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
|
|
1206
1600
|
}
|
|
1207
1601
|
async function readMergedPins(workspaceDir) {
|
|
1208
1602
|
const manifests = {};
|
|
@@ -1230,7 +1624,7 @@ async function readMergedPins(workspaceDir) {
|
|
|
1230
1624
|
// src/kb-pins/frozen.ts
|
|
1231
1625
|
async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
|
|
1232
1626
|
const merged = await readMergedPins(workspaceDir);
|
|
1233
|
-
const absolute = (0,
|
|
1627
|
+
const absolute = (0, import_node_path5.resolve)(bundlePath2);
|
|
1234
1628
|
const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
|
|
1235
1629
|
if (pin?.frozen === true) {
|
|
1236
1630
|
throw new KbBaseFrozenError(pin.path, pin.layer);
|
|
@@ -1315,7 +1709,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
|
|
|
1315
1709
|
}
|
|
1316
1710
|
|
|
1317
1711
|
// src/kb-pins/unpin.ts
|
|
1318
|
-
var
|
|
1712
|
+
var import_node_path6 = require("path");
|
|
1319
1713
|
async function unpinBase(workspaceDir, bundlePath2) {
|
|
1320
1714
|
const layers = [];
|
|
1321
1715
|
for (const layer of PIN_LAYERS) {
|
|
@@ -1336,7 +1730,7 @@ async function unpinBase(workspaceDir, bundlePath2) {
|
|
|
1336
1730
|
}
|
|
1337
1731
|
}
|
|
1338
1732
|
return {
|
|
1339
|
-
path: storablePath((0,
|
|
1733
|
+
path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
|
|
1340
1734
|
removed: layers.length > 0,
|
|
1341
1735
|
layers
|
|
1342
1736
|
};
|
|
@@ -1372,12 +1766,15 @@ function argvFlag(argv, name) {
|
|
|
1372
1766
|
var anchorResolveCommand = define({
|
|
1373
1767
|
name: "anchor-resolve",
|
|
1374
1768
|
tool: "kb_anchor_resolve",
|
|
1375
|
-
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
|
|
1376
|
-
description: "Resolve a record's anchors
|
|
1769
|
+
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
|
|
1770
|
+
description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
|
|
1377
1771
|
input: import_zod6.z.object({
|
|
1378
1772
|
bundlePath,
|
|
1379
1773
|
conceptId,
|
|
1380
1774
|
repoRoot: import_zod6.z.string().min(1).optional(),
|
|
1775
|
+
offline: import_zod6.z.boolean().optional().describe(
|
|
1776
|
+
"Resolve foreign anchors from the local repo cache only, never fetching."
|
|
1777
|
+
),
|
|
1381
1778
|
rebaseline: import_zod6.z.boolean().optional().describe(
|
|
1382
1779
|
"Accept the current code as the new baseline for anchors that drifted."
|
|
1383
1780
|
),
|
|
@@ -1389,10 +1786,11 @@ var anchorResolveCommand = define({
|
|
|
1389
1786
|
bundlePath: path,
|
|
1390
1787
|
conceptId: argv[1],
|
|
1391
1788
|
repoRoot: argvFlag(argv, "--repo-root"),
|
|
1789
|
+
offline: argv.includes("--offline"),
|
|
1392
1790
|
rebaseline: argv.includes("--rebaseline"),
|
|
1393
1791
|
restamp: argv.includes("--restamp")
|
|
1394
1792
|
}),
|
|
1395
|
-
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
|
|
1793
|
+
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
|
|
1396
1794
|
const root = repoRoot ?? process.cwd();
|
|
1397
1795
|
const record = await store.read(path, id);
|
|
1398
1796
|
if (!record) throw new KbRecordNotFoundError(id);
|
|
@@ -1407,18 +1805,10 @@ var anchorResolveCommand = define({
|
|
|
1407
1805
|
}
|
|
1408
1806
|
const results = [];
|
|
1409
1807
|
const updated = [];
|
|
1410
|
-
const origin = new LazyOrigin(root);
|
|
1411
1808
|
let dirty = false;
|
|
1412
|
-
|
|
1413
|
-
const foreign = new Map(
|
|
1414
|
-
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
1415
|
-
);
|
|
1416
|
-
const reads = await readAnchorFiles(
|
|
1417
|
-
anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
|
|
1418
|
-
anchorFileReader(root)
|
|
1419
|
-
);
|
|
1809
|
+
const sources = await readSources(anchors, root, offline === true);
|
|
1420
1810
|
for (const anchor of anchors) {
|
|
1421
|
-
const
|
|
1811
|
+
const base2 = {
|
|
1422
1812
|
file: anchor.file,
|
|
1423
1813
|
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
1424
1814
|
// Carried onto unresolved findings too: an anchor that once hashed
|
|
@@ -1426,21 +1816,17 @@ var anchorResolveCommand = define({
|
|
|
1426
1816
|
// has to be able to tell it from one nobody ever stamped.
|
|
1427
1817
|
...anchor.hash ? { storedHash: anchor.hash } : {}
|
|
1428
1818
|
};
|
|
1429
|
-
|
|
1430
|
-
|
|
1819
|
+
const source = sources.get(anchor);
|
|
1820
|
+
if (source.repo) base2.repo = source.repo;
|
|
1821
|
+
if (!source.ok) {
|
|
1822
|
+
results.push({ ...base2, state: "unresolved", reason: source.reason });
|
|
1431
1823
|
updated.push(anchor);
|
|
1432
1824
|
continue;
|
|
1433
1825
|
}
|
|
1434
|
-
const
|
|
1435
|
-
if (!fileRead.ok) {
|
|
1436
|
-
results.push({ ...base, state: "unresolved", reason: fileRead.reason });
|
|
1437
|
-
updated.push(anchor);
|
|
1438
|
-
continue;
|
|
1439
|
-
}
|
|
1440
|
-
const resolved = resolveAnchor(fileRead.source, anchor);
|
|
1826
|
+
const resolved = resolveAnchor(source.source, anchor);
|
|
1441
1827
|
if (!resolved) {
|
|
1442
1828
|
results.push({
|
|
1443
|
-
...
|
|
1829
|
+
...base2,
|
|
1444
1830
|
state: "unresolved",
|
|
1445
1831
|
reason: "symbol-not-found"
|
|
1446
1832
|
});
|
|
@@ -1455,30 +1841,47 @@ var anchorResolveCommand = define({
|
|
|
1455
1841
|
lines: currentLines,
|
|
1456
1842
|
resolved_at: now()
|
|
1457
1843
|
};
|
|
1844
|
+
const pinned = anchor.ref !== void 0 && source.repo !== void 0;
|
|
1458
1845
|
if (!anchor.hash) {
|
|
1459
|
-
results.push({ ...
|
|
1846
|
+
results.push({ ...base2, state: "stamped", currentHash });
|
|
1460
1847
|
updated.push(stamped);
|
|
1461
1848
|
dirty = true;
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
state: "match",
|
|
1466
|
-
currentHash
|
|
1467
|
-
});
|
|
1468
|
-
const refresh = restamp || anchor.resolved_at === void 0;
|
|
1469
|
-
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
1470
|
-
if (refresh) dirty = true;
|
|
1471
|
-
} else {
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
if (anchor.hash !== currentHash) {
|
|
1472
1852
|
results.push({
|
|
1473
|
-
...
|
|
1853
|
+
...base2,
|
|
1474
1854
|
state: "drifted",
|
|
1475
1855
|
currentHash,
|
|
1476
|
-
diffSize: anchor
|
|
1856
|
+
diffSize: lineDelta(anchor, currentLines),
|
|
1857
|
+
...pinned ? { remoteState: "drifted-from-ref" } : {},
|
|
1477
1858
|
...rebaseline ? { rebaselined: true } : {}
|
|
1478
1859
|
});
|
|
1479
1860
|
updated.push(rebaseline ? stamped : anchor);
|
|
1480
1861
|
if (rebaseline) dirty = true;
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
const onDefault = pinned ? headHash(source, anchor) : void 0;
|
|
1865
|
+
if (onDefault && onDefault.hash !== anchor.hash) {
|
|
1866
|
+
results.push({
|
|
1867
|
+
...base2,
|
|
1868
|
+
state: "drifted",
|
|
1869
|
+
currentHash: onDefault.hash,
|
|
1870
|
+
diffSize: lineDelta(anchor, onDefault.lines),
|
|
1871
|
+
remoteState: "drifted-on-default"
|
|
1872
|
+
});
|
|
1873
|
+
updated.push(anchor);
|
|
1874
|
+
continue;
|
|
1481
1875
|
}
|
|
1876
|
+
results.push({
|
|
1877
|
+
...base2,
|
|
1878
|
+
state: "match",
|
|
1879
|
+
currentHash,
|
|
1880
|
+
...pinned ? { remoteState: "matches-ref" } : {}
|
|
1881
|
+
});
|
|
1882
|
+
const refresh = restamp || anchor.resolved_at === void 0;
|
|
1883
|
+
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
1884
|
+
if (refresh) dirty = true;
|
|
1482
1885
|
}
|
|
1483
1886
|
let frozen = false;
|
|
1484
1887
|
if (dirty) {
|
|
@@ -1491,16 +1894,19 @@ var anchorResolveCommand = define({
|
|
|
1491
1894
|
if (!frozen) await store.updateAnchors(path, id, updated, actor);
|
|
1492
1895
|
}
|
|
1493
1896
|
const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const
|
|
1897
|
+
const unreachable = results.filter(
|
|
1898
|
+
(entry) => isUncheckedReason(entry.reason)
|
|
1899
|
+
).length;
|
|
1900
|
+
const checked = results.length - unreachable;
|
|
1901
|
+
const matches2 = results.filter((entry) => entry.state === "match").length;
|
|
1902
|
+
const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
|
|
1903
|
+
const clean = checked > 0 && matches2 === checked && unreachable === 0;
|
|
1498
1904
|
if (clean) {
|
|
1499
1905
|
try {
|
|
1500
1906
|
await store.verify(
|
|
1501
1907
|
path,
|
|
1502
1908
|
id,
|
|
1503
|
-
`anchor-resolve: ${
|
|
1909
|
+
`anchor-resolve: ${note} (regex resolver)`,
|
|
1504
1910
|
actor,
|
|
1505
1911
|
now()
|
|
1506
1912
|
);
|
|
@@ -1516,18 +1922,81 @@ var anchorResolveCommand = define({
|
|
|
1516
1922
|
}
|
|
1517
1923
|
return { conceptId: id, results, verified: true, ...frozenNote };
|
|
1518
1924
|
}
|
|
1519
|
-
return {
|
|
1925
|
+
return {
|
|
1926
|
+
conceptId: id,
|
|
1927
|
+
results,
|
|
1928
|
+
verified: false,
|
|
1929
|
+
...unreachable ? { note } : {},
|
|
1930
|
+
...frozenNote
|
|
1931
|
+
};
|
|
1520
1932
|
},
|
|
1521
1933
|
// A stored hash that no longer resolves is a broken anchor, not an absence:
|
|
1522
1934
|
// the file was deleted or the symbol renamed, and exiting zero on it would
|
|
1523
1935
|
// let the one edit that destroys an anchor pass the gate that exists to
|
|
1524
1936
|
// catch it. An anchor nobody ever stamped is still just unstamped, and one
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1937
|
+
// whose remote nothing could reach was never checked — failing CI on either
|
|
1938
|
+
// would gate on work this command did not do.
|
|
1527
1939
|
failsWhen: (result) => result.results.some(
|
|
1528
|
-
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason
|
|
1940
|
+
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
|
|
1529
1941
|
)
|
|
1530
1942
|
});
|
|
1943
|
+
function lineDelta(anchor, current) {
|
|
1944
|
+
return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
|
|
1945
|
+
}
|
|
1946
|
+
function headHash(source, anchor) {
|
|
1947
|
+
if (source.head === void 0) return void 0;
|
|
1948
|
+
const resolved = resolveAnchor(source.head, anchor);
|
|
1949
|
+
if (!resolved) return void 0;
|
|
1950
|
+
return {
|
|
1951
|
+
hash: hashAnchorText(resolved.text),
|
|
1952
|
+
lines: resolved.endLine - resolved.startLine + 1
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
async function readSources(anchors, root, offline) {
|
|
1956
|
+
const origin = new LazyOrigin(root);
|
|
1957
|
+
if (anchors.some((anchor) => anchor.repo)) await origin.prime();
|
|
1958
|
+
const foreign = new Map(
|
|
1959
|
+
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
1960
|
+
);
|
|
1961
|
+
const local = anchors.filter((anchor) => !foreign.get(anchor));
|
|
1962
|
+
const remote = anchors.filter((anchor) => foreign.get(anchor));
|
|
1963
|
+
const reads = await readAnchorFiles(
|
|
1964
|
+
local.map((anchor) => anchor.file),
|
|
1965
|
+
anchorFileReader(root)
|
|
1966
|
+
);
|
|
1967
|
+
const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
|
|
1968
|
+
offline
|
|
1969
|
+
});
|
|
1970
|
+
const sources = /* @__PURE__ */ new Map();
|
|
1971
|
+
for (const anchor of local) {
|
|
1972
|
+
const read = reads.get(anchor.file);
|
|
1973
|
+
sources.set(
|
|
1974
|
+
anchor,
|
|
1975
|
+
read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
for (const anchor of remote) {
|
|
1979
|
+
const repo = anchor.repo;
|
|
1980
|
+
const key = normalizeRepoUrl(repo);
|
|
1981
|
+
const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
|
|
1982
|
+
const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
|
|
1983
|
+
if (!primary?.ok) {
|
|
1984
|
+
sources.set(anchor, {
|
|
1985
|
+
ok: false,
|
|
1986
|
+
reason: primary?.ok === false ? primary.reason : "remote-unreachable",
|
|
1987
|
+
repo
|
|
1988
|
+
});
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
sources.set(anchor, {
|
|
1992
|
+
ok: true,
|
|
1993
|
+
source: primary.source,
|
|
1994
|
+
repo,
|
|
1995
|
+
...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
return sources;
|
|
1999
|
+
}
|
|
1531
2000
|
|
|
1532
2001
|
// src/commands/answer.ts
|
|
1533
2002
|
var import_zod7 = require("zod");
|
|
@@ -1604,23 +2073,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
|
|
|
1604
2073
|
if (!record.frontmatter.verified?.length) {
|
|
1605
2074
|
warnings.push({ kind: "unverified" });
|
|
1606
2075
|
}
|
|
1607
|
-
const
|
|
1608
|
-
(entry) => entry.state !== "match"
|
|
2076
|
+
const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
|
|
2077
|
+
(entry) => entry.state !== "match"
|
|
1609
2078
|
);
|
|
2079
|
+
const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
|
|
2080
|
+
const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
|
|
1610
2081
|
if (moved.length) {
|
|
2082
|
+
warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
|
|
2083
|
+
}
|
|
2084
|
+
if (unchecked2.length) {
|
|
1611
2085
|
warnings.push({
|
|
1612
|
-
kind: "
|
|
1613
|
-
anchors:
|
|
1614
|
-
file,
|
|
1615
|
-
...symbol !== void 0 ? { symbol } : {},
|
|
1616
|
-
diffSize,
|
|
1617
|
-
...reason !== void 0 ? { reason } : {}
|
|
1618
|
-
}))
|
|
2086
|
+
kind: "unchecked",
|
|
2087
|
+
anchors: unchecked2.map(warningAnchor)
|
|
1619
2088
|
});
|
|
1620
2089
|
}
|
|
1621
2090
|
return { record, standing: STANDING[status], heads, warnings };
|
|
1622
2091
|
});
|
|
1623
2092
|
}
|
|
2093
|
+
function warningAnchor(entry) {
|
|
2094
|
+
const { file, symbol, diffSize, reason, repo, remoteState } = entry;
|
|
2095
|
+
return {
|
|
2096
|
+
file,
|
|
2097
|
+
...symbol !== void 0 ? { symbol } : {},
|
|
2098
|
+
diffSize,
|
|
2099
|
+
...reason !== void 0 ? { reason } : {},
|
|
2100
|
+
...repo !== void 0 ? { repo } : {},
|
|
2101
|
+
...remoteState !== void 0 ? { remoteState } : {}
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
1624
2104
|
function resolveHeads(from, byId) {
|
|
1625
2105
|
const warnings = [];
|
|
1626
2106
|
const heads = /* @__PURE__ */ new Map();
|
|
@@ -1782,7 +2262,7 @@ function count(value, noun) {
|
|
|
1782
2262
|
var import_zod10 = require("zod");
|
|
1783
2263
|
|
|
1784
2264
|
// src/kb-context.ts
|
|
1785
|
-
var
|
|
2265
|
+
var import_promises4 = require("fs/promises");
|
|
1786
2266
|
|
|
1787
2267
|
// src/kb-index.ts
|
|
1788
2268
|
var INDEX_FILE = "INDEX.md";
|
|
@@ -1964,7 +2444,7 @@ async function buildContext(store, workspaceDir, options = {}) {
|
|
|
1964
2444
|
operation: "kb.context.refused",
|
|
1965
2445
|
approxTokens: total,
|
|
1966
2446
|
budgetTokens,
|
|
1967
|
-
bases: bases.map((
|
|
2447
|
+
bases: bases.map((base2) => base2.path)
|
|
1968
2448
|
});
|
|
1969
2449
|
const refusal = [
|
|
1970
2450
|
HEADING2,
|
|
@@ -1974,7 +2454,7 @@ async function buildContext(store, workspaceDir, options = {}) {
|
|
|
1974
2454
|
"from a complete one. The pinned bases:",
|
|
1975
2455
|
"",
|
|
1976
2456
|
...bases.map(
|
|
1977
|
-
(
|
|
2457
|
+
(base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
|
|
1978
2458
|
),
|
|
1979
2459
|
"",
|
|
1980
2460
|
"For the question at hand, read what you need now \u2014 `kb_load` a base",
|
|
@@ -2009,13 +2489,13 @@ function toHookJson(block, event) {
|
|
|
2009
2489
|
var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
|
|
2010
2490
|
var CONTEXT_END = "<!-- strauss-kb:end -->";
|
|
2011
2491
|
async function syncInstructions(file, block) {
|
|
2012
|
-
const existing = await (0,
|
|
2492
|
+
const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
|
|
2013
2493
|
const region = block ? `${CONTEXT_BEGIN}
|
|
2014
2494
|
${block.trim()}
|
|
2015
2495
|
${CONTEXT_END}` : null;
|
|
2016
2496
|
if (existing === null) {
|
|
2017
2497
|
if (!region) return { file, action: "unchanged" };
|
|
2018
|
-
await (0,
|
|
2498
|
+
await (0, import_promises4.writeFile)(file, `${region}
|
|
2019
2499
|
`, "utf8");
|
|
2020
2500
|
return { file, action: "created" };
|
|
2021
2501
|
}
|
|
@@ -2026,11 +2506,11 @@ ${CONTEXT_END}` : null;
|
|
|
2026
2506
|
const after = existing.slice(end + CONTEXT_END.length);
|
|
2027
2507
|
const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
|
|
2028
2508
|
if (next === existing) return { file, action: "unchanged" };
|
|
2029
|
-
await (0,
|
|
2509
|
+
await (0, import_promises4.writeFile)(file, next, "utf8");
|
|
2030
2510
|
return { file, action: region ? "replaced" : "removed" };
|
|
2031
2511
|
}
|
|
2032
2512
|
if (!region) return { file, action: "unchanged" };
|
|
2033
|
-
await (0,
|
|
2513
|
+
await (0, import_promises4.writeFile)(
|
|
2034
2514
|
file,
|
|
2035
2515
|
`${existing.replace(/\n*$/, "\n\n")}${region}
|
|
2036
2516
|
`,
|
|
@@ -2247,6 +2727,16 @@ function validateBundle(records) {
|
|
|
2247
2727
|
);
|
|
2248
2728
|
}
|
|
2249
2729
|
}
|
|
2730
|
+
for (const anchor of fm.strauss_anchors ?? []) {
|
|
2731
|
+
if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
|
|
2732
|
+
report(
|
|
2733
|
+
"anchor_repo",
|
|
2734
|
+
conceptId2,
|
|
2735
|
+
`anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
|
|
2736
|
+
"warning"
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2250
2740
|
if (fm.strauss_assumption && fm.sources?.length) {
|
|
2251
2741
|
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
2252
2742
|
}
|
|
@@ -2266,7 +2756,8 @@ var CHECK_HEADLINES = {
|
|
|
2266
2756
|
orphaned: "no other record links to it",
|
|
2267
2757
|
"broken-supersession": "the supersession pointers do not resolve",
|
|
2268
2758
|
"superseded-but-cited": "a live record's body links to one that no longer holds",
|
|
2269
|
-
drifted: "the code an anchor points at moved out from under its hash"
|
|
2759
|
+
drifted: "the code an anchor points at moved out from under its hash",
|
|
2760
|
+
unchecked: "an anchor in another repository nothing could reach"
|
|
2270
2761
|
};
|
|
2271
2762
|
var DAY_MS = 864e5;
|
|
2272
2763
|
function doctor(bundle, options = {}) {
|
|
@@ -2291,7 +2782,8 @@ function doctor(bundle, options = {}) {
|
|
|
2291
2782
|
group("orphaned", orphaned(bundle)),
|
|
2292
2783
|
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
2293
2784
|
group("superseded-but-cited", supersededButCited(bundle, standings)),
|
|
2294
|
-
group("drifted", drifted(inForce))
|
|
2785
|
+
group("drifted", drifted(inForce)),
|
|
2786
|
+
group("unchecked", unchecked(inForce))
|
|
2295
2787
|
];
|
|
2296
2788
|
const counts = Object.fromEntries(
|
|
2297
2789
|
groups.map((entry) => [entry.check, entry.count])
|
|
@@ -2478,21 +2970,38 @@ function supersededButCited(bundle, standings) {
|
|
|
2478
2970
|
return findings;
|
|
2479
2971
|
}
|
|
2480
2972
|
function drifted(hits) {
|
|
2973
|
+
return anchorFindings(
|
|
2974
|
+
hits,
|
|
2975
|
+
"drifted",
|
|
2976
|
+
(count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
|
|
2977
|
+
);
|
|
2978
|
+
}
|
|
2979
|
+
function unchecked(hits) {
|
|
2980
|
+
return anchorFindings(
|
|
2981
|
+
hits,
|
|
2982
|
+
"unchecked",
|
|
2983
|
+
(count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
|
|
2984
|
+
);
|
|
2985
|
+
}
|
|
2986
|
+
function anchorFindings(hits, kind, headline) {
|
|
2481
2987
|
const findings = [];
|
|
2482
2988
|
for (const hit of hits) {
|
|
2483
|
-
const warning = hit.warnings.find(
|
|
2989
|
+
const warning = hit.warnings.find(
|
|
2990
|
+
(entry) => entry.kind === kind
|
|
2991
|
+
);
|
|
2484
2992
|
if (!warning) continue;
|
|
2993
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
2994
|
+
for (const anchor of warning.anchors) {
|
|
2995
|
+
const repo = anchor.repo ?? "";
|
|
2996
|
+
byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
|
|
2997
|
+
}
|
|
2998
|
+
const detail = [...byRepo.entries()].map(
|
|
2999
|
+
([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
|
|
3000
|
+
);
|
|
2485
3001
|
findings.push(
|
|
2486
3002
|
finding(
|
|
2487
3003
|
hit.record,
|
|
2488
|
-
`${warning.anchors.length} ${warning.anchors.length
|
|
2489
|
-
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
2490
|
-
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
2491
|
-
if (anchor.diffSize === null) {
|
|
2492
|
-
return `${at} (changed, size unrecorded)`;
|
|
2493
|
-
}
|
|
2494
|
-
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
2495
|
-
}).join(", ")}`
|
|
3004
|
+
`${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
|
|
2496
3005
|
)
|
|
2497
3006
|
);
|
|
2498
3007
|
}
|
|
@@ -2500,6 +3009,15 @@ function drifted(hits) {
|
|
|
2500
3009
|
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
2501
3010
|
);
|
|
2502
3011
|
}
|
|
3012
|
+
function describeAnchor(anchor) {
|
|
3013
|
+
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
3014
|
+
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
3015
|
+
if (anchor.remoteState === "drifted-on-default") {
|
|
3016
|
+
return `${at} (matches ref, moved on the default branch)`;
|
|
3017
|
+
}
|
|
3018
|
+
if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
|
|
3019
|
+
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
3020
|
+
}
|
|
2503
3021
|
function replaces(later, earlier) {
|
|
2504
3022
|
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
2505
3023
|
}
|
|
@@ -2527,8 +3045,8 @@ var days = (what, fallback) => import_zod11.z.number().int().positive().optional
|
|
|
2527
3045
|
var doctorCommand = define({
|
|
2528
3046
|
name: "doctor",
|
|
2529
3047
|
tool: "kb_doctor",
|
|
2530
|
-
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
|
|
2531
|
-
description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
|
|
3048
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
|
|
3049
|
+
description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
|
|
2532
3050
|
input: import_zod11.z.object({
|
|
2533
3051
|
bundlePath,
|
|
2534
3052
|
repoRoot: REPO_ROOT,
|
|
@@ -2544,6 +3062,9 @@ var doctorCommand = define({
|
|
|
2544
3062
|
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
2545
3063
|
DEFAULT_AGING_DAYS
|
|
2546
3064
|
),
|
|
3065
|
+
offline: import_zod11.z.boolean().optional().describe(
|
|
3066
|
+
"Read foreign anchors from the local repo cache only, never fetching."
|
|
3067
|
+
),
|
|
2547
3068
|
strict: import_zod11.z.boolean().optional().describe(
|
|
2548
3069
|
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
2549
3070
|
)
|
|
@@ -2563,13 +3084,23 @@ var doctorCommand = define({
|
|
|
2563
3084
|
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
2564
3085
|
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
2565
3086
|
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
3087
|
+
...argv.includes("--offline") ? { offline: true } : {},
|
|
2566
3088
|
...argv.includes("--strict") ? { strict: true } : {}
|
|
2567
3089
|
};
|
|
2568
3090
|
},
|
|
2569
|
-
run: async ({ store, now }, {
|
|
3091
|
+
run: async ({ store, now }, {
|
|
3092
|
+
bundlePath: path,
|
|
3093
|
+
expiringDays,
|
|
3094
|
+
unverifiedDays,
|
|
3095
|
+
agingDays,
|
|
3096
|
+
repoRoot,
|
|
3097
|
+
offline
|
|
3098
|
+
}) => {
|
|
2570
3099
|
const checkedAt = now();
|
|
2571
3100
|
const records = await store.list(path);
|
|
2572
|
-
const anchorDrift = await store.detectDrift(records, repoRoot
|
|
3101
|
+
const anchorDrift = await store.detectDrift(records, repoRoot, {
|
|
3102
|
+
offline: offline === true
|
|
3103
|
+
});
|
|
2573
3104
|
const report = doctor(records, {
|
|
2574
3105
|
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
2575
3106
|
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
@@ -3372,8 +3903,8 @@ var KB_COMMANDS_BY_NAME = new Map(
|
|
|
3372
3903
|
|
|
3373
3904
|
// src/kb-store.ts
|
|
3374
3905
|
var import_node_crypto2 = require("crypto");
|
|
3375
|
-
var
|
|
3376
|
-
var
|
|
3906
|
+
var import_promises6 = require("fs/promises");
|
|
3907
|
+
var import_node_path8 = require("path");
|
|
3377
3908
|
|
|
3378
3909
|
// src/markdown.ts
|
|
3379
3910
|
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
@@ -3401,8 +3932,8 @@ function parseMarkdownWithFrontmatter(text, schema) {
|
|
|
3401
3932
|
}
|
|
3402
3933
|
|
|
3403
3934
|
// src/search-index.ts
|
|
3404
|
-
var
|
|
3405
|
-
var
|
|
3935
|
+
var import_promises5 = require("fs/promises");
|
|
3936
|
+
var import_node_path7 = require("path");
|
|
3406
3937
|
var SEARCH_INDEX_FILE = ".index.sqlite";
|
|
3407
3938
|
var COLLECTION = "kb";
|
|
3408
3939
|
async function searchBase(bundlePath2, query, options = {}) {
|
|
@@ -3411,7 +3942,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
3411
3942
|
let store = null;
|
|
3412
3943
|
try {
|
|
3413
3944
|
store = await qmd.createStore({
|
|
3414
|
-
dbPath: (0,
|
|
3945
|
+
dbPath: (0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE),
|
|
3415
3946
|
config: {
|
|
3416
3947
|
collections: {
|
|
3417
3948
|
[COLLECTION]: {
|
|
@@ -3446,7 +3977,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
3446
3977
|
}
|
|
3447
3978
|
}
|
|
3448
3979
|
async function isStale(bundlePath2) {
|
|
3449
|
-
const indexAt = await (0,
|
|
3980
|
+
const indexAt = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
|
|
3450
3981
|
if (!indexAt) return true;
|
|
3451
3982
|
const { readdir: readdir2 } = await import("fs/promises");
|
|
3452
3983
|
const names = (await readdir2(bundlePath2).catch(() => [])).filter(
|
|
@@ -3455,7 +3986,7 @@ async function isStale(bundlePath2) {
|
|
|
3455
3986
|
let stale = false;
|
|
3456
3987
|
await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
|
|
3457
3988
|
if (stale) return;
|
|
3458
|
-
const at = await (0,
|
|
3989
|
+
const at = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
|
|
3459
3990
|
if (at > indexAt) stale = true;
|
|
3460
3991
|
});
|
|
3461
3992
|
return stale;
|
|
@@ -3733,7 +4264,7 @@ function appendUnionMergeLine(contents) {
|
|
|
3733
4264
|
}
|
|
3734
4265
|
|
|
3735
4266
|
// src/kb-store.ts
|
|
3736
|
-
var KB_DIR = (0,
|
|
4267
|
+
var KB_DIR = (0, import_node_path8.join)(".strauss", "kb");
|
|
3737
4268
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
3738
4269
|
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
3739
4270
|
var KbStore = class {
|
|
@@ -3764,7 +4295,7 @@ var KbStore = class {
|
|
|
3764
4295
|
const conceptId2 = `${input.type}.${input.slug}`;
|
|
3765
4296
|
const root = this.root(bundlePath2);
|
|
3766
4297
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
3767
|
-
await (0,
|
|
4298
|
+
await (0, import_promises6.mkdir)(root, { recursive: true });
|
|
3768
4299
|
await this.publish(
|
|
3769
4300
|
target,
|
|
3770
4301
|
stringifyMarkdownWithFrontmatter(input.body, frontmatter),
|
|
@@ -3803,7 +4334,7 @@ var KbStore = class {
|
|
|
3803
4334
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
3804
4335
|
let raw;
|
|
3805
4336
|
try {
|
|
3806
|
-
raw = await (0,
|
|
4337
|
+
raw = await (0, import_promises6.readFile)(target, "utf8");
|
|
3807
4338
|
} catch {
|
|
3808
4339
|
return null;
|
|
3809
4340
|
}
|
|
@@ -3820,7 +4351,7 @@ var KbStore = class {
|
|
|
3820
4351
|
const root = this.root(bundlePath2);
|
|
3821
4352
|
let names;
|
|
3822
4353
|
try {
|
|
3823
|
-
names = await (0,
|
|
4354
|
+
names = await (0, import_promises6.readdir)(root);
|
|
3824
4355
|
} catch {
|
|
3825
4356
|
return [];
|
|
3826
4357
|
}
|
|
@@ -3828,7 +4359,7 @@ var KbStore = class {
|
|
|
3828
4359
|
const records = await mapLimit(
|
|
3829
4360
|
wanted,
|
|
3830
4361
|
DEFAULT_IO_CONCURRENCY,
|
|
3831
|
-
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0,
|
|
4362
|
+
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises6.readFile)((0, import_node_path8.join)(root, name), "utf8"))
|
|
3832
4363
|
);
|
|
3833
4364
|
return records.filter((record) => record !== null);
|
|
3834
4365
|
}
|
|
@@ -3996,7 +4527,8 @@ ${answer}
|
|
|
3996
4527
|
* at the repo root, and the MCP server's cwd is the workspace.
|
|
3997
4528
|
*
|
|
3998
4529
|
* Public because `doctor` needs the same map with the same degradation: a
|
|
3999
|
-
* sweep that failed to read the tree should report no drift, not fail
|
|
4530
|
+
* sweep that failed to read the tree should report no drift, not fail — and
|
|
4531
|
+
* `offline: false` there, because a sweep is worth a fetch.
|
|
4000
4532
|
*
|
|
4001
4533
|
* When no root was given and not one anchored file was found, the finding is
|
|
4002
4534
|
* discarded. A base read from somewhere other than the tree it describes
|
|
@@ -4008,10 +4540,13 @@ ${answer}
|
|
|
4008
4540
|
* plausible, and the misses become findings again; an explicit `repoRoot` is
|
|
4009
4541
|
* taken at its word either way.
|
|
4010
4542
|
*/
|
|
4011
|
-
async detectDrift(records, repoRoot) {
|
|
4543
|
+
async detectDrift(records, repoRoot, options = {}) {
|
|
4012
4544
|
try {
|
|
4013
4545
|
const drift = await detectAnchorDrift(records, {
|
|
4014
|
-
repoRoot: repoRoot ?? process.cwd()
|
|
4546
|
+
repoRoot: repoRoot ?? process.cwd(),
|
|
4547
|
+
// Offline by default: a read path must never spend a network fetch per
|
|
4548
|
+
// call. `doctor` and `anchor-resolve` are the verbs that go get it.
|
|
4549
|
+
remote: { offline: options.offline !== false }
|
|
4015
4550
|
});
|
|
4016
4551
|
if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
|
|
4017
4552
|
this.logger.warn?.({
|
|
@@ -4129,11 +4664,11 @@ ${answer}
|
|
|
4129
4664
|
async readIndex(bundlePath2) {
|
|
4130
4665
|
const root = this.root(bundlePath2);
|
|
4131
4666
|
const expected = renderIndex(await this.list(bundlePath2));
|
|
4132
|
-
const stored = await (0,
|
|
4667
|
+
const stored = await (0, import_promises6.readFile)((0, import_node_path8.join)(root, INDEX_FILE), "utf8").catch(
|
|
4133
4668
|
() => null
|
|
4134
4669
|
);
|
|
4135
4670
|
if (indexIsStale(stored, expected)) {
|
|
4136
|
-
await this.publish((0,
|
|
4671
|
+
await this.publish((0, import_node_path8.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
|
|
4137
4672
|
this.logger.info?.({
|
|
4138
4673
|
operation: "kb.index.repair",
|
|
4139
4674
|
bundlePath: root,
|
|
@@ -4150,8 +4685,8 @@ ${answer}
|
|
|
4150
4685
|
* knows which agent touched what. So a bad line is surfaced and left alone.
|
|
4151
4686
|
*/
|
|
4152
4687
|
async readLog(bundlePath2) {
|
|
4153
|
-
const raw = await (0,
|
|
4154
|
-
(0,
|
|
4688
|
+
const raw = await (0, import_promises6.readFile)(
|
|
4689
|
+
(0, import_node_path8.join)(this.root(bundlePath2), LOG_FILE),
|
|
4155
4690
|
"utf8"
|
|
4156
4691
|
).catch(() => "");
|
|
4157
4692
|
const result = parseLog(raw);
|
|
@@ -4202,14 +4737,14 @@ ${answer}
|
|
|
4202
4737
|
}
|
|
4203
4738
|
async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
|
|
4204
4739
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
4205
|
-
const before = await (0,
|
|
4740
|
+
const before = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
|
|
4206
4741
|
if (before === null) throw new KbRecordNotFoundError(conceptId2);
|
|
4207
4742
|
const parsed = this.parse(conceptId2, before);
|
|
4208
4743
|
if (!parsed) throw new KbRecordNotFoundError(conceptId2);
|
|
4209
4744
|
const frontmatter = change(parsed.frontmatter);
|
|
4210
4745
|
const body = changeBody(parsed.body);
|
|
4211
4746
|
const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
|
|
4212
|
-
const witness = await (0,
|
|
4747
|
+
const witness = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
|
|
4213
4748
|
if (witness === null || digest(witness) !== digest(before)) {
|
|
4214
4749
|
throw new KbWriteConflictError(conceptId2);
|
|
4215
4750
|
}
|
|
@@ -4235,20 +4770,20 @@ ${answer}
|
|
|
4235
4770
|
*/
|
|
4236
4771
|
async publish(target, contents, overwrite, conceptId2) {
|
|
4237
4772
|
const staging = `${target}.${process.pid}.tmp`;
|
|
4238
|
-
await (0,
|
|
4773
|
+
await (0, import_promises6.writeFile)(staging, contents, "utf8");
|
|
4239
4774
|
try {
|
|
4240
4775
|
if (overwrite) {
|
|
4241
|
-
await (0,
|
|
4776
|
+
await (0, import_promises6.rename)(staging, target);
|
|
4242
4777
|
return;
|
|
4243
4778
|
}
|
|
4244
|
-
await (0,
|
|
4779
|
+
await (0, import_promises6.link)(staging, target);
|
|
4245
4780
|
} catch (error) {
|
|
4246
4781
|
if (error.code === "EEXIST") {
|
|
4247
4782
|
throw new KbRecordAlreadyExistsError(conceptId2);
|
|
4248
4783
|
}
|
|
4249
4784
|
throw error;
|
|
4250
4785
|
} finally {
|
|
4251
|
-
await (0,
|
|
4786
|
+
await (0, import_promises6.unlink)(staging).catch(() => void 0);
|
|
4252
4787
|
}
|
|
4253
4788
|
}
|
|
4254
4789
|
/**
|
|
@@ -4292,18 +4827,18 @@ ${answer}
|
|
|
4292
4827
|
* file must not fail the mutation it guards.
|
|
4293
4828
|
*/
|
|
4294
4829
|
async ensureGitattributes(root) {
|
|
4295
|
-
const target = (0,
|
|
4830
|
+
const target = (0, import_node_path8.join)(root, GITATTRIBUTES_FILE);
|
|
4296
4831
|
try {
|
|
4297
4832
|
let existing;
|
|
4298
4833
|
try {
|
|
4299
|
-
existing = await (0,
|
|
4834
|
+
existing = await (0, import_promises6.readFile)(target, "utf8");
|
|
4300
4835
|
} catch (error) {
|
|
4301
4836
|
if (error.code !== "ENOENT") throw error;
|
|
4302
4837
|
existing = null;
|
|
4303
4838
|
}
|
|
4304
4839
|
if (existing === null) {
|
|
4305
4840
|
try {
|
|
4306
|
-
await (0,
|
|
4841
|
+
await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
|
|
4307
4842
|
encoding: "utf8",
|
|
4308
4843
|
flag: "wx"
|
|
4309
4844
|
});
|
|
@@ -4324,7 +4859,7 @@ ${answer}
|
|
|
4324
4859
|
return;
|
|
4325
4860
|
}
|
|
4326
4861
|
if (!hasMergeDeclaration(existing)) {
|
|
4327
|
-
await (0,
|
|
4862
|
+
await (0, import_promises6.appendFile)(target, appendUnionMergeLine(existing), "utf8");
|
|
4328
4863
|
this.logger.info?.({
|
|
4329
4864
|
operation: "kb.gitattributes.ensure",
|
|
4330
4865
|
bundlePath: root,
|
|
@@ -4343,7 +4878,7 @@ ${answer}
|
|
|
4343
4878
|
async record(root, entry) {
|
|
4344
4879
|
await this.ensureGitattributes(root);
|
|
4345
4880
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
4346
|
-
await (0,
|
|
4881
|
+
await (0, import_promises6.appendFile)((0, import_node_path8.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
4347
4882
|
this.logger.warn?.({
|
|
4348
4883
|
operation: "kb.log.append",
|
|
4349
4884
|
outcome: "failed",
|
|
@@ -4369,18 +4904,18 @@ ${answer}
|
|
|
4369
4904
|
};
|
|
4370
4905
|
}
|
|
4371
4906
|
root(bundlePath2) {
|
|
4372
|
-
return (0,
|
|
4907
|
+
return (0, import_node_path8.resolve)(bundlePath2);
|
|
4373
4908
|
}
|
|
4374
4909
|
// Concept ids are `<type>.<slug>` and map to a single file directly under the
|
|
4375
4910
|
// bundle root; anything carrying a separator would escape it.
|
|
4376
4911
|
recordPath(bundlePath2, conceptId2) {
|
|
4377
|
-
if (conceptId2.includes(
|
|
4912
|
+
if (conceptId2.includes(import_node_path8.sep) || conceptId2.includes("/")) {
|
|
4378
4913
|
throw new KbInvalidConceptIdError(
|
|
4379
4914
|
"concept id must not contain a path separator",
|
|
4380
4915
|
{ conceptId: conceptId2 }
|
|
4381
4916
|
);
|
|
4382
4917
|
}
|
|
4383
|
-
return (0,
|
|
4918
|
+
return (0, import_node_path8.join)(this.root(bundlePath2), `${conceptId2}.md`);
|
|
4384
4919
|
}
|
|
4385
4920
|
};
|
|
4386
4921
|
function estimateTokens(record) {
|
|
@@ -4439,7 +4974,7 @@ function bundleDigest(records, superseded) {
|
|
|
4439
4974
|
}
|
|
4440
4975
|
|
|
4441
4976
|
// src/version.ts
|
|
4442
|
-
var VERSION = true ? "0.1.
|
|
4977
|
+
var VERSION = true ? "0.1.15" : "0.0.0-dev";
|
|
4443
4978
|
|
|
4444
4979
|
// src/mcp.ts
|
|
4445
4980
|
function createKbMcpServer() {
|