@topogram/cli 0.3.41 → 0.3.43

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.41",
3
+ "version": "0.3.43",
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
@@ -44,6 +44,17 @@ import {
44
44
  loadPackageGeneratorManifest,
45
45
  packageGeneratorInstallCommand
46
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";
47
58
  import {
48
59
  buildAuthHintsQueryPayload,
49
60
  buildAuthReviewPacketPayload,
@@ -207,6 +218,11 @@ function printUsage(options = {}) {
207
218
  console.log(" or: topogram generator list [--json]");
208
219
  console.log(" or: topogram generator show <id-or-package> [--json]");
209
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 status [path] [--json]");
223
+ console.log(" or: topogram generator policy check [path] [--json]");
224
+ console.log(" or: topogram generator policy explain [path] [--json]");
225
+ console.log(" or: topogram generator policy pin [package@version] [path] [--json]");
210
226
  console.log(" or: topogram new <path> [--template hello-web|todo|./local-template|@scope/template]");
211
227
  console.log(" or: topogram new --list-templates [--json] [--catalog <path-or-source>]");
212
228
  console.log("");
@@ -228,6 +244,7 @@ function printUsage(options = {}) {
228
244
  console.log(" topogram generator list");
229
245
  console.log(" topogram generator show @topogram/generator-react-web");
230
246
  console.log(" topogram generator check ./generator-package");
247
+ console.log(" topogram generator policy check");
231
248
  console.log(" topogram generate");
232
249
  console.log(" topogram import ./existing-app --out ./imported-topogram");
233
250
  console.log(" topogram import diff ./imported-topogram");
@@ -569,6 +586,11 @@ function printGeneratorHelp() {
569
586
  console.log("Usage: topogram generator list [--json]");
570
587
  console.log(" or: topogram generator show <id-or-package> [--json]");
571
588
  console.log(" or: topogram generator check <path-or-package> [--json]");
589
+ console.log(" or: topogram generator policy init [path] [--json]");
590
+ console.log(" or: topogram generator policy status [path] [--json]");
591
+ console.log(" or: topogram generator policy check [path] [--json]");
592
+ console.log(" or: topogram generator policy explain [path] [--json]");
593
+ console.log(" or: topogram generator policy pin [package@version] [path] [--json]");
572
594
  console.log("");
573
595
  console.log("Inspects generator manifests and checks generator pack conformance.");
574
596
  console.log("");
@@ -577,6 +599,7 @@ function printGeneratorHelp() {
577
599
  console.log(" - show accepts an installed package name or a bundled fallback generator id.");
578
600
  console.log(" - check validates a local generator package path or an already installed package.");
579
601
  console.log(" - Topogram does not install generator packages during show or check.");
602
+ console.log(` - package-backed project generators are governed by ${GENERATOR_POLICY_FILE}; bundled topogram/* generators are allowed.`);
580
603
  console.log("");
581
604
  console.log("Examples:");
582
605
  console.log(" topogram generator list");
@@ -585,6 +608,10 @@ function printGeneratorHelp() {
585
608
  console.log(" topogram generator show @scope/topogram-generator-web --json");
586
609
  console.log(" topogram generator check ./generator-package");
587
610
  console.log(" topogram generator check @scope/topogram-generator-web --json");
611
+ console.log(" topogram generator policy init");
612
+ console.log(" topogram generator policy status --json");
613
+ console.log(" topogram generator policy check --json");
614
+ console.log(" topogram generator policy pin @topogram/generator-react-web@1");
588
615
  }
589
616
 
590
617
  function printTemplateHelp() {
@@ -789,7 +816,7 @@ function printImportHelp() {
789
816
  function printCheckHelp() {
790
817
  console.log("Usage: topogram check [path] [--json]");
791
818
  console.log("");
792
- console.log("Validates Topogram files, project configuration, topology, generator compatibility, output ownership, and template policy.");
819
+ console.log("Validates Topogram files, project configuration, topology, generator compatibility, generator policy, output ownership, and template policy.");
793
820
  console.log("");
794
821
  console.log("Defaults: path is ./topogram.");
795
822
  console.log("");
@@ -1524,6 +1551,556 @@ function printGeneratorShow(payload) {
1524
1551
  console.log(stableStringify(payload.exampleTopologyBinding));
1525
1552
  }
1526
1553
 
1554
+ /**
1555
+ * @param {string} name
1556
+ * @param {boolean} ok
1557
+ * @param {string} actual
1558
+ * @param {string} expected
1559
+ * @param {string} message
1560
+ * @param {string|null} fix
1561
+ * @returns {{ name: string, ok: boolean, actual: string, expected: string, message: string, fix: string|null }}
1562
+ */
1563
+ function generatorPolicyRule(name, ok, actual, expected, message, fix = null) {
1564
+ return { name, ok, actual, expected, message, fix };
1565
+ }
1566
+
1567
+ /**
1568
+ * @param {string} name
1569
+ * @returns {string}
1570
+ */
1571
+ function generatorPolicyRuleLabel(name) {
1572
+ return ({
1573
+ "policy-file": "Policy file",
1574
+ "allowed-package": "Allowed package",
1575
+ "pinned-version": "Pinned version"
1576
+ })[name] || name;
1577
+ }
1578
+
1579
+ /**
1580
+ * @param {import("./generator-policy.js").GeneratorPolicyInfo} policyInfo
1581
+ * @returns {import("./generator-policy.js").GeneratorPolicy}
1582
+ */
1583
+ function effectiveGeneratorPolicy(policyInfo) {
1584
+ return policyInfo.policy || {
1585
+ version: "0.1",
1586
+ allowedPackageScopes: ["@topogram"],
1587
+ allowedPackages: [],
1588
+ pinnedVersions: {}
1589
+ };
1590
+ }
1591
+
1592
+ /**
1593
+ * @param {string} filePath
1594
+ * @returns {any|null}
1595
+ */
1596
+ function readJsonIfPresent(filePath) {
1597
+ if (!fs.existsSync(filePath)) {
1598
+ return null;
1599
+ }
1600
+ try {
1601
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
1602
+ } catch {
1603
+ return null;
1604
+ }
1605
+ }
1606
+
1607
+ /**
1608
+ * @param {Record<string, any>|null} projectPackage
1609
+ * @param {string} packageName
1610
+ * @returns {{ field: string|null, spec: string|null }}
1611
+ */
1612
+ function dependencySpecForPackage(projectPackage, packageName) {
1613
+ const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
1614
+ for (const field of dependencyFields) {
1615
+ const dependencies = projectPackage?.[field];
1616
+ if (dependencies && typeof dependencies === "object" && typeof dependencies[packageName] === "string") {
1617
+ return {
1618
+ field,
1619
+ spec: dependencies[packageName]
1620
+ };
1621
+ }
1622
+ }
1623
+ return {
1624
+ field: null,
1625
+ spec: null
1626
+ };
1627
+ }
1628
+
1629
+ /**
1630
+ * @param {Record<string, any>|null} lockfile
1631
+ * @param {string} packageName
1632
+ * @returns {{ version: string|null, resolved: string|null, integrity: string|null, entryPath: string|null }}
1633
+ */
1634
+ function lockfileInfoForPackage(lockfile, packageName) {
1635
+ const packageEntryPath = `node_modules/${packageName}`;
1636
+ const packageEntry = lockfile?.packages?.[packageEntryPath];
1637
+ if (packageEntry && typeof packageEntry === "object") {
1638
+ return {
1639
+ version: typeof packageEntry.version === "string" ? packageEntry.version : null,
1640
+ resolved: typeof packageEntry.resolved === "string" ? packageEntry.resolved : null,
1641
+ integrity: typeof packageEntry.integrity === "string" ? packageEntry.integrity : null,
1642
+ entryPath: packageEntryPath
1643
+ };
1644
+ }
1645
+ const dependencyEntry = lockfile?.dependencies?.[packageName];
1646
+ if (dependencyEntry && typeof dependencyEntry === "object") {
1647
+ return {
1648
+ version: typeof dependencyEntry.version === "string" ? dependencyEntry.version : null,
1649
+ resolved: typeof dependencyEntry.resolved === "string" ? dependencyEntry.resolved : null,
1650
+ integrity: typeof dependencyEntry.integrity === "string" ? dependencyEntry.integrity : null,
1651
+ entryPath: packageName
1652
+ };
1653
+ }
1654
+ return {
1655
+ version: null,
1656
+ resolved: null,
1657
+ integrity: null,
1658
+ entryPath: null
1659
+ };
1660
+ }
1661
+
1662
+ /**
1663
+ * @param {string} projectRoot
1664
+ * @param {string} packageName
1665
+ * @returns {{ dependencyField: string|null, dependencySpec: string|null, installedVersion: string|null, installedPackageJsonPath: string|null, lockfileVersion: string|null, lockfileResolved: string|null, lockfileIntegrity: string|null, lockfileEntryPath: string|null }}
1666
+ */
1667
+ function packageInfoForGenerator(projectRoot, packageName) {
1668
+ const projectPackage = readJsonIfPresent(path.join(projectRoot, "package.json"));
1669
+ const dependency = dependencySpecForPackage(projectPackage, packageName);
1670
+ const lockfile = readJsonIfPresent(path.join(projectRoot, "package-lock.json"));
1671
+ const lockfileInfo = lockfileInfoForPackage(lockfile, packageName);
1672
+ const installedPackageJsonPath = path.join(projectRoot, "node_modules", ...packageName.split("/"), "package.json");
1673
+ const installedPackage = readJsonIfPresent(installedPackageJsonPath);
1674
+ return {
1675
+ dependencyField: dependency.field,
1676
+ dependencySpec: dependency.spec,
1677
+ installedVersion: typeof installedPackage?.version === "string" ? installedPackage.version : null,
1678
+ installedPackageJsonPath: installedPackage ? installedPackageJsonPath : null,
1679
+ lockfileVersion: lockfileInfo.version,
1680
+ lockfileResolved: lockfileInfo.resolved,
1681
+ lockfileIntegrity: lockfileInfo.integrity,
1682
+ lockfileEntryPath: lockfileInfo.entryPath
1683
+ };
1684
+ }
1685
+
1686
+ /**
1687
+ * @param {string} projectRoot
1688
+ * @param {import("./generator-policy.js").GeneratorPolicy} policy
1689
+ * @param {ReturnType<typeof packageBackedGeneratorBindings>[number]} binding
1690
+ * @returns {ReturnType<typeof packageBackedGeneratorBindings>[number] & { allowed: boolean, packageInfo: ReturnType<typeof packageInfoForGenerator>, pin: { key: string|null, version: string|null, matches: boolean|null } }}
1691
+ */
1692
+ function generatorPolicyBindingStatus(projectRoot, policy, binding) {
1693
+ const packagePin = policy.pinnedVersions[binding.packageName] || null;
1694
+ const generatorPin = policy.pinnedVersions[binding.generatorId] || null;
1695
+ const pinnedVersion = packagePin || generatorPin;
1696
+ return {
1697
+ ...binding,
1698
+ allowed: generatorPackageAllowed(policy, binding.packageName),
1699
+ packageInfo: packageInfoForGenerator(projectRoot, binding.packageName),
1700
+ pin: {
1701
+ key: packagePin ? binding.packageName : generatorPin ? binding.generatorId : null,
1702
+ version: pinnedVersion,
1703
+ matches: pinnedVersion ? pinnedVersion === binding.version : null
1704
+ }
1705
+ };
1706
+ }
1707
+
1708
+ /**
1709
+ * @param {any[]} diagnostics
1710
+ * @param {Array<ReturnType<typeof generatorPolicyBindingStatus>>} bindings
1711
+ * @returns {any[]}
1712
+ */
1713
+ function annotateGeneratorPolicyDiagnostics(diagnostics, bindings) {
1714
+ return diagnostics.map((diagnostic) => {
1715
+ const binding = bindings.find((item) => (
1716
+ item.packageName === diagnostic.packageName &&
1717
+ (!diagnostic.componentId || item.componentId === diagnostic.componentId)
1718
+ ));
1719
+ if (!binding) {
1720
+ return diagnostic;
1721
+ }
1722
+ return {
1723
+ ...diagnostic,
1724
+ packageVersion: binding.packageInfo.installedVersion || binding.packageInfo.lockfileVersion || null,
1725
+ packageDependencyField: binding.packageInfo.dependencyField,
1726
+ packageDependencySpec: binding.packageInfo.dependencySpec,
1727
+ packageLockVersion: binding.packageInfo.lockfileVersion
1728
+ };
1729
+ });
1730
+ }
1731
+
1732
+ /**
1733
+ * @param {string} projectPath
1734
+ * @returns {{ ok: boolean, path: string, exists: boolean, policy: any, defaulted: boolean, bindings: Array<ReturnType<typeof generatorPolicyBindingStatus>>, diagnostics: any[], errors: string[] }}
1735
+ */
1736
+ function buildGeneratorPolicyCheckPayload(projectPath) {
1737
+ const projectConfigInfo = loadProjectConfig(projectPath);
1738
+ if (!projectConfigInfo) {
1739
+ const diagnostic = {
1740
+ code: "generator_policy_project_missing",
1741
+ severity: "error",
1742
+ message: "Cannot check generator policy without topogram.project.json.",
1743
+ path: path.resolve(projectPath),
1744
+ suggestedFix: "Run this command in a Topogram project.",
1745
+ step: "generator-policy"
1746
+ };
1747
+ return {
1748
+ ok: false,
1749
+ path: path.join(path.resolve(projectPath), GENERATOR_POLICY_FILE),
1750
+ exists: false,
1751
+ policy: null,
1752
+ defaulted: false,
1753
+ bindings: [],
1754
+ diagnostics: [diagnostic],
1755
+ errors: [diagnostic.message]
1756
+ };
1757
+ }
1758
+ const policyInfo = loadGeneratorPolicy(projectConfigInfo.configDir);
1759
+ const rawBindings = packageBackedGeneratorBindings(projectConfigInfo.config);
1760
+ const policy = policyInfo.policy || effectiveGeneratorPolicy(policyInfo);
1761
+ const bindings = rawBindings.map((binding) => generatorPolicyBindingStatus(projectConfigInfo.configDir, policy, binding));
1762
+ const diagnostics = [];
1763
+ if (!policyInfo.exists) {
1764
+ diagnostics.push({
1765
+ code: "generator_policy_missing",
1766
+ severity: "warning",
1767
+ message: `No ${GENERATOR_POLICY_FILE} found. Default generator policy allows @topogram/* package-backed generators and blocks other package scopes.`,
1768
+ path: policyInfo.path,
1769
+ suggestedFix: "Run `topogram generator policy init` to write an explicit project generator policy after review.",
1770
+ step: "generator-policy"
1771
+ });
1772
+ }
1773
+ diagnostics.push(...generatorPolicyDiagnosticsForBindings(policyInfo, rawBindings, "generator-policy"));
1774
+ const annotatedDiagnostics = annotateGeneratorPolicyDiagnostics(diagnostics, bindings);
1775
+ const errors = annotatedDiagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
1776
+ return {
1777
+ ok: errors.length === 0,
1778
+ path: policyInfo.path,
1779
+ exists: policyInfo.exists,
1780
+ policy,
1781
+ defaulted: !policyInfo.exists,
1782
+ bindings,
1783
+ diagnostics: annotatedDiagnostics,
1784
+ errors
1785
+ };
1786
+ }
1787
+
1788
+ /**
1789
+ * @param {string} projectPath
1790
+ * @returns {ReturnType<typeof buildGeneratorPolicyCheckPayload> & { rules: Array<{ name: string, ok: boolean, actual: string, expected: string, message: string, fix: string|null }> }}
1791
+ */
1792
+ function buildGeneratorPolicyExplainPayload(projectPath) {
1793
+ const check = buildGeneratorPolicyCheckPayload(projectPath);
1794
+ const policy = check.policy || effectiveGeneratorPolicy({ path: check.path, exists: false, policy: null, diagnostics: [] });
1795
+ const rules = [];
1796
+ rules.push(generatorPolicyRule(
1797
+ "policy-file",
1798
+ check.exists,
1799
+ check.exists ? "present" : "missing",
1800
+ "present",
1801
+ check.exists
1802
+ ? "Project has a generator policy file."
1803
+ : "Project is using the default generator policy.",
1804
+ check.exists ? null : "Run `topogram generator policy init` after review."
1805
+ ));
1806
+ for (const binding of check.bindings) {
1807
+ const scope = packageScopeFromName(binding.packageName);
1808
+ rules.push(generatorPolicyRule(
1809
+ "allowed-package",
1810
+ generatorPackageAllowed(policy, binding.packageName),
1811
+ `${binding.packageName}${scope ? ` (${scope})` : ""}`,
1812
+ [
1813
+ `scopes=${policy.allowedPackageScopes.join(", ") || "(none)"}`,
1814
+ `packages=${policy.allowedPackages.join(", ") || "(none)"}`
1815
+ ].join("; "),
1816
+ `Component '${binding.componentId}' package-backed generator must be from an allowed package or scope.`,
1817
+ `Run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after reviewing the generator package.`
1818
+ ));
1819
+ const pinnedVersion = policy.pinnedVersions[binding.packageName] || policy.pinnedVersions[binding.generatorId] || null;
1820
+ rules.push(generatorPolicyRule(
1821
+ "pinned-version",
1822
+ !pinnedVersion || pinnedVersion === binding.version,
1823
+ binding.version,
1824
+ pinnedVersion || "(unpinned)",
1825
+ `Component '${binding.componentId}' generator version must match its policy pin when one exists.`,
1826
+ `Run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after review.`
1827
+ ));
1828
+ }
1829
+ return {
1830
+ ...check,
1831
+ rules
1832
+ };
1833
+ }
1834
+
1835
+ /**
1836
+ * @param {string} projectPath
1837
+ * @returns {ReturnType<typeof buildGeneratorPolicyExplainPayload> & { summary: { packageBackedGenerators: number, allowed: number, denied: number, pinned: number, unpinned: number, pinMismatches: number } }}
1838
+ */
1839
+ function buildGeneratorPolicyStatusPayload(projectPath) {
1840
+ const explain = buildGeneratorPolicyExplainPayload(projectPath);
1841
+ return {
1842
+ ...explain,
1843
+ summary: {
1844
+ packageBackedGenerators: explain.bindings.length,
1845
+ allowed: explain.bindings.filter((binding) => binding.allowed).length,
1846
+ denied: explain.bindings.filter((binding) => !binding.allowed).length,
1847
+ pinned: explain.bindings.filter((binding) => Boolean(binding.pin.version)).length,
1848
+ unpinned: explain.bindings.filter((binding) => !binding.pin.version).length,
1849
+ pinMismatches: explain.bindings.filter((binding) => binding.pin.matches === false).length
1850
+ }
1851
+ };
1852
+ }
1853
+
1854
+ /**
1855
+ * @param {ReturnType<typeof buildGeneratorPolicyCheckPayload>} payload
1856
+ * @returns {void}
1857
+ */
1858
+ function printGeneratorPolicyCheckPayload(payload) {
1859
+ console.log(payload.ok ? "Generator policy check passed" : "Generator policy check failed");
1860
+ console.log(`Policy: ${payload.path}`);
1861
+ console.log(`Exists: ${payload.exists ? "yes" : "no"}`);
1862
+ console.log(`Defaulted: ${payload.defaulted ? "yes" : "no"}`);
1863
+ console.log(`Package-backed generators: ${payload.bindings.length}`);
1864
+ for (const binding of payload.bindings) {
1865
+ console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
1866
+ console.log(` npm package: ${binding.packageInfo.installedVersion || "(not installed)"}`);
1867
+ if (binding.packageInfo.dependencySpec) {
1868
+ console.log(` dependency: ${binding.packageInfo.dependencyField} ${binding.packageInfo.dependencySpec}`);
1869
+ }
1870
+ if (binding.packageInfo.lockfileVersion) {
1871
+ console.log(` lockfile: ${binding.packageInfo.lockfileVersion}`);
1872
+ }
1873
+ }
1874
+ for (const diagnostic of payload.diagnostics) {
1875
+ console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
1876
+ if (diagnostic.path) {
1877
+ console.log(` path: ${diagnostic.path}`);
1878
+ }
1879
+ if (diagnostic.suggestedFix) {
1880
+ console.log(` fix: ${diagnostic.suggestedFix}`);
1881
+ }
1882
+ }
1883
+ }
1884
+
1885
+ /**
1886
+ * @param {ReturnType<typeof buildGeneratorPolicyStatusPayload>} payload
1887
+ * @returns {void}
1888
+ */
1889
+ function printGeneratorPolicyStatusPayload(payload) {
1890
+ console.log(payload.ok ? "Generator policy status: allowed" : "Generator policy status: denied");
1891
+ console.log(`Policy file: ${payload.path}`);
1892
+ console.log(`Policy file exists: ${payload.exists ? "yes" : "no"}`);
1893
+ console.log(`Default policy active: ${payload.defaulted ? "yes" : "no"}`);
1894
+ console.log(`Package-backed generators: ${payload.summary.packageBackedGenerators}`);
1895
+ console.log(`Allowed packages: ${payload.summary.allowed}`);
1896
+ console.log(`Denied packages: ${payload.summary.denied}`);
1897
+ console.log(`Pinned generators: ${payload.summary.pinned}`);
1898
+ console.log(`Unpinned generators: ${payload.summary.unpinned}`);
1899
+ console.log(`Pin mismatches: ${payload.summary.pinMismatches}`);
1900
+ if (payload.bindings.length > 0) {
1901
+ console.log("");
1902
+ console.log("Generator packages:");
1903
+ }
1904
+ for (const binding of payload.bindings) {
1905
+ console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
1906
+ console.log(` allowed: ${binding.allowed ? "yes" : "no"}`);
1907
+ console.log(` npm package: ${binding.packageInfo.installedVersion || "(not installed)"}`);
1908
+ console.log(` dependency: ${binding.packageInfo.dependencyField && binding.packageInfo.dependencySpec ? `${binding.packageInfo.dependencyField} ${binding.packageInfo.dependencySpec}` : "(not declared)"}`);
1909
+ console.log(` lockfile: ${binding.packageInfo.lockfileVersion || "(not locked)"}`);
1910
+ console.log(` policy pin: ${binding.pin.version ? `${binding.pin.key}@${binding.pin.version}` : "(none)"}`);
1911
+ }
1912
+ for (const diagnostic of payload.diagnostics) {
1913
+ const label = diagnostic.severity === "warning" ? "Warning" : "Error";
1914
+ console.log(`${label}: ${diagnostic.code}: ${diagnostic.message}`);
1915
+ if (diagnostic.packageVersion) {
1916
+ console.log(` package version: ${diagnostic.packageVersion}`);
1917
+ }
1918
+ if (diagnostic.packageDependencySpec) {
1919
+ console.log(` dependency: ${diagnostic.packageDependencyField} ${diagnostic.packageDependencySpec}`);
1920
+ }
1921
+ if (diagnostic.suggestedFix) {
1922
+ console.log(` fix: ${diagnostic.suggestedFix}`);
1923
+ }
1924
+ }
1925
+ }
1926
+
1927
+ /**
1928
+ * @param {ReturnType<typeof buildGeneratorPolicyExplainPayload>} payload
1929
+ * @returns {void}
1930
+ */
1931
+ function printGeneratorPolicyExplainPayload(payload) {
1932
+ console.log(payload.ok ? "Generator policy: allowed" : "Generator policy: denied");
1933
+ console.log(payload.ok
1934
+ ? "Decision: package-backed generators are allowed by this project's generator policy."
1935
+ : "Decision: one or more package-backed generators are blocked by this project's generator policy.");
1936
+ console.log(`Policy file: ${payload.path}`);
1937
+ console.log(`Policy file exists: ${payload.exists ? "yes" : "no"}`);
1938
+ console.log(`Default policy active: ${payload.defaulted ? "yes" : "no"}`);
1939
+ if (payload.bindings.length > 0) {
1940
+ console.log("");
1941
+ console.log("Package-backed generators:");
1942
+ for (const binding of payload.bindings) {
1943
+ console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
1944
+ console.log(` npm package: ${binding.packageInfo.installedVersion || "(not installed)"}`);
1945
+ if (binding.packageInfo.dependencySpec) {
1946
+ console.log(` dependency: ${binding.packageInfo.dependencyField} ${binding.packageInfo.dependencySpec}`);
1947
+ }
1948
+ }
1949
+ }
1950
+ if (payload.rules.length > 0) {
1951
+ console.log("");
1952
+ console.log("Policy checks:");
1953
+ }
1954
+ for (const rule of payload.rules) {
1955
+ console.log(`${rule.ok ? "PASS" : "FAIL"} ${generatorPolicyRuleLabel(rule.name)}: ${rule.message}`);
1956
+ console.log(` actual: ${rule.actual}`);
1957
+ console.log(` expected: ${rule.expected}`);
1958
+ if (!rule.ok && rule.fix) {
1959
+ console.log(` fix: ${rule.fix}`);
1960
+ }
1961
+ }
1962
+ for (const diagnostic of payload.diagnostics) {
1963
+ const label = diagnostic.severity === "warning" ? "Warning" : "Error";
1964
+ console.log(`${label}: ${diagnostic.code}: ${diagnostic.message}`);
1965
+ if (diagnostic.suggestedFix) {
1966
+ console.log(` fix: ${diagnostic.suggestedFix}`);
1967
+ }
1968
+ }
1969
+ }
1970
+
1971
+ /**
1972
+ * @param {string} projectPath
1973
+ * @param {string|null|undefined} spec
1974
+ * @returns {{ ok: boolean, path: string, policy: any, pinned: Array<{ packageName: string, version: string }>, diagnostics: any[], errors: string[] }}
1975
+ */
1976
+ function buildGeneratorPolicyPinPayload(projectPath, spec) {
1977
+ const projectConfigInfo = loadProjectConfig(projectPath);
1978
+ if (!projectConfigInfo) {
1979
+ const diagnostic = {
1980
+ code: "generator_policy_project_missing",
1981
+ severity: "error",
1982
+ message: "Cannot pin generator policy without topogram.project.json.",
1983
+ path: path.resolve(projectPath),
1984
+ suggestedFix: "Run this command in a Topogram project.",
1985
+ step: "generator-policy"
1986
+ };
1987
+ return {
1988
+ ok: false,
1989
+ path: path.join(path.resolve(projectPath), GENERATOR_POLICY_FILE),
1990
+ policy: null,
1991
+ pinned: [],
1992
+ diagnostics: [diagnostic],
1993
+ errors: [diagnostic.message]
1994
+ };
1995
+ }
1996
+ const policyInfo = loadGeneratorPolicy(projectConfigInfo.configDir);
1997
+ if (policyInfo.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
1998
+ const errors = policyInfo.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
1999
+ return {
2000
+ ok: false,
2001
+ path: policyInfo.path,
2002
+ policy: policyInfo.policy,
2003
+ pinned: [],
2004
+ diagnostics: policyInfo.diagnostics,
2005
+ errors
2006
+ };
2007
+ }
2008
+ let pins = [];
2009
+ try {
2010
+ pins = spec
2011
+ ? [parseGeneratorPolicyPin(spec)]
2012
+ : packageBackedGeneratorBindings(projectConfigInfo.config).map((binding) => ({
2013
+ packageName: binding.packageName,
2014
+ version: binding.version
2015
+ }));
2016
+ } catch (error) {
2017
+ const diagnostic = {
2018
+ code: "generator_policy_pin_invalid",
2019
+ severity: "error",
2020
+ message: error instanceof Error ? error.message : String(error),
2021
+ path: policyInfo.path,
2022
+ suggestedFix: "Pass a pin such as @topogram/generator-react-web@1.",
2023
+ step: "generator-policy"
2024
+ };
2025
+ return {
2026
+ ok: false,
2027
+ path: policyInfo.path,
2028
+ policy: policyInfo.policy,
2029
+ pinned: [],
2030
+ diagnostics: [diagnostic],
2031
+ errors: [diagnostic.message]
2032
+ };
2033
+ }
2034
+ if (pins.length === 0) {
2035
+ const diagnostic = {
2036
+ code: "generator_policy_pin_no_generators",
2037
+ severity: "error",
2038
+ message: "No package-backed topology generator bindings are available to pin.",
2039
+ path: projectConfigInfo.configPath,
2040
+ suggestedFix: "Pass an explicit pin such as @topogram/generator-react-web@1, or use bundled generators.",
2041
+ step: "generator-policy"
2042
+ };
2043
+ return {
2044
+ ok: false,
2045
+ path: policyInfo.path,
2046
+ policy: policyInfo.policy,
2047
+ pinned: [],
2048
+ diagnostics: [diagnostic],
2049
+ errors: [diagnostic.message]
2050
+ };
2051
+ }
2052
+ const policy = policyInfo.policy || defaultGeneratorPolicy();
2053
+ const allowedPackages = [...policy.allowedPackages];
2054
+ const allowedPackageScopes = [...policy.allowedPackageScopes];
2055
+ const pinnedVersions = { ...policy.pinnedVersions };
2056
+ for (const pin of pins) {
2057
+ if (!allowedPackages.includes(pin.packageName)) {
2058
+ allowedPackages.push(pin.packageName);
2059
+ }
2060
+ const scope = packageScopeFromName(pin.packageName);
2061
+ if (scope && !allowedPackageScopes.includes(scope)) {
2062
+ allowedPackageScopes.push(scope);
2063
+ }
2064
+ pinnedVersions[pin.packageName] = pin.version;
2065
+ }
2066
+ const nextPolicy = {
2067
+ ...policy,
2068
+ allowedPackageScopes,
2069
+ allowedPackages,
2070
+ pinnedVersions
2071
+ };
2072
+ writeGeneratorPolicy(projectConfigInfo.configDir, nextPolicy);
2073
+ return {
2074
+ ok: true,
2075
+ path: path.join(projectConfigInfo.configDir, GENERATOR_POLICY_FILE),
2076
+ policy: nextPolicy,
2077
+ pinned: pins,
2078
+ diagnostics: [],
2079
+ errors: []
2080
+ };
2081
+ }
2082
+
2083
+ /**
2084
+ * @param {{ ok: boolean, path: string, pinned: Array<{ packageName: string, version: string }>, diagnostics: any[] }} payload
2085
+ * @returns {void}
2086
+ */
2087
+ function printGeneratorPolicyPinPayload(payload) {
2088
+ console.log(payload.ok ? "Generator policy pin updated" : "Generator policy pin failed");
2089
+ console.log(`Policy: ${payload.path}`);
2090
+ for (const pin of payload.pinned) {
2091
+ console.log(`Pinned: ${pin.packageName}@${pin.version}`);
2092
+ }
2093
+ for (const diagnostic of payload.diagnostics) {
2094
+ console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
2095
+ if (diagnostic.path) {
2096
+ console.log(` path: ${diagnostic.path}`);
2097
+ }
2098
+ if (diagnostic.suggestedFix) {
2099
+ console.log(` fix: ${diagnostic.suggestedFix}`);
2100
+ }
2101
+ }
2102
+ }
2103
+
1527
2104
  function combineProjectValidationResults(...results) {
1528
2105
  const errors = [];
1529
2106
  for (const result of results) {
@@ -3340,6 +3917,7 @@ function printNewProjectResult(result, cwd) {
3340
3917
  }
3341
3918
  console.log(`Executable implementation: ${template.includesExecutableImplementation ? "yes" : "no"}`);
3342
3919
  console.log("Policy: topogram.template-policy.json");
3920
+ console.log(`Generator policy: ${GENERATOR_POLICY_FILE}`);
3343
3921
  console.log("Template files: .topogram-template-files.json");
3344
3922
  if (template.includesExecutableImplementation) {
3345
3923
  console.log("Trust: .topogram-template-trust.json");
@@ -3355,6 +3933,8 @@ function printNewProjectResult(result, cwd) {
3355
3933
  console.log(" npm run source:status");
3356
3934
  console.log(" npm run template:explain");
3357
3935
  console.log(" npm run check");
3936
+ console.log(" npm run generator:policy:status");
3937
+ console.log(" npm run generator:policy:check");
3358
3938
  if (template.includesExecutableImplementation) {
3359
3939
  console.log(" npm run template:policy:explain");
3360
3940
  console.log(" npm run trust:status");
@@ -6856,6 +7436,16 @@ if (args[0] === "version" || args[0] === "--version") {
6856
7436
  commandArgs = { generatorShow: true, inputPath: args[2] };
6857
7437
  } else if (args[0] === "generator" && args[1] === "check") {
6858
7438
  commandArgs = { generatorCheck: true, inputPath: args[2] };
7439
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "init") {
7440
+ commandArgs = { generatorPolicyInit: true, inputPath: commandPath(3) };
7441
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "status") {
7442
+ commandArgs = { generatorPolicyStatus: true, inputPath: commandPath(3) };
7443
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "check") {
7444
+ commandArgs = { generatorPolicyCheck: true, inputPath: commandPath(3) };
7445
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "explain") {
7446
+ commandArgs = { generatorPolicyExplain: true, inputPath: commandPath(3) };
7447
+ } else if (args[0] === "generator" && args[1] === "policy" && args[2] === "pin") {
7448
+ commandArgs = { generatorPolicyPin: true, generatorPolicyPinSpec: args[3] && !args[3].startsWith("-") ? args[3] : null, inputPath: commandPath(4) };
6859
7449
  } else if (args[0] === "generator") {
6860
7450
  printGeneratorHelp();
6861
7451
  process.exit(args[1] ? 1 : 0);
@@ -7052,6 +7642,11 @@ const shouldComponentBehavior = Boolean(commandArgs?.componentBehavior);
7052
7642
  const shouldGeneratorList = Boolean(commandArgs?.generatorList);
7053
7643
  const shouldGeneratorShow = Boolean(commandArgs?.generatorShow);
7054
7644
  const shouldGeneratorCheck = Boolean(commandArgs?.generatorCheck);
7645
+ const shouldGeneratorPolicyInit = Boolean(commandArgs?.generatorPolicyInit);
7646
+ const shouldGeneratorPolicyStatus = Boolean(commandArgs?.generatorPolicyStatus);
7647
+ const shouldGeneratorPolicyCheck = Boolean(commandArgs?.generatorPolicyCheck);
7648
+ const shouldGeneratorPolicyExplain = Boolean(commandArgs?.generatorPolicyExplain);
7649
+ const shouldGeneratorPolicyPin = Boolean(commandArgs?.generatorPolicyPin);
7055
7650
  const shouldTrustTemplate = Boolean(commandArgs?.trustTemplate);
7056
7651
  const shouldTrustStatus = Boolean(commandArgs?.trustStatus);
7057
7652
  const shouldTrustDiff = Boolean(commandArgs?.trustDiff);
@@ -7185,7 +7780,7 @@ const outIndex = args.indexOf("--out");
7185
7780
  const outPath = outIndex >= 0 ? args[outIndex + 1] : null;
7186
7781
  const effectiveOutDir = outDir || outPath || commandArgs?.defaultOutDir || null;
7187
7782
 
7188
- if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldGeneratorCheck || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldSourceStatus || shouldTemplateExplain || shouldTemplateStatus || shouldTemplateDetach || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateCheck || shouldTemplateUpdate || generateTarget === "app-bundle") && !inputPath) {
7783
+ if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldGeneratorCheck || shouldGeneratorPolicyInit || shouldGeneratorPolicyStatus || shouldGeneratorPolicyCheck || shouldGeneratorPolicyExplain || shouldGeneratorPolicyPin || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldSourceStatus || shouldTemplateExplain || shouldTemplateStatus || shouldTemplateDetach || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateCheck || shouldTemplateUpdate || generateTarget === "app-bundle") && !inputPath) {
7189
7784
  console.error("Missing required <path>.");
7190
7785
  printUsage();
7191
7786
  process.exit(1);
@@ -7239,7 +7834,7 @@ if (shouldQueryShow && !commandArgs?.queryShowName) {
7239
7834
  process.exit(1);
7240
7835
  }
7241
7836
 
7242
- if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldTemplateExplain || shouldTemplateStatus || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateUpdate || generateTarget === "app-bundle") && inputPath) {
7837
+ if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldValidate || shouldGeneratorPolicyInit || shouldGeneratorPolicyStatus || shouldGeneratorPolicyCheck || shouldGeneratorPolicyExplain || shouldGeneratorPolicyPin || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldTemplateExplain || shouldTemplateStatus || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateUpdate || generateTarget === "app-bundle") && inputPath) {
7243
7838
  inputPath = normalizeTopogramPath(inputPath);
7244
7839
  }
7245
7840
 
@@ -7366,6 +7961,69 @@ try {
7366
7961
  process.exit(payload.ok ? 0 : 1);
7367
7962
  }
7368
7963
 
7964
+ if (shouldGeneratorPolicyInit) {
7965
+ const projectConfigInfo = loadProjectConfig(inputPath);
7966
+ if (!projectConfigInfo) {
7967
+ throw new Error("Cannot initialize generator policy without topogram.project.json.");
7968
+ }
7969
+ const policy = writeGeneratorPolicy(projectConfigInfo.configDir, defaultGeneratorPolicy());
7970
+ const payload = {
7971
+ ok: true,
7972
+ path: path.join(projectConfigInfo.configDir, GENERATOR_POLICY_FILE),
7973
+ policy,
7974
+ diagnostics: [],
7975
+ errors: []
7976
+ };
7977
+ if (emitJson) {
7978
+ console.log(stableStringify(payload));
7979
+ } else {
7980
+ console.log(`Wrote generator policy: ${payload.path}`);
7981
+ console.log(`Allowed package scopes: ${policy.allowedPackageScopes.join(", ") || "(none)"}`);
7982
+ console.log(`Allowed packages: ${policy.allowedPackages.join(", ") || "(none)"}`);
7983
+ }
7984
+ process.exit(0);
7985
+ }
7986
+
7987
+ if (shouldGeneratorPolicyStatus) {
7988
+ const payload = buildGeneratorPolicyStatusPayload(inputPath);
7989
+ if (emitJson) {
7990
+ console.log(stableStringify(payload));
7991
+ } else {
7992
+ printGeneratorPolicyStatusPayload(payload);
7993
+ }
7994
+ process.exit(payload.ok ? 0 : 1);
7995
+ }
7996
+
7997
+ if (shouldGeneratorPolicyCheck) {
7998
+ const payload = buildGeneratorPolicyCheckPayload(inputPath);
7999
+ if (emitJson) {
8000
+ console.log(stableStringify(payload));
8001
+ } else {
8002
+ printGeneratorPolicyCheckPayload(payload);
8003
+ }
8004
+ process.exit(payload.ok ? 0 : 1);
8005
+ }
8006
+
8007
+ if (shouldGeneratorPolicyExplain) {
8008
+ const payload = buildGeneratorPolicyExplainPayload(inputPath);
8009
+ if (emitJson) {
8010
+ console.log(stableStringify(payload));
8011
+ } else {
8012
+ printGeneratorPolicyExplainPayload(payload);
8013
+ }
8014
+ process.exit(payload.ok ? 0 : 1);
8015
+ }
8016
+
8017
+ if (shouldGeneratorPolicyPin) {
8018
+ const payload = buildGeneratorPolicyPinPayload(inputPath, commandArgs?.generatorPolicyPinSpec);
8019
+ if (emitJson) {
8020
+ console.log(stableStringify(payload));
8021
+ } else {
8022
+ printGeneratorPolicyPinPayload(payload);
8023
+ }
8024
+ process.exit(payload.ok ? 0 : 1);
8025
+ }
8026
+
7369
8027
  if (shouldCatalogList) {
7370
8028
  const payload = buildCatalogListPayload(catalogSource || inputPath || null);
7371
8029
  if (emitJson) {
@@ -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";
@@ -1974,6 +1975,9 @@ function writeProjectPackage(projectRoot, engineRoot, template) {
1974
1975
  "template:detach:dry-run": "topogram template detach --dry-run",
1975
1976
  "template:policy:check": "topogram template policy check",
1976
1977
  "template:policy:explain": "topogram template policy explain",
1978
+ "generator:policy:status": "topogram generator policy status",
1979
+ "generator:policy:check": "topogram generator policy check",
1980
+ "generator:policy:explain": "topogram generator policy explain",
1977
1981
  "template:update:status": "topogram template update --status",
1978
1982
  "template:update:recommend": "topogram template update --recommend",
1979
1983
  "template:update:plan": "topogram template update --plan",
@@ -2049,6 +2053,9 @@ Useful inspection:
2049
2053
  npm run template:detach:dry-run
2050
2054
  npm run template:policy:check
2051
2055
  npm run template:policy:explain
2056
+ npm run generator:policy:status
2057
+ npm run generator:policy:check
2058
+ npm run generator:policy:explain
2052
2059
  npm run template:update:status
2053
2060
  npm run template:update:recommend
2054
2061
  npm run template:update:plan
@@ -2079,6 +2086,8 @@ function writeProjectReadme(projectRoot, projectConfig) {
2079
2086
  "npm run template:explain",
2080
2087
  "npm run check",
2081
2088
  "npm run template:policy:check",
2089
+ "npm run generator:policy:status",
2090
+ "npm run generator:policy:check",
2082
2091
  ...(template.includesExecutableImplementation ? [
2083
2092
  "npm run template:policy:explain",
2084
2093
  "npm run trust:status"
@@ -2154,6 +2163,7 @@ export function createNewProject({
2154
2163
  writeProjectReadme(projectRoot, projectConfig);
2155
2164
  writeTemplateFilesManifest(projectRoot, projectConfig);
2156
2165
  writeTemplatePolicy(projectRoot, defaultTemplatePolicyForTemplate(template));
2166
+ writeGeneratorPolicy(projectRoot, defaultGeneratorPolicy());
2157
2167
 
2158
2168
  const warnings = [];
2159
2169
  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) {