@holmes-lab/holmes-kit 0.23.2 → 0.24.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.
- package/CHANGELOG.md +86 -0
- package/README.md +3 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.js +2 -0
- package/dist/holmes/cli/index.js +41 -1
- package/dist/holmes/cli/open-url.d.ts +30 -0
- package/dist/holmes/cli/open-url.js +38 -0
- package/dist/holmes/cli/report.d.ts +20 -0
- package/dist/holmes/cli/report.js +115 -0
- package/dist/holmes/cli/reported-log.d.ts +23 -0
- package/dist/holmes/cli/reported-log.js +31 -0
- package/dist/holmes/hooks/pre-tool-use.js +2 -0
- package/dist/holmes/hooks/stop.d.ts +14 -0
- package/dist/holmes/hooks/stop.js +82 -1
- package/dist/holmes/mcp/tool-schemas.js +2 -0
- package/dist/holmes/project/analysis-currency.d.ts +22 -0
- package/dist/holmes/project/analysis-currency.js +96 -0
- package/dist/holmes/project/ci-lock.d.ts +20 -0
- package/dist/holmes/project/ci-lock.js +111 -0
- package/dist/holmes/project/dist-freshness.d.ts +30 -0
- package/dist/holmes/project/dist-freshness.js +114 -0
- package/dist/holmes/project/field-report-url.d.ts +47 -0
- package/dist/holmes/project/field-report-url.js +63 -0
- package/dist/holmes/project/field-report.d.ts +54 -0
- package/dist/holmes/project/field-report.js +107 -0
- package/dist/holmes/semantic/credentials.js +2 -0
- package/dist/holmes/update/workspaces.d.ts +53 -2
- package/dist/holmes/update/workspaces.js +77 -4
- package/package.json +3 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type AnalysisState = 'analysed' | 'missing' | 'not-adopted' | 'unknown';
|
|
2
|
+
export interface AnalysisVerdict {
|
|
3
|
+
state: AnalysisState;
|
|
4
|
+
openCount: number | null;
|
|
5
|
+
changedSources: number;
|
|
6
|
+
}
|
|
7
|
+
export interface AnalysisInput {
|
|
8
|
+
adopted: boolean;
|
|
9
|
+
openAnalyses: number | null;
|
|
10
|
+
changedSources: number;
|
|
11
|
+
}
|
|
12
|
+
/** Adoption is the directory's existence. A project that never analysed anything did not opt in. */
|
|
13
|
+
export declare function hasAnalysisDir(root: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Judged by COMMIT, never by clock. "An analysis within N hours" is an invented constant that turns
|
|
16
|
+
* a slow slice into a violation; what matters is whether an analysis stands open for the work that
|
|
17
|
+
* changed these files, which is exactly what `test_run` closes when it reconciles a persisted
|
|
18
|
+
* analysis against the files a slice actually touched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function analysisCurrency(input: AnalysisInput): AnalysisVerdict;
|
|
21
|
+
/** Empty when there is nothing to say; the two unjudgeable states say so rather than passing. */
|
|
22
|
+
export declare function analysisStatusLine(v: AnalysisVerdict): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
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.hasAnalysisDir = hasAnalysisDir;
|
|
37
|
+
exports.analysisCurrency = analysisCurrency;
|
|
38
|
+
exports.analysisStatusLine = analysisStatusLine;
|
|
39
|
+
// @implements A-SPEC-681
|
|
40
|
+
/**
|
|
41
|
+
* Whether the graph analysis this project's own instructions require was actually run.
|
|
42
|
+
*
|
|
43
|
+
* `AGENTS.md` step 3 asks for `maintenance_analyze({persist:true})` before editing source, and
|
|
44
|
+
* nothing enforced it: measured 2026-09-19, the last persisted analysis was at 10:52 the previous
|
|
45
|
+
* day and seventeen feat/fix commits followed it with none. A user had to point at the gap.
|
|
46
|
+
*
|
|
47
|
+
* The cost was concrete. Searching for the precedent for spawning a browser, grep returned a CSS
|
|
48
|
+
* class named 'open', a d3 'start' event and a schema enum; the analysis returned `wiringSpawnCheck`
|
|
49
|
+
* and `Supervisor.spawnChild`. The question was "who opens a browser" and the answer lived under
|
|
50
|
+
* "who spawns a child" — a gap lexical search cannot cross and the graph does not notice.
|
|
51
|
+
*
|
|
52
|
+
* This OBSERVES. It does not block, and it never runs the tool on the operator's behalf: analysis is
|
|
53
|
+
* expensive and what to ask is a person's judgement.
|
|
54
|
+
*/
|
|
55
|
+
const fs = __importStar(require("node:fs"));
|
|
56
|
+
const path = __importStar(require("node:path"));
|
|
57
|
+
/** Adoption is the directory's existence. A project that never analysed anything did not opt in. */
|
|
58
|
+
function hasAnalysisDir(root) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(path.join(root, '.ax', 'evidence', 'maintenance')).isDirectory();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Judged by COMMIT, never by clock. "An analysis within N hours" is an invented constant that turns
|
|
68
|
+
* a slow slice into a violation; what matters is whether an analysis stands open for the work that
|
|
69
|
+
* changed these files, which is exactly what `test_run` closes when it reconciles a persisted
|
|
70
|
+
* analysis against the files a slice actually touched.
|
|
71
|
+
*/
|
|
72
|
+
function analysisCurrency(input) {
|
|
73
|
+
const changedSources = typeof input.changedSources === 'number' && Number.isFinite(input.changedSources)
|
|
74
|
+
? Math.max(0, input.changedSources) : 0;
|
|
75
|
+
const open = input.openAnalyses;
|
|
76
|
+
const base = { openCount: typeof open === 'number' && Number.isFinite(open) ? open : null, changedSources };
|
|
77
|
+
if (!input.adopted)
|
|
78
|
+
return { state: 'not-adopted', ...base };
|
|
79
|
+
// The discipline is about editing source. Nothing changed, nothing to say.
|
|
80
|
+
if (changedSources === 0)
|
|
81
|
+
return { state: 'analysed', ...base };
|
|
82
|
+
if (typeof open !== 'number' || !Number.isFinite(open) || open < 0)
|
|
83
|
+
return { state: 'unknown', ...base };
|
|
84
|
+
return { state: open > 0 ? 'analysed' : 'missing', ...base };
|
|
85
|
+
}
|
|
86
|
+
/** Empty when there is nothing to say; the two unjudgeable states say so rather than passing. */
|
|
87
|
+
function analysisStatusLine(v) {
|
|
88
|
+
if (v.state === 'analysed' || v.state === 'not-adopted')
|
|
89
|
+
return '';
|
|
90
|
+
if (v.state === 'unknown') {
|
|
91
|
+
return 'the persisted analyses could not be counted — whether this change was analysed could not be judged';
|
|
92
|
+
}
|
|
93
|
+
const n = v.changedSources;
|
|
94
|
+
return `${n} source file${n === 1 ? '' : 's'} changed with no analysis standing open — `
|
|
95
|
+
+ 'AGENTS.md step 3 asks for `maintenance_analyze({ persist: true })` first; it finds precedents a name search cannot reach';
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CiLockHolder {
|
|
2
|
+
pid: number;
|
|
3
|
+
host: string;
|
|
4
|
+
rev: string;
|
|
5
|
+
at: string;
|
|
6
|
+
}
|
|
7
|
+
export type CiLockState = 'free' | 'held' | 'dead' | 'unknown';
|
|
8
|
+
export interface CiLockInput {
|
|
9
|
+
holder: CiLockHolder | null;
|
|
10
|
+
thisHost: string;
|
|
11
|
+
alive: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Null for every shape we cannot trust — including the legacy empty directory. */
|
|
14
|
+
export declare function readCiLock(lockDir: string): CiLockHolder | null;
|
|
15
|
+
/**
|
|
16
|
+
* A holder written by ANOTHER host is `unknown`, not dead: we cannot ask that machine about its
|
|
17
|
+
* pids, and guessing would hand two runners the same VM. Unknown is respected like `held`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function ciLockState(input: CiLockInput): CiLockState;
|
|
20
|
+
export declare function ciLockLine(state: CiLockState, holder: CiLockHolder | null, now?: Date): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
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.readCiLock = readCiLock;
|
|
37
|
+
exports.ciLockState = ciLockState;
|
|
38
|
+
exports.ciLockLine = ciLockLine;
|
|
39
|
+
// @implements A-SPEC-674
|
|
40
|
+
/**
|
|
41
|
+
* Who holds the CI runner's lock, and is that holder still alive.
|
|
42
|
+
*
|
|
43
|
+
* The runner serialises itself with a `mkdir` lock — atomic, and that atomicity is the whole reason
|
|
44
|
+
* it works — released by `trap ... EXIT`. SIGKILL and launchd tearing down the process group skip
|
|
45
|
+
* that trap. Measured 2026-09-18: one such lock survived 95 minutes while the matrix judged nothing
|
|
46
|
+
* and nine commits went unjudged, with zero jest processes in the VM and a load of 0.57.
|
|
47
|
+
*
|
|
48
|
+
* The lock used to be an EMPTY directory, so it could not say who held it — and a lock that cannot
|
|
49
|
+
* name its holder cannot be asked whether that holder is alive. It now carries a holder file, and
|
|
50
|
+
* the judgement below is about liveness, never about elapsed time: calling a slow run dead would
|
|
51
|
+
* start a second run on the same VM, which is a worse failure than waiting.
|
|
52
|
+
*/
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
/** Null for every shape we cannot trust — including the legacy empty directory. */
|
|
56
|
+
function readCiLock(lockDir) {
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = fs.readFileSync(path.join(lockDir, 'holder.json'), 'utf8');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const h = parsed;
|
|
72
|
+
if (typeof h?.pid !== 'number' || !Number.isFinite(h.pid))
|
|
73
|
+
return null;
|
|
74
|
+
if (typeof h.host !== 'string' || typeof h.rev !== 'string' || typeof h.at !== 'string')
|
|
75
|
+
return null;
|
|
76
|
+
return { pid: h.pid, host: h.host, rev: h.rev, at: h.at };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A holder written by ANOTHER host is `unknown`, not dead: we cannot ask that machine about its
|
|
80
|
+
* pids, and guessing would hand two runners the same VM. Unknown is respected like `held`.
|
|
81
|
+
*/
|
|
82
|
+
function ciLockState(input) {
|
|
83
|
+
const { holder, thisHost, alive } = input;
|
|
84
|
+
if (!holder)
|
|
85
|
+
return 'free';
|
|
86
|
+
if (holder.host !== thisHost)
|
|
87
|
+
return 'unknown';
|
|
88
|
+
return alive ? 'held' : 'dead';
|
|
89
|
+
}
|
|
90
|
+
const elapsed = (at, now) => {
|
|
91
|
+
const t = Date.parse(at);
|
|
92
|
+
if (!Number.isFinite(t))
|
|
93
|
+
return null;
|
|
94
|
+
const mins = Math.floor((now.getTime() - t) / 60000);
|
|
95
|
+
// A clock that runs backwards is a fact about the clock, not about the holder; never report it as
|
|
96
|
+
// negative time.
|
|
97
|
+
return mins < 0 ? null : `${mins} min`;
|
|
98
|
+
};
|
|
99
|
+
function ciLockLine(state, holder, now = new Date()) {
|
|
100
|
+
if (state === 'free' || !holder)
|
|
101
|
+
return '';
|
|
102
|
+
const held = elapsed(holder.at, now);
|
|
103
|
+
const forHow = held ? ` for ${held}` : '';
|
|
104
|
+
if (state === 'dead') {
|
|
105
|
+
return `ci lock holder pid ${holder.pid} is gone (held${forHow}, rev ${holder.rev}) — reclaiming it; the matrix judges nothing while a dead holder stands`;
|
|
106
|
+
}
|
|
107
|
+
if (state === 'unknown') {
|
|
108
|
+
return `ci lock was taken by ${holder.host} (rev ${holder.rev}${forHow}) — this host cannot judge another machine's pids, so it is respected`;
|
|
109
|
+
}
|
|
110
|
+
return `ci lock is held by pid ${holder.pid} running rev ${holder.rev}${forHow}`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type DistState = 'fresh' | 'behind' | 'absent' | 'no-build-id' | 'unknown';
|
|
2
|
+
export interface DistVerdict {
|
|
3
|
+
state: DistState;
|
|
4
|
+
buildId: string | null;
|
|
5
|
+
head: string | null;
|
|
6
|
+
behind: number | null;
|
|
7
|
+
}
|
|
8
|
+
export interface DistInput {
|
|
9
|
+
distExists: boolean;
|
|
10
|
+
buildId: string | null;
|
|
11
|
+
head: string | null;
|
|
12
|
+
/** Commits from the build id to HEAD, or null when it could not be obtained. */
|
|
13
|
+
behind: number | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The adoption predicate: existence, never contents. A workspace that does not build has no build
|
|
17
|
+
* freshness to report, and telling it about one would be noise it can do nothing with (the same
|
|
18
|
+
* reasoning REQ-664 used for the CI ledger).
|
|
19
|
+
*/
|
|
20
|
+
export declare function hasDist(root: string): boolean;
|
|
21
|
+
/** The commit the artefact came from — the part before the dash `npm run build` writes. */
|
|
22
|
+
export declare function distBuildId(root: string): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Five states because each calls for something different: `behind` means build, `absent` means
|
|
25
|
+
* nothing at all to this workspace, and the two unjudgeable ones mean the check did not run — which
|
|
26
|
+
* must never be reported as a check that passed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function distFreshness(input: DistInput): DistVerdict;
|
|
29
|
+
/** Empty when there is nothing to say. A line is spent only on a fact the reader can act on. */
|
|
30
|
+
export declare function distStatusLine(v: DistVerdict): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
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.hasDist = hasDist;
|
|
37
|
+
exports.distBuildId = distBuildId;
|
|
38
|
+
exports.distFreshness = distFreshness;
|
|
39
|
+
exports.distStatusLine = distStatusLine;
|
|
40
|
+
// @implements A-SPEC-673
|
|
41
|
+
/**
|
|
42
|
+
* Whether the built artefact still represents the source.
|
|
43
|
+
*
|
|
44
|
+
* Thirty suites in this repository load `dist/` while they run — they spawn it or require it — and
|
|
45
|
+
* nothing asserted that it was current. `scripts/verify-release.js` compares the build id to HEAD,
|
|
46
|
+
* but only at publish time, so during development a stale build does not go red: it quietly verifies
|
|
47
|
+
* old code and returns green. This repository has already paid for that once.
|
|
48
|
+
*
|
|
49
|
+
* The judgement is built on the BUILD ID, never on mtime, for a measured reason. On 2026-09-18 this
|
|
50
|
+
* tree's `dist` was nine commits behind HEAD and two changed sources were missing from it entirely,
|
|
51
|
+
* while `find src -newer dist/.build-id` answered zero — the marker's mtime had been touched without
|
|
52
|
+
* a rebuild. mtime lies. The build id says which commit the artefact actually came from.
|
|
53
|
+
*
|
|
54
|
+
* This module only JUDGES. It never rebuilds: a hook does not change the operator's tree in silence.
|
|
55
|
+
*/
|
|
56
|
+
const fs = __importStar(require("node:fs"));
|
|
57
|
+
const path = __importStar(require("node:path"));
|
|
58
|
+
/**
|
|
59
|
+
* The adoption predicate: existence, never contents. A workspace that does not build has no build
|
|
60
|
+
* freshness to report, and telling it about one would be noise it can do nothing with (the same
|
|
61
|
+
* reasoning REQ-664 used for the CI ledger).
|
|
62
|
+
*/
|
|
63
|
+
function hasDist(root) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.statSync(path.join(root, 'dist')).isDirectory();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** The commit the artefact came from — the part before the dash `npm run build` writes. */
|
|
72
|
+
function distBuildId(root) {
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = fs.readFileSync(path.join(root, 'dist', '.build-id'), 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const id = raw.trim().split('-')[0];
|
|
81
|
+
// A shape we do not recognise falls through to a judgement we cannot make, never to a pass.
|
|
82
|
+
return /^[0-9a-f]{7,40}$/.test(id) ? id : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Five states because each calls for something different: `behind` means build, `absent` means
|
|
86
|
+
* nothing at all to this workspace, and the two unjudgeable ones mean the check did not run — which
|
|
87
|
+
* must never be reported as a check that passed.
|
|
88
|
+
*/
|
|
89
|
+
function distFreshness(input) {
|
|
90
|
+
const { distExists, buildId, head, behind } = input;
|
|
91
|
+
const base = { buildId: buildId ?? null, head: head ?? null, behind: typeof behind === 'number' ? behind : null };
|
|
92
|
+
if (!distExists)
|
|
93
|
+
return { state: 'absent', ...base };
|
|
94
|
+
if (!buildId)
|
|
95
|
+
return { state: 'no-build-id', ...base };
|
|
96
|
+
if (!head || typeof behind !== 'number' || !Number.isFinite(behind) || behind < 0)
|
|
97
|
+
return { state: 'unknown', ...base };
|
|
98
|
+
return { state: behind === 0 ? 'fresh' : 'behind', ...base };
|
|
99
|
+
}
|
|
100
|
+
/** Empty when there is nothing to say. A line is spent only on a fact the reader can act on. */
|
|
101
|
+
function distStatusLine(v) {
|
|
102
|
+
if (v.state === 'fresh' || v.state === 'absent')
|
|
103
|
+
return '';
|
|
104
|
+
if (v.state === 'no-build-id') {
|
|
105
|
+
return 'dist/.build-id is missing or unreadable — the build could not be judged against the source; `npm run build` writes it';
|
|
106
|
+
}
|
|
107
|
+
if (v.state === 'unknown') {
|
|
108
|
+
return `dist was built from ${v.buildId ?? 'an unknown commit'} and the distance to HEAD could not be obtained — the build could not be judged`;
|
|
109
|
+
}
|
|
110
|
+
// No invented threshold: one commit is reported like nine, because which commits matter is a
|
|
111
|
+
// question the reader can answer and this function cannot.
|
|
112
|
+
const n = v.behind ?? 0;
|
|
113
|
+
return `dist is ${n} commit${n === 1 ? '' : 's'} behind HEAD (built from ${v.buildId}) — suites that load dist are verifying that build, not this tree; \`npm run build\``;
|
|
114
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report's way to us: a PREFILLED GitHub issue URL that a person submits on GitHub's own page.
|
|
3
|
+
*
|
|
4
|
+
* The alternative — holmes-kit holding a token and posting through the API — was rejected for a
|
|
5
|
+
* measured reason, not a stylistic one. Consent is the weakest link here: the first field report of
|
|
6
|
+
* the approval queue counted 1,412 unique requests against 8 decisions, 99.4% undecided. A yes/no
|
|
7
|
+
* buried in a stream of prompts is not a decision, and a user consents to "report this" without
|
|
8
|
+
* reading forty lines of body.
|
|
9
|
+
*
|
|
10
|
+
* A URL moves the decision to where the bytes are visible. The person sees the whole body in
|
|
11
|
+
* GitHub's own editor, can change it, and cancelling costs nothing — closing the tab is the entire
|
|
12
|
+
* undo. Four consequences follow structurally rather than by discipline: no token exists to leak,
|
|
13
|
+
* nothing can be sent silently, there is no auth/rate-limit/retry surface, and this module cannot
|
|
14
|
+
* exfiltrate anything because it has no transport at all. It only builds strings.
|
|
15
|
+
*/
|
|
16
|
+
import type { FieldReport } from './field-report';
|
|
17
|
+
export declare const ISSUE_REPO = "holmes-kit/holmes-kit";
|
|
18
|
+
/** Conservative: GitHub accepts more, but a URL that silently fails to open helps nobody. */
|
|
19
|
+
export declare const URL_BUDGET = 8000;
|
|
20
|
+
/**
|
|
21
|
+
* The repository owner, offered as the default assignee.
|
|
22
|
+
*
|
|
23
|
+
* KNOWN LIMIT, stated rather than implied: GitHub honours `assignee=` only when the person filling
|
|
24
|
+
* the form may assign (write or triage on the repo). A third-party reporter usually cannot, and the
|
|
25
|
+
* parameter is then dropped silently. It costs nothing either way — it routes when it can and is
|
|
26
|
+
* discarded when it cannot — but it is not a guarantee, and nobody should plan triage around it.
|
|
27
|
+
*/
|
|
28
|
+
export declare const ISSUE_ASSIGNEE = "sungnamparkkorea-arch";
|
|
29
|
+
export interface UrlOptions {
|
|
30
|
+
repo?: string;
|
|
31
|
+
localPath?: string;
|
|
32
|
+
budget?: number;
|
|
33
|
+
title?: string;
|
|
34
|
+
assignee?: string | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The fingerprint rides in the title so a person scanning the issue list recognises a duplicate
|
|
38
|
+
* without opening anything. The MESSAGE deliberately does not: a title is the most visible place in
|
|
39
|
+
* the tracker, and `message` is the one allowlisted field whose VALUE can be withheld as unsafe.
|
|
40
|
+
*/
|
|
41
|
+
export declare function issueTitle(r: FieldReport): string;
|
|
42
|
+
export declare function issueUrl(body: string, opts?: UrlOptions): {
|
|
43
|
+
url: string;
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
};
|
|
46
|
+
/** Empty in, empty out: a query with no fingerprint lists every issue, which is not a duplicate check. */
|
|
47
|
+
export declare function searchUrl(fingerprint: string, opts?: UrlOptions): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ISSUE_ASSIGNEE = exports.URL_BUDGET = exports.ISSUE_REPO = void 0;
|
|
4
|
+
exports.issueTitle = issueTitle;
|
|
5
|
+
exports.issueUrl = issueUrl;
|
|
6
|
+
exports.searchUrl = searchUrl;
|
|
7
|
+
exports.ISSUE_REPO = 'holmes-kit/holmes-kit';
|
|
8
|
+
/** Conservative: GitHub accepts more, but a URL that silently fails to open helps nobody. */
|
|
9
|
+
exports.URL_BUDGET = 8000;
|
|
10
|
+
/**
|
|
11
|
+
* The repository owner, offered as the default assignee.
|
|
12
|
+
*
|
|
13
|
+
* KNOWN LIMIT, stated rather than implied: GitHub honours `assignee=` only when the person filling
|
|
14
|
+
* the form may assign (write or triage on the repo). A third-party reporter usually cannot, and the
|
|
15
|
+
* parameter is then dropped silently. It costs nothing either way — it routes when it can and is
|
|
16
|
+
* discarded when it cannot — but it is not a guarantee, and nobody should plan triage around it.
|
|
17
|
+
*/
|
|
18
|
+
exports.ISSUE_ASSIGNEE = 'sungnamparkkorea-arch';
|
|
19
|
+
/**
|
|
20
|
+
* The fingerprint rides in the title so a person scanning the issue list recognises a duplicate
|
|
21
|
+
* without opening anything. The MESSAGE deliberately does not: a title is the most visible place in
|
|
22
|
+
* the tracker, and `message` is the one allowlisted field whose VALUE can be withheld as unsafe.
|
|
23
|
+
*/
|
|
24
|
+
function issueTitle(r) {
|
|
25
|
+
const what = r.article || r.tool || 'report';
|
|
26
|
+
return `holmes-kit ${r.kit}: ${what} (${r.fingerprint.slice(0, 8)})`;
|
|
27
|
+
}
|
|
28
|
+
const NOTE = (localPath) => localPath
|
|
29
|
+
? `\n\n— truncated to fit the URL; the full report is at ${localPath}`
|
|
30
|
+
: '\n\n— truncated to fit the URL; the full report stayed on the reporter\'s machine';
|
|
31
|
+
function issueUrl(body, opts = {}) {
|
|
32
|
+
const repo = opts.repo ?? exports.ISSUE_REPO;
|
|
33
|
+
const budget = opts.budget ?? exports.URL_BUDGET;
|
|
34
|
+
// The title goes in the URL, not just into a helper nobody calls. It was missing here for a
|
|
35
|
+
// slice: `issueTitle` existed, a pin called it directly, and the product path built `?body=`
|
|
36
|
+
// alone — so GitHub opened with a blank title. A function pinned in isolation says nothing about
|
|
37
|
+
// the path that is supposed to feed it.
|
|
38
|
+
const titlePart = opts.title && opts.title.trim() !== '' ? `title=${encodeURIComponent(opts.title)}&` : '';
|
|
39
|
+
// Omitted rather than emptied when absent: a fork or an internal mirror must not point at someone
|
|
40
|
+
// else's account, and `assignee=` with nothing after it is noise in the form.
|
|
41
|
+
const assigneePart = typeof opts.assignee === 'string' && opts.assignee.trim() !== ''
|
|
42
|
+
? `assignee=${encodeURIComponent(opts.assignee.trim())}&` : '';
|
|
43
|
+
const base = `https://github.com/${repo}/issues/new?${titlePart}${assigneePart}body=`;
|
|
44
|
+
const build = (text) => base + encodeURIComponent(text);
|
|
45
|
+
const whole = build(body);
|
|
46
|
+
if (whole.length <= budget)
|
|
47
|
+
return { url: whole, truncated: false };
|
|
48
|
+
// Never a silent cut: the note is part of the body that gets sent, so the reader of the ISSUE — not
|
|
49
|
+
// just the reporter — learns that something was left out.
|
|
50
|
+
const note = NOTE(opts.localPath);
|
|
51
|
+
let keep = body.length;
|
|
52
|
+
while (keep > 0 && build(body.slice(0, keep) + note).length > budget) {
|
|
53
|
+
keep = Math.floor(keep * 0.9);
|
|
54
|
+
}
|
|
55
|
+
return { url: build(body.slice(0, keep) + note), truncated: true };
|
|
56
|
+
}
|
|
57
|
+
/** Empty in, empty out: a query with no fingerprint lists every issue, which is not a duplicate check. */
|
|
58
|
+
function searchUrl(fingerprint, opts = {}) {
|
|
59
|
+
if (typeof fingerprint !== 'string' || fingerprint.trim() === '')
|
|
60
|
+
return '';
|
|
61
|
+
const repo = opts.repo ?? exports.ISSUE_REPO;
|
|
62
|
+
return `https://github.com/${repo}/issues?q=${encodeURIComponent(`is:issue ${fingerprint.trim()}`)}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The only keys a report may carry. Adding one is an edit here, which the pin makes visible. */
|
|
2
|
+
export declare const FIELD_REPORT_KEYS: readonly ["kit", "harness", "os", "arch", "node", "message", "article", "tool", "specIds", "observations", "fingerprint", "withheld"];
|
|
3
|
+
export interface FieldReportInput {
|
|
4
|
+
kit: string;
|
|
5
|
+
harness: string;
|
|
6
|
+
os: string;
|
|
7
|
+
arch: string;
|
|
8
|
+
node: string;
|
|
9
|
+
message?: string;
|
|
10
|
+
article?: string;
|
|
11
|
+
tool?: string;
|
|
12
|
+
specIds?: string[];
|
|
13
|
+
observations?: Array<{
|
|
14
|
+
label: string;
|
|
15
|
+
value: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** Strings that identify this machine or person, injected by the caller so a pin can search for them. */
|
|
18
|
+
identity?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FieldReport {
|
|
21
|
+
kit: string;
|
|
22
|
+
harness: string;
|
|
23
|
+
os: string;
|
|
24
|
+
arch: string;
|
|
25
|
+
node: string;
|
|
26
|
+
message: string;
|
|
27
|
+
article: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
specIds: string[];
|
|
30
|
+
observations: Array<{
|
|
31
|
+
label: string;
|
|
32
|
+
value: number;
|
|
33
|
+
}>;
|
|
34
|
+
fingerprint: string;
|
|
35
|
+
withheld: string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Injected rather than read from `os` here, so a pin can search a composed body for THIS machine's
|
|
39
|
+
* real values. A module that reads the environment itself can only be tested against fixtures, and
|
|
40
|
+
* fixtures are exactly what a redaction pin must not trust.
|
|
41
|
+
*
|
|
42
|
+
* Tokens shorter than two characters are dropped: a one-character identity would redact every
|
|
43
|
+
* sentence, which is destruction rather than redaction.
|
|
44
|
+
*/
|
|
45
|
+
export declare function machineIdentity(env: {
|
|
46
|
+
hostname?: string;
|
|
47
|
+
username?: string;
|
|
48
|
+
homedir?: string;
|
|
49
|
+
replicas?: string[];
|
|
50
|
+
}): string[];
|
|
51
|
+
export declare function isSafeValue(text: string, identity: string[]): boolean;
|
|
52
|
+
export declare function composeFieldReport(input: FieldReportInput): FieldReport;
|
|
53
|
+
/** The bytes a human will read and paste. Anything withheld is named, never dropped in silence. */
|
|
54
|
+
export declare function fieldReportBody(r: FieldReport): string;
|