@geonosis/doctor 2.0.0 → 2.2.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.
@@ -1,12 +1,108 @@
1
1
  import {
2
2
  CHECKS,
3
+ ENVELOPES_DIR,
3
4
  formatDoctor,
4
5
  formatJson,
5
6
  runDoctor
6
- } from "./chunk-SMCRXSH5.js";
7
+ } from "./chunk-DR5P4QYC.js";
8
+
9
+ // src/doctor-cli.ts
10
+ import { fstatSync, statSync } from "fs";
11
+ import { resolve as resolve2 } from "path";
12
+
13
+ // src/envelope-write.ts
14
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
15
+ import { dirname, join, resolve } from "path";
16
+ import { fileURLToPath } from "url";
17
+ var envelopePath = (root, tool) => join(root, ENVELOPES_DIR, `${tool}.json`);
18
+ var UnbalancedEnvelope = class extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "UnbalancedEnvelope";
22
+ }
23
+ };
24
+ var isCount = (value) => Number.isSafeInteger(value) && value >= 0;
25
+ var unbalancedMessage = (envelope, next) => `${envelope.tool}: considered ${envelope.considered} but accounts for ${envelope.read + envelope.refused.length + envelope.excused.length} \u2014 ${envelope.read} read + ${envelope.refused.length} refused + ${envelope.excused.length} excused. A run that has lost count of its own inputs cannot say what it measured, so no verdict was rendered and no envelope was written. Next: ${next}`;
26
+ var FORBIDDEN_ROOT = "GEONOSIS_ENVELOPES_FORBIDDEN_ROOT";
27
+ var refuseForbiddenRoot = (root) => {
28
+ const forbidden = process.env[FORBIDDEN_ROOT];
29
+ if (forbidden === void 0 || resolve(forbidden) !== resolve(root)) return;
30
+ throw new UnbalancedEnvelope(
31
+ `${root} is off limits to envelope writers in this process (${FORBIDDEN_ROOT}) \u2014 a run that writes one into a shared root races every other run reading it, and leaves a file the next one takes for real. Point this at a scratch root of its own: tooling/scratch-dir.ts.`
32
+ );
33
+ };
34
+ var writeEnvelope = ({
35
+ envelope,
36
+ next,
37
+ root
38
+ }) => {
39
+ if (envelope.tool.trim() === "") {
40
+ throw new UnbalancedEnvelope(
41
+ `an envelope with no tool name cannot be filed or reported against. Next: ${next}`
42
+ );
43
+ }
44
+ if (envelope.version.trim() === "") {
45
+ throw new UnbalancedEnvelope(
46
+ `${envelope.tool}: an envelope that cannot name the build that wrote it dates nothing, and a stale one reads exactly like a fresh one. Next: ${next}`
47
+ );
48
+ }
49
+ if (!isCount(envelope.considered) || !isCount(envelope.read)) {
50
+ throw new UnbalancedEnvelope(
51
+ `${envelope.tool}: considered ${envelope.considered} and read ${envelope.read} \u2014 a census is a whole number of things, and arithmetic over anything else balances by accident. Next: ${next}`
52
+ );
53
+ }
54
+ if (envelope.considered !== envelope.read + envelope.refused.length + envelope.excused.length) {
55
+ throw new UnbalancedEnvelope(unbalancedMessage(envelope, next));
56
+ }
57
+ refuseForbiddenRoot(root);
58
+ const at = envelopePath(root, envelope.tool);
59
+ mkdirSync(dirname(at), { recursive: true });
60
+ writeFileSync(at, `${JSON.stringify(envelope, void 0, 2)}
61
+ `);
62
+ return at;
63
+ };
64
+ var UNKNOWN = "unknown";
65
+ var versionIn = (dir) => {
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
68
+ return typeof manifest.version === "string" ? manifest.version : void 0;
69
+ } catch {
70
+ return void 0;
71
+ }
72
+ };
73
+ var versionOf = (moduleUrl) => {
74
+ let dir = dirname(fileURLToPath(moduleUrl));
75
+ for (; ; ) {
76
+ const found = versionIn(dir);
77
+ if (found !== void 0) return found;
78
+ const up = dirname(dir);
79
+ if (up === dir) return UNKNOWN;
80
+ dir = up;
81
+ }
82
+ };
83
+ var DOCTOR_TOOL = "doctor";
84
+ var DOCTOR_NEXT = "geonosis-doctor --json and count the checks it printed \u2014 this run considered every question the tool has, and a check nobody asked for is excused by name rather than left out of the denominator";
85
+ var doctorEnvelope = ({
86
+ durationMs,
87
+ report
88
+ }) => {
89
+ const ran = new Set(report.ran);
90
+ return {
91
+ considered: CHECKS.length,
92
+ durationMs,
93
+ excused: CHECKS.filter((check) => !ran.has(check)).map((check) => ({
94
+ path: check,
95
+ reason: "not asked for by --only, so this run says nothing about it"
96
+ })),
97
+ findings: report.findings,
98
+ read: report.ran.length,
99
+ refused: [],
100
+ tool: DOCTOR_TOOL,
101
+ version: versionOf(import.meta.url)
102
+ };
103
+ };
7
104
 
8
105
  // src/doctor-cli.ts
9
- import { resolve } from "path";
10
106
  var ABOUT = {
11
107
  baseline: `The ratchet's numbers at HEAD against another ref. A ratchet lowers its own baseline
12
108
  when a number shrinks, so nothing inside one checkout can see a branch raise one back
@@ -94,6 +190,10 @@ plugin-route-namespaced on its package root, while the shipped corpus says acme
94
190
 
95
191
  --print-config-shape every file this reads, and the keys it reads out of each
96
192
 
193
+ Every run writes .geonosis/envelopes/doctor.json: the ten checks CONSIDERED against the ones this
194
+ run read, with the rest excused by name, so a --only run can never read as a verdict about the whole
195
+ tree. The envelope check SKIPs that one file \u2014 this run is the one writing it.
196
+
97
197
  Exits 1 when any line is a FAIL or an UNJUDGED \u2014 a question this could not ask is not a pass \u2014 and
98
198
  2 when the run could not be made at all. --strict promotes every WARN to a FAIL. --json prints the
99
199
  whole report for a CI step to read.`;
@@ -134,8 +234,8 @@ var parseDoctorArgs = (argv, cwd) => {
134
234
  ...baseline === void 0 ? {} : { baseline },
135
235
  json,
136
236
  ...only === void 0 || only.length === 0 ? {} : { only },
137
- ...read["--oxlint"] === void 0 ? {} : { oxlint: resolve(cwd, read["--oxlint"]) },
138
- root: resolve(cwd, read["--root"] ?? cwd),
237
+ ...read["--oxlint"] === void 0 ? {} : { oxlint: resolve2(cwd, read["--oxlint"]) },
238
+ root: resolve2(cwd, read["--root"] ?? cwd),
139
239
  strict
140
240
  };
141
241
  };
@@ -186,6 +286,15 @@ geonosis.json \u2192 "rails.egress"
186
286
  .geonosis/rails-run.json
187
287
  deniedEgress object[] what a run reached for and was refused. A GATE AT ZERO (D-025): one
188
288
  entry fails, and it is never a counter to hold flat`;
289
+ var sameFile = (fd, path) => {
290
+ try {
291
+ const open = fstatSync(fd);
292
+ const there = statSync(path);
293
+ return open.dev === there.dev && open.ino === there.ino;
294
+ } catch {
295
+ return false;
296
+ }
297
+ };
189
298
  var main = async () => {
190
299
  const argv = process.argv.slice(2);
191
300
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -199,7 +308,21 @@ var main = async () => {
199
308
  return 0;
200
309
  }
201
310
  const { json, ...options } = parseDoctorArgs(argv, process.cwd());
311
+ const started = Date.now();
202
312
  const report = await runDoctor(options);
313
+ const at = envelopePath(options.root, "doctor");
314
+ if (sameFile(process.stdout.fd, at)) {
315
+ process.stderr.write(
316
+ `geonosis-doctor: stdout is ${ENVELOPES_DIR}/doctor.json, so no envelope was written \u2014 it would have overwritten the report redirected there. Send --json somewhere else and the envelope is written after every run.
317
+ `
318
+ );
319
+ } else {
320
+ writeEnvelope({
321
+ envelope: doctorEnvelope({ durationMs: Date.now() - started, report }),
322
+ next: DOCTOR_NEXT,
323
+ root: options.root
324
+ });
325
+ }
203
326
  process.stdout.write(json ? formatJson(report) : formatDoctor(report));
204
327
  return report.ok ? 0 : 1;
205
328
  };
package/dist/index.d.ts CHANGED
@@ -66,6 +66,8 @@ type RatchetConfig = {
66
66
  counters: Record<string, unknown>[];
67
67
  };
68
68
  type DoctorOptions = {
69
+ /** Where `~/.claude` lives — the plugin registry and the user settings the doctor reads; the home directory unless a caller says otherwise. */
70
+ home?: string;
69
71
  /**
70
72
  * Present only when a baseline comparison was asked for, because making one is the one part of
71
73
  * this tool that runs git — and the release check runs the doctor inside repos other sessions
@@ -83,6 +85,8 @@ type DoctorOptions = {
83
85
  type DoctorReport = {
84
86
  counts: Record<Verdict, number>;
85
87
  findings: Finding[];
88
+ /** The checks this run asked. `--only` makes it a subset, and the envelope's denominator needs it. */
89
+ ran: CheckName[];
86
90
  /**
87
91
  * False as soon as one line is a FAIL or an UNJUDGED — after `--strict` has promoted the
88
92
  * warnings. "We could not tell" is not a pass; it is the quietest way there is of reading green.
@@ -136,7 +140,7 @@ declare const discoverConfigs: (root: string) => DiscoveredConfig[];
136
140
  declare const discoverWorkspaces: (root: string) => Workspace[];
137
141
  declare const readRatchet: (root: string) => RatchetConfig | undefined;
138
142
 
139
- declare const runDoctor: ({ baseline, only, oxlint, root, strict, }: DoctorOptions) => Promise<DoctorReport>;
143
+ declare const runDoctor: ({ baseline, home, only, oxlint, root, strict, }: DoctorOptions) => Promise<DoctorReport>;
140
144
 
141
145
  /** The file `create-geonosis --domains` writes, held against the scaffold's own name by a test. */
142
146
  declare const COMPOSITION_ROOT = "src/platform.ts";
@@ -189,7 +193,7 @@ declare const checkDrift: ({ readers, root, userSettings, workspaces, }: {
189
193
  * duplicated here on purpose: it is one comparison over three numbers, and it is the check.
190
194
  */
191
195
  declare const ENVELOPES_DIR = ".geonosis/envelopes";
192
- declare const NO_ENVELOPES = "no .geonosis/envelopes/*.json \u2014 the tools write one per run, so an absent envelope is a run nobody has made here yet, and it is not a balanced one";
196
+ declare const NO_ENVELOPES = "no .geonosis/envelopes/*.json \u2014 the tools write one per run, so an absent envelope is a run nobody has made here yet, and it is not a balanced one (this run\u2019s own doctor.json is not one of them: it is written after these checks, and balanced at write time)";
193
197
  declare const checkEnvelopes: ({ root }: {
194
198
  root: string;
195
199
  }) => Finding[];
@@ -263,6 +267,39 @@ declare const checkGroup: ({ groups, root, workspaces, }: {
263
267
  workspaces: Workspace[];
264
268
  }) => Finding[];
265
269
 
270
+ /**
271
+ * Where Claude Code records what it has installed.
272
+ *
273
+ * MEASURED 2026-09-02 (`~/.claude/plugins/installed_plugins.json`, `version: 2`): a map keyed
274
+ * `<plugin>@<marketplace>`, each value a list of installations carrying `version`, `installPath`
275
+ * and the commit it came from. This is the file `claude plugin list` answers from.
276
+ */
277
+ declare const INSTALLED_PLUGINS = ".claude/plugins/installed_plugins.json";
278
+ /** The plugin this kit publishes. Its own name, the way `FIXED_GROUP` is its own group. */
279
+ declare const PLUGIN_NAME = "geonosis";
280
+ /**
281
+ * Every version of one plugin this machine has installed, in the order the record lists them.
282
+ *
283
+ * A name can be installed from more than one marketplace and at more than one scope, so this
284
+ * answers with a list rather than picking one: a session loads what it loads, and a doctor that
285
+ * chose for the reader would be reporting about an installation nobody has.
286
+ */
287
+ declare const installedPluginVersions: (home: string, name: string) => string[];
288
+ /**
289
+ * #133: a consumer's npm bump does not move the CLAUDE PLUGIN.
290
+ *
291
+ * The hooks, the agents and the skills live in the plugin, and it updates on `claude plugin update`
292
+ * and never on an install — so a repo can be on this release's packages while every session in it
293
+ * runs the hooks of the one before, and a refusal added this release cannot fire. That is the
294
+ * declared-versus-loaded question one surface out, and it is this check's to ask.
295
+ */
296
+ declare const checkClaudePlugin: ({ home, name, train, }: {
297
+ home?: string;
298
+ name?: string;
299
+ /** The version the package train is on here, or nothing when this tree declares none. */
300
+ train: string | undefined;
301
+ }) => Finding[];
302
+
266
303
  /** The scope this tool asks about. A config's other plugins are somebody else's release to check. */
267
304
  declare const SCOPE = "@geonosis/";
268
305
  /**
@@ -284,8 +321,9 @@ declare const declaredFor: ({ dir, specifier, workspaces, }: {
284
321
  at: string;
285
322
  spec: string;
286
323
  } | undefined;
287
- declare const checkLoaded: ({ configs, root, workspaces, }: {
324
+ declare const checkLoaded: ({ configs, home, root, workspaces, }: {
288
325
  configs: DiscoveredConfig[];
326
+ home?: string;
289
327
  root: string;
290
328
  workspaces: Workspace[];
291
329
  }) => Promise<Finding[]>;
@@ -368,4 +406,4 @@ declare const checkRunner: ({ ratchet, root, workspaces, }: {
368
406
  workspaces: Workspace[];
369
407
  }) => Finding[];
370
408
 
371
- export { CHECKS, COMPOSITION_ROOT, CONFIG_FILE, type CheckName, DEPLOYED_FILE, type DiscoveredConfig, DoctorError, type DoctorOptions, type DoctorReport, ENVELOPES_DIR, FIXED_GROUP, FLOOR_PACKAGES, type Finding, GEONOSIS_FILE, type HeldTogether, KIT_GROUP, type LastEventRecord, MANIFEST_FILE, type Manifest, NOT_WRITTEN, NO_ENVELOPES, type ObservabilityConfig, RATCHET_FILE, READERS, type RatchetConfig, SCOPE, type Verdict, type Workspace, checkBaseline, checkDeployed, checkDrift, checkEnvelopes, checkExercised, checkGroup, checkLoaded, checkObservability, checkRunner, corpusOfPlugin, declaredFor, declaredGroupsOf, defaultRef, discoverConfigs, discoverWorkspaces, enabledRulesOf, formatDoctor, formatJson, packageDirOf, pluginVersionOf, readConfig, readRatchet, relativePath, relativeToRoot, repoCorpusOf, resolveFrom, runDoctor, satisfies };
409
+ export { CHECKS, COMPOSITION_ROOT, CONFIG_FILE, type CheckName, DEPLOYED_FILE, type DiscoveredConfig, DoctorError, type DoctorOptions, type DoctorReport, ENVELOPES_DIR, FIXED_GROUP, FLOOR_PACKAGES, type Finding, GEONOSIS_FILE, type HeldTogether, INSTALLED_PLUGINS, KIT_GROUP, type LastEventRecord, MANIFEST_FILE, type Manifest, NOT_WRITTEN, NO_ENVELOPES, type ObservabilityConfig, PLUGIN_NAME, RATCHET_FILE, READERS, type RatchetConfig, SCOPE, type Verdict, type Workspace, checkBaseline, checkClaudePlugin, checkDeployed, checkDrift, checkEnvelopes, checkExercised, checkGroup, checkLoaded, checkObservability, checkRunner, corpusOfPlugin, declaredFor, declaredGroupsOf, defaultRef, discoverConfigs, discoverWorkspaces, enabledRulesOf, formatDoctor, formatJson, installedPluginVersions, packageDirOf, pluginVersionOf, readConfig, readRatchet, relativePath, relativeToRoot, repoCorpusOf, resolveFrom, runDoctor, satisfies };
package/dist/index.js CHANGED
@@ -8,14 +8,17 @@ import {
8
8
  FIXED_GROUP,
9
9
  FLOOR_PACKAGES,
10
10
  GEONOSIS_FILE,
11
+ INSTALLED_PLUGINS,
11
12
  KIT_GROUP,
12
13
  MANIFEST_FILE,
13
14
  NOT_WRITTEN,
14
15
  NO_ENVELOPES,
16
+ PLUGIN_NAME,
15
17
  RATCHET_FILE,
16
18
  READERS,
17
19
  SCOPE,
18
20
  checkBaseline,
21
+ checkClaudePlugin,
19
22
  checkDeployed,
20
23
  checkDrift,
21
24
  checkEnvelopes,
@@ -33,6 +36,7 @@ import {
33
36
  enabledRulesOf,
34
37
  formatDoctor,
35
38
  formatJson,
39
+ installedPluginVersions,
36
40
  packageDirOf,
37
41
  pluginVersionOf,
38
42
  readConfig,
@@ -43,7 +47,7 @@ import {
43
47
  resolveFrom,
44
48
  runDoctor,
45
49
  satisfies
46
- } from "./chunk-SMCRXSH5.js";
50
+ } from "./chunk-DR5P4QYC.js";
47
51
  export {
48
52
  CHECKS,
49
53
  COMPOSITION_ROOT,
@@ -54,14 +58,17 @@ export {
54
58
  FIXED_GROUP,
55
59
  FLOOR_PACKAGES,
56
60
  GEONOSIS_FILE,
61
+ INSTALLED_PLUGINS,
57
62
  KIT_GROUP,
58
63
  MANIFEST_FILE,
59
64
  NOT_WRITTEN,
60
65
  NO_ENVELOPES,
66
+ PLUGIN_NAME,
61
67
  RATCHET_FILE,
62
68
  READERS,
63
69
  SCOPE,
64
70
  checkBaseline,
71
+ checkClaudePlugin,
65
72
  checkDeployed,
66
73
  checkDrift,
67
74
  checkEnvelopes,
@@ -79,6 +86,7 @@ export {
79
86
  enabledRulesOf,
80
87
  formatDoctor,
81
88
  formatJson,
89
+ installedPluginVersions,
82
90
  packageDirOf,
83
91
  pluginVersionOf,
84
92
  readConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/doctor",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "The adoption doctor — declared ≠ loaded, enabled ≠ exercised, a baseline that grew, a runner whose exit code is the only verdict.",
6
6
  "keywords": [
@@ -35,7 +35,10 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@geonosis/lint-parity": "2.0.0"
38
+ "@geonosis/lint-parity": "2.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@geonosis/ratchet": "2.2.0"
39
42
  },
40
43
  "peerDependencies": {
41
44
  "oxlint": ">=1.77"