@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.
- package/CHANGELOG.md +143 -0
- package/README.md +9 -2
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/doctor-check.d.ts +15 -0
- package/dist/holmes/cli/doctor-check.js +2 -0
- package/dist/holmes/cli/doctor.d.ts +2 -8
- package/dist/holmes/cli/doctor.js +12 -3
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
- package/dist/holmes/cli/npx-cache-check.js +130 -0
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/cli/probe-process.d.ts +17 -2
- package/dist/holmes/cli/probe-process.js +44 -11
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +91 -13
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +72 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +373 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +661 -424
- package/dist/holmes/mcp/handlers.js +270 -3251
- package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
- package/dist/holmes/mcp/server-specs-dir.js +71 -0
- package/dist/holmes/mcp/server.js +12 -2
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +121 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +12 -1
- package/dist/holmes/project/root.js +22 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +136 -5
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/rtm/test-scope.js +4 -1
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +216 -0
- package/dist/holmes/spec/entity-integration.js +760 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1053 -0
- package/dist/holmes/spec/entity-transaction.d.ts +105 -0
- package/dist/holmes/spec/entity-transaction.js +741 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- 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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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;
|