pi-helper-core 0.1.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 +21 -0
- package/LICENSE +17 -0
- package/README.md +86 -0
- package/package.json +42 -0
- package/src/adapter.ts +152 -0
- package/src/build/staleness.ts +147 -0
- package/src/core/result.ts +205 -0
- package/src/core/runner.ts +128 -0
- package/src/core/safety.ts +167 -0
- package/src/index.ts +102 -0
- package/src/selection/select.ts +322 -0
- package/src/validation/bundle.ts +131 -0
- package/src/validation/evidence.ts +48 -0
- package/src/validation/tdd.ts +152 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Initial extraction of the shared core from `pi-ros-helper` and `pi-python-helper`, which had each implemented the same envelope, runner, safety, validation, and staleness logic independently (`src/core/version.ts` was byte-identical; `src/core/result.ts` had already drifted).
|
|
14
|
+
- `core/result.ts`: the shared response envelope with the `ok`/`attention` contract, `CORE_SCHEMA_VERSION`, and an ecosystem-neutral `ToolchainInfo` that replaces per-ecosystem metadata fields (`pythonVersion`, `rosDistro`). `createResultFactory(toolVersion)` binds the envelope to a package so a helper keeps its own call sites.
|
|
15
|
+
- `core/runner.ts`: one bounded subprocess entry point with timeout, `AbortSignal`, output caps, process-group termination, and `isSpawnFailure` for "the command never started".
|
|
16
|
+
- `core/safety.ts`: risk classification from command text. Universal rules (repository history, file system, containers, destructive SQL, pipe-to-shell) always apply; adapters add only their own package-manager rules.
|
|
17
|
+
- `validation/evidence.ts`, `validation/tdd.ts`, `validation/bundle.ts`: the completion gates, parameterised by adapter-supplied labels and file classification. `summarizeValidation` treats a preview, an unexecuted step, and a run that executed zero tests as failures rather than as non-failures.
|
|
18
|
+
- `build/staleness.ts`: generic "derived artifact older than its newest source" detection with adapter-supplied artifact specs.
|
|
19
|
+
- `selection/select.ts`: the test-selection ranking with injected file classification and module naming. Signals shared by every candidate are discarded so a package-rooted test tree narrows instead of selecting the whole suite.
|
|
20
|
+
- `adapter.ts`: the `EcosystemAdapter` seam and its supporting types.
|
|
21
|
+
- `test/purity.test.ts`: fails the build if an ecosystem name appears in core executable code, or if the core imports anything outside itself and Node builtins.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 wkqco
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# pi-helper-core
|
|
2
|
+
|
|
3
|
+
pi 언어 헬퍼 확장(`pi-ros-helper`, `pi-python-helper`, `pi-rust-helper`, …)이 공유하는 **생태계 중립 코어**입니다.
|
|
4
|
+
|
|
5
|
+
## 왜 필요한가
|
|
6
|
+
|
|
7
|
+
`pi-ros-helper`와 `pi-python-helper`는 같은 것을 각자 구현했습니다.
|
|
8
|
+
|
|
9
|
+
- `src/core/version.ts` → **바이트 단위로 동일**
|
|
10
|
+
- `src/core/result.ts` → 같은 envelope, 이미 **드리프트**(ROS에 `attention`/`CommandPreview.risk` 없음)
|
|
11
|
+
- `src/core/{runner,safety}.ts`, `src/validation/{bundle,evidence,tdd}.ts`, `src/build/staleness.ts` → 양쪽에 중복
|
|
12
|
+
|
|
13
|
+
확장을 하나 더 복사할 때마다 이 계약이 한 벌씩 늘어납니다. 이 패키지가 단일 진실입니다.
|
|
14
|
+
|
|
15
|
+
## 무엇이 들어 있나
|
|
16
|
+
|
|
17
|
+
| 모듈 | 내용 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `core/result.ts` | 응답 envelope, `ok`/`attention` 계약, 생태계 중립 `ToolchainInfo` |
|
|
20
|
+
| `core/runner.ts` | 타임아웃·`AbortSignal`·출력 상한이 걸린 단일 서브프로세스 진입점 |
|
|
21
|
+
| `core/safety.ts` | 명령 위험도 분류(universal 규칙 + 어댑터 규칙 주입) |
|
|
22
|
+
| `validation/evidence.ts` | 완료 증거 게이트 |
|
|
23
|
+
| `validation/tdd.ts` | 프로덕션 변경 ↔ 테스트 변경 연관 검사 |
|
|
24
|
+
| `validation/bundle.ts` | 검증 번들 요약 게이트 |
|
|
25
|
+
| `build/staleness.ts` | 파생 산출물이 소스보다 오래됐는지 판정 |
|
|
26
|
+
| `selection/select.ts` | 테스트 선별 랭킹 알고리즘 |
|
|
27
|
+
| `adapter.ts` | `EcosystemAdapter` 계약 |
|
|
28
|
+
|
|
29
|
+
**들어 있지 않은 것**: 도구 등록(`registerTool` 호출 금지), 특정 생태계의 명령·매니페스트·출력 형식. `test/purity.test.ts`가 코어 소스에 생태계 이름이 들어오는 것을 막습니다.
|
|
30
|
+
|
|
31
|
+
## 사용법
|
|
32
|
+
|
|
33
|
+
기존 헬퍼는 로컬 shim 두 줄로 마이그레이션할 수 있습니다. 호출부는 바뀌지 않습니다.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// src/core/result.ts (헬퍼 쪽 shim)
|
|
37
|
+
import { createResultFactory } from 'pi-helper-core';
|
|
38
|
+
import { TOOL_VERSION } from './version.ts';
|
|
39
|
+
|
|
40
|
+
export const { result, failure } = createResultFactory(TOOL_VERSION);
|
|
41
|
+
export { warn, note } from 'pi-helper-core';
|
|
42
|
+
export type { ToolResult, Diagnostic, CommandPreview, ToolchainInfo } from 'pi-helper-core';
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
어댑터는 생태계 지식만 채웁니다.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { defineAdapter } from 'pi-helper-core';
|
|
49
|
+
|
|
50
|
+
export const adapter = defineAdapter({
|
|
51
|
+
id: 'rust',
|
|
52
|
+
riskRules: [/* cargo 전용 규칙 */],
|
|
53
|
+
selectionSignals: {/* 파일 분류, 모듈명, 토큰 */},
|
|
54
|
+
resolveToolchain: async (ctx) => {/* ... */},
|
|
55
|
+
readProjectModel: async (ctx) => {/* ... */},
|
|
56
|
+
testCommand: (input, ctx) => {/* ... */},
|
|
57
|
+
parseTestOutput: (stdout, stderr) => {/* ... */},
|
|
58
|
+
diagnoseFailure: (output) => {/* ... */},
|
|
59
|
+
derivedArtifacts: () => [],
|
|
60
|
+
testDirectories: () => ['tests'],
|
|
61
|
+
checkCommand: (input, ctx) => {/* ... */},
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 계약 (반드시 지킬 것)
|
|
66
|
+
|
|
67
|
+
- `ok`는 **도구의 판정**입니다("도구가 실행됐다"가 아님). 문제를 찾으면 도구가 실패하지 않았어도 `ok: false`입니다.
|
|
68
|
+
- `attention`은 **조치 필요 여부**입니다. `ok: false`이거나 warning/error가 있으면 `true`, `info`는 `false`.
|
|
69
|
+
- `ok: false`에는 **항상 설명하는 진단**이 따라옵니다.
|
|
70
|
+
- 프리뷰는 **절대 통과가 아닙니다**(`summarizeValidation`이 `preview: true`면 `ok: false`).
|
|
71
|
+
- 실행 범위를 숨기지 않습니다. 워크스페이스에서 "테스트 통과"는 **무엇이 실제로 실행됐는지**와 함께여야 합니다(`TestReport.ranTargets`, `noTestsRan`).
|
|
72
|
+
|
|
73
|
+
## 개발
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
npm test # 48 tests
|
|
78
|
+
npm run typecheck
|
|
79
|
+
npm run check # test + typecheck + format:check + pack-check
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 상태
|
|
83
|
+
|
|
84
|
+
- `0.1.0`, **아직 npm에 배포하지 않음**
|
|
85
|
+
- TypeScript 소스를 그대로 export합니다(`exports: "./src/index.ts"`). pi 확장 생태계가 TS를 직접 로드하므로 빌드 단계를 두지 않습니다.
|
|
86
|
+
- 소비자: `pi-rust-helper`(파일럿) → 검증 후 `pi-ros-helper`, `pi-python-helper` 순으로 마이그레이션 예정
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-helper-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ecosystem-neutral core for pi language helper extensions: result envelope, bounded command runner, risk classification, completion gates, artifact staleness, and test selection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"keywords": ["pi-package", "pi", "helper", "toolchain", "validation"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/wkqco33/pi-helper-core.git"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "tsx --test test/*.test.ts",
|
|
22
|
+
"test:coverage": "tsx --test --experimental-test-coverage test/*.test.ts",
|
|
23
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
24
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
25
|
+
"pack-check": "node scripts/verify-pack.mjs",
|
|
26
|
+
"check": "npm test && npm run typecheck && npm run format:check && npm run pack-check"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^26.6.2",
|
|
38
|
+
"prettier": "^3.9.8",
|
|
39
|
+
"tsx": "^4.23.13",
|
|
40
|
+
"typescript": "^7.0.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { CommandPreview, Diagnostic, Suggestion, ToolchainInfo } from './core/result.ts';
|
|
2
|
+
import type { RiskRule } from './core/safety.ts';
|
|
3
|
+
import type { StaleArtifactSpec } from './build/staleness.ts';
|
|
4
|
+
import type { SelectionSignals } from './selection/select.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The seam between the shared core and one ecosystem.
|
|
8
|
+
*
|
|
9
|
+
* Only the methods a helper's implemented tools actually need must exist; the
|
|
10
|
+
* rest can land later. The interface is deliberately narrower than "everything
|
|
11
|
+
* a helper might want" so that a single pilot cannot freeze a wrong abstraction
|
|
12
|
+
* for the other ecosystems.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface AdapterContext {
|
|
16
|
+
cwd: string;
|
|
17
|
+
projectRoot?: string;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FailureFrame {
|
|
22
|
+
path: string;
|
|
23
|
+
line: number;
|
|
24
|
+
column?: number;
|
|
25
|
+
/** True for installed dependencies and toolchain internals. */
|
|
26
|
+
library: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FailureDiagnosis {
|
|
30
|
+
/** Ecosystem-defined kind; `unknown` must mean "not classified". */
|
|
31
|
+
kind: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
/** Error code or exception type, for example `E0599`. */
|
|
34
|
+
exceptionType?: string;
|
|
35
|
+
missingModule?: string;
|
|
36
|
+
missingExecutable?: string;
|
|
37
|
+
frames: FailureFrame[];
|
|
38
|
+
/** The frame the user should look at, which is never a library frame. */
|
|
39
|
+
firstUserFrame?: FailureFrame;
|
|
40
|
+
evidence: { message: string; file?: string; line?: number }[];
|
|
41
|
+
suggestions: Suggestion[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TestCounts {
|
|
45
|
+
passed: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
errors: number;
|
|
48
|
+
skipped: number;
|
|
49
|
+
xfailed?: number;
|
|
50
|
+
xpassed?: number;
|
|
51
|
+
warnings?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TestFailure {
|
|
55
|
+
test: string;
|
|
56
|
+
message: string;
|
|
57
|
+
file?: string;
|
|
58
|
+
line?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TestReport {
|
|
62
|
+
executed: boolean;
|
|
63
|
+
exitCode: number | null;
|
|
64
|
+
timedOut: boolean;
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
counts: TestCounts;
|
|
67
|
+
/**
|
|
68
|
+
* The units that actually ran. In a workspace, "tests passed" is only
|
|
69
|
+
* meaningful next to the list of members that were compiled and tested,
|
|
70
|
+
* because a root-level run often covers a subset.
|
|
71
|
+
*/
|
|
72
|
+
ranTargets: string[];
|
|
73
|
+
/** True when documentation tests were part of the run. */
|
|
74
|
+
includedDocTests?: boolean;
|
|
75
|
+
/** True when the runner completed without executing a single test. */
|
|
76
|
+
noTestsRan: boolean;
|
|
77
|
+
/** True when no trustworthy summary could be parsed. */
|
|
78
|
+
incomplete: boolean;
|
|
79
|
+
summaryLine?: string;
|
|
80
|
+
failures: TestFailure[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ProjectPackage {
|
|
84
|
+
name: string;
|
|
85
|
+
version?: string;
|
|
86
|
+
manifest: string;
|
|
87
|
+
/** True when the package is a member of the workspace. */
|
|
88
|
+
member: boolean;
|
|
89
|
+
/** True when a bare workspace-level run covers this package. */
|
|
90
|
+
defaultMember?: boolean;
|
|
91
|
+
/** Declared minimum toolchain version, when the manifest carries one. */
|
|
92
|
+
minimumToolchain?: string;
|
|
93
|
+
detail?: Record<string, string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ProjectModel {
|
|
97
|
+
root: string;
|
|
98
|
+
toolchain: ToolchainInfo;
|
|
99
|
+
packages: ProjectPackage[];
|
|
100
|
+
warnings: Diagnostic[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A derived file that must be newer than the sources it describes. */
|
|
104
|
+
export type DerivedArtifact = StaleArtifactSpec;
|
|
105
|
+
|
|
106
|
+
export interface TestCommandInput {
|
|
107
|
+
/** Workspace members to restrict the run to. */
|
|
108
|
+
targets?: string[];
|
|
109
|
+
/** Individual files to run, when the runner accepts them directly. */
|
|
110
|
+
files?: string[];
|
|
111
|
+
/** Run tests for every target the manifest declares. */
|
|
112
|
+
allTargets?: boolean;
|
|
113
|
+
/** Include documentation tests, when the ecosystem has them. */
|
|
114
|
+
docTests?: boolean;
|
|
115
|
+
extraArgs?: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CheckCommandInput {
|
|
119
|
+
targets?: string[];
|
|
120
|
+
allTargets?: boolean;
|
|
121
|
+
/** Compile only, without producing final artifacts. */
|
|
122
|
+
extraArgs?: string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface EcosystemAdapter {
|
|
126
|
+
/** Ecosystem identifier, for example `rust`. */
|
|
127
|
+
readonly id: string;
|
|
128
|
+
/** Ecosystem-specific additions to the universal risk rules. */
|
|
129
|
+
readonly riskRules: RiskRule[];
|
|
130
|
+
/** File-classification and ranking signals for test selection. */
|
|
131
|
+
readonly selectionSignals: SelectionSignals;
|
|
132
|
+
/** Tdd checkpoint signals; defaults to the selection signals when omitted. */
|
|
133
|
+
readonly tddSignals?: import('./validation/tdd.ts').TddSignals;
|
|
134
|
+
|
|
135
|
+
resolveToolchain(ctx: AdapterContext): Promise<ToolchainInfo>;
|
|
136
|
+
readProjectModel(ctx: AdapterContext): Promise<ProjectModel>;
|
|
137
|
+
|
|
138
|
+
testCommand(input: TestCommandInput, ctx: AdapterContext): CommandPreview;
|
|
139
|
+
checkCommand(input: CheckCommandInput, ctx: AdapterContext): CommandPreview;
|
|
140
|
+
parseTestOutput(stdout: string, stderr: string): TestReport;
|
|
141
|
+
diagnoseFailure(output: string, model?: ProjectModel): FailureDiagnosis;
|
|
142
|
+
|
|
143
|
+
/** Derived artifacts worth a staleness comparison; may be empty. */
|
|
144
|
+
derivedArtifacts(model: ProjectModel): DerivedArtifact[];
|
|
145
|
+
/** Directories the runner collects tests from. */
|
|
146
|
+
testDirectories(model: ProjectModel): string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Identity helper that preserves literal types when defining an adapter. */
|
|
150
|
+
export function defineAdapter<T extends EcosystemAdapter>(adapter: T): T {
|
|
151
|
+
return adapter;
|
|
152
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface StaleArtifact {
|
|
5
|
+
code: string;
|
|
6
|
+
message: string;
|
|
7
|
+
path: string;
|
|
8
|
+
artifactMtimeMs?: number;
|
|
9
|
+
newestSource?: { path: string; mtimeMs: number };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StalenessReport {
|
|
13
|
+
stale: boolean;
|
|
14
|
+
artifacts: StaleArtifact[];
|
|
15
|
+
/** Populated when the check could not be completed, so `stale: false` is not overclaimed. */
|
|
16
|
+
incompleteReason?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One derived file that must be newer than the sources it describes. */
|
|
20
|
+
export interface StaleArtifactSpec {
|
|
21
|
+
/** File name relative to the project root. */
|
|
22
|
+
name: string;
|
|
23
|
+
code: string;
|
|
24
|
+
/** What the file holds, used in the message (`coverage results`). */
|
|
25
|
+
describe: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StalenessSpec {
|
|
29
|
+
/** Extensions that count as source, including the dot (`.py`, `.rs`). */
|
|
30
|
+
sourceExtensions: string[];
|
|
31
|
+
artifacts: StaleArtifactSpec[];
|
|
32
|
+
/** Directories that never hold sources worth comparing. */
|
|
33
|
+
ignoredDirectories: ReadonlySet<string>;
|
|
34
|
+
maxWalkedFiles?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Directories that are never source in any ecosystem. */
|
|
38
|
+
export const UNIVERSAL_IGNORED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
39
|
+
'.git',
|
|
40
|
+
'node_modules',
|
|
41
|
+
'__pycache__',
|
|
42
|
+
'.pytest_cache',
|
|
43
|
+
'.mypy_cache',
|
|
44
|
+
'.ruff_cache',
|
|
45
|
+
'.tox',
|
|
46
|
+
'.nox',
|
|
47
|
+
'.eggs',
|
|
48
|
+
'build',
|
|
49
|
+
'dist',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const DEFAULT_MAX_WALKED_FILES = 5000;
|
|
53
|
+
|
|
54
|
+
async function mtimeMs(path: string): Promise<number | undefined> {
|
|
55
|
+
try {
|
|
56
|
+
return (await stat(path)).mtimeMs;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function newestSource(
|
|
63
|
+
root: string,
|
|
64
|
+
spec: StalenessSpec,
|
|
65
|
+
): Promise<{ path: string; mtimeMs: number } | undefined> {
|
|
66
|
+
let newest: { path: string; mtimeMs: number } | undefined;
|
|
67
|
+
let visited = 0;
|
|
68
|
+
const maxFiles = spec.maxWalkedFiles ?? DEFAULT_MAX_WALKED_FILES;
|
|
69
|
+
const ignored = new Set([...UNIVERSAL_IGNORED_DIRECTORIES, ...spec.ignoredDirectories]);
|
|
70
|
+
const stack = [root];
|
|
71
|
+
while (stack.length > 0) {
|
|
72
|
+
const directory = stack.pop() as string;
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
// A single unreadable directory must not abort the whole scan.
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (visited > maxFiles) return newest;
|
|
82
|
+
const path = join(directory, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
if (ignored.has(entry.name)) continue;
|
|
85
|
+
stack.push(path);
|
|
86
|
+
} else if (entry.isFile() && spec.sourceExtensions.some((ext) => entry.name.endsWith(ext))) {
|
|
87
|
+
visited += 1;
|
|
88
|
+
const modified = await mtimeMs(path);
|
|
89
|
+
if (modified === undefined) continue;
|
|
90
|
+
if (!newest || modified > newest.mtimeMs) newest = { path, mtimeMs: modified };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return newest;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detect a derived artifact that predates the sources it claims to describe.
|
|
99
|
+
*
|
|
100
|
+
* Bytecode caches and build fingerprints are invalidated automatically by most
|
|
101
|
+
* toolchains, so the artifacts that matter are the ones a tool writes and then
|
|
102
|
+
* quotes later (a coverage report, a generated binding). Quoting one of those
|
|
103
|
+
* after the sources changed is how a stale number becomes a false claim.
|
|
104
|
+
*
|
|
105
|
+
* When the check cannot be completed the report says why instead of returning a
|
|
106
|
+
* clean `stale: false`.
|
|
107
|
+
*/
|
|
108
|
+
export async function detectStaleArtifacts(
|
|
109
|
+
root: string,
|
|
110
|
+
spec: StalenessSpec,
|
|
111
|
+
): Promise<StalenessReport> {
|
|
112
|
+
const artifacts: StaleArtifact[] = [];
|
|
113
|
+
let newest: { path: string; mtimeMs: number } | undefined;
|
|
114
|
+
try {
|
|
115
|
+
newest = await newestSource(root, spec);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return {
|
|
118
|
+
stale: false,
|
|
119
|
+
artifacts,
|
|
120
|
+
incompleteReason: `Source scan failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (!newest) {
|
|
124
|
+
return {
|
|
125
|
+
stale: false,
|
|
126
|
+
artifacts,
|
|
127
|
+
incompleteReason: `No source file (${spec.sourceExtensions.join(', ')}) was found.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const artifact of spec.artifacts) {
|
|
132
|
+
const path = join(root, artifact.name);
|
|
133
|
+
const modified = await mtimeMs(path);
|
|
134
|
+
if (modified === undefined) continue;
|
|
135
|
+
if (modified < newest.mtimeMs) {
|
|
136
|
+
artifacts.push({
|
|
137
|
+
code: artifact.code,
|
|
138
|
+
message: `${artifact.name} was written before ${newest.path} changed; ${artifact.describe} do not describe the current sources.`,
|
|
139
|
+
path,
|
|
140
|
+
artifactMtimeMs: modified,
|
|
141
|
+
newestSource: newest,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { stale: artifacts.length > 0, artifacts };
|
|
147
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single response contract every helper tool returns, so an agent can rely
|
|
3
|
+
* on one shape regardless of which ecosystem the helper targets.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here may mention a specific ecosystem: the fields are the union of
|
|
6
|
+
* what a Python, ROS, Rust, Go, or Flutter helper needs, and anything else an
|
|
7
|
+
* ecosystem requires goes into `metadata.detail` rather than into a new field.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bumped when the envelope's shape changes. A consumer that pins a helper and
|
|
12
|
+
* this library independently can compare it and refuse a document it does not
|
|
13
|
+
* understand, the same way a scanner protocol version works.
|
|
14
|
+
*/
|
|
15
|
+
export const CORE_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
export type Severity = 'info' | 'warning' | 'error';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How dangerous running a command is. `read` is safe to run automatically;
|
|
21
|
+
* `mutating` and `irreversible` require explicit opt-in from the caller.
|
|
22
|
+
*/
|
|
23
|
+
export type CommandRisk = 'read' | 'mutating' | 'irreversible';
|
|
24
|
+
|
|
25
|
+
export interface Diagnostic {
|
|
26
|
+
code?: string;
|
|
27
|
+
message: string;
|
|
28
|
+
severity: Severity;
|
|
29
|
+
path?: string;
|
|
30
|
+
line?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Evidence {
|
|
34
|
+
kind: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Suggestion {
|
|
40
|
+
message: string;
|
|
41
|
+
confidence?: 'low' | 'medium' | 'high';
|
|
42
|
+
command?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CommandPreview {
|
|
46
|
+
executable: string;
|
|
47
|
+
args: string[];
|
|
48
|
+
cwd?: string;
|
|
49
|
+
/** Risk classification for the command; `read` commands never change state. */
|
|
50
|
+
risk?: CommandRisk;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Ecosystem-neutral description of the toolchain a helper actually inspected.
|
|
55
|
+
*
|
|
56
|
+
* This replaces per-ecosystem metadata fields (`pythonVersion`, `rosDistro`):
|
|
57
|
+
* a consumer reads `toolchain.kind` and `toolchain.version` without knowing
|
|
58
|
+
* which helper produced the response.
|
|
59
|
+
*/
|
|
60
|
+
export interface ToolchainInfo {
|
|
61
|
+
/** Ecosystem identifier, for example `rust`, `python`, or `ros`. */
|
|
62
|
+
kind: string;
|
|
63
|
+
/** Version of the toolchain that would run commands. */
|
|
64
|
+
version?: string;
|
|
65
|
+
/**
|
|
66
|
+
* How the toolchain was located, so a caller can tell a project-local
|
|
67
|
+
* environment from whatever happened to be on `PATH`.
|
|
68
|
+
*/
|
|
69
|
+
source?: 'project' | 'path' | 'override' | 'unknown';
|
|
70
|
+
/** Target triple, platform string, or distribution name. */
|
|
71
|
+
host?: string;
|
|
72
|
+
/** Ecosystem-specific extras that do not deserve a shared field. */
|
|
73
|
+
detail?: Record<string, string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ToolMetadata {
|
|
77
|
+
toolVersion: string;
|
|
78
|
+
cwd: string;
|
|
79
|
+
durationMs: number;
|
|
80
|
+
truncated: boolean;
|
|
81
|
+
projectRoot?: string;
|
|
82
|
+
toolchain?: ToolchainInfo;
|
|
83
|
+
/** Escape hatch for ecosystem-specific metadata that is not shared. */
|
|
84
|
+
detail?: Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ToolResult<T = unknown> {
|
|
88
|
+
/**
|
|
89
|
+
* The tool's own verdict, not "the tool ran". `true` means the question this
|
|
90
|
+
* tool asks was answered affirmatively: the project state is acceptable, the
|
|
91
|
+
* command succeeded, or the gate may proceed. A diagnostic tool that finds a
|
|
92
|
+
* problem therefore returns `ok: false` without the tool itself having
|
|
93
|
+
* failed. Read `attention` for "must the caller act".
|
|
94
|
+
*/
|
|
95
|
+
ok: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* `true` when the caller must act before proceeding: the tool failed, or it
|
|
98
|
+
* emitted a warning or an error. An `info` diagnostic is informational by
|
|
99
|
+
* definition and does not set this. Derived from `ok`, `warnings`, and
|
|
100
|
+
* `errors` unless a tool sets it explicitly, so `ok: false` always implies
|
|
101
|
+
* `attention: true` and no diagnostic is silently dropped. This is the field
|
|
102
|
+
* to read when the question is "do I need to do something".
|
|
103
|
+
*/
|
|
104
|
+
attention: boolean;
|
|
105
|
+
summary: string;
|
|
106
|
+
data?: T;
|
|
107
|
+
evidence: Evidence[];
|
|
108
|
+
warnings: Diagnostic[];
|
|
109
|
+
errors: Diagnostic[];
|
|
110
|
+
suggestions: Suggestion[];
|
|
111
|
+
commands?: CommandPreview[];
|
|
112
|
+
metadata: ToolMetadata;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Values a tool supplies. `attention` is normally derived, and `metadata` is
|
|
117
|
+
* assembled by the factory, so neither is expected from the caller.
|
|
118
|
+
*/
|
|
119
|
+
export type ResultInput<T = unknown> = Omit<ToolResult<T>, 'metadata' | 'attention'> & {
|
|
120
|
+
attention?: boolean;
|
|
121
|
+
truncated?: boolean;
|
|
122
|
+
projectRoot?: string;
|
|
123
|
+
toolchain?: ToolchainInfo;
|
|
124
|
+
detail?: Record<string, unknown>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* An `info` diagnostic records a fact; only a warning or an error asks the
|
|
129
|
+
* caller to do something. Keeping them apart stops a purely informational note
|
|
130
|
+
* from raising `attention`.
|
|
131
|
+
*/
|
|
132
|
+
export function isActionable(value: { warnings: Diagnostic[]; errors: Diagnostic[] }): boolean {
|
|
133
|
+
return [...value.warnings, ...value.errors].some((entry) => entry.severity !== 'info');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ResultFactory {
|
|
137
|
+
result: <T>(cwd: string, startedAt: number, value: ResultInput<T>) => ToolResult<T>;
|
|
138
|
+
failure: (
|
|
139
|
+
cwd: string,
|
|
140
|
+
startedAt: number,
|
|
141
|
+
message: string,
|
|
142
|
+
code: string,
|
|
143
|
+
details?: Partial<ResultInput>,
|
|
144
|
+
) => ToolResult;
|
|
145
|
+
toolVersion: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bind the envelope to one package's version.
|
|
150
|
+
*
|
|
151
|
+
* A helper keeps a two-line local shim so its own modules keep importing
|
|
152
|
+
* `result`/`failure` unchanged:
|
|
153
|
+
*
|
|
154
|
+
* ```ts
|
|
155
|
+
* export const { result, failure } = createResultFactory(TOOL_VERSION);
|
|
156
|
+
* export type { ToolResult, Diagnostic, CommandPreview } from 'pi-helper-core';
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function createResultFactory(toolVersion: string): ResultFactory {
|
|
160
|
+
const result = <T>(cwd: string, startedAt: number, value: ResultInput<T>): ToolResult<T> => {
|
|
161
|
+
const { attention, truncated, projectRoot, toolchain, detail, ...rest } = value;
|
|
162
|
+
return {
|
|
163
|
+
...rest,
|
|
164
|
+
attention: attention ?? (!rest.ok || isActionable(rest)),
|
|
165
|
+
metadata: {
|
|
166
|
+
toolVersion,
|
|
167
|
+
cwd,
|
|
168
|
+
durationMs: Date.now() - startedAt,
|
|
169
|
+
truncated: truncated ?? false,
|
|
170
|
+
projectRoot,
|
|
171
|
+
toolchain,
|
|
172
|
+
detail,
|
|
173
|
+
},
|
|
174
|
+
} as ToolResult<T>;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const failure = (
|
|
178
|
+
cwd: string,
|
|
179
|
+
startedAt: number,
|
|
180
|
+
message: string,
|
|
181
|
+
code: string,
|
|
182
|
+
details?: Partial<ResultInput>,
|
|
183
|
+
): ToolResult =>
|
|
184
|
+
result(cwd, startedAt, {
|
|
185
|
+
ok: false,
|
|
186
|
+
summary: message,
|
|
187
|
+
evidence: [],
|
|
188
|
+
warnings: [],
|
|
189
|
+
errors: [{ code, message, severity: 'error' }],
|
|
190
|
+
suggestions: [],
|
|
191
|
+
...details,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { result, failure, toolVersion };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Shared helper so diagnostics never lose their code when built inline. */
|
|
198
|
+
export function warn(code: string, message: string, path?: string, line?: number): Diagnostic {
|
|
199
|
+
return { code, message, severity: 'warning', path, line };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Shared helper for informational notes, which never raise `attention`. */
|
|
203
|
+
export function note(code: string, message: string, path?: string, line?: number): Diagnostic {
|
|
204
|
+
return { code, message, severity: 'info', path, line };
|
|
205
|
+
}
|