@archstone/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { writeFileSync as writeFileSync2 } from "fs";
5
- import { resolve as resolve2 } from "path";
4
+ import { writeFileSync as writeFileSync3 } from "fs";
5
+ import { resolve as resolve3 } from "path";
6
6
  import { createRequire } from "module";
7
7
  import { createServer } from "http";
8
- import { load } from "@archstone/schema";
9
- import { validateSemantics, compile } from "@archstone/compiler";
10
- import { Registry, buildRegistry, serveStdio, runVerify } from "@archstone/runtime";
8
+ import { load as load2 } from "@archstone/schema";
9
+ import { validateSemantics as validateSemantics2, compile as compile2 } from "@archstone/compiler";
10
+ import { Registry as Registry2, buildRegistry, serveStdio, runVerify } from "@archstone/runtime";
11
11
  import { createHttpHandler } from "@archstone/runtime/http";
12
12
 
13
13
  // src/init.ts
@@ -596,6 +596,451 @@ function runAuditCmd(argv) {
596
596
  return 0;
597
597
  }
598
598
 
599
+ // src/doctor.ts
600
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
601
+ import { join as join2 } from "path";
602
+ var ENV_INTERP = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
603
+ var CALLER_INTERP = /\$\{caller\./;
604
+ function baseUrlOf(tool) {
605
+ return tool.connector?.rest?.baseUrl;
606
+ }
607
+ function diagnose(ir, manifestDir, opts = {}) {
608
+ const findings = [];
609
+ const add = (f) => findings.push(f);
610
+ for (const tool of ir.tools) {
611
+ const bound = tool.connector !== void 0;
612
+ if (!bound && tool.lifecycle !== "retired") {
613
+ add({
614
+ severity: "warning",
615
+ code: "unbound-capability",
616
+ capability: tool.id,
617
+ message: "declared but has no binding, so it is not invocable",
618
+ because: "A capability with no binding compiles and then does nothing. That is fine while you are drafting and a defect at go-live."
619
+ });
620
+ }
621
+ if (bound && !tool.contract && tool.effect === "read") {
622
+ add({
623
+ severity: "warning",
624
+ code: "no-contract",
625
+ capability: tool.id,
626
+ message: "bound, but records no contract fixture",
627
+ because: "`archstone verify` replays a recorded fixture against the live backend. With no fixture there is nothing to replay, so backend drift is found by an agent, in front of a customer, instead of by CI."
628
+ });
629
+ }
630
+ if (bound && !tool.contract && tool.effect !== "read") {
631
+ add({
632
+ // `advisory`, not `warning`: on a `write`/`irreversible` capability, having no contract
633
+ // fixture is now the CORRECT state, not a gap to close. `warning` would keep asking for
634
+ // the thing this advisory exists to stop recommending.
635
+ severity: "advisory",
636
+ code: "no-contract-non-read",
637
+ capability: tool.id,
638
+ message: `bound and \`${tool.effect}\`, so it records no contract fixture \u2014 and should not`,
639
+ because: "`archstone verify` replays a recorded fixture as a real invocation, so a fixture here would repeat this capability's effect against the live backend on every CI run. `verify` skips it by default for that reason. Where this capability has a `read` counterpart, cover the drift with that instead \u2014 the quote half of a quote \u2192 commit pair hits the same host, auth and serialization at zero risk. Not every write has one, and Archstone cannot tell you which capability it is: nothing in CDL declares that relationship. Only if this binding's `${VAR}` genuinely resolves to a sandbox tenant is recording one worthwhile, replayed with `archstone verify --sandbox`: the flag re-includes the binding, it does not make the backend safe."
640
+ });
641
+ }
642
+ if (tool.contract?.probeFixture) {
643
+ const fixture = join2(manifestDir, tool.contract.probeFixture);
644
+ if (!existsSync2(fixture)) {
645
+ add({
646
+ severity: "error",
647
+ code: "missing-fixture-file",
648
+ capability: tool.id,
649
+ message: `contract names a fixture that is not on disk: ${tool.contract.probeFixture}`,
650
+ because: "The contract points at a file that does not exist, so `verify` cannot run at all \u2014 a green pipeline that never checked anything."
651
+ });
652
+ }
653
+ }
654
+ if (tool.lifecycle === "retired" && tool.contract) {
655
+ add({
656
+ severity: "advisory",
657
+ code: "retired-with-contract",
658
+ capability: tool.id,
659
+ message: "is retired but still carries a contract fixture",
660
+ because: "Retired capabilities are blocked on every surface, so the fixture is dead weight \u2014 harmless, but it makes the manifest read as though the capability is still live."
661
+ });
662
+ }
663
+ const baseUrl = baseUrlOf(tool);
664
+ if (baseUrl && CALLER_INTERP.test(baseUrl)) {
665
+ add({
666
+ severity: "error",
667
+ code: "caller-influenced-baseurl",
668
+ capability: tool.id,
669
+ message: "baseUrl interpolates caller-supplied data",
670
+ because: "This is the SSRF shape: a caller who chooses part of the URL chooses where the request goes. Set `allowedHosts` on the provider, which constrains the resolved host to an allowlist."
671
+ });
672
+ } else if (baseUrl && ENV_INTERP.test(baseUrl)) {
673
+ add({
674
+ severity: "advisory",
675
+ code: "env-baseurl",
676
+ capability: tool.id,
677
+ message: `baseUrl comes from the environment (${baseUrl})`,
678
+ because: "Nothing wrong with it \u2014 but the deployment, not the manifest, decides where this capability points. Confirm the variable is set to the intended backend in every environment that runs it."
679
+ });
680
+ }
681
+ if (tool.effect === "irreversible") {
682
+ add({
683
+ severity: "advisory",
684
+ code: "irreversible-effect",
685
+ capability: tool.id,
686
+ message: "is declared `irreversible`",
687
+ because: (
688
+ // #125 (ADD-124 D-12) appends the last sentence — code and severity unchanged. Without
689
+ // it, this advisory and the contract advisory above land on the same capability saying
690
+ // opposite things ("never auto-retry" vs "wire it into CI"). Naming `verify`'s default
691
+ // here is what makes the two agree wherever a reader starts.
692
+ "No API description states this, so it was a human judgement: an agent must confirm explicitly and must never auto-retry. Re-read it before go-live \u2014 `irreversible` is the difference between looking up a price and charging a card. `archstone verify` applies the same judgement: it will not replay this capability's fixture against the live backend unless you assert a sandbox with --sandbox."
693
+ )
694
+ });
695
+ }
696
+ if (tool.policyRules?.some((r) => r.rateLimit !== void 0)) {
697
+ add({
698
+ severity: "advisory",
699
+ code: "ratelimit-needs-counter",
700
+ capability: tool.id,
701
+ message: "declares a rate limit, which needs a counter supplied at runtime",
702
+ because: "With no counter the call is denied, fail-closed, at the first invocation. On more than one instance the counter must be shared, or a declared 100/min becomes 100/min per instance."
703
+ });
704
+ }
705
+ if (tool.policies?.includes("authenticated")) {
706
+ add({
707
+ severity: "advisory",
708
+ code: "authenticated-needs-principal",
709
+ capability: tool.id,
710
+ message: "requires an authenticated caller",
711
+ because: "The surface serving it must carry a per-request principal \u2014 `resolveCaller` on HTTP or the embedded SDK. `archstone serve` (stdio) has one static caller for the whole process, so it cannot serve this capability to more than one identity."
712
+ });
713
+ }
714
+ if (tool.lifecycle === "experimental") {
715
+ add({
716
+ severity: "advisory",
717
+ code: "experimental-capability",
718
+ capability: tool.id,
719
+ message: "is `experimental`: hidden from tool listings but still invocable by id",
720
+ because: "Deliberate behaviour, and easy to forget: an agent that knows the id can still call it. Confirm that is what you want in production."
721
+ });
722
+ }
723
+ }
724
+ if (opts.builtIr !== void 0) {
725
+ const committed = join2(manifestDir, "archstone.ir.json");
726
+ if (existsSync2(committed)) {
727
+ const onDisk = readFileSync3(committed, "utf8");
728
+ if (onDisk.trim() !== opts.builtIr.trim()) {
729
+ add({
730
+ severity: "error",
731
+ code: "ir-drift",
732
+ message: "the committed archstone.ir.json does not match a fresh build of this manifest",
733
+ because: "The artifact is what runs. A stale one enforces stale policy and exposes stale tools, silently \u2014 rebuild it and commit the result."
734
+ });
735
+ }
736
+ }
737
+ }
738
+ return {
739
+ findings,
740
+ checked: ir.tools.length,
741
+ ok: !findings.some((f) => f.severity === "error")
742
+ };
743
+ }
744
+ var ICON = { error: "\u{1F534}", warning: "\u{1F7E1}", advisory: "\u{1F535}" };
745
+ function formatReport2(report, dir) {
746
+ const lines = [`
747
+ archstone doctor ${dir}
748
+ `];
749
+ if (report.findings.length === 0) {
750
+ lines.push(`\u{1F7E2} ${report.checked} capabilities checked \u2014 nothing to flag.
751
+ `);
752
+ return lines.join("\n");
753
+ }
754
+ const order = ["error", "warning", "advisory"];
755
+ for (const sev of order) {
756
+ for (const f of report.findings.filter((x) => x.severity === sev)) {
757
+ lines.push(`${ICON[sev]} ${f.capability ? `${f.capability} \u2014 ` : ""}${f.message}`);
758
+ lines.push(` ${f.because}`);
759
+ lines.push(` (${f.code})
760
+ `);
761
+ }
762
+ }
763
+ const counts = order.map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`).join(" \xB7 ");
764
+ lines.push(`${report.checked} capabilities checked \u2014 ${counts}.
765
+ `);
766
+ return lines.join("\n");
767
+ }
768
+
769
+ // src/adopt.ts
770
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdtempSync, cpSync, rmSync } from "fs";
771
+ import { tmpdir } from "os";
772
+ import { join as join3, resolve as resolve2 } from "path";
773
+ import { createInterface as createInterface2 } from "readline/promises";
774
+ import { load } from "@archstone/schema";
775
+ import { compile, diffShape, validateSemantics } from "@archstone/compiler";
776
+ import { Registry } from "@archstone/emitter-support";
777
+ import { recordContract, adoptable, planAdoption } from "@archstone/runtime";
778
+
779
+ // src/adopt-edit.ts
780
+ import { yamlKey, yamlScalar } from "@archstone/init";
781
+ function indentOf(line) {
782
+ return line.length - line.trimStart().length;
783
+ }
784
+ function locate(lines, from, to, key) {
785
+ const header = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(#.*)?$`);
786
+ const hits = [];
787
+ for (let i = from; i < to; i++) if (header.test(lines[i])) hits.push(i);
788
+ if (hits.length === 0) return { problem: `could not find a '${key}:' block to append to` };
789
+ if (hits.length > 1) return { problem: `found ${hits.length} '${key}:' blocks; refusing to guess which one to extend` };
790
+ const start = hits[0];
791
+ const own = indentOf(lines[start]);
792
+ let end = to;
793
+ for (let i = start + 1; i < to; i++) {
794
+ if (lines[i].trim() === "") continue;
795
+ if (indentOf(lines[i]) <= own) {
796
+ end = i;
797
+ break;
798
+ }
799
+ }
800
+ while (end > start + 1 && lines[end - 1].trim() === "") end--;
801
+ let indent = " ".repeat(own + 2);
802
+ for (let i = start + 1; i < end; i++) {
803
+ if (lines[i].trim() === "") continue;
804
+ indent = lines[i].slice(0, indentOf(lines[i]));
805
+ break;
806
+ }
807
+ return { end, indent };
808
+ }
809
+ function nest(lines, path) {
810
+ let from = 0;
811
+ let to = lines.length;
812
+ let block = { problem: "empty path" };
813
+ for (const key of path) {
814
+ block = locate(lines, from, to, key);
815
+ if ("problem" in block) return block;
816
+ to = block.end;
817
+ for (let i = from; i < to; i++) {
818
+ const header = new RegExp(`^\\s*${key}:\\s*(#.*)?$`);
819
+ if (header.test(lines[i])) {
820
+ from = i + 1;
821
+ break;
822
+ }
823
+ }
824
+ }
825
+ return block;
826
+ }
827
+ function insert(lines, at, added) {
828
+ return [...lines.slice(0, at), ...added, ...lines.slice(at)];
829
+ }
830
+ function applyAdoption(resourceYaml, bindingYaml, edits) {
831
+ if (edits.length === 0) return { ok: true, resource: resourceYaml, binding: bindingYaml };
832
+ let resourceLines = resourceYaml.split("\n");
833
+ const fields = nest(resourceLines, ["resource", "fields"]);
834
+ if ("problem" in fields) return { ok: false, problem: `resource: ${fields.problem}` };
835
+ const added = [];
836
+ for (const e of edits) {
837
+ added.push(
838
+ `${fields.indent}${yamlKey(e.field)}:`,
839
+ `${fields.indent} type: ${yamlScalar(e.semantic)}`,
840
+ `${fields.indent} required: false`,
841
+ `${fields.indent} description: ${yamlScalar(e.description)}`
842
+ );
843
+ }
844
+ resourceLines = insert(resourceLines, fields.end, added);
845
+ let bindingLines = bindingYaml.split("\n");
846
+ const map = nest(bindingLines, ["binding", "response", "map"]);
847
+ if ("problem" in map) return { ok: false, problem: `binding: ${map.problem}` };
848
+ bindingLines = insert(
849
+ bindingLines,
850
+ map.end,
851
+ edits.map((e) => `${map.indent}${yamlKey(e.field)}: ${yamlScalar(e.itemPath)}`)
852
+ );
853
+ return { ok: true, resource: resourceLines.join("\n"), binding: bindingLines.join("\n") };
854
+ }
855
+ function applyContractRecording(bindingYaml, fingerprint, shape) {
856
+ const lines = bindingYaml.split("\n");
857
+ const fpIdx = lines.findIndex((l) => /^\s*fingerprint:\s/.test(l));
858
+ if (fpIdx === -1) return { ok: false, problem: "binding: no contract fingerprint to update" };
859
+ const indent = lines[fpIdx].slice(0, indentOf(lines[fpIdx]));
860
+ const rendered = [
861
+ `${indent}fingerprint: ${yamlScalar(fingerprint)}`,
862
+ `${indent}shape:`,
863
+ // Sorted, so re-adopting against an unchanged backend is a no-op diff rather than a
864
+ // reshuffle a reviewer has to read.
865
+ ...Object.keys(shape).sort().map((k) => `${indent} ${yamlKey(k)}: ${yamlScalar(shape[k])}`)
866
+ ];
867
+ const removeFrom = fpIdx;
868
+ let removeTo = fpIdx + 1;
869
+ const shapeIdx = lines.findIndex((l, i) => i > fpIdx && /^\s*shape:\s*$/.test(l));
870
+ if (shapeIdx !== -1 && lines.slice(fpIdx + 1, shapeIdx).every((l) => l.trim() === "" || l.trim().startsWith("#"))) {
871
+ removeTo = shapeIdx + 1;
872
+ const own = indentOf(lines[shapeIdx]);
873
+ while (removeTo < lines.length && (lines[removeTo].trim() === "" || indentOf(lines[removeTo]) > own)) removeTo++;
874
+ const preserved = lines.slice(fpIdx + 1, shapeIdx);
875
+ return {
876
+ ok: true,
877
+ binding: [...lines.slice(0, removeFrom), rendered[0], ...preserved, ...rendered.slice(1), ...lines.slice(removeTo)].join("\n")
878
+ };
879
+ }
880
+ return { ok: true, binding: [...lines.slice(0, fpIdx), ...rendered, ...lines.slice(fpIdx + 1)].join("\n") };
881
+ }
882
+
883
+ // src/adopt.ts
884
+ function locateFiles(dir, tool) {
885
+ const res = load(dir);
886
+ const wanted = tool.response?.resource;
887
+ if (!wanted) return { problem: `${tool.id}: no response mapping \u2014 nothing to adopt into` };
888
+ const bare = wanted.includes(".") ? wanted.slice(wanted.lastIndexOf(".") + 1) : wanted;
889
+ const doc = res.resourceDocs.find((d) => d.resource.name === wanted || d.resource.name.endsWith(`.${bare}`) || d.resource.name === bare);
890
+ if (!doc) return { problem: `${tool.id}: could not find the file declaring resource '${wanted}'` };
891
+ const binding = res.bindings.find((b) => b.binding.capabilityId === tool.id);
892
+ if (!binding) return { problem: `${tool.id}: could not find its binding file` };
893
+ return { tool, resourceFile: join3(dir, doc.file), bindingFile: join3(dir, binding.file) };
894
+ }
895
+ function readFixture(dir, path) {
896
+ try {
897
+ return JSON.parse(readFileSync4(resolve2(dir, path), "utf8"));
898
+ } catch {
899
+ return void 0;
900
+ }
901
+ }
902
+ async function confirm2(ask, question) {
903
+ const answer = (await ask.question(`${question} [y/N] `)).trim().toLowerCase();
904
+ return answer === "y" || answer === "yes";
905
+ }
906
+ function compilesClean(dir, resourceFile, resourceYaml, bindingFile, bindingYaml) {
907
+ const scratch = mkdtempSync(join3(tmpdir(), "archstone-adopt-"));
908
+ try {
909
+ cpSync(dir, scratch, { recursive: true });
910
+ writeFileSync2(join3(scratch, resourceFile.slice(dir.length + 1)), resourceYaml);
911
+ writeFileSync2(join3(scratch, bindingFile.slice(dir.length + 1)), bindingYaml);
912
+ const res = load(scratch);
913
+ if (!res.ok) return res.issues.map((i) => `${i.file}: ${i.message}`).join("; ");
914
+ const errors = validateSemantics(res).filter((d) => d.severity === "error");
915
+ if (errors.length > 0) return errors.map((e) => e.message).join("; ");
916
+ compile(res);
917
+ return void 0;
918
+ } catch (err) {
919
+ return err.message;
920
+ } finally {
921
+ rmSync(scratch, { recursive: true, force: true });
922
+ }
923
+ }
924
+ async function adoptOne(dir, target, contractShape, ask) {
925
+ const { tool } = target;
926
+ const fixture = readFixture(dir, tool.contract.probeFixture);
927
+ if (!fixture) {
928
+ console.error(` ${tool.id}: fixture not found or unreadable \u2014 nothing to replay`);
929
+ return 1;
930
+ }
931
+ const recording = await recordContract(tool, fixture.request, {}, {});
932
+ if (recording.outcome !== "green" && recording.outcome !== "yellow") {
933
+ console.error(` ${tool.id}: ${recording.detail}`);
934
+ return 1;
935
+ }
936
+ const liveShape = recording.shape ?? {};
937
+ const drift = diffShape(contractShape ?? {}, liveShape);
938
+ const plan = planAdoption(tool, drift, new Registry(compile(load(dir))).ir.resources);
939
+ const offers = adoptable(plan);
940
+ const refused = plan.candidates.filter((c) => !c.adoptable);
941
+ if (refused.length > 0) {
942
+ console.log(`
943
+ ${tool.id} \u2014 not adoptable:`);
944
+ for (const c of refused) if (!c.adoptable) console.log(` \xB7 ${c.path} (${c.observed}) \u2014 ${c.detail}`);
945
+ }
946
+ if (offers.length === 0) {
947
+ console.log(`
948
+ ${tool.id} \u2014 nothing to adopt.`);
949
+ return 0;
950
+ }
951
+ console.log(`
952
+ ${tool.id} \u2014 ${offers.length} field(s) the backend returns and the manifest does not declare:`);
953
+ for (const o of offers) console.log(` \xB7 ${o.path} (${o.observed}) \u2192 ${o.field}: ${o.semantic}`);
954
+ console.log("");
955
+ const edits = [];
956
+ for (const o of offers) {
957
+ if (!await confirm2(ask, ` Declare ${o.field} (${o.semantic})?`)) continue;
958
+ const description = (await ask.question(` Describe ${o.field} \u2014 an agent reads this to decide whether to use it:
959
+ > `)).trim();
960
+ if (description === "") {
961
+ console.log(` ${o.field}: no description given \u2014 not adopted.`);
962
+ continue;
963
+ }
964
+ edits.push({ field: o.field, itemPath: o.itemPath, semantic: o.semantic, description });
965
+ }
966
+ if (edits.length === 0) {
967
+ console.log(`
968
+ ${tool.id} \u2014 nothing adopted.`);
969
+ return 0;
970
+ }
971
+ const resourceYaml = readFileSync4(target.resourceFile, "utf8");
972
+ const bindingYaml = readFileSync4(target.bindingFile, "utf8");
973
+ const applied = applyAdoption(resourceYaml, bindingYaml, edits);
974
+ if (!applied.ok) {
975
+ console.error(` ${tool.id}: ${applied.problem}`);
976
+ return 1;
977
+ }
978
+ const rewritten = applyContractRecording(applied.binding, recording.fingerprint, liveShape);
979
+ if (!rewritten.ok) {
980
+ console.error(` ${tool.id}: ${rewritten.problem}`);
981
+ return 1;
982
+ }
983
+ const problem = compilesClean(dir, target.resourceFile, applied.resource, target.bindingFile, rewritten.binding);
984
+ if (problem) {
985
+ console.error(`
986
+ ${tool.id}: the edit does not compile \u2014 nothing written.
987
+ ${problem}`);
988
+ return 1;
989
+ }
990
+ writeFileSync2(target.resourceFile, applied.resource);
991
+ writeFileSync2(target.bindingFile, rewritten.binding);
992
+ console.log(`
993
+ ${tool.id} \u2014 declared ${edits.map((e) => e.field).join(", ")}; contract re-recorded.`);
994
+ return 0;
995
+ }
996
+ async function runAdoptCmd(argv) {
997
+ const dir = argv.find((a, i) => i > 0 && !a.startsWith("-"));
998
+ if (!dir) {
999
+ console.error("usage: archstone adopt <manifest-dir>");
1000
+ return 2;
1001
+ }
1002
+ const res = load(dir);
1003
+ const errors = validateSemantics(res).filter((d) => d.severity === "error");
1004
+ if (!res.ok || errors.length > 0) {
1005
+ console.error(`archstone adopt ${dir}: manifest invalid \u2014 run 'archstone apply ${dir}' for details`);
1006
+ return 2;
1007
+ }
1008
+ const registry = new Registry(compile(res));
1009
+ const targets = registry.listCapabilities().filter((t) => t.contract);
1010
+ if (targets.length === 0) {
1011
+ console.log(`
1012
+ archstone adopt ${dir}
1013
+
1014
+ (no bindings declare a contract: \u2014 nothing to probe)
1015
+ `);
1016
+ return 0;
1017
+ }
1018
+ console.log(`
1019
+ archstone adopt ${dir}`);
1020
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1021
+ const ask = terminalAsk(rl);
1022
+ let worst = 0;
1023
+ try {
1024
+ for (const tool of targets) {
1025
+ const located = locateFiles(dir, tool);
1026
+ if ("problem" in located) {
1027
+ console.error(` ${located.problem}`);
1028
+ worst = Math.max(worst, 1);
1029
+ continue;
1030
+ }
1031
+ worst = Math.max(worst, await adoptOne(dir, located, tool.contract.shape, ask));
1032
+ }
1033
+ } catch (err) {
1034
+ console.error(`
1035
+ archstone adopt: no more input \u2014 nothing written. Adoption needs a person (${err.name}).`);
1036
+ return 1;
1037
+ } finally {
1038
+ rl.close();
1039
+ }
1040
+ console.log("");
1041
+ return worst;
1042
+ }
1043
+
599
1044
  // src/index.ts
600
1045
  function cliVersion() {
601
1046
  try {
@@ -610,11 +1055,11 @@ function printUsage(opts) {
610
1055
  // `init` is named HERE, in the verb list, and not only in the block below it. It takes a
611
1056
  // spec file rather than a manifest directory, so it cannot share the first line's shape —
612
1057
  // which is exactly how it came to be missing from the one line a user actually scans.
613
- "usage: archstone <apply|serve|verify|build|init|audit>\n\n archstone <apply|serve|verify|build> <manifest-dir> [--json] [--out path]\n archstone serve --http <manifest-dir> [--port <n>] [--token <value>]\n bearer token: --token <value>, or the ARCHSTONE_HTTP_TOKEN env var (required \u2014 never serves open)\n archstone init <spec-file> --out <dir> \u2014 start here if you have no manifest yet\n archstone audit <file...> [--since <date>] [--format summary|jsonl|csv]\n read your own Execution audit records; nothing is uploaded (audit --help for filters)\n\n archstone --version | --help\n\n" + INIT_USAGE
1058
+ "usage: archstone <apply|serve|verify|build|doctor|init|adopt|audit>\n\n archstone <apply|serve|verify|build> <manifest-dir> [--json] [--out path]\n archstone verify <manifest-dir> [--json] [--sandbox]\n --sandbox: also replay `write`/`irreversible` fixtures \u2014 they are skipped by default,\n because a replay is a real invocation. Only for a backend you know is a sandbox tenant.\n archstone serve --http <manifest-dir> [--port <n>] [--token <value>]\n bearer token: --token <value>, or the ARCHSTONE_HTTP_TOKEN env var (required \u2014 never serves open)\n archstone doctor <manifest-dir> [--json] \u2014 pre-production checks, offline\n archstone init <spec-file> --out <dir> \u2014 start here if you have no manifest yet\n archstone adopt <manifest-dir>\n declare a field the backend started returning; asks before writing, needs a person\n\n archstone audit <file...> [--since <date>] [--format summary|jsonl|csv]\n read your own Execution audit records; nothing is uploaded (audit --help for filters)\n\n archstone --version | --help\n\n" + INIT_USAGE
614
1059
  );
615
1060
  }
616
1061
  function runApply(dir) {
617
- const res = load(dir);
1062
+ const res = load2(dir);
618
1063
  console.log(`
619
1064
  archstone apply ${dir}
620
1065
  `);
@@ -643,14 +1088,14 @@ archstone apply ${dir}
643
1088
  console.log(`
644
1089
  \u2713 shapes valid`);
645
1090
  }
646
- const diags = validateSemantics(res);
1091
+ const diags = validateSemantics2(res);
647
1092
  const errors = diags.filter((d) => d.severity === "error");
648
1093
  const warnings = diags.filter((d) => d.severity === "warning");
649
1094
  console.log(` semantic ${errors.length} error(s), ${warnings.length} warning(s)`);
650
1095
  for (const d of errors) console.log(` \u2717 ${d.message}`);
651
1096
  for (const d of warnings) console.log(` \u26A0 ${d.message}`);
652
1097
  const shapesAndSemanticsOk = res.ok && errors.length === 0;
653
- const registry = shapesAndSemanticsOk ? new Registry(compile(res)) : void 0;
1098
+ const registry = shapesAndSemanticsOk ? new Registry2(compile2(res)) : void 0;
654
1099
  const collisions = registry?.toolNameCollisions ?? [];
655
1100
  if (collisions.length > 0) {
656
1101
  console.log(`
@@ -670,8 +1115,8 @@ archstone apply ${dir}
670
1115
  process.exit(ok ? 0 : 1);
671
1116
  }
672
1117
  function runBuild(dir, outPath) {
673
- const res = load(dir);
674
- const diags = validateSemantics(res);
1118
+ const res = load2(dir);
1119
+ const diags = validateSemantics2(res);
675
1120
  const errors = diags.filter((d) => d.severity === "error");
676
1121
  const ok = res.ok && errors.length === 0;
677
1122
  if (!ok) {
@@ -680,8 +1125,8 @@ function runBuild(dir, outPath) {
680
1125
  for (const d of errors) console.error(` - ${d.message}`);
681
1126
  process.exit(1);
682
1127
  }
683
- const ir = compile(res);
684
- const registry = new Registry(ir);
1128
+ const ir = compile2(res);
1129
+ const registry = new Registry2(ir);
685
1130
  if (registry.toolNameCollisions.length > 0) {
686
1131
  console.error(`archstone build ${dir}: refusing to write artifact \u2014 tool-name collision(s):`);
687
1132
  for (const c of registry.toolNameCollisions) {
@@ -690,8 +1135,8 @@ function runBuild(dir, outPath) {
690
1135
  process.exit(1);
691
1136
  }
692
1137
  const stripped = { ...ir, tools: ir.tools.map(({ contract: _contract, ...t }) => t) };
693
- const outFile = resolve2(process.cwd(), outPath ?? "archstone.ir.json");
694
- writeFileSync2(outFile, `${JSON.stringify(stripped, null, 2)}
1138
+ const outFile = resolve3(process.cwd(), outPath ?? "archstone.ir.json");
1139
+ writeFileSync3(outFile, `${JSON.stringify(stripped, null, 2)}
695
1140
  `);
696
1141
  console.log(`archstone build ${dir} \u2192 ${outFile} (${stripped.tools.length} tool(s))`);
697
1142
  process.exit(0);
@@ -782,9 +1227,11 @@ async function handleHttpRequest(handler, req, res) {
782
1227
  }
783
1228
  }
784
1229
  var HEALTH_ICON = { green: "\u{1F7E2}", yellow: "\u{1F7E1}", red: "\u{1F534}" };
785
- async function runVerifyCmd(dir, json) {
786
- const res = load(dir);
787
- const diags = validateSemantics(res);
1230
+ var SKIP_ICON = "\u23ED";
1231
+ var READ_TWIN_TIP = " Where one of these has a `read` capability against the same backend \u2014 the quote half of a\n quote \u2192 commit pair \u2014 verifying that instead hits the same host, auth and serialization,\n catching most infrastructure and schema drift at zero risk. Not every write has one, and\n Archstone cannot tell you which capability it is: nothing in CDL declares that relationship.\n If this backend really is a sandbox tenant, pass --sandbox.";
1232
+ async function runVerifyCmd(dir, json, sandbox) {
1233
+ const res = load2(dir);
1234
+ const diags = validateSemantics2(res);
788
1235
  const errors = diags.filter((d) => d.severity === "error");
789
1236
  const ok = res.ok && errors.length === 0;
790
1237
  if (!ok) {
@@ -795,29 +1242,53 @@ async function runVerifyCmd(dir, json) {
795
1242
  }
796
1243
  process.exit(2);
797
1244
  }
798
- const registry = new Registry(compile(res));
799
- const reports = await runVerify(registry.listCapabilities(), dir, registry.ir.resources);
1245
+ const registry = new Registry2(compile2(res));
1246
+ const { results, skipped } = sandbox ? await runVerify(registry.listCapabilities(), dir, registry.ir.resources, void 0, { includeNonRead: true }) : await runVerify(registry.listCapabilities(), dir, registry.ir.resources);
1247
+ const exitCode = results.some((r) => r.status === "red") ? 1 : 0;
800
1248
  if (json) {
801
- console.log(JSON.stringify({ results: reports }));
802
- process.exit(reports.some((r) => r.status === "red") ? 1 : 0);
1249
+ console.log(JSON.stringify({ results, skipped, sandbox }));
1250
+ process.exit(exitCode);
803
1251
  }
804
1252
  console.log(`
805
1253
  archstone verify ${dir}
806
1254
  `);
807
- if (reports.length === 0) {
1255
+ if (results.length === 0 && skipped.length === 0) {
808
1256
  console.log(" (no bindings declare a contract: \u2014 nothing to verify)\n");
809
1257
  process.exit(0);
810
1258
  }
811
- for (const r of reports) {
1259
+ for (const r of results) {
812
1260
  console.log(` ${HEALTH_ICON[r.status]} ${r.capabilityId} \u2014 ${r.detail}`);
813
1261
  }
1262
+ for (const s of skipped) {
1263
+ console.log(` ${SKIP_ICON} ${s.capabilityId} \u2014 ${s.detail}`);
1264
+ }
1265
+ if (skipped.length > 0) {
1266
+ console.log(`
1267
+ ${skipped.length} binding(s) were NOT verified against the backend.`);
1268
+ console.log(READ_TWIN_TIP);
1269
+ }
814
1270
  console.log("");
815
- process.exit(reports.some((r) => r.status === "red") ? 1 : 0);
1271
+ process.exit(exitCode);
816
1272
  }
817
1273
  function flagArg(argv, name) {
818
1274
  const idx = argv.indexOf(name);
819
1275
  return { value: idx !== -1 ? argv[idx + 1] : void 0, idx };
820
1276
  }
1277
+ function runDoctor(dir, json) {
1278
+ const res = load2(dir);
1279
+ const diags = validateSemantics2(res);
1280
+ const errors = diags.filter((d) => d.severity === "error");
1281
+ if (!res.ok || errors.length > 0) {
1282
+ console.error(`archstone doctor ${dir}: manifest invalid \u2014 run 'archstone apply ${dir}' for details`);
1283
+ process.exit(1);
1284
+ }
1285
+ const ir = compile2(res);
1286
+ const stripped = { ...ir, tools: ir.tools.map(({ contract: _contract, ...t }) => t) };
1287
+ const report = diagnose(ir, dir, { builtIr: `${JSON.stringify(stripped, null, 2)}
1288
+ ` });
1289
+ console.log(json ? JSON.stringify(report, null, 2) : formatReport2(report, dir));
1290
+ process.exit(report.ok ? 0 : 1);
1291
+ }
821
1292
  async function main() {
822
1293
  const argv = process.argv.slice(2);
823
1294
  if (argv.includes("--version") || argv.includes("-V")) {
@@ -830,6 +1301,7 @@ async function main() {
830
1301
  }
831
1302
  const json = argv.includes("--json");
832
1303
  const http = argv.includes("--http");
1304
+ const sandbox = argv.includes("--sandbox");
833
1305
  const out = flagArg(argv, "--out");
834
1306
  const port = flagArg(argv, "--port");
835
1307
  const token = flagArg(argv, "--token");
@@ -840,7 +1312,7 @@ async function main() {
840
1312
  consumed.add(f.idx + 1);
841
1313
  }
842
1314
  }
843
- const positional = argv.filter((a, i) => !consumed.has(i) && a !== "--json" && a !== "--http");
1315
+ const positional = argv.filter((a, i) => !consumed.has(i) && a !== "--json" && a !== "--http" && a !== "--sandbox");
844
1316
  const [cmd, dir] = positional;
845
1317
  if (cmd === "apply" && dir) {
846
1318
  runApply(dir);
@@ -855,13 +1327,20 @@ async function main() {
855
1327
  return;
856
1328
  }
857
1329
  if (cmd === "verify" && dir) {
858
- await runVerifyCmd(dir, json);
1330
+ await runVerifyCmd(dir, json, sandbox);
859
1331
  return;
860
1332
  }
861
1333
  if (cmd === "build" && dir) {
862
1334
  runBuild(dir, out.value);
863
1335
  return;
864
1336
  }
1337
+ if (cmd === "doctor" && dir) {
1338
+ runDoctor(dir, json);
1339
+ return;
1340
+ }
1341
+ if (cmd === "adopt") {
1342
+ process.exit(await runAdoptCmd(argv));
1343
+ }
865
1344
  if (cmd === "audit") {
866
1345
  process.exit(runAuditCmd(argv));
867
1346
  }