@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.
@@ -1,14 +1,27 @@
1
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
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) => /open questions?|assumptions?|risks?|blockers?|constraints?|unknowns?|n\/a|none/i.test(combinedContent(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 decisions, tradeoffs, alternatives, or ADR references.",
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"], /trade-?offs?|alternatives?|ADR|decision|rationale|consequences|权衡|替代/is),
173
- evidence: () => ["design decision/tradeoff marker required"],
174
- gap: "Design lacks decision rationale for task planning.",
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 readFile(resolve(target, artifact.path), "utf8"));
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
- const path = spec.moduleStage
358
- ? moduleStagePath(config, spec.moduleStage, moduleId)
359
- : spec.path?.(config, moduleId);
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, "");