@holmes-lab/holmes-kit 0.3.4 → 0.3.5

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 CHANGED
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.3.5] - 2026-09-01
9
+
10
+ Two P0 dogfooding fixes: the self-healer no longer breaks what it heals, and Python evidence
11
+ finally flows on venv projects.
12
+
13
+ ### Fixed
14
+
15
+ - **spec_remediate language-blind `//` injection** (A-SPEC-501.1, dogfooded twice): the anchor is
16
+ now injected in the file's own comment syntax (`#` for py/yml/sh/toml + Makefile/Dockerfile,
17
+ `//` for the C family — byte-identical to before, `/* */` for CSS), and a file whose syntax the
18
+ tool does not know is **refused with guidance** instead of broken. Every injected form is
19
+ round-trip verified against the real anchor scanner, so injector and scanner can no longer
20
+ drift apart.
21
+ - **test_run on Python venv projects** (A-SPEC-502.1): the interpreter resolves from evidence —
22
+ `HOLMES_PYTHON` first, then a `.venv`/`venv` carrying `pyvenv.cfg`, then the old `python3`
23
+ fallback (byte-identical when no venv exists). A `conftest.py` in the scoped file list widens
24
+ to its directory (the fixture's actual blast radius) instead of collection-ERRORing the whole
25
+ run — measured: the same tree passed 1995 cases manually while every scoped run failed.
26
+
7
27
  <!-- @implements A-SPEC-209 -->
8
28
  ## [0.3.4] - 2026-09-01
9
29
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 68b5982-mthe8y11
1
+ f98587b-mthg333w
@@ -130,6 +130,7 @@ const package_1 = require("../review/package");
130
130
  const risk_classifier_1 = require("../guardrail/risk-classifier");
131
131
  const risk_gate_1 = require("../guardrail/risk-gate");
132
132
  const elicit_approval_1 = require("./elicit-approval");
133
+ const anchor_comment_1 = require("../rtm/anchor-comment");
133
134
  const approval_queue_1 = require("../governance/approval-queue");
134
135
  const approval_grants_1 = require("../governance/approval-grants");
135
136
  const spec_digest_1 = require("../spec/spec-digest");
@@ -3037,9 +3038,17 @@ depends_on:
3037
3038
  if (a.targetFile && a.aspecId) {
3038
3039
  const fullPath = path.isAbsolute(a.targetFile) ? a.targetFile : path.join(root, a.targetFile);
3039
3040
  if (fs.existsSync(fullPath)) {
3041
+ // @implements A-SPEC-501.1 — BUG-4 (dogfooded twice): the old unconditional
3042
+ // `// @implements` broke Python/YAML targets outright AND produced anchors the scanner
3043
+ // could not read. The syntax comes from the one map the scanner is aligned with, an
3044
+ // unknown syntax is an honest refusal, and the containment check is form-agnostic so a
3045
+ // `#` anchor is not double-injected.
3046
+ const anchorTag = (0, anchor_comment_1.anchorLineFor)(fullPath, a.aspecId);
3040
3047
  const content = fs.readFileSync(fullPath, 'utf8');
3041
- const anchorTag = `// @implements ${a.aspecId}`;
3042
- if (!content.includes(anchorTag)) {
3048
+ if (anchorTag === null) {
3049
+ actionsTaken.push(`앵커 주입 거부: ${path.basename(fullPath)} — 이 확장자의 주석 문법을 모릅니다. 파일 관례에 맞는 주석으로 1행에 '@implements ${a.aspecId}'를 직접 추가하십시오`);
3050
+ }
3051
+ else if (!content.includes(`@implements ${a.aspecId}`)) {
3043
3052
  fs.writeFileSync(fullPath, `${anchorTag}\n${content}`);
3044
3053
  actionsTaken.push(`Injected ${anchorTag} on line 1 of ${a.targetFile}`);
3045
3054
  }
@@ -0,0 +1,7 @@
1
+ export interface PythonEnvDeps {
2
+ env: NodeJS.ProcessEnv;
3
+ exists: (p: string) => boolean;
4
+ platform: NodeJS.Platform;
5
+ }
6
+ export declare function pythonFor(cwd: string, deps: PythonEnvDeps): string;
7
+ export declare function pytestTargets(files: string[]): string[];
@@ -0,0 +1,76 @@
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.pythonFor = pythonFor;
37
+ exports.pytestTargets = pytestTargets;
38
+ // @implements A-SPEC-502.1
39
+ /**
40
+ * The two pure transforms that stand between planTestRun and runPytest's execFileSync — BUG-2,
41
+ * dogfooded on a real .venv project, where both halves of the evidence system idled:
42
+ *
43
+ * WHICH python: PATH's python3 was the system interpreter (no pytest), so last-green was never
44
+ * recorded and ART-4 debt piled 12 entries deep. The interpreter now resolves from EVIDENCE —
45
+ * pyvenv.cfg, the same proof containsPyvenvCfg uses in the scanner — never from a directory name,
46
+ * with HOLMES_PYTHON as the explicit operator escape hatch above all detection.
47
+ *
48
+ * WHAT arguments: conftest.py is a legitimate TEST FILE for anchor/coverage purposes, but not a
49
+ * runnable target — passed verbatim it collection-ERRORs every scoped run (measured: the same
50
+ * tree's manual `pytest tests/` was green, 1995 cases). A conftest argument WIDENS to its
51
+ * directory — the fixture's actual blast radius — because dropping it would verify less and
52
+ * keeping it verbatim verifies nothing.
53
+ */
54
+ const path = __importStar(require("node:path"));
55
+ function pythonFor(cwd, deps) {
56
+ const explicit = deps.env.HOLMES_PYTHON;
57
+ if (typeof explicit === 'string' && explicit.trim() !== '')
58
+ return explicit;
59
+ for (const dir of ['.venv', 'venv']) {
60
+ if (deps.exists(path.join(cwd, dir, 'pyvenv.cfg'))) {
61
+ return deps.platform === 'win32'
62
+ ? path.join(cwd, dir, 'Scripts', 'python.exe')
63
+ : path.join(cwd, dir, 'bin', 'python');
64
+ }
65
+ }
66
+ return 'python3';
67
+ }
68
+ function pytestTargets(files) {
69
+ const out = [];
70
+ for (const f of files) {
71
+ const target = path.basename(f) === 'conftest.py' ? path.dirname(f) : f;
72
+ if (!out.includes(target))
73
+ out.push(target);
74
+ }
75
+ return out;
76
+ }
@@ -75,6 +75,12 @@ export declare function parseJUnitXmlCounts(xml: string, ext?: string): Record<s
75
75
  * there), and a repo with no local jest could never yield meaningful suite evidence anyway.
76
76
  */
77
77
  export declare function jestEntry(cwd: string): string | null;
78
+ /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
79
+ export declare function runPytest(files: string[], mode: TestRunPlan['mode'], cwd: string): {
80
+ passed: boolean;
81
+ tail: string;
82
+ executed: Record<string, number>;
83
+ };
78
84
  /**
79
85
  * @implements A-SPEC-137.2
80
86
  * Executed-test count from `cargo test` text output. cargo prints one `test <name> ... ok|FAILED|
@@ -39,6 +39,7 @@ exports.parseGoTestJson = parseGoTestJson;
39
39
  exports.parseExecutedCounts = parseExecutedCounts;
40
40
  exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
41
41
  exports.jestEntry = jestEntry;
42
+ exports.runPytest = runPytest;
42
43
  exports.parseCargoTest = parseCargoTest;
43
44
  exports.runCargo = runCargo;
44
45
  exports.runGradle = runGradle;
@@ -50,6 +51,7 @@ const node_child_process_1 = require("node:child_process");
50
51
  const fs = __importStar(require("node:fs"));
51
52
  const os = __importStar(require("node:os"));
52
53
  const path = __importStar(require("node:path"));
54
+ const python_env_1 = require("./python-env");
53
55
  function planTestRun(scope) {
54
56
  if (scope.tier === 'full') {
55
57
  return { mode: 'full', testFiles: [], reason: 'full regression — run the entire suite' };
@@ -241,6 +243,7 @@ function runJest(files, mode, cwd) {
241
243
  }
242
244
  }
243
245
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
246
+ // @implements A-SPEC-502.1 — exported for the wiring test (a fake venv python capturing argv).
244
247
  function runPytest(files, mode, cwd) {
245
248
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-pytest-'));
246
249
  const report = path.join(dir, 'report.xml');
@@ -253,8 +256,12 @@ function runPytest(files, mode, cwd) {
253
256
  // attribute per testcase. pytest's default since v6 is xunit2, which carries only `classname` —
254
257
  // measured: a green 4-passed/1-skipped run yielded ZERO per-file evidence under the default, so
255
258
  // the tier gate would have had nothing to verify against.
259
+ // @implements A-SPEC-502.1 — BUG-2, both halves: the interpreter resolves from pyvenv.cfg
260
+ // evidence (HOLMES_PYTHON above all, python3 fallback byte-identical to the old path), and a
261
+ // conftest.py argument widens to its directory instead of collection-ERRORing the whole run.
256
262
  const args = ['-m', 'pytest', '-q', '-o', 'junit_family=xunit1', `--junit-xml=${report}`,
257
- ...(mode === 'scoped' ? ['--', ...files] : [])];
263
+ ...(mode === 'scoped' ? ['--', ...(0, python_env_1.pytestTargets)(files)] : [])];
264
+ const python = (0, python_env_1.pythonFor)(cwd, { env: process.env, exists: fs.existsSync, platform: process.platform });
258
265
  const read = () => {
259
266
  try {
260
267
  return parseJUnitXmlCounts(fs.readFileSync(report, 'utf8'));
@@ -264,7 +271,7 @@ function runPytest(files, mode, cwd) {
264
271
  }
265
272
  };
266
273
  try {
267
- const out = (0, node_child_process_1.execFileSync)('python3', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
274
+ const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
268
275
  return { passed: true, tail: tailOf(out), executed: read() };
269
276
  }
270
277
  catch (e) {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The one comment-syntax truth the anchor INJECTOR shares with the anchor SCANNER. BUG-4
3
+ * (dogfooded twice): spec_remediate wrote `// @implements` into every file type — a YAML workflow
4
+ * became a parse error, a Python golden file a SyntaxError, and the anchor was not even legible to
5
+ * extractAnchors. Every form emitted here matches the scanner's comment prefixes (`//`, `/*`, `#`);
6
+ * a syntax this map does not know returns null and the caller must REFUSE to write — an honest
7
+ * refusal beats a broken file, and beats an anchor the gate cannot read.
8
+ */
9
+ export declare function anchorLineFor(filePath: string, aspecId: string): string | null;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ // @implements A-SPEC-501.1
3
+ /**
4
+ * The one comment-syntax truth the anchor INJECTOR shares with the anchor SCANNER. BUG-4
5
+ * (dogfooded twice): spec_remediate wrote `// @implements` into every file type — a YAML workflow
6
+ * became a parse error, a Python golden file a SyntaxError, and the anchor was not even legible to
7
+ * extractAnchors. Every form emitted here matches the scanner's comment prefixes (`//`, `/*`, `#`);
8
+ * a syntax this map does not know returns null and the caller must REFUSE to write — an honest
9
+ * refusal beats a broken file, and beats an anchor the gate cannot read.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.anchorLineFor = anchorLineFor;
13
+ const SLASH = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'java', 'c', 'h', 'cc', 'cpp',
14
+ 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'kts', 'scala', 'dart']);
15
+ const HASH = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yml', 'yaml', 'toml', 'pl', 'pm',
16
+ 'r', 'jl', 'cmake', 'mk']);
17
+ const CSS = new Set(['css', 'scss', 'less']);
18
+ const HASH_BASENAMES = new Set(['Makefile', 'Dockerfile', 'Rakefile', 'Gemfile']);
19
+ function anchorLineFor(filePath, aspecId) {
20
+ const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
21
+ if (HASH_BASENAMES.has(basename))
22
+ return `# @implements ${aspecId}`;
23
+ const dot = basename.lastIndexOf('.');
24
+ if (dot <= 0)
25
+ return null;
26
+ const ext = basename.slice(dot + 1).toLowerCase();
27
+ if (SLASH.has(ext))
28
+ return `// @implements ${aspecId}`;
29
+ if (HASH.has(ext))
30
+ return `# @implements ${aspecId}`;
31
+ if (CSS.has(ext))
32
+ return `/* @implements ${aspecId} */`;
33
+ return null;
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.3.4",
4
+ "version": "0.3.5",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",