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.
- package/CHANGELOG.md +38 -0
- package/CITATION.cff +2 -2
- package/README.md +17 -0
- package/examples/executable-technology-course/README.md +16 -0
- package/examples/executable-technology-course/technology.json +99 -0
- package/package.json +41 -40
- package/packages/cli/context-pack.js +0 -0
- package/packages/cli/mirai-graph.js +1 -1
- package/packages/cli/mirai_graph.js +0 -0
- package/packages/cli/project-technology.js +21 -1
- package/packages/cli/readiness-score.js +0 -0
- package/packages/cli/seed-preview.js +0 -0
- package/packages/cli/validate-mirai-graph.js +0 -0
- package/packages/cli/validate-profile-results.js +0 -0
- package/packages/cli/validate-provider-archive.js +165 -0
- package/packages/cli/validate-technology-course.js +81 -0
- package/packages/project-technology/index.js +124 -10
- package/packages/project-technology/technology-course.js +282 -0
- package/releases/1.5.0.md +37 -0
- package/releases/README.md +2 -0
- package/schemas/executable-technology.schema.json +70 -0
- package/schemas/project-technology-target-export.schema.json +1 -0
- package/schemas/technology-course-pack.schema.json +57 -0
- package/standard/project-technology.md +83 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
684
|
-
|
|
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 (
|
|
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
|
+
};
|