@mstar-harness/engine 3.1.2 → 3.2.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/dist/audit.d.ts +46 -4
- package/dist/audit.js +755 -0
- package/dist/core.d.ts +2 -2
- package/dist/engine.js +176 -110
- package/dist/index.d.ts +2 -2
- package/dist/lint.d.ts +2 -2
- package/dist/status.d.ts +20 -0
- package/package.json +7 -3
package/dist/audit.js
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// src/audit.ts
|
|
2
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3
|
+
import { basename as basename2, join as join4, resolve as resolve4, sep as sep2 } from "node:path";
|
|
4
|
+
|
|
5
|
+
// src/core.ts
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
function readJson(filePath) {
|
|
10
|
+
if (!existsSync(filePath))
|
|
11
|
+
return {};
|
|
12
|
+
const content = readFileSync(filePath, "utf8").trim();
|
|
13
|
+
if (!content)
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(content);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function writeJson(filePath, value) {
|
|
22
|
+
const parent = dirname(filePath);
|
|
23
|
+
mkdirSync(parent, { recursive: true });
|
|
24
|
+
const tmp = join(parent, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
|
|
27
|
+
`, "utf8");
|
|
28
|
+
renameSync(tmp, filePath);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
try {
|
|
31
|
+
unlinkSync(tmp);
|
|
32
|
+
} catch {}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/lease.ts
|
|
38
|
+
import { mkdirSync as mkdirSync2, rmdirSync, statSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
39
|
+
import { dirname as dirname2, isAbsolute, join as join2, resolve as resolve2 } from "node:path";
|
|
40
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
41
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
42
|
+
var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
|
|
43
|
+
var RFC3339_Z_RE = new RegExp(String.raw`^${DATE_PART}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`);
|
|
44
|
+
var DATE_ONLY_RE = new RegExp(String.raw`^${DATE_PART}$`);
|
|
45
|
+
var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
|
|
46
|
+
var LOCKDIR_HOLDER_PID = "holder.pid";
|
|
47
|
+
var heldLockDirs = new AsyncLocalStorage;
|
|
48
|
+
async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
49
|
+
const lockDir = join2(dirname2(resolve2(statusPath)), STATUS_WRITE_LOCKDIR);
|
|
50
|
+
const held = heldLockDirs.getStore();
|
|
51
|
+
if (held !== undefined && held.has(lockDir)) {
|
|
52
|
+
throw new Error(`${lockDir} is already held by this process in this async context — withStatusWriteLock is not reentrant; a nested acquisition on the same status.json is a bug`);
|
|
53
|
+
}
|
|
54
|
+
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
55
|
+
const pollMs = opts.pollMs ?? 25;
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
let acquired = null;
|
|
58
|
+
for (;; ) {
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync2(lockDir);
|
|
61
|
+
const st = statSync(lockDir);
|
|
62
|
+
acquired = { dev: st.dev, ino: st.ino };
|
|
63
|
+
break;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== "EEXIST")
|
|
66
|
+
throw error;
|
|
67
|
+
if (Date.now() >= deadline) {
|
|
68
|
+
throw new Error(`${lockDir} already exists — another writer holds the status write lock; Blocked (same-host exclusive lock; status-and-residuals.md § Same-host exclusive write lock). ` + `Recovery: remove ${lockDir} if no writer is alive (holder.pid inside names the acquiring process)`);
|
|
69
|
+
}
|
|
70
|
+
await sleep(pollMs);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync2(join2(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
|
|
75
|
+
} catch {}
|
|
76
|
+
const owns = held ?? new Set;
|
|
77
|
+
owns.add(lockDir);
|
|
78
|
+
try {
|
|
79
|
+
return await heldLockDirs.run(owns, fn);
|
|
80
|
+
} finally {
|
|
81
|
+
owns.delete(lockDir);
|
|
82
|
+
try {
|
|
83
|
+
const current = statSync(lockDir);
|
|
84
|
+
if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync2(join2(lockDir, LOCKDIR_HOLDER_PID));
|
|
87
|
+
} catch {}
|
|
88
|
+
rmdirSync(lockDir);
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/mstarc.ts
|
|
95
|
+
var MSTARC_HARNESS_DIR_KEY = "harness_dir";
|
|
96
|
+
var MSTARC_PLAN_DIR_KEY = "plan_dir";
|
|
97
|
+
var MSTARC_SDD_DIR_KEY = "sdd_dir";
|
|
98
|
+
var MSTARC_ITERATION_DIR_KEY = "iteration_dir";
|
|
99
|
+
var MSTARC_KNOWLEDGE_DIR_KEY = "knowledge_dir";
|
|
100
|
+
var MSTARC_SPECS_DIR_KEY = "specs_dir";
|
|
101
|
+
var MSTARC_WORKFLOW_DIR_KEY = "workflow_dir";
|
|
102
|
+
var MSTARC_PROJECT_DIR_KEY = "project_dir";
|
|
103
|
+
var MSTARC_ENFORCEMENT_KEY = "enforcement";
|
|
104
|
+
var CONFIG_KEYS = {
|
|
105
|
+
[MSTARC_HARNESS_DIR_KEY]: "harnessDir",
|
|
106
|
+
[MSTARC_PLAN_DIR_KEY]: "planDir",
|
|
107
|
+
[MSTARC_SDD_DIR_KEY]: "sddDir",
|
|
108
|
+
[MSTARC_ITERATION_DIR_KEY]: "iterationDir",
|
|
109
|
+
[MSTARC_KNOWLEDGE_DIR_KEY]: "knowledgeDir",
|
|
110
|
+
[MSTARC_SPECS_DIR_KEY]: "specsDir",
|
|
111
|
+
[MSTARC_WORKFLOW_DIR_KEY]: "workflowDir",
|
|
112
|
+
[MSTARC_PROJECT_DIR_KEY]: "projectDir",
|
|
113
|
+
[MSTARC_ENFORCEMENT_KEY]: "enforcement"
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// src/path.ts
|
|
117
|
+
function assertSafePathComponent(value, what) {
|
|
118
|
+
if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
119
|
+
throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
|
|
123
|
+
# Principle: process stays local; results are shared with the team.
|
|
124
|
+
# Default-ignore everything under .mstar/, then re-include the tracked results.
|
|
125
|
+
.mstar/**
|
|
126
|
+
!.mstar/AGENTS.md
|
|
127
|
+
!.mstar/knowledge/
|
|
128
|
+
!.mstar/knowledge/**
|
|
129
|
+
!.mstar/specs/
|
|
130
|
+
!.mstar/specs/**
|
|
131
|
+
# .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
|
|
132
|
+
.mstarc
|
|
133
|
+
`;
|
|
134
|
+
var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
|
|
135
|
+
# Default-ignore everything under .agents/, then re-include the tracked results.
|
|
136
|
+
.agents/**
|
|
137
|
+
!.agents/AGENTS.md
|
|
138
|
+
!.agents/knowledge/
|
|
139
|
+
!.agents/knowledge/**
|
|
140
|
+
!.agents/specs/
|
|
141
|
+
!.agents/specs/**
|
|
142
|
+
`;
|
|
143
|
+
var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
|
|
144
|
+
`).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
|
|
145
|
+
var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
|
|
146
|
+
`).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
|
|
147
|
+
|
|
148
|
+
// src/status.ts
|
|
149
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, realpathSync } from "node:fs";
|
|
150
|
+
import { dirname as dirname3, join as join3, resolve as resolve3, sep } from "node:path";
|
|
151
|
+
|
|
152
|
+
// src/workflow.ts
|
|
153
|
+
var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
|
|
154
|
+
var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
|
|
155
|
+
var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
|
|
156
|
+
|
|
157
|
+
// src/status.ts
|
|
158
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
159
|
+
function isPlainObject(value) {
|
|
160
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
161
|
+
}
|
|
162
|
+
function violation(severity, code, message, fix) {
|
|
163
|
+
return { ok: false, severity, code, message, fix };
|
|
164
|
+
}
|
|
165
|
+
function todayString() {
|
|
166
|
+
const now = new Date;
|
|
167
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
168
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
169
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
170
|
+
}
|
|
171
|
+
function validateNonEmptyString(violations, value, field, missingCode, invalidCode) {
|
|
172
|
+
if (value === undefined) {
|
|
173
|
+
violations.push(violation("high", missingCode, `missing required field: ${field}`));
|
|
174
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
175
|
+
violations.push(violation("medium", invalidCode, `${field} must be a non-empty string`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function isHarnessRelativePath(dir) {
|
|
179
|
+
if (dir.startsWith("/") || dir.startsWith("\\"))
|
|
180
|
+
return false;
|
|
181
|
+
if (/^[A-Za-z]:[\\/]/.test(dir))
|
|
182
|
+
return false;
|
|
183
|
+
return !dir.split(/[\\/]+/).includes("..");
|
|
184
|
+
}
|
|
185
|
+
function validateWorkflowEntry(entry) {
|
|
186
|
+
const violations = [];
|
|
187
|
+
if (!isPlainObject(entry)) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
violations: [violation("high", "status.workflow.invalid", "workflow entry must be an object")]
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
validateNonEmptyString(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
|
|
194
|
+
if (entry.type === undefined) {
|
|
195
|
+
violations.push(violation("high", "status.workflow.missing-type", "missing required field: type"));
|
|
196
|
+
} else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
|
|
197
|
+
violations.push(violation("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
|
|
198
|
+
}
|
|
199
|
+
validateNonEmptyString(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
|
|
200
|
+
if (entry.dir === undefined) {
|
|
201
|
+
violations.push(violation("high", "status.workflow.missing-dir", "missing required field: dir"));
|
|
202
|
+
} else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
|
|
203
|
+
violations.push(violation("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
|
|
204
|
+
} else if (!isHarnessRelativePath(entry.dir)) {
|
|
205
|
+
violations.push(violation("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
|
|
206
|
+
}
|
|
207
|
+
return { ok: violations.length === 0, violations };
|
|
208
|
+
}
|
|
209
|
+
function validateStatusV2(docOrPath, opts = {}) {
|
|
210
|
+
let doc;
|
|
211
|
+
let harnessDir = opts.harnessDir;
|
|
212
|
+
if (typeof docOrPath === "string") {
|
|
213
|
+
try {
|
|
214
|
+
doc = readJson(docOrPath);
|
|
215
|
+
harnessDir = dirname3(resolve3(docOrPath));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
violations: [violation("high", "status.invalid-json", error.message)]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
doc = docOrPath;
|
|
224
|
+
}
|
|
225
|
+
if (!isPlainObject(doc)) {
|
|
226
|
+
return { ok: false, violations: [violation("high", "status.invalid-doc", "status document must be an object")] };
|
|
227
|
+
}
|
|
228
|
+
if (doc.version !== 2) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
violations: [
|
|
232
|
+
violation("high", "status.migration-required", `status.json schema version 2 required — got ${JSON.stringify(doc.version)} (v1 or unknown version); run \`mstar migrate\` to convert the tree`, "run `mstar migrate`")
|
|
233
|
+
]
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(doc.plans)) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
violations: [
|
|
240
|
+
violation("high", "status.migration-required", "v1-shaped status.json (root plans[]) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
|
|
241
|
+
]
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (doc.residual_findings !== undefined) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
violations: [
|
|
248
|
+
violation("high", "status.migration-required", "v1-shaped status.json (root residual_findings) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
|
|
249
|
+
]
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const violations = [];
|
|
253
|
+
if (doc.updated_at === undefined) {
|
|
254
|
+
violations.push(violation("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
255
|
+
} else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
|
|
256
|
+
violations.push(violation("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
257
|
+
}
|
|
258
|
+
if (doc.workflows === undefined) {
|
|
259
|
+
violations.push(violation("high", "status.missing-workflows", "missing required field: workflows"));
|
|
260
|
+
} else if (!Array.isArray(doc.workflows)) {
|
|
261
|
+
violations.push(violation("high", "status.invalid-workflows", "workflows must be an array"));
|
|
262
|
+
} else {
|
|
263
|
+
const seen = new Set;
|
|
264
|
+
for (const entry of doc.workflows) {
|
|
265
|
+
violations.push(...validateWorkflowEntry(entry).violations);
|
|
266
|
+
if (isPlainObject(entry) && typeof entry.id === "string") {
|
|
267
|
+
if (seen.has(entry.id)) {
|
|
268
|
+
violations.push(violation("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
|
|
269
|
+
}
|
|
270
|
+
seen.add(entry.id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
|
|
275
|
+
let realHarnessDir = null;
|
|
276
|
+
try {
|
|
277
|
+
realHarnessDir = realpathSync(harnessDir);
|
|
278
|
+
} catch {}
|
|
279
|
+
for (const entry of doc.workflows) {
|
|
280
|
+
if (!isPlainObject(entry) || typeof entry.dir !== "string")
|
|
281
|
+
continue;
|
|
282
|
+
const relSnapshot = join3(entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
283
|
+
const snapshotPath = join3(harnessDir, relSnapshot);
|
|
284
|
+
const label = typeof entry.id === "string" ? entry.id : relSnapshot;
|
|
285
|
+
let physical;
|
|
286
|
+
try {
|
|
287
|
+
physical = realpathSync(snapshotPath);
|
|
288
|
+
} catch {
|
|
289
|
+
violations.push(violation("high", "status.workflow.snapshot-missing", `workflows[] lists ${JSON.stringify(label)} but its snapshot does not exist at ${JSON.stringify(relSnapshot)} — the root holds active lifecycles only; unregister the id when its snapshot is removed`));
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep}`)) {
|
|
293
|
+
violations.push(violation("high", "status.workflow.snapshot-outside-harness", `workflows[] lists ${JSON.stringify(label)} but its snapshot resolves outside the harness dir (${JSON.stringify(physical)}) — symlinked snapshot paths are rejected; the snapshot must physically live under ${JSON.stringify(harnessDir)}`));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
let snapshot;
|
|
297
|
+
try {
|
|
298
|
+
snapshot = readJson(snapshotPath);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
violations.push(violation("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
|
|
304
|
+
violations.push(violation("high", "status.workflow.terminal-listed", `workflows[] lists ${JSON.stringify(label)} whose snapshot status is terminal (${snapshot.status}) — removal-at-terminal: terminal writers unregister AFTER the snapshot write`));
|
|
305
|
+
}
|
|
306
|
+
if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
|
|
307
|
+
violations.push(violation("medium", "status.workflow.mismatched-type", `workflows[] entry ${JSON.stringify(label)} type ${JSON.stringify(entry.type)} does not match its snapshot type ${JSON.stringify(snapshot.type)} — the root entry mirrors the snapshot; align them`));
|
|
308
|
+
}
|
|
309
|
+
if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
|
|
310
|
+
violations.push(violation("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — workflow ${JSON.stringify(label)} collided with another writer (e.g. a concurrent/re-run \`audit promote\` with the same workflow id rewrote the snapshot); the root entry mirrors the snapshot — align them or remove the colliding workflow`));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { ok: violations.length === 0, violations };
|
|
315
|
+
}
|
|
316
|
+
function registerWorkflowEntryLocked(statusPath, entry) {
|
|
317
|
+
const harnessDir = dirname3(statusPath);
|
|
318
|
+
const current = readJson(statusPath);
|
|
319
|
+
const fresh = Object.keys(current).length === 0;
|
|
320
|
+
const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
|
|
321
|
+
if (!fresh && !Array.isArray(doc.workflows)) {
|
|
322
|
+
throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
|
|
323
|
+
}
|
|
324
|
+
const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
|
|
325
|
+
if (existing >= 0) {
|
|
326
|
+
doc.workflows[existing] = entry;
|
|
327
|
+
} else {
|
|
328
|
+
doc.workflows.push(entry);
|
|
329
|
+
}
|
|
330
|
+
doc.updated_at = todayString();
|
|
331
|
+
const gate = validateStatusV2(doc, { harnessDir });
|
|
332
|
+
if (!gate.ok) {
|
|
333
|
+
throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
|
|
334
|
+
}
|
|
335
|
+
writeJson(statusPath, doc);
|
|
336
|
+
return doc;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/audit.ts
|
|
340
|
+
function violation2(severity, code, message, fix) {
|
|
341
|
+
return { ok: false, severity, code, message, fix };
|
|
342
|
+
}
|
|
343
|
+
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
344
|
+
var AUDIT_EFFORTS = ["XS", "S", "M", "L", "XL"];
|
|
345
|
+
var AUDIT_RISKS = ["LOW", "MED", "HIGH"];
|
|
346
|
+
var AUDIT_CATEGORIES = [
|
|
347
|
+
"bug",
|
|
348
|
+
"security",
|
|
349
|
+
"perf",
|
|
350
|
+
"tests",
|
|
351
|
+
"tech-debt",
|
|
352
|
+
"migration",
|
|
353
|
+
"dx",
|
|
354
|
+
"docs",
|
|
355
|
+
"direction"
|
|
356
|
+
];
|
|
357
|
+
var AUDIT_STATUS_FIELDS = ["Priority", "Effort", "Risk", "Depends on", "Category", "Planned at"];
|
|
358
|
+
function parseStatusBlocks(planText) {
|
|
359
|
+
const blocks = [];
|
|
360
|
+
let current = null;
|
|
361
|
+
for (const line of planText.split(/\r?\n/)) {
|
|
362
|
+
const trimmed = line.trim();
|
|
363
|
+
if (trimmed === "## Status") {
|
|
364
|
+
current = new Map;
|
|
365
|
+
blocks.push({ fields: current });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (current === null)
|
|
369
|
+
continue;
|
|
370
|
+
if (trimmed.startsWith("#")) {
|
|
371
|
+
current = null;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const match = /^-\s*\*\*([^*]+)\*\*:\s*(.*)$/.exec(trimmed);
|
|
375
|
+
if (match !== null)
|
|
376
|
+
current.set(match[1].trim(), match[2].trim());
|
|
377
|
+
}
|
|
378
|
+
return blocks;
|
|
379
|
+
}
|
|
380
|
+
function validateAuditStatusBlocks(planText) {
|
|
381
|
+
const violations = [];
|
|
382
|
+
const blocks = parseStatusBlocks(planText);
|
|
383
|
+
if (blocks.length === 0) {
|
|
384
|
+
violations.push(violation2("medium", "audit.status.missing-block", "no `## Status` block found — audit plan files carry the Status block fields (mstar-audit SKILL.md § Plan output)", "add a `## Status` block with Priority, Effort, Risk, Depends on, Category, Planned at"));
|
|
385
|
+
return { ok: false, violations };
|
|
386
|
+
}
|
|
387
|
+
blocks.forEach((block, index) => {
|
|
388
|
+
const label = blocks.length > 1 ? ` #${index + 1}` : "";
|
|
389
|
+
for (const field of AUDIT_STATUS_FIELDS) {
|
|
390
|
+
if (!block.fields.has(field)) {
|
|
391
|
+
violations.push(violation2("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL.md § Plan output)`, `add \`- **${field}**: <value>\` to the Status block`));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const check = (field, pattern, code, expected) => {
|
|
395
|
+
const value = block.fields.get(field);
|
|
396
|
+
if (value === undefined)
|
|
397
|
+
return;
|
|
398
|
+
if (!pattern.test(value)) {
|
|
399
|
+
violations.push(violation2("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL.md § Plan output)`, `fix \`- **${field}**:\` to one of: ${expected}`));
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
|
|
403
|
+
check("Effort", /^(?:XS|S|M|L|XL)$/, "audit.status.invalid-effort", "XS | S | M | L | XL");
|
|
404
|
+
check("Risk", /^(?:LOW|MED|HIGH)$/, "audit.status.invalid-risk", "LOW | MED | HIGH");
|
|
405
|
+
check("Category", /^(?:bug|security|perf|tests|tech-debt|migration|dx|docs|direction)$/, "audit.status.invalid-category", "bug | security | perf | tests | tech-debt | migration | dx | docs | direction");
|
|
406
|
+
check("Depends on", /^(?:none|plans\/\d{3}-[\w.*-]+\.md)$/i, "audit.status.invalid-depends-on", "none or plans/NNN-*.md");
|
|
407
|
+
check("Planned at", /^commit \`?(?:[0-9a-f]{7,40}|unknown)\`?, \d{4}-\d{2}-\d{2}$/, "audit.status.invalid-planned-at", "commit <short SHA>, <YYYY-MM-DD> (or `commit unknown` outside a git repo)");
|
|
408
|
+
});
|
|
409
|
+
return { ok: violations.length === 0, violations };
|
|
410
|
+
}
|
|
411
|
+
var WHOLE_MATCH_PATTERNS = [
|
|
412
|
+
{ type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
413
|
+
{ type: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
414
|
+
{ type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
|
|
415
|
+
{ type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
416
|
+
{ type: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\b/g },
|
|
417
|
+
{ type: "api-secret-key", re: /\bsk-[A-Za-z0-9-]{20,}\b/g }
|
|
418
|
+
];
|
|
419
|
+
var VALUE_PATTERNS = [
|
|
420
|
+
{
|
|
421
|
+
typeOf: (key) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[_-]+/g, "-"),
|
|
422
|
+
re: /(["']?)\b(password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|secret|token)\b(["']?)(\s*[:=]\s*)("[^"\n]{8,}"|'[^'\n]{8,}'|[A-Za-z0-9_./+\-=]{16,})/gi
|
|
423
|
+
}
|
|
424
|
+
];
|
|
425
|
+
function buildLineStarts(text) {
|
|
426
|
+
const starts = [0];
|
|
427
|
+
for (let i = 0;i < text.length; i++) {
|
|
428
|
+
if (text[i] === `
|
|
429
|
+
`)
|
|
430
|
+
starts.push(i + 1);
|
|
431
|
+
}
|
|
432
|
+
return starts;
|
|
433
|
+
}
|
|
434
|
+
function lineAt(starts, index) {
|
|
435
|
+
let lo = 0;
|
|
436
|
+
let hi = starts.length - 1;
|
|
437
|
+
while (lo < hi) {
|
|
438
|
+
const mid = lo + hi + 1 >> 1;
|
|
439
|
+
if (starts[mid] <= index)
|
|
440
|
+
lo = mid;
|
|
441
|
+
else
|
|
442
|
+
hi = mid - 1;
|
|
443
|
+
}
|
|
444
|
+
return lo + 1;
|
|
445
|
+
}
|
|
446
|
+
function redactSecrets(text, filePath) {
|
|
447
|
+
const starts = buildLineStarts(text);
|
|
448
|
+
const marker = (type, index) => `[REDACTED ${type}@${lineAt(starts, index)}${filePath === undefined ? "" : ` in ${filePath}`}]`;
|
|
449
|
+
const replacements = [];
|
|
450
|
+
const findings = [];
|
|
451
|
+
for (const pattern of WHOLE_MATCH_PATTERNS) {
|
|
452
|
+
for (const match of text.matchAll(pattern.re)) {
|
|
453
|
+
if (match.index === undefined)
|
|
454
|
+
continue;
|
|
455
|
+
replacements.push({ index: match.index, length: match[0].length, text: marker(pattern.type, match.index) });
|
|
456
|
+
findings.push({ line: lineAt(starts, match.index), type: pattern.type });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const pattern of VALUE_PATTERNS) {
|
|
460
|
+
for (const match of text.matchAll(pattern.re)) {
|
|
461
|
+
if (match.index === undefined)
|
|
462
|
+
continue;
|
|
463
|
+
const type = pattern.typeOf(match[2]);
|
|
464
|
+
const replacement = `${match[1]}${match[2]}${match[3]}${match[4]}${marker(type, match.index)}`;
|
|
465
|
+
replacements.push({ index: match.index, length: match[0].length, text: replacement });
|
|
466
|
+
findings.push({ line: lineAt(starts, match.index), type });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
replacements.sort((a, b) => b.index - a.index);
|
|
470
|
+
let out = text;
|
|
471
|
+
for (const r of replacements)
|
|
472
|
+
out = out.slice(0, r.index) + r.text + out.slice(r.index + r.length);
|
|
473
|
+
const deduped = new Map;
|
|
474
|
+
for (const f of findings)
|
|
475
|
+
deduped.set(`${f.line}:${f.type}`, f);
|
|
476
|
+
const sorted = [...deduped.values()].sort((a, b) => a.line - b.line || a.type.localeCompare(b.type));
|
|
477
|
+
return { text: out, findings: sorted };
|
|
478
|
+
}
|
|
479
|
+
function slugify(title) {
|
|
480
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
481
|
+
}
|
|
482
|
+
var escapeCell = (value) => value.replace(/\|/g, "\\|");
|
|
483
|
+
var truncate = (value, max) => value.length > max ? `${value.slice(0, max)}…` : value;
|
|
484
|
+
function renderPlanFile(finding, plannedAt) {
|
|
485
|
+
const sections = [
|
|
486
|
+
`# ${finding.title}`,
|
|
487
|
+
"",
|
|
488
|
+
"## Status",
|
|
489
|
+
`- **Priority**: ${finding.priority}`,
|
|
490
|
+
`- **Effort**: ${finding.effort}`,
|
|
491
|
+
`- **Risk**: ${finding.risk}`,
|
|
492
|
+
`- **Depends on**: ${finding.dependsOn ?? "none"}`,
|
|
493
|
+
`- **Category**: ${finding.category}`,
|
|
494
|
+
`- **Planned at**: commit \`${plannedAt.commit}\`, ${plannedAt.date}`,
|
|
495
|
+
"",
|
|
496
|
+
"## Impact",
|
|
497
|
+
finding.impact
|
|
498
|
+
];
|
|
499
|
+
if (finding.evidence.length > 0) {
|
|
500
|
+
sections.push("", "## Evidence", ...finding.evidence.map((e) => `- ${e}`));
|
|
501
|
+
}
|
|
502
|
+
if (finding.fixSketch !== undefined) {
|
|
503
|
+
sections.push("", "## Fix sketch", finding.fixSketch);
|
|
504
|
+
}
|
|
505
|
+
if (finding.verification !== undefined) {
|
|
506
|
+
sections.push("", "## Verification", finding.verification);
|
|
507
|
+
}
|
|
508
|
+
return `${sections.join(`
|
|
509
|
+
`)}
|
|
510
|
+
`;
|
|
511
|
+
}
|
|
512
|
+
function readPlanFileSummary(filePath) {
|
|
513
|
+
const text = readFileSync3(filePath, "utf8");
|
|
514
|
+
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
515
|
+
const blocks = parseStatusBlocks(text);
|
|
516
|
+
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
517
|
+
}
|
|
518
|
+
function renderIndex(params) {
|
|
519
|
+
const { date, repoName, repoShortSha, rows, rejected } = params;
|
|
520
|
+
const findingsRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.category} | ${escapeCell(truncate(r.impact, 80))} | ${r.effort} | ${r.risk} | ${r.confidence} | ${escapeCell(truncate(r.evidence, 80))} |`).join(`
|
|
521
|
+
`);
|
|
522
|
+
const directionRows = rows.filter((r) => r.category === "direction").map((r) => `- ${escapeCell(r.title)} — ${escapeCell(truncate(r.impact, 120))}`).join(`
|
|
523
|
+
`);
|
|
524
|
+
const executionRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.priority} | ${r.effort} | ${r.dependsOn} | TODO |`).join(`
|
|
525
|
+
`);
|
|
526
|
+
const rejectedRows = rejected.map((r) => `- ${escapeCell(r.title)}: ${escapeCell(r.reason)}`).join(`
|
|
527
|
+
`);
|
|
528
|
+
const sections = [
|
|
529
|
+
`# Audit Report — ${repoName} @ ${repoShortSha} (${date})`,
|
|
530
|
+
"",
|
|
531
|
+
"## Findings",
|
|
532
|
+
"",
|
|
533
|
+
"| # | Finding | Category | Impact | Effort | Risk | Confidence | Evidence |",
|
|
534
|
+
"|---|---------|----------|--------|--------|------|------------|----------|",
|
|
535
|
+
findingsRows
|
|
536
|
+
];
|
|
537
|
+
if (directionRows !== "") {
|
|
538
|
+
sections.push("", "## Direction", "", directionRows);
|
|
539
|
+
}
|
|
540
|
+
sections.push("", "## Execution order & status", "", "| Plan | Title | Priority | Effort | Depends on | Status |", "|------|-------|----------|--------|------------|--------|", executionRows);
|
|
541
|
+
if (rejectedRows !== "") {
|
|
542
|
+
sections.push("", "## Findings considered and rejected", "", rejectedRows);
|
|
543
|
+
}
|
|
544
|
+
sections.push("", "## Red-team dispositions", "", "- <finding>: <survived / refuted / hallucination-dropped / uncovered-kept>, <one-line reason>");
|
|
545
|
+
return `${sections.join(`
|
|
546
|
+
`)}
|
|
547
|
+
`;
|
|
548
|
+
}
|
|
549
|
+
function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
550
|
+
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
551
|
+
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
552
|
+
mkdirSync3(outDir, { recursive: true });
|
|
553
|
+
const existing = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
554
|
+
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
555
|
+
const written = [];
|
|
556
|
+
const usedSlugs = new Set;
|
|
557
|
+
for (const finding of findings) {
|
|
558
|
+
const num = String(next).padStart(3, "0");
|
|
559
|
+
let slug = slugify(finding.title);
|
|
560
|
+
if (usedSlugs.has(slug)) {
|
|
561
|
+
let n = 2;
|
|
562
|
+
while (usedSlugs.has(`${slug}-${n}`))
|
|
563
|
+
n++;
|
|
564
|
+
slug = `${slug}-${n}`;
|
|
565
|
+
}
|
|
566
|
+
usedSlugs.add(slug);
|
|
567
|
+
const file = `${num}-${slug}.md`;
|
|
568
|
+
writeFileSync3(join4(outDir, file), renderPlanFile(finding, plannedAt));
|
|
569
|
+
written.push(file);
|
|
570
|
+
next++;
|
|
571
|
+
}
|
|
572
|
+
const all = [...existing, ...written].sort();
|
|
573
|
+
const rows = all.map((file) => {
|
|
574
|
+
const summary = readPlanFileSummary(join4(outDir, file));
|
|
575
|
+
const fields = summary.fields;
|
|
576
|
+
return {
|
|
577
|
+
num: file.slice(0, 3),
|
|
578
|
+
title: summary.title,
|
|
579
|
+
category: fields.get("Category") ?? "—",
|
|
580
|
+
impact: "see plan file",
|
|
581
|
+
effort: fields.get("Effort") ?? "—",
|
|
582
|
+
risk: fields.get("Risk") ?? "—",
|
|
583
|
+
confidence: "—",
|
|
584
|
+
evidence: fields.get("Evidence") ?? "—",
|
|
585
|
+
priority: fields.get("Priority") ?? "—",
|
|
586
|
+
dependsOn: fields.get("Depends on") ?? "—"
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
const byNum = new Map(rows.map((r) => [r.num, r]));
|
|
590
|
+
written.forEach((file, i) => {
|
|
591
|
+
const finding = findings[i];
|
|
592
|
+
if (finding === undefined)
|
|
593
|
+
return;
|
|
594
|
+
const row = byNum.get(file.slice(0, 3));
|
|
595
|
+
if (row !== undefined) {
|
|
596
|
+
row.category = finding.category;
|
|
597
|
+
row.impact = finding.impact;
|
|
598
|
+
row.effort = finding.effort;
|
|
599
|
+
row.risk = finding.risk;
|
|
600
|
+
row.confidence = finding.confidence;
|
|
601
|
+
row.evidence = finding.evidence[0] ?? "";
|
|
602
|
+
row.priority = finding.priority;
|
|
603
|
+
row.dependsOn = finding.dependsOn ?? "none";
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
writeFileSync3(join4(outDir, "README.md"), renderIndex({
|
|
607
|
+
date,
|
|
608
|
+
repoName: options.repoName ?? "repo",
|
|
609
|
+
repoShortSha: options.repoShortSha ?? "unknown",
|
|
610
|
+
rows,
|
|
611
|
+
rejected: options.rejected ?? []
|
|
612
|
+
}));
|
|
613
|
+
return { outDir: resolve4(outDir), date, files: written, nextNumber: next };
|
|
614
|
+
}
|
|
615
|
+
async function promoteAuditPlans(outDir, selected, options) {
|
|
616
|
+
if (selected.length === 0) {
|
|
617
|
+
throw new Error("promoteAuditPlans: at least one plan id must be selected (--plans 001,002,…)");
|
|
618
|
+
}
|
|
619
|
+
if (typeof options.harnessDir !== "string" || options.harnessDir.trim() === "") {
|
|
620
|
+
throw new Error("promoteAuditPlans: options.harnessDir is required (must contain status.json + workflows/)");
|
|
621
|
+
}
|
|
622
|
+
const workflowId = options.workflowId ?? basename2(resolve4(outDir));
|
|
623
|
+
assertSafePathComponent(workflowId, "workflow id");
|
|
624
|
+
const harnessDir = resolve4(options.harnessDir);
|
|
625
|
+
const statusPath = join4(harnessDir, "status.json");
|
|
626
|
+
const workflowDir = join4(harnessDir, "workflows", workflowId);
|
|
627
|
+
const snapshotPath = join4(workflowDir, WORKFLOW_SNAPSHOT_FILE);
|
|
628
|
+
const planFiles = resolveSelectedPlanFiles(outDir, selected);
|
|
629
|
+
const indexRows = readExecutionOrderIndex(outDir);
|
|
630
|
+
const plans = planFiles.map((planFile) => {
|
|
631
|
+
const stem = planFile.replace(/\.md$/, "");
|
|
632
|
+
const num = stem.slice(0, 3);
|
|
633
|
+
const indexRow = indexRows.get(num);
|
|
634
|
+
const title = indexRow?.title ?? readPlanFileSummary(join4(outDir, planFile)).title;
|
|
635
|
+
return {
|
|
636
|
+
id: stem,
|
|
637
|
+
title,
|
|
638
|
+
file: planFileRel(outDir, planFile),
|
|
639
|
+
status: "Todo"
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
const now = new Date;
|
|
643
|
+
const snapshot = {
|
|
644
|
+
schema_version: 1,
|
|
645
|
+
id: workflowId,
|
|
646
|
+
type: "plan",
|
|
647
|
+
status: "running",
|
|
648
|
+
started_at: now.toISOString(),
|
|
649
|
+
updated_at: now.toISOString().slice(0, 10),
|
|
650
|
+
plans
|
|
651
|
+
};
|
|
652
|
+
const entry = {
|
|
653
|
+
id: workflowId,
|
|
654
|
+
type: "plan",
|
|
655
|
+
started_at: snapshot.started_at,
|
|
656
|
+
dir: `workflows/${workflowId}`
|
|
657
|
+
};
|
|
658
|
+
const entryGate = validateWorkflowEntry(entry);
|
|
659
|
+
if (!entryGate.ok) {
|
|
660
|
+
throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
|
|
661
|
+
}
|
|
662
|
+
await withStatusWriteLock(statusPath, () => {
|
|
663
|
+
if (existsSync3(snapshotPath)) {
|
|
664
|
+
throw new Error(`refusing to promote audit plans: workflow ${JSON.stringify(workflowId)} already exists ` + `(snapshot at ${snapshotPath}) — re-promote would drop its registered plan rows; ` + `remove that workflow before promoting again`);
|
|
665
|
+
}
|
|
666
|
+
mkdirSync3(workflowDir, { recursive: true });
|
|
667
|
+
try {
|
|
668
|
+
writeJson(snapshotPath, snapshot);
|
|
669
|
+
registerWorkflowEntryLocked(statusPath, entry);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
rmSync(snapshotPath, { force: true });
|
|
672
|
+
try {
|
|
673
|
+
if (readdirSync2(workflowDir).length === 0) {
|
|
674
|
+
rmdirSync2(workflowDir);
|
|
675
|
+
}
|
|
676
|
+
} catch {}
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
return { workflowId, snapshotPath };
|
|
680
|
+
});
|
|
681
|
+
return { workflowId, snapshotPath };
|
|
682
|
+
}
|
|
683
|
+
function resolveSelectedPlanFiles(outDir, selected) {
|
|
684
|
+
const files = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
|
|
685
|
+
const byNum = new Map;
|
|
686
|
+
const byStem = new Map;
|
|
687
|
+
for (const file of files) {
|
|
688
|
+
const stem = file.replace(/\.md$/, "");
|
|
689
|
+
if (!byNum.has(stem.slice(0, 3))) {
|
|
690
|
+
byNum.set(stem.slice(0, 3), file);
|
|
691
|
+
}
|
|
692
|
+
byStem.set(stem, file);
|
|
693
|
+
}
|
|
694
|
+
const resolved = [];
|
|
695
|
+
const seen = new Set;
|
|
696
|
+
for (const id of selected) {
|
|
697
|
+
const file = byNum.get(id) ?? byStem.get(id) ?? byStem.get(id.replace(/\.md$/, ""));
|
|
698
|
+
if (file === undefined) {
|
|
699
|
+
throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve4(outDir)}`);
|
|
700
|
+
}
|
|
701
|
+
if (!seen.has(file)) {
|
|
702
|
+
seen.add(file);
|
|
703
|
+
resolved.push(file);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return resolved;
|
|
707
|
+
}
|
|
708
|
+
function readExecutionOrderIndex(outDir) {
|
|
709
|
+
const readmePath = join4(outDir, "README.md");
|
|
710
|
+
let text;
|
|
711
|
+
try {
|
|
712
|
+
text = readFileSync3(readmePath, "utf8");
|
|
713
|
+
} catch {
|
|
714
|
+
return new Map;
|
|
715
|
+
}
|
|
716
|
+
const rows = new Map;
|
|
717
|
+
const lines = text.split(`
|
|
718
|
+
`);
|
|
719
|
+
let inSection = false;
|
|
720
|
+
for (const line of lines) {
|
|
721
|
+
if (/^##\s+Execution order & status/.test(line)) {
|
|
722
|
+
inSection = true;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (inSection && /^#/.test(line)) {
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
if (!inSection)
|
|
729
|
+
continue;
|
|
730
|
+
const cells = line.split(/(?<!\\)\|/).map((c) => c.trim());
|
|
731
|
+
if (cells.length >= 3 && /^\d{3}$/.test(cells[1])) {
|
|
732
|
+
rows.set(cells[1], { title: cells[2].replace(/\\\|/g, "|") });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return rows;
|
|
736
|
+
}
|
|
737
|
+
function planFileRel(outDir, planFile) {
|
|
738
|
+
const resolved = resolve4(outDir);
|
|
739
|
+
const parts = resolved.split(sep2);
|
|
740
|
+
const plansIdx = parts.lastIndexOf("plans");
|
|
741
|
+
if (plansIdx >= 0) {
|
|
742
|
+
return `${parts.slice(plansIdx + 1).join(sep2)}${sep2}${planFile}`;
|
|
743
|
+
}
|
|
744
|
+
return planFile;
|
|
745
|
+
}
|
|
746
|
+
export {
|
|
747
|
+
validateAuditStatusBlocks,
|
|
748
|
+
scaffoldAuditPlan,
|
|
749
|
+
redactSecrets,
|
|
750
|
+
promoteAuditPlans,
|
|
751
|
+
AUDIT_RISKS,
|
|
752
|
+
AUDIT_PRIORITIES,
|
|
753
|
+
AUDIT_EFFORTS,
|
|
754
|
+
AUDIT_CATEGORIES
|
|
755
|
+
};
|