opencode-codeops 2.5.0 → 2.6.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 +13 -0
- package/README.md +39 -0
- package/_shared/spec-first-ordering.md +6 -2
- package/_shared/test-selection.md +130 -0
- package/bin/codeops-test.mjs +855 -0
- package/bin/lib/test-branch-policy.d.mts +51 -0
- package/bin/lib/test-branch-policy.mjs +185 -0
- package/bin/lib/test-map-validate.d.mts +42 -0
- package/bin/lib/test-map-validate.mjs +618 -0
- package/bin/lib/test-map.d.mts +114 -0
- package/bin/lib/test-map.mjs +384 -0
- package/bin/lib/test-provenance.d.mts +73 -0
- package/bin/lib/test-provenance.mjs +188 -0
- package/bin/lib/test-runner.d.mts +72 -0
- package/bin/lib/test-runner.mjs +350 -0
- package/bin/lib/test-selection-changes.d.mts +76 -0
- package/bin/lib/test-selection-changes.mjs +272 -0
- package/bin/lib/test-selection-explain.mjs +75 -0
- package/bin/lib/test-selection-glob.d.mts +32 -0
- package/bin/lib/test-selection-glob.mjs +206 -0
- package/bin/lib/test-selection.d.mts +80 -0
- package/bin/lib/test-selection.mjs +418 -0
- package/package.json +4 -2
- package/schemas/test-map.schema.json +164 -0
- package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
- package/skills/analyze-project/SKILL.md +8 -1
- package/skills/exec-plan/SKILL.md +16 -8
- package/skills/exec-plan/execution-protocol.md +15 -2
- package/skills/git-commit/SKILL.md +1 -1
- package/skills/make-plan/SKILL.md +10 -3
- package/skills/make-plan/quality-checklist.md +4 -0
- package/skills/make-plan/templates.md +19 -1
- package/skills/organize-tests/SKILL.md +169 -0
- package/skills/organize-tests/references/agents-testing-block.md +80 -0
- package/skills/organize-tests/references/analysis-protocol.md +94 -0
- package/skills/organize-tests/references/ci-github-actions.md +178 -0
- package/skills/organize-tests/references/language-adapters.md +54 -0
- package/skills/organize-tests/references/map-health.md +47 -0
- package/skills/organize-tests/references/project-wrapper.md +72 -0
- package/skills/organize-tests/references/shadow-mode.md +60 -0
- package/standards/coding-standards-full.md +28 -0
- package/standards/coding-standards.md +29 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the test-map loader and canonical hash.
|
|
3
|
+
*
|
|
4
|
+
* The implementation is plain JavaScript so Node's test runner can import it
|
|
5
|
+
* directly; these declarations describe the public data shape for typed
|
|
6
|
+
* consumers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One feature-slice test group. */
|
|
10
|
+
export interface TestMapGroup {
|
|
11
|
+
id: string
|
|
12
|
+
slice: string
|
|
13
|
+
tier: "task" | "phase" | "release" | "acceptance"
|
|
14
|
+
sources: string[]
|
|
15
|
+
tests?: string[]
|
|
16
|
+
dependsOn?: string[]
|
|
17
|
+
alwaysRun?: boolean
|
|
18
|
+
durationSec?: number
|
|
19
|
+
parallelSafe?: boolean
|
|
20
|
+
requires?: string[]
|
|
21
|
+
owner?: string
|
|
22
|
+
description?: string
|
|
23
|
+
acceptance?: { mode: "manual" | "external" | "na"; reason: string }
|
|
24
|
+
run: { command: string[]; cwd?: string }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normalized map policy. */
|
|
28
|
+
export interface TestMapPolicy {
|
|
29
|
+
schema: number
|
|
30
|
+
full: string[] | string[][] | null
|
|
31
|
+
budgets: { taskTargetSeconds: number; taskAcceptableSeconds: number }
|
|
32
|
+
branches: Record<string, string | string[]>
|
|
33
|
+
ignorePaths: Array<{ glob: string; reason: string }>
|
|
34
|
+
fullOnPaths: string[]
|
|
35
|
+
acceptance: { mode: "automated" | "manual" | "external"; groups: string[]; checklist: string | null }
|
|
36
|
+
testRoots: string[] | null
|
|
37
|
+
fullThreshold: number
|
|
38
|
+
prSmoke: string[]
|
|
39
|
+
requirements: Record<string, { check: string[] }>
|
|
40
|
+
caseSensitive: boolean
|
|
41
|
+
[key: string]: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A loaded map. */
|
|
45
|
+
export interface TestMap {
|
|
46
|
+
schema: number
|
|
47
|
+
policy: TestMapPolicy
|
|
48
|
+
groups: TestMapGroup[]
|
|
49
|
+
layout: "single" | "directory"
|
|
50
|
+
path: string
|
|
51
|
+
fragments: string[]
|
|
52
|
+
warnings: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A map load result. */
|
|
56
|
+
export type LoadMapResult =
|
|
57
|
+
| { mode: "no-map" }
|
|
58
|
+
| { mode: "map"; map: TestMap; mapHash: string; path: string; fragments: string[]; warnings: string[] }
|
|
59
|
+
|
|
60
|
+
/** Error raised for an unreadable or unsafe map. */
|
|
61
|
+
export declare class MapLoadError extends Error {
|
|
62
|
+
code: string
|
|
63
|
+
path?: string
|
|
64
|
+
constructor(code: string, message: string, path?: string)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The only supported map schema. */
|
|
68
|
+
export declare const MAP_SCHEMA: number
|
|
69
|
+
|
|
70
|
+
/** Layout marker for one file. */
|
|
71
|
+
export declare const SINGLE_LAYOUT: "single"
|
|
72
|
+
|
|
73
|
+
/** Layout marker for a directory. */
|
|
74
|
+
export declare const DIRECTORY_LAYOUT: "directory"
|
|
75
|
+
|
|
76
|
+
/** Default branch classification. */
|
|
77
|
+
export declare const DEFAULT_BRANCHES: Readonly<Record<string, string | readonly string[]>>
|
|
78
|
+
|
|
79
|
+
/** Default task budgets. */
|
|
80
|
+
export declare const DEFAULT_BUDGETS: Readonly<{ taskTargetSeconds: number; taskAcceptableSeconds: number }>
|
|
81
|
+
|
|
82
|
+
/** Deep-copy a JSON-compatible value. */
|
|
83
|
+
export declare function cloneValue<T>(value: T): T
|
|
84
|
+
|
|
85
|
+
/** Check whether a repository path stays inside the root. */
|
|
86
|
+
export declare function isSafeRepoPath(value: unknown): boolean
|
|
87
|
+
|
|
88
|
+
/** Normalize a path to forward slashes. */
|
|
89
|
+
export declare function normalizeRepoPath(value: string): string
|
|
90
|
+
|
|
91
|
+
/** Resolve the map layout for a project. */
|
|
92
|
+
export declare function resolveMapPath(input: { root: string; mapPath?: string }): {
|
|
93
|
+
kind: "none" | "file" | "directory"
|
|
94
|
+
path: string | null
|
|
95
|
+
warning: string | null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Apply policy defaults. */
|
|
99
|
+
export declare function normalizePolicy(raw: unknown): TestMapPolicy
|
|
100
|
+
|
|
101
|
+
/** Load the project map. */
|
|
102
|
+
export declare function loadMap(input: { root: string; mapPath?: string }): LoadMapResult
|
|
103
|
+
|
|
104
|
+
/** Produce the canonical JSON used for hashing. */
|
|
105
|
+
export declare function canonicalMap(map: TestMap): string
|
|
106
|
+
|
|
107
|
+
/** Compute the stable map hash. */
|
|
108
|
+
export declare function hashMap(map: TestMap): string
|
|
109
|
+
|
|
110
|
+
/** Check whether a group relies on target discovery instead of `{tests}`. */
|
|
111
|
+
export declare function isTargetBasedGroup(group: TestMapGroup): boolean
|
|
112
|
+
|
|
113
|
+
/** Resolve a repo-relative path inside the root, or return null. */
|
|
114
|
+
export declare function resolveInsideRoot(root: string, relativePath: string): string | null
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Test-map loading, policy normalization, and canonical hashing.
|
|
4
|
+
*
|
|
5
|
+
* A test map is plain data: a policy plus a list of feature-slice groups. This
|
|
6
|
+
* module owns only the file-level concerns — finding the map, merging fragments,
|
|
7
|
+
* applying defaults, and producing a stable hash. Validation and selection live
|
|
8
|
+
* in separate modules so each can be read and tested on its own.
|
|
9
|
+
*
|
|
10
|
+
* The loader is deliberately strict about layout and lenient about optional
|
|
11
|
+
* policy fields. A missing `full` command is left for the validator to report;
|
|
12
|
+
* inventing one here would hide a project configuration error.
|
|
13
|
+
*
|
|
14
|
+
* @module lib/test-map
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto"
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
19
|
+
import { isAbsolute, join, relative, resolve } from "node:path"
|
|
20
|
+
|
|
21
|
+
/** Layout marker used when one file holds the whole map. */
|
|
22
|
+
export const SINGLE_LAYOUT = "single"
|
|
23
|
+
|
|
24
|
+
/** Layout marker used when `codeops/test-map/` holds policy and fragments. */
|
|
25
|
+
export const DIRECTORY_LAYOUT = "directory"
|
|
26
|
+
|
|
27
|
+
/** The only map schema this version understands. */
|
|
28
|
+
export const MAP_SCHEMA = 1
|
|
29
|
+
|
|
30
|
+
/** Default branch classification, applied when policy does not override it. */
|
|
31
|
+
export const DEFAULT_BRANCHES = Object.freeze({
|
|
32
|
+
integration: Object.freeze(["main", "master"]),
|
|
33
|
+
feature: Object.freeze(["feature/**"]),
|
|
34
|
+
release: Object.freeze(["release/**", "release*", "hotfix/**", "hotfix*"]),
|
|
35
|
+
acceptance: Object.freeze(["acceptance/**", "acceptance*"]),
|
|
36
|
+
releaseTags: Object.freeze(["v*"]),
|
|
37
|
+
unknown: "integration",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** Default soft task budget in seconds. */
|
|
41
|
+
export const DEFAULT_BUDGETS = Object.freeze({
|
|
42
|
+
taskTargetSeconds: 120,
|
|
43
|
+
taskAcceptableSeconds: 180,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Error raised when a map cannot be read or addressed safely.
|
|
48
|
+
*
|
|
49
|
+
* The stable `code` lets the CLI map the failure to an exit code without
|
|
50
|
+
* parsing an error message.
|
|
51
|
+
*/
|
|
52
|
+
export class MapLoadError extends Error {
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} code Stable machine code such as `map.policy`.
|
|
55
|
+
* @param {string} message Human-readable explanation.
|
|
56
|
+
* @param {string} [path] File or directory involved.
|
|
57
|
+
*/
|
|
58
|
+
constructor(code, message, path) {
|
|
59
|
+
super(message)
|
|
60
|
+
this.name = "MapLoadError"
|
|
61
|
+
this.code = code
|
|
62
|
+
this.path = path
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create one independent copy of a JSON-compatible value.
|
|
68
|
+
*
|
|
69
|
+
* @template T
|
|
70
|
+
* @param {T} value Value to copy.
|
|
71
|
+
* @returns {T} A deep copy that shares no mutable structure with the input.
|
|
72
|
+
*/
|
|
73
|
+
export function cloneValue(value) {
|
|
74
|
+
if (Array.isArray(value)) return value.map((item) => cloneValue(item))
|
|
75
|
+
if (value && typeof value === "object") {
|
|
76
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneValue(item)]))
|
|
77
|
+
}
|
|
78
|
+
return value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check whether a repository-relative path is safe to match or execute.
|
|
83
|
+
*
|
|
84
|
+
* Rejects absolute paths, Windows drive paths, NUL bytes, empty values, and any
|
|
85
|
+
* parent-directory segment. Backslashes are treated as separators so a Windows
|
|
86
|
+
* style traversal cannot slip through.
|
|
87
|
+
*
|
|
88
|
+
* @param {unknown} value Candidate path or glob.
|
|
89
|
+
* @returns {boolean} `true` when the value stays inside the repository.
|
|
90
|
+
* @example
|
|
91
|
+
* isSafeRepoPath("web/billing/**") // true
|
|
92
|
+
* isSafeRepoPath("../outside/**") // false
|
|
93
|
+
*/
|
|
94
|
+
export function isSafeRepoPath(value) {
|
|
95
|
+
if (typeof value !== "string" || value.length === 0) return false
|
|
96
|
+
if (value.includes("\0")) return false
|
|
97
|
+
if (value.startsWith("/") || value.startsWith("\\")) return false
|
|
98
|
+
if (/^[A-Za-z]:[\\/]/.test(value)) return false
|
|
99
|
+
const normalized = value.replace(/\\/g, "/")
|
|
100
|
+
return !normalized.split("/").includes("..")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Normalize a path or glob to POSIX separators without changing its meaning.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} value Path from a map or a Git change set.
|
|
107
|
+
* @returns {string} The same path with forward slashes.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeRepoPath(value) {
|
|
110
|
+
return value.replace(/\\/g, "/")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve which map layout a project uses.
|
|
115
|
+
*
|
|
116
|
+
* A single file wins when both shapes exist; the caller receives a warning so
|
|
117
|
+
* `check --strict` can surface the ambiguity without guessing.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} input Resolution input.
|
|
120
|
+
* @param {string} input.root Repository root.
|
|
121
|
+
* @param {string} [input.mapPath] Explicit file or directory override.
|
|
122
|
+
* @returns {{kind: "none"|"file"|"directory", path: string|null, warning: string|null}}
|
|
123
|
+
*/
|
|
124
|
+
export function resolveMapPath({ root, mapPath }) {
|
|
125
|
+
if (mapPath) {
|
|
126
|
+
const absolute = resolve(root, mapPath)
|
|
127
|
+
if (!existsSync(absolute)) return { kind: "none", path: absolute, warning: null }
|
|
128
|
+
const info = statSync(absolute)
|
|
129
|
+
return {
|
|
130
|
+
kind: info.isDirectory() ? "directory" : "file",
|
|
131
|
+
path: absolute,
|
|
132
|
+
warning: null,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const single = join(root, "codeops", "test-map.json")
|
|
137
|
+
const directory = join(root, "codeops", "test-map")
|
|
138
|
+
const hasSingle = existsSync(single)
|
|
139
|
+
const hasDirectory = existsSync(directory)
|
|
140
|
+
|
|
141
|
+
if (hasSingle && hasDirectory) {
|
|
142
|
+
return {
|
|
143
|
+
kind: "file",
|
|
144
|
+
path: single,
|
|
145
|
+
warning: "both codeops/test-map.json and codeops/test-map/ exist; the single file wins",
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (hasSingle) return { kind: "file", path: single, warning: null }
|
|
149
|
+
if (hasDirectory) return { kind: "directory", path: directory, warning: null }
|
|
150
|
+
return { kind: "none", path: null, warning: null }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read and parse one JSON file with a stable error code.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} absolutePath File to read.
|
|
157
|
+
* @returns {unknown} Parsed JSON.
|
|
158
|
+
* @throws {MapLoadError} When the file is missing or is not valid JSON.
|
|
159
|
+
*/
|
|
160
|
+
function readJson(absolutePath) {
|
|
161
|
+
let text
|
|
162
|
+
try {
|
|
163
|
+
text = readFileSync(absolutePath, "utf8")
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new MapLoadError("map.policy", `cannot read ${absolutePath}: ${error.message}`, absolutePath)
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(text)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new MapLoadError("map.parse", `invalid JSON in ${absolutePath}: ${error.message}`, absolutePath)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Apply defaults and copy the known policy fields into one normalized object.
|
|
176
|
+
*
|
|
177
|
+
* Unknown fields are preserved so a future schema can report them rather than
|
|
178
|
+
* silently dropping project intent.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} raw Policy object from the map.
|
|
181
|
+
* @returns {object} Normalized policy with defaults applied.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizePolicy(raw) {
|
|
184
|
+
const source = raw && typeof raw === "object" ? raw : {}
|
|
185
|
+
const branches = source.branches && typeof source.branches === "object" ? source.branches : {}
|
|
186
|
+
const budgets = source.budgets && typeof source.budgets === "object" ? source.budgets : {}
|
|
187
|
+
return {
|
|
188
|
+
...cloneValue(source),
|
|
189
|
+
schema: source.schema ?? MAP_SCHEMA,
|
|
190
|
+
full: source.full === undefined ? null : cloneValue(source.full),
|
|
191
|
+
budgets: {
|
|
192
|
+
taskTargetSeconds: budgets.taskTargetSeconds ?? DEFAULT_BUDGETS.taskTargetSeconds,
|
|
193
|
+
taskAcceptableSeconds: budgets.taskAcceptableSeconds ?? DEFAULT_BUDGETS.taskAcceptableSeconds,
|
|
194
|
+
},
|
|
195
|
+
branches: {
|
|
196
|
+
...cloneValue(DEFAULT_BRANCHES),
|
|
197
|
+
...cloneValue(branches),
|
|
198
|
+
},
|
|
199
|
+
ignorePaths: cloneValue(source.ignorePaths ?? []),
|
|
200
|
+
fullOnPaths: cloneValue(source.fullOnPaths ?? []),
|
|
201
|
+
acceptance: cloneValue(source.acceptance ?? { mode: "automated", groups: [], checklist: null }),
|
|
202
|
+
testRoots: source.testRoots === undefined ? null : cloneValue(source.testRoots),
|
|
203
|
+
fullThreshold: source.fullThreshold ?? 0.6,
|
|
204
|
+
prSmoke: cloneValue(source.prSmoke ?? []),
|
|
205
|
+
requirements: cloneValue(source.requirements ?? {}),
|
|
206
|
+
caseSensitive: source.caseSensitive ?? true,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load the project test map.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} input Load input.
|
|
214
|
+
* @param {string} input.root Repository root.
|
|
215
|
+
* @param {string} [input.mapPath] Explicit map file or directory.
|
|
216
|
+
* @returns {{mode: "no-map"} | {mode: "map", map: object, mapHash: string, path: string, fragments: string[], warnings: string[]}}
|
|
217
|
+
* @throws {MapLoadError} When a present map cannot be read or parsed.
|
|
218
|
+
* @example
|
|
219
|
+
* const loaded = loadMap({ root: process.cwd() })
|
|
220
|
+
* if (loaded.mode === "map") console.log(loaded.mapHash)
|
|
221
|
+
*/
|
|
222
|
+
export function loadMap({ root, mapPath }) {
|
|
223
|
+
const resolvedRoot = resolve(root)
|
|
224
|
+
const located = resolveMapPath({ root: resolvedRoot, mapPath: mapPath === undefined ? undefined : mapPath })
|
|
225
|
+
if (located.kind === "none") return { mode: "no-map" }
|
|
226
|
+
|
|
227
|
+
const warnings = located.warning ? [located.warning] : []
|
|
228
|
+
let map
|
|
229
|
+
|
|
230
|
+
if (located.kind === "file") {
|
|
231
|
+
const data = readJson(located.path)
|
|
232
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
233
|
+
throw new MapLoadError("map.schema", "the map file must contain a JSON object", located.path)
|
|
234
|
+
}
|
|
235
|
+
map = {
|
|
236
|
+
schema: data.schema ?? data.policy?.schema ?? MAP_SCHEMA,
|
|
237
|
+
policy: normalizePolicy({ schema: data.schema, ...(data.policy ?? {}) }),
|
|
238
|
+
groups: Array.isArray(data.groups) ? cloneValue(data.groups) : [],
|
|
239
|
+
layout: SINGLE_LAYOUT,
|
|
240
|
+
path: located.path,
|
|
241
|
+
fragments: [],
|
|
242
|
+
warnings,
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
const policyPath = join(located.path, "policy.json")
|
|
246
|
+
if (!existsSync(policyPath)) {
|
|
247
|
+
throw new MapLoadError("map.policy", "codeops/test-map/policy.json is required", policyPath)
|
|
248
|
+
}
|
|
249
|
+
const policyData = readJson(policyPath)
|
|
250
|
+
const names = listFragmentNames(located.path)
|
|
251
|
+
const groups = []
|
|
252
|
+
for (const name of names) {
|
|
253
|
+
const fragment = readJson(join(located.path, name))
|
|
254
|
+
if (!fragment || typeof fragment !== "object") {
|
|
255
|
+
throw new MapLoadError("map.schema", `fragment ${name} must contain an object`, join(located.path, name))
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(fragment.groups)) groups.push(...cloneValue(fragment.groups))
|
|
258
|
+
}
|
|
259
|
+
map = {
|
|
260
|
+
schema: policyData.schema ?? MAP_SCHEMA,
|
|
261
|
+
policy: normalizePolicy(policyData),
|
|
262
|
+
groups,
|
|
263
|
+
layout: DIRECTORY_LAYOUT,
|
|
264
|
+
path: located.path,
|
|
265
|
+
fragments: names,
|
|
266
|
+
warnings,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
mode: "map",
|
|
272
|
+
map,
|
|
273
|
+
mapHash: hashMap(map),
|
|
274
|
+
path: map.path,
|
|
275
|
+
fragments: map.fragments,
|
|
276
|
+
warnings: map.warnings,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* List fragment file names for a directory map.
|
|
282
|
+
*
|
|
283
|
+
* `shared.json` loads first so cross-cutting groups are available to every
|
|
284
|
+
* domain fragment's dependency references. Remaining fragments load in sorted
|
|
285
|
+
* order to keep diagnostics stable across machines.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} directory Map directory.
|
|
288
|
+
* @returns {string[]} Fragment file names.
|
|
289
|
+
*/
|
|
290
|
+
function listFragmentNames(directory) {
|
|
291
|
+
const names = readdirSync(directory)
|
|
292
|
+
.filter((name) => name.endsWith(".json"))
|
|
293
|
+
.filter((name) => name !== "policy.json")
|
|
294
|
+
.sort()
|
|
295
|
+
const sharedIndex = names.indexOf("shared.json")
|
|
296
|
+
if (sharedIndex > 0) {
|
|
297
|
+
names.splice(sharedIndex, 1)
|
|
298
|
+
names.unshift("shared.json")
|
|
299
|
+
}
|
|
300
|
+
return names
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Sort object keys recursively so equal content serializes identically.
|
|
305
|
+
*
|
|
306
|
+
* @param {unknown} value JSON-compatible value.
|
|
307
|
+
* @returns {unknown} A key-sorted copy.
|
|
308
|
+
*/
|
|
309
|
+
function sortKeys(value) {
|
|
310
|
+
if (Array.isArray(value)) return value.map(sortKeys)
|
|
311
|
+
if (value && typeof value === "object") {
|
|
312
|
+
return Object.fromEntries(
|
|
313
|
+
Object.keys(value)
|
|
314
|
+
.sort()
|
|
315
|
+
.map((key) => [key, sortKeys(value[key])]),
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
return value
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Produce the canonical JSON used for hashing.
|
|
323
|
+
*
|
|
324
|
+
* Groups are sorted by ID and object keys are sorted recursively. Load-only
|
|
325
|
+
* metadata such as the layout, file path, fragments, and warnings is excluded,
|
|
326
|
+
* so reordering fragments never changes the hash.
|
|
327
|
+
*
|
|
328
|
+
* @param {object} map Loaded map.
|
|
329
|
+
* @returns {string} Canonical JSON string.
|
|
330
|
+
*/
|
|
331
|
+
export function canonicalMap(map) {
|
|
332
|
+
const groups = [...(map.groups ?? [])].sort((left, right) =>
|
|
333
|
+
String(left.id ?? "").localeCompare(String(right.id ?? "")),
|
|
334
|
+
)
|
|
335
|
+
return JSON.stringify(
|
|
336
|
+
sortKeys({
|
|
337
|
+
schema: map.schema,
|
|
338
|
+
policy: map.policy,
|
|
339
|
+
groups,
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Compute the stable SHA-256 hash of a map.
|
|
346
|
+
*
|
|
347
|
+
* @param {object} map Loaded map.
|
|
348
|
+
* @returns {string} Lowercase hexadecimal SHA-256 digest.
|
|
349
|
+
* @example
|
|
350
|
+
* const hash = hashMap(loaded.map)
|
|
351
|
+
*/
|
|
352
|
+
export function hashMap(map) {
|
|
353
|
+
return createHash("sha256").update(canonicalMap(map)).digest("hex")
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Check whether a group relies on its runner's own target discovery.
|
|
358
|
+
*
|
|
359
|
+
* A target-based group has no `{tests}` token, so it may omit `tests` and is
|
|
360
|
+
* exempt from file-ownership checks.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} group Map group.
|
|
363
|
+
* @returns {boolean} `true` when the command contains no `{tests}` placeholder.
|
|
364
|
+
*/
|
|
365
|
+
export function isTargetBasedGroup(group) {
|
|
366
|
+
const command = group?.run?.command
|
|
367
|
+
if (!Array.isArray(command)) return false
|
|
368
|
+
return !command.some((token) => typeof token === "string" && token.includes("{tests}"))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Convert a repository-relative path to an absolute path inside the root.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} root Repository root.
|
|
375
|
+
* @param {string} relativePath Safe repository-relative path.
|
|
376
|
+
* @returns {string|null} Absolute path, or `null` when the path would escape.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveInsideRoot(root, relativePath) {
|
|
379
|
+
if (!isSafeRepoPath(relativePath)) return null
|
|
380
|
+
const absolute = resolve(root, relativePath)
|
|
381
|
+
const relation = relative(resolve(root), absolute)
|
|
382
|
+
if (relation.startsWith("..") || isAbsolute(relation)) return null
|
|
383
|
+
return absolute
|
|
384
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for release provenance.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Artifact evidence in a provenance record. */
|
|
6
|
+
export interface ProvenanceArtifact {
|
|
7
|
+
path: string | null
|
|
8
|
+
digest: string
|
|
9
|
+
sourceCommit: string
|
|
10
|
+
tested: "source" | "artifact"
|
|
11
|
+
reproducible: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One provenance record. */
|
|
15
|
+
export interface ProvenanceRecord {
|
|
16
|
+
schema: number
|
|
17
|
+
commit: string
|
|
18
|
+
ref: string | null
|
|
19
|
+
gate: string
|
|
20
|
+
mapHash: string | null
|
|
21
|
+
selection: "map" | "no-map"
|
|
22
|
+
full: boolean
|
|
23
|
+
groups: string[]
|
|
24
|
+
fullRunId: string | null
|
|
25
|
+
artifact: ProvenanceArtifact | null
|
|
26
|
+
coverage: object | null
|
|
27
|
+
coverageStatus: "provided" | "not-provided"
|
|
28
|
+
acceptance: object | null
|
|
29
|
+
acceptanceStatus: "provided" | "not-provided"
|
|
30
|
+
startedAt: string
|
|
31
|
+
finishedAt: string
|
|
32
|
+
result: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Verification result. */
|
|
36
|
+
export interface ProvenanceVerification {
|
|
37
|
+
ok: boolean
|
|
38
|
+
errors: Array<{ field: string; message: string }>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compute a file digest. */
|
|
42
|
+
export declare function sha256File(path: string): string
|
|
43
|
+
|
|
44
|
+
/** Build a provenance record. */
|
|
45
|
+
export declare function createProvenanceRecord(input: {
|
|
46
|
+
commit: string
|
|
47
|
+
ref?: string | null
|
|
48
|
+
gate: string
|
|
49
|
+
mapHash: string | null
|
|
50
|
+
groups?: string[]
|
|
51
|
+
fullRunId?: string | null
|
|
52
|
+
artifactPath?: string | null
|
|
53
|
+
artifactDigest?: string | null
|
|
54
|
+
sourceCommit?: string | null
|
|
55
|
+
artifactTested?: "source" | "artifact"
|
|
56
|
+
reproducible?: boolean
|
|
57
|
+
coverage?: object | null
|
|
58
|
+
acceptance?: object | null
|
|
59
|
+
result?: string
|
|
60
|
+
startedAt?: string
|
|
61
|
+
finishedAt?: string
|
|
62
|
+
}): ProvenanceRecord
|
|
63
|
+
|
|
64
|
+
/** Verify a record against expected release facts. */
|
|
65
|
+
export declare function verifyProvenance(
|
|
66
|
+
record: ProvenanceRecord,
|
|
67
|
+
expected?: {
|
|
68
|
+
commit?: string
|
|
69
|
+
mapHash?: string
|
|
70
|
+
artifactPath?: string
|
|
71
|
+
artifactDigest?: string
|
|
72
|
+
}
|
|
73
|
+
): ProvenanceVerification
|