mirai-graph 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const {
10
+ canonicalBytes,
11
+ compileTechnologyCourse,
12
+ reconcileTechnologyCourse,
13
+ verifyTechnologyCourse,
14
+ } = require("../project-technology/technology-course");
15
+
16
+ const root = path.resolve(__dirname, "../..");
17
+ const fixture = JSON.parse(fs.readFileSync(path.join(root, "examples/executable-technology-course/technology.json"), "utf8"));
18
+
19
+ function compile(options = {}) {
20
+ return compileTechnologyCourse(root, { technology: fixture, audience: "staff", ...options });
21
+ }
22
+
23
+ const full = compile();
24
+ assert.equal(full.status, "success");
25
+ assert.deepEqual(full.course_pack.scenario_ids, ["scenario.full_release", "scenario.recovery_preparation"]);
26
+ assert.deepEqual(full.course_pack.sections.map((item) => item.technology_node_id), [
27
+ "operation.inventory", "operation.backup", "operation.validate", "operation.promote",
28
+ ]);
29
+
30
+ const partial = compile({ scenarioIds: ["scenario.recovery_preparation"] });
31
+ assert.equal(partial.status, "success");
32
+ assert.deepEqual(partial.course_pack.sections.map((item) => item.technology_node_id), ["operation.inventory", "operation.backup"]);
33
+ assert.equal(partial.course_pack.course_pack_digest, compile({ scenarioIds: ["scenario.recovery_preparation"] }).course_pack.course_pack_digest);
34
+ assert.equal(verifyTechnologyCourse(root, { coursePack: partial.course_pack }).status, "success");
35
+ assert.equal(verifyTechnologyCourse(root, { coursePack: partial }).status, "success");
36
+
37
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "mirai-course-"));
38
+ const technologyFile = path.join(temp, "technology.json");
39
+ fs.writeFileSync(technologyFile, canonicalBytes(fixture));
40
+ const fromFile = compileTechnologyCourse(root, { technologyFile, scenarioIds: ["scenario.recovery_preparation"], audience: "staff" });
41
+ assert.equal(fromFile.course_pack.course_pack_digest, partial.course_pack.course_pack_digest);
42
+
43
+ const editorial = JSON.parse(JSON.stringify(partial.course_pack));
44
+ editorial.sections[0].title = "Understand the current state";
45
+ const editorialResult = reconcileTechnologyCourse(root, { coursePack: partial.course_pack, projection: editorial });
46
+ assert.equal(editorialResult.status, "success");
47
+ assert.equal(editorialResult.editorial_changes.length, 1);
48
+ assert.equal(editorialResult.canonical_write_allowed, false);
49
+
50
+ const semantic = JSON.parse(JSON.stringify(partial.course_pack));
51
+ semantic.sections[1].check_refs = [];
52
+ const semanticResult = reconcileTechnologyCourse(root, { coursePack: partial.course_pack, projection: semantic });
53
+ assert.equal(semanticResult.status, "needs_decision");
54
+ assert.equal(semanticResult.semantic_proposals.length, 1);
55
+ assert.equal(semanticResult.canonical_write_allowed, false);
56
+
57
+ const stale = JSON.parse(JSON.stringify(partial.course_pack));
58
+ stale.course_pack_digest = `sha256:${"0".repeat(64)}`;
59
+ assert.equal(reconcileTechnologyCourse(root, { coursePack: partial.course_pack, projection: stale }).status, "blocked");
60
+
61
+ for (const mutate of [
62
+ (item) => { item.operations[1].prerequisites = ["operation.unknown"]; },
63
+ (item) => { item.operations[0].prerequisites = ["operation.backup"]; },
64
+ (item) => { item.operations.push(JSON.parse(JSON.stringify(item.operations[0]))); },
65
+ (item) => { item.scenarios[0].operation_ids = ["operation.unknown"]; },
66
+ ]) {
67
+ const invalid = JSON.parse(JSON.stringify(fixture)); mutate(invalid);
68
+ assert.equal(compileTechnologyCourse(root, { technology: invalid }).status, "blocked");
69
+ }
70
+
71
+ const tampered = JSON.parse(JSON.stringify(partial.course_pack));
72
+ tampered.sections[0].summary = "Tampered";
73
+ assert.equal(verifyTechnologyCourse(root, { coursePack: tampered }).status, "blocked");
74
+
75
+ const secret = JSON.parse(JSON.stringify(partial.course_pack));
76
+ secret.sections[0].token = "forbidden";
77
+ assert.equal(verifyTechnologyCourse(root, { coursePack: secret }).status, "blocked");
78
+
79
+ assert.deepEqual(fs.readdirSync(temp).sort(), ["technology.json"]);
80
+ fs.rmSync(temp, { recursive: true, force: true });
81
+ console.log("technology course validation: PASS");
@@ -16,6 +16,7 @@ const {
16
16
  const traversal = require("./context-traversal");
17
17
  const continuity = require("./continuity");
18
18
  const artifacts = require("./artifact-release");
19
+ const technologyCourse = require("./technology-course");
19
20
 
20
21
  const CONTRACT_VERSION = "1.0.0";
21
22
  const EXTENSION_KEY = "mirai.project_technology";
@@ -35,7 +36,7 @@ const TARGET_EXPORT_KEYS = new Set([
35
36
  "schema_version", "target_id", "semantic_digest", "provider_revision",
36
37
  "decision_refs", "goal_binding", "requirement_bindings", "constraint_ids",
37
38
  "non_goal_ids", "deferred_boundary_ids", "allowed_change_scope",
38
- "architecture_contract", "execution_contract_digest",
39
+ "architecture_contract", "execution_contract_digest", "provider_graph_id",
39
40
  ]);
40
41
  const SECRET_PARTS = [".env", "credential", "secret", "token", "password", "private-key", "id_rsa", ".pem", ".p12"];
41
42
  const EXCLUDED_PARTS = new Set([".git", ".mirai-graph", ".simai", "node_modules", "vendor", "dist", "build", "coverage", "generated"]);
@@ -424,6 +425,7 @@ function readExport(filePath) {
424
425
  try { payload = JSON.parse(bytes.toString("utf8").replace(/^\uFEFF/, "")); } catch (_) { return { export: {}, blockers: ["provider_export_invalid"] }; }
425
426
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { export: {}, blockers: ["provider_export_invalid"] };
426
427
  if (Object.keys(payload).some((key) => !TARGET_EXPORT_KEYS.has(key))) blockers.push("provider_export_not_bounded");
428
+ if (Object.hasOwn(payload, "provider_graph_id") && (typeof payload.provider_graph_id !== "string" || !REF_RE.test(payload.provider_graph_id))) blockers.push("provider_graph_id_invalid");
427
429
  const identity = bindingValues(payload); blockers.push(...identity.blockers);
428
430
  const executionFields = [
429
431
  "decision_refs", "goal_binding", "requirement_bindings", "constraint_ids",
@@ -434,7 +436,7 @@ function readExport(filePath) {
434
436
  const digest = sha256(canonicalBytes(normalized.contract));
435
437
  if (!payload.execution_contract_digest) blockers.push("provider_execution_contract_digest_missing");
436
438
  else if (payload.execution_contract_digest !== digest) blockers.push("provider_execution_contract_digest_mismatch");
437
- return { export: { schema_version: "1.0.0", ...identity.values, ...normalized.contract, execution_contract_digest: digest }, blockers: [...new Set(blockers)].sort(), raw_sha256: sha256(bytes) };
439
+ return { export: { schema_version: "1.0.0", ...identity.values, ...normalized.contract, execution_contract_digest: digest, ...(payload.provider_graph_id ? { provider_graph_id: payload.provider_graph_id } : {}) }, blockers: [...new Set(blockers)].sort(), raw_sha256: sha256(bytes) };
438
440
  }
439
441
 
440
442
  function trackedFiles(repo) {
@@ -452,7 +454,42 @@ function safeTrackedFile(relative) {
452
454
 
453
455
  function inventory(repo) {
454
456
  const entries = [];
455
- for (const relative of trackedFiles(repo).filter(safeTrackedFile).sort()) {
457
+ const blockers = [];
458
+ let files = trackedFiles(repo);
459
+ const revision = git(repo, "rev-parse", "HEAD") || null;
460
+ if (!revision) {
461
+ if (fs.existsSync(path.join(repo, ".git"))) blockers.push("inventory_git_unavailable");
462
+ else {
463
+ // Ordinary folders and immutable distributions have no Git index. Use
464
+ // only explicitly declared graph/raw sources, never scan arbitrary data.
465
+ const graph = readManifest(repo).manifest?.graph || {};
466
+ const refs = ["graph.json", ...(graph.source_of_truth || []), ...(graph.objects || []),
467
+ ...(graph.relations || []), ...(graph.schemas || []), ...(graph.raw_sources || [])];
468
+ const found = new Set();
469
+ const visited = new Set();
470
+ function collect(relative) {
471
+ if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.includes("\\") || relative.includes(":") || relative.split("/").includes("..") || /[?*\[\]]/.test(relative)) {
472
+ blockers.push("inventory_declared_source_unsafe_or_unsupported"); return;
473
+ }
474
+ if (!safeTrackedFile(relative)) return;
475
+ if (visited.has(relative)) return;
476
+ visited.add(relative);
477
+ if (visited.size > 10000) { blockers.push("inventory_declared_source_budget_exceeded"); return; }
478
+ const absolute = path.join(repo, relative);
479
+ if (!fs.existsSync(absolute)) { blockers.push("inventory_declared_source_missing"); return; }
480
+ if (fs.lstatSync(absolute).isSymbolicLink() || !fs.realpathSync(absolute).startsWith(`${fs.realpathSync(repo)}${path.sep}`)) {
481
+ blockers.push("inventory_declared_source_unsafe_or_unsupported"); return;
482
+ }
483
+ if (fs.statSync(absolute).isDirectory()) {
484
+ for (const name of fs.readdirSync(absolute).sort()) collect(`${relative.replace(/\/$/, "")}/${name}`);
485
+ } else if (fs.statSync(absolute).isFile()) found.add(relative);
486
+ else blockers.push("inventory_declared_source_unsafe_or_unsupported");
487
+ }
488
+ refs.forEach(collect);
489
+ files = [...found];
490
+ }
491
+ }
492
+ for (const relative of files.filter(safeTrackedFile).sort()) {
456
493
  const absolute = path.join(repo, relative);
457
494
  if (!fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink() || !fs.statSync(absolute).isFile()) continue;
458
495
  const bytes = fs.readFileSync(absolute);
@@ -461,10 +498,11 @@ function inventory(repo) {
461
498
  const payload = {
462
499
  schema_version: "1.0.0",
463
500
  repository_id: readManifest(repo).manifest?.id || path.basename(repo),
464
- revision: git(repo, "rev-parse", "HEAD") || null,
501
+ revision,
465
502
  files: entries,
466
503
  };
467
504
  payload.inventory_digest = sha256(canonicalBytes(payload), true);
505
+ if (blockers.length) payload.blockers = [...new Set(blockers)].sort();
468
506
  return payload;
469
507
  }
470
508
 
@@ -520,6 +558,7 @@ function targetBindingStatus(repo, options = {}) {
520
558
  provider_export_sha256: binding.provider_export_sha256 || null,
521
559
  execution_contract: Object.fromEntries(["decision_refs", "goal_binding", "requirement_bindings", "constraint_ids", "non_goal_ids", "deferred_boundary_ids", "allowed_change_scope", "architecture_contract"].map((key) => [key, exported.export[key]])),
522
560
  execution_contract_digest: exported.export.execution_contract_digest || null,
561
+ provider_graph_id: exported.export.provider_graph_id || null,
523
562
  blockers: [...new Set(blockers)].sort(),
524
563
  next_action: blockers.length ? "refresh or repair the exact provider binding" : "none",
525
564
  };
@@ -534,7 +573,7 @@ function status(repoArg, options = {}) {
534
573
  try { if (fs.existsSync(path.join(root, INVENTORY_FILE))) stored = readJson(path.join(root, INVENTORY_FILE)); } catch (_) { /* stale */ }
535
574
  const current = inventory(repo);
536
575
  const freshness = stored && stored.inventory_digest === current.inventory_digest ? "current" : stored ? "stale" : "missing";
537
- const blockers = [...manifestState.blockers, ...ext.blockers];
576
+ const blockers = [...manifestState.blockers, ...ext.blockers, ...(current.blockers || [])];
538
577
  if (!stored && legacyRuntimeState(repo).present) blockers.push("project_technology_host_state_migration_required");
539
578
  if (ext.contract && ext.contract.enabled === false) blockers.push("project_technology_disabled");
540
579
  if (freshness !== "current") blockers.push(`project_technology_inventory_${freshness}`);
@@ -579,6 +618,8 @@ function enable(repoArg, options = {}) {
579
618
  const repo = normalizeRepo(repoArg);
580
619
  const manifestState = readManifest(repo);
581
620
  if (!manifestState.manifest || manifestState.blockers.length) return result("enable", "transactional", "fail", { blockers: manifestState.blockers, next_action: "repair graph.json" });
621
+ const inventoryPreflight = inventory(repo);
622
+ if (inventoryPreflight.blockers?.length) return result("enable", "transactional", "blocked", { blockers: inventoryPreflight.blockers, next_action: "repair declared graph sources" });
582
623
  const manifest = structuredClone(manifestState.manifest);
583
624
  const extensions = { ...(manifest.extensions || {}) };
584
625
  const legacy = extensions[LEGACY_EXTENSION_KEY];
@@ -616,7 +657,7 @@ function sync(repoArg, options = {}) {
616
657
  const repo = normalizeRepo(repoArg);
617
658
  const manifestState = readManifest(repo);
618
659
  const ext = extensionState(manifestState.manifest);
619
- const blockers = [...manifestState.blockers, ...ext.blockers];
660
+ const blockers = [...manifestState.blockers, ...ext.blockers, ...(inventory(repo).blockers || [])];
620
661
  const hostRoot = runtimeRoot(repo, manifestState.manifest, options);
621
662
  if (legacyRuntimeState(repo).present && !fs.existsSync(path.join(hostRoot, INVENTORY_FILE))) blockers.push("project_technology_host_state_migration_required");
622
663
  if (!ext.contract || ext.legacy || ext.contract.enabled !== true) blockers.push("project_technology_not_enabled");
@@ -660,7 +701,7 @@ function provide(repoArg, options = {}) {
660
701
  blockers.push(...target.blockers);
661
702
  if (blockers.length) return result("provide", "transactional", "fail", { blockers: [...new Set(blockers)].sort(), next_action: "repair the accepted target contract" });
662
703
  const executionContractDigest = sha256(canonicalBytes(target.contract));
663
- const payload = { schema_version: "1.0.0", ...identity.values, ...target.contract, execution_contract_digest: executionContractDigest };
704
+ const payload = { schema_version: "1.0.0", ...identity.values, ...target.contract, execution_contract_digest: executionContractDigest, provider_graph_id: manifestState.manifest.id };
664
705
  const changed = atomicWrite(path.join(repo, EXPORT_FILE), canonicalBytes(payload));
665
706
  return result("provide", "transactional", "success", { changed, export_ref: EXPORT_FILE, target_binding: payload });
666
707
  }
@@ -670,6 +711,32 @@ function providerRootFor(exportPath) {
670
711
  return completed.status === 0 ? completed.stdout.trim() : null;
671
712
  }
672
713
 
714
+ // This is explicit consumer trust, NOT an assertion read from the provider.
715
+ // The caller must obtain it from authenticated, checksum-bound release metadata.
716
+ // Keeping it out of the export prevents an archive from authenticating itself.
717
+ function archiveProviderIdentity(source, exported, anchor) {
718
+ const fail = code => ({ blockers: [code], ancestors: [] });
719
+ if (!anchor || typeof anchor !== "object" || Array.isArray(anchor)) return fail("provider_archive_trust_invalid");
720
+ const keys = ["exportSha256", "graphId", "providerRevision", "ancestorRevisions"];
721
+ if (Object.keys(anchor).some(key => !keys.includes(key)) || keys.some(key => !Object.hasOwn(anchor, key))) return fail("provider_archive_trust_invalid");
722
+ if (!/^[0-9a-f]{64}$/.test(anchor.exportSha256 || "") ||
723
+ typeof anchor.graphId !== "string" || !REF_RE.test(anchor.graphId) ||
724
+ !REVISION_RE.test(anchor.providerRevision || "") ||
725
+ !Array.isArray(anchor.ancestorRevisions) || anchor.ancestorRevisions.length > 4096 ||
726
+ anchor.ancestorRevisions.some(rev => typeof rev !== "string" || !REVISION_RE.test(rev) || rev === anchor.providerRevision) ||
727
+ new Set(anchor.ancestorRevisions).size !== anchor.ancestorRevisions.length) return fail("provider_archive_trust_invalid");
728
+ try {
729
+ // Parent aliases such as macOS /var are allowed; the exact bytes are pinned.
730
+ if (fs.lstatSync(source).isSymbolicLink()) return fail("provider_archive_source_unsafe");
731
+ if (!fs.statSync(source).isFile() || fs.statSync(source).size > 1024 * 1024) return fail("provider_archive_source_unsafe");
732
+ const bytes = fs.readFileSync(source);
733
+ if (sha256(bytes) !== anchor.exportSha256 || canonicalBytes(JSON.parse(bytes)) !== canonicalBytes(exported)) return fail("provider_archive_export_digest_mismatch");
734
+ } catch (_) { return fail("provider_archive_source_unsafe"); }
735
+ if (exported.provider_graph_id !== anchor.graphId) return fail("provider_archive_graph_mismatch");
736
+ if (exported.provider_revision !== anchor.providerRevision) return fail("provider_archive_revision_mismatch");
737
+ return { blockers: [], ancestors: anchor.ancestorRevisions };
738
+ }
739
+
673
740
  function connect(repoArg, options = {}) {
674
741
  const repo = normalizeRepo(repoArg);
675
742
  const source = path.resolve(String(options.source || ""));
@@ -680,14 +747,20 @@ function connect(repoArg, options = {}) {
680
747
  const blockers = [...identity.blockers, ...read.blockers, ...manifestState.blockers, ...ext.blockers];
681
748
  if (!ext.contract || ext.legacy || ext.contract.enabled !== true) blockers.push("project_technology_not_enabled");
682
749
  for (const key of Object.keys(identity.values)) if (read.export[key] !== identity.values[key]) blockers.push(`${key}_mismatch`);
683
- const providerRoot = providerRootFor(source);
684
- if (!providerRoot) blockers.push("provider_revision_order_unverifiable");
750
+ const archiveRequested = Object.hasOwn(options, "providerArchive");
751
+ const archive = archiveRequested ? archiveProviderIdentity(source, read.export, options.providerArchive) : null;
752
+ const providerRoot = archiveRequested ? null : providerRootFor(source);
753
+ if (archive) blockers.push(...archive.blockers);
754
+ else if (!providerRoot) blockers.push("provider_revision_order_unverifiable");
685
755
  else if (git(providerRoot, "rev-parse", "HEAD").toLowerCase() !== identity.values.provider_revision) blockers.push("provider_revision_does_not_match_head");
686
756
  const root = runtimeRoot(repo, manifestState.manifest, options);
687
757
  const currentPath = path.join(root, BINDING_FILE);
688
758
  let current = null;
689
759
  try { if (fs.existsSync(currentPath)) current = readJson(currentPath); } catch (_) { blockers.push("current_target_binding_invalid"); }
690
760
  if (current) {
761
+ const currentExport = targetBindingStatus(repo, options);
762
+ const existingGraphId = currentExport.provider_graph_id;
763
+ if (existingGraphId && existingGraphId !== read.export.provider_graph_id) blockers.push("target_provider_graph_conflict");
691
764
  if (!options.refreshBinding) {
692
765
  const same = Object.keys(identity.values).every((key) => current[key] === identity.values[key]);
693
766
  if (!same) blockers.push("target_provider_conflict", "target_provider_refresh_required");
@@ -702,7 +775,9 @@ function connect(repoArg, options = {}) {
702
775
  const currentState = targetBindingStatus(repo, options);
703
776
  if (currentState.status !== "ready") blockers.push(...currentState.blockers);
704
777
  if (currentState.execution_contract_digest !== read.export.execution_contract_digest) blockers.push("provider_execution_contract_refresh_mismatch");
705
- if (!providerRoot) blockers.push("provider_revision_order_unverifiable");
778
+ if (archive) {
779
+ if (current.provider_revision !== identity.values.provider_revision && !archive.ancestors.includes(current.provider_revision)) blockers.push("provider_revision_not_forward");
780
+ } else if (!providerRoot) blockers.push("provider_revision_order_unverifiable");
706
781
  else {
707
782
  if (current.provider_revision !== identity.values.provider_revision) {
708
783
  const ancestry = spawnSync("git", ["-C", providerRoot, "merge-base", "--is-ancestor", current.provider_revision, identity.values.provider_revision]);
@@ -767,7 +842,40 @@ function repair(repoArg, options = {}) {
767
842
  return sync(repo, options);
768
843
  }
769
844
 
845
+ function verifyProviderExport(repoArg, options = {}) {
846
+ const repo = normalizeRepo(repoArg);
847
+ const source = path.resolve(options.source || path.join(repo, EXPORT_FILE));
848
+ const exported = readExport(source);
849
+ const manifestState = readManifest(repo);
850
+ const ext = extensionState(manifestState.manifest);
851
+ const identity = bindingValues(exported.export);
852
+ const blockers = [...exported.blockers, ...manifestState.blockers, ...ext.blockers, ...identity.blockers];
853
+ if (options.significantWork) blockers.push("provider_export_verification_is_not_execution_authority");
854
+ if (!trackedFiles(repo).includes("graph.json") || spawnSync("git", ["diff", "--quiet", "HEAD", "--", "graph.json"], { cwd: repo }).status !== 0) blockers.push("provider_manifest_not_revision_bound");
855
+ if (!source.startsWith(`${repo}${path.sep}`)) blockers.push("provider_export_outside_repository");
856
+ if (ext.contract?.enabled !== true || ext.legacy) blockers.push("project_technology_not_enabled");
857
+ const head = git(repo, "rev-parse", "HEAD");
858
+ if (head !== identity.values.provider_revision) blockers.push("provider_revision_does_not_match_head");
859
+ if (exported.export.provider_graph_id !== manifestState.manifest?.id) blockers.push("provider_archive_graph_mismatch");
860
+ const target = targetContract(repo, identity.values.target_id, identity.values.semantic_digest, manifestState.manifest);
861
+ blockers.push(...target.blockers);
862
+ if (sha256(canonicalBytes(target.contract)) !== exported.export.execution_contract_digest) blockers.push("provider_execution_contract_source_mismatch");
863
+ // Bound supported ancestry; deeper histories need a narrower supported
864
+ // release window rather than unbounded metadata in every consumer.
865
+ const history = git(repo, "rev-list", "--max-count=4098", "HEAD").split("\n").filter(Boolean);
866
+ if (history[0] !== head || history.length > 4097) blockers.push("provider_archive_ancestry_unverifiable_or_too_large");
867
+ return result("verify", "read_only", blockers.length ? "blocked" : "success", {
868
+ blockers: [...new Set(blockers)].sort(),
869
+ provider_archive: blockers.length ? null : {
870
+ exportSha256: exported.raw_sha256, graphId: manifestState.manifest.id,
871
+ providerRevision: head, ancestorRevisions: history.slice(1),
872
+ },
873
+ next_action: blockers.length ? "repair the canonical provider export before packaging" : "seal this anchor in authenticated release metadata",
874
+ });
875
+ }
876
+
770
877
  function verify(repoArg, options = {}) {
878
+ if (options.source) return verifyProviderExport(repoArg, options);
771
879
  const state = status(repoArg, options);
772
880
  const blockers = [...state.blockers];
773
881
  const repo = normalizeRepo(repoArg);
@@ -790,6 +898,7 @@ function verify(repoArg, options = {}) {
790
898
 
791
899
  function execute(operation, repoArg, options = {}) {
792
900
  if (operation === "artifact") return artifacts.executeArtifact(repoArg, options);
901
+ if (operation === "course") return technologyCourse.executeCourse(repoArg, options);
793
902
  const readOnly = { explain, status, plan, verify, context };
794
903
  if (readOnly[operation]) return readOnly[operation](repoArg, options);
795
904
  const transactional = { enable, sync, connect, disconnect, provide, disable, repair };
@@ -807,6 +916,7 @@ module.exports = {
807
916
  compareArtifactReleases: artifacts.compareArtifactReleases,
808
917
  connect,
809
918
  compileContext: traversal.compileContext,
919
+ compileTechnologyCourse: technologyCourse.compileTechnologyCourse,
810
920
  context,
811
921
  createArtifactRelease: artifacts.createArtifactRelease,
812
922
  discoverContext: traversal.discoverContext,
@@ -820,16 +930,20 @@ module.exports = {
820
930
  inventory,
821
931
  inspectArtifactBundle: artifacts.inspectArtifactBundle,
822
932
  normalizeExecutionContract,
933
+ normalizeTechnology: technologyCourse.normalizeTechnology,
823
934
  continuity,
824
935
  plan,
825
936
  provide,
826
937
  readExport,
938
+ reconcileTechnologyCourse: technologyCourse.reconcileTechnologyCourse,
827
939
  repair,
828
940
  sha256,
829
941
  status,
830
942
  sync,
831
943
  targetBindingStatus,
832
944
  verify,
945
+ verifyProviderExport,
833
946
  verifyArtifactRelease: artifacts.verifyArtifactRelease,
947
+ verifyTechnologyCourse: technologyCourse.verifyTechnologyCourse,
834
948
  verifyContext: traversal.verifyContext,
835
949
  };
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/@?=+*-]{1,255}$/;
8
+ const ACTIVE_LIFECYCLES = new Set(["reviewed", "accepted", "active"]);
9
+ const SECRET_MARKERS = ["password", "secret", "token", "cookie", "private_key", "totp", ".env"];
10
+
11
+ function sortValue(value) {
12
+ if (Array.isArray(value)) return value.map(sortValue);
13
+ if (!value || typeof value !== "object") return value;
14
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
15
+ }
16
+
17
+ function canonicalBytes(value) {
18
+ return `${JSON.stringify(sortValue(value), null, 2)}\n`;
19
+ }
20
+
21
+ function sha256(value, prefix = true) {
22
+ const digest = crypto.createHash("sha256").update(Buffer.from(String(value))).digest("hex");
23
+ return prefix ? `sha256:${digest}` : digest;
24
+ }
25
+
26
+ function result(action, status, extra = {}) {
27
+ return {
28
+ schema_version: "1.0.0",
29
+ operation_id: `mirai.project_technology.course.${action}`,
30
+ operation_mode: "read_only",
31
+ status,
32
+ changed: false,
33
+ blockers: [],
34
+ warnings: [],
35
+ next_action: "none",
36
+ ...extra,
37
+ };
38
+ }
39
+
40
+ function readJson(file) {
41
+ const absolute = path.resolve(file);
42
+ if (!fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink()) throw new Error("unsafe_or_missing_json_input");
43
+ return JSON.parse(fs.readFileSync(absolute, "utf8").replace(/^\uFEFF/, ""));
44
+ }
45
+
46
+ function unwrapCoursePack(value) {
47
+ return value && typeof value === "object" && !Array.isArray(value) && value.course_pack
48
+ ? value.course_pack
49
+ : value;
50
+ }
51
+
52
+ function uniqueStrings(value, field, blockers, required = true) {
53
+ if (!Array.isArray(value) || (required && value.length === 0)) {
54
+ blockers.push(`technology_${field}_empty`);
55
+ return [];
56
+ }
57
+ const output = [];
58
+ for (const item of value) {
59
+ if (typeof item !== "string" || !SAFE_ID.test(item)) blockers.push(`technology_${field}_unsafe`);
60
+ else output.push(item);
61
+ }
62
+ return [...new Set(output)];
63
+ }
64
+
65
+ function normalizeTechnology(input) {
66
+ const blockers = [];
67
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { technology: {}, blockers: ["technology_contract_missing"] };
68
+ const technology = {
69
+ schema_version: String(input.schema_version || "1.0.0"),
70
+ id: String(input.id || ""),
71
+ title: String(input.title || ""),
72
+ owner: String(input.owner || ""),
73
+ outcome: String(input.outcome || ""),
74
+ lifecycle: String(input.lifecycle || ""),
75
+ version: String(input.version || ""),
76
+ source_refs: uniqueStrings(input.source_refs || [], "source_refs", blockers),
77
+ projection_refs: uniqueStrings(input.projection_refs || [], "projection_refs", blockers, false),
78
+ };
79
+ for (const field of ["id", "owner"]) if (!SAFE_ID.test(technology[field])) blockers.push(`technology_${field}_missing_or_unsafe`);
80
+ for (const field of ["title", "outcome", "version"]) if (!technology[field].trim()) blockers.push(`technology_${field}_missing`);
81
+ if (!ACTIVE_LIFECYCLES.has(technology.lifecycle)) blockers.push("technology_lifecycle_not_executable");
82
+
83
+ const operationIds = new Set();
84
+ technology.operations = [];
85
+ if (!Array.isArray(input.operations) || input.operations.length === 0) blockers.push("technology_operations_empty");
86
+ else for (const raw of input.operations) {
87
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) { blockers.push("technology_operation_invalid"); continue; }
88
+ const operation = {
89
+ id: String(raw.id || ""), title: String(raw.title || ""), summary: String(raw.summary || ""),
90
+ owner: String(raw.owner || ""), capability_ref: String(raw.capability_ref || ""),
91
+ prerequisites: uniqueStrings(raw.prerequisites || [], "operation_prerequisites", blockers, false),
92
+ input_refs: uniqueStrings(raw.input_refs || [], "operation_input_refs", blockers, false),
93
+ output_refs: uniqueStrings(raw.output_refs || [], "operation_output_refs", blockers),
94
+ check_refs: uniqueStrings(raw.check_refs || [], "operation_check_refs", blockers),
95
+ stop_condition_refs: uniqueStrings(raw.stop_condition_refs || [], "operation_stop_condition_refs", blockers, false),
96
+ rollback_refs: uniqueStrings(raw.rollback_refs || [], "operation_rollback_refs", blockers, false),
97
+ source_refs: uniqueStrings(raw.source_refs || [], "operation_source_refs", blockers),
98
+ instructional_refs: uniqueStrings(raw.instructional_refs || [], "operation_instructional_refs", blockers, false),
99
+ applicability: Array.isArray(raw.applicability) ? [...new Set(raw.applicability.map(String))].sort() : [],
100
+ negative_boundaries: Array.isArray(raw.negative_boundaries) ? [...new Set(raw.negative_boundaries.map(String))].sort() : [],
101
+ };
102
+ for (const field of ["id", "owner", "capability_ref"]) if (!SAFE_ID.test(operation[field])) blockers.push(`technology_operation_${field}_missing_or_unsafe`);
103
+ if (!operation.title || !operation.summary) blockers.push("technology_operation_explanation_missing");
104
+ if (operationIds.has(operation.id)) blockers.push("technology_operation_duplicate");
105
+ operationIds.add(operation.id); technology.operations.push(operation);
106
+ }
107
+
108
+ technology.scenarios = [];
109
+ if (!Array.isArray(input.scenarios) || input.scenarios.length === 0) blockers.push("technology_scenarios_empty");
110
+ else {
111
+ const scenarioIds = new Set();
112
+ for (const raw of input.scenarios) {
113
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) { blockers.push("technology_scenario_invalid"); continue; }
114
+ const scenario = {
115
+ id: String(raw.id || ""), title: String(raw.title || ""), outcome: String(raw.outcome || ""),
116
+ operation_ids: uniqueStrings(raw.operation_ids || [], "scenario_operation_ids", blockers),
117
+ required_inputs: uniqueStrings(raw.required_inputs || [], "scenario_required_inputs", blockers, false),
118
+ audience: Array.isArray(raw.audience) ? [...new Set(raw.audience.map(String))].sort() : [],
119
+ };
120
+ if (!SAFE_ID.test(scenario.id)) blockers.push("technology_scenario_id_missing_or_unsafe");
121
+ if (!scenario.title || !scenario.outcome) blockers.push("technology_scenario_explanation_missing");
122
+ if (scenarioIds.has(scenario.id)) blockers.push("technology_scenario_duplicate");
123
+ scenarioIds.add(scenario.id);
124
+ for (const id of scenario.operation_ids) if (!operationIds.has(id)) blockers.push("technology_scenario_operation_unknown");
125
+ technology.scenarios.push(scenario);
126
+ }
127
+ }
128
+ for (const operation of technology.operations) for (const dependency of operation.prerequisites) if (!operationIds.has(dependency)) blockers.push("technology_operation_prerequisite_unknown");
129
+
130
+ const visiting = new Set(); const visited = new Set(); const byId = new Map(technology.operations.map((item) => [item.id, item]));
131
+ function visit(id) {
132
+ if (visiting.has(id)) return true;
133
+ if (visited.has(id)) return false;
134
+ visiting.add(id);
135
+ for (const dependency of (byId.get(id) || {}).prerequisites || []) if (visit(dependency)) return true;
136
+ visiting.delete(id); visited.add(id); return false;
137
+ }
138
+ if ([...byId.keys()].some(visit)) blockers.push("technology_required_dependency_cycle");
139
+ return { technology, blockers: [...new Set(blockers)].sort() };
140
+ }
141
+
142
+ function technologyDigest(technology) {
143
+ return sha256(canonicalBytes(technology));
144
+ }
145
+
146
+ function resolveClosure(operations, selectedIds, blockers) {
147
+ const byId = new Map(operations.map((item) => [item.id, item]));
148
+ const output = []; const seen = new Set();
149
+ function add(id) {
150
+ if (seen.has(id)) return;
151
+ const operation = byId.get(id);
152
+ if (!operation) { blockers.push("course_required_operation_missing"); return; }
153
+ for (const dependency of operation.prerequisites) add(dependency);
154
+ seen.add(id); output.push(operation);
155
+ }
156
+ for (const id of selectedIds) add(id);
157
+ return output;
158
+ }
159
+
160
+ function compileTechnologyCourse(repository, options = {}) {
161
+ let raw;
162
+ try { raw = options.technology || readJson(options.technologyFile); }
163
+ catch (error) { return result("compile", "blocked", { blockers: [String(error.message || error)], next_action: "provide a valid executable technology contract" }); }
164
+ const normalized = normalizeTechnology(raw);
165
+ const blockers = [...normalized.blockers];
166
+ if (blockers.length) return result("compile", "blocked", { blockers, next_action: "repair the executable technology contract" });
167
+ const scenarioIds = options.scenarioIds && options.scenarioIds.length ? options.scenarioIds : normalized.technology.scenarios.map((item) => item.id);
168
+ const scenarios = normalized.technology.scenarios.filter((item) => scenarioIds.includes(item.id));
169
+ if (scenarios.length !== new Set(scenarioIds).size) blockers.push("course_scenario_unknown");
170
+ const selectedOperationIds = scenarios.flatMap((item) => item.operation_ids);
171
+ const operations = resolveClosure(normalized.technology.operations, selectedOperationIds, blockers);
172
+ const audience = String(options.audience || "learner");
173
+ if (!SAFE_ID.test(audience)) blockers.push("course_audience_unsafe");
174
+ if (blockers.length) return result("compile", "blocked", { blockers: [...new Set(blockers)].sort(), next_action: "repair the technology or course selection" });
175
+ const technology_digest = technologyDigest(normalized.technology);
176
+ const packBase = {
177
+ schema_version: "1.0.0",
178
+ technology_id: normalized.technology.id,
179
+ technology_title: normalized.technology.title,
180
+ technology_outcome: normalized.technology.outcome,
181
+ technology_version: normalized.technology.version,
182
+ technology_digest,
183
+ audience,
184
+ scenario_ids: scenarios.map((item) => item.id),
185
+ source_refs: normalized.technology.source_refs,
186
+ source_revisions: options.sourceRevisions || {},
187
+ scenarios,
188
+ sections: operations.map((item, index) => ({
189
+ order: index + 1,
190
+ technology_node_id: item.id,
191
+ title: item.title,
192
+ summary: item.summary,
193
+ owner: item.owner,
194
+ capability_ref: item.capability_ref,
195
+ prerequisite_ids: item.prerequisites,
196
+ input_refs: item.input_refs,
197
+ output_refs: item.output_refs,
198
+ check_refs: item.check_refs,
199
+ stop_condition_refs: item.stop_condition_refs,
200
+ rollback_refs: item.rollback_refs,
201
+ source_refs: item.source_refs,
202
+ instructional_refs: item.instructional_refs,
203
+ })),
204
+ exercises: options.exercises || [],
205
+ checks: [...new Set(operations.flatMap((item) => item.check_refs))].sort(),
206
+ omissions: [],
207
+ limitations: [],
208
+ };
209
+ const context = { ...packBase, course_pack_digest: sha256(canonicalBytes(packBase)) };
210
+ return result("compile", "success", { repository: path.resolve(repository || "."), course_pack: context });
211
+ }
212
+
213
+ function verifyTechnologyCourse(repository, options = {}) {
214
+ let pack;
215
+ try { pack = unwrapCoursePack(options.coursePack || readJson(options.coursePackFile)); }
216
+ catch (error) { return result("verify", "blocked", { blockers: [String(error.message || error)] }); }
217
+ const blockers = [];
218
+ const digest = pack.course_pack_digest;
219
+ const base = { ...pack }; delete base.course_pack_digest;
220
+ if (digest !== sha256(canonicalBytes(base))) blockers.push("course_pack_digest_mismatch");
221
+ if (!Array.isArray(pack.sections) || pack.sections.length === 0) blockers.push("course_sections_empty");
222
+ const ids = new Set();
223
+ for (const section of pack.sections || []) {
224
+ if (!SAFE_ID.test(String(section.technology_node_id || ""))) blockers.push("course_section_identity_missing");
225
+ if (ids.has(section.technology_node_id)) blockers.push("course_section_duplicate");
226
+ ids.add(section.technology_node_id);
227
+ if (!section.title || !section.summary || !section.owner || !section.capability_ref) blockers.push("course_section_incomplete");
228
+ }
229
+ const serialized = JSON.stringify(pack).toLowerCase();
230
+ for (const marker of SECRET_MARKERS) if (serialized.includes(`\"${marker}\":`)) blockers.push("course_pack_secret_field_forbidden");
231
+ return result("verify", blockers.length ? "blocked" : "success", { blockers: [...new Set(blockers)].sort(), course_pack_digest: digest, next_action: blockers.length ? "recompile the course from current accepted technology" : "none" });
232
+ }
233
+
234
+ function reconcileTechnologyCourse(repository, options = {}) {
235
+ let pack; let projection;
236
+ try {
237
+ pack = unwrapCoursePack(options.coursePack || readJson(options.coursePackFile));
238
+ projection = options.projection || readJson(options.projectionFile);
239
+ } catch (error) { return result("reconcile", "blocked", { blockers: [String(error.message || error)] }); }
240
+ const verified = verifyTechnologyCourse(repository, { coursePack: pack });
241
+ if (verified.status !== "success") return result("reconcile", "blocked", { blockers: verified.blockers, next_action: verified.next_action });
242
+ if (projection.course_pack_digest !== pack.course_pack_digest) return result("reconcile", "blocked", { blockers: ["course_projection_source_stale"], next_action: "re-export the projection or explicitly reconcile against its source pack" });
243
+ const base = new Map(pack.sections.map((item) => [item.technology_node_id, item]));
244
+ const seen = new Set(); const changes = [];
245
+ for (const current of projection.sections || []) {
246
+ const id = current.technology_node_id;
247
+ if (!base.has(id)) { changes.push({ type: "semantic_proposal", technology_node_id: id || null, reason: "course_section_added" }); continue; }
248
+ seen.add(id); const previous = base.get(id);
249
+ const semanticFields = ["owner", "capability_ref", "prerequisite_ids", "input_refs", "output_refs", "check_refs", "stop_condition_refs", "rollback_refs", "source_refs"];
250
+ const semantic = semanticFields.some((field) => canonicalBytes(previous[field] || null) !== canonicalBytes(current[field] || null));
251
+ const editorial = previous.title !== current.title || previous.summary !== current.summary || canonicalBytes(previous.instructional_refs || []) !== canonicalBytes(current.instructional_refs || []);
252
+ if (semantic) changes.push({ type: "semantic_proposal", technology_node_id: id, reason: "executable_contract_changed" });
253
+ else if (editorial) changes.push({ type: "editorial", technology_node_id: id, reason: "instructional_projection_changed" });
254
+ }
255
+ for (const id of base.keys()) if (!seen.has(id)) changes.push({ type: "semantic_proposal", technology_node_id: id, reason: "required_course_section_removed" });
256
+ const semantic = changes.filter((item) => item.type === "semantic_proposal");
257
+ return result("reconcile", semantic.length ? "needs_decision" : "success", {
258
+ changes,
259
+ semantic_proposals: semantic,
260
+ editorial_changes: changes.filter((item) => item.type === "editorial"),
261
+ canonical_write_allowed: false,
262
+ next_action: semantic.length ? "send semantic proposals to the technology owners" : changes.length ? "apply editorial changes through the documentation owner" : "none",
263
+ });
264
+ }
265
+
266
+ function executeCourse(repository, options = {}) {
267
+ const action = options.courseAction;
268
+ if (action === "compile") return compileTechnologyCourse(repository, options);
269
+ if (action === "verify") return verifyTechnologyCourse(repository, options);
270
+ if (action === "reconcile") return reconcileTechnologyCourse(repository, options);
271
+ return result(String(action || "unknown"), "fail", { blockers: ["unsupported_technology_course_action"] });
272
+ }
273
+
274
+ module.exports = {
275
+ canonicalBytes,
276
+ compileTechnologyCourse,
277
+ executeCourse,
278
+ normalizeTechnology,
279
+ reconcileTechnologyCourse,
280
+ technologyDigest,
281
+ verifyTechnologyCourse,
282
+ };