@holmes-lab/holmes-kit 0.19.6 → 0.20.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 (139) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +9 -2
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/doctor-check.d.ts +15 -0
  13. package/dist/holmes/cli/doctor-check.js +2 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -8
  15. package/dist/holmes/cli/doctor.js +12 -3
  16. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  17. package/dist/holmes/cli/gitignore-merge.js +6 -2
  18. package/dist/holmes/cli/index.js +176 -139
  19. package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
  20. package/dist/holmes/cli/npx-cache-check.js +130 -0
  21. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  22. package/dist/holmes/cli/playbook-skills.js +10 -54
  23. package/dist/holmes/cli/probe-process.d.ts +17 -2
  24. package/dist/holmes/cli/probe-process.js +44 -11
  25. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  26. package/dist/holmes/governance/approval-grants.js +55 -1
  27. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  28. package/dist/holmes/governance/approval-queue.js +91 -13
  29. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  30. package/dist/holmes/governance/display-id-aliases.js +86 -0
  31. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  32. package/dist/holmes/governance/ledger-store.js +49 -3
  33. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  34. package/dist/holmes/governance/ledger-timeline.js +11 -1
  35. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  36. package/dist/holmes/governance/provenance-chain.js +13 -2
  37. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  38. package/dist/holmes/governance/provenance-ledger.js +69 -16
  39. package/dist/holmes/governance/session-context.d.ts +3 -0
  40. package/dist/holmes/governance/session-context.js +26 -8
  41. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  42. package/dist/holmes/guardrail/write-target.js +2 -1
  43. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  44. package/dist/holmes/hooks/stop.js +14 -3
  45. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  46. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  47. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  48. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  49. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  50. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  51. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  52. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  53. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  54. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  55. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  56. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  57. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  58. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  59. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  60. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  61. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  62. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  63. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  64. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  65. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  66. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  67. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  68. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  69. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +72 -0
  70. package/dist/holmes/mcp/handlers/slice-orchestration.js +373 -0
  71. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  72. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  73. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  74. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  75. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  76. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  77. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  78. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  79. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  80. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  81. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  82. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  83. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  84. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  85. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  86. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  87. package/dist/holmes/mcp/handlers.d.ts +661 -424
  88. package/dist/holmes/mcp/handlers.js +270 -3251
  89. package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
  90. package/dist/holmes/mcp/server-specs-dir.js +71 -0
  91. package/dist/holmes/mcp/server.js +12 -2
  92. package/dist/holmes/mcp/tool-schemas.js +43 -2
  93. package/dist/holmes/project/execution-context.d.ts +17 -0
  94. package/dist/holmes/project/execution-context.js +121 -0
  95. package/dist/holmes/project/installer-markers.d.ts +34 -0
  96. package/dist/holmes/project/installer-markers.js +65 -0
  97. package/dist/holmes/project/root.d.ts +12 -1
  98. package/dist/holmes/project/root.js +22 -3
  99. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  100. package/dist/holmes/project/workspace-identity.js +181 -0
  101. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  102. package/dist/holmes/review/test-outcomes.js +23 -5
  103. package/dist/holmes/review/test-runner.d.ts +18 -0
  104. package/dist/holmes/review/test-runner.js +136 -5
  105. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  106. package/dist/holmes/rtm/graph-store.js +244 -3
  107. package/dist/holmes/rtm/incremental.d.ts +1 -0
  108. package/dist/holmes/rtm/incremental.js +12 -3
  109. package/dist/holmes/rtm/localize.js +7 -0
  110. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  111. package/dist/holmes/rtm/rtm-graph.js +13 -0
  112. package/dist/holmes/rtm/test-scope.js +4 -1
  113. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  114. package/dist/holmes/semantic/vector-cache.js +155 -19
  115. package/dist/holmes/spec/approval-status.d.ts +10 -0
  116. package/dist/holmes/spec/approval-status.js +7 -3
  117. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  118. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  119. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  120. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  121. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  122. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  123. package/dist/holmes/spec/entity-integration.d.ts +216 -0
  124. package/dist/holmes/spec/entity-integration.js +760 -0
  125. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  126. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  127. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  128. package/dist/holmes/spec/entity-renumber.js +156 -0
  129. package/dist/holmes/spec/entity-store.d.ts +135 -0
  130. package/dist/holmes/spec/entity-store.js +1053 -0
  131. package/dist/holmes/spec/entity-transaction.d.ts +105 -0
  132. package/dist/holmes/spec/entity-transaction.js +741 -0
  133. package/dist/holmes/spec/renumber.d.ts +58 -0
  134. package/dist/holmes/spec/renumber.js +200 -1
  135. package/dist/holmes/spec/spec-store.d.ts +3 -2
  136. package/dist/holmes/spec/spec-store.js +23 -2
  137. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  138. package/dist/holmes/spec/transition-policy.js +135 -0
  139. package/package.json +5 -2
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.WorkspaceIdentityError = void 0;
37
+ exports.workspaceIdentity = workspaceIdentity;
38
+ // @implements A-SPEC-623
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const node_crypto_1 = require("node:crypto");
42
+ const root_1 = require("./root");
43
+ class WorkspaceIdentityError extends Error {
44
+ code;
45
+ holmesRefusal = true;
46
+ constructor(code, message) {
47
+ super(message);
48
+ this.code = code;
49
+ this.name = 'WorkspaceIdentityError';
50
+ }
51
+ }
52
+ exports.WorkspaceIdentityError = WorkspaceIdentityError;
53
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
54
+ const errno = (e, code) => e?.code === code;
55
+ function directory(file, optional = false) {
56
+ let stat;
57
+ try {
58
+ stat = fs.lstatSync(file);
59
+ }
60
+ catch (e) {
61
+ if (optional && errno(e, 'ENOENT'))
62
+ return false;
63
+ throw e;
64
+ }
65
+ if (!stat.isDirectory() || stat.isSymbolicLink())
66
+ throw new WorkspaceIdentityError('unsafe-path', 'Identity directories must be real directories, not symlinks.');
67
+ return true;
68
+ }
69
+ function readRecord(file, local) {
70
+ let stat;
71
+ try {
72
+ stat = fs.lstatSync(file);
73
+ }
74
+ catch (e) {
75
+ if (errno(e, 'ENOENT'))
76
+ return null;
77
+ throw e;
78
+ }
79
+ if (!stat.isFile() || stat.isSymbolicLink())
80
+ throw new WorkspaceIdentityError('unsafe-path', 'Identity must be a regular file, not a symlink.');
81
+ let value;
82
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
83
+ try {
84
+ const opened = fs.fstatSync(fd);
85
+ if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino)
86
+ throw new WorkspaceIdentityError('identity-changed', 'Identity changed while opening; retry.');
87
+ try {
88
+ value = JSON.parse(fs.readFileSync(fd, 'utf8'));
89
+ }
90
+ catch (e) {
91
+ if (e instanceof SyntaxError)
92
+ throw new WorkspaceIdentityError('invalid-identity', 'Identity contains invalid JSON; preserve and repair it explicitly.');
93
+ throw e;
94
+ }
95
+ }
96
+ finally {
97
+ fs.closeSync(fd);
98
+ }
99
+ const schema = local ? 'holmes-workspace-replica/1' : 'holmes-workspace/1';
100
+ if (!value || typeof value !== 'object' || Array.isArray(value))
101
+ throw new WorkspaceIdentityError('invalid-identity', 'Identity must be a schema-versioned object.');
102
+ const record = value, keys = local ? ['schema', 'workspaceId', 'replicaId'] : ['schema', 'workspaceId'];
103
+ if (record.schema !== schema || Object.keys(record).length !== keys.length || keys.some(k => !(k in record))
104
+ || typeof record.workspaceId !== 'string' || !uuid.test(record.workspaceId)
105
+ || (local && (typeof record.replicaId !== 'string' || !uuid.test(record.replicaId)))) {
106
+ throw new WorkspaceIdentityError('invalid-identity', 'Unsupported schema, fields or identifier; existing identity was not replaced.');
107
+ }
108
+ return local ? record : record;
109
+ }
110
+ /** Complete bytes first, then one atomic no-replace link. Never fall back to overwriting a winner. */
111
+ function publish(file, record) {
112
+ const temp = path.join(path.dirname(file), `.identity-${(0, node_crypto_1.randomUUID)()}.tmp`);
113
+ let owned = false;
114
+ try {
115
+ const fd = fs.openSync(temp, 'wx', 0o600);
116
+ owned = true;
117
+ try {
118
+ fs.writeFileSync(fd, JSON.stringify(record) + '\n');
119
+ fs.fsyncSync(fd);
120
+ }
121
+ finally {
122
+ fs.closeSync(fd);
123
+ }
124
+ try {
125
+ fs.linkSync(temp, file);
126
+ }
127
+ catch (e) {
128
+ if (!errno(e, 'EEXIST'))
129
+ throw e;
130
+ }
131
+ }
132
+ finally {
133
+ if (owned)
134
+ fs.unlinkSync(temp);
135
+ }
136
+ }
137
+ function workspaceIdentity(rootArg, operation = 'status') {
138
+ if (!['status', 'initialize', 'register'].includes(operation))
139
+ throw new WorkspaceIdentityError('invalid-operation', 'Use status, initialize or register.');
140
+ const resolved = (0, root_1.resolveProjectRoot)(rootArg, { includeGit: false });
141
+ if (resolved.marker !== '.ax')
142
+ throw new WorkspaceIdentityError('missing-workspace', 'An existing Holmes .ax workspace marker is required.');
143
+ const ax = path.join(resolved.root, '.ax'), state = path.join(ax, 'state');
144
+ directory(ax);
145
+ const canonicalFile = path.join(ax, 'workspace.json'), localFile = path.join(state, 'workspace-replica.json');
146
+ let canonical = readRecord(canonicalFile, false);
147
+ let local = directory(state, true) ? readRecord(localFile, true) : null;
148
+ if (local && (!canonical || local.workspaceId !== canonical.workspaceId))
149
+ throw new WorkspaceIdentityError('workspace-mismatch', 'Local registration does not belong to the canonical workspace; it was preserved.');
150
+ if (!canonical) {
151
+ if (operation === 'status')
152
+ return { ok: true, state: 'legacy' };
153
+ if (operation === 'register')
154
+ throw new WorkspaceIdentityError('uninitialized', 'Initialize and share the canonical workspace identity before registering a replica.');
155
+ publish(canonicalFile, { schema: 'holmes-workspace/1', workspaceId: (0, node_crypto_1.randomUUID)() });
156
+ canonical = readRecord(canonicalFile, false);
157
+ if (!canonical)
158
+ throw new WorkspaceIdentityError('identity-changed', 'Canonical identity disappeared during initialization; retry.');
159
+ }
160
+ if (!local && operation !== 'status') {
161
+ try {
162
+ fs.mkdirSync(state);
163
+ }
164
+ catch (e) {
165
+ if (!errno(e, 'EEXIST'))
166
+ throw e;
167
+ }
168
+ directory(state);
169
+ publish(localFile, { schema: 'holmes-workspace-replica/1', workspaceId: canonical.workspaceId, replicaId: (0, node_crypto_1.randomUUID)() });
170
+ local = readRecord(localFile, true);
171
+ if (!local)
172
+ throw new WorkspaceIdentityError('identity-changed', 'Local registration disappeared during initialization; retry.');
173
+ }
174
+ const current = readRecord(canonicalFile, false);
175
+ if (!current || current.workspaceId !== canonical.workspaceId || (local && local.workspaceId !== canonical.workspaceId)) {
176
+ throw new WorkspaceIdentityError('workspace-mismatch', 'Workspace identity changed during registration; preserve records and resolve the mismatch.');
177
+ }
178
+ return local
179
+ ? { ok: true, state: 'registered', workspaceId: canonical.workspaceId, replicaId: local.replicaId }
180
+ : { ok: true, state: 'initialized', workspaceId: canonical.workspaceId };
181
+ }
@@ -1,3 +1,4 @@
1
+ import { ExecutionContext } from '../project/execution-context';
1
2
  import { TestOutcome } from './test-runner';
2
3
  export declare const OUTCOMES_FILE: string;
3
4
  export declare function outcomesFilename(replica: string): string;
@@ -10,6 +11,7 @@ export declare function isOutcomesFilename(name: string): boolean;
10
11
  * still dirty, which is exactly when the Stop gate reads them.
11
12
  */
12
13
  export interface OutcomeRecord {
14
+ execution?: ExecutionContext;
13
15
  aspec: string;
14
16
  outcome: TestOutcome;
15
17
  ts: string;
@@ -44,7 +46,10 @@ export declare function buildOutcomeRecords(outcomeByFile: Record<string, TestOu
44
46
  * Group outcomes by A-SPEC, keeping only records stamped with the given baseline `head` — a stale
45
47
  * record from another commit cannot vouch for the current work (the isFresh discipline). Pure.
46
48
  */
47
- export declare function groupOutcomesByAspec(records: OutcomeRecord[], head: string): Record<string, Array<{
49
+ export declare function groupOutcomesByAspec(records: OutcomeRecord[], head: string,
50
+ /** @implements A-SPEC-645 — maps a record's display id to the id the entity carries now (from the ledger's renames). */
51
+ canonical?: (id: string) => string): Record<string, Array<{
48
52
  outcome: TestOutcome;
49
53
  ts: string;
54
+ execution?: ExecutionContext;
50
55
  }>>;
@@ -40,7 +40,9 @@ exports.appendOutcomes = appendOutcomes;
40
40
  exports.readOutcomes = readOutcomes;
41
41
  exports.buildOutcomeRecords = buildOutcomeRecords;
42
42
  exports.groupOutcomesByAspec = groupOutcomesByAspec;
43
- // @implements A-SPEC-534.3
43
+ // @implements A-SPEC-534.3, A-SPEC-625
44
+ // @implements A-SPEC-645
45
+ const execution_context_1 = require("../project/execution-context");
44
46
  const fs = __importStar(require("node:fs"));
45
47
  const path = __importStar(require("node:path"));
46
48
  const replica_id_1 = require("../governance/replica-id");
@@ -66,9 +68,16 @@ function isOutcomesFilename(name) {
66
68
  */
67
69
  function appendOutcomes(root, records) {
68
70
  try {
69
- const file = path.join(root, '.ax', 'ledger', outcomesFilename((0, replica_id_1.resolveReplicaId)(root)));
71
+ const prepared = records.map(r => {
72
+ const execution = (0, execution_context_1.observationExecution)(root, r);
73
+ return execution ? { ...r, execution } : r;
74
+ });
75
+ const attributed = prepared.find(r => r.execution)?.execution;
76
+ if (attributed && prepared.some(r => !r.execution || r.execution.replicaId !== attributed.replicaId))
77
+ return false;
78
+ const file = path.join(root, '.ax', 'ledger', outcomesFilename(attributed?.replicaId ?? (0, replica_id_1.resolveReplicaId)(root)));
70
79
  fs.mkdirSync(path.dirname(file), { recursive: true });
71
- fs.appendFileSync(file, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
80
+ fs.appendFileSync(file, prepared.map((r) => `${JSON.stringify(r)}\n`).join(''));
72
81
  return true;
73
82
  }
74
83
  catch {
@@ -110,6 +119,11 @@ function readOutcomes(root) {
110
119
  const r = JSON.parse(s);
111
120
  if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
112
121
  && typeof r.ts === 'string' && typeof r.head === 'string') {
122
+ if ('execution' in r) {
123
+ r.execution = (0, execution_context_1.validateExecution)(r.execution);
124
+ if (name !== outcomesFilename(r.execution.replicaId))
125
+ continue;
126
+ }
113
127
  out.push({ r: r, i: i++ });
114
128
  }
115
129
  }
@@ -136,12 +150,16 @@ function buildOutcomeRecords(outcomeByFile, anchors, head, ts) {
136
150
  * Group outcomes by A-SPEC, keeping only records stamped with the given baseline `head` — a stale
137
151
  * record from another commit cannot vouch for the current work (the isFresh discipline). Pure.
138
152
  */
139
- function groupOutcomesByAspec(records, head) {
153
+ function groupOutcomesByAspec(records, head,
154
+ /** @implements A-SPEC-645 — maps a record's display id to the id the entity carries now (from the ledger's renames). */
155
+ canonical) {
140
156
  const out = {};
141
157
  for (const r of records) {
142
158
  if (r.head !== head)
143
159
  continue;
144
- (out[r.aspec] ??= []).push({ outcome: r.outcome, ts: r.ts });
160
+ const execution = 'execution' in r ? (0, execution_context_1.validateExecution)(r.execution) : undefined;
161
+ const key = canonical ? canonical(r.aspec) : r.aspec; // A-SPEC-645 — evidence follows the entity
162
+ (out[key] ??= []).push({ outcome: r.outcome, ts: r.ts, ...(execution ? { execution } : {}) });
145
163
  }
146
164
  return out;
147
165
  }
@@ -106,10 +106,28 @@ export declare function jestEntry(cwd: string): string | null;
106
106
  */
107
107
  export declare function runJestOutcomes(files: string[], cwd: string): Record<string, TestOutcome>;
108
108
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
109
+ /**
110
+ * @implements A-SPEC-647
111
+ * Per-file RED-first outcome from the same JUnit XML `parseJUnitXmlCounts` reads. Measured 2026-09-14
112
+ * in an external pytest project: a ranged test_run executed the anchored case and recorded NOTHING,
113
+ * because classification was jest-only — a Python project could never produce the red-assertion→green
114
+ * sequence ART-8 reads. `<error>` (collection/import/setup) is a red-error, `<failure>` (an assertion)
115
+ * a red-assertion, executed cases without either are green; a file with only skipped cases says nothing.
116
+ */
117
+ export declare function parseJUnitXmlOutcomes(xml: string, ext?: string): Record<string, TestOutcome>;
118
+ /**
119
+ * @implements A-SPEC-647
120
+ * The ecosystems a project's OWN test files belong to, by file-name convention, under a bounded walk
121
+ * that skips vendored trees and virtualenvs. Measured: a full-tier run with no impacted list assumed
122
+ * the jest suite on a pytest-only project, ran nothing and reported passed:false. This repository's
123
+ * Python tests live under `reference/`, so it detects jest only and its runs are unchanged.
124
+ */
125
+ export declare function detectProjectEcosystems(cwd: string): Ecosystem[];
109
126
  export declare function runPytest(files: string[], mode: TestRunPlan['mode'], cwd: string): {
110
127
  passed: boolean;
111
128
  tail: string;
112
129
  executed: Record<string, number>;
130
+ outcomes: Record<string, TestOutcome>;
113
131
  };
114
132
  /**
115
133
  * @implements A-SPEC-137.2
@@ -42,6 +42,8 @@ exports.classifyJestOutcomes = classifyJestOutcomes;
42
42
  exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
43
43
  exports.jestEntry = jestEntry;
44
44
  exports.runJestOutcomes = runJestOutcomes;
45
+ exports.parseJUnitXmlOutcomes = parseJUnitXmlOutcomes;
46
+ exports.detectProjectEcosystems = detectProjectEcosystems;
45
47
  exports.runPytest = runPytest;
46
48
  exports.parseCargoTest = parseCargoTest;
47
49
  exports.runCargo = runCargo;
@@ -53,6 +55,7 @@ exports.tailOf = tailOf;
53
55
  exports.failedTestSummary = failedTestSummary;
54
56
  exports.summarizeJestJson = summarizeJestJson;
55
57
  // @implements A-SPEC-102.1
58
+ // @implements A-SPEC-647
56
59
  const node_child_process_1 = require("node:child_process");
57
60
  const fs = __importStar(require("node:fs"));
58
61
  const os = __importStar(require("node:os"));
@@ -325,6 +328,105 @@ function runJestOutcomes(files, cwd) {
325
328
  }
326
329
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
327
330
  // @implements A-SPEC-502.1 — exported for the wiring test (a fake venv python capturing argv).
331
+ /**
332
+ * @implements A-SPEC-647
333
+ * Per-file RED-first outcome from the same JUnit XML `parseJUnitXmlCounts` reads. Measured 2026-09-14
334
+ * in an external pytest project: a ranged test_run executed the anchored case and recorded NOTHING,
335
+ * because classification was jest-only — a Python project could never produce the red-assertion→green
336
+ * sequence ART-8 reads. `<error>` (collection/import/setup) is a red-error, `<failure>` (an assertion)
337
+ * a red-assertion, executed cases without either are green; a file with only skipped cases says nothing.
338
+ */
339
+ function parseJUnitXmlOutcomes(xml, ext = 'py') {
340
+ const flags = new Map();
341
+ for (const m of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
342
+ const attrs = m[1] ?? '';
343
+ const body = m[3] ?? '';
344
+ let file = /\bfile="([^"]*)"/.exec(attrs)?.[1];
345
+ if (!file) {
346
+ const cls = /\bclassname="([^"]*)"/.exec(attrs)?.[1];
347
+ if (!cls)
348
+ continue;
349
+ const parts = cls.split('.').filter(Boolean);
350
+ if (ext === 'py') {
351
+ while (parts.length > 1 && /^[A-Z]/.test(parts[parts.length - 1]))
352
+ parts.pop();
353
+ }
354
+ if (parts.length === 0)
355
+ continue;
356
+ file = `${parts.join('/')}.${ext}`;
357
+ }
358
+ const key = file.split('\\').join('/');
359
+ const f = flags.get(key) ?? { error: false, failure: false, executed: false };
360
+ if (/<error\b/.test(body))
361
+ f.error = true;
362
+ else if (/<failure\b/.test(body))
363
+ f.failure = true;
364
+ if (!/<skipped\b/.test(body))
365
+ f.executed = true;
366
+ flags.set(key, f);
367
+ }
368
+ const out = {};
369
+ for (const [file, f] of flags) {
370
+ if (f.error)
371
+ out[file] = 'red-error';
372
+ else if (f.failure)
373
+ out[file] = 'red-assertion';
374
+ else if (f.executed)
375
+ out[file] = 'green';
376
+ }
377
+ return out;
378
+ }
379
+ /**
380
+ * @implements A-SPEC-647
381
+ * The ecosystems a project's OWN test files belong to, by file-name convention, under a bounded walk
382
+ * that skips vendored trees and virtualenvs. Measured: a full-tier run with no impacted list assumed
383
+ * the jest suite on a pytest-only project, ran nothing and reported passed:false. This repository's
384
+ * Python tests live under `reference/`, so it detects jest only and its runs are unchanged.
385
+ */
386
+ function detectProjectEcosystems(cwd) {
387
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-tools', 'build', 'vendor', 'third_party', 'reference']);
388
+ const found = new Set();
389
+ const walk = (dir, depth) => {
390
+ if (depth > 6)
391
+ return;
392
+ let entries;
393
+ try {
394
+ entries = fs.readdirSync(dir, { withFileTypes: true });
395
+ }
396
+ catch {
397
+ return;
398
+ }
399
+ if (entries.some((e) => e.isFile() && e.name === 'pyvenv.cfg'))
400
+ return; // a virtualenv is not the project
401
+ for (const e of entries) {
402
+ const name = e.name;
403
+ if (e.isDirectory()) {
404
+ if (SKIP_DIRS.has(name) || (name.startsWith('.') && name !== '.'))
405
+ continue;
406
+ if (name === '__tests__')
407
+ found.add('jest');
408
+ walk(path.join(dir, name), depth + 1);
409
+ continue;
410
+ }
411
+ if (!e.isFile())
412
+ continue;
413
+ if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/.test(name))
414
+ found.add('jest');
415
+ else if (/^(test_[^/]*|[^/]*_test)\.py$/.test(name) || name === 'conftest.py')
416
+ found.add('pytest');
417
+ else if (/_test\.go$/.test(name))
418
+ found.add('go');
419
+ else if (name === 'Cargo.toml')
420
+ found.add('rust');
421
+ else if (name === 'build.gradle' || name === 'build.gradle.kts')
422
+ found.add('java');
423
+ else if (/\.csproj$/.test(name))
424
+ found.add('dotnet');
425
+ }
426
+ };
427
+ walk(cwd, 0);
428
+ return [...found].sort();
429
+ }
328
430
  function runPytest(files, mode, cwd) {
329
431
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-pytest-'));
330
432
  const report = path.join(dir, 'report.xml');
@@ -351,14 +453,38 @@ function runPytest(files, mode, cwd) {
351
453
  return {};
352
454
  }
353
455
  };
456
+ // @implements A-SPEC-647 — per-file outcomes from the same report; in a scoped run a planned file the
457
+ // failed run never reached (no case at all — a collection error before its cases) is a red-error, so a
458
+ // RED that never ran is not mistaken for evidence of nothing.
459
+ const readOutcomes = (passed) => {
460
+ let outcomes = {};
461
+ try {
462
+ outcomes = parseJUnitXmlOutcomes(fs.readFileSync(report, 'utf8'));
463
+ }
464
+ catch {
465
+ outcomes = {};
466
+ }
467
+ if (mode === 'scoped' && !passed) {
468
+ for (const f of files) {
469
+ const key = f.split('\\').join('/');
470
+ if (path.basename(key) === 'conftest.py' || outcomes[key])
471
+ continue;
472
+ outcomes[key] = 'red-error';
473
+ }
474
+ }
475
+ return outcomes;
476
+ };
354
477
  try {
355
- const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
356
- return { passed: true, tail: tailOf(out), executed: read() };
478
+ // @implements A-SPEC-651.1 launch from the canonical spelling: pytest keys its JUnit `file`
479
+ // attribute relative to its rootdir, and an 8.3 invocation dir made that key climb out of the
480
+ // project (`../../…/tests/test_calc.py`, measured 2026-09-14 on Windows).
481
+ const out = (0, node_child_process_1.execFileSync)(python, args, { cwd: fs.existsSync(cwd) ? (0, root_1.canonicalPath)(cwd) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
482
+ return { passed: true, tail: tailOf(out), executed: read(), outcomes: readOutcomes(true) };
357
483
  }
358
484
  catch (e) {
359
485
  const err = e;
360
486
  // A red pytest run still writes the XML, so execution evidence survives — same property as jest.
361
- return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read() };
487
+ return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read(), outcomes: readOutcomes(false) };
362
488
  }
363
489
  finally {
364
490
  try {
@@ -650,8 +776,13 @@ function runTestScope(scope, cwd) {
650
776
  // to a caller that only sees passed=true.
651
777
  let assumedJest = false;
652
778
  if (plan.mode === 'full' && groups.size === 0) {
653
- groups.set('jest', []);
654
- assumedJest = true;
779
+ // @implements A-SPEC-647 — run what the project HAS; assume jest only when nothing is detected.
780
+ for (const eco of detectProjectEcosystems(cwd))
781
+ groups.set(eco, []);
782
+ if (groups.size === 0) {
783
+ groups.set('jest', []);
784
+ assumedJest = true;
785
+ }
655
786
  }
656
787
  if (groups.size > 0) {
657
788
  const runners = { jest: runJest, pytest: runPytest, go: runGo, rust: runCargo, java: runGradle, dotnet: runDotnet };
@@ -42,6 +42,23 @@ export interface OpenedGraph {
42
42
  reason: string | null;
43
43
  /** Record the basis for this graph's CURRENT contents. Call after building. */
44
44
  commitBasis(): void;
45
+ /**
46
+ * @implements A-SPEC-631 — finish with the graph. In `publishable` mode a non-reusable graph
47
+ * lives in a private temporary database: `publish()` closes it and replaces the live file by one
48
+ * atomic rename; `discard()` closes it and removes the temporary file. In the default in-place
49
+ * mode both simply close the handle. Exactly one of them is called, exactly once.
50
+ */
51
+ publish(): void;
52
+ discard(): void;
53
+ }
54
+ export interface ReuseOptions {
55
+ /**
56
+ * @implements A-SPEC-631 — never touch the live file: when the stored graph cannot be reused,
57
+ * hand back an EMPTY graph in a private temporary database beside it instead of clearing the live
58
+ * file in place (which let concurrent readers see zero nodes and a killed rebuild leave an empty,
59
+ * basis-less file). The caller builds into it, `commitBasis()`, then `publish()`.
60
+ */
61
+ publishable?: boolean;
45
62
  }
46
63
  /**
47
64
  * Open the graph at `dbPath`, reusing its contents only if `expected` still holds.
@@ -50,4 +67,36 @@ export interface OpenedGraph {
50
67
  * to fail: the caller always receives a usable graph, empty when the stored one could not be
51
68
  * trusted, so "the cache is broken" degrades to "this call is slower" rather than to an outage.
52
69
  */
53
- export declare function openReusableGraph(dbPath: string, expected: GraphBasis): OpenedGraph;
70
+ export declare function openReusableGraph(dbPath: string, expected: GraphBasis, opts?: ReuseOptions): OpenedGraph;
71
+ /** Age after which a `.tmp-*` / `.bak-*` sibling of the live graph is treated as abandoned by a dead publisher. */
72
+ export declare const GRAPH_STALE_MS: number;
73
+ export type PublishedGraph = {
74
+ status: 'absent' | 'unreadable' | 'empty';
75
+ graph: null;
76
+ graphAsOf?: string;
77
+ } | {
78
+ status: 'current' | 'stale' | 'unverified';
79
+ graph: RtmGraph;
80
+ sourceCommit: string | null;
81
+ head: string | null;
82
+ graphAsOf?: string;
83
+ };
84
+ /**
85
+ * Build a complete graph in a private temporary database, commit `basis`, close it, and replace
86
+ * the live file atomically. The live file is never opened for writing and never cleared; a build
87
+ * that throws leaves it untouched and removes the temporary file. Returns the published counts.
88
+ */
89
+ export declare function publishGraph(livePath: string, basis: GraphBasis, build: (graph: RtmGraph) => void): {
90
+ nodes: number;
91
+ edges: number;
92
+ };
93
+ /**
94
+ * Open the live graph WITHOUT mutating it and say what it is: absent, unreadable (open or metadata
95
+ * failed — the file is left for the next publication to replace, never deleted here), empty (a
96
+ * schema with no nodes — the state an interrupted in-place rebuild used to leave behind), or a
97
+ * served graph whose basis commit is current / stale against `head`, or unverified when either side
98
+ * is unknown. Served statuses return an OPEN handle the caller must close.
99
+ */
100
+ export declare function openPublishedGraph(livePath: string, head: string | null): PublishedGraph;
101
+ /** HEAD of the repository at `root`, or null outside Git — the only freshness input a consumer pays for (no scan). */
102
+ export declare function headCommitOf(root: string, env?: NodeJS.ProcessEnv): string | null;