skalpel 4.0.45 → 4.0.46

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/anchor-shadow.mjs CHANGED
@@ -438,6 +438,434 @@ export async function runProbe(rawTarget) {
438
438
  return { pass: null, evidence: "unknown target kind" };
439
439
  }
440
440
 
441
+ // ===================================================================================================
442
+ // SHIP-STATUS v1 ("Remote-Truth"): STRICT kind↔probe reconstruction + read-only probes.
443
+ //
444
+ // Distinct from the doubt-spiral reconstructTarget/runProbe above (which allow claim-FAMILY fallbacks
445
+ // to resolve a user's re-verify spiral): the ship-status CATCH must be precision-first. So here EACH
446
+ // ship claim kind maps to EXACTLY ONE probe family, with NO cross-kind fallback — a `pushed` claim can
447
+ // NEVER fall back to a PR probe (the cross-kind false-gotcha that fires "PR #n is OPEN" against a TRUE
448
+ // push). No target of the RIGHT kind → null → the caller stays honestly silent (NO_TARGET, never a red).
449
+ //
450
+ // Every probe is READ-ONLY, execFile arg-array (never a shell), args re-validated against strict
451
+ // whitelists, bounded timeout, fail-open (any error → UNKNOWN, never a lie). The `pushed` probe is the
452
+ // new REMOTE-TRUTH read: one `git ls-remote` (the ONLY network read) resolves the REAL remote tip, then
453
+ // a purely-local `git merge-base --is-ancestor` decides reachability — so a push that never left the
454
+ // machine is caught, while a legitimate feature-branch push is never false-accused.
455
+ // ===================================================================================================
456
+
457
+ // The 1:1 map the caller asserts: each ship claim kind → its single allowed probe FAMILY. `live` &
458
+ // `deployed` share the URL probe; `released` & `published` share the registry/tag family; the rest are
459
+ // unique. reconstructShipTargetV1 ALWAYS returns a target whose family is SHIP_KIND_PROBE[claim], or null.
460
+ export const SHIP_KIND_PROBE = {
461
+ pushed: "git-remote",
462
+ merged: "pr",
463
+ live: "url",
464
+ deployed: "url",
465
+ released: "registry",
466
+ published: "registry",
467
+ ci: "gh-checks",
468
+ };
469
+
470
+ // shipProbeFamily(targetKind) → the probe FAMILY a concrete target belongs to (npm & git-tag are both
471
+ // the registry/tag family). Used to assert reconstruction never crosses kinds.
472
+ export function shipProbeFamily(kind) {
473
+ if (kind === "npm" || kind === "git-tag") return "registry";
474
+ return kind; // git-remote | pr | url | gh-checks
475
+ }
476
+
477
+ // Strict arg whitelists (defense in depth — execFile already means no shell).
478
+ const SHIP_SAFE_REF = /^[A-Za-z0-9._@/+-]{1,120}$/; // git remote/branch/tag name
479
+ const SHIP_SHA_RE = /^[0-9a-f]{7,40}$/;
480
+ const SHIP_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
481
+ const SHIP_NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
482
+ const SHIP_NET_TIMEOUT_MS = 10000; // network probes (ls-remote / npm view / gh) get a touch more than 5s
483
+
484
+ // findPushBranch(text) → the LAST `git push [<remote>] [<branch>]` seen in context, as {remote, branch}
485
+ // (either may be null). Used ONLY to name the branch/remote whose remote tip the `pushed` probe reads —
486
+ // never executed. A refspec `local:remote` keeps the remote-side ref. Both fields strictly validated.
487
+ function findPushBranch(text) {
488
+ const re = /\bgit\s+push\b([^\n;&|]*)/gi;
489
+ let last = null;
490
+ let m;
491
+ while ((m = re.exec(text))) {
492
+ const pos = m[1]
493
+ .trim()
494
+ .split(/\s+/)
495
+ .filter((t) => t && !t.startsWith("-"));
496
+ if (pos.length >= 2) last = { remote: pos[0], branch: pos[1] };
497
+ else if (pos.length === 1) last = { remote: pos[0], branch: null };
498
+ else last = { remote: null, branch: null };
499
+ }
500
+ if (!last) return null;
501
+ let branch = last.branch;
502
+ if (branch && branch.includes(":")) branch = branch.split(":").pop(); // refspec local:remote → remote
503
+ const remote = last.remote && SHIP_SAFE_REF.test(last.remote) ? last.remote : null;
504
+ branch = branch && SHIP_SAFE_REF.test(branch) ? branch : null;
505
+ return { remote, branch };
506
+ }
507
+
508
+ // findTag(text) → a release tag (vX.Y.Z or a `git tag <t>` / `git push origin <t>` mention). Validated.
509
+ function findTag(text) {
510
+ const near = /\bgit\s+(?:tag|push[^\n;&|]*?)\s+(v?\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/i.exec(text);
511
+ if (near && SHIP_SAFE_REF.test(near[1])) return near[1];
512
+ const m = /\b(v\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/.exec(text);
513
+ return m && SHIP_SAFE_REF.test(m[1]) ? m[1] : null;
514
+ }
515
+
516
+ // findNpmVersion(text) → a semver version string near a publish/version mention (strict semver). The
517
+ // npm target's NAME is read from the session's own package.json — never from claim/prompt content.
518
+ function findNpmVersion(text) {
519
+ const near =
520
+ /\b(?:publish|version|v)\s*@?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/i.exec(
521
+ text,
522
+ ) || /@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
523
+ if (near && SHIP_SEMVER_RE.test(near[1])) return near[1];
524
+ const m = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
525
+ return m && SHIP_SEMVER_RE.test(m[1]) ? m[1] : null;
526
+ }
527
+
528
+ // readPkgName(repoDir) → the "name" field of the session's own package.json, validated against npm's
529
+ // package-name rules, or null. This is the ONLY source of the published package name (never the claim).
530
+ function readPkgName(repoDir) {
531
+ try {
532
+ if (!repoDir) return null;
533
+ const name = JSON.parse(readFileSync(join(repoDir, "package.json"), "utf8"))?.name;
534
+ return typeof name === "string" && SHIP_NPM_NAME_RE.test(name) ? name : null;
535
+ } catch {
536
+ return null;
537
+ }
538
+ }
539
+
540
+ // reconstructShipTargetV1(claim, contextText, payload) → the STRICT, single-family target for a ship
541
+ // claim, or null. NO cross-kind fallback: a kind that can't reconstruct ITS OWN target returns null.
542
+ export function reconstructShipTargetV1(claim, contextText, payload) {
543
+ const text = String(contextText || "");
544
+ const repoDir = repoDirOf(payload);
545
+ const family = SHIP_KIND_PROBE[claim];
546
+ if (!family) return null;
547
+ switch (family) {
548
+ case "git-remote": {
549
+ // pushed → REMOTE-TRUTH. Needs the claimed sha + a repo. The branch (from the session's own
550
+ // `git push`, else resolved at probe time from the upstream) is optional here; ambiguity that
551
+ // can't be resolved becomes UNKNOWN in the probe, never a false red.
552
+ const sha = findSha(text);
553
+ if (!sha || !repoDir) return null;
554
+ const pb = findPushBranch(text) || {};
555
+ return {
556
+ kind: "git-remote",
557
+ sha,
558
+ remote: pb.remote || null,
559
+ branch: pb.branch || null,
560
+ repoDir,
561
+ };
562
+ }
563
+ case "pr": {
564
+ // merged → the PR probe ONLY. No PR number in context → null (never a git/url fallback).
565
+ const n = findPr(text);
566
+ return n != null ? { kind: "pr", n, repoDir } : null;
567
+ }
568
+ case "url": {
569
+ // live/deployed → the URL probe ONLY.
570
+ const url = findUrl(text);
571
+ return url ? { kind: "url", url } : null;
572
+ }
573
+ case "registry": {
574
+ // released/published → the registry/tag family ONLY. Prefer npm when the session actually ran
575
+ // `npm publish` AND both the package name (from package.json) and a semver version reconstruct;
576
+ // else a git tag if one is present. Neither → null.
577
+ if (/\bnpm\s+publish\b/i.test(text)) {
578
+ const name = readPkgName(repoDir);
579
+ const version = findNpmVersion(text);
580
+ if (name && version) return { kind: "npm", name, version };
581
+ }
582
+ const tag = findTag(text);
583
+ if (tag && repoDir) {
584
+ const pb = findPushBranch(text) || {};
585
+ return { kind: "git-tag", tag, remote: pb.remote || "origin", repoDir };
586
+ }
587
+ return null;
588
+ }
589
+ case "gh-checks": {
590
+ // "CI green/passing" → gh checks ONLY (a PR's checks, else a commit's runs).
591
+ const n = findPr(text);
592
+ if (n != null) return { kind: "gh-checks", n, repoDir };
593
+ const sha = findSha(text);
594
+ if (sha) return { kind: "gh-checks", sha, repoDir };
595
+ return null;
596
+ }
597
+ default:
598
+ return null;
599
+ }
600
+ }
601
+
602
+ // validateShipTargetV1(t) → re-validate every field of a ship-v1 target before it is passed to execFile
603
+ // (defense in depth). Returns a normalized target or null. Never throws.
604
+ function validateShipTargetV1(t) {
605
+ if (!t || typeof t !== "object") return null;
606
+ switch (t.kind) {
607
+ case "url":
608
+ return isSafeUrl(t.url) ? { kind: "url", url: t.url } : null;
609
+ case "pr": {
610
+ const n = Number(t.n);
611
+ if (!Number.isInteger(n) || n <= 0 || n >= 1e7) return null;
612
+ return { kind: "pr", n, repoDir: validDir(t.repoDir) };
613
+ }
614
+ case "git-remote": {
615
+ if (!SHIP_SHA_RE.test(String(t.sha || ""))) return null;
616
+ const repoDir = validDir(t.repoDir);
617
+ if (!repoDir) return null;
618
+ const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
619
+ const branch = t.branch && SHIP_SAFE_REF.test(t.branch) ? t.branch : null;
620
+ return { kind: "git-remote", sha: String(t.sha).toLowerCase(), remote, branch, repoDir };
621
+ }
622
+ case "npm": {
623
+ if (
624
+ !SHIP_NPM_NAME_RE.test(String(t.name || "")) ||
625
+ !SHIP_SEMVER_RE.test(String(t.version || ""))
626
+ )
627
+ return null;
628
+ return { kind: "npm", name: t.name, version: t.version };
629
+ }
630
+ case "git-tag": {
631
+ const repoDir = validDir(t.repoDir);
632
+ if (!repoDir || !SHIP_SAFE_REF.test(String(t.tag || ""))) return null;
633
+ const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
634
+ return { kind: "git-tag", tag: t.tag, remote, repoDir };
635
+ }
636
+ case "gh-checks": {
637
+ const repoDir = validDir(t.repoDir);
638
+ const n = t.n != null ? Number(t.n) : null;
639
+ const sha = t.sha ? String(t.sha).toLowerCase() : null;
640
+ if (n != null && Number.isInteger(n) && n > 0 && n < 1e7)
641
+ return { kind: "gh-checks", n, repoDir };
642
+ if (sha && SHIP_SHA_RE.test(sha)) return { kind: "gh-checks", sha, repoDir };
643
+ return null;
644
+ }
645
+ default:
646
+ return null;
647
+ }
648
+ }
649
+
650
+ // classifyHttpVerdict(code) → the Defect-3 HTTP precision map. A responding server is REACHABLE; only a
651
+ // genuine "gone" (404/410) is a FAIL CANDIDATE (never an immediate red — the caller confirm-reprobes).
652
+ // 401/403/405 (auth-walled), 429 (rate-limit), 5xx (transient), and any other response → UNKNOWN.
653
+ export function classifyHttpVerdict(code) {
654
+ if (!Number.isFinite(code) || code === 0) return "UNKNOWN";
655
+ if (code >= 200 && code < 400) return "PASS";
656
+ if (code === 404 || code === 410) return "FAIL_CANDIDATE";
657
+ return "UNKNOWN";
658
+ }
659
+
660
+ // parseLsRemoteTip(stdout, branch) → the 40-hex tip sha for refs/heads/<branch> (or the sole ref line),
661
+ // else null. `git ls-remote <remote> <branch>` prints `<sha>\t<ref>` lines.
662
+ function parseLsRemoteTip(stdout, branch) {
663
+ const lines = String(stdout || "").split("\n");
664
+ for (const line of lines) {
665
+ const mm = line.match(/^([0-9a-f]{40})\s+(\S+)/i);
666
+ if (mm && (mm[2] === `refs/heads/${branch}` || mm[2] === "HEAD")) return mm[1].toLowerCase();
667
+ }
668
+ const first = String(stdout || "").match(/^([0-9a-f]{40})\b/im);
669
+ return first ? first[1].toLowerCase() : null;
670
+ }
671
+
672
+ // runShipProbeV1(target, exec) → ONE read-only probe for the strict target. Returns
673
+ // { verdict, evidence } where verdict ∈ PASS | FAIL | FAIL_CANDIDATE | UNKNOWN. `exec` is injectable
674
+ // (defaults to the real read-only `run`) purely so the eval harness can drive it hermetically. Never throws.
675
+ export async function runShipProbeV1(rawTarget, exec = run) {
676
+ const t = validateShipTargetV1(rawTarget);
677
+ if (!t) return { verdict: "UNKNOWN", evidence: "rejected: target failed validation" };
678
+ try {
679
+ if (t.kind === "url") {
680
+ // curl -sS -m 5 -o /dev/null -w "%{http_code}" <url> (READ-ONLY). curl exits 0 on ANY HTTP
681
+ // status; a non-zero exit is a transport error (6=NXDOMAIN, 7=refused → fail candidate; else unknown).
682
+ const { err, stdout } = await exec(
683
+ "curl",
684
+ ["-sS", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", t.url],
685
+ { timeout: SHIP_NET_TIMEOUT_MS },
686
+ );
687
+ if (err) {
688
+ const c = err.code;
689
+ if (c === 6)
690
+ return { verdict: "FAIL_CANDIDATE", evidence: "curl: could not resolve host (NXDOMAIN)" };
691
+ if (c === 7) return { verdict: "FAIL_CANDIDATE", evidence: "curl: connection refused" };
692
+ return {
693
+ verdict: "UNKNOWN",
694
+ evidence: clip(`curl error: ${err.killed ? "timeout" : err.message || c}`),
695
+ };
696
+ }
697
+ const code = parseInt(stdout, 10);
698
+ const verdict = classifyHttpVerdict(code);
699
+ const evidence =
700
+ Number.isFinite(code) && code !== 0 ? `http_code=${code}` : clip(`no http code: ${stdout}`);
701
+ return { verdict, evidence };
702
+ }
703
+ if (t.kind === "pr") {
704
+ // gh pr view <n> --json state,mergedAt (READ-ONLY, DECISIVE)
705
+ const opts = t.repoDir ? { cwd: t.repoDir } : {};
706
+ const { err, stdout } = await exec(
707
+ "gh",
708
+ ["pr", "view", String(t.n), "--json", "state,mergedAt"],
709
+ opts,
710
+ );
711
+ if (err)
712
+ return {
713
+ verdict: "UNKNOWN",
714
+ evidence: clip(`gh error: ${err.killed ? "timeout" : err.message}`),
715
+ };
716
+ let j;
717
+ try {
718
+ j = JSON.parse(stdout);
719
+ } catch {
720
+ return { verdict: "UNKNOWN", evidence: clip(`gh non-json: ${stdout}`) };
721
+ }
722
+ const merged = j.state === "MERGED" || !!j.mergedAt;
723
+ return {
724
+ verdict: merged ? "PASS" : "FAIL",
725
+ evidence: clip(`state=${j.state} mergedAt=${j.mergedAt || "null"}`),
726
+ };
727
+ }
728
+ if (t.kind === "git-remote") {
729
+ // REMOTE-TRUTH. Resolve the branch (from the push, else the upstream / current branch), read the
730
+ // REAL remote tip with ONE `git ls-remote`, then decide reachability locally with merge-base.
731
+ const remote = t.remote || "origin";
732
+ let branch = t.branch;
733
+ if (!branch) {
734
+ const up = await exec(
735
+ "git",
736
+ ["-C", t.repoDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
737
+ { timeout: SHIP_NET_TIMEOUT_MS },
738
+ );
739
+ if (!up.err) {
740
+ const ref = String(up.stdout || "").trim(); // e.g. origin/feat-x
741
+ const idx = ref.indexOf("/");
742
+ if (idx > 0) branch = ref.slice(idx + 1);
743
+ }
744
+ if (!branch) {
745
+ const cur = await exec("git", ["-C", t.repoDir, "symbolic-ref", "--short", "HEAD"], {
746
+ timeout: SHIP_NET_TIMEOUT_MS,
747
+ });
748
+ if (!cur.err) branch = String(cur.stdout || "").trim() || null;
749
+ }
750
+ if (!branch || !SHIP_SAFE_REF.test(branch))
751
+ return { verdict: "UNKNOWN", evidence: "git-remote: branch unresolved (ambiguous)" };
752
+ }
753
+ const ls = await exec("git", ["-C", t.repoDir, "ls-remote", remote, branch], {
754
+ timeout: SHIP_NET_TIMEOUT_MS,
755
+ });
756
+ if (ls.err)
757
+ return {
758
+ verdict: "UNKNOWN",
759
+ evidence: clip(`ls-remote error: ${ls.err.killed ? "timeout" : ls.err.message}`),
760
+ };
761
+ const tip = parseLsRemoteTip(ls.stdout, branch);
762
+ if (!tip)
763
+ return { verdict: "UNKNOWN", evidence: clip(`no remote tip for ${remote}/${branch}`) };
764
+ const mb = await exec("git", ["-C", t.repoDir, "merge-base", "--is-ancestor", t.sha, tip], {
765
+ timeout: SHIP_NET_TIMEOUT_MS,
766
+ });
767
+ const evid = `sha=${t.sha.slice(0, 12)} branch=${remote}/${branch} remoteTip=${tip.slice(0, 12)}`;
768
+ // exit 0 → reachable (PASS). exit 1 → both objects known AND not reachable (DECISIVE FAIL — it
769
+ // never left the machine). Any other exit → a commit is unknown locally → UNKNOWN (never a lie).
770
+ if (!mb.err) return { verdict: "PASS", evidence: clip(`${evid} ancestor`) };
771
+ if (mb.err.code === 1) return { verdict: "FAIL", evidence: clip(`${evid} not-ancestor`) };
772
+ return {
773
+ verdict: "UNKNOWN",
774
+ evidence: clip(`${evid} merge-base indeterminate (tip/sha unknown locally)`),
775
+ };
776
+ }
777
+ if (t.kind === "npm") {
778
+ // npm view <name>@<version> version (READ-ONLY registry read, DECISIVE)
779
+ const { err, stdout, stderr } = await exec(
780
+ "npm",
781
+ ["view", `${t.name}@${t.version}`, "version"],
782
+ {
783
+ timeout: SHIP_NET_TIMEOUT_MS,
784
+ },
785
+ );
786
+ if (!err) {
787
+ return String(stdout || "").trim()
788
+ ? {
789
+ verdict: "PASS",
790
+ evidence: clip(`npm ${t.name}@${t.version} = ${String(stdout).trim()}`),
791
+ }
792
+ : { verdict: "UNKNOWN", evidence: clip(`npm empty for ${t.name}@${t.version}`) };
793
+ }
794
+ const combined = `${stderr || ""} ${stdout || ""}`;
795
+ if (/E404|No match found|is not in (?:this|the)/i.test(combined))
796
+ return { verdict: "FAIL", evidence: clip(`npm: ${t.name}@${t.version} not on registry`) };
797
+ return {
798
+ verdict: "UNKNOWN",
799
+ evidence: clip(`npm error: ${err.killed ? "timeout" : err.message}`),
800
+ };
801
+ }
802
+ if (t.kind === "git-tag") {
803
+ // git -C <repo> ls-remote --tags <remote> <tag> (READ-ONLY, DECISIVE)
804
+ const { err, stdout } = await exec(
805
+ "git",
806
+ ["-C", t.repoDir, "ls-remote", "--tags", t.remote, t.tag],
807
+ {
808
+ timeout: SHIP_NET_TIMEOUT_MS,
809
+ },
810
+ );
811
+ if (err)
812
+ return {
813
+ verdict: "UNKNOWN",
814
+ evidence: clip(`ls-remote --tags error: ${err.killed ? "timeout" : err.message}`),
815
+ };
816
+ const esc = t.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
817
+ const has = new RegExp(`\\brefs/tags/${esc}(?:\\^\\{\\})?$`, "m").test(String(stdout || ""));
818
+ return has
819
+ ? { verdict: "PASS", evidence: clip(`tag ${t.remote}/${t.tag} present`) }
820
+ : { verdict: "FAIL", evidence: clip(`tag ${t.tag} not on ${t.remote}`) };
821
+ }
822
+ if (t.kind === "gh-checks") {
823
+ // gh pr checks <n> --json ... / gh run list --commit <sha> --json ... (READ-ONLY). gh prints
824
+ // JSON even on a non-zero exit (failing/pending), so we parse stdout regardless of err.
825
+ const opts = t.repoDir ? { cwd: t.repoDir } : {};
826
+ let err, stdout;
827
+ if (t.n != null) {
828
+ ({ err, stdout } = await exec(
829
+ "gh",
830
+ ["pr", "checks", String(t.n), "--json", "state,bucket"],
831
+ opts,
832
+ ));
833
+ } else {
834
+ ({ err, stdout } = await exec(
835
+ "gh",
836
+ ["run", "list", "--commit", t.sha, "--json", "conclusion,status"],
837
+ opts,
838
+ ));
839
+ }
840
+ const out = String(stdout || "");
841
+ if (err && !out.trim())
842
+ return {
843
+ verdict: "UNKNOWN",
844
+ evidence: clip(`gh checks error: ${err.killed ? "timeout" : err.message}`),
845
+ };
846
+ let arr;
847
+ try {
848
+ arr = JSON.parse(out);
849
+ } catch {
850
+ return { verdict: "UNKNOWN", evidence: clip(`gh checks non-json: ${out}`) };
851
+ }
852
+ if (!Array.isArray(arr) || !arr.length)
853
+ return { verdict: "UNKNOWN", evidence: "gh checks: none reported" };
854
+ const norm = arr.map((c) =>
855
+ String((c && (c.bucket || c.conclusion || c.state || c.status)) || "").toLowerCase(),
856
+ );
857
+ if (norm.some((s) => /fail|error|cancel|timed_out|action_required/.test(s)))
858
+ return { verdict: "FAIL", evidence: clip(`checks: ${norm.join(",")}`) };
859
+ if (norm.some((s) => s === "" || /pending|queued|in_progress|waiting|requested/.test(s)))
860
+ return { verdict: "UNKNOWN", evidence: clip(`checks pending: ${norm.join(",")}`) };
861
+ return { verdict: "PASS", evidence: clip(`checks: ${norm.join(",")}`) };
862
+ }
863
+ } catch (e) {
864
+ return { verdict: "UNKNOWN", evidence: clip(`probe threw: ${e}`) };
865
+ }
866
+ return { verdict: "UNKNOWN", evidence: "unknown ship target kind" };
867
+ }
868
+
441
869
  // ---------------------------------------------------------------------------------------------------
442
870
  // Logging — the ONLY side effect. Append-only NDJSON at ~/.skalpel/anchor-shadow.log. Never throws.
443
871
  // ---------------------------------------------------------------------------------------------------