@topogram/cli 0.3.40 → 0.3.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.40",
3
+ "version": "0.3.42",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  getTemplateTrustStatus,
33
33
  implementationRequiresTrust,
34
34
  TEMPLATE_TRUST_FILE,
35
+ templateTrustRecoveryGuidance,
35
36
  validateProjectImplementationTrust,
36
37
  writeTemplateTrustRecord
37
38
  } from "./template-trust.js";
@@ -43,6 +44,17 @@ import {
43
44
  loadPackageGeneratorManifest,
44
45
  packageGeneratorInstallCommand
45
46
  } from "./generator/registry.js";
47
+ import {
48
+ defaultGeneratorPolicy,
49
+ GENERATOR_POLICY_FILE,
50
+ generatorPackageAllowed,
51
+ generatorPolicyDiagnosticsForBindings,
52
+ loadGeneratorPolicy,
53
+ packageBackedGeneratorBindings,
54
+ packageScopeFromName,
55
+ parseGeneratorPolicyPin,
56
+ writeGeneratorPolicy
57
+ } from "./generator-policy.js";
46
58
  import {
47
59
  buildAuthHintsQueryPayload,
48
60
  buildAuthReviewPacketPayload,
@@ -206,6 +218,10 @@ function printUsage(options = {}) {
206
218
  console.log(" or: topogram generator list [--json]");
207
219
  console.log(" or: topogram generator show <id-or-package> [--json]");
208
220
  console.log(" or: topogram generator check <path-or-package> [--json]");
221
+ console.log(" or: topogram generator policy init [path] [--json]");
222
+ console.log(" or: topogram generator policy check [path] [--json]");
223
+ console.log(" or: topogram generator policy explain [path] [--json]");
224
+ console.log(" or: topogram generator policy pin [package@version] [path] [--json]");
209
225
  console.log(" or: topogram new <path> [--template hello-web|todo|./local-template|@scope/template]");
210
226
  console.log(" or: topogram new --list-templates [--json] [--catalog <path-or-source>]");
211
227
  console.log("");
@@ -227,6 +243,7 @@ function printUsage(options = {}) {
227
243
  console.log(" topogram generator list");
228
244
  console.log(" topogram generator show @topogram/generator-react-web");
229
245
  console.log(" topogram generator check ./generator-package");
246
+ console.log(" topogram generator policy check");
230
247
  console.log(" topogram generate");
231
248
  console.log(" topogram import ./existing-app --out ./imported-topogram");
232
249
  console.log(" topogram import diff ./imported-topogram");
@@ -568,6 +585,10 @@ function printGeneratorHelp() {
568
585
  console.log("Usage: topogram generator list [--json]");
569
586
  console.log(" or: topogram generator show <id-or-package> [--json]");
570
587
  console.log(" or: topogram generator check <path-or-package> [--json]");
588
+ console.log(" or: topogram generator policy init [path] [--json]");
589
+ console.log(" or: topogram generator policy check [path] [--json]");
590
+ console.log(" or: topogram generator policy explain [path] [--json]");
591
+ console.log(" or: topogram generator policy pin [package@version] [path] [--json]");
571
592
  console.log("");
572
593
  console.log("Inspects generator manifests and checks generator pack conformance.");
573
594
  console.log("");
@@ -576,6 +597,7 @@ function printGeneratorHelp() {
576
597
  console.log(" - show accepts an installed package name or a bundled fallback generator id.");
577
598
  console.log(" - check validates a local generator package path or an already installed package.");
578
599
  console.log(" - Topogram does not install generator packages during show or check.");
600
+ console.log(` - package-backed project generators are governed by ${GENERATOR_POLICY_FILE}; bundled topogram/* generators are allowed.`);
579
601
  console.log("");
580
602
  console.log("Examples:");
581
603
  console.log(" topogram generator list");
@@ -584,6 +606,9 @@ function printGeneratorHelp() {
584
606
  console.log(" topogram generator show @scope/topogram-generator-web --json");
585
607
  console.log(" topogram generator check ./generator-package");
586
608
  console.log(" topogram generator check @scope/topogram-generator-web --json");
609
+ console.log(" topogram generator policy init");
610
+ console.log(" topogram generator policy check --json");
611
+ console.log(" topogram generator policy pin @topogram/generator-react-web@1");
587
612
  }
588
613
 
589
614
  function printTemplateHelp() {
@@ -788,7 +813,7 @@ function printImportHelp() {
788
813
  function printCheckHelp() {
789
814
  console.log("Usage: topogram check [path] [--json]");
790
815
  console.log("");
791
- console.log("Validates Topogram files, project configuration, topology, generator compatibility, output ownership, and template policy.");
816
+ console.log("Validates Topogram files, project configuration, topology, generator compatibility, generator policy, output ownership, and template policy.");
792
817
  console.log("");
793
818
  console.log("Defaults: path is ./topogram.");
794
819
  console.log("");
@@ -1523,6 +1548,341 @@ function printGeneratorShow(payload) {
1523
1548
  console.log(stableStringify(payload.exampleTopologyBinding));
1524
1549
  }
1525
1550
 
1551
+ /**
1552
+ * @param {string} name
1553
+ * @param {boolean} ok
1554
+ * @param {string} actual
1555
+ * @param {string} expected
1556
+ * @param {string} message
1557
+ * @param {string|null} fix
1558
+ * @returns {{ name: string, ok: boolean, actual: string, expected: string, message: string, fix: string|null }}
1559
+ */
1560
+ function generatorPolicyRule(name, ok, actual, expected, message, fix = null) {
1561
+ return { name, ok, actual, expected, message, fix };
1562
+ }
1563
+
1564
+ /**
1565
+ * @param {string} name
1566
+ * @returns {string}
1567
+ */
1568
+ function generatorPolicyRuleLabel(name) {
1569
+ return ({
1570
+ "policy-file": "Policy file",
1571
+ "allowed-package": "Allowed package",
1572
+ "pinned-version": "Pinned version"
1573
+ })[name] || name;
1574
+ }
1575
+
1576
+ /**
1577
+ * @param {import("./generator-policy.js").GeneratorPolicyInfo} policyInfo
1578
+ * @returns {import("./generator-policy.js").GeneratorPolicy}
1579
+ */
1580
+ function effectiveGeneratorPolicy(policyInfo) {
1581
+ return policyInfo.policy || {
1582
+ version: "0.1",
1583
+ allowedPackageScopes: ["@topogram"],
1584
+ allowedPackages: [],
1585
+ pinnedVersions: {}
1586
+ };
1587
+ }
1588
+
1589
+ /**
1590
+ * @param {string} projectPath
1591
+ * @returns {{ ok: boolean, path: string, exists: boolean, policy: any, defaulted: boolean, bindings: ReturnType<typeof packageBackedGeneratorBindings>, diagnostics: any[], errors: string[] }}
1592
+ */
1593
+ function buildGeneratorPolicyCheckPayload(projectPath) {
1594
+ const projectConfigInfo = loadProjectConfig(projectPath);
1595
+ if (!projectConfigInfo) {
1596
+ const diagnostic = {
1597
+ code: "generator_policy_project_missing",
1598
+ severity: "error",
1599
+ message: "Cannot check generator policy without topogram.project.json.",
1600
+ path: path.resolve(projectPath),
1601
+ suggestedFix: "Run this command in a Topogram project.",
1602
+ step: "generator-policy"
1603
+ };
1604
+ return {
1605
+ ok: false,
1606
+ path: path.join(path.resolve(projectPath), GENERATOR_POLICY_FILE),
1607
+ exists: false,
1608
+ policy: null,
1609
+ defaulted: false,
1610
+ bindings: [],
1611
+ diagnostics: [diagnostic],
1612
+ errors: [diagnostic.message]
1613
+ };
1614
+ }
1615
+ const policyInfo = loadGeneratorPolicy(projectConfigInfo.configDir);
1616
+ const bindings = packageBackedGeneratorBindings(projectConfigInfo.config);
1617
+ const diagnostics = [];
1618
+ if (!policyInfo.exists) {
1619
+ diagnostics.push({
1620
+ code: "generator_policy_missing",
1621
+ severity: "warning",
1622
+ message: `No ${GENERATOR_POLICY_FILE} found. Default generator policy allows @topogram/* package-backed generators and blocks other package scopes.`,
1623
+ path: policyInfo.path,
1624
+ suggestedFix: "Run `topogram generator policy init` to write an explicit project generator policy after review.",
1625
+ step: "generator-policy"
1626
+ });
1627
+ }
1628
+ diagnostics.push(...generatorPolicyDiagnosticsForBindings(policyInfo, bindings, "generator-policy"));
1629
+ const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
1630
+ return {
1631
+ ok: errors.length === 0,
1632
+ path: policyInfo.path,
1633
+ exists: policyInfo.exists,
1634
+ policy: policyInfo.policy || effectiveGeneratorPolicy(policyInfo),
1635
+ defaulted: !policyInfo.exists,
1636
+ bindings,
1637
+ diagnostics,
1638
+ errors
1639
+ };
1640
+ }
1641
+
1642
+ /**
1643
+ * @param {string} projectPath
1644
+ * @returns {ReturnType<typeof buildGeneratorPolicyCheckPayload> & { rules: Array<{ name: string, ok: boolean, actual: string, expected: string, message: string, fix: string|null }> }}
1645
+ */
1646
+ function buildGeneratorPolicyExplainPayload(projectPath) {
1647
+ const check = buildGeneratorPolicyCheckPayload(projectPath);
1648
+ const policy = check.policy || effectiveGeneratorPolicy({ path: check.path, exists: false, policy: null, diagnostics: [] });
1649
+ const rules = [];
1650
+ rules.push(generatorPolicyRule(
1651
+ "policy-file",
1652
+ check.exists,
1653
+ check.exists ? "present" : "missing",
1654
+ "present",
1655
+ check.exists
1656
+ ? "Project has a generator policy file."
1657
+ : "Project is using the default generator policy.",
1658
+ check.exists ? null : "Run `topogram generator policy init` after review."
1659
+ ));
1660
+ for (const binding of check.bindings) {
1661
+ const scope = packageScopeFromName(binding.packageName);
1662
+ rules.push(generatorPolicyRule(
1663
+ "allowed-package",
1664
+ generatorPackageAllowed(policy, binding.packageName),
1665
+ `${binding.packageName}${scope ? ` (${scope})` : ""}`,
1666
+ [
1667
+ `scopes=${policy.allowedPackageScopes.join(", ") || "(none)"}`,
1668
+ `packages=${policy.allowedPackages.join(", ") || "(none)"}`
1669
+ ].join("; "),
1670
+ `Component '${binding.componentId}' package-backed generator must be from an allowed package or scope.`,
1671
+ `Run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after reviewing the generator package.`
1672
+ ));
1673
+ const pinnedVersion = policy.pinnedVersions[binding.packageName] || policy.pinnedVersions[binding.generatorId] || null;
1674
+ rules.push(generatorPolicyRule(
1675
+ "pinned-version",
1676
+ !pinnedVersion || pinnedVersion === binding.version,
1677
+ binding.version,
1678
+ pinnedVersion || "(unpinned)",
1679
+ `Component '${binding.componentId}' generator version must match its policy pin when one exists.`,
1680
+ `Run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after review.`
1681
+ ));
1682
+ }
1683
+ return {
1684
+ ...check,
1685
+ rules
1686
+ };
1687
+ }
1688
+
1689
+ /**
1690
+ * @param {ReturnType<typeof buildGeneratorPolicyCheckPayload>} payload
1691
+ * @returns {void}
1692
+ */
1693
+ function printGeneratorPolicyCheckPayload(payload) {
1694
+ console.log(payload.ok ? "Generator policy check passed" : "Generator policy check failed");
1695
+ console.log(`Policy: ${payload.path}`);
1696
+ console.log(`Exists: ${payload.exists ? "yes" : "no"}`);
1697
+ console.log(`Defaulted: ${payload.defaulted ? "yes" : "no"}`);
1698
+ console.log(`Package-backed generators: ${payload.bindings.length}`);
1699
+ for (const binding of payload.bindings) {
1700
+ console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
1701
+ }
1702
+ for (const diagnostic of payload.diagnostics) {
1703
+ console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
1704
+ if (diagnostic.path) {
1705
+ console.log(` path: ${diagnostic.path}`);
1706
+ }
1707
+ if (diagnostic.suggestedFix) {
1708
+ console.log(` fix: ${diagnostic.suggestedFix}`);
1709
+ }
1710
+ }
1711
+ }
1712
+
1713
+ /**
1714
+ * @param {ReturnType<typeof buildGeneratorPolicyExplainPayload>} payload
1715
+ * @returns {void}
1716
+ */
1717
+ function printGeneratorPolicyExplainPayload(payload) {
1718
+ console.log(payload.ok ? "Generator policy: allowed" : "Generator policy: denied");
1719
+ console.log(payload.ok
1720
+ ? "Decision: package-backed generators are allowed by this project's generator policy."
1721
+ : "Decision: one or more package-backed generators are blocked by this project's generator policy.");
1722
+ console.log(`Policy file: ${payload.path}`);
1723
+ console.log(`Policy file exists: ${payload.exists ? "yes" : "no"}`);
1724
+ console.log(`Default policy active: ${payload.defaulted ? "yes" : "no"}`);
1725
+ if (payload.bindings.length > 0) {
1726
+ console.log("");
1727
+ console.log("Package-backed generators:");
1728
+ for (const binding of payload.bindings) {
1729
+ console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
1730
+ }
1731
+ }
1732
+ if (payload.rules.length > 0) {
1733
+ console.log("");
1734
+ console.log("Policy checks:");
1735
+ }
1736
+ for (const rule of payload.rules) {
1737
+ console.log(`${rule.ok ? "PASS" : "FAIL"} ${generatorPolicyRuleLabel(rule.name)}: ${rule.message}`);
1738
+ console.log(` actual: ${rule.actual}`);
1739
+ console.log(` expected: ${rule.expected}`);
1740
+ if (!rule.ok && rule.fix) {
1741
+ console.log(` fix: ${rule.fix}`);
1742
+ }
1743
+ }
1744
+ for (const diagnostic of payload.diagnostics) {
1745
+ const label = diagnostic.severity === "warning" ? "Warning" : "Error";
1746
+ console.log(`${label}: ${diagnostic.code}: ${diagnostic.message}`);
1747
+ if (diagnostic.suggestedFix) {
1748
+ console.log(` fix: ${diagnostic.suggestedFix}`);
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ /**
1754
+ * @param {string} projectPath
1755
+ * @param {string|null|undefined} spec
1756
+ * @returns {{ ok: boolean, path: string, policy: any, pinned: Array<{ packageName: string, version: string }>, diagnostics: any[], errors: string[] }}
1757
+ */
1758
+ function buildGeneratorPolicyPinPayload(projectPath, spec) {
1759
+ const projectConfigInfo = loadProjectConfig(projectPath);
1760
+ if (!projectConfigInfo) {
1761
+ const diagnostic = {
1762
+ code: "generator_policy_project_missing",
1763
+ severity: "error",
1764
+ message: "Cannot pin generator policy without topogram.project.json.",
1765
+ path: path.resolve(projectPath),
1766
+ suggestedFix: "Run this command in a Topogram project.",
1767
+ step: "generator-policy"
1768
+ };
1769
+ return {
1770
+ ok: false,
1771
+ path: path.join(path.resolve(projectPath), GENERATOR_POLICY_FILE),
1772
+ policy: null,
1773
+ pinned: [],
1774
+ diagnostics: [diagnostic],
1775
+ errors: [diagnostic.message]
1776
+ };
1777
+ }
1778
+ const policyInfo = loadGeneratorPolicy(projectConfigInfo.configDir);
1779
+ if (policyInfo.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
1780
+ const errors = policyInfo.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
1781
+ return {
1782
+ ok: false,
1783
+ path: policyInfo.path,
1784
+ policy: policyInfo.policy,
1785
+ pinned: [],
1786
+ diagnostics: policyInfo.diagnostics,
1787
+ errors
1788
+ };
1789
+ }
1790
+ let pins = [];
1791
+ try {
1792
+ pins = spec
1793
+ ? [parseGeneratorPolicyPin(spec)]
1794
+ : packageBackedGeneratorBindings(projectConfigInfo.config).map((binding) => ({
1795
+ packageName: binding.packageName,
1796
+ version: binding.version
1797
+ }));
1798
+ } catch (error) {
1799
+ const diagnostic = {
1800
+ code: "generator_policy_pin_invalid",
1801
+ severity: "error",
1802
+ message: error instanceof Error ? error.message : String(error),
1803
+ path: policyInfo.path,
1804
+ suggestedFix: "Pass a pin such as @topogram/generator-react-web@1.",
1805
+ step: "generator-policy"
1806
+ };
1807
+ return {
1808
+ ok: false,
1809
+ path: policyInfo.path,
1810
+ policy: policyInfo.policy,
1811
+ pinned: [],
1812
+ diagnostics: [diagnostic],
1813
+ errors: [diagnostic.message]
1814
+ };
1815
+ }
1816
+ if (pins.length === 0) {
1817
+ const diagnostic = {
1818
+ code: "generator_policy_pin_no_generators",
1819
+ severity: "error",
1820
+ message: "No package-backed topology generator bindings are available to pin.",
1821
+ path: projectConfigInfo.configPath,
1822
+ suggestedFix: "Pass an explicit pin such as @topogram/generator-react-web@1, or use bundled generators.",
1823
+ step: "generator-policy"
1824
+ };
1825
+ return {
1826
+ ok: false,
1827
+ path: policyInfo.path,
1828
+ policy: policyInfo.policy,
1829
+ pinned: [],
1830
+ diagnostics: [diagnostic],
1831
+ errors: [diagnostic.message]
1832
+ };
1833
+ }
1834
+ const policy = policyInfo.policy || defaultGeneratorPolicy();
1835
+ const allowedPackages = [...policy.allowedPackages];
1836
+ const allowedPackageScopes = [...policy.allowedPackageScopes];
1837
+ const pinnedVersions = { ...policy.pinnedVersions };
1838
+ for (const pin of pins) {
1839
+ if (!allowedPackages.includes(pin.packageName)) {
1840
+ allowedPackages.push(pin.packageName);
1841
+ }
1842
+ const scope = packageScopeFromName(pin.packageName);
1843
+ if (scope && !allowedPackageScopes.includes(scope)) {
1844
+ allowedPackageScopes.push(scope);
1845
+ }
1846
+ pinnedVersions[pin.packageName] = pin.version;
1847
+ }
1848
+ const nextPolicy = {
1849
+ ...policy,
1850
+ allowedPackageScopes,
1851
+ allowedPackages,
1852
+ pinnedVersions
1853
+ };
1854
+ writeGeneratorPolicy(projectConfigInfo.configDir, nextPolicy);
1855
+ return {
1856
+ ok: true,
1857
+ path: path.join(projectConfigInfo.configDir, GENERATOR_POLICY_FILE),
1858
+ policy: nextPolicy,
1859
+ pinned: pins,
1860
+ diagnostics: [],
1861
+ errors: []
1862
+ };
1863
+ }
1864
+
1865
+ /**
1866
+ * @param {{ ok: boolean, path: string, pinned: Array<{ packageName: string, version: string }>, diagnostics: any[] }} payload
1867
+ * @returns {void}
1868
+ */
1869
+ function printGeneratorPolicyPinPayload(payload) {
1870
+ console.log(payload.ok ? "Generator policy pin updated" : "Generator policy pin failed");
1871
+ console.log(`Policy: ${payload.path}`);
1872
+ for (const pin of payload.pinned) {
1873
+ console.log(`Pinned: ${pin.packageName}@${pin.version}`);
1874
+ }
1875
+ for (const diagnostic of payload.diagnostics) {
1876
+ console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
1877
+ if (diagnostic.path) {
1878
+ console.log(` path: ${diagnostic.path}`);
1879
+ }
1880
+ if (diagnostic.suggestedFix) {
1881
+ console.log(` fix: ${diagnostic.suggestedFix}`);
1882
+ }
1883
+ }
1884
+ }
1885
+
1526
1886
  function combineProjectValidationResults(...results) {
1527
1887
  const errors = [];
1528
1888
  for (const result of results) {
@@ -3339,6 +3699,7 @@ function printNewProjectResult(result, cwd) {
3339
3699
  }
3340
3700
  console.log(`Executable implementation: ${template.includesExecutableImplementation ? "yes" : "no"}`);
3341
3701
  console.log("Policy: topogram.template-policy.json");
3702
+ console.log(`Generator policy: ${GENERATOR_POLICY_FILE}`);
3342
3703
  console.log("Template files: .topogram-template-files.json");
3343
3704
  if (template.includesExecutableImplementation) {
3344
3705
  console.log("Trust: .topogram-template-trust.json");
@@ -3354,6 +3715,7 @@ function printNewProjectResult(result, cwd) {
3354
3715
  console.log(" npm run source:status");
3355
3716
  console.log(" npm run template:explain");
3356
3717
  console.log(" npm run check");
3718
+ console.log(" npm run generator:policy:check");
3357
3719
  if (template.includesExecutableImplementation) {
3358
3720
  console.log(" npm run template:policy:explain");
3359
3721
  console.log(" npm run trust:status");
@@ -5951,6 +6313,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
5951
6313
  step
5952
6314
  });
5953
6315
  }
6316
+ if (message.includes("unsupported symlink")) {
6317
+ return templateCheckDiagnostic({
6318
+ code: "template_symlink_unsupported",
6319
+ message,
6320
+ path: path.isAbsolute(templateSpec) ? templateSpec : null,
6321
+ suggestedFix: "Replace template symlinks with real files or directories, then rerun `topogram new` or `topogram template check`.",
6322
+ step
6323
+ });
6324
+ }
5954
6325
  return templateCheckDiagnostic({
5955
6326
  code: "template_create_failed",
5956
6327
  message,
@@ -5968,13 +6339,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
5968
6339
  */
5969
6340
  function diagnosticForStarterCheckFailure(error, step, configPath) {
5970
6341
  const locFile = typeof error?.loc?.file === "string" ? error.loc.file : null;
5971
- const isTrust = error.message.includes(TEMPLATE_TRUST_FILE);
6342
+ const isTrust = error.message.includes(TEMPLATE_TRUST_FILE) ||
6343
+ error.message.includes("unsupported symlink") ||
6344
+ error.message.includes("must be under implementation/");
5972
6345
  return templateCheckDiagnostic({
5973
6346
  code: isTrust ? "template_trust_invalid" : "starter_check_failed",
5974
6347
  message: error.message,
5975
6348
  path: locFile || configPath,
5976
6349
  suggestedFix: isTrust
5977
- ? "Review implementation/ and run topogram trust template in the generated starter."
6350
+ ? templateTrustRecoveryGuidance(error.message)
5978
6351
  : "Fix the generated Topogram source or topogram.project.json so topogram check passes.",
5979
6352
  step
5980
6353
  });
@@ -6176,7 +6549,7 @@ function buildTemplateCheckPayload(templateSpec) {
6176
6549
  code: "template_trust_invalid",
6177
6550
  message: issue,
6178
6551
  path: trustStatus.trustPath,
6179
- suggestedFix: "Review implementation/ and run topogram trust template in the generated starter.",
6552
+ suggestedFix: templateTrustRecoveryGuidance(issue),
6180
6553
  step: "executable-implementation-trust"
6181
6554
  }));
6182
6555
  steps.push(templateCheckStep("executable-implementation-trust", trustStatus.ok, {
@@ -6844,6 +7217,14 @@ if (args[0] === "version" || args[0] === "--version") {
6844
7217
  commandArgs = { generatorShow: true, inputPath: args[2] };
6845
7218
  } else if (args[0] === "generator" && args[1] === "check") {
6846
7219
  commandArgs = { generatorCheck: true, inputPath: args[2] };
7220
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "init") {
7221
+ commandArgs = { generatorPolicyInit: true, inputPath: commandPath(3) };
7222
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "check") {
7223
+ commandArgs = { generatorPolicyCheck: true, inputPath: commandPath(3) };
7224
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "explain") {
7225
+ commandArgs = { generatorPolicyExplain: true, inputPath: commandPath(3) };
7226
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "pin") {
7227
+ commandArgs = { generatorPolicyPin: true, generatorPolicyPinSpec: args[3] && !args[3].startsWith("-") ? args[3] : null, inputPath: commandPath(4) };
6847
7228
  } else if (args[0] === "generator") {
6848
7229
  printGeneratorHelp();
6849
7230
  process.exit(args[1] ? 1 : 0);
@@ -7040,6 +7421,10 @@ const shouldComponentBehavior = Boolean(commandArgs?.componentBehavior);
7040
7421
  const shouldGeneratorList = Boolean(commandArgs?.generatorList);
7041
7422
  const shouldGeneratorShow = Boolean(commandArgs?.generatorShow);
7042
7423
  const shouldGeneratorCheck = Boolean(commandArgs?.generatorCheck);
7424
+ const shouldGeneratorPolicyInit = Boolean(commandArgs?.generatorPolicyInit);
7425
+ const shouldGeneratorPolicyCheck = Boolean(commandArgs?.generatorPolicyCheck);
7426
+ const shouldGeneratorPolicyExplain = Boolean(commandArgs?.generatorPolicyExplain);
7427
+ const shouldGeneratorPolicyPin = Boolean(commandArgs?.generatorPolicyPin);
7043
7428
  const shouldTrustTemplate = Boolean(commandArgs?.trustTemplate);
7044
7429
  const shouldTrustStatus = Boolean(commandArgs?.trustStatus);
7045
7430
  const shouldTrustDiff = Boolean(commandArgs?.trustDiff);
@@ -7173,7 +7558,7 @@ const outIndex = args.indexOf("--out");
7173
7558
  const outPath = outIndex >= 0 ? args[outIndex + 1] : null;
7174
7559
  const effectiveOutDir = outDir || outPath || commandArgs?.defaultOutDir || null;
7175
7560
 
7176
- if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldGeneratorCheck || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldSourceStatus || shouldTemplateExplain || shouldTemplateStatus || shouldTemplateDetach || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateCheck || shouldTemplateUpdate || generateTarget === "app-bundle") && !inputPath) {
7561
+ if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldGeneratorCheck || shouldGeneratorPolicyInit || shouldGeneratorPolicyCheck || shouldGeneratorPolicyExplain || shouldGeneratorPolicyPin || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldSourceStatus || shouldTemplateExplain || shouldTemplateStatus || shouldTemplateDetach || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateCheck || shouldTemplateUpdate || generateTarget === "app-bundle") && !inputPath) {
7177
7562
  console.error("Missing required <path>.");
7178
7563
  printUsage();
7179
7564
  process.exit(1);
@@ -7227,7 +7612,7 @@ if (shouldQueryShow && !commandArgs?.queryShowName) {
7227
7612
  process.exit(1);
7228
7613
  }
7229
7614
 
7230
- if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldTemplateExplain || shouldTemplateStatus || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateUpdate || generateTarget === "app-bundle") && inputPath) {
7615
+ if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldValidate || shouldGeneratorPolicyInit || shouldGeneratorPolicyCheck || shouldGeneratorPolicyExplain || shouldGeneratorPolicyPin || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldTemplateExplain || shouldTemplateStatus || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateUpdate || generateTarget === "app-bundle") && inputPath) {
7231
7616
  inputPath = normalizeTopogramPath(inputPath);
7232
7617
  }
7233
7618
 
@@ -7354,6 +7739,59 @@ try {
7354
7739
  process.exit(payload.ok ? 0 : 1);
7355
7740
  }
7356
7741
 
7742
+ if (shouldGeneratorPolicyInit) {
7743
+ const projectConfigInfo = loadProjectConfig(inputPath);
7744
+ if (!projectConfigInfo) {
7745
+ throw new Error("Cannot initialize generator policy without topogram.project.json.");
7746
+ }
7747
+ const policy = writeGeneratorPolicy(projectConfigInfo.configDir, defaultGeneratorPolicy());
7748
+ const payload = {
7749
+ ok: true,
7750
+ path: path.join(projectConfigInfo.configDir, GENERATOR_POLICY_FILE),
7751
+ policy,
7752
+ diagnostics: [],
7753
+ errors: []
7754
+ };
7755
+ if (emitJson) {
7756
+ console.log(stableStringify(payload));
7757
+ } else {
7758
+ console.log(`Wrote generator policy: ${payload.path}`);
7759
+ console.log(`Allowed package scopes: ${policy.allowedPackageScopes.join(", ") || "(none)"}`);
7760
+ console.log(`Allowed packages: ${policy.allowedPackages.join(", ") || "(none)"}`);
7761
+ }
7762
+ process.exit(0);
7763
+ }
7764
+
7765
+ if (shouldGeneratorPolicyCheck) {
7766
+ const payload = buildGeneratorPolicyCheckPayload(inputPath);
7767
+ if (emitJson) {
7768
+ console.log(stableStringify(payload));
7769
+ } else {
7770
+ printGeneratorPolicyCheckPayload(payload);
7771
+ }
7772
+ process.exit(payload.ok ? 0 : 1);
7773
+ }
7774
+
7775
+ if (shouldGeneratorPolicyExplain) {
7776
+ const payload = buildGeneratorPolicyExplainPayload(inputPath);
7777
+ if (emitJson) {
7778
+ console.log(stableStringify(payload));
7779
+ } else {
7780
+ printGeneratorPolicyExplainPayload(payload);
7781
+ }
7782
+ process.exit(payload.ok ? 0 : 1);
7783
+ }
7784
+
7785
+ if (shouldGeneratorPolicyPin) {
7786
+ const payload = buildGeneratorPolicyPinPayload(inputPath, commandArgs?.generatorPolicyPinSpec);
7787
+ if (emitJson) {
7788
+ console.log(stableStringify(payload));
7789
+ } else {
7790
+ printGeneratorPolicyPinPayload(payload);
7791
+ }
7792
+ process.exit(payload.ok ? 0 : 1);
7793
+ }
7794
+
7357
7795
  if (shouldCatalogList) {
7358
7796
  const payload = buildCatalogListPayload(catalogSource || inputPath || null);
7359
7797
  if (emitJson) {
@@ -7853,7 +8291,10 @@ try {
7853
8291
  console.log(`Removed: ${filePath}`);
7854
8292
  }
7855
8293
  if (!status.ok) {
7856
- console.log("Run `topogram trust diff` to review implementation changes, then `topogram trust template` to trust the current files.");
8294
+ const guidance = templateTrustRecoveryGuidance(status.issues);
8295
+ if (guidance) {
8296
+ console.log(guidance);
8297
+ }
7857
8298
  }
7858
8299
  }
7859
8300
  process.exit(status.ok ? 0 : 1);
@@ -7882,6 +8323,12 @@ try {
7882
8323
  for (const issue of diff.status.issues) {
7883
8324
  console.log(`Issue: ${issue}`);
7884
8325
  }
8326
+ if (!diff.ok) {
8327
+ const guidance = templateTrustRecoveryGuidance(diff.status.issues);
8328
+ if (guidance) {
8329
+ console.log(guidance);
8330
+ }
8331
+ }
7885
8332
  } else {
7886
8333
  console.log(diff.ok ? "Template trust diff: no implementation changes." : "Template trust diff: review required");
7887
8334
  for (const file of diff.files) {
@@ -7906,7 +8353,10 @@ try {
7906
8353
  }
7907
8354
  if (!diff.ok) {
7908
8355
  console.log("");
7909
- console.log("After review, run `topogram trust template` to trust the current files.");
8356
+ const guidance = templateTrustRecoveryGuidance(diff.status.issues);
8357
+ if (guidance) {
8358
+ console.log(guidance);
8359
+ }
7910
8360
  }
7911
8361
  }
7912
8362
  process.exit(diff.ok ? 0 : 1);
@@ -0,0 +1,12 @@
1
+ export const GENERATOR_POLICY_FILE: string;
2
+ export function defaultGeneratorPolicy(): any;
3
+ export function validateGeneratorPolicy(value: unknown, policyPath: string): any;
4
+ export function packageScopeFromName(packageName: string): string | null;
5
+ export function generatorPackageAllowed(policy: any, packageName: string): boolean;
6
+ export function packageBackedGeneratorBindings(projectConfig: any): any[];
7
+ export function loadGeneratorPolicy(projectRoot: string): any;
8
+ export function writeGeneratorPolicy(projectRoot: string, policy: any): any;
9
+ export function generatorPolicyDiagnosticsForBindings(policyInfo: any, bindings: any[], step?: string): any[];
10
+ export function generatorPolicyDiagnosticsForProject(projectConfig: any, projectRoot: string, step?: string): any[];
11
+ export function validateProjectGeneratorPolicy(projectConfig: any, options?: { configDir?: string | null; rootDir?: string | null }): { ok: boolean; errors: Array<{ message: string; loc: null }> };
12
+ export function parseGeneratorPolicyPin(spec: string): { packageName: string; version: string };
@@ -0,0 +1,344 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import { stableStringify } from "./format.js";
7
+
8
+ export const GENERATOR_POLICY_FILE = "topogram.generator-policy.json";
9
+
10
+ /**
11
+ * @typedef {Object} GeneratorPolicy
12
+ * @property {string} version
13
+ * @property {string[]} allowedPackageScopes
14
+ * @property {string[]} allowedPackages
15
+ * @property {Record<string, string>} pinnedVersions
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} GeneratorPolicyInfo
20
+ * @property {string} path
21
+ * @property {GeneratorPolicy|null} policy
22
+ * @property {boolean} exists
23
+ * @property {GeneratorPolicyDiagnostic[]} diagnostics
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} GeneratorPolicyDiagnostic
28
+ * @property {string} code
29
+ * @property {"error"|"warning"} severity
30
+ * @property {string} message
31
+ * @property {string|null} path
32
+ * @property {string|null} suggestedFix
33
+ * @property {string|null} step
34
+ * @property {string|null} [componentId]
35
+ * @property {string|null} [generatorId]
36
+ * @property {string|null} [packageName]
37
+ * @property {string|null} [version]
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} PackageGeneratorBinding
42
+ * @property {string} componentId
43
+ * @property {string} componentType
44
+ * @property {string} projection
45
+ * @property {string} generatorId
46
+ * @property {string} version
47
+ * @property {string} packageName
48
+ */
49
+
50
+ /**
51
+ * @param {Record<string, any>} input
52
+ * @returns {GeneratorPolicyDiagnostic}
53
+ */
54
+ function generatorPolicyDiagnostic(input) {
55
+ return {
56
+ code: String(input.code || "generator_policy_failed"),
57
+ severity: input.severity === "warning" ? "warning" : "error",
58
+ message: String(input.message || "Generator policy check failed."),
59
+ path: typeof input.path === "string" ? input.path : null,
60
+ suggestedFix: typeof input.suggestedFix === "string" ? input.suggestedFix : null,
61
+ step: typeof input.step === "string" ? input.step : null,
62
+ componentId: typeof input.componentId === "string" ? input.componentId : null,
63
+ generatorId: typeof input.generatorId === "string" ? input.generatorId : null,
64
+ packageName: typeof input.packageName === "string" ? input.packageName : null,
65
+ version: typeof input.version === "string" ? input.version : null
66
+ };
67
+ }
68
+
69
+ /**
70
+ * @param {unknown} value
71
+ * @param {string} fieldName
72
+ * @param {string} policyPath
73
+ * @returns {string[]}
74
+ */
75
+ function optionalStringArray(value, fieldName, policyPath) {
76
+ if (value == null) {
77
+ return [];
78
+ }
79
+ if (!Array.isArray(value)) {
80
+ throw new Error(`${policyPath} ${fieldName} must be an array of strings.`);
81
+ }
82
+ return value.map((item) => {
83
+ if (typeof item !== "string" || item.length === 0) {
84
+ throw new Error(`${policyPath} ${fieldName} must contain only non-empty strings.`);
85
+ }
86
+ return item;
87
+ });
88
+ }
89
+
90
+ /**
91
+ * @param {unknown} value
92
+ * @param {string} policyPath
93
+ * @returns {Record<string, string>}
94
+ */
95
+ function optionalStringRecord(value, policyPath) {
96
+ if (value == null) {
97
+ return {};
98
+ }
99
+ if (typeof value !== "object" || Array.isArray(value)) {
100
+ throw new Error(`${policyPath} pinnedVersions must be an object of package-or-generator ids to versions.`);
101
+ }
102
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => {
103
+ if (typeof item !== "string" || item.length === 0) {
104
+ throw new Error(`${policyPath} pinnedVersions['${key}'] must be a non-empty string.`);
105
+ }
106
+ return [key, item];
107
+ }));
108
+ }
109
+
110
+ /**
111
+ * @returns {GeneratorPolicy}
112
+ */
113
+ export function defaultGeneratorPolicy() {
114
+ return {
115
+ version: "0.1",
116
+ allowedPackageScopes: ["@topogram"],
117
+ allowedPackages: [],
118
+ pinnedVersions: {}
119
+ };
120
+ }
121
+
122
+ /**
123
+ * @param {unknown} value
124
+ * @param {string} policyPath
125
+ * @returns {GeneratorPolicy}
126
+ */
127
+ export function validateGeneratorPolicy(value, policyPath) {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
129
+ throw new Error(`${GENERATOR_POLICY_FILE} must contain a JSON object.`);
130
+ }
131
+ const raw = /** @type {Record<string, unknown>} */ (value);
132
+ const defaults = defaultGeneratorPolicy();
133
+ return {
134
+ version: typeof raw.version === "string" && raw.version ? raw.version : defaults.version,
135
+ allowedPackageScopes: raw.allowedPackageScopes == null
136
+ ? defaults.allowedPackageScopes
137
+ : optionalStringArray(raw.allowedPackageScopes, "allowedPackageScopes", policyPath),
138
+ allowedPackages: optionalStringArray(raw.allowedPackages, "allowedPackages", policyPath),
139
+ pinnedVersions: optionalStringRecord(raw.pinnedVersions, policyPath)
140
+ };
141
+ }
142
+
143
+ /**
144
+ * @param {string} packageName
145
+ * @returns {string|null}
146
+ */
147
+ export function packageScopeFromName(packageName) {
148
+ return packageName.startsWith("@") ? packageName.split("/")[0] || null : null;
149
+ }
150
+
151
+ /**
152
+ * @param {string} allowed
153
+ * @param {string|null} scope
154
+ * @returns {boolean}
155
+ */
156
+ function packageScopeMatches(allowed, scope) {
157
+ return Boolean(scope && (allowed === scope || allowed === `${scope}/*`));
158
+ }
159
+
160
+ /**
161
+ * @param {GeneratorPolicy} policy
162
+ * @param {string} packageName
163
+ * @returns {boolean}
164
+ */
165
+ export function generatorPackageAllowed(policy, packageName) {
166
+ if (policy.allowedPackages.includes(packageName)) {
167
+ return true;
168
+ }
169
+ const scope = packageScopeFromName(packageName);
170
+ return policy.allowedPackageScopes.some((allowed) => packageScopeMatches(allowed, scope));
171
+ }
172
+
173
+ /**
174
+ * @param {Record<string, any>} projectConfig
175
+ * @returns {PackageGeneratorBinding[]}
176
+ */
177
+ export function packageBackedGeneratorBindings(projectConfig) {
178
+ const components = Array.isArray(projectConfig?.topology?.components)
179
+ ? projectConfig.topology.components
180
+ : [];
181
+ return components
182
+ .filter((component) => typeof component?.generator?.package === "string" && component.generator.package.length > 0)
183
+ .map((component) => ({
184
+ componentId: String(component.id || "unknown"),
185
+ componentType: String(component.type || "unknown"),
186
+ projection: String(component.projection || "unknown"),
187
+ generatorId: String(component.generator.id || "unknown"),
188
+ version: String(component.generator.version || "unknown"),
189
+ packageName: String(component.generator.package)
190
+ }));
191
+ }
192
+
193
+ /**
194
+ * @param {string} projectRoot
195
+ * @returns {GeneratorPolicyInfo}
196
+ */
197
+ export function loadGeneratorPolicy(projectRoot) {
198
+ const policyPath = path.join(projectRoot, GENERATOR_POLICY_FILE);
199
+ if (!fs.existsSync(policyPath)) {
200
+ return {
201
+ path: policyPath,
202
+ policy: null,
203
+ exists: false,
204
+ diagnostics: []
205
+ };
206
+ }
207
+ try {
208
+ return {
209
+ path: policyPath,
210
+ policy: validateGeneratorPolicy(JSON.parse(fs.readFileSync(policyPath, "utf8")), policyPath),
211
+ exists: true,
212
+ diagnostics: []
213
+ };
214
+ } catch (error) {
215
+ return {
216
+ path: policyPath,
217
+ policy: null,
218
+ exists: true,
219
+ diagnostics: [generatorPolicyDiagnostic({
220
+ code: "generator_policy_invalid",
221
+ message: error instanceof Error ? error.message : String(error),
222
+ path: policyPath,
223
+ suggestedFix: `Fix ${GENERATOR_POLICY_FILE} or regenerate it with \`topogram generator policy init\`.`,
224
+ step: "generator-policy"
225
+ })]
226
+ };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * @param {string} projectRoot
232
+ * @param {GeneratorPolicy} policy
233
+ * @returns {GeneratorPolicy}
234
+ */
235
+ export function writeGeneratorPolicy(projectRoot, policy) {
236
+ fs.writeFileSync(path.join(projectRoot, GENERATOR_POLICY_FILE), `${stableStringify(policy)}\n`, "utf8");
237
+ return policy;
238
+ }
239
+
240
+ /**
241
+ * @param {GeneratorPolicyInfo} policyInfo
242
+ * @returns {GeneratorPolicy}
243
+ */
244
+ function effectivePolicy(policyInfo) {
245
+ return policyInfo.policy || defaultGeneratorPolicy();
246
+ }
247
+
248
+ /**
249
+ * @param {GeneratorPolicyInfo} policyInfo
250
+ * @param {PackageGeneratorBinding[]} bindings
251
+ * @param {string} step
252
+ * @returns {GeneratorPolicyDiagnostic[]}
253
+ */
254
+ export function generatorPolicyDiagnosticsForBindings(policyInfo, bindings, step = "generator-policy") {
255
+ if (policyInfo.diagnostics.length > 0) {
256
+ return policyInfo.diagnostics;
257
+ }
258
+ const policy = effectivePolicy(policyInfo);
259
+ /** @type {GeneratorPolicyDiagnostic[]} */
260
+ const diagnostics = [];
261
+ for (const binding of bindings) {
262
+ if (!generatorPackageAllowed(policy, binding.packageName)) {
263
+ const scope = packageScopeFromName(binding.packageName);
264
+ const allowedScopes = policy.allowedPackageScopes.join(", ") || "(none)";
265
+ const allowedPackages = policy.allowedPackages.join(", ") || "(none)";
266
+ diagnostics.push(generatorPolicyDiagnostic({
267
+ code: "generator_package_denied",
268
+ message: `Component '${binding.componentId}' generator package '${binding.packageName}' is not allowed by ${GENERATOR_POLICY_FILE}.`,
269
+ path: policyInfo.path,
270
+ suggestedFix: `Review '${binding.packageName}', then run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` or add '${scope || binding.packageName}' to ${GENERATOR_POLICY_FILE}.`,
271
+ step,
272
+ componentId: binding.componentId,
273
+ generatorId: binding.generatorId,
274
+ packageName: binding.packageName,
275
+ version: binding.version
276
+ }));
277
+ diagnostics[diagnostics.length - 1].message += ` Allowed scopes: ${allowedScopes}; allowed packages: ${allowedPackages}.`;
278
+ }
279
+ const pinnedVersion = policy.pinnedVersions[binding.packageName] || policy.pinnedVersions[binding.generatorId] || null;
280
+ if (pinnedVersion && pinnedVersion !== binding.version) {
281
+ diagnostics.push(generatorPolicyDiagnostic({
282
+ code: "generator_version_mismatch",
283
+ message: `Component '${binding.componentId}' generator '${binding.generatorId}' uses version '${binding.version}', but ${GENERATOR_POLICY_FILE} pins '${binding.packageName}' to '${pinnedVersion}'.`,
284
+ path: policyInfo.path,
285
+ suggestedFix: `Use generator version '${pinnedVersion}', or run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after review.`,
286
+ step,
287
+ componentId: binding.componentId,
288
+ generatorId: binding.generatorId,
289
+ packageName: binding.packageName,
290
+ version: binding.version
291
+ }));
292
+ }
293
+ }
294
+ return diagnostics;
295
+ }
296
+
297
+ /**
298
+ * @param {Record<string, any>} projectConfig
299
+ * @param {string} projectRoot
300
+ * @param {string} [step]
301
+ * @returns {GeneratorPolicyDiagnostic[]}
302
+ */
303
+ export function generatorPolicyDiagnosticsForProject(projectConfig, projectRoot, step = "generator-policy") {
304
+ const bindings = packageBackedGeneratorBindings(projectConfig);
305
+ const policyInfo = loadGeneratorPolicy(projectRoot);
306
+ return generatorPolicyDiagnosticsForBindings(policyInfo, bindings, step);
307
+ }
308
+
309
+ /**
310
+ * @param {Record<string, any>} projectConfig
311
+ * @param {{ configDir?: string|null, rootDir?: string|null }} [options]
312
+ * @returns {{ ok: boolean, errors: Array<{ message: string, loc: null }> }}
313
+ */
314
+ export function validateProjectGeneratorPolicy(projectConfig, options = {}) {
315
+ const projectRoot = options.configDir || options.rootDir || process.cwd();
316
+ const diagnostics = generatorPolicyDiagnosticsForProject(projectConfig, projectRoot, "project-config");
317
+ const errors = diagnostics
318
+ .filter((diagnostic) => diagnostic.severity === "error")
319
+ .map((diagnostic) => ({
320
+ message: diagnostic.suggestedFix
321
+ ? `${diagnostic.message} Suggested fix: ${diagnostic.suggestedFix}`
322
+ : diagnostic.message,
323
+ loc: null
324
+ }));
325
+ return {
326
+ ok: errors.length === 0,
327
+ errors
328
+ };
329
+ }
330
+
331
+ /**
332
+ * @param {string} spec
333
+ * @returns {{ packageName: string, version: string }}
334
+ */
335
+ export function parseGeneratorPolicyPin(spec) {
336
+ const separator = spec.lastIndexOf("@");
337
+ if (separator <= 0 || separator === spec.length - 1) {
338
+ throw new Error("Generator policy pin requires a package name and generator version, for example @topogram/generator-react-web@1.");
339
+ }
340
+ return {
341
+ packageName: spec.slice(0, separator),
342
+ version: spec.slice(separator + 1)
343
+ };
344
+ }
@@ -6,6 +6,7 @@ import crypto from "node:crypto";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
 
9
+ import { defaultGeneratorPolicy, writeGeneratorPolicy } from "./generator-policy.js";
9
10
  import { writeTemplateTrustRecord } from "./template-trust.js";
10
11
 
11
12
  const CLI_PACKAGE_NAME = "@topogram/cli";
@@ -32,6 +33,15 @@ const SURFACE_ORDER = new Map([
32
33
  ["native", 40]
33
34
  ]);
34
35
 
36
+ /**
37
+ * @param {string} templateId
38
+ * @param {string} relativePath
39
+ * @returns {string}
40
+ */
41
+ function unsupportedTemplateSymlinkMessage(templateId, relativePath) {
42
+ return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied topogram/ and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram new or topogram template check.`;
43
+ }
44
+
35
45
  /**
36
46
  * @typedef {Object} CreateNewProjectOptions
37
47
  * @property {string} targetPath
@@ -342,7 +352,7 @@ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
342
352
  const rootStat = fs.lstatSync(currentDir);
343
353
  const relativeRoot = path.relative(root, currentDir).replace(/\\/g, "/") || label;
344
354
  if (rootStat.isSymbolicLink()) {
345
- throw new Error(`Template '${templateId}' contains unsupported symlink '${relativeRoot}'.`);
355
+ throw new Error(unsupportedTemplateSymlinkMessage(templateId, relativeRoot));
346
356
  }
347
357
  if (!rootStat.isDirectory()) {
348
358
  return;
@@ -351,7 +361,7 @@ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
351
361
  const entryPath = path.join(currentDir, entry.name);
352
362
  const relativePath = path.relative(root, entryPath).replace(/\\/g, "/");
353
363
  if (entry.isSymbolicLink()) {
354
- throw new Error(`Template '${templateId}' contains unsupported symlink '${relativePath}'.`);
364
+ throw new Error(unsupportedTemplateSymlinkMessage(templateId, relativePath));
355
365
  }
356
366
  if (entry.isDirectory()) {
357
367
  assertTemplateTreeHasNoSymlinks(root, entryPath, label, templateId);
@@ -368,10 +378,10 @@ function validateTemplateRoot(templateRoot) {
368
378
  const topogramRoot = path.join(templateRoot, "topogram");
369
379
  const projectConfigPath = path.join(templateRoot, "topogram.project.json");
370
380
  if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
371
- throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram'.`);
381
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram"));
372
382
  }
373
383
  if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
374
- throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram.project.json'.`);
384
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram.project.json"));
375
385
  }
376
386
  if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
377
387
  throw new Error(`Template '${manifest.id}' is missing topogram/.`);
@@ -383,7 +393,7 @@ function validateTemplateRoot(templateRoot) {
383
393
  if (manifest.includesExecutableImplementation) {
384
394
  const implementationRoot = path.join(templateRoot, "implementation");
385
395
  if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
386
- throw new Error(`Template '${manifest.id}' contains unsupported symlink 'implementation'.`);
396
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "implementation"));
387
397
  }
388
398
  if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
389
399
  throw new Error(
@@ -1169,7 +1179,7 @@ function collectFiles(root, currentDir, files) {
1169
1179
  }
1170
1180
  const entryPath = path.join(currentDir, entry.name);
1171
1181
  if (entry.isSymbolicLink()) {
1172
- throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'.`);
1182
+ throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'. Template-owned files must be real files so Topogram can hash the exact content being trusted. Replace the symlink with a real file, then run topogram trust status, topogram trust diff, and topogram trust template after review.`);
1173
1183
  }
1174
1184
  if (entry.isDirectory()) {
1175
1185
  collectFiles(root, entryPath, files);
@@ -1965,6 +1975,8 @@ function writeProjectPackage(projectRoot, engineRoot, template) {
1965
1975
  "template:detach:dry-run": "topogram template detach --dry-run",
1966
1976
  "template:policy:check": "topogram template policy check",
1967
1977
  "template:policy:explain": "topogram template policy explain",
1978
+ "generator:policy:check": "topogram generator policy check",
1979
+ "generator:policy:explain": "topogram generator policy explain",
1968
1980
  "template:update:status": "topogram template update --status",
1969
1981
  "template:update:recommend": "topogram template update --recommend",
1970
1982
  "template:update:plan": "topogram template update --plan",
@@ -2040,6 +2052,8 @@ Useful inspection:
2040
2052
  npm run template:detach:dry-run
2041
2053
  npm run template:policy:check
2042
2054
  npm run template:policy:explain
2055
+ npm run generator:policy:check
2056
+ npm run generator:policy:explain
2043
2057
  npm run template:update:status
2044
2058
  npm run template:update:recommend
2045
2059
  npm run template:update:plan
@@ -2070,6 +2084,7 @@ function writeProjectReadme(projectRoot, projectConfig) {
2070
2084
  "npm run template:explain",
2071
2085
  "npm run check",
2072
2086
  "npm run template:policy:check",
2087
+ "npm run generator:policy:check",
2073
2088
  ...(template.includesExecutableImplementation ? [
2074
2089
  "npm run template:policy:explain",
2075
2090
  "npm run trust:status"
@@ -2145,6 +2160,7 @@ export function createNewProject({
2145
2160
  writeProjectReadme(projectRoot, projectConfig);
2146
2161
  writeTemplateFilesManifest(projectRoot, projectConfig);
2147
2162
  writeTemplatePolicy(projectRoot, defaultTemplatePolicyForTemplate(template));
2163
+ writeGeneratorPolicy(projectRoot, defaultGeneratorPolicy());
2148
2164
 
2149
2165
  const warnings = [];
2150
2166
  if (template.manifest.includesExecutableImplementation) {
@@ -8,6 +8,7 @@ import {
8
8
  resolveGeneratorManifestForBinding,
9
9
  validateGeneratorManifest
10
10
  } from "./generator/registry.js";
11
+ import { validateProjectGeneratorPolicy } from "./generator-policy.js";
11
12
 
12
13
  /**
13
14
  * @typedef {Object} GeneratorBinding
@@ -434,6 +435,10 @@ export function validateProjectConfig(config, graph = null, options = {}) {
434
435
  for (const component of config.topology.components) {
435
436
  validateComponentShape(errors, component, seenIds);
436
437
  }
438
+ const generatorPolicy = validateProjectGeneratorPolicy(config, options);
439
+ for (const error of generatorPolicy.errors) {
440
+ pushError(errors, error.message, error.loc);
441
+ }
437
442
  if (graph) {
438
443
  const projections = projectionById(graph);
439
444
  for (const component of config.topology.components) {
@@ -19,6 +19,7 @@ export const TEMPLATE_TRUST_POLICY = "topogram-template-executable-implementatio
19
19
 
20
20
  const IGNORED_IMPLEMENTATION_ENTRIES = new Set([".DS_Store", "node_modules", ".tmp"]);
21
21
  const MAX_TEXT_DIFF_BYTES = 256 * 1024;
22
+ const TRUST_REVIEW_COMMANDS = "`topogram trust status`, `topogram trust diff`, and `topogram trust template`";
22
23
 
23
24
  /**
24
25
  * @param {string} parent
@@ -46,6 +47,45 @@ function normalizeRelativePath(value) {
46
47
  return value.replace(/\\/g, "/");
47
48
  }
48
49
 
50
+ /**
51
+ * @param {string} relativePath
52
+ * @returns {string}
53
+ */
54
+ function unsupportedImplementationSymlinkMessage(relativePath) {
55
+ return `Template implementation contains unsupported symlink '${relativePath}'. Implementation trust hashes real files under implementation/; symlinks can point outside the trusted root. Replace symlinks with real files under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
56
+ }
57
+
58
+ /**
59
+ * @param {string} modulePath
60
+ * @returns {string}
61
+ */
62
+ function implementationOutsideRootMessage(modulePath) {
63
+ return `Template implementation module '${modulePath}' must be under implementation/ for template-attached projects. Keep executable template code inside implementation/ so the trust record covers what topogram generate may load. Move the module back under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
64
+ }
65
+
66
+ /**
67
+ * @param {string|string[]} issueOrIssues
68
+ * @returns {string}
69
+ */
70
+ export function templateTrustRecoveryGuidance(issueOrIssues) {
71
+ const issues = Array.isArray(issueOrIssues) ? issueOrIssues : [issueOrIssues];
72
+ const text = issues.join("\n");
73
+ if (issues.length > 0 && issues.every((issue) =>
74
+ issue.includes("topogram trust status") &&
75
+ issue.includes("topogram trust diff") &&
76
+ issue.includes("topogram trust template")
77
+ )) {
78
+ return "";
79
+ }
80
+ if (text.includes("unsupported symlink")) {
81
+ return `Replace symlinks with real files under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
82
+ }
83
+ if (text.includes("must be under implementation/")) {
84
+ return `Keep executable template code under implementation/ so it can be hashed and trusted; move the module back under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
85
+ }
86
+ return `Run \`topogram trust status\` and \`topogram trust diff\` to review implementation changes; after review, run \`topogram trust template\` to trust the current files.`;
87
+ }
88
+
49
89
  /**
50
90
  * @param {string} value
51
91
  * @returns {string}
@@ -187,7 +227,7 @@ function collectImplementationFiles(implementationRoot, currentDir, files) {
187
227
  const entryPath = path.join(currentDir, entry.name);
188
228
  const relativePath = normalizeRelativePath(path.relative(implementationRoot, entryPath));
189
229
  if (entry.isSymbolicLink()) {
190
- throw new Error(`Template implementation contains unsupported symlink '${relativePath}'.`);
230
+ throw new Error(unsupportedImplementationSymlinkMessage(relativePath));
191
231
  }
192
232
  if (entry.isDirectory()) {
193
233
  collectImplementationFiles(implementationRoot, entryPath, files);
@@ -343,7 +383,7 @@ export function writeTemplateTrustRecord(configDir, projectConfig) {
343
383
  };
344
384
  if (!implementationModuleIsUnderRoot(implementationInfo)) {
345
385
  const implementation = implementationTrustFingerprint(implementationConfig);
346
- throw new Error(`Template implementation module '${implementation.module}' must be under implementation/.`);
386
+ throw new Error(implementationOutsideRootMessage(implementation.module));
347
387
  }
348
388
  const implementation = implementationTrustFingerprint(implementationConfig);
349
389
  const record = buildTrustRecord(configDir, projectConfig, implementation);
@@ -362,8 +402,9 @@ export function assertTrustedImplementation(implementationInfo, projectConfig =
362
402
  return;
363
403
  }
364
404
  const firstIssue = status.issues[0] || "implementation trust is invalid";
405
+ const guidance = templateTrustRecoveryGuidance(firstIssue);
365
406
  throw new Error(
366
- `${firstIssue}. Review implementation/ and run 'topogram trust template' to trust the current files.`
407
+ guidance ? `${firstIssue}. ${guidance}` : firstIssue
367
408
  );
368
409
  }
369
410
 
@@ -451,9 +492,7 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
451
492
  const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
452
493
 
453
494
  if (templateAttached && !moduleInsideImplementation) {
454
- issues.push(
455
- `Template implementation module '${fingerprint.module}' must be under implementation/ for template-attached projects`
456
- );
495
+ issues.push(implementationOutsideRootMessage(fingerprint.module));
457
496
  }
458
497
 
459
498
  if (!trustRecord) {
@@ -490,17 +529,21 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
490
529
  } else if (trustRecord.content.algorithm !== "sha256") {
491
530
  issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);
492
531
  } else {
493
- const currentContent = hashImplementationContent(implementationInfo.configDir);
494
- contentStatus.trustedDigest = trustRecord.content.digest;
495
- contentStatus.currentDigest = currentContent.digest;
496
- const trustedByPath = new Map((trustRecord.content.files || []).map((file) => [file.path, file]));
497
- const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
498
- const diff = diffContentFiles(trustedByPath, currentByPath);
499
- contentStatus.added = diff.added;
500
- contentStatus.removed = diff.removed;
501
- contentStatus.changed = diff.changed;
502
- if (trustRecord.content.digest !== currentContent.digest) {
503
- issues.push(`${TEMPLATE_TRUST_FILE} implementation content changed since it was last trusted`);
532
+ try {
533
+ const currentContent = hashImplementationContent(implementationInfo.configDir);
534
+ contentStatus.trustedDigest = trustRecord.content.digest;
535
+ contentStatus.currentDigest = currentContent.digest;
536
+ const trustedByPath = new Map((trustRecord.content.files || []).map((file) => [file.path, file]));
537
+ const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
538
+ const diff = diffContentFiles(trustedByPath, currentByPath);
539
+ contentStatus.added = diff.added;
540
+ contentStatus.removed = diff.removed;
541
+ contentStatus.changed = diff.changed;
542
+ if (trustRecord.content.digest !== currentContent.digest) {
543
+ issues.push(`${TEMPLATE_TRUST_FILE} implementation content changed since it was last trusted`);
544
+ }
545
+ } catch (error) {
546
+ issues.push(error instanceof Error ? error.message : String(error));
504
547
  }
505
548
  }
506
549
  }
@@ -538,7 +581,12 @@ export function getTemplateTrustDiff(implementationInfo, projectConfig = null) {
538
581
  if (!status.requiresTrust || !status.trustRecord?.content) {
539
582
  return { ok: status.ok, requiresTrust: status.requiresTrust, status, files: [] };
540
583
  }
541
- const currentContent = hashImplementationContent(implementationInfo.configDir);
584
+ let currentContent;
585
+ try {
586
+ currentContent = hashImplementationContent(implementationInfo.configDir);
587
+ } catch (_error) {
588
+ return { ok: false, requiresTrust: status.requiresTrust, status, files: [] };
589
+ }
542
590
  const trustedByPath = new Map((status.trustRecord.content.files || []).map((file) => [file.path, file]));
543
591
  const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
544
592
  /** @type {Array<{ path: string, kind: "added"|"removed"|"changed", trusted: { path: string, sha256: string|null, size: number|null }|null, current: { path: string, sha256: string|null, size: number|null, binary: boolean, diffOmitted: boolean }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }>} */