@loomhelm/multi-task-flow 0.2.6 → 0.2.7
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/README.md +3 -3
- package/package.json +1 -1
- package/toolkit/artifact-manifest.json +330 -26
- package/toolkit/package.json +1 -1
- package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_cli.ts +12 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_docs.ts +115 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_shared.ts +230 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/checks/replay.ts +336 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/git.ts +54 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/migration_common.ts +291 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/next_plan.ts +199 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
- package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
- package/toolkit/skills/docs-migrate/scripts/gen_legacy_spec_id.ts +1 -1
- package/toolkit/skills/docs-migrate/scripts/specs_migrate_guard.ts +1 -1
- package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_cli.ts +12 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_docs.ts +115 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_shared.ts +230 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/checks/replay.ts +336 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/git.ts +54 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/migration_common.ts +291 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/next_plan.ts +199 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
- package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
- package/toolkit/skills/find-next-plan/scripts/find_next_plan.ts +1 -1
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_cli.ts +12 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_docs.ts +115 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_shared.ts +230 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/checks/replay.ts +336 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/git.ts +54 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/migration_common.ts +291 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/next_plan.ts +199 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
- package/toolkit/skills/hardware-doc-ingest/scripts/ingest_mineru_extract.ts +1 -1
- package/toolkit/skills/hardware-doc-ingest/scripts/verify_doc_locks.ts +1 -1
- package/toolkit/skills/multi-task-flow/dist/app.js +66 -66
- package/toolkit/skills/multi-task-flow/lib/view.ts +6 -2
- package/toolkit/skills/multi-task-flow/web/app.ts +2 -2
- package/toolkit/skills/multi-task-flow/web/graph-layout.ts +3 -3
- package/toolkit/skills/source-manifest.json +315 -11
- package/toolkit/skills/spec-sync/SKILL.md +1 -1
- package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_cli.ts +12 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_docs.ts +115 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_shared.ts +230 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/checks/replay.ts +336 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/git.ts +54 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/migration_common.ts +291 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/next_plan.ts +199 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
- package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
- package/toolkit/skills/spec-sync/scripts/spec_drift_check.ts +1 -1
- package/toolkit/skills/support-manifest.json +77 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
parseReplayCases,
|
|
5
|
+
resolveCasePath,
|
|
6
|
+
runReplayCases,
|
|
7
|
+
} from "./modules/checks/replay";
|
|
8
|
+
|
|
9
|
+
export type Analyzer = (path: string) => {
|
|
10
|
+
findings: Array<{ code: string; line: number; message: string }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function option(
|
|
14
|
+
argv: string[],
|
|
15
|
+
name: string,
|
|
16
|
+
fallback?: string,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
19
|
+
const token = argv[index];
|
|
20
|
+
if (token === `--${name}`) return argv[index + 1] ?? fallback;
|
|
21
|
+
if (token?.startsWith(`--${name}=`)) return token.slice(name.length + 3);
|
|
22
|
+
}
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function values(argv: string[], name: string): string[] {
|
|
27
|
+
const result: string[] = [];
|
|
28
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
29
|
+
const token = argv[index];
|
|
30
|
+
if (token === `--${name}` && argv[index + 1]) result.push(argv[++index]!);
|
|
31
|
+
else if (token?.startsWith(`--${name}=`))
|
|
32
|
+
result.push(token.slice(name.length + 3));
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function groups(argv: string[], name: string, size: number): string[][] {
|
|
38
|
+
const result: string[][] = [];
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
if (argv[index] !== `--${name}`) continue;
|
|
41
|
+
const group = argv.slice(index + 1, index + 1 + size);
|
|
42
|
+
if (group.length === size) result.push(group);
|
|
43
|
+
index += size;
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function positional(argv: string[]): string[] {
|
|
49
|
+
const skip = new Set([
|
|
50
|
+
"--repo-root",
|
|
51
|
+
"--cases",
|
|
52
|
+
"--path",
|
|
53
|
+
"--base-sha",
|
|
54
|
+
"--output-dir",
|
|
55
|
+
"--repo",
|
|
56
|
+
"--remote",
|
|
57
|
+
"--number",
|
|
58
|
+
"--sha",
|
|
59
|
+
"--method",
|
|
60
|
+
"--expected-base",
|
|
61
|
+
"--expected-head",
|
|
62
|
+
"--branch",
|
|
63
|
+
"--title",
|
|
64
|
+
"--body-file",
|
|
65
|
+
"--head",
|
|
66
|
+
"--base",
|
|
67
|
+
"--run-id",
|
|
68
|
+
"--expected-sha",
|
|
69
|
+
"--tag",
|
|
70
|
+
"--note-file",
|
|
71
|
+
"--target",
|
|
72
|
+
"--event",
|
|
73
|
+
"--text-file",
|
|
74
|
+
"--context",
|
|
75
|
+
]);
|
|
76
|
+
const result: string[] = [];
|
|
77
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
78
|
+
const token = argv[index]!;
|
|
79
|
+
if (skip.has(token)) {
|
|
80
|
+
index += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!token.startsWith("--")) result.push(token);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function replay(
|
|
89
|
+
argv: string[],
|
|
90
|
+
analyzer: Analyzer,
|
|
91
|
+
expectation: "required" | "exact" = "required",
|
|
92
|
+
): number {
|
|
93
|
+
const casePaths = values(argv, "cases");
|
|
94
|
+
const paths = positional(argv);
|
|
95
|
+
if (!casePaths.length && !paths.length) {
|
|
96
|
+
console.error("at least one replay path is required");
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
let exitCode = 0;
|
|
100
|
+
const summary = argv.includes("--summary");
|
|
101
|
+
for (const casesPath of casePaths) {
|
|
102
|
+
const failures = replayCases(casesPath, analyzer, expectation, summary);
|
|
103
|
+
if (failures.length) {
|
|
104
|
+
exitCode = 1;
|
|
105
|
+
for (const failure of failures)
|
|
106
|
+
console.error(formatCaseMismatch(failure, summary));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const path of paths) {
|
|
110
|
+
try {
|
|
111
|
+
const caseFailures = path.endsWith(".txt")
|
|
112
|
+
? replayCases(path, analyzer, expectation, summary)
|
|
113
|
+
: undefined;
|
|
114
|
+
if (caseFailures !== undefined) {
|
|
115
|
+
if (caseFailures.length) {
|
|
116
|
+
exitCode = 1;
|
|
117
|
+
for (const failure of caseFailures)
|
|
118
|
+
console.error(formatCaseMismatch(failure, summary));
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const findings = analyzer(path).findings;
|
|
123
|
+
if (findings.length) {
|
|
124
|
+
exitCode = 1;
|
|
125
|
+
if (summary)
|
|
126
|
+
console.log(`SUMMARY file=${path} ${replaySummary(findings)}`);
|
|
127
|
+
else
|
|
128
|
+
findings.forEach((finding) =>
|
|
129
|
+
console.log(
|
|
130
|
+
`FINDING file=${path} code=${finding.code} line=${finding.line} msg=${finding.message}`,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
console.log(
|
|
135
|
+
summary
|
|
136
|
+
? `SUMMARY file=${path} status=PASS total=0`
|
|
137
|
+
: `PASS file=${path}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
exitCode = 1;
|
|
142
|
+
console.error(
|
|
143
|
+
`FAIL file=${path} ${error instanceof Error ? error.message : String(error)}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return exitCode;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatCaseMismatch(
|
|
151
|
+
failure: ReturnType<typeof runReplayCases>[number],
|
|
152
|
+
summary: boolean,
|
|
153
|
+
): string {
|
|
154
|
+
const actual = failure.actualCodes.join(",") || "PASS";
|
|
155
|
+
if (summary) {
|
|
156
|
+
return failure.case.expectFail
|
|
157
|
+
? `ERROR file=${failure.path} expected finding ${failure.case.expectedCodes.join(",") || "any"} but saw ${actual}`
|
|
158
|
+
: `ERROR file=${failure.path} expected PASS but saw findings ${actual}`;
|
|
159
|
+
}
|
|
160
|
+
return `CASE_MISMATCH file=${failure.path} line=${failure.case.line} expected=${failure.case.expectedCodes.join(",") || (failure.case.expectFail ? "finding" : "pass")} actual=${failure.actualCodes.join(",") || "pass"}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function replayCases(
|
|
164
|
+
casesPath: string,
|
|
165
|
+
analyzer: Analyzer,
|
|
166
|
+
expectation: "required" | "exact",
|
|
167
|
+
summary: boolean,
|
|
168
|
+
): ReturnType<typeof runReplayCases> {
|
|
169
|
+
const failures: ReturnType<typeof runReplayCases> = [];
|
|
170
|
+
for (const item of parseReplayCases(casesPath)) {
|
|
171
|
+
const localPath = resolveCasePath(dirname(casesPath), item.path);
|
|
172
|
+
const path = existsSync(localPath)
|
|
173
|
+
? localPath
|
|
174
|
+
: resolveCasePath(process.cwd(), item.path);
|
|
175
|
+
const actual = analyzer(path).findings;
|
|
176
|
+
const actualCodes = actual.map((finding) => finding.code).sort();
|
|
177
|
+
const actualCodeSet = [...new Set(actualCodes)];
|
|
178
|
+
const expectedCodes = [...new Set(item.expectedCodes)].sort();
|
|
179
|
+
const matches = item.expectFail
|
|
180
|
+
? expectation === "exact"
|
|
181
|
+
? actualCodes.length === expectedCodes.length &&
|
|
182
|
+
actualCodes.every((code, index) => code === expectedCodes[index])
|
|
183
|
+
: expectedCodes.every((code) => actualCodeSet.includes(code))
|
|
184
|
+
: actualCodes.length === 0;
|
|
185
|
+
if (summary) console.log(`SUMMARY file=${path} ${replaySummary(actual)}`);
|
|
186
|
+
else if (actual.length) {
|
|
187
|
+
for (const finding of actual)
|
|
188
|
+
console.log(
|
|
189
|
+
`FINDING file=${path} code=${finding.code} line=${finding.line} msg=${finding.message}`,
|
|
190
|
+
);
|
|
191
|
+
} else console.log(`PASS file=${path}`);
|
|
192
|
+
if (!matches) failures.push({ case: item, actualCodes, path });
|
|
193
|
+
}
|
|
194
|
+
return failures;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function replaySummary(
|
|
198
|
+
findings: Array<{ code: string; line: number }>,
|
|
199
|
+
): string {
|
|
200
|
+
if (!findings.length) return "status=PASS total=0";
|
|
201
|
+
const counts = new Map<string, { count: number; line: number }>();
|
|
202
|
+
for (const finding of findings) {
|
|
203
|
+
const previous = counts.get(finding.code);
|
|
204
|
+
counts.set(finding.code, {
|
|
205
|
+
count: (previous?.count ?? 0) + 1,
|
|
206
|
+
line: previous?.line ?? finding.line,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const details = [...counts.entries()]
|
|
210
|
+
.sort(
|
|
211
|
+
(left, right) =>
|
|
212
|
+
left[1].line - right[1].line || left[0].localeCompare(right[0]),
|
|
213
|
+
)
|
|
214
|
+
.map(([code, detail]) => `${code}:${detail.count}@${detail.line}`)
|
|
215
|
+
.join(",");
|
|
216
|
+
return `status=FINDINGS total=${findings.length} codes=${details}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function rootFor(argv: string[]): string {
|
|
220
|
+
return resolve(option(argv, "repo-root", process.cwd())!);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function printList(valuesToPrint: string[], prefix = "FINDING"): number {
|
|
224
|
+
valuesToPrint.forEach((value) => console.log(`${prefix} ${value}`));
|
|
225
|
+
return valuesToPrint.length ? 1 : 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function json(value: unknown): void {
|
|
229
|
+
console.log(JSON.stringify(value, null, 2));
|
|
230
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type JsonValue =
|
|
5
|
+
string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
6
|
+
export type JsonRecord = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface Finding {
|
|
9
|
+
code: string;
|
|
10
|
+
line: number;
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReplayResult {
|
|
15
|
+
findings: Finding[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReplayCase {
|
|
19
|
+
path: string;
|
|
20
|
+
expectFail: boolean;
|
|
21
|
+
expectedCodes: string[];
|
|
22
|
+
line: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isRecord(value: unknown): value is JsonRecord {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function collectStrings(value: unknown): string[] {
|
|
30
|
+
if (typeof value === "string") return [value];
|
|
31
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
32
|
+
if (isRecord(value)) return Object.values(value).flatMap(collectStrings);
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const trackerCardFields = new Set([
|
|
37
|
+
"phase",
|
|
38
|
+
"tracking_phase",
|
|
39
|
+
"追踪阶段",
|
|
40
|
+
"tracking_target",
|
|
41
|
+
"追踪目标",
|
|
42
|
+
"bound_sha",
|
|
43
|
+
"checked_sha",
|
|
44
|
+
"追踪绑定sha",
|
|
45
|
+
"绑定sha",
|
|
46
|
+
"terminal_state",
|
|
47
|
+
"追踪终态",
|
|
48
|
+
"result",
|
|
49
|
+
"追踪结果",
|
|
50
|
+
"external_locator",
|
|
51
|
+
"外部定位",
|
|
52
|
+
]);
|
|
53
|
+
const routeResolutionFields = new Set([
|
|
54
|
+
"pre_merge_release_route",
|
|
55
|
+
"release_disposition",
|
|
56
|
+
"release_route",
|
|
57
|
+
"release_route_resolved",
|
|
58
|
+
"current_repository_facts",
|
|
59
|
+
"repository_facts",
|
|
60
|
+
"current_pr_facts",
|
|
61
|
+
"pr_facts",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function pythonText(value: unknown): string {
|
|
65
|
+
if (value === null) return "None";
|
|
66
|
+
if (typeof value === "boolean") return value ? "True" : "False";
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function collectTrackerCardStrings(value: unknown): string[] {
|
|
71
|
+
if (Array.isArray(value)) return value.flatMap(collectTrackerCardStrings);
|
|
72
|
+
if (!isRecord(value)) return [];
|
|
73
|
+
const texts: string[] = [];
|
|
74
|
+
for (const [key, item] of Object.entries(value)) {
|
|
75
|
+
if (
|
|
76
|
+
trackerCardFields.has(key) &&
|
|
77
|
+
(typeof item === "string" ||
|
|
78
|
+
typeof item === "number" ||
|
|
79
|
+
typeof item === "boolean")
|
|
80
|
+
) {
|
|
81
|
+
texts.push(`${key}=${pythonText(item)}`);
|
|
82
|
+
} else if (routeResolutionFields.has(key)) {
|
|
83
|
+
if (Array.isArray(item) || isRecord(item)) {
|
|
84
|
+
texts.push(key);
|
|
85
|
+
texts.push(...collectTrackerCardStrings(item));
|
|
86
|
+
} else {
|
|
87
|
+
texts.push(`${key}=${pythonText(item)}`);
|
|
88
|
+
}
|
|
89
|
+
} else if (Array.isArray(item) || isRecord(item)) {
|
|
90
|
+
texts.push(...collectTrackerCardStrings(item));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return texts;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type JsonScanResult = number | "duplicate" | undefined;
|
|
97
|
+
|
|
98
|
+
function skipJsonWhitespace(text: string, index: number): number {
|
|
99
|
+
while (index < text.length && /\s/.test(text[index]!)) index += 1;
|
|
100
|
+
return index;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scanJsonString(
|
|
104
|
+
text: string,
|
|
105
|
+
start: number,
|
|
106
|
+
): { end: number; value: string } | undefined {
|
|
107
|
+
if (text[start] !== '"') return undefined;
|
|
108
|
+
let index = start + 1;
|
|
109
|
+
while (index < text.length) {
|
|
110
|
+
if (text[index] === "\\") {
|
|
111
|
+
index += 2;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (text[index] === '"') {
|
|
115
|
+
const raw = text.slice(start, index + 1);
|
|
116
|
+
try {
|
|
117
|
+
const value: unknown = JSON.parse(raw);
|
|
118
|
+
return typeof value === "string"
|
|
119
|
+
? { end: index + 1, value }
|
|
120
|
+
: undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
index += 1;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scanJsonValue(text: string, start: number): JsonScanResult {
|
|
131
|
+
const index = skipJsonWhitespace(text, start);
|
|
132
|
+
if (text[index] === '"') return scanJsonString(text, index)?.end;
|
|
133
|
+
if (text[index] === "{") {
|
|
134
|
+
const keys = new Set<string>();
|
|
135
|
+
let cursor = skipJsonWhitespace(text, index + 1);
|
|
136
|
+
if (text[cursor] === "}") return cursor + 1;
|
|
137
|
+
while (cursor < text.length) {
|
|
138
|
+
const key = scanJsonString(text, cursor);
|
|
139
|
+
if (!key) return undefined;
|
|
140
|
+
if (keys.has(key.value)) return "duplicate";
|
|
141
|
+
keys.add(key.value);
|
|
142
|
+
cursor = skipJsonWhitespace(text, key.end);
|
|
143
|
+
if (text[cursor] !== ":") return undefined;
|
|
144
|
+
const value = scanJsonValue(text, cursor + 1);
|
|
145
|
+
if (value === "duplicate" || value === undefined) return value;
|
|
146
|
+
cursor = skipJsonWhitespace(text, value);
|
|
147
|
+
if (text[cursor] === "}") return cursor + 1;
|
|
148
|
+
if (text[cursor] !== ",") return undefined;
|
|
149
|
+
cursor = skipJsonWhitespace(text, cursor + 1);
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
if (text[index] === "[") {
|
|
154
|
+
let cursor = skipJsonWhitespace(text, index + 1);
|
|
155
|
+
if (text[cursor] === "]") return cursor + 1;
|
|
156
|
+
while (cursor < text.length) {
|
|
157
|
+
const value = scanJsonValue(text, cursor);
|
|
158
|
+
if (value === "duplicate" || value === undefined) return value;
|
|
159
|
+
cursor = skipJsonWhitespace(text, value);
|
|
160
|
+
if (text[cursor] === "]") return cursor + 1;
|
|
161
|
+
if (text[cursor] !== ",") return undefined;
|
|
162
|
+
cursor = skipJsonWhitespace(text, cursor + 1);
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
let cursor = index;
|
|
167
|
+
while (cursor < text.length && !/[\s,\]}]/.test(text[cursor]!)) cursor += 1;
|
|
168
|
+
return cursor > index ? cursor : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasDuplicateJsonKeys(text: string): boolean {
|
|
172
|
+
return scanJsonValue(text, 0) === "duplicate";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function parseJsonArguments(raw: unknown): JsonRecord {
|
|
176
|
+
if (isRecord(raw)) return raw;
|
|
177
|
+
if (typeof raw !== "string") return {};
|
|
178
|
+
const parsed = parseJsonValue(raw);
|
|
179
|
+
return isRecord(parsed) ? parsed : {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function parseJsonValue(raw: string): unknown | undefined {
|
|
183
|
+
if (hasDuplicateJsonKeys(raw)) return undefined;
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(raw) as unknown;
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function eventTexts(record: JsonRecord): {
|
|
192
|
+
kind: string | undefined;
|
|
193
|
+
role: string | undefined;
|
|
194
|
+
texts: string[];
|
|
195
|
+
} {
|
|
196
|
+
const recordType = typeof record.type === "string" ? record.type : undefined;
|
|
197
|
+
const payload = isRecord(record.payload) ? record.payload : {};
|
|
198
|
+
if (recordType !== "response_item")
|
|
199
|
+
return {
|
|
200
|
+
kind: recordType,
|
|
201
|
+
role: undefined,
|
|
202
|
+
texts: collectStrings(payload),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const payloadType =
|
|
206
|
+
typeof payload.type === "string" ? payload.type : undefined;
|
|
207
|
+
if (payloadType === "message") {
|
|
208
|
+
const content = Array.isArray(payload.content) ? payload.content : [];
|
|
209
|
+
const texts = content.flatMap((item) => {
|
|
210
|
+
if (!isRecord(item) || typeof item.text !== "string") return [];
|
|
211
|
+
return [item.text];
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
kind: "message",
|
|
215
|
+
role: typeof payload.role === "string" ? payload.role : undefined,
|
|
216
|
+
texts,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (payloadType === "function_call") {
|
|
220
|
+
const texts = [payload.name, payload.arguments].filter(
|
|
221
|
+
(item): item is string => typeof item === "string",
|
|
222
|
+
);
|
|
223
|
+
return { kind: "function_call", role: undefined, texts };
|
|
224
|
+
}
|
|
225
|
+
if (payloadType === "function_call_output")
|
|
226
|
+
return {
|
|
227
|
+
kind: "function_call_output",
|
|
228
|
+
role: undefined,
|
|
229
|
+
texts: [
|
|
230
|
+
...collectStrings(payload.output),
|
|
231
|
+
...(typeof payload.output === "object" && payload.output !== null
|
|
232
|
+
? [JSON.stringify(payload.output)]
|
|
233
|
+
: []),
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
return { kind: payloadType, role: undefined, texts: collectStrings(payload) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function eventText(record: JsonRecord): string {
|
|
240
|
+
return eventTexts(record).texts.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function readJsonLines(
|
|
244
|
+
path: string,
|
|
245
|
+
): Array<{ line: number; value: JsonRecord }> {
|
|
246
|
+
const records: Array<{ line: number; value: JsonRecord }> = [];
|
|
247
|
+
for (const [index, raw] of readFileSync(path, "utf8")
|
|
248
|
+
.split(/\r?\n/)
|
|
249
|
+
.entries()) {
|
|
250
|
+
if (!raw.trim()) continue;
|
|
251
|
+
const parsed: unknown = JSON.parse(raw);
|
|
252
|
+
if (!isRecord(parsed))
|
|
253
|
+
throw new Error(`replay record at line ${index + 1} must be an object`);
|
|
254
|
+
records.push({ line: index + 1, value: parsed });
|
|
255
|
+
}
|
|
256
|
+
return records;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function parseReplayCases(path: string): ReplayCase[] {
|
|
260
|
+
const cases: ReplayCase[] = [];
|
|
261
|
+
for (const [index, raw] of readFileSync(path, "utf8")
|
|
262
|
+
.split(/\r?\n/)
|
|
263
|
+
.entries()) {
|
|
264
|
+
const line = raw.trim();
|
|
265
|
+
if (!line || line.startsWith("#")) continue;
|
|
266
|
+
const fields = line.split(/\s+/);
|
|
267
|
+
if (fields.length < 2) throw new Error(`invalid cases line ${index + 1}`);
|
|
268
|
+
if (fields[0] === "pass" || fields[0] === "fail") {
|
|
269
|
+
if (fields.length < 3)
|
|
270
|
+
throw new Error(`missing expectation at line ${index + 1}`);
|
|
271
|
+
cases.push({
|
|
272
|
+
path: fields[1]!,
|
|
273
|
+
expectFail: fields[0] === "fail",
|
|
274
|
+
expectedCodes: fields[2] === "-" ? [] : fields[2]!.split(","),
|
|
275
|
+
line: index + 1,
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
cases.push({
|
|
280
|
+
path: fields[0]!,
|
|
281
|
+
expectFail: fields[1] !== "pass",
|
|
282
|
+
expectedCodes: fields[1] === "pass" ? [] : fields[1]!.split(","),
|
|
283
|
+
line: index + 1,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return cases;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function resolveCasePath(baseDir: string, rawPath: string): string {
|
|
290
|
+
return isAbsolute(rawPath) ? rawPath : resolve(baseDir, rawPath);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export type ReplayAnalyzer = (path: string) => ReplayResult;
|
|
294
|
+
|
|
295
|
+
export interface ReplayCaseFailure {
|
|
296
|
+
case: ReplayCase;
|
|
297
|
+
actualCodes: string[];
|
|
298
|
+
path: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export type ReplayExpectation = "required" | "exact";
|
|
302
|
+
|
|
303
|
+
export function runReplayCases(
|
|
304
|
+
casesPath: string,
|
|
305
|
+
analyzer: ReplayAnalyzer,
|
|
306
|
+
expectation: ReplayExpectation = "required",
|
|
307
|
+
): ReplayCaseFailure[] {
|
|
308
|
+
const failures: ReplayCaseFailure[] = [];
|
|
309
|
+
for (const item of parseReplayCases(casesPath)) {
|
|
310
|
+
const localPath = resolveCasePath(dirname(casesPath), item.path);
|
|
311
|
+
const path = existsSync(localPath)
|
|
312
|
+
? localPath
|
|
313
|
+
: resolveCasePath(process.cwd(), item.path);
|
|
314
|
+
const actualCodes = analyzer(path)
|
|
315
|
+
.findings.map((finding) => finding.code)
|
|
316
|
+
.sort();
|
|
317
|
+
const actualCodeSet = [...new Set(actualCodes)];
|
|
318
|
+
const expectedCodes = [...new Set(item.expectedCodes)].sort();
|
|
319
|
+
const equal = item.expectFail
|
|
320
|
+
? expectation === "exact"
|
|
321
|
+
? actualCodes.length === expectedCodes.length &&
|
|
322
|
+
actualCodes.every((code, index) => code === expectedCodes[index])
|
|
323
|
+
: expectedCodes.every((code) => actualCodeSet.includes(code))
|
|
324
|
+
: actualCodes.length === 0;
|
|
325
|
+
if (!equal) failures.push({ case: item, actualCodes, path });
|
|
326
|
+
}
|
|
327
|
+
return failures;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function result(findings: Finding[]): ReplayResult {
|
|
331
|
+
return { findings };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function finding(code: string, line: number, message: string): Finding {
|
|
335
|
+
return { code, line, message };
|
|
336
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface GitCommandResult {
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function text(value: unknown): string {
|
|
10
|
+
if (typeof value === "string") return value;
|
|
11
|
+
if (value instanceof Uint8Array) return new TextDecoder().decode(value);
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runGit(repoRoot: string, args: string[]): GitCommandResult {
|
|
16
|
+
try {
|
|
17
|
+
const stdout = execFileSync("git", ["-C", repoRoot, ...args], {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
return { exitCode: 0, stdout: text(stdout), stderr: "" };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const failure = error as {
|
|
24
|
+
status?: number;
|
|
25
|
+
stdout?: unknown;
|
|
26
|
+
stderr?: unknown;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
exitCode: failure.status ?? 1,
|
|
30
|
+
stdout: text(failure.stdout),
|
|
31
|
+
stderr: text(failure.stderr),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function gitText(repoRoot: string, args: string[]): string | null {
|
|
37
|
+
const result = runGit(repoRoot, args);
|
|
38
|
+
return result.exitCode === 0 ? result.stdout : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function gitRefExists(repoRoot: string, ref: string): boolean {
|
|
42
|
+
return runGit(repoRoot, ["rev-parse", "--verify", ref]).exitCode === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function gitFileText(
|
|
46
|
+
repoRoot: string,
|
|
47
|
+
ref: string,
|
|
48
|
+
path: string,
|
|
49
|
+
): string | null {
|
|
50
|
+
return gitText(repoRoot, [
|
|
51
|
+
"show",
|
|
52
|
+
ref === ":" ? `:${path}` : `${ref}:${path}`,
|
|
53
|
+
]);
|
|
54
|
+
}
|