pi-usereq 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import {
16
16
  createStaticCheckLanguageConfig,
17
17
  DEFAULT_DOCS_DIR,
18
18
  getDefaultConfig,
19
+ getGlobalConfigPath,
19
20
  getProjectConfigPath,
20
21
  loadConfig,
21
22
  } from "../src/core/config.js";
@@ -58,7 +59,13 @@ import {
58
59
  } from "../src/core/extension-status.js";
59
60
  import { showPiUsereqSettingsMenu } from "../src/core/settings-menu.js";
60
61
  import { getSupportedStaticCheckLanguageSupport } from "../src/core/static-check.js";
61
- import { createTempDir, initFixtureRepo } from "./helpers.js";
62
+ import {
63
+ createTempDir,
64
+ initFixtureRepo,
65
+ readGlobalConfigJson,
66
+ readProjectConfigJson,
67
+ saveFixtureConfigs,
68
+ } from "./helpers.js";
62
69
 
63
70
  /**
64
71
  * @brief Describes one fake extension command registration captured during tests.
@@ -91,17 +98,16 @@ type RegisteredTool = {
91
98
  type RegisteredShortcut = { description?: string; handler: (ctx: any) => Promise<void> | void };
92
99
 
93
100
  /**
94
- * @brief Applies targeted `.pi-usereq.json` overrides inside one test project.
95
- * @details Loads the persisted config JSON, merges the supplied top-level overrides, and writes the updated payload back with a trailing newline so prompt-command tests can script worktree and notification behavior deterministically. Runtime is O(n) in config size. Side effects include filesystem reads and file overwrite.
101
+ * @brief Applies targeted effective-config overrides inside one test project.
102
+ * @details Loads the merged local/global config, merges the supplied top-level overrides, rewrites the split Node config files plus the oracle mirror, and commits the repository-local config artifacts when the fixture is a git repo. Runtime is O(n) in config size. Side effects include filesystem reads, file overwrite, and optional git commits.
96
103
  * @param[in] projectBase {string} Fixture project root.
97
104
  * @param[in] overrides {Record<string, unknown>} Top-level config overrides.
98
105
  * @return {void} No return value.
99
106
  */
100
107
  function writeProjectConfigOverrides(projectBase: string, overrides: Record<string, unknown>): void {
101
- const configPath = getProjectConfigPath(projectBase);
102
- const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
108
+ const config = loadConfig(projectBase);
103
109
  Object.assign(config, overrides);
104
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
110
+ saveFixtureConfigs(projectBase, config);
105
111
  if (fs.existsSync(path.join(projectBase, ".git"))) {
106
112
  const gitEnv = {
107
113
  ...process.env,
@@ -519,13 +525,13 @@ function buildExpectedFakeBasePath(cwd: string): string {
519
525
  }
520
526
 
521
527
  /**
522
- * @brief Formats one expected home-relative base path for notify-template assertions.
523
- * @details Resolves the supplied cwd, emits `~` or `~/...` when the path is inside the current user home directory, and otherwise returns the slash-normalized absolute path. Runtime is O(p) in path length. No external state is mutated.
524
- * @param[in] cwd {string} Runtime working directory.
525
- * @return {string} Home-relative or absolute base path used by notification templates.
528
+ * @brief Formats one expected home-relative display path.
529
+ * @details Resolves the supplied path, emits `~` or `~/...` when the path is inside the current user home directory, and otherwise returns the slash-normalized absolute path. Runtime is O(p) in path length. No external state is mutated.
530
+ * @param[in] targetPath {string} Absolute or relative path candidate.
531
+ * @return {string} Home-relative or absolute display path.
526
532
  */
527
- function buildExpectedNotifyBasePath(cwd: string): string {
528
- const resolvedPath = path.resolve(cwd);
533
+ function buildExpectedDisplayPath(targetPath: string): string {
534
+ const resolvedPath = path.resolve(targetPath);
529
535
  const homePath = path.resolve(process.env.HOME ?? os.homedir());
530
536
  if (resolvedPath === homePath) {
531
537
  return "~";
@@ -538,15 +544,32 @@ function buildExpectedNotifyBasePath(cwd: string): string {
538
544
  }
539
545
 
540
546
  /**
541
- * @brief Formats the expected `Show configuration` path value.
542
- * @details Resolves the current project config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
547
+ * @brief Formats one expected home-relative base path for notify-template assertions.
548
+ * @details Delegates to the generic display-path formatter so notify-template assertions match the runtime `~`-relative path contract. Runtime is O(p) in path length. No external state is mutated.
543
549
  * @param[in] cwd {string} Runtime working directory.
544
- * @return {string} `~`-relative or absolute config-path display string.
550
+ * @return {string} Home-relative or absolute base path used by notification templates.
545
551
  */
546
- function buildExpectedShowConfigPath(cwd: string): string {
547
- return buildExpectedNotifyBasePath(path.dirname(getProjectConfigPath(cwd))) === buildExpectedNotifyBasePath(cwd)
548
- ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq.json`
549
- : getProjectConfigPath(cwd).split(path.sep).join("/");
552
+ function buildExpectedNotifyBasePath(cwd: string): string {
553
+ return buildExpectedDisplayPath(cwd);
554
+ }
555
+
556
+ /**
557
+ * @brief Formats the expected `Show local configuration` path value.
558
+ * @details Resolves the current local project config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
559
+ * @param[in] cwd {string} Runtime working directory.
560
+ * @return {string} `~`-relative or absolute local config-path display string.
561
+ */
562
+ function buildExpectedShowLocalConfigPath(cwd: string): string {
563
+ return buildExpectedDisplayPath(getProjectConfigPath(cwd));
564
+ }
565
+
566
+ /**
567
+ * @brief Formats the expected `Show global configuration` path value.
568
+ * @details Resolves the current global config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
569
+ * @return {string} `~`-relative or absolute global config-path display string.
570
+ */
571
+ function buildExpectedShowGlobalConfigPath(): string {
572
+ return buildExpectedDisplayPath(getGlobalConfigPath());
550
573
  }
551
574
 
552
575
  /**
@@ -1505,19 +1528,15 @@ test("static-check tools return monolithic text payloads with minimal execution
1505
1528
  assert.equal(staticCheckResult.details?.execution.code, 0);
1506
1529
  assert.deepEqual(staticCheckResult.details?.execution.stderr_lines, undefined);
1507
1530
 
1508
- fs.writeFileSync(
1509
- getProjectConfigPath(projectBase),
1510
- `${JSON.stringify({
1511
- ...getDefaultConfig(projectBase),
1512
- "docs-dir": DEFAULT_DOCS_DIR,
1513
- "tests-dir": "tests",
1514
- "src-dir": ["src"],
1515
- "static-check": {
1516
- TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1517
- },
1518
- }, null, 2)}\n`,
1519
- "utf8",
1520
- );
1531
+ saveFixtureConfigs(projectBase, {
1532
+ ...getDefaultConfig(projectBase),
1533
+ "docs-dir": DEFAULT_DOCS_DIR,
1534
+ "tests-dir": "tests",
1535
+ "src-dir": ["src"],
1536
+ "static-check": {
1537
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1538
+ },
1539
+ });
1521
1540
  const disabledFilesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1522
1541
  files: ["src/check.ts"],
1523
1542
  }) as {
@@ -1541,7 +1560,7 @@ test("configuration menu saves updated docs-dir in project config", async () =>
1541
1560
  assert.ok(command);
1542
1561
 
1543
1562
  await command!.handler("", createFakeCtx(cwd, { selects: ["docs-dir", "Save and close"], inputs: ["docs/custom"] }));
1544
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1563
+ const config = readProjectConfigJson(cwd);
1545
1564
  assert.equal(config["docs-dir"], "docs/custom");
1546
1565
  });
1547
1566
 
@@ -1566,7 +1585,7 @@ test("configuration menu persists trailing-slash-free relative directory values"
1566
1585
 
1567
1586
  await command!.handler("", ctx);
1568
1587
 
1569
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1588
+ const config = readProjectConfigJson(cwd);
1570
1589
  assert.equal(config["docs-dir"], "docs/custom");
1571
1590
  assert.equal(config["tests-dir"], "tests/custom");
1572
1591
  assert.deepEqual(config["src-dir"], ["src", "src/custom"]);
@@ -1586,12 +1605,14 @@ test("configuration menu forces worktree rows off and dim when auto git commit i
1586
1605
 
1587
1606
  await command!.handler("", ctx);
1588
1607
 
1589
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1608
+ const localConfig = readProjectConfigJson(cwd);
1609
+ const globalConfig = readGlobalConfigJson();
1590
1610
  const defaultConfig = getDefaultConfig(cwd);
1591
1611
  const renderedLockedMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1592
- assert.equal(config.AUTO_GIT_COMMIT, "disable");
1593
- assert.equal(config.GIT_WORKTREE_ENABLED, "disable");
1594
- assert.equal(config.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1612
+ assert.equal(globalConfig.AUTO_GIT_COMMIT, "disable");
1613
+ assert.equal(globalConfig.GIT_WORKTREE_ENABLED, "disable");
1614
+ assert.equal(globalConfig.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1615
+ assert.equal(Object.hasOwn(localConfig, "AUTO_GIT_COMMIT"), false);
1595
1616
  assert.match(renderedLockedMenu, /<dim>Git worktree/);
1596
1617
  assert.match(renderedLockedMenu, /<dim>Worktree prefix/);
1597
1618
  });
@@ -1612,7 +1633,7 @@ test("configuration menu omits prompt-delivery controls and persisted config omi
1612
1633
  assert.equal(fs.existsSync(getProjectConfigPath(cwd)), false);
1613
1634
  });
1614
1635
 
1615
- test("configuration menus expose show-config ordering and omit overview or notification-reference rows", async () => {
1636
+ test("configuration menus expose local/global config ordering and omit overview or notification-reference rows", async () => {
1616
1637
  const cwd = fs.mkdtempSync(
1617
1638
  path.join(process.env.HOME ?? process.cwd(), "pi-usereq-menu-structure-"),
1618
1639
  );
@@ -1638,10 +1659,18 @@ test("configuration menus expose show-config ordering and omit overview or notif
1638
1659
  "Enable tools",
1639
1660
  "Notifications",
1640
1661
  "Debug",
1641
- "Show configuration",
1662
+ "Show local configuration",
1663
+ "Show global configuration",
1642
1664
  "Reset defaults",
1643
1665
  ]);
1644
- assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1666
+ assert.match(
1667
+ renderedMenu,
1668
+ new RegExp(`<dim>${buildExpectedShowLocalConfigPath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dim>`),
1669
+ );
1670
+ assert.match(
1671
+ renderedMenu,
1672
+ new RegExp(`<dim>${buildExpectedShowGlobalConfigPath().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dim>`),
1673
+ );
1645
1674
  assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1646
1675
  assert.ok(renderedMenu.includes("disable"));
1647
1676
  assert.doesNotMatch(renderedMenu, /beep:/);
@@ -1668,15 +1697,15 @@ test("configuration menus expose show-config ordering and omit overview or notif
1668
1697
  ]);
1669
1698
  });
1670
1699
 
1671
- test("show configuration saves pending config, closes the menu, and writes the persisted project config file text into the editor", async () => {
1672
- const cwd = createTempDir("pi-usereq-menu-show-config-");
1700
+ test("show local configuration saves pending config, closes the menu, and writes the persisted local config file text into the editor", async () => {
1701
+ const cwd = createTempDir("pi-usereq-menu-show-local-config-");
1673
1702
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1674
1703
  const pi = createFakePi();
1675
1704
  piUsereqExtension(pi);
1676
1705
  const command = pi.commands.get("pi-usereq");
1677
1706
  assert.ok(command);
1678
1707
  const ctx = createFakeCtx(cwd, {
1679
- selects: ["docs-dir", "show-config"],
1708
+ selects: ["docs-dir", "show-local-config"],
1680
1709
  inputs: ["docs/custom"],
1681
1710
  });
1682
1711
 
@@ -1691,6 +1720,28 @@ test("show configuration saves pending config, closes the menu, and writes the p
1691
1720
  );
1692
1721
  });
1693
1722
 
1723
+ test("show global configuration saves pending config, closes the menu, and writes the persisted global config file text into the editor", async () => {
1724
+ const cwd = createTempDir("pi-usereq-menu-show-global-config-");
1725
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1726
+ const pi = createFakePi();
1727
+ piUsereqExtension(pi);
1728
+ const command = pi.commands.get("pi-usereq");
1729
+ assert.ok(command);
1730
+ const ctx = createFakeCtx(cwd, {
1731
+ selects: ["Auto git commit", "show-global-config"],
1732
+ });
1733
+
1734
+ await command!.handler("", ctx);
1735
+
1736
+ const persistedText = fs.readFileSync(getGlobalConfigPath(), "utf8");
1737
+ assert.equal(ctx.__state.editorText, persistedText);
1738
+ assert.match(ctx.__state.editorText, /"AUTO_GIT_COMMIT": "disable"/);
1739
+ assert.equal(
1740
+ ctx.__state.selectCalls.filter((call) => call.title === "pi-usereq").length,
1741
+ 2,
1742
+ );
1743
+ });
1744
+
1694
1745
  test("descendant configuration menus end with Reset defaults only", async () => {
1695
1746
  const cwd = createTempDir("pi-usereq-menu-terminal-rows-");
1696
1747
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1785,7 +1836,7 @@ test("debug menu dims locked rows and persists debug settings with focus-preserv
1785
1836
  await command!.handler("", ctx);
1786
1837
 
1787
1838
  const renderedInitialDebugMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1788
- const persistedConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1839
+ const persistedConfig = readProjectConfigJson(cwd);
1789
1840
  const debugCalls = ctx.__state.selectCalls.filter((call) => call.title === "Debug");
1790
1841
  assert.match(renderedInitialDebugMenu, /<dim>Log file/);
1791
1842
  assert.match(renderedInitialDebugMenu, /<dim>Log on status/);
@@ -1811,7 +1862,7 @@ test("debug menu dims locked rows and persists debug settings with focus-preserv
1811
1862
  selects: ["Debug", "Reset defaults", "Approve reset", "Save and close"],
1812
1863
  });
1813
1864
  await command!.handler("", resetCtx);
1814
- const resetConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1865
+ const resetConfig = readProjectConfigJson(cwd);
1815
1866
  assert.equal(resetConfig.DEBUG_ENABLED, "disable");
1816
1867
  assert.equal(resetConfig.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
1817
1868
  assert.equal(resetConfig.DEBUG_STATUS_CHANGES, "disable");
@@ -1856,17 +1907,13 @@ test("debug menu rows derive from canonical tool and prompt inventories", async
1856
1907
  test("tool debug logging honors global enablement and workflow-status filters", async () => {
1857
1908
  const idleBase = createTempDir("pi-usereq-tool-debug-idle-");
1858
1909
  fs.mkdirSync(path.dirname(getProjectConfigPath(idleBase)), { recursive: true });
1859
- fs.writeFileSync(
1860
- getProjectConfigPath(idleBase),
1861
- `${JSON.stringify({
1862
- ...getDefaultConfig(idleBase),
1863
- DEBUG_ENABLED: "enable",
1864
- DEBUG_LOG_FILE: "tool-debug.json",
1865
- DEBUG_LOG_ON_STATUS: "any",
1866
- DEBUG_ENABLED_TOOLS: ["bash"],
1867
- }, null, 2)}\n`,
1868
- "utf8",
1869
- );
1910
+ saveFixtureConfigs(idleBase, {
1911
+ ...getDefaultConfig(idleBase),
1912
+ DEBUG_ENABLED: "enable",
1913
+ DEBUG_LOG_FILE: "tool-debug.json",
1914
+ DEBUG_LOG_ON_STATUS: "any",
1915
+ DEBUG_ENABLED_TOOLS: ["bash"],
1916
+ });
1870
1917
  const idlePi = createFakePi();
1871
1918
  piUsereqExtension(idlePi);
1872
1919
  const idleCtx = createFakeCtx(idleBase);
@@ -1927,17 +1974,13 @@ test("tool debug logging honors global enablement and workflow-status filters",
1927
1974
 
1928
1975
  const disabledBase = createTempDir("pi-usereq-tool-debug-disabled-");
1929
1976
  fs.mkdirSync(path.dirname(getProjectConfigPath(disabledBase)), { recursive: true });
1930
- fs.writeFileSync(
1931
- getProjectConfigPath(disabledBase),
1932
- `${JSON.stringify({
1933
- ...getDefaultConfig(disabledBase),
1934
- DEBUG_ENABLED: "disable",
1935
- DEBUG_LOG_FILE: "tool-debug.json",
1936
- DEBUG_LOG_ON_STATUS: "any",
1937
- DEBUG_ENABLED_TOOLS: ["bash"],
1938
- }, null, 2)}\n`,
1939
- "utf8",
1940
- );
1977
+ saveFixtureConfigs(disabledBase, {
1978
+ ...getDefaultConfig(disabledBase),
1979
+ DEBUG_ENABLED: "disable",
1980
+ DEBUG_LOG_FILE: "tool-debug.json",
1981
+ DEBUG_LOG_ON_STATUS: "any",
1982
+ DEBUG_ENABLED_TOOLS: ["bash"],
1983
+ });
1941
1984
  const disabledPi = createFakePi();
1942
1985
  piUsereqExtension(disabledPi);
1943
1986
  const disabledCtx = createFakeCtx(disabledBase);
@@ -2181,11 +2224,12 @@ test("notifications reset defaults preserves non-notification settings", async (
2181
2224
 
2182
2225
  await command!.handler("", ctx);
2183
2226
 
2184
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2185
- assert.equal(config["docs-dir"], "docs/custom");
2186
- assert.equal(config["notify-enabled"], false);
2187
- assert.equal(config["notify-sound"], "none");
2188
- assert.equal(config["notify-pushover-enabled"], false);
2227
+ const localConfig = readProjectConfigJson(cwd);
2228
+ const globalConfig = readGlobalConfigJson();
2229
+ assert.equal(localConfig["docs-dir"], "docs/custom");
2230
+ assert.equal(globalConfig["notify-enabled"], false);
2231
+ assert.equal(globalConfig["notify-sound"], "none");
2232
+ assert.equal(globalConfig["notify-pushover-enabled"], false);
2189
2233
  });
2190
2234
 
2191
2235
  test("top-level reset defaults restores all configuration values", async () => {
@@ -2210,11 +2254,12 @@ test("top-level reset defaults restores all configuration values", async () => {
2210
2254
 
2211
2255
  await command!.handler("", ctx);
2212
2256
 
2213
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2214
- assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
2215
- assert.deepEqual(config["src-dir"], ["src"]);
2216
- assert.equal(config["notify-enabled"], false);
2217
- assert.equal(config["notify-sound"], "none");
2257
+ const localConfig = readProjectConfigJson(cwd);
2258
+ const globalConfig = readGlobalConfigJson();
2259
+ assert.equal(localConfig["docs-dir"], DEFAULT_DOCS_DIR);
2260
+ assert.deepEqual(localConfig["src-dir"], ["src"]);
2261
+ assert.equal(globalConfig["notify-enabled"], false);
2262
+ assert.equal(globalConfig["notify-sound"], "none");
2218
2263
  });
2219
2264
 
2220
2265
  test("configuration menus reuse CLI settings theme semantics", async () => {
@@ -4795,17 +4840,11 @@ test("prompt commands remain functional when custom tool registrations are remov
4795
4840
  test("session_start applies configured pi-usereq startup tools", async () => {
4796
4841
  const cwd = createTempDir("pi-usereq-tools-startup-");
4797
4842
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4798
- fs.writeFileSync(
4799
- getProjectConfigPath(cwd),
4800
- `${JSON.stringify({
4801
- "docs-dir": DEFAULT_DOCS_DIR,
4802
- "tests-dir": "tests",
4803
- "src-dir": ["src", "foobar"],
4804
- "static-check": {},
4805
- "enabled-tools": ["files-tokens", "static-check"],
4806
- }, null, 2)}\n`,
4807
- "utf8",
4808
- );
4843
+ saveFixtureConfigs(cwd, {
4844
+ ...getDefaultConfig(cwd),
4845
+ "src-dir": ["src", "foobar"],
4846
+ "enabled-tools": ["files-tokens", "static-check"],
4847
+ });
4809
4848
 
4810
4849
  const pi = createFakePi();
4811
4850
  piUsereqExtension(pi);
@@ -4915,7 +4954,7 @@ test("stale replacement contexts do not break workflow-state rendering", () => {
4915
4954
  const cwd = createTempDir("pi-usereq-stale-render-");
4916
4955
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4917
4956
  const controller = createPiUsereqStatusController();
4918
- setPiUsereqStatusConfig(controller, getDefaultConfig());
4957
+ setPiUsereqStatusConfig(controller, getDefaultConfig(cwd));
4919
4958
  const staleCtx = createFakeCtx(cwd);
4920
4959
  Object.defineProperty(staleCtx, "ui", {
4921
4960
  configurable: true,
@@ -5296,7 +5335,9 @@ test("session_start enables default custom tools and default embedded tools only
5296
5335
  });
5297
5336
 
5298
5337
  test("default configuration applies the documented static-check, debug, notify, sound, and pushover defaults", () => {
5299
- const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
5338
+ const cwd = createTempDir("pi-usereq-default-notify-");
5339
+ const config = getDefaultConfig(cwd);
5340
+ saveFixtureConfigs(cwd, config);
5300
5341
 
5301
5342
  assert.equal(config.AUTO_GIT_COMMIT, "enable");
5302
5343
  assert.deepEqual(Object.keys(config["static-check"]).length, 20);
@@ -5363,6 +5404,21 @@ test("default configuration applies the documented static-check, debug, notify,
5363
5404
  assert.equal(config["notify-pushover-on-failed"], false);
5364
5405
  assert.equal(config.PI_NOTIFY_CMD, 'notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a "PI-useReq" "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"');
5365
5406
  assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
5407
+
5408
+ const localConfig = readProjectConfigJson(cwd);
5409
+ const globalConfig = readGlobalConfigJson();
5410
+ assert.equal((localConfig["static-check"] as Record<string, any>).Python.enabled, "enable");
5411
+ assert.equal((localConfig["static-check"] as Record<string, any>).Ruby.enabled, "disable");
5412
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Python.checkers, [
5413
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
5414
+ { module: "Command", cmd: "ruff", params: ["check"] },
5415
+ ]);
5416
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Ruby.checkers, []);
5417
+ assert.equal(globalConfig.AUTO_GIT_COMMIT, "enable");
5418
+ assert.equal(globalConfig.GIT_WORKTREE_ENABLED, "enable");
5419
+ assert.equal(globalConfig["notify-enabled"], false);
5420
+ assert.equal(Object.hasOwn(localConfig, "notify-enabled"), false);
5421
+ assert.equal(Object.hasOwn(localConfig, "enabled-tools"), false);
5366
5422
  });
5367
5423
 
5368
5424
  test("configuration menu orders tool toggles and can enable embedded builtin tools", async () => {
@@ -5378,7 +5434,7 @@ test("configuration menu orders tool toggles and can enable embedded builtin too
5378
5434
 
5379
5435
  await command!.handler("", ctx);
5380
5436
 
5381
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5437
+ const config = readGlobalConfigJson();
5382
5438
  assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
5383
5439
  "compress",
5384
5440
  "references",
@@ -5419,7 +5475,7 @@ test("configuration menu can disable configurable active tools", async () => {
5419
5475
  }),
5420
5476
  );
5421
5477
 
5422
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5478
+ const config = readGlobalConfigJson();
5423
5479
  assert.deepEqual(config["enabled-tools"], []);
5424
5480
  assert.deepEqual(pi.getActiveTools().filter((toolName: string) => PI_USEREQ_STARTUP_TOOL_NAMES.includes(toolName as never)), []);
5425
5481
  });
@@ -5463,7 +5519,7 @@ test("configuration menu can persist notify and boot-sound settings without chan
5463
5519
  await pi.emit("session_start", { reason: "startup" }, ctx);
5464
5520
  await command!.handler("", ctx);
5465
5521
 
5466
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5522
+ const config = readGlobalConfigJson();
5467
5523
  assert.equal(config["notify-enabled"], true);
5468
5524
  assert.equal(config["notify-on-completed"], true);
5469
5525
  assert.equal(config["notify-on-interrupted"], false);
@@ -5524,7 +5580,7 @@ test("configuration menu can persist pushover settings", async () => {
5524
5580
 
5525
5581
  await command!.handler("", ctx);
5526
5582
 
5527
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5583
+ const config = readGlobalConfigJson();
5528
5584
  assert.equal(config["notify-pushover-enabled"], true);
5529
5585
  assert.equal(config["notify-pushover-on-completed"], true);
5530
5586
  assert.equal(config["notify-pushover-on-interrupted"], false);
@@ -5551,14 +5607,10 @@ test("configuration menu can persist pushover settings", async () => {
5551
5607
  test("notifications menu escapes pushover text and locks pushover enablement until credentials exist", async () => {
5552
5608
  const cwd = createTempDir("pi-usereq-menu-pushover-display-");
5553
5609
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
5554
- fs.writeFileSync(
5555
- getProjectConfigPath(cwd),
5556
- `${JSON.stringify({
5557
- ...getDefaultConfig(cwd),
5558
- "notify-pushover-text": "first line\nsecond line",
5559
- }, null, 2)}\n`,
5560
- "utf8",
5561
- );
5610
+ saveFixtureConfigs(cwd, {
5611
+ ...getDefaultConfig(cwd),
5612
+ "notify-pushover-text": "first line\nsecond line",
5613
+ });
5562
5614
  const pi = createFakePi();
5563
5615
  piUsereqExtension(pi);
5564
5616
  const command = pi.commands.get("pi-usereq");
@@ -5784,7 +5836,7 @@ test("pushover global enable controls status and suppresses delivery when disabl
5784
5836
  await command!.handler("", ctx);
5785
5837
  writeProjectConfigOverrides(cwd, { GIT_WORKTREE_ENABLED: "disable" });
5786
5838
 
5787
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5839
+ const config = readGlobalConfigJson();
5788
5840
  assert.equal(config["notify-pushover-enabled"], false);
5789
5841
  assert.equal(config["notify-pushover-on-completed"], true);
5790
5842
  assert.equal(
@@ -6038,8 +6090,9 @@ test("sound routing honors the selected sound state and per-event toggles", asyn
6038
6090
  test("sound toggle shortcut cycles active runtime pi-notify sound levels without persisting boot config", async () => {
6039
6091
  const cwd = createTempDir("pi-usereq-shortcut-notify-");
6040
6092
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
6041
- const persistedConfigText = `${JSON.stringify(getDefaultConfig(cwd), null, 2)}\n`;
6042
- fs.writeFileSync(getProjectConfigPath(cwd), persistedConfigText, "utf8");
6093
+ const persistedConfig = getDefaultConfig(cwd);
6094
+ saveFixtureConfigs(cwd, persistedConfig);
6095
+ const persistedGlobalConfigText = fs.readFileSync(getGlobalConfigPath(), "utf8");
6043
6096
  const previousCwd = process.cwd();
6044
6097
  const pi = createFakePi();
6045
6098
  process.chdir(cwd);
@@ -6056,7 +6109,7 @@ test("sound toggle shortcut cycles active runtime pi-notify sound levels without
6056
6109
 
6057
6110
  for (const expectedSound of ["low", "mid", "high", "none"]) {
6058
6111
  await shortcut!.handler(ctx);
6059
- assert.equal(fs.readFileSync(getProjectConfigPath(cwd), "utf8"), persistedConfigText);
6112
+ assert.equal(fs.readFileSync(getGlobalConfigPath(), "utf8"), persistedGlobalConfigText);
6060
6113
  assert.equal(
6061
6114
  ctx.__state.statuses.get("pi-usereq"),
6062
6115
  buildExpectedFakeStatusText({
@@ -6077,11 +6130,7 @@ test("session_start loads the active runtime sound from persisted boot config",
6077
6130
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
6078
6131
  const persistedConfig = getDefaultConfig(cwd);
6079
6132
  persistedConfig["notify-sound"] = "mid";
6080
- fs.writeFileSync(
6081
- getProjectConfigPath(cwd),
6082
- `${JSON.stringify(persistedConfig, null, 2)}\n`,
6083
- "utf8",
6084
- );
6133
+ saveFixtureConfigs(cwd, persistedConfig);
6085
6134
  const previousCwd = process.cwd();
6086
6135
  const pi = createFakePi();
6087
6136
  process.chdir(cwd);
@@ -6180,9 +6229,10 @@ test("configuration menu can toggle per-language static-check enablement", async
6180
6229
  }),
6181
6230
  );
6182
6231
 
6183
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
6184
- assert.equal(config["static-check"].Python.enabled, "disable");
6185
- assert.deepEqual(config["static-check"].Python.checkers, [
6232
+ const localConfig = readProjectConfigJson(cwd);
6233
+ const globalConfig = readGlobalConfigJson();
6234
+ assert.equal((localConfig["static-check"] as Record<string, any>).Python.enabled, "disable");
6235
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Python.checkers, [
6186
6236
  { module: "Command", cmd: "pyright", params: ["--outputjson"] },
6187
6237
  { module: "Command", cmd: "ruff", params: ["check"] },
6188
6238
  ]);
@@ -6210,8 +6260,10 @@ test("configuration menu can add guided static-check entries for explicit suppor
6210
6260
  }),
6211
6261
  );
6212
6262
 
6213
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
6214
- assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([
6263
+ const localConfig = readProjectConfigJson(cwd);
6264
+ const globalConfig = readGlobalConfigJson();
6265
+ assert.equal((localConfig["static-check"] as Record<string, any>).Ruby.enabled, "enable");
6266
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Ruby.checkers, [
6215
6267
  { module: "Command", cmd: "true" },
6216
- ], "enable"));
6268
+ ]);
6217
6269
  });
package/tests/helpers.ts CHANGED
@@ -8,8 +8,10 @@ import { createRequire } from "node:module";
8
8
  import {
9
9
  DEFAULT_DOCS_DIR,
10
10
  getDefaultConfig,
11
+ getGlobalConfigPath,
11
12
  getProjectConfigPath,
12
13
  saveConfig,
14
+ saveGlobalConfig,
13
15
  type StaticCheckLanguageConfig,
14
16
  type UseReqConfig,
15
17
  } from "../src/core/config.js";
@@ -31,6 +33,10 @@ const ORACLE_NODE_ROOT = fs.existsSync(path.join(ROOT, "node_modules"))
31
33
  : path.join(ROOT, "..", "PI-useReq");
32
34
  const ORACLE_STUBS_ROOT = path.join(ROOT, "tests", "python_oracle_stubs");
33
35
  const FIXTURES_DIR = path.join(ROOT, "tests", "fixtures");
36
+ const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "pi-usereq-home-"));
37
+ process.env.HOME = TEST_HOME;
38
+ process.env.USERPROFILE = TEST_HOME;
39
+ process.env.XDG_CONFIG_HOME = path.join(TEST_HOME, ".config");
34
40
 
35
41
  export function getFixtureFiles(): string[] {
36
42
  return fs
@@ -104,8 +110,16 @@ export function runPythonInline(code: string, cwd = ROOT, envOverrides?: NodeJS.
104
110
  });
105
111
  }
106
112
 
113
+ /**
114
+ * @brief Creates one temporary working directory and resets the isolated test global config.
115
+ * @details Allocates a unique directory under the system temp root, rewrites the process-scoped test global config file to documented defaults derived from the new project base, and returns the directory path. Runtime is O(n) in serialized config size. Side effects include directory creation and global-config file overwrite under the isolated test home.
116
+ * @param[in] prefix {string} Prefix forwarded to `fs.mkdtempSync(...)`.
117
+ * @return {string} Absolute temporary directory path.
118
+ */
107
119
  export function createTempDir(prefix: string): string {
108
- return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
120
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
121
+ saveGlobalConfig(getDefaultConfig(tempDir));
122
+ return tempDir;
109
123
  }
110
124
 
111
125
  /**
@@ -126,8 +140,8 @@ function writeOracleReqConfig(projectBase: string, config: UseReqConfig): void {
126
140
  }
127
141
 
128
142
  /**
129
- * @brief Persists both Node and Python-oracle project configs for parity tests.
130
- * @details Writes `.pi-usereq.json` for the TypeScript CLI and `.req/config.json` for the Python oracle using the same logical payload. Runtime is O(n) in serialized config size. Side effects include directory creation and file overwrite.
143
+ * @brief Persists Node local/global configs plus the Python-oracle mirror for parity tests.
144
+ * @details Writes split `.pi-usereq.json` and `~/.config/pi-usereq/config.json` files for the TypeScript runtime, then writes `.req/config.json` for the Python oracle using the same effective payload. Runtime is O(n) in serialized config size. Side effects include directory creation and file overwrite.
131
145
  * @param[in] projectBase {string} Fixture project root.
132
146
  * @param[in] config {UseReqConfig} Effective project configuration to persist.
133
147
  * @return {void} No return value.
@@ -138,15 +152,24 @@ export function saveFixtureConfigs(projectBase: string, config: UseReqConfig): v
138
152
  }
139
153
 
140
154
  /**
141
- * @brief Reads the raw persisted TypeScript project config JSON.
142
- * @details Loads `.pi-usereq.json` without normalization so tests can inspect merge order and unknown metadata preservation exactly as written. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
155
+ * @brief Reads the raw persisted TypeScript local project config JSON.
156
+ * @details Loads `.pi-usereq.json` without normalization so tests can inspect local-scope merge order and unknown metadata preservation exactly as written. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
143
157
  * @param[in] projectBase {string} Fixture project root.
144
- * @return {Record<string, unknown>} Parsed raw configuration object.
158
+ * @return {Record<string, unknown>} Parsed raw local configuration object.
145
159
  */
146
160
  export function readProjectConfigJson(projectBase: string): Record<string, unknown> {
147
161
  return JSON.parse(fs.readFileSync(getProjectConfigPath(projectBase), "utf8")) as Record<string, unknown>;
148
162
  }
149
163
 
164
+ /**
165
+ * @brief Reads the raw persisted TypeScript global config JSON.
166
+ * @details Loads `~/.config/pi-usereq/config.json` without normalization so tests can inspect global-scope merge order and unknown metadata preservation exactly as written. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
167
+ * @return {Record<string, unknown>} Parsed raw global configuration object.
168
+ */
169
+ export function readGlobalConfigJson(): Record<string, unknown> {
170
+ return JSON.parse(fs.readFileSync(getGlobalConfigPath(), "utf8")) as Record<string, unknown>;
171
+ }
172
+
150
173
  /**
151
174
  * @brief Writes one executable shell script for integration tests.
152
175
  * @details Creates the parent directory when required, writes UTF-8 content, and marks the file owner-executable. Runtime is O(n) in script size. Side effects include directory creation, file overwrite, and permission changes.