nix-option-synthesizer.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /**
  2. * Nix module-system option wiring (nix-option-path synthesizer).
  3. *
  4. * An option is DECLARED in one module (`options.launchd.user.agents =
  5. * mkOption { ... }`) and SET in others (`launchd.user.agents.yabai = { ... }`)
  6. * — the module-system evaluator unifies them by option path, so there is no
  7. * static edge to follow. The synthesizer links each config write to the
  8. * declaration whose path is the longest plain-segment prefix of the write
  9. * path, and these tests pin its precision gates: ambiguous declarations bail,
  10. * dynamic path heads never match, 1-segment paths never register (a package's
  11. * `meta = { ... }` must not link to `options.meta`), and submodule-internal
  12. * `options` blocks are quarantined.
  13. */
  14. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  15. import * as fs from 'node:fs';
  16. import * as path from 'node:path';
  17. import * as os from 'node:os';
  18. import { CodeGraph } from '../src';
  19. describe('nix-option-path synthesizer', () => {
  20. let dir: string;
  21. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nix-option-')); });
  22. afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
  23. async function synthEdges(d: string): Promise<any[]> {
  24. const cg = await CodeGraph.init(d, { silent: true });
  25. await cg.indexAll();
  26. const db = (cg as any).db.db;
  27. const rows = db
  28. .prepare(
  29. `SELECT s.name source, s.file_path sf, t.name target, t.file_path tf,
  30. json_extract(e.metadata,'$.optionPath') optionPath
  31. FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
  32. WHERE json_extract(e.metadata,'$.synthesizedBy') = 'nix-option-path'`
  33. )
  34. .all();
  35. cg.destroy();
  36. return rows;
  37. }
  38. it('links a cross-file config write to its flat option declaration', async () => {
  39. fs.mkdirSync(path.join(dir, 'modules'), { recursive: true });
  40. fs.writeFileSync(
  41. path.join(dir, 'modules', 'launchd.nix'),
  42. `{ config, lib, ... }:
  43. {
  44. options.launchd.user.agents = lib.mkOption {
  45. type = lib.types.attrsOf (lib.types.submodule {});
  46. default = {};
  47. description = "launchd agents";
  48. };
  49. }
  50. `
  51. );
  52. fs.writeFileSync(
  53. path.join(dir, 'modules', 'yabai.nix'),
  54. `{ config, lib, ... }:
  55. {
  56. config = lib.mkIf config.services.yabai.enable {
  57. launchd.user.agents.yabai = {
  58. command = "yabai";
  59. keepAlive = true;
  60. };
  61. };
  62. }
  63. `
  64. );
  65. const edges = await synthEdges(dir);
  66. const hit = edges.find((e) => e.source === 'launchd.user.agents.yabai');
  67. expect(hit).toBeDefined();
  68. expect(hit.target).toBe('options.launchd.user.agents');
  69. expect(hit.tf).toBe('modules/launchd.nix');
  70. expect(hit.optionPath).toBe('launchd.user.agents');
  71. });
  72. it('composes nested declaration spellings and prefers the longest declared prefix', async () => {
  73. fs.writeFileSync(
  74. path.join(dir, 'git-module.nix'),
  75. `{ lib, ... }:
  76. {
  77. options = {
  78. programs.git = {
  79. enable = lib.mkOption {
  80. type = lib.types.bool;
  81. default = false;
  82. };
  83. signing.key = lib.mkOption {
  84. type = lib.types.str;
  85. default = "";
  86. };
  87. };
  88. };
  89. }
  90. `
  91. );
  92. fs.writeFileSync(
  93. path.join(dir, 'user-config.nix'),
  94. `{ ... }:
  95. {
  96. programs.git.enable = true;
  97. programs.git.signing.key = "ABCD1234";
  98. }
  99. `
  100. );
  101. const edges = await synthEdges(dir);
  102. const enable = edges.find((e) => e.source === 'programs.git.enable');
  103. const key = edges.find((e) => e.source === 'programs.git.signing.key');
  104. expect(enable).toBeDefined();
  105. // Longest declared prefix wins: the leaf `enable` declaration, not `programs.git`.
  106. expect(enable.optionPath).toBe('programs.git.enable');
  107. expect(enable.target).toBe('enable');
  108. expect(key).toBeDefined();
  109. expect(key.optionPath).toBe('programs.git.signing.key');
  110. expect(key.target).toBe('signing.key');
  111. });
  112. it('matches through a quoted segment only up to the static prefix', async () => {
  113. fs.writeFileSync(
  114. path.join(dir, 'xdg.nix'),
  115. `{ lib, ... }:
  116. {
  117. options.xdg.configFile = lib.mkOption {
  118. type = lib.types.attrsOf (lib.types.anything);
  119. default = {};
  120. };
  121. }
  122. `
  123. );
  124. fs.writeFileSync(
  125. path.join(dir, 'writer.nix'),
  126. `{ ... }:
  127. {
  128. xdg.configFile."git/config".text = "[user]";
  129. }
  130. `
  131. );
  132. const edges = await synthEdges(dir);
  133. const hit = edges.find((e) => e.sf === 'writer.nix');
  134. expect(hit).toBeDefined();
  135. expect(hit.optionPath).toBe('xdg.configFile');
  136. expect(hit.target).toBe('options.xdg.configFile');
  137. });
  138. it('anchors quoted writes to their own quoted declaration, never a sibling', async () => {
  139. // NSGlobalDomain-style enumerated quoted options: each quoted write must
  140. // hit ITS declaration; an undeclared quoted write must not fall back to a
  141. // same-prefix sibling.
  142. fs.writeFileSync(
  143. path.join(dir, 'domain.nix'),
  144. `{ lib, ... }:
  145. {
  146. options = {
  147. system.defaults.NSGlobalDomain."com.apple.keyboard.fnState" = lib.mkOption {
  148. type = lib.types.nullOr lib.types.bool;
  149. default = null;
  150. };
  151. system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior" = lib.mkOption {
  152. type = lib.types.nullOr lib.types.int;
  153. default = null;
  154. };
  155. };
  156. }
  157. `
  158. );
  159. fs.writeFileSync(
  160. path.join(dir, 'writer.nix'),
  161. `{ ... }:
  162. {
  163. system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior" = 1;
  164. system.defaults.NSGlobalDomain."com.apple.undeclared.domain" = 2;
  165. }
  166. `
  167. );
  168. const edges = await synthEdges(dir);
  169. const tap = edges.filter((e) => e.sf === 'writer.nix' && e.source.includes('tapBehavior'));
  170. expect(tap).toHaveLength(1);
  171. expect(tap[0].target).toContain('tapBehavior');
  172. expect(tap[0].optionPath).toBe('system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior"');
  173. // No parent declaration exists, so the undeclared quoted write stays silent.
  174. expect(edges.filter((e) => e.source.includes('undeclared'))).toEqual([]);
  175. });
  176. it('bails on ambiguous declarations and dynamic path heads; never registers 1-segment paths', async () => {
  177. fs.writeFileSync(
  178. path.join(dir, 'dup-a.nix'),
  179. `{ lib, ... }: { options.services.dup = lib.mkOption { default = {}; }; }
  180. `
  181. );
  182. fs.writeFileSync(
  183. path.join(dir, 'dup-b.nix'),
  184. `{ lib, ... }:
  185. {
  186. options.services.dup = lib.mkOption {
  187. default = {};
  188. };
  189. }
  190. `
  191. );
  192. fs.writeFileSync(
  193. path.join(dir, 'meta-decl.nix'),
  194. `{ lib, ... }:
  195. {
  196. options.meta = lib.mkOption {
  197. default = {};
  198. };
  199. }
  200. `
  201. );
  202. fs.writeFileSync(
  203. path.join(dir, 'writers.nix'),
  204. `{ name, ... }:
  205. {
  206. services.dup.enable = true;
  207. services.\${name}.enable = true;
  208. meta.maintainers = [ "someone" ];
  209. }
  210. `
  211. );
  212. const edges = await synthEdges(dir);
  213. // services.dup is declared in two files → ambiguous → no edge at all.
  214. expect(edges.filter((e) => e.source === 'services.dup.enable')).toEqual([]);
  215. // The interpolated head leaves <2 static segments → no edge.
  216. expect(edges.filter((e) => e.sf === 'writers.nix' && e.optionPath?.startsWith('services'))).toEqual([]);
  217. // `options.meta` is a 1-segment path → never registered, `meta.*` writes stay unlinked.
  218. expect(edges.filter((e) => e.source?.startsWith('meta.'))).toEqual([]);
  219. });
  220. it('quarantines submodule-internal options blocks', async () => {
  221. fs.writeFileSync(
  222. path.join(dir, 'agents.nix'),
  223. `{ lib, ... }:
  224. {
  225. options.launchd.agents = lib.mkOption {
  226. type = lib.types.attrsOf (lib.types.submodule {
  227. options = {
  228. command.text = lib.mkOption {
  229. type = lib.types.str;
  230. default = "";
  231. };
  232. };
  233. });
  234. };
  235. }
  236. `
  237. );
  238. fs.writeFileSync(
  239. path.join(dir, 'writer.nix'),
  240. `{ ... }:
  241. {
  242. command.text = "not an option write";
  243. launchd.agents.myapp = { };
  244. }
  245. `
  246. );
  247. const edges = await synthEdges(dir);
  248. // The submodule's own `command.text` namespace is not globally addressable.
  249. expect(edges.filter((e) => e.source === 'command.text')).toEqual([]);
  250. // The outer attrsOf declaration still anchors writes into the attr set.
  251. const hit = edges.find((e) => e.source === 'launchd.agents.myapp');
  252. expect(hit).toBeDefined();
  253. expect(hit.optionPath).toBe('launchd.agents');
  254. });
  255. });