kld-sdd 2.7.8-2 → 2.7.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,26 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- /**
5
- * consistency-check-core.cjs
6
- *
7
- * pre-commit / pre-push 一致性校验 hook 的共享核心(非独立插件,不在 hooks.config 中登记)。
8
- * 两个入口脚本通过 require 引用本模块,注入各自差异配置:
9
- * - diff 采集方式(pre-commit: git diff HEAD;pre-push: 待推送范围 @{push}..HEAD)
10
- * - 是否检查置信度(pre-commit 检查;pre-push 轻量跳过)
11
- * - strict 模式(仅 pre-commit 支持 SDD_STRICT_CONSISTENCY)
12
- * - 过期比对来源(pre-commit: 工作区内容;pre-push: HEAD 已提交内容,staleFromHead)
13
- * - 提示文案(提交 / 推送)
14
- *
15
- * 核心行为(简化版):
16
- * - diff 驱动:仅检查 diff 文件与报告 coveredFiles 有交集的 change,无关 change 跳过
17
- * - 多仓归属过滤:报告 repositories 已声明覆盖仓库且不含当前仓的 change 静默跳过(不输出、不拦截)
18
- * - 纯注释/空行/空格变更过滤:diff 中所有变更行均为注释/空行/空格的文件直接跳过,不参与校验
19
- * - 报告存在性:活跃 change 无报告 → 阻止
20
- * - 报告过期:diff 业务代码与 coveredFiles 交集文件内容变化(size 短路 + SHA-256)→ 阻止
21
- * - 置信度门禁(仅 pre-commit):low → 阻止;strict 时 medium 也阻止
22
- * - 无活跃 change 但 diff 有业务代码 → 阻止(提示先执行 opsx-propose)
23
- * - 变更范围采集失败 → 阻止(避免静默放行的 fail-open)
4
+ /** Shared collection utilities and the optional strict index entry.
5
+ * Delivery readiness uses delivery-check.cjs receipts with complete input fingerprints.
6
+ * Legacy JSON parsing helpers remain available for readers; old confidence-only reports
7
+ * cannot satisfy the strict entry. Daily commit/push do not call this module by default.
24
8
  */
25
9
 
26
10
  const fs = require('fs');
@@ -105,7 +89,7 @@ function parseReport(reportPath) {
105
89
  : raw;
106
90
  const t = new Date(normalized.replace(/-/g, '/')).getTime();
107
91
  if (!isNaN(t)) {
108
- generatedAtMs = t + 1000;
92
+ generatedAtMs = t;
109
93
  } else {
110
94
  generatedAtFormatError = `generatedAt 无法解析(实际: ${raw},应为 YYYY-MM-DD HH:mm:ss 本地时间)`;
111
95
  }
@@ -417,8 +401,7 @@ function getModifiedFiles(projectRoot, diffFn, options = {}) {
417
401
  /**
418
402
  * 解析 INI 风格的 [consistency] 段,返回段内所有 key=value。
419
403
  */
420
- function parseConsistencySection() {
421
- const configPath = path.join(__dirname, 'hooks.config');
404
+ function parseConsistencySection(configPath = path.join(__dirname, 'hooks.config')) {
422
405
  const values = {};
423
406
  try {
424
407
  const content = fs.readFileSync(configPath, 'utf8');
@@ -448,8 +431,8 @@ const CONSISTENCY_CONFIG = parseConsistencySection();
448
431
  * 业务代码扩展名白名单(business_code_exts,唯一来源为 hooks.config,无内置默认值)。
449
432
  * @returns {{exts: Set<string>, source: 'config'|'missing'|'empty', configPath: string|null}}
450
433
  */
451
- function loadBusinessCodeExts() {
452
- const { configPath, values } = CONSISTENCY_CONFIG;
434
+ function loadBusinessCodeExts(file) {
435
+ const { configPath, values } = file ? parseConsistencySection(file) : CONSISTENCY_CONFIG;
453
436
  const rawValue = Object.prototype.hasOwnProperty.call(values, 'business_code_exts')
454
437
  ? values.business_code_exts : null;
455
438
 
@@ -490,14 +473,14 @@ const CONSISTENCY_MODE = loadConsistencyMode();
490
473
  const TOOLCHAIN_CONFIG_RE = /^(?:vite|vitest|next|nuxt|astro|svelte|remix|webpack|rollup|babel|jest|playwright|cypress|eslint|prettier|tailwind|postcss|karma|wrangler|nest-cli|angular|vue|craco|rspack|metro|tsconfig|jsconfig|turbo|nx)\.(?:config|conf)\.[a-z]+$/i;
491
474
  const DOTFILE_CONFIG_RE = /^\.?(?:eslintrc|babelrc|prettierrc|stylelintrc)\.[a-z]+$/i;
492
475
 
493
- function isBusinessCode(file) {
476
+ function isBusinessCode(file, exts = BUSINESS_CODE_EXTS) {
494
477
  const normalized = String(file).replace(/\\/g, '/');
495
478
  const base = normalized.split('/').pop() || '';
496
479
 
497
480
  const dot = base.lastIndexOf('.');
498
481
  if (dot < 0) return false;
499
482
  const ext = base.slice(dot).toLowerCase();
500
- if (!BUSINESS_CODE_EXTS.has(ext)) return false;
483
+ if (!(exts instanceof Set ? exts : BUSINESS_CODE_EXTS).has(ext)) return false;
501
484
 
502
485
  if (/\.d\.tsx?$/i.test(base)) return false;
503
486
  if (/(^|\/)(dist|build|target|out|node_modules)\//.test(normalized)) return false;
@@ -634,25 +617,12 @@ function normalizeEolLf(buf) {
634
617
  return Buffer.from(str.replace(/\r\n/g, '\n'), 'binary');
635
618
  }
636
619
 
637
- /**
638
- * 单文件过期判断(mtime 快速跳过 + size 短路 + SHA-256,行尾归一化)。
639
- * mtime 快速路径:文件修改时间早于报告生成时间 → 报告生成后从未被碰过 → 内容必然未变,
640
- * 跳过读取和哈希(大变更场景省 100-300ms/百文件)。mtime 晚于报告时间才做精确哈希判断,
641
- * 因此 git pull/checkout 重置 mtime(内容未变)只会多算一次哈希,不会误判过期。
642
- */
620
+ /** 内容哈希是有效性依据,mtime 不参与正确性判断。 */
643
621
  function isFileChangedOnDisk(fp, metas, reportTime, readContent = readWorkingFile, relFile = '') {
644
622
  const list = Array.isArray(metas) ? metas : (metas ? [metas] : []);
645
623
  const hashed = list.filter(m => m && m.sha256);
646
624
 
647
625
  if (hashed.length) {
648
- // mtime 快速跳过:仅在能拿到本地文件时(readContent 为工作区读取)且 mtime 可信时启用
649
- if (readContent === readWorkingFile) {
650
- try {
651
- if (fs.statSync(fp).mtimeMs <= reportTime) return false;
652
- } catch {
653
- return true; // 文件不存在 → 过期
654
- }
655
- }
656
626
  const content = readContent(fp, relFile);
657
627
  if (content === null) return true;
658
628
  const normalized = normalizeEolLf(content);
@@ -663,14 +633,8 @@ function isFileChangedOnDisk(fp, metas, reportTime, readContent = readWorkingFil
663
633
  return !candidates.some(m => hash === String(m.sha256).toLowerCase());
664
634
  }
665
635
 
666
- // 无哈希条目:mtime 比较
667
- let stat;
668
- try {
669
- stat = fs.statSync(fp);
670
- } catch {
671
- return true;
672
- }
673
- return stat.mtimeMs > reportTime;
636
+ // mtime cannot prove content equality. Legacy entries without hashes need review.
637
+ return true;
674
638
  }
675
639
 
676
640
  /**
@@ -773,205 +737,62 @@ function generateInstructions(opts, result, warnOnly) {
773
737
  * @param {object} opts 入口差异配置
774
738
  */
775
739
  function runConsistencyCheck(opts) {
776
- const {
777
- label, actionNoun, diffFn,
778
- checkConfidence = false, includeUntracked = true,
779
- staleFromHead = false,
780
- } = opts;
740
+ const { label, actionNoun, diffFn, includeUntracked = false, staleFromHead = false, staleFromIndex = false } = opts;
781
741
  const projectRoot = path.resolve(readArg('project') || process.env.SDD_PROJECT || process.cwd());
782
-
783
- // 模式解析
784
- const envSkip = process.env.SDD_SKIP_CONSISTENCY_CHECK;
785
- const envStrict = process.env.SDD_STRICT_CONSISTENCY;
786
- const envWarnOnly = process.env.SDD_WARN_ONLY;
787
- const skip = envSkip !== undefined ? envSkip === '1' : CONSISTENCY_MODE.skip;
788
- const strict = envStrict !== undefined ? envStrict === '1' : CONSISTENCY_MODE.strict;
789
- const warnOnly = envWarnOnly !== undefined ? envWarnOnly === '1' : CONSISTENCY_MODE.warnOnly;
790
-
791
- // 互斥检查
792
- const activeModes = [skip, strict, warnOnly].filter(Boolean).length;
793
- if (activeModes > 1) {
794
- const names = [
795
- ...(skip ? ['SDD_SKIP_CONSISTENCY_CHECK' + (envSkip === undefined ? '(hooks.config)' : '')] : []),
796
- ...(strict ? ['SDD_STRICT_CONSISTENCY' + (envStrict === undefined ? '(hooks.config)' : '')] : []),
797
- ...(warnOnly ? ['SDD_WARN_ONLY' + (envWarnOnly === undefined ? '(hooks.config)' : '')] : []),
798
- ];
799
- console.error(`${label} 错误:${names.join('、')} 互斥,只能启用一个`);
800
- process.exit(1);
801
- }
802
-
803
- if (skip) {
804
- console.log(`${label} 跳过校验`);
805
- process.exit(0);
806
- }
807
-
808
- if (warnOnly) console.log(`${label} 仅告警模式`);
809
-
810
- // 配置缺失/空清单显式告警
811
- if (BUSINESS_CODE_EXTS_INFO.source !== 'config') {
812
- const reason = BUSINESS_CODE_EXTS_INFO.source === 'missing'
813
- ? '未配置 business_code_exts(hooks.config 缺失或无 [consistency] 段)'
814
- : 'business_code_exts 为空清单';
815
- console.warn(`${label} ⚠️ ${reason},一致性校验不拦截任何业务代码文件(仅保留报告存在性/过期检查)`);
816
- console.warn(`${label} ℹ️ 请在 ${BUSINESS_CODE_EXTS_INFO.configPath || path.join(__dirname, 'hooks.config')} 的 [consistency] 段配置 business_code_exts`);
817
- }
818
-
819
- const specRoot = resolveSpecRoot();
820
-
821
- // 1. 采集 diff 文件
822
- const { files: modifiedFiles, deleted: deletedFiles, failed: diffFailed } =
823
- getModifiedFiles(projectRoot, diffFn, { includeUntracked });
824
-
825
- if (diffFailed) {
826
- console.log(`${label} ❌ 无法确定本次${actionNoun}的变更范围(git diff 采集失败),拒绝${actionNoun}`);
827
- if (warnOnly) {
828
- console.log(`${label} 仅告警模式,放行${actionNoun}`);
829
- process.exit(0);
830
- }
742
+ const mode = loadConsistencyMode();
743
+ const skip = process.env.SDD_SKIP_CONSISTENCY_CHECK === undefined ? mode.skip : process.env.SDD_SKIP_CONSISTENCY_CHECK === '1';
744
+ const strict = process.env.SDD_STRICT_CONSISTENCY === undefined ? mode.strict : process.env.SDD_STRICT_CONSISTENCY === '1';
745
+ const warnOnly = process.env.SDD_WARN_ONLY === undefined ? mode.warnOnly : process.env.SDD_WARN_ONLY === '1';
746
+ if ([skip, strict, warnOnly].filter(Boolean).length > 1) {
747
+ console.error(`${label} skip / strict / warn_only 互斥`);
831
748
  process.exit(1);
832
749
  }
833
-
834
- // 2. 筛选业务代码文件
835
- const diffBusinessFiles = modifiedFiles.filter(isBusinessCode);
836
- const deletedBusinessFiles = deletedFiles.filter(isBusinessCode);
837
-
838
- // 3. 无业务代码文件 → 放行
839
- if (diffBusinessFiles.length === 0 && deletedBusinessFiles.length === 0) {
750
+ if (skip) { console.log(`${label} 跳过校验`); process.exit(0); }
751
+ const problems = [];
752
+ const { files, deleted, failed } = getModifiedFiles(projectRoot, diffFn, { includeUntracked });
753
+ if (failed) problems.push('无法确定变更范围');
754
+ if (BUSINESS_CODE_EXTS_INFO.source !== 'config') problems.push('business_code_exts 未配置,不能证明检查完整');
755
+ const required = [...new Set([...files, ...deleted])].filter(isBusinessCode);
756
+ if (!problems.length && !required.length) {
840
757
  console.log(`${label} 本次${actionNoun}无业务代码文件,跳过一致性校验`);
841
758
  process.exit(0);
842
759
  }
843
-
844
- // 4. 活跃 change 发现
845
- const changes = discoverActiveChanges(specRoot);
846
- if (!changes.length) {
847
- console.log([
848
- '', '==========================================',
849
- `${label} 存在业务代码变更但无活跃 change 可归属,拒绝${actionNoun}:`,
850
- '',
851
- ' 请先执行 opsx-propose 创建变更(登记到 sdd.config.yaml 的 active_changes),',
852
- ' 再执行 opsx-consistency-check 生成一致性报告。',
853
- '==========================================', '',
854
- ].join('\n'));
855
- if (warnOnly) {
856
- console.log(`${label} 仅告警模式,放行${actionNoun}`);
857
- process.exit(0);
858
- }
859
- process.exit(1);
860
- }
861
-
862
- // 5. 逐 change 解析报告
863
- const parseError = [];
864
- const invalid = [];
865
- const lowConfidence = [];
866
- const stale = [];
867
- const missing = [];
868
- const parsedReports = new Map();
869
-
870
- // 当前代码仓目录名(多仓布局下用于报告归属过滤 + normalizeCoveredFiles 兜底前缀剥离)
871
- const currentRepoName = path.basename(projectRoot).toLowerCase();
872
-
873
- for (const cn of changes) {
874
- const rp = findReportJson(specRoot, cn);
875
- if (!rp) {
876
- missing.push(cn);
877
- continue;
878
- }
879
-
880
- const report = parseReport(rp);
881
- if (report.parseError) {
882
- console.warn(`${label} ⚠️ ${cn} 报告解析失败`);
883
- parseError.push(cn);
884
- continue;
885
- }
886
-
887
- // 旧格式报告(无 coveredFiles)
888
- if (!report.coveredFiles) {
889
- console.log(`${label} ⚠️ ${cn}: 报告为旧格式(缺少 coveredFiles),建议重新生成`);
890
- missing.push(cn);
891
- continue;
892
- }
893
-
894
- // 字段合法性校验(仅 confidence + generatedAt)
895
- const fieldErrors = validateReportFields(report);
896
- if (fieldErrors.length) {
897
- console.warn(`${label} ⚠️ ${cn} 报告字段不合法:${fieldErrors.join(';')}`);
898
- invalid.push({ cn, errors: fieldErrors });
899
- continue;
900
- }
901
-
902
- const covered = normalizeCoveredFiles(report, currentRepoName);
903
- parsedReports.set(cn, { report, covered });
904
- }
905
-
906
- // 6. 对 diff(含删除)与 coveredFiles 有交集的 change 检查报告
907
- const checkFiles = [...new Set([...diffBusinessFiles, ...deletedBusinessFiles])];
908
- const checkFilesLower = checkFiles.map(f => f.toLowerCase());
909
-
910
- let headContentCache = null;
911
- const readContent = staleFromHead
912
- ? (fp, relFile) => {
913
- if (headContentCache === null) {
914
- headContentCache = gitShowHeadContentBatch(projectRoot, checkFiles);
915
- }
916
- const fromHead = headContentCache.get(relFile);
917
- return fromHead !== undefined && fromHead !== null ? fromHead : readWorkingFile(fp);
918
- }
919
- : readWorkingFile;
920
-
921
- for (const [cn, { report, covered }] of parsedReports) {
922
- // 多仓归属过滤:报告显式声明了覆盖仓库且不含当前仓 → 与本仓无关,静默跳过
923
- // (repositories 为空视为未声明,保持原交集判断行为,兼容单仓与旧报告)
924
- const declaredRepos = (report.repositories || [])
925
- .map(r => String(r && r.path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase())
926
- .filter(Boolean);
927
- if (declaredRepos.length && !declaredRepos.includes(currentRepoName)) {
928
- continue;
929
- }
930
-
931
- const hasIntersection = checkFilesLower.some(f => covered.has(f));
932
- if (!hasIntersection) {
933
- console.log(`${label} ⏭️ ${cn}: 与本次${actionNoun}无交集,跳过`);
934
- continue;
935
- }
936
-
937
- if (isReportStale(report, covered, checkFiles, projectRoot, readContent)) {
938
- console.log(`${label} ⚠️ ${cn}: 报告已过期`);
939
- stale.push(cn);
940
- continue;
941
- }
942
-
943
- if (checkConfidence) {
944
- const c = report.confidence;
945
- if (c === 'low' || (c === 'medium' && strict)) {
946
- console.log(`${label} ❌ ${cn}: 置信度 ${c}`);
947
- lowConfidence.push(cn);
760
+ const delivery = require('./delivery-check.cjs');
761
+ const snap = require('./delivery-snapshot.cjs');
762
+ const specRoot = resolveSpecRoot();
763
+ const explicit = String(readArg('change') || process.env.KLD_SDD_CHANGE || '').split(/[,\s]+/).filter(Boolean);
764
+ const changes = explicit.length ? explicit : discoverActiveChanges(specRoot);
765
+ const covered = new Set();
766
+ for (const change of changes) {
767
+ try {
768
+ const identity = snap.repoIdentity(projectRoot);
769
+ const rp = delivery.reviewPath({ specRoot, change, identity });
770
+ if (!fs.existsSync(rp)) {
771
+ // Unrelated active changes do not require a report for this commit.
772
+ if (explicit.includes(change)) problems.push(`${change}: 缺少一致性检查回执,请运行 opsx-consistency-check`);
948
773
  continue;
949
774
  }
950
- console.log(`${label} ✅ ${cn}: 置信度 ${c}`);
951
- } else {
952
- console.log(`${label} ✅ ${cn}: 报告有效`);
953
- }
954
- }
955
-
956
- const result = {
957
- missing, parseError, invalid, lowConfidence, stale,
958
- };
959
-
960
- const hasBlocking = missing.length || parseError.length || invalid.length || lowConfidence.length
961
- || stale.length;
962
-
963
- if (hasBlocking) {
964
- console.log(generateInstructions(opts, result, warnOnly));
965
-
966
- if (warnOnly) {
967
- console.log(`${label} 仅告警模式,放行${actionNoun}`);
968
- process.exit(0);
969
- }
970
-
971
- process.exit(1);
972
- }
973
-
974
- console.log(`\n${label} ✅ 本次${actionNoun}涉及的业务代码均已通过一致性校验,放行${actionNoun}`);
775
+ const report = JSON.parse(fs.readFileSync(rp, 'utf8'));
776
+ if (!explicit.includes(change) && !required.some(f => (report.reviewedFiles || []).includes(f))) continue;
777
+ const ctx = delivery.context({ project: projectRoot, 'spec-root': specRoot, change,
778
+ scope: staleFromHead ? 'commit' : staleFromIndex ? 'index' : 'worktree',
779
+ base: opts.base || report.base, ref: opts.ref || 'HEAD', 'spec-ref': opts.specRef });
780
+ // This report need only cover its own files; union coverage is asserted below.
781
+ ctx.required = required.filter(f => (report.reviewedFiles || []).includes(f));
782
+ const errors = delivery.reviewErrors(ctx, report);
783
+ if (errors.length) problems.push(`${change}: 报告过期或未完成:${errors.join(';')}`);
784
+ else for (const file of ctx.required) covered.add(file);
785
+ } catch (error) { problems.push(`${change}: ${error.message}`); }
786
+ }
787
+ const uncovered = required.filter(file => !covered.has(file));
788
+ if (uncovered.length) problems.push(`缺少有效检查覆盖(UNCOVERED_FILES):${uncovered.join(', ')}`);
789
+ if (problems.length) {
790
+ console.log(`${label} 未通过:\n${problems.map(p => ` - ${p}`).join('\n')}`);
791
+ console.log('请由 opsx-consistency-check 更新回执;日常提交默认不运行此严格入口。');
792
+ if (warnOnly) console.log(`${label} 仅告警模式,放行${actionNoun}(不代表已通过检查)`);
793
+ process.exit(warnOnly ? 0 : 1);
794
+ }
795
+ console.log(`${label} 本次${actionNoun}涉及的业务代码均已通过一致性校验`);
975
796
  process.exit(0);
976
797
  }
977
798
 
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Deterministic evidence checking. This command never performs or invents an AI review.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const snap = require('./delivery-snapshot.cjs');
8
+ const DIMENSIONS = ['interfaceContract', 'businessRules', 'validationRules', 'errorCodes'];
9
+
10
+ function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
11
+ function optionalJson(file) { try { return readJson(file); } catch { return null; } }
12
+
13
+ function context(args) {
14
+ const project = fs.realpathSync(path.resolve(args.project || process.cwd()));
15
+ const specRoot = fs.realpathSync(path.resolve(args['spec-root'] || path.join(__dirname, '..', '..')));
16
+ const projectTop = fs.realpathSync(snap.git(project, ['rev-parse', '--show-toplevel']).trim());
17
+ if (projectTop !== project) throw new Error('PROJECT_ROOT_REQUIRED: --project 请指定代码仓 Git 根');
18
+ const change = args.change;
19
+ if (!change || !/^[a-z0-9][a-z0-9-]+$/.test(change)) throw new Error('CHANGE_REQUIRED: --change 必须明确指定');
20
+ const scope = args.scope || 'worktree';
21
+ const ref = args.ref || 'HEAD';
22
+ const identity = snap.repoIdentity(project);
23
+ const key = snap.hashJson({ identity, change });
24
+ const dir = path.join(specRoot, 'skywalk-sdd/state/delivery', key);
25
+ const prepared = optionalJson(path.join(dir, 'prepared.json'));
26
+ let base = args.base || prepared?.base;
27
+ if (scope === 'commit' && !args.base) throw new Error('BASE_REQUIRED: --scope=commit 必须明确提供 --base');
28
+ if (!base) {
29
+ try { base = snap.git(project, ['rev-parse', '--verify', 'HEAD']).trim(); }
30
+ catch { base = snap.git(project, ['mktree'], { input: '' }).trim(); }
31
+ }
32
+ base = snap.git(project, ['rev-parse', '--verify', '--end-of-options', `${base}^{object}`]).trim();
33
+ const baseType = snap.git(project, ['cat-file', '-t', base]).trim();
34
+ if (!['tree', 'commit'].includes(baseType)) throw new Error('BASE_INVALID');
35
+ // Freeze named refs before reading any files, including a CI target that is not HEAD.
36
+ const resolvedRef = scope === 'commit' ? snap.git(project, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim() : ref;
37
+ const relative = path.relative(project, specRoot);
38
+ let specRepository = `local:${specRoot}`, specTop = '';
39
+ try { specTop = fs.realpathSync(snap.git(specRoot, ['rev-parse', '--show-toplevel']).trim()); specRepository = snap.repoIdentity(specTop); } catch { /* uncommitted standalone Spec folder */ }
40
+ const mono = specTop === project;
41
+ if (!mono && fs.existsSync(path.join(project, '.sdd.yaml'))) {
42
+ const configModule = [path.join(__dirname, '../ontology/sdd-config.cjs'), path.join(__dirname, '../../skywalk-sdd/ontology/sdd-config.cjs')].find(fs.existsSync);
43
+ if (!configModule) throw new Error('SDD_CONFIG_RUNTIME_MISSING');
44
+ const sdd = require(configModule);
45
+ const declared = sdd.loadSddYaml(project);
46
+ if (!declared.ok) throw new Error('SDD_CONFIG_INVALID');
47
+ const actual = snap.git(specRoot, ['remote', 'get-url', 'origin']).trim();
48
+ if (!sdd.remotesMatch(declared.spec_repository, actual)) throw new Error('SPEC_REMOTE_MISMATCH');
49
+ }
50
+ let specOptions = {};
51
+ if (scope !== 'worktree') {
52
+ if (mono) specOptions = { root: project, scope, ref: resolvedRef };
53
+ else {
54
+ if (scope === 'commit' && !args['spec-ref']) throw new Error('SPEC_REF_REQUIRED: 多仓提交校验必须提供 --spec-ref 的完整提交 SHA');
55
+ const specRef = args['spec-ref'] || 'HEAD';
56
+ if (scope === 'commit' && !/^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(specRef)) throw new Error('SPEC_REF_INVALID: 需要完整 SHA');
57
+ specOptions = { root: specRoot, scope: 'commit', ref: specRef };
58
+ }
59
+ }
60
+ const code = snap.codeSnapshot(project, specRoot, scope, resolvedRef);
61
+ const spec = snap.specSnapshot(specRoot, change, specOptions);
62
+ const core = require('./consistency-check-core.cjs');
63
+ const configFile = path.join(specRoot, 'skywalk-sdd/git-hooks/hooks.config');
64
+ const config = core.loadBusinessCodeExts(configFile);
65
+ if (config.source !== 'config') throw new Error('BUSINESS_SCOPE_UNCONFIGURED: hooks.config 缺少业务文件扩展名');
66
+ const inputs = { ruleVersion: snap.RULE_VERSION, ruleConfig: snap.fileMeta('hooks.config', fs.readFileSync(configFile)).sha256, repository: identity, specRepository, change, code, spec };
67
+ const fingerprint = snap.hashJson(inputs);
68
+ const required = snap.changedFiles(project, scope, base, resolvedRef).filter(file => core.isBusinessCode(file, config.exts) && !snap.excluded(file, relative.startsWith('..') ? '' : relative.replace(/\\/g, '/')));
69
+ return { project, specRoot, change, scope, ref: resolvedRef, mono, identity, dir, base, inputs, fingerprint, required };
70
+ }
71
+
72
+ function reviewPath(ctx) {
73
+ return path.join(ctx.specRoot, 'openspec/changes', ctx.change, 'consistency-reviews', `${snap.digest(ctx.identity)}.json`);
74
+ }
75
+
76
+ function reviewErrors(ctx, review) {
77
+ if (!review) return ['REVIEW_MISSING: 缺少代码与 Spec 一致性检查回执'];
78
+ const errors = [];
79
+ if (review.inputFingerprint !== ctx.fingerprint || !review.inputs || snap.hashJson(review.inputs) !== ctx.fingerprint) errors.push('REVIEW_STALE: 代码、Spec、依赖或规则版本已变化');
80
+ const covered = new Set(Array.isArray(review.reviewedFiles) ? review.reviewedFiles : []);
81
+ const uncovered = ctx.required.filter(file => !covered.has(file));
82
+ if (uncovered.length) errors.push(`UNCOVERED_FILES: ${uncovered.join(', ')}`);
83
+ for (const name of DIMENSIONS) {
84
+ const item = review.dimensions?.[name];
85
+ if (!item || !['pass', 'na'].includes(item.status)) errors.push(`REVIEW_INCOMPLETE: ${name}=${item?.status || 'not_checked'}`);
86
+ else if (item.status === 'na' && !String(item.reason || '').trim()) errors.push(`NA_REASON_REQUIRED: ${name}`);
87
+ else if (item.status === 'pass' && (!Array.isArray(item.evidence) || !item.evidence.length || item.evidence.some(e => typeof e !== 'string' || !e.trim()))) errors.push(`EVIDENCE_REQUIRED: ${name}`);
88
+ }
89
+ if (review.issues != null && !Array.isArray(review.issues)) errors.push('REVIEW_ISSUES_INVALID');
90
+ if (Array.isArray(review.issues) && review.issues.length) errors.push('REVIEW_ISSUES: 存在未解决问题');
91
+ return errors;
92
+ }
93
+
94
+ function saveAssociation(ctx, review) {
95
+ const file = path.join(ctx.specRoot, 'skywalk-sdd/state/delivery-associations', `${snap.digest(ctx.identity)}.json`);
96
+ const previous = optionalJson(file) || { repository: ctx.identity, changes: {} };
97
+ const byPath = new Map(ctx.inputs.code.map(f => [f.path, f]));
98
+ previous.changes[ctx.change] = {
99
+ coveredFiles: review.reviewedFiles.map(file => byPath.get(file) || { path: file, deleted: true }),
100
+ };
101
+ snap.atomicJson(file, previous);
102
+ }
103
+
104
+ function prepare(args) {
105
+ const ctx = context(args);
106
+ const old = optionalJson(reviewPath(ctx));
107
+ const errors = reviewErrors(ctx, old);
108
+ const value = {
109
+ base: ctx.base, inputFingerprint: ctx.fingerprint, inputs: ctx.inputs,
110
+ requiredFiles: ctx.required, scope: ctx.scope, ref: ctx.ref,
111
+ };
112
+ const file = path.join(ctx.dir, 'prepared.json');
113
+ snap.atomicJson(file, value);
114
+ return { ...value, preparedFile: file, reviewFile: reviewPath(ctx), reuseReview: errors.length === 0, reviewErrors: errors };
115
+ }
116
+
117
+ function recordReview(args) {
118
+ const ctx = context(args);
119
+ const prepared = readJson(path.join(ctx.dir, 'prepared.json'));
120
+ const input = readJson(args.input);
121
+ if (prepared.inputFingerprint !== ctx.fingerprint || input.inputFingerprint !== ctx.fingerprint) throw new Error('REVIEW_STALE: 取证后输入已改变;重新 prepare 并复核变化');
122
+ if (!Array.isArray(input.reviewedFiles) || input.reviewedFiles.some(f => typeof f !== 'string')) throw new Error('REVIEW_FILES_REQUIRED');
123
+ const allowed = new Set([...ctx.inputs.code.map(f => f.path), ...ctx.required]);
124
+ if (input.reviewedFiles.some(f => !allowed.has(f))) throw new Error('REVIEW_FILE_OUT_OF_SCOPE');
125
+ const review = {
126
+ changeName: ctx.change, generatedAt: new Date().toISOString(),
127
+ origin: 'agent-review', inputFingerprint: ctx.fingerprint, inputs: ctx.inputs,
128
+ reviewedFiles: [...new Set(input.reviewedFiles)], dimensions: input.dimensions,
129
+ issues: input.issues || [], tests: input.tests || null, base: ctx.base,
130
+ };
131
+ // Save incomplete/failed reviews as such; never relabel them as passed.
132
+ snap.atomicJson(reviewPath(ctx), review);
133
+ const errors = reviewErrors(ctx, review);
134
+ if (!errors.length) saveAssociation(ctx, review);
135
+ return { ok: errors.length === 0, status: errors.length ? 'pending' : 'reviewed', errors, reviewFile: reviewPath(ctx) };
136
+ }
137
+
138
+ function testRuntime() {
139
+ for (const candidate of [path.join(__dirname, '../lib/test-execution.cjs'), path.join(__dirname, '../../skywalk-sdd/lib/test-execution.cjs')]) {
140
+ if (fs.existsSync(candidate)) return require(candidate);
141
+ }
142
+ throw new Error('TEST_RUNTIME_MISSING');
143
+ }
144
+
145
+ function runTests(args) {
146
+ if ((args.scope || 'worktree') !== 'worktree') throw new Error('TEST_WORKTREE_REQUIRED: 请在待测提交的独立 checkout/worktree 执行测试');
147
+ const ctx = context(args);
148
+ const command = args['command-file'] ? JSON.stringify(readJson(args['command-file'])) : args['command-json'];
149
+ if (!args['session-id']) throw new Error('TEST_SESSION_REQUIRED: --session-id');
150
+ const logger = [path.join(__dirname, '../log.cjs'), path.join(__dirname, '../../skywalk-sdd/index.cjs')].find(fs.existsSync);
151
+ if (!logger) throw new Error('TEST_LOGGER_MISSING');
152
+ const { spawnSync } = require('child_process');
153
+ const child = spawnSync(process.execPath, [logger, 'test-run', `--project=${ctx.specRoot}`,
154
+ `--change=${ctx.change}`, `--session-id=${args['session-id']}`, `--cwd=${ctx.project}`,
155
+ `--task-ids=${args['task-ids'] || ''}`, `--command-json=${command}`,
156
+ `--timeout-ms=${args['timeout-ms'] || 120000}`], {
157
+ cwd: ctx.project, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
158
+ timeout: Number(args['timeout-ms'] || 120000) + 30000,
159
+ });
160
+ let output;
161
+ try { output = JSON.parse(child.stdout); }
162
+ catch { throw new Error(`TEST_RECORD_FAILED: ${(child.stderr || child.error?.message || '').slice(-2000)}`); }
163
+ if (!output.test_results || !output.event_id) throw new Error('TEST_RECORD_FAILED: 未保存严格测试事件');
164
+ const result = { testResults: output.test_results, receipt: output.receipt };
165
+ const after = context(args);
166
+ const stable = ctx.fingerprint === after.fingerprint;
167
+ const changedInputs = {};
168
+ for (const kind of ['code', 'spec']) {
169
+ const beforeFiles = kind === 'code' ? ctx.inputs.code : ctx.inputs.spec.files;
170
+ const afterFiles = kind === 'code' ? after.inputs.code : after.inputs.spec.files;
171
+ const before = new Map(beforeFiles.map(f => [f.path, snap.hashJson(f)]));
172
+ const next = new Map(afterFiles.map(f => [f.path, snap.hashJson(f)]));
173
+ changedInputs[kind] = [...new Set([...before.keys(), ...next.keys()])].filter(p => before.get(p) !== next.get(p));
174
+ }
175
+ const value = { inputFingerprint: ctx.fingerprint, stable, changedInputs, testResults: result.testResults, receipt: result.receipt };
176
+ snap.atomicJson(path.join(ctx.dir, 'tests.json'), value);
177
+ const ok = stable && result.testResults.exit_code === 0 && result.testResults.failure_type === 'none'
178
+ && result.testResults.counts_known && result.testResults.passed > 0;
179
+ return { ok, status: ok ? 'tested' : 'pending', ...value, errors: stable ? [] : ['INPUT_CHANGED_DURING_TEST'] };
180
+ }
181
+
182
+ function verify(args) {
183
+ const ctx = context(args);
184
+ const review = optionalJson(reviewPath(ctx));
185
+ const errors = reviewErrors(ctx, review);
186
+ const tests = optionalJson(path.join(ctx.dir, 'tests.json'));
187
+ const testsNotApplicable = ctx.required.length === 0 && review?.tests?.status === 'na'
188
+ && typeof review.tests.reason === 'string' && review.tests.reason.trim().length > 0
189
+ && DIMENSIONS.every(name => review.dimensions?.[name]?.status === 'na');
190
+ if (testsNotApplicable) { /* explicitly reviewed as no implementation/contract change */ }
191
+ else if (!tests || tests.inputFingerprint !== ctx.fingerprint || !tests.stable) errors.push('TESTS_MISSING_OR_STALE: 需要对当前输入执行测试');
192
+ else {
193
+ try { testRuntime().verifyCapture(tests.testResults, ctx.specRoot); }
194
+ catch (error) { errors.push(error.message); }
195
+ if (tests.testResults.exit_code !== 0 || tests.testResults.failure_type !== 'none') errors.push('TESTS_FAILED');
196
+ if (!tests.testResults.counts_known || tests.testResults.passed < 1) errors.push('NO_PASSING_TESTS');
197
+ }
198
+ const result = {
199
+ ok: !errors.length, status: errors.length ? 'pending' : 'ready', errors,
200
+ change: ctx.change, repository: ctx.identity, scope: ctx.scope,
201
+ codeRevision: ctx.scope === 'commit' ? ctx.ref : null,
202
+ specRevision: ctx.scope === 'commit' ? (ctx.mono ? ctx.ref : args['spec-ref']) : null,
203
+ inputFingerprint: ctx.fingerprint,
204
+ testStatus: testsNotApplicable ? 'not_applicable' : errors.some(e => /TEST|CAPTURE/.test(e)) ? 'pending' : 'passed',
205
+ testNotApplicableReason: testsNotApplicable ? review.tests.reason : null,
206
+ testReceipt: testsNotApplicable ? null : tests?.receipt || null, reviewFile: reviewPath(ctx),
207
+ };
208
+ snap.atomicJson(path.join(ctx.dir, 'verification.json'), result);
209
+ return result;
210
+ }
211
+
212
+ function main(argv = process.argv.slice(2)) {
213
+ const command = argv.shift();
214
+ const args = {};
215
+ for (const arg of argv) {
216
+ const match = /^--([\w-]+)=(.*)$/s.exec(arg);
217
+ if (!match) throw new Error(`ARG_INVALID: 使用 --参数=值: ${arg}`);
218
+ args[match[1]] = match[2];
219
+ }
220
+ const handlers = { prepare, 'record-review': recordReview, test: runTests, verify };
221
+ if (!handlers[command]) throw new Error('用法: delivery-check.cjs prepare|record-review|test|verify --project=<代码仓> --change=<变更> [--scope=worktree|index|commit]');
222
+ const result = handlers[command](args);
223
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
224
+ if (result.ok === false) process.exitCode = 1;
225
+ }
226
+
227
+ if (require.main === module) {
228
+ try { main(); }
229
+ catch (error) { console.error(`[SDD delivery] ${error.message}`); process.exitCode = 1; }
230
+ }
231
+ module.exports = { context, prepare, recordReview, runTests, verify, reviewPath, reviewErrors, DIMENSIONS };