entropy-machines 0.1.1

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.
Files changed (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
package/lib/config.mjs ADDED
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * config — load config.json once per process, fill in defaults, and refuse
4
+ * with a named key when a key has no safe default and the project did not
5
+ * supply one.
6
+ *
7
+ * Every value the rest of this harness used to hardcode about its host
8
+ * project lives in config.json at the repo root (see docs/CONFIG.md). This
9
+ * is the one place that reads the file; every entry point calls loadConfig()
10
+ * once and passes the object down. A previous version of the tooling this
11
+ * harness grew from read its own state file from four independent places,
12
+ * each with a slightly different idea of what was optional, and reconciling
13
+ * them after the fact was worse than writing one loader up front.
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ export const CONFIG_FILENAME = 'config.json';
21
+
22
+ /**
23
+ * Defaults for every key that has a safe one. A default here must be inert —
24
+ * "do nothing" or "match everything", never a guess at what a project's
25
+ * toolchain looks like. `suites` defaults to an empty list, not to a set of
26
+ * npm scripts; a consuming Go-only or shell-only project must not see this
27
+ * harness silently assume it is Node.
28
+ */
29
+ const DEFAULTS = {
30
+ project: { name: null, protectedPaths: [] },
31
+ tracker: {
32
+ backend: 'file',
33
+ file: { path: '.entropy-machines/issues.json' },
34
+ command: { bin: null },
35
+ },
36
+ suites: [],
37
+ generate: { cmd: null, outputs: [] },
38
+ changelog: {
39
+ enabled: true,
40
+ fragmentDir: 'changelog.d',
41
+ collatedFile: 'docs/CHANGELOG.md',
42
+ marker: '<!-- BEGIN COLLATED changelog.d -->',
43
+ // Not in docs/CONFIG.md yet — see lib/changelog-guard.sh's header comment.
44
+ // Paths whose change requires a fragment. Default: everything, i.e. "one
45
+ // fragment per commit" until a project narrows it.
46
+ watchedPathPatterns: ['**'],
47
+ // Not in docs/CONFIG.md yet. Display text only (changelog-guard.sh's
48
+ // failure message) — not shelled out to.
49
+ newFragmentCmd: 'node lib/changelog-new.mjs --',
50
+ // Not in docs/CONFIG.md yet. Display text only (changelog-collate.mjs's
51
+ // "you need to re-collate" message) — not shelled out to.
52
+ collateCmd: 'node lib/changelog-collate.mjs',
53
+ },
54
+ guards: {
55
+ testPathPatterns: ['**/*.test.*', 'tests/**'],
56
+ nonCodePatterns: ['**/*.md', 'docs/**'],
57
+ buildFailureMarkers: [],
58
+ // Not in docs/CONFIG.md yet — see lib/fail-first.mjs's runTest(). Extra
59
+ // environment variables applied to every guard-test invocation. This is
60
+ // how a project that uses Go's test result cache opts into
61
+ // `{ "GOFLAGS": "-count=1" }`; the harness no longer injects it for you.
62
+ testEnv: {},
63
+ },
64
+ worktree: { linkPaths: ['node_modules'] },
65
+ unattended: {
66
+ enabled: false,
67
+ stateHome: '~/.entropy-machines',
68
+ scheduler: 'launchd',
69
+ label: 'com.entropy-machines.drain',
70
+ agent: { cmd: ['claude', '-p'] },
71
+ },
72
+ };
73
+
74
+ function isPlainObject(v) {
75
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
76
+ }
77
+
78
+ function deepMerge(base, override) {
79
+ if (!isPlainObject(base) || !isPlainObject(override)) return override ?? base;
80
+ const out = { ...base };
81
+ for (const [k, v] of Object.entries(override)) {
82
+ out[k] = isPlainObject(base[k]) && isPlainObject(v) ? deepMerge(base[k], v) : v;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * The main checkout of the git repository containing `dir`, or null.
89
+ *
90
+ * `--git-common-dir`, NOT `--show-toplevel`. Mirrors lib/roots.sh's
91
+ * entropy_machines_root(), which is canonical — read its header for why. The short
92
+ * version: from inside a LINKED WORKTREE --show-toplevel prints the worktree,
93
+ * --git-common-dir names the main checkout, and the main checkout is where
94
+ * config.json and .entropy-machines/ live. This loader used to say --show-toplevel,
95
+ * so from a worktree it read a DIFFERENT config.json than the shell scripts
96
+ * calling it did; that was invisible only because the file is tracked and the
97
+ * two copies were identical.
98
+ *
99
+ * WHY THIS IS REIMPLEMENTED RATHER THAN SOURCING roots.sh: shelling out to
100
+ * `sh -c '. roots.sh; entropy_machines_root'` would be a shell process wrapping the
101
+ * same git process — two spawns where there is one — on a loader every entry
102
+ * point calls. The duplication is bounded to these ~8 lines, and the common
103
+ * path avoids the spawn entirely via ENTROPY_MACHINES_ROOT below.
104
+ */
105
+ function gitRoot(dir) {
106
+ let common;
107
+ try {
108
+ common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
109
+ cwd: dir,
110
+ encoding: 'utf8',
111
+ stdio: ['ignore', 'pipe', 'ignore'],
112
+ }).trim();
113
+ } catch {
114
+ return null;
115
+ }
116
+ if (!common) return null;
117
+ // git printed it relative to the directory it ran in.
118
+ if (!path.isAbsolute(common)) common = path.join(dir, common);
119
+ try {
120
+ return fs.realpathSync(path.dirname(path.resolve(common)));
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The ONE root: the repository's main checkout. Agrees with lib/roots.sh.
128
+ *
129
+ * Precedence:
130
+ * 1. an explicit `start` — resolved with git from there. This is a seam
131
+ * (lib/fail-first.mjs's FAIL_FIRST_REPO uses it) for pointing the loader
132
+ * at a repo other than the ambient one, so it must win.
133
+ * 2. $ENTROPY_MACHINES_ROOT — already resolved by lib/roots.sh's
134
+ * entropy_machines_require_root(), which exports it. Honouring it is what makes
135
+ * the shell entry points and this loader agree BY CONSTRUCTION rather
136
+ * than by two implementations happening to compute the same path, and it
137
+ * skips the git spawn on the path every entry point takes. It is not a
138
+ * user knob and not the deleted ENTROPY_PROJECT override: nothing sets it
139
+ * but roots.sh, and it is ignored unless it names an existing directory.
140
+ * 3. the process cwd.
141
+ * Falls back to walking up for config.json when there is no git.
142
+ */
143
+ export function findRepoRoot(start) {
144
+ if (start === undefined || start === null) {
145
+ const envRoot = process.env.ENTROPY_MACHINES_ROOT;
146
+ if (envRoot) {
147
+ try {
148
+ if (fs.statSync(envRoot).isDirectory()) return fs.realpathSync(envRoot);
149
+ } catch {
150
+ /* not a directory we can see — fall through to resolving it ourselves */
151
+ }
152
+ }
153
+ start = process.cwd();
154
+ }
155
+ const root = gitRoot(start);
156
+ if (root) return root;
157
+ let dir = path.resolve(start);
158
+ for (;;) {
159
+ if (fs.existsSync(path.join(dir, CONFIG_FILENAME))) return dir;
160
+ const parent = path.dirname(dir);
161
+ if (parent === dir) return path.resolve(start);
162
+ dir = parent;
163
+ }
164
+ }
165
+
166
+ /** Keys with no safe default: named and refused, per docs/CONFIG.md rule 2. */
167
+ function validate(cfg) {
168
+ const missing = [];
169
+ if (cfg.tracker.backend === 'command' && !cfg.tracker.command?.bin) {
170
+ missing.push('tracker.command.bin (required because tracker.backend is "command")');
171
+ }
172
+ if (cfg.unattended.enabled && !(cfg.unattended.agent?.cmd?.length)) {
173
+ missing.push('unattended.agent.cmd (required because unattended.enabled is true)');
174
+ }
175
+ if (missing.length) {
176
+ throw new Error(
177
+ `config.json is missing required configuration:\n${missing
178
+ .map((m) => ` - ${m}`)
179
+ .join('\n')}\nSee docs/CONFIG.md.`,
180
+ );
181
+ }
182
+ }
183
+
184
+ let cached = null;
185
+
186
+ /**
187
+ * Load and validate config.json. Cached per process (docs/CONFIG.md rule 3)
188
+ * — call this once at each entry point and pass the result down; do not call
189
+ * it again from a library function. `force` is for tests only.
190
+ */
191
+ export function loadConfig({ cwd, force = false } = {}) {
192
+ if (cached && !force) return cached;
193
+ const root = findRepoRoot(cwd);
194
+ // config.json lives in the harness dir, not at the git root.
195
+ const harnessDir = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '..');
196
+ const file = path.join(harnessDir, CONFIG_FILENAME);
197
+ let user = {};
198
+ if (fs.existsSync(file)) {
199
+ try {
200
+ user = JSON.parse(fs.readFileSync(file, 'utf8'));
201
+ } catch (err) {
202
+ throw new Error(`${file}: invalid JSON — ${err.message}`);
203
+ }
204
+ }
205
+ const merged = deepMerge(DEFAULTS, user);
206
+ if (!merged.project.name) merged.project.name = path.basename(root);
207
+ validate(merged);
208
+ Object.defineProperty(merged, '_root', { value: root, enumerable: false });
209
+ Object.defineProperty(merged, '_path', {
210
+ value: fs.existsSync(file) ? file : null,
211
+ enumerable: false,
212
+ });
213
+ cached = merged;
214
+ return merged;
215
+ }
216
+
217
+ /**
218
+ * `**`/`*`/`?` glob -> RegExp, anchored to the whole string. Enough for the
219
+ * path-pattern lists in config.json (`guards.testPathPatterns` and
220
+ * friends); not a full minimatch, and not meant to be one.
221
+ */
222
+ export function globToRegExp(glob) {
223
+ let re = '';
224
+ for (let i = 0; i < glob.length; i++) {
225
+ const c = glob[i];
226
+ if (c === '*') {
227
+ if (glob[i + 1] === '*') {
228
+ re += '.*';
229
+ i++;
230
+ if (glob[i + 1] === '/') i++;
231
+ } else {
232
+ re += '[^/]*';
233
+ }
234
+ } else if (c === '?') {
235
+ re += '[^/]';
236
+ } else if ('.+^${}()|[]\\'.includes(c)) {
237
+ re += `\\${c}`;
238
+ } else {
239
+ re += c;
240
+ }
241
+ }
242
+ return new RegExp(`^${re}$`);
243
+ }
244
+
245
+ export function matchesAny(patterns, p) {
246
+ return patterns.some((pat) => globToRegExp(pat).test(p));
247
+ }
248
+
249
+ // --- CLI: `node lib/config.mjs [root | get <dotted.key>]` — for callers that need one value and don't want to embed Node. ---
250
+ function cliMain(argv) {
251
+ const cfg = loadConfig();
252
+ const [cmd, ...rest] = argv;
253
+ if (cmd === 'root') {
254
+ console.log(cfg._root);
255
+ return 0;
256
+ }
257
+ if (cmd === 'get') {
258
+ const key = rest[0];
259
+ if (!key) {
260
+ console.error('usage: config.mjs get <dotted.key>');
261
+ return 2;
262
+ }
263
+ let v = cfg;
264
+ for (const part of key.split('.')) v = v?.[part];
265
+ if (v === undefined) {
266
+ console.error(`config.mjs: no such key ${key}`);
267
+ return 1;
268
+ }
269
+ console.log(typeof v === 'string' ? v : JSON.stringify(v));
270
+ return 0;
271
+ }
272
+ console.error('usage: config.mjs [root | get <dotted.key>]');
273
+ return 2;
274
+ }
275
+
276
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
277
+ try {
278
+ process.exit(cliMain(process.argv.slice(2)));
279
+ } catch (err) {
280
+ console.error(`config.mjs: ${err.message}`);
281
+ process.exit(1);
282
+ }
283
+ }
package/lib/config.py ADDED
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """config — load config.json once per process, fill in defaults, and refuse
3
+ with a named key when a key has no safe default and the project did not
4
+ supply one.
5
+
6
+ Mirrors lib/config.mjs. Two implementations exist because this harness's own
7
+ tooling is a mix of Node and Python; both read the same config.json and
8
+ apply the same defaults independently, so a consuming project edits exactly
9
+ one file and either loader sees it the same way. Neither reads the file more
10
+ than once per process (docs/CONFIG.md rule 3) — call load_config() once at
11
+ each entry point and pass the result down.
12
+ """
13
+ import json
14
+ import os
15
+ import re as _re
16
+ import subprocess
17
+ import sys
18
+
19
+ CONFIG_FILENAME = "config.json"
20
+
21
+ # Defaults for every key that has a safe one. A default here must be inert —
22
+ # "do nothing" or "match everything" — never a guess at a project's
23
+ # toolchain. `suites` defaults to an empty list, not to a set of npm scripts.
24
+ DEFAULTS = {
25
+ "project": {"name": None, "protectedPaths": []},
26
+ "tracker": {
27
+ "backend": "file",
28
+ "file": {"path": ".entropy-machines/issues.json"},
29
+ "command": {"bin": None},
30
+ },
31
+ "suites": [],
32
+ "generate": {"cmd": None, "outputs": []},
33
+ "changelog": {
34
+ "enabled": True,
35
+ "fragmentDir": "changelog.d",
36
+ "collatedFile": "docs/CHANGELOG.md",
37
+ "marker": "<!-- BEGIN COLLATED changelog.d -->",
38
+ # Not in docs/CONFIG.md yet — see lib/changelog-guard.sh's header.
39
+ "watchedPathPatterns": ["**"],
40
+ # Not in docs/CONFIG.md yet. Display text only (changelog-guard.sh's
41
+ # failure message) — not shelled out to.
42
+ "newFragmentCmd": "node lib/changelog-new.mjs --",
43
+ # Not in docs/CONFIG.md yet. Display text only (changelog-collate.mjs's
44
+ # "you need to re-collate" message) — not shelled out to.
45
+ "collateCmd": "node lib/changelog-collate.mjs",
46
+ },
47
+ "guards": {
48
+ "testPathPatterns": ["**/*.test.*", "tests/**"],
49
+ "nonCodePatterns": ["**/*.md", "docs/**"],
50
+ "buildFailureMarkers": [],
51
+ # Not in docs/CONFIG.md yet — see lib/fail-first.mjs's runTest().
52
+ "testEnv": {},
53
+ },
54
+ "worktree": {"linkPaths": ["node_modules"]},
55
+ "unattended": {
56
+ "enabled": False,
57
+ "stateHome": "~/.entropy-machines",
58
+ "scheduler": "launchd",
59
+ "label": "com.entropy-machines.drain",
60
+ "agent": {"cmd": ["claude", "-p"]},
61
+ },
62
+ }
63
+
64
+
65
+ def _is_dict(v):
66
+ return isinstance(v, dict)
67
+
68
+
69
+ def _deep_merge(base, override):
70
+ if not _is_dict(base) or not _is_dict(override):
71
+ return override if override is not None else base
72
+ out = dict(base)
73
+ for k, v in override.items():
74
+ if _is_dict(base.get(k)) and _is_dict(v):
75
+ out[k] = _deep_merge(base[k], v)
76
+ else:
77
+ out[k] = v
78
+ return out
79
+
80
+
81
+ def _git_root(start):
82
+ """The main checkout of the git repository containing `start`, or None.
83
+
84
+ `--git-common-dir`, NOT `--show-toplevel`. Mirrors lib/roots.sh's
85
+ entropy_machines_root(), which is canonical — read its header for why. The short
86
+ version: from inside a LINKED WORKTREE --show-toplevel prints the
87
+ worktree, --git-common-dir names the main checkout, and the main checkout
88
+ is where config.json and .entropy-machines/ live. This loader used to say
89
+ --show-toplevel, so from a worktree it read a DIFFERENT config.json than
90
+ the shell scripts calling it did; that was invisible only because the
91
+ file is tracked and the two copies were identical.
92
+ """
93
+ try:
94
+ out = subprocess.run(
95
+ ["git", "rev-parse", "--git-common-dir"],
96
+ cwd=start,
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=10,
100
+ )
101
+ except (OSError, subprocess.SubprocessError):
102
+ return None
103
+ if out.returncode != 0 or not out.stdout.strip():
104
+ return None
105
+ common = out.stdout.strip()
106
+ if not os.path.isabs(common):
107
+ # git printed it relative to the directory it ran in.
108
+ common = os.path.join(start, common)
109
+ return os.path.realpath(os.path.join(common, os.pardir))
110
+
111
+
112
+ def refuse_nested_clone(tool, harness_dir=None):
113
+ """Exit 2 if the harness is a git repo nested inside another git repo.
114
+
115
+ The Python mirror of lib/roots.sh's entropy_machines_refuse_nested_clone(). The
116
+ harness is meant to be VENDORED as plain tracked files; a nested .git
117
+ shadows the enclosing repo for every git query, so commands run from
118
+ inside it resolve to the harness and silently operate on the wrong
119
+ repository.
120
+
121
+ Every shell entry point gets this through entropy_machines_require_root(). The
122
+ Python ones resolve the root themselves and so have to ask explicitly --
123
+ which is exactly how bin/init came to be the one command exempt from a
124
+ refusal whose whole purpose is to stop it writing into the harness.
125
+ """
126
+ if harness_dir is None:
127
+ harness_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
128
+ if not os.path.exists(os.path.join(harness_dir, ".git")):
129
+ return
130
+ inner = _git_root(harness_dir)
131
+ parent = os.path.dirname(harness_dir)
132
+ outer = _git_root(parent) if parent and parent != harness_dir else None
133
+ if not outer or not inner or os.path.realpath(outer) == os.path.realpath(inner):
134
+ return
135
+ sys.stderr.write(
136
+ "%s: REFUSED — the harness at %s is a git repository of its own,\n"
137
+ " nested inside %s.\n"
138
+ " A nested .git shadows the enclosing repo, so commands run here\n"
139
+ " resolve to the harness and operate on the wrong repository.\n"
140
+ " Fix: remove %s/.git and commit these files into the project, or\n"
141
+ " re-vendor the harness as plain files.\n"
142
+ % (tool, harness_dir, outer, harness_dir))
143
+ sys.exit(2)
144
+
145
+
146
+ def find_repo_root(start=None):
147
+ """The ONE root: the repository's main checkout. Agrees with lib/roots.sh.
148
+
149
+ Precedence:
150
+ 1. an explicit `start` — resolved with git from there. This is a seam
151
+ (lib/fail-first.mjs's FAIL_FIRST_REPO is the Node twin) for pointing
152
+ the loader at a repo other than the ambient one, so it must win.
153
+ 2. $ENTROPY_MACHINES_ROOT — already resolved by lib/roots.sh's
154
+ entropy_machines_require_root(), which exports it. Honouring it is what makes
155
+ the shell entry points and this loader agree BY CONSTRUCTION rather
156
+ than by two implementations happening to compute the same path. It
157
+ is not a user knob and not the deleted ENTROPY_PROJECT override:
158
+ nothing sets it but roots.sh, and it is ignored unless it names an
159
+ existing directory.
160
+ 3. the process cwd.
161
+ Falls back to walking up for config.json when there is no git.
162
+ """
163
+ if start is None:
164
+ env_root = os.environ.get("ENTROPY_MACHINES_ROOT")
165
+ if env_root and os.path.isdir(env_root):
166
+ return os.path.realpath(env_root)
167
+ start = os.getcwd()
168
+ root = _git_root(start)
169
+ if root:
170
+ return root
171
+ d = os.path.abspath(start)
172
+ while True:
173
+ if os.path.exists(os.path.join(d, CONFIG_FILENAME)):
174
+ return d
175
+ parent = os.path.dirname(d)
176
+ if parent == d:
177
+ return os.path.abspath(start)
178
+ d = parent
179
+
180
+
181
+ def _validate(cfg):
182
+ """Keys with no safe default: named and refused, per docs/CONFIG.md rule 2."""
183
+ missing = []
184
+ if cfg["tracker"]["backend"] == "command" and not cfg["tracker"]["command"].get("bin"):
185
+ missing.append('tracker.command.bin (required because tracker.backend is "command")')
186
+ if cfg["unattended"]["enabled"] and not cfg["unattended"]["agent"].get("cmd"):
187
+ missing.append("unattended.agent.cmd (required because unattended.enabled is true)")
188
+ if missing:
189
+ lines = "\n".join(" - %s" % m for m in missing)
190
+ raise SystemExit(
191
+ "config.json is missing required configuration:\n%s\nSee docs/CONFIG.md." % lines
192
+ )
193
+
194
+
195
+ _cached = None
196
+
197
+
198
+ _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
199
+
200
+
201
+ def load_config(cwd=None, force=False):
202
+ """Load and validate config.json. Cached per process; `force` is for tests only."""
203
+ global _cached
204
+ if _cached is not None and not force:
205
+ return _cached
206
+ root = find_repo_root(cwd)
207
+ # config.json lives in the harness dir, not at the git root.
208
+ file_path = os.path.join(_HARNESS_DIR, CONFIG_FILENAME)
209
+ user = {}
210
+ if os.path.exists(file_path):
211
+ with open(file_path, "r", encoding="utf-8") as f:
212
+ try:
213
+ user = json.load(f)
214
+ except json.JSONDecodeError as e:
215
+ raise SystemExit("%s: invalid JSON — %s" % (file_path, e))
216
+ merged = _deep_merge(DEFAULTS, user)
217
+ if not merged["project"].get("name"):
218
+ merged["project"]["name"] = os.path.basename(root)
219
+ _validate(merged)
220
+ merged["_root"] = root
221
+ merged["_path"] = file_path if os.path.exists(file_path) else None
222
+ _cached = merged
223
+ return merged
224
+
225
+
226
+ _ERE_META = set(".^$+()|[]{}\\")
227
+
228
+
229
+ def _escape_ere(s):
230
+ return "".join(("\\" + c) if c in _ERE_META else c for c in s)
231
+
232
+
233
+ def _glob_to_ere(glob):
234
+ """`**`/`*`/`?` glob -> POSIX ERE fragment (unanchored). For grep -E, not Python's `re`."""
235
+ out = []
236
+ i = 0
237
+ n = len(glob)
238
+ while i < n:
239
+ c = glob[i]
240
+ if c == "*":
241
+ if i + 1 < n and glob[i + 1] == "*":
242
+ out.append(".*")
243
+ i += 2
244
+ if i < n and glob[i] == "/":
245
+ i += 1
246
+ continue
247
+ out.append("[^/]*")
248
+ i += 1
249
+ continue
250
+ if c == "?":
251
+ out.append(".")
252
+ i += 1
253
+ continue
254
+ out.append(_escape_ere(c) if c in _ERE_META else c)
255
+ i += 1
256
+ return "".join(out)
257
+
258
+
259
+ def glob_to_regex(glob):
260
+ """`**`/`*`/`?` glob -> compiled Python regex, anchored to the whole string."""
261
+ return _re.compile("^" + _glob_to_ere(glob) + "$")
262
+
263
+
264
+ def matches_any(patterns, p):
265
+ return any(glob_to_regex(pat).match(p) for pat in patterns)
266
+
267
+
268
+ def _get_path(cfg, dotted):
269
+ v = cfg
270
+ for part in dotted.split("."):
271
+ if not isinstance(v, dict) or part not in v:
272
+ return None, False
273
+ v = v[part]
274
+ return v, True
275
+
276
+
277
+ def _cli_main(argv):
278
+ if not argv:
279
+ print("usage: config.py [root | get <dotted.key> | glob-re <dotted.key> | changelog-fragment-re]", file=sys.stderr)
280
+ return 2
281
+ cmd = argv[0]
282
+ if cmd == "root":
283
+ print(load_config()["_root"])
284
+ return 0
285
+ if cmd == "get":
286
+ if len(argv) < 2:
287
+ print("usage: config.py get <dotted.key>", file=sys.stderr)
288
+ return 2
289
+ value, found = _get_path(load_config(), argv[1])
290
+ if not found:
291
+ print("config.py: no such key %s" % argv[1], file=sys.stderr)
292
+ return 1
293
+ print(value if isinstance(value, str) else json.dumps(value))
294
+ return 0
295
+ if cmd == "glob-re":
296
+ # A list-of-globs key -> one POSIX ERE alternation, anchored at the
297
+ # start only (a prefix/contains test over a bare path, the shape
298
+ # lib/changelog-guard.sh's `grep -Eq` wants).
299
+ if len(argv) < 2:
300
+ print("usage: config.py glob-re <dotted.key>", file=sys.stderr)
301
+ return 2
302
+ value, found = _get_path(load_config(), argv[1])
303
+ if not found or not isinstance(value, list):
304
+ print("config.py: %s is not a list key" % argv[1], file=sys.stderr)
305
+ return 1
306
+ print("^(" + "|".join(_glob_to_ere(p) for p in value) + ")")
307
+ return 0
308
+ if cmd == "changelog-fragment-re":
309
+ cfg = load_config()
310
+ print("^" + _escape_ere(cfg["changelog"]["fragmentDir"]) + r"/[^_].*\.md$")
311
+ return 0
312
+ print("usage: config.py [root | get <dotted.key> | glob-re <dotted.key> | changelog-fragment-re]", file=sys.stderr)
313
+ return 2
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sys.exit(_cli_main(sys.argv[1:]))