@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,376 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { basename, dirname, resolve } from "node:path";
|
|
9
|
+
import { gitRefExists } from "./git";
|
|
10
|
+
import { catalogConfirmsLegacyId } from "./migration_common";
|
|
11
|
+
import type { DriftPathContext } from "./spec_drift_types";
|
|
12
|
+
|
|
13
|
+
export function pathCountsAsSpecSync(
|
|
14
|
+
path: string,
|
|
15
|
+
root: string,
|
|
16
|
+
associatedSpec: string,
|
|
17
|
+
): boolean {
|
|
18
|
+
if (!root || !path.startsWith(`${root}/`)) return false;
|
|
19
|
+
const relativePathValue = path.slice(root.length + 1);
|
|
20
|
+
if (
|
|
21
|
+
["SPEC.md", "IMPLEMENTATION.md", "HISTORY.md"].includes(relativePathValue)
|
|
22
|
+
)
|
|
23
|
+
return true;
|
|
24
|
+
if (relativePathValue === "PLAN.md")
|
|
25
|
+
return /^(docs\/plan|docs\/specs)\/.+\/PLAN\.md$/.test(associatedSpec);
|
|
26
|
+
return relativePathValue.startsWith("contracts/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function specTargetFromChangedPath(path: string): string | null {
|
|
30
|
+
if (path.startsWith("docs/plan/")) {
|
|
31
|
+
const relativePathValue = path.slice("docs/plan/".length);
|
|
32
|
+
const root = relativePathValue.split("/")[0];
|
|
33
|
+
return root ? `docs/plan/${root}/PLAN.md` : null;
|
|
34
|
+
}
|
|
35
|
+
if (/^docs\/specs\/[^/]+\/PLAN\.md$/.test(path)) return path;
|
|
36
|
+
if (path.startsWith("docs/specs/")) {
|
|
37
|
+
const root = path.slice("docs/specs/".length).split("/")[0];
|
|
38
|
+
return root ? `docs/specs/${root}/SPEC.md` : null;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isLegacySpecDirName(name: string): boolean {
|
|
44
|
+
return /^\d{4}(?:$|[-:])/.test(name) || /^[a-z0-9]{5}[-:]/i.test(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function extractSpecId(path: string): string {
|
|
48
|
+
const match = /^(?:docs\/specs|docs\/plan)\/([^/]+)\//.exec(path);
|
|
49
|
+
if (!match?.[1]) return "";
|
|
50
|
+
return match[1].split(/[-:]/, 1)[0] ?? "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function legacyTopicFromDir(directory: string): string {
|
|
54
|
+
if (/^\d{4}[-:]/.test(directory)) return directory.slice(5);
|
|
55
|
+
if (/^[a-z0-9]{5}[-:]/i.test(directory)) return directory.slice(6);
|
|
56
|
+
return directory;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function pathExists(path: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
lstatSync(path);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function file(path: string): boolean {
|
|
69
|
+
try {
|
|
70
|
+
return statSync(path).isFile();
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function directory(path: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(path).isDirectory();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function catalogProves(repoRoot: string, sourcePath: string): boolean {
|
|
85
|
+
const match = /^(docs\/(?:plan|specs)\/[^/]+\/(?:PLAN|SPEC)\.md)$/.exec(
|
|
86
|
+
sourcePath,
|
|
87
|
+
);
|
|
88
|
+
if (!match) return false;
|
|
89
|
+
const root = dirname(sourcePath);
|
|
90
|
+
const name = basename(root);
|
|
91
|
+
if (!isLegacySpecDirName(name)) return false;
|
|
92
|
+
const id = extractSpecId(sourcePath);
|
|
93
|
+
const catalog = sourcePath.startsWith("docs/plan/")
|
|
94
|
+
? "docs/plan/README.md"
|
|
95
|
+
: "docs/specs/README.md";
|
|
96
|
+
const relativeToCatalog = sourcePath.slice(dirname(catalog).length + 1);
|
|
97
|
+
return Boolean(
|
|
98
|
+
(id && catalogConfirmsLegacyId(repoRoot, catalog, sourcePath, id)) ||
|
|
99
|
+
(id && catalogConfirmsLegacyId(repoRoot, catalog, relativeToCatalog, id)),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function historyProves(repoRoot: string, sourcePath: string): boolean {
|
|
104
|
+
if (!sourcePath.startsWith("docs/specs/") || !sourcePath.endsWith("/SPEC.md"))
|
|
105
|
+
return false;
|
|
106
|
+
const directoryName = basename(dirname(sourcePath));
|
|
107
|
+
if (!isLegacySpecDirName(directoryName)) return false;
|
|
108
|
+
const id = extractSpecId(sourcePath);
|
|
109
|
+
const topic = legacyTopicFromDir(directoryName);
|
|
110
|
+
const history = resolve(repoRoot, `docs/specs/${topic}/HISTORY.md`);
|
|
111
|
+
if (!id || !file(history)) return false;
|
|
112
|
+
return new RegExp(
|
|
113
|
+
`^\\s*-\\s*Legacy compatibility identity:\\s*\`#?${id}\`(?:\\s|\\.|$)`,
|
|
114
|
+
"m",
|
|
115
|
+
).test(readFileSync(history, "utf8"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function legacyProof(repoRoot: string, sourcePath: string): boolean {
|
|
119
|
+
return (
|
|
120
|
+
catalogProves(repoRoot, sourcePath) || historyProves(repoRoot, sourcePath)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renameMatches(
|
|
125
|
+
context: DriftPathContext,
|
|
126
|
+
oldPath: string,
|
|
127
|
+
newPath: string,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (
|
|
130
|
+
!oldPath.startsWith("docs/specs/") ||
|
|
131
|
+
!oldPath.endsWith("/SPEC.md") ||
|
|
132
|
+
!newPath.startsWith("docs/specs/") ||
|
|
133
|
+
!newPath.endsWith("/SPEC.md")
|
|
134
|
+
)
|
|
135
|
+
return false;
|
|
136
|
+
const oldDirectory = basename(dirname(oldPath));
|
|
137
|
+
const newDirectory = basename(dirname(newPath));
|
|
138
|
+
if (
|
|
139
|
+
!isLegacySpecDirName(oldDirectory) ||
|
|
140
|
+
!legacyProof(context.repoRoot, oldPath)
|
|
141
|
+
)
|
|
142
|
+
return false;
|
|
143
|
+
const oldId = extractSpecId(oldPath);
|
|
144
|
+
if (isLegacySpecDirName(newDirectory))
|
|
145
|
+
return oldId !== "" && oldId === extractSpecId(newPath);
|
|
146
|
+
return newDirectory === legacyTopicFromDir(oldDirectory);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function movedTarget(
|
|
150
|
+
context: DriftPathContext,
|
|
151
|
+
oldPath: string,
|
|
152
|
+
): string | null | "ambiguous" {
|
|
153
|
+
const direct = context.renamePairs.find(
|
|
154
|
+
(pair) =>
|
|
155
|
+
pair.oldPath === oldPath &&
|
|
156
|
+
pair.newPath.startsWith("docs/specs/") &&
|
|
157
|
+
pair.newPath.endsWith("/SPEC.md") &&
|
|
158
|
+
renameMatches(context, pair.oldPath, pair.newPath),
|
|
159
|
+
);
|
|
160
|
+
if (direct) return direct.newPath;
|
|
161
|
+
if (!legacyProof(context.repoRoot, oldPath)) return null;
|
|
162
|
+
const id = extractSpecId(oldPath);
|
|
163
|
+
const candidates = new Set<string>();
|
|
164
|
+
for (const path of context.changedFiles)
|
|
165
|
+
if (
|
|
166
|
+
path.startsWith(`docs/specs/${id}-`) ||
|
|
167
|
+
path.startsWith(`docs/specs/${id}:`)
|
|
168
|
+
)
|
|
169
|
+
if (
|
|
170
|
+
path.endsWith("/SPEC.md") &&
|
|
171
|
+
pathExists(resolve(context.repoRoot, path)) &&
|
|
172
|
+
renameMatches(context, oldPath, path)
|
|
173
|
+
)
|
|
174
|
+
candidates.add(path);
|
|
175
|
+
const specsRoot = resolve(context.repoRoot, "docs/specs");
|
|
176
|
+
if (directory(specsRoot))
|
|
177
|
+
for (const entry of readdirSync(specsRoot, { withFileTypes: true }))
|
|
178
|
+
if (
|
|
179
|
+
entry.isDirectory() &&
|
|
180
|
+
(entry.name.startsWith(`${id}-`) || entry.name.startsWith(`${id}:`))
|
|
181
|
+
) {
|
|
182
|
+
const path = `docs/specs/${entry.name}/SPEC.md`;
|
|
183
|
+
if (
|
|
184
|
+
file(resolve(context.repoRoot, path)) &&
|
|
185
|
+
renameMatches(context, oldPath, path)
|
|
186
|
+
)
|
|
187
|
+
candidates.add(path);
|
|
188
|
+
}
|
|
189
|
+
if (candidates.size === 1) return [...candidates][0] as string;
|
|
190
|
+
return candidates.size > 1 ? "ambiguous" : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveLegacyPlan(
|
|
194
|
+
context: DriftPathContext,
|
|
195
|
+
path: string,
|
|
196
|
+
): string | null | "ambiguous" {
|
|
197
|
+
const match = /^(?:docs\/plan|docs\/specs)\/([^/]+)\/PLAN\.md$/.exec(path);
|
|
198
|
+
if (!match?.[1]) return null;
|
|
199
|
+
const relativeDirectory = match[1];
|
|
200
|
+
const candidates = new Set<string>();
|
|
201
|
+
const direct = `docs/specs/${relativeDirectory}/SPEC.md`;
|
|
202
|
+
if (pathExists(resolve(context.repoRoot, direct))) candidates.add(direct);
|
|
203
|
+
if (legacyProof(context.repoRoot, path)) {
|
|
204
|
+
const topic = legacyTopicFromDir(relativeDirectory);
|
|
205
|
+
const slug = `docs/specs/${topic}/SPEC.md`;
|
|
206
|
+
if (file(resolve(context.repoRoot, slug))) candidates.add(slug);
|
|
207
|
+
const id = extractSpecId(path);
|
|
208
|
+
const root = resolve(context.repoRoot, "docs/specs");
|
|
209
|
+
if (directory(root))
|
|
210
|
+
for (const entry of readdirSync(root, { withFileTypes: true }))
|
|
211
|
+
if (
|
|
212
|
+
entry.isDirectory() &&
|
|
213
|
+
(entry.name.startsWith(`${id}-`) || entry.name.startsWith(`${id}:`))
|
|
214
|
+
) {
|
|
215
|
+
const candidate = `docs/specs/${entry.name}/SPEC.md`;
|
|
216
|
+
if (file(resolve(context.repoRoot, candidate)))
|
|
217
|
+
candidates.add(candidate);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (candidates.size === 1) return [...candidates][0] as string;
|
|
221
|
+
return candidates.size > 1 ? "ambiguous" : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resolveLegacySpec(
|
|
225
|
+
context: DriftPathContext,
|
|
226
|
+
path: string,
|
|
227
|
+
): string | null | "ambiguous" {
|
|
228
|
+
if (!/^docs\/specs\/[^/]+\/SPEC\.md$/.test(path)) return null;
|
|
229
|
+
const directoryName = basename(dirname(path));
|
|
230
|
+
if (
|
|
231
|
+
pathExists(resolve(context.repoRoot, path)) &&
|
|
232
|
+
isLegacySpecDirName(directoryName) &&
|
|
233
|
+
legacyProof(context.repoRoot, path)
|
|
234
|
+
) {
|
|
235
|
+
const slug = `docs/specs/${legacyTopicFromDir(directoryName)}/SPEC.md`;
|
|
236
|
+
if (file(resolve(context.repoRoot, slug)) && slug !== path) return slug;
|
|
237
|
+
}
|
|
238
|
+
if (pathExists(resolve(context.repoRoot, path))) return path;
|
|
239
|
+
const moved = movedTarget(context, path);
|
|
240
|
+
if (moved) return moved;
|
|
241
|
+
if (moved === "ambiguous") return "ambiguous";
|
|
242
|
+
if (!legacyProof(context.repoRoot, path)) return path;
|
|
243
|
+
const id = extractSpecId(path);
|
|
244
|
+
const topic = legacyTopicFromDir(directoryName);
|
|
245
|
+
const candidates = [`docs/specs/${topic}/SPEC.md`];
|
|
246
|
+
const root = resolve(context.repoRoot, "docs/specs");
|
|
247
|
+
if (directory(root))
|
|
248
|
+
for (const entry of readdirSync(root, { withFileTypes: true }))
|
|
249
|
+
if (
|
|
250
|
+
entry.isDirectory() &&
|
|
251
|
+
(entry.name.startsWith(`${id}-`) || entry.name.startsWith(`${id}:`))
|
|
252
|
+
)
|
|
253
|
+
candidates.push(`docs/specs/${entry.name}/SPEC.md`);
|
|
254
|
+
const existing = [
|
|
255
|
+
...new Set(
|
|
256
|
+
candidates.filter((candidate) =>
|
|
257
|
+
file(resolve(context.repoRoot, candidate)),
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
];
|
|
261
|
+
return existing.length === 1
|
|
262
|
+
? (existing[0] as string)
|
|
263
|
+
: existing.length > 1
|
|
264
|
+
? "ambiguous"
|
|
265
|
+
: path;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function resolveAssociatedSpecPath(
|
|
269
|
+
context: DriftPathContext,
|
|
270
|
+
path: string,
|
|
271
|
+
): string | null | "ambiguous" {
|
|
272
|
+
if (/^(?:docs\/plan|docs\/specs)\/[^/]+\/PLAN\.md$/.test(path))
|
|
273
|
+
return resolveLegacyPlan(context, path) ?? path;
|
|
274
|
+
if (/^docs\/specs\/[^/]+\/SPEC\.md$/.test(path))
|
|
275
|
+
return resolveLegacySpec(context, path) ?? path;
|
|
276
|
+
if (legacyProof(context.repoRoot, path)) {
|
|
277
|
+
const id = extractSpecId(path);
|
|
278
|
+
const root = resolve(context.repoRoot, "docs/specs");
|
|
279
|
+
if (directory(root))
|
|
280
|
+
for (const entry of readdirSync(root, { withFileTypes: true }))
|
|
281
|
+
if (
|
|
282
|
+
entry.isDirectory() &&
|
|
283
|
+
(entry.name.startsWith(`${id}-`) || entry.name.startsWith(`${id}:`))
|
|
284
|
+
)
|
|
285
|
+
return `docs/specs/${entry.name}/SPEC.md`;
|
|
286
|
+
}
|
|
287
|
+
return path;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function compareRefHas(context: DriftPathContext, path: string): boolean {
|
|
291
|
+
return gitRefExists(context.repoRoot, `${context.baseRef}:${path}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function grandfatheredLegacyRename(
|
|
295
|
+
context: DriftPathContext,
|
|
296
|
+
newPath: string,
|
|
297
|
+
): boolean {
|
|
298
|
+
const oldPaths = context.renamePairs
|
|
299
|
+
.filter((pair) => pair.newPath === newPath)
|
|
300
|
+
.map((pair) => pair.oldPath)
|
|
301
|
+
.concat(context.deletedFiles);
|
|
302
|
+
return oldPaths.some(
|
|
303
|
+
(oldPath) =>
|
|
304
|
+
oldPath !== newPath &&
|
|
305
|
+
renameMatches(context, oldPath, newPath) &&
|
|
306
|
+
compareRefHas(context, oldPath) &&
|
|
307
|
+
!compareRefHas(context, `${dirname(oldPath)}/IMPLEMENTATION.md`) &&
|
|
308
|
+
!compareRefHas(context, `${dirname(oldPath)}/HISTORY.md`),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function requiredSpecFiles(
|
|
313
|
+
context: DriftPathContext,
|
|
314
|
+
specPath: string,
|
|
315
|
+
): string[] {
|
|
316
|
+
if (/^(?:docs\/plan|docs\/specs)\/[^/]+\/PLAN\.md$/.test(specPath)) {
|
|
317
|
+
if (compareRefHas(context, specPath)) return [specPath];
|
|
318
|
+
const topic = legacyTopicFromDir(basename(dirname(specPath)));
|
|
319
|
+
const canonicalRoot = `docs/specs/${topic}`;
|
|
320
|
+
return [
|
|
321
|
+
`${canonicalRoot}/SPEC.md`,
|
|
322
|
+
`${canonicalRoot}/IMPLEMENTATION.md`,
|
|
323
|
+
`${canonicalRoot}/HISTORY.md`,
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
const root = dirname(specPath);
|
|
327
|
+
const implementation = `${root}/IMPLEMENTATION.md`;
|
|
328
|
+
const history = `${root}/HISTORY.md`;
|
|
329
|
+
const isNew =
|
|
330
|
+
!compareRefHas(context, specPath) &&
|
|
331
|
+
!grandfatheredLegacyRename(context, specPath);
|
|
332
|
+
const needsCompanions =
|
|
333
|
+
isNew ||
|
|
334
|
+
compareRefHas(context, implementation) ||
|
|
335
|
+
compareRefHas(context, history) ||
|
|
336
|
+
pathExists(resolve(context.repoRoot, implementation)) ||
|
|
337
|
+
pathExists(resolve(context.repoRoot, history)) ||
|
|
338
|
+
context.deletedFiles.includes(implementation) ||
|
|
339
|
+
context.deletedFiles.includes(history);
|
|
340
|
+
return [
|
|
341
|
+
`${root}/SPEC.md`,
|
|
342
|
+
...(needsCompanions ? [implementation, history] : []),
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function sourceRequiresRelatedAdrs(
|
|
347
|
+
context: DriftPathContext,
|
|
348
|
+
source: string,
|
|
349
|
+
target: string,
|
|
350
|
+
): boolean {
|
|
351
|
+
if (
|
|
352
|
+
!/^docs\/specs\/[^/]+\/SPEC\.md$/.test(target) ||
|
|
353
|
+
source !== target ||
|
|
354
|
+
!context.changedFiles.includes(target)
|
|
355
|
+
)
|
|
356
|
+
return false;
|
|
357
|
+
if (
|
|
358
|
+
legacyProof(context.repoRoot, target) ||
|
|
359
|
+
grandfatheredLegacyRename(context, target)
|
|
360
|
+
)
|
|
361
|
+
return false;
|
|
362
|
+
return !context.renamePairs.some(
|
|
363
|
+
(pair) =>
|
|
364
|
+
pair.newPath === target && renameMatches(context, pair.oldPath, target),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function specAllowsLegacyRelatedAdrsNone(
|
|
369
|
+
context: DriftPathContext,
|
|
370
|
+
spec: string,
|
|
371
|
+
): boolean {
|
|
372
|
+
return (
|
|
373
|
+
legacyProof(context.repoRoot, spec) ||
|
|
374
|
+
grandfatheredLegacyRename(context, spec)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface DriftPathContext {
|
|
2
|
+
repoRoot: string;
|
|
3
|
+
baseRef: string;
|
|
4
|
+
changedFiles: string[];
|
|
5
|
+
deletedFiles: string[];
|
|
6
|
+
renamePairs: RenamePair[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RenamePair {
|
|
10
|
+
oldPath: string;
|
|
11
|
+
newPath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RelatedAdrResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
paths: string[];
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DriftResult {
|
|
21
|
+
exitCode: 0 | 2 | 3;
|
|
22
|
+
status: "通过" | "阻断" | "未检查";
|
|
23
|
+
drift: "不存在" | "存在" | "未检查";
|
|
24
|
+
evidence: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
stdout: string;
|
|
27
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { gitFileText, gitRefExists, gitText } from "./git";
|
|
4
|
+
import { isTopicSlug } from "./migration_common";
|
|
5
|
+
import { file, pathExists } from "./spec_drift_legacy";
|
|
6
|
+
import type { DriftPathContext, RelatedAdrResult } from "./spec_drift_types";
|
|
7
|
+
|
|
8
|
+
export function relatedAdrPaths(
|
|
9
|
+
text: string,
|
|
10
|
+
allowLegacyNone: boolean,
|
|
11
|
+
requireSection: boolean,
|
|
12
|
+
): RelatedAdrResult {
|
|
13
|
+
let inSection = false;
|
|
14
|
+
let headingCount = 0;
|
|
15
|
+
let noneCount = 0;
|
|
16
|
+
let sawLink = false;
|
|
17
|
+
let previous = "";
|
|
18
|
+
const paths: string[] = [];
|
|
19
|
+
for (const line of text.split(/\r?\n/)) {
|
|
20
|
+
if (/^##\s+/.test(line)) {
|
|
21
|
+
if (line === "## Related ADRs" || /^## Related ADRs\s+$/.test(line)) {
|
|
22
|
+
headingCount += 1;
|
|
23
|
+
inSection = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (inSection) inSection = false;
|
|
27
|
+
}
|
|
28
|
+
if (!inSection || !line.trim()) continue;
|
|
29
|
+
if (line === "None" || (allowLegacyNone && line === "- None")) {
|
|
30
|
+
noneCount += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const match =
|
|
34
|
+
/^-\s+\[[^\]]+\]\(\.\.\/\.\.\/(adr\/[0-9]{4}-[a-z0-9]+(?:-[a-z0-9]+)*\.md)\)$/.exec(
|
|
35
|
+
line,
|
|
36
|
+
);
|
|
37
|
+
if (!match?.[1])
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
paths,
|
|
41
|
+
error: "entries must be None or canonical ADR links",
|
|
42
|
+
};
|
|
43
|
+
const path = `docs/${match[1]}`;
|
|
44
|
+
if (previous && path <= previous)
|
|
45
|
+
return { ok: false, paths, error: "ADR paths must be unique and sorted" };
|
|
46
|
+
previous = path;
|
|
47
|
+
paths.push(path);
|
|
48
|
+
sawLink = true;
|
|
49
|
+
}
|
|
50
|
+
if (headingCount === 0)
|
|
51
|
+
return requireSection
|
|
52
|
+
? { ok: false, paths, error: "slug-only Specs require the section" }
|
|
53
|
+
: { ok: true, paths };
|
|
54
|
+
if (headingCount !== 1)
|
|
55
|
+
return { ok: false, paths, error: "section must appear exactly once" };
|
|
56
|
+
if ((noneCount > 0 && sawLink) || (noneCount !== 1 && !sawLink))
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
paths,
|
|
60
|
+
error: "use exactly None or one or more ADR links",
|
|
61
|
+
};
|
|
62
|
+
return { ok: true, paths };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function adrStatus(text: string): string {
|
|
66
|
+
const lines = text.split(/\r?\n/);
|
|
67
|
+
if (lines[0] !== "---") return "";
|
|
68
|
+
for (const line of lines.slice(1)) {
|
|
69
|
+
if (line === "---") break;
|
|
70
|
+
const match = /^status:\s*(.*?)\s*$/.exec(line);
|
|
71
|
+
if (match?.[1]) return match[1];
|
|
72
|
+
}
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateRelatedAdrs(
|
|
77
|
+
context: DriftPathContext,
|
|
78
|
+
spec: string,
|
|
79
|
+
requireSection: boolean,
|
|
80
|
+
allowLegacyNone: boolean,
|
|
81
|
+
): RelatedAdrResult {
|
|
82
|
+
if (!spec.startsWith("docs/specs/") || !spec.endsWith("/SPEC.md"))
|
|
83
|
+
return { ok: true, paths: [] };
|
|
84
|
+
const current = resolve(context.repoRoot, spec);
|
|
85
|
+
if (pathExists(current)) {
|
|
86
|
+
if (!file(current))
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
paths: [],
|
|
90
|
+
error: `candidate Spec must be a regular file`,
|
|
91
|
+
};
|
|
92
|
+
const parsed = relatedAdrPaths(
|
|
93
|
+
readFileSync(current, "utf8"),
|
|
94
|
+
allowLegacyNone,
|
|
95
|
+
requireSection,
|
|
96
|
+
);
|
|
97
|
+
if (!parsed.ok)
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
paths: parsed.paths,
|
|
101
|
+
error: `entries in candidate ${spec}: ${parsed.error}`,
|
|
102
|
+
};
|
|
103
|
+
const targetError = validateAdrTargets(
|
|
104
|
+
context,
|
|
105
|
+
parsed.paths,
|
|
106
|
+
"candidate",
|
|
107
|
+
false,
|
|
108
|
+
);
|
|
109
|
+
if (targetError)
|
|
110
|
+
return { ok: false, paths: parsed.paths, error: targetError };
|
|
111
|
+
}
|
|
112
|
+
const indexEntry =
|
|
113
|
+
gitText(context.repoRoot, ["ls-files", "-s", "--", spec]) ?? "";
|
|
114
|
+
if (indexEntry) {
|
|
115
|
+
if (!/^(?:100644|100755)\s+[0-9a-f]{40}\s+0\s/.test(indexEntry))
|
|
116
|
+
return { ok: false, paths: [], error: `Spec must be a regular file` };
|
|
117
|
+
const staged = gitFileText(context.repoRoot, ":", spec);
|
|
118
|
+
if (staged === null)
|
|
119
|
+
return { ok: false, paths: [], error: "cannot read staged Spec" };
|
|
120
|
+
const parsed = relatedAdrPaths(staged, allowLegacyNone, requireSection);
|
|
121
|
+
if (!parsed.ok)
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
paths: parsed.paths,
|
|
125
|
+
error: `entries in index ${spec}: ${parsed.error}`,
|
|
126
|
+
};
|
|
127
|
+
const targetError = validateAdrTargets(
|
|
128
|
+
context,
|
|
129
|
+
parsed.paths,
|
|
130
|
+
"index",
|
|
131
|
+
true,
|
|
132
|
+
);
|
|
133
|
+
if (targetError)
|
|
134
|
+
return { ok: false, paths: parsed.paths, error: targetError };
|
|
135
|
+
return { ok: true, paths: parsed.paths };
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
pathExists(current) &&
|
|
139
|
+
gitRefExists(context.repoRoot, `${context.baseRef}:${spec}`)
|
|
140
|
+
)
|
|
141
|
+
return { ok: false, paths: [], error: "staged candidate removed Spec" };
|
|
142
|
+
return { ok: true, paths: [] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateAdrTargets(
|
|
146
|
+
context: DriftPathContext,
|
|
147
|
+
paths: string[],
|
|
148
|
+
label: string,
|
|
149
|
+
index: boolean,
|
|
150
|
+
): string | null {
|
|
151
|
+
for (const path of paths) {
|
|
152
|
+
let text: string | null;
|
|
153
|
+
if (index) {
|
|
154
|
+
const entry =
|
|
155
|
+
gitText(context.repoRoot, ["ls-files", "-s", "--", path]) ?? "";
|
|
156
|
+
if (!/^(?:100644|100755)\s+[0-9a-f]{40}\s+0\s/.test(entry))
|
|
157
|
+
return `missing ADR target ${path} (not a regular index file)`;
|
|
158
|
+
text = gitFileText(context.repoRoot, ":", path);
|
|
159
|
+
if (text === null) return `cannot read staged ADR target ${path}`;
|
|
160
|
+
} else {
|
|
161
|
+
const candidate = resolve(context.repoRoot, path);
|
|
162
|
+
if (!file(candidate))
|
|
163
|
+
return `missing ADR target ${path} (not a regular file)`;
|
|
164
|
+
text = readFileSync(candidate, "utf8");
|
|
165
|
+
}
|
|
166
|
+
const status = adrStatus(text);
|
|
167
|
+
if (status === "superseded" || status === "deprecated")
|
|
168
|
+
return `ADR ${path} is ${status}`;
|
|
169
|
+
if (status && status !== "accepted")
|
|
170
|
+
return `ADR ${path} has non-accepted status ${status}`;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isImplementationPath(path: string): boolean {
|
|
176
|
+
if (
|
|
177
|
+
path.startsWith("docs/") ||
|
|
178
|
+
path.startsWith("README") ||
|
|
179
|
+
path.startsWith(".github/")
|
|
180
|
+
)
|
|
181
|
+
return false;
|
|
182
|
+
const base = basename(path);
|
|
183
|
+
if ([".DS_Store"].includes(base) || /\.(log|tmp|swp|bak)$/.test(base))
|
|
184
|
+
return false;
|
|
185
|
+
if (
|
|
186
|
+
/^(src|app|web\/src|server|backend|frontend|cmd|pkg|internal|services|modules|crates|libs)\//.test(
|
|
187
|
+
path,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return true;
|
|
191
|
+
if (
|
|
192
|
+
/^(Dockerfile|Containerfile|Makefile|GNUmakefile|Procfile|Gemfile|Rakefile|Vagrantfile|Jenkinsfile)$/.test(
|
|
193
|
+
base,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return true;
|
|
197
|
+
if (
|
|
198
|
+
/\.(c|cc|cpp|h|hpp|rs|go|py|ts|tsx|js|jsx|mjs|cjs|java|kt|swift|rb|php|cs|sh|bash|zsh|toml|json|yaml|yml)$/.test(
|
|
199
|
+
path,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return true;
|
|
203
|
+
return !base.includes(".");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function parseNameStatus(
|
|
207
|
+
line: string,
|
|
208
|
+
): { status: string; path1: string; path2?: string } | null {
|
|
209
|
+
const tab = line.indexOf("\t");
|
|
210
|
+
if (!line || tab < 0) return null;
|
|
211
|
+
const status = line.slice(0, tab);
|
|
212
|
+
const rest = line.slice(tab + 1);
|
|
213
|
+
if (/^[RC]/.test(status)) {
|
|
214
|
+
const split = rest.indexOf("\t");
|
|
215
|
+
if (split < 0) return null;
|
|
216
|
+
return {
|
|
217
|
+
status,
|
|
218
|
+
path1: rest.slice(0, split),
|
|
219
|
+
path2: rest.slice(split + 1),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { status, path1: rest };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function pathDeleted(context: DriftPathContext, path: string): boolean {
|
|
226
|
+
return context.deletedFiles.includes(path);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function currentHash(context: DriftPathContext, path: string): string {
|
|
230
|
+
return (
|
|
231
|
+
gitText(context.repoRoot, ["hash-object", path])?.trim() ||
|
|
232
|
+
gitText(context.repoRoot, [
|
|
233
|
+
"rev-parse",
|
|
234
|
+
"--verify",
|
|
235
|
+
`HEAD:${path}`,
|
|
236
|
+
])?.trim() ||
|
|
237
|
+
"unknown"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function frozenAdrMembership(
|
|
242
|
+
context: DriftPathContext,
|
|
243
|
+
spec: string,
|
|
244
|
+
): string[] {
|
|
245
|
+
const text = gitFileText(context.repoRoot, ":", spec);
|
|
246
|
+
return text ? relatedAdrPaths(text, false, false).paths : [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function slugOnly(value: string): boolean {
|
|
250
|
+
return isTopicSlug(value);
|
|
251
|
+
}
|