@captain_z/zsk 1.8.2 → 1.8.3
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/bin.js +4 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/check.js +184 -8
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/gate.d.ts +2 -0
- package/dist/commands/gate.js +2 -0
- package/dist/commands/gate.js.map +1 -1
- package/dist/core/prepare-sync.js +23 -8
- package/dist/core/prepare-sync.js.map +1 -1
- package/dist/core/staffing-plan.d.ts +7 -1
- package/dist/core/staffing-plan.js +144 -21
- package/dist/core/staffing-plan.js.map +1 -1
- package/dist/core/stage-quality.d.ts +8 -0
- package/dist/core/stage-quality.js +380 -20
- package/dist/core/stage-quality.js.map +1 -1
- package/dist/core/template-registry.js +6 -0
- package/dist/core/template-registry.js.map +1 -1
- package/package.json +2 -2
- package/templates/module/frontend-module/design.md +15 -3
- package/templates/module/frontend-module/spec.md +9 -0
- package/templates/module/frontend-module/tasks.md +23 -7
- package/templates/project-init/.zsk/docs/SYSTEM-SPEC.md +2 -0
- package/templates/project-init/.zsk/raws/index.md +3 -0
- package/templates/project-init/.zsk/roles.yaml +9 -9
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
4
5
|
import { getWorkspacePath } from "./workspace-layout.js";
|
|
5
6
|
const DEFAULT_THRESHOLD = 9;
|
|
6
|
-
const MODULE_STAGES = new Set(["proposal", "spec", "design", "task", "coding", "smoke", "review", "ready", "verify", "acceptance", "archive"]);
|
|
7
|
+
const MODULE_STAGES = new Set(["proposal", "spec", "design", "task", "coding", "fix", "smoke", "review", "ready", "verify", "acceptance", "archive"]);
|
|
8
|
+
const SPLITTABLE_MODULE_STAGES = new Set(["proposal", "spec", "design", "tasks"]);
|
|
9
|
+
const SKIPPED_REVIEW_TARGET_DIRS = new Set([
|
|
10
|
+
".auth",
|
|
11
|
+
".cache",
|
|
12
|
+
".git",
|
|
13
|
+
".next",
|
|
14
|
+
".turbo",
|
|
15
|
+
"coverage",
|
|
16
|
+
"dist",
|
|
17
|
+
"node_modules",
|
|
18
|
+
]);
|
|
7
19
|
const STAGE_INPUTS = {
|
|
8
20
|
prepare: [
|
|
9
21
|
{ key: "project-config", path: (config) => getWorkspacePath(config, "projectConfig") },
|
|
10
22
|
{ key: "system-spec", path: (config) => getWorkspacePath(config, "systemSpec") },
|
|
11
23
|
],
|
|
24
|
+
preproposal: [],
|
|
12
25
|
proposal: [
|
|
13
26
|
{ key: "project-config", path: (config) => getWorkspacePath(config, "projectConfig") },
|
|
14
27
|
{ key: "system-spec", path: (config) => getWorkspacePath(config, "systemSpec") },
|
|
@@ -34,6 +47,7 @@ const STAGE_INPUTS = {
|
|
|
34
47
|
coding: [
|
|
35
48
|
{ key: "tasks", moduleStage: "tasks" },
|
|
36
49
|
],
|
|
50
|
+
fix: [],
|
|
37
51
|
smoke: [
|
|
38
52
|
{ key: "tasks", moduleStage: "tasks" },
|
|
39
53
|
],
|
|
@@ -72,7 +86,7 @@ const COMMON_CRITERIA = [
|
|
|
72
86
|
weight: 1,
|
|
73
87
|
required: true,
|
|
74
88
|
test: (ctx) => {
|
|
75
|
-
if (["coding", "smoke", "review", "ready", "verify", "acceptance", "archive"].includes(ctx.stage))
|
|
89
|
+
if (["preproposal", "coding", "fix", "smoke", "review", "ready", "verify", "acceptance", "archive"].includes(ctx.stage))
|
|
76
90
|
return true;
|
|
77
91
|
return hasContent(ctx, "project-config") && hasContent(ctx, "system-spec");
|
|
78
92
|
},
|
|
@@ -110,6 +124,7 @@ const COMMON_CRITERIA = [
|
|
|
110
124
|
label: "Claims trace to source, requirement, acceptance, design, task, issue, or evidence identifiers.",
|
|
111
125
|
weight: 1,
|
|
112
126
|
test: (ctx) => combinedContent(ctx).match(/\b(FR|NFR|AC|SPEC|DES|TASK|ISSUE|EVIDENCE|ADR|REQ)-?\d+\b/i) !== null ||
|
|
127
|
+
(ctx.stage === "preproposal" && ctx.artifacts.length === 0) ||
|
|
113
128
|
/traceability|source|evidence|acceptance|scenario/i.test(combinedContent(ctx)),
|
|
114
129
|
evidence: (ctx) => [`trace terms found: ${/traceability|source|evidence|acceptance|scenario/i.test(combinedContent(ctx)) ? "yes" : "no"}`],
|
|
115
130
|
gap: "Traceability is weak; the next stage may rely on chat memory instead of source-backed IDs or evidence.",
|
|
@@ -118,7 +133,8 @@ const COMMON_CRITERIA = [
|
|
|
118
133
|
id: "GATE-UNCERTAINTY-01",
|
|
119
134
|
label: "Open questions, assumptions, risks, or blockers are explicit instead of silently resolved.",
|
|
120
135
|
weight: 1,
|
|
121
|
-
test: (ctx) =>
|
|
136
|
+
test: (ctx) => (ctx.stage === "preproposal" && ctx.artifacts.length === 0) ||
|
|
137
|
+
/open questions?|assumptions?|risks?|blockers?|constraints?|unknowns?|n\/a|none/i.test(combinedContent(ctx)),
|
|
122
138
|
evidence: (ctx) => [`uncertainty section marker found: ${/open questions?|assumptions?|risks?|blockers?|constraints?|unknowns?/i.test(combinedContent(ctx)) ? "yes" : "n/a or none marker required"}`],
|
|
123
139
|
gap: "Uncertainty handling is not explicit enough for a downstream stage to decide whether to continue or ask.",
|
|
124
140
|
},
|
|
@@ -153,6 +169,16 @@ const STAGE_CRITERIA = {
|
|
|
153
169
|
evidence: () => ["spec NFR/quality marker required"],
|
|
154
170
|
gap: "Spec does not expose quality attributes enough for design tradeoffs.",
|
|
155
171
|
},
|
|
172
|
+
{
|
|
173
|
+
id: "DESIGN-ENTRY-CONTROL-01",
|
|
174
|
+
label: "Permission/auth/privacy/data-access behavior has a behavior-level control matrix before design starts.",
|
|
175
|
+
weight: 2,
|
|
176
|
+
required: true,
|
|
177
|
+
artifactKeys: ["spec"],
|
|
178
|
+
test: (ctx) => !touchesControlConcern(ctx, ["spec"]) || hasControlMatrix(ctx, ["spec"]),
|
|
179
|
+
evidence: (ctx) => [`control concern: ${touchesControlConcern(ctx, ["spec"]) ? "triggered" : "not triggered"}`],
|
|
180
|
+
gap: "Spec touches a control concern but lacks a behavior-level control matrix.",
|
|
181
|
+
},
|
|
156
182
|
],
|
|
157
183
|
task: [
|
|
158
184
|
{
|
|
@@ -166,12 +192,23 @@ const STAGE_CRITERIA = {
|
|
|
166
192
|
},
|
|
167
193
|
{
|
|
168
194
|
id: "TASK-ENTRY-DES-02",
|
|
169
|
-
label: "Design records
|
|
195
|
+
label: "Design records ADR/decision-record entries for task planning.",
|
|
170
196
|
weight: 1,
|
|
197
|
+
required: true,
|
|
171
198
|
artifactKeys: ["design"],
|
|
172
|
-
test: (ctx) => hasPattern(ctx, ["design"], /
|
|
173
|
-
evidence: () => ["design decision
|
|
174
|
-
gap: "Design lacks decision
|
|
199
|
+
test: (ctx) => hasPattern(ctx, ["design"], /ADR-?\d+|Architecture Decision Record|Decision Record|ADR\s*\/|##\s*ADR|决策记录/is),
|
|
200
|
+
evidence: () => ["design ADR/decision-record marker required"],
|
|
201
|
+
gap: "Design lacks ADR/decision-record entries required for task planning.",
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "TASK-ENTRY-CONTROL-01",
|
|
205
|
+
label: "Triggered permission/auth/privacy/data-access behavior has a design control traceability matrix.",
|
|
206
|
+
weight: 2,
|
|
207
|
+
required: true,
|
|
208
|
+
artifactKeys: ["spec", "design"],
|
|
209
|
+
test: (ctx) => !touchesControlConcern(ctx, ["spec", "design"]) || hasControlMatrix(ctx, ["design"]),
|
|
210
|
+
evidence: (ctx) => [`control concern: ${touchesControlConcern(ctx, ["spec", "design"]) ? "triggered" : "not triggered"}`],
|
|
211
|
+
gap: "Spec or design touches a control concern but design lacks a control traceability matrix.",
|
|
175
212
|
},
|
|
176
213
|
],
|
|
177
214
|
coding: [
|
|
@@ -196,6 +233,18 @@ const STAGE_CRITERIA = {
|
|
|
196
233
|
gap: "Tasks do not define how each slice proves completion.",
|
|
197
234
|
},
|
|
198
235
|
],
|
|
236
|
+
fix: [
|
|
237
|
+
{
|
|
238
|
+
id: "FIX-ENTRY-ISSUE-01",
|
|
239
|
+
label: "Issue anchor exposes expected behavior, actual behavior, and evidence.",
|
|
240
|
+
weight: 2,
|
|
241
|
+
required: true,
|
|
242
|
+
artifactKeys: ["issue-record"],
|
|
243
|
+
test: (ctx) => hasPattern(ctx, ["issue-record"], /expected|actual|evidence|repro|steps|root cause|fix route|预期|实际|证据|复现/is),
|
|
244
|
+
evidence: () => ["issue record must expose expected/actual/evidence markers"],
|
|
245
|
+
gap: "Fix should not start without an issue anchor that records expected/actual behavior and evidence.",
|
|
246
|
+
},
|
|
247
|
+
],
|
|
199
248
|
smoke: [
|
|
200
249
|
{
|
|
201
250
|
id: "SMOKE-ENTRY-TEST-01",
|
|
@@ -209,16 +258,39 @@ const STAGE_CRITERIA = {
|
|
|
209
258
|
},
|
|
210
259
|
],
|
|
211
260
|
review: [
|
|
261
|
+
{
|
|
262
|
+
id: "REVIEW-TARGET-01",
|
|
263
|
+
label: "Explicit review target exists and is treated as the review contract when supplied.",
|
|
264
|
+
weight: 2,
|
|
265
|
+
required: true,
|
|
266
|
+
artifactKeys: ["review-target"],
|
|
267
|
+
test: (ctx) => !ctx.reviewTarget || hasContent(ctx, "review-target"),
|
|
268
|
+
evidence: (ctx) => [
|
|
269
|
+
`review target: ${ctx.reviewTarget ?? "module implementation review"}`,
|
|
270
|
+
`review mode: ${ctx.reviewMode ?? "implementation"}`,
|
|
271
|
+
],
|
|
272
|
+
gap: "The explicit review target is missing or has no reviewable content.",
|
|
273
|
+
},
|
|
212
274
|
{
|
|
213
275
|
id: "REVIEW-ENTRY-TEST-01",
|
|
214
276
|
label: "Smoke evidence distinguishes passed, failed, skipped, and unrun checks.",
|
|
215
277
|
weight: 1,
|
|
216
278
|
required: true,
|
|
217
279
|
artifactKeys: ["smoke"],
|
|
218
|
-
test: (ctx) => hasPattern(ctx, ["smoke"], /passed|failed|skipped|unrun|exit status|command|evidence|通过|失败|跳过/is),
|
|
219
|
-
evidence: () => ["smoke pass/fail/skipped/unrun markers required"],
|
|
280
|
+
test: (ctx) => ctx.reviewMode !== "implementation" || hasPattern(ctx, ["smoke"], /passed|failed|skipped|unrun|exit status|command|evidence|通过|失败|跳过/is),
|
|
281
|
+
evidence: (ctx) => [ctx.reviewMode === "implementation" ? "smoke pass/fail/skipped/unrun markers required" : "explicit target review does not require module smoke input"],
|
|
220
282
|
gap: "Review needs precise test result status, not only a generic all-passed claim.",
|
|
221
283
|
},
|
|
284
|
+
{
|
|
285
|
+
id: "REVIEW-PREPROPOSAL-01",
|
|
286
|
+
label: "Preproposal/raw-source reviews check product, roadmap, UX, assumptions, or readiness evidence instead of module spec artifacts.",
|
|
287
|
+
weight: 1,
|
|
288
|
+
artifactKeys: ["review-target"],
|
|
289
|
+
test: (ctx) => !["preproposal", "raw-source"].includes(ctx.reviewMode ?? "implementation") ||
|
|
290
|
+
hasPattern(ctx, ["review-target"], /product|problem|user|persona|journey|roadmap|mvp|phase|ux|flow|assumption|risk|blocker|source|evidence|产品|用户|路线图|阶段|流程|假设|风险|证据/is),
|
|
291
|
+
evidence: (ctx) => [`target-aware raw review: ${["preproposal", "raw-source"].includes(ctx.reviewMode ?? "implementation") ? "required" : "n/a"}`],
|
|
292
|
+
gap: "Raw/preproposal review target lacks product, roadmap, UX, assumption, risk, source, or evidence markers.",
|
|
293
|
+
},
|
|
222
294
|
],
|
|
223
295
|
};
|
|
224
296
|
function unknownStageCriterion(stage) {
|
|
@@ -254,15 +326,17 @@ export async function buildGateAssessment(target, config, opts) {
|
|
|
254
326
|
const stage = safeSlug(opts.stage || "design");
|
|
255
327
|
const threshold = normalizeThreshold(opts.threshold);
|
|
256
328
|
const moduleId = await resolveModuleId(target, config, stage, opts.module);
|
|
329
|
+
const reviewTarget = normalizeReviewTarget(target, opts.reviewTarget);
|
|
330
|
+
const reviewMode = reviewModeFor(reviewTarget);
|
|
257
331
|
const runId = createGateRunId();
|
|
258
|
-
const artifacts = await readGateArtifacts(target, config, stage, moduleId);
|
|
332
|
+
const artifacts = await readGateArtifacts(target, config, stage, moduleId, opts.issue, reviewTarget);
|
|
259
333
|
const contentByKey = new Map();
|
|
260
334
|
for (const artifact of artifacts) {
|
|
261
335
|
if (!artifact.exists)
|
|
262
336
|
continue;
|
|
263
|
-
contentByKey.set(artifact.key, await
|
|
337
|
+
contentByKey.set(artifact.key, await readGateArtifactContent(target, artifact));
|
|
264
338
|
}
|
|
265
|
-
const ctx = { stage, artifacts, contentByKey };
|
|
339
|
+
const ctx = { stage, ...(reviewTarget ? { reviewTarget, reviewMode } : {}), artifacts, contentByKey };
|
|
266
340
|
const specs = [
|
|
267
341
|
...(!STAGE_INPUTS[stage] ? [unknownStageCriterion(stage)] : []),
|
|
268
342
|
...COMMON_CRITERIA,
|
|
@@ -307,6 +381,7 @@ export async function buildGateAssessment(target, config, opts) {
|
|
|
307
381
|
runId,
|
|
308
382
|
stage,
|
|
309
383
|
...(moduleId ? { module: moduleId } : {}),
|
|
384
|
+
...(reviewTarget ? { reviewTarget, reviewMode } : {}),
|
|
310
385
|
threshold,
|
|
311
386
|
score,
|
|
312
387
|
status,
|
|
@@ -350,13 +425,19 @@ async function resolveModuleId(target, config, stage, requested) {
|
|
|
350
425
|
}
|
|
351
426
|
return undefined;
|
|
352
427
|
}
|
|
353
|
-
async function readGateArtifacts(target, config, stage, moduleId) {
|
|
428
|
+
async function readGateArtifacts(target, config, stage, moduleId, issue, reviewTarget) {
|
|
429
|
+
if (stage === "fix")
|
|
430
|
+
return readFixGateArtifacts(target, config, moduleId, issue);
|
|
431
|
+
if (stage === "review" && reviewTarget)
|
|
432
|
+
return [await readPathArtifact(target, "review-target", reviewTarget)];
|
|
354
433
|
const specs = STAGE_INPUTS[stage] ?? [];
|
|
355
434
|
const artifacts = [];
|
|
356
435
|
for (const spec of specs) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
436
|
+
if (spec.moduleStage) {
|
|
437
|
+
artifacts.push(await readModuleStageArtifact(target, config, spec.key, spec.moduleStage, moduleId));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const path = spec.path?.(config, moduleId);
|
|
360
441
|
if (!path) {
|
|
361
442
|
artifacts.push({ key: spec.key, path: "<module-required>", exists: false, bytes: 0 });
|
|
362
443
|
continue;
|
|
@@ -372,11 +453,282 @@ async function readGateArtifacts(target, config, stage, moduleId) {
|
|
|
372
453
|
}
|
|
373
454
|
return artifacts;
|
|
374
455
|
}
|
|
456
|
+
async function readPathArtifact(target, key, path) {
|
|
457
|
+
const boundary = resolve(target);
|
|
458
|
+
const abs = resolve(boundary, path);
|
|
459
|
+
if (!isInsidePath(boundary, abs))
|
|
460
|
+
throw new Error(`review target must stay under project root: ${path}`);
|
|
461
|
+
try {
|
|
462
|
+
const info = await stat(abs);
|
|
463
|
+
if (info.isFile()) {
|
|
464
|
+
return { key, path, exists: true, bytes: Buffer.byteLength(await readFile(abs, "utf8")) };
|
|
465
|
+
}
|
|
466
|
+
if (info.isDirectory()) {
|
|
467
|
+
const content = await readReviewTargetDirectoryContent(abs);
|
|
468
|
+
return { key, path, exists: true, bytes: Buffer.byteLength(content) };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// Missing paths are reported as missing artifacts by the assessment.
|
|
473
|
+
}
|
|
474
|
+
return { key, path, exists: false, bytes: 0 };
|
|
475
|
+
}
|
|
476
|
+
async function readModuleStageArtifact(target, config, key, stage, moduleId) {
|
|
477
|
+
const path = moduleStagePath(config, stage, moduleId);
|
|
478
|
+
if (!path)
|
|
479
|
+
return { key, path: "<module-required>", exists: false, bytes: 0 };
|
|
480
|
+
const abs = resolve(target, path);
|
|
481
|
+
try {
|
|
482
|
+
const info = await stat(abs);
|
|
483
|
+
if (info.isFile()) {
|
|
484
|
+
return { key, path, exists: true, bytes: Buffer.byteLength(await readFile(abs, "utf8")) };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// Fall through to stage-directory lookup for splittable artifacts.
|
|
489
|
+
}
|
|
490
|
+
if (!SPLITTABLE_MODULE_STAGES.has(stage)) {
|
|
491
|
+
return { key, path, exists: false, bytes: 0 };
|
|
492
|
+
}
|
|
493
|
+
const indexPath = moduleStageIndexPath(config, stage, moduleId);
|
|
494
|
+
if (!indexPath)
|
|
495
|
+
return { key, path, exists: false, bytes: 0 };
|
|
496
|
+
const indexAbs = resolve(target, indexPath);
|
|
497
|
+
try {
|
|
498
|
+
const info = await stat(indexAbs);
|
|
499
|
+
if (!info.isFile())
|
|
500
|
+
return { key, path: indexPath, exists: false, bytes: 0 };
|
|
501
|
+
const content = await readStageDirectoryContent(indexAbs);
|
|
502
|
+
return { key, path: indexPath, exists: true, bytes: Buffer.byteLength(content) };
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return { key, path, exists: false, bytes: 0 };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function readFixGateArtifacts(target, config, moduleId, issue) {
|
|
509
|
+
const path = await resolveIssueArtifactPath(target, config, moduleId, issue);
|
|
510
|
+
if (!path)
|
|
511
|
+
return [{ key: "issue-record", path: "<issue-required>", exists: false, bytes: 0 }];
|
|
512
|
+
const abs = resolve(target, path);
|
|
513
|
+
const exists = existsSync(abs);
|
|
514
|
+
return [{
|
|
515
|
+
key: "issue-record",
|
|
516
|
+
path,
|
|
517
|
+
exists,
|
|
518
|
+
bytes: exists ? Buffer.byteLength(await readFile(abs, "utf8")) : 0,
|
|
519
|
+
}];
|
|
520
|
+
}
|
|
521
|
+
async function resolveIssueArtifactPath(target, config, moduleId, issue) {
|
|
522
|
+
const value = issue?.trim();
|
|
523
|
+
if (!value)
|
|
524
|
+
return undefined;
|
|
525
|
+
if (looksLikeIssuePath(value))
|
|
526
|
+
return normalizeIssuePath(target, value);
|
|
527
|
+
const candidates = await collectIssueRecordCandidates(target, config, moduleId);
|
|
528
|
+
return candidates.find((candidate) => issueKey(candidate) === value) ??
|
|
529
|
+
candidates.find((candidate) => issueKey(candidate).startsWith(`${value}-`));
|
|
530
|
+
}
|
|
531
|
+
async function normalizeIssuePath(target, value) {
|
|
532
|
+
const abs = resolve(target, value);
|
|
533
|
+
try {
|
|
534
|
+
const info = await stat(abs);
|
|
535
|
+
if (info.isDirectory())
|
|
536
|
+
return toProjectPath(target, join(abs, "issue.md"));
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// Missing issue paths are returned as-is so the gate can report the missing artifact.
|
|
540
|
+
}
|
|
541
|
+
if (!extname(value))
|
|
542
|
+
return toProjectPath(target, join(abs, "issue.md"));
|
|
543
|
+
return toProjectPath(target, abs);
|
|
544
|
+
}
|
|
545
|
+
async function collectIssueRecordCandidates(target, config, moduleId) {
|
|
546
|
+
const roots = [
|
|
547
|
+
...(moduleId ? [resolve(target, getWorkspacePath(config, "moduleIssues", { module: moduleId }))] : []),
|
|
548
|
+
resolve(target, getWorkspacePath(config, "issuesRoot")),
|
|
549
|
+
resolve(target, getWorkspacePath(config, "issuesRoot"), "global"),
|
|
550
|
+
resolve(target, getWorkspacePath(config, "issuesRoot"), "shared"),
|
|
551
|
+
resolve(target, getWorkspacePath(config, "issuesRoot"), "public"),
|
|
552
|
+
];
|
|
553
|
+
const files = [];
|
|
554
|
+
for (const root of roots)
|
|
555
|
+
files.push(...(await collectIssueFiles(root)));
|
|
556
|
+
return [...new Set(files.map((file) => toProjectPath(target, file)))].sort();
|
|
557
|
+
}
|
|
558
|
+
async function collectIssueFiles(root) {
|
|
559
|
+
try {
|
|
560
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
561
|
+
const files = [];
|
|
562
|
+
for (const entry of entries) {
|
|
563
|
+
const path = join(root, entry.name);
|
|
564
|
+
if (entry.isDirectory())
|
|
565
|
+
files.push(...(await collectIssueFiles(path)));
|
|
566
|
+
if (entry.isFile() && entry.name !== "index.md" && entry.name.endsWith(".md"))
|
|
567
|
+
files.push(path);
|
|
568
|
+
}
|
|
569
|
+
return files;
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function looksLikeIssuePath(value) {
|
|
576
|
+
return value.includes("/") || value.endsWith(".md") || value.startsWith(".");
|
|
577
|
+
}
|
|
578
|
+
function issueKey(path) {
|
|
579
|
+
const name = basename(path);
|
|
580
|
+
return name === "issue.md" ? basename(dirname(path)) : name.replace(/\.md$/i, "");
|
|
581
|
+
}
|
|
582
|
+
function toProjectPath(target, path) {
|
|
583
|
+
return relative(target, path).split("\\").join("/");
|
|
584
|
+
}
|
|
375
585
|
function moduleStagePath(config, stage, moduleId) {
|
|
376
586
|
if (!moduleId)
|
|
377
587
|
return undefined;
|
|
378
588
|
return join(getWorkspacePath(config, "modulesRoot"), moduleId, `${stage}.md`);
|
|
379
589
|
}
|
|
590
|
+
function moduleStageIndexPath(config, stage, moduleId) {
|
|
591
|
+
if (!moduleId)
|
|
592
|
+
return undefined;
|
|
593
|
+
return join(getWorkspacePath(config, "modulesRoot"), moduleId, stage, "index.md");
|
|
594
|
+
}
|
|
595
|
+
async function readGateArtifactContent(target, artifact) {
|
|
596
|
+
const boundary = resolve(target);
|
|
597
|
+
const abs = resolve(boundary, artifact.path);
|
|
598
|
+
if (!isInsidePath(boundary, abs))
|
|
599
|
+
throw new Error(`gate artifact must stay under project root: ${artifact.path}`);
|
|
600
|
+
if (basename(abs) === "index.md" && SPLITTABLE_MODULE_STAGES.has(basename(dirname(abs)))) {
|
|
601
|
+
return readStageDirectoryContent(abs);
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const info = await stat(abs);
|
|
605
|
+
if (info.isDirectory())
|
|
606
|
+
return readReviewTargetDirectoryContent(abs);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// The caller only asks for content after the artifact exists; fall through
|
|
610
|
+
// to the file read so the original error remains visible if the path races.
|
|
611
|
+
}
|
|
612
|
+
return readFile(abs, "utf8");
|
|
613
|
+
}
|
|
614
|
+
async function readReviewTargetDirectoryContent(dir) {
|
|
615
|
+
const files = await collectReviewableFiles(dir, dir);
|
|
616
|
+
const contents = [];
|
|
617
|
+
for (const file of files) {
|
|
618
|
+
try {
|
|
619
|
+
contents.push(await readFile(file, "utf8"));
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// Ignore unreadable children; the target can still be reviewed from the
|
|
623
|
+
// accessible files and the missing child remains a review finding.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return contents.join("\n\n");
|
|
627
|
+
}
|
|
628
|
+
async function collectReviewableFiles(root, boundary) {
|
|
629
|
+
try {
|
|
630
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
631
|
+
const files = [];
|
|
632
|
+
for (const entry of entries) {
|
|
633
|
+
if (shouldSkipReviewTargetEntry(entry.name))
|
|
634
|
+
continue;
|
|
635
|
+
const path = join(root, entry.name);
|
|
636
|
+
if (!isInsidePath(boundary, path))
|
|
637
|
+
continue;
|
|
638
|
+
if (entry.isDirectory()) {
|
|
639
|
+
files.push(...(await collectReviewableFiles(path, boundary)));
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (entry.isFile() && /\.(md|mdx|ya?ml|json|txt)$/i.test(entry.name))
|
|
643
|
+
files.push(path);
|
|
644
|
+
}
|
|
645
|
+
return files.sort();
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function normalizeReviewTarget(target, value) {
|
|
652
|
+
return normalizeReviewTargetPath(target, value);
|
|
653
|
+
}
|
|
654
|
+
export function normalizeReviewTargetPath(target, value) {
|
|
655
|
+
const trimmed = value?.trim();
|
|
656
|
+
if (!trimmed)
|
|
657
|
+
return undefined;
|
|
658
|
+
const boundary = resolve(target);
|
|
659
|
+
const abs = resolve(boundary, trimmed);
|
|
660
|
+
if (isInsidePath(boundary, abs))
|
|
661
|
+
return toProjectPath(boundary, abs) || ".";
|
|
662
|
+
throw new Error(`review target must stay under project root: ${trimmed}`);
|
|
663
|
+
}
|
|
664
|
+
function reviewModeFor(reviewTarget) {
|
|
665
|
+
return reviewModeForReviewTarget(reviewTarget);
|
|
666
|
+
}
|
|
667
|
+
export function reviewModeForReviewTarget(reviewTarget) {
|
|
668
|
+
if (!reviewTarget)
|
|
669
|
+
return undefined;
|
|
670
|
+
const path = reviewTarget.replace(/\\/g, "/");
|
|
671
|
+
if (path === ".zsk" || path.startsWith(".zsk/")) {
|
|
672
|
+
if (path.startsWith(".zsk/raws/prepare/product") ||
|
|
673
|
+
path.startsWith(".zsk/raws/prepare/roadmap") ||
|
|
674
|
+
path.startsWith(".zsk/raws/prepare/ux") ||
|
|
675
|
+
path.startsWith(".zsk/raws/prepare/design") ||
|
|
676
|
+
path.startsWith(".zsk/evidence/preproposal"))
|
|
677
|
+
return "preproposal";
|
|
678
|
+
if (path.startsWith(".zsk/raws/design/") || path.startsWith(".zsk/raws/visual/") || path.includes("/design-source"))
|
|
679
|
+
return "design-source";
|
|
680
|
+
if (path.startsWith(".zsk/raws/"))
|
|
681
|
+
return "raw-source";
|
|
682
|
+
if (/^\.zsk\/modules\/[^/]+\/(?:proposal|spec|design|tasks?)(?:\.md|\/|$)/.test(path))
|
|
683
|
+
return "stage-doc";
|
|
684
|
+
return "zsk-workspace";
|
|
685
|
+
}
|
|
686
|
+
if (/\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx|vue|svelte|css|scss|go|rs|java|kt|swift|py|rb|php|cs|cpp|c|h|hpp)$/i.test(path))
|
|
687
|
+
return "code-target";
|
|
688
|
+
if (/(?:design|figma|prototype|wireframe|mockup|screen|asset)/i.test(path))
|
|
689
|
+
return "design-source";
|
|
690
|
+
return "target-path";
|
|
691
|
+
}
|
|
692
|
+
function shouldSkipReviewTargetEntry(name) {
|
|
693
|
+
if (SKIPPED_REVIEW_TARGET_DIRS.has(name))
|
|
694
|
+
return true;
|
|
695
|
+
return /(?:storage-state|storagestate|cookies?|tokens?|secrets?|credentials?)\.(?:json|ya?ml|txt)$/i.test(name);
|
|
696
|
+
}
|
|
697
|
+
async function readStageDirectoryContent(indexPath) {
|
|
698
|
+
const indexContent = await readFile(indexPath, "utf8");
|
|
699
|
+
const dir = dirname(indexPath);
|
|
700
|
+
const linkedChildren = linkedMarkdownChildren(indexContent, dir);
|
|
701
|
+
const childContents = [];
|
|
702
|
+
for (const child of linkedChildren) {
|
|
703
|
+
try {
|
|
704
|
+
const info = await stat(child);
|
|
705
|
+
if (info.isFile())
|
|
706
|
+
childContents.push(await readFile(child, "utf8"));
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// Missing linked children are handled by zsk check; gate content still
|
|
710
|
+
// relies on the index so the artifact cannot pass from hidden files.
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return [indexContent, ...childContents].join("\n\n");
|
|
714
|
+
}
|
|
715
|
+
function linkedMarkdownChildren(content, dir) {
|
|
716
|
+
const files = new Set();
|
|
717
|
+
for (const match of content.matchAll(/\[[^\]]+\]\(([^)]+?\.md)(?:#[^)]+)?\)/gi)) {
|
|
718
|
+
const raw = match[1]?.trim();
|
|
719
|
+
if (!raw || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("/"))
|
|
720
|
+
continue;
|
|
721
|
+
const resolved = resolve(dir, raw);
|
|
722
|
+
if (!isInsidePath(dir, resolved) || resolved === resolve(dir, "index.md"))
|
|
723
|
+
continue;
|
|
724
|
+
files.add(resolved);
|
|
725
|
+
}
|
|
726
|
+
return [...files].sort();
|
|
727
|
+
}
|
|
728
|
+
function isInsidePath(parent, child) {
|
|
729
|
+
const rel = relative(parent, child);
|
|
730
|
+
return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && !rel.includes("..\\"));
|
|
731
|
+
}
|
|
380
732
|
function hasContent(ctx, key) {
|
|
381
733
|
return (ctx.contentByKey.get(key)?.trim().length ?? 0) > 0;
|
|
382
734
|
}
|
|
@@ -398,6 +750,12 @@ function combinedContent(ctx) {
|
|
|
398
750
|
function hasPattern(ctx, keys, pattern) {
|
|
399
751
|
return keys.some((key) => pattern.test(ctx.contentByKey.get(key) ?? ""));
|
|
400
752
|
}
|
|
753
|
+
function touchesControlConcern(ctx, keys) {
|
|
754
|
+
return hasPattern(ctx, keys, /auth(?:entication|orization)?|permission|access control|role|tenant|privacy|data access|compliance|audit|credential|PII|RBAC|ABAC|权限|认证|授权|角色|租户|隐私|审计|合规|凭证/is);
|
|
755
|
+
}
|
|
756
|
+
function hasControlMatrix(ctx, keys) {
|
|
757
|
+
return hasPattern(ctx, keys, /control (?:traceability )?matrix|control matrix|permission matrix|access control matrix|权限(?:追踪|控制)?矩阵|Source Rule|Subject \/ Actor|Action \/ Capability|Resource \/ Data|Enforcement Point/is);
|
|
758
|
+
}
|
|
401
759
|
function wordCount(content) {
|
|
402
760
|
return content.split(/\s+/).filter(Boolean).length;
|
|
403
761
|
}
|
|
@@ -424,6 +782,8 @@ function renderGateAssessmentMarkdown(assessment) {
|
|
|
424
782
|
"",
|
|
425
783
|
`- Stage: \`${assessment.stage}\``,
|
|
426
784
|
...(assessment.module ? [`- Module: \`${assessment.module}\``] : []),
|
|
785
|
+
...(assessment.reviewTarget ? [`- Review target: \`${assessment.reviewTarget}\``] : []),
|
|
786
|
+
...(assessment.reviewMode ? [`- Review mode: \`${assessment.reviewMode}\``] : []),
|
|
427
787
|
`- Score: ${assessment.score}/10`,
|
|
428
788
|
`- Threshold: ${assessment.threshold}/10`,
|
|
429
789
|
`- Status: \`${assessment.status}\``,
|
|
@@ -478,8 +838,8 @@ function renderWaiverMarkdown(assessment) {
|
|
|
478
838
|
"",
|
|
479
839
|
].join("\n");
|
|
480
840
|
}
|
|
481
|
-
function createGateRunId(now = new Date()) {
|
|
482
|
-
return now.toISOString().replace(/[:.]/g, "-")
|
|
841
|
+
export function createGateRunId(now = new Date()) {
|
|
842
|
+
return `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 12)}`;
|
|
483
843
|
}
|
|
484
844
|
function safeSlug(value) {
|
|
485
845
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|