@veewo/gitnexus 1.5.0 → 1.5.2

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.
Files changed (137) hide show
  1. package/dist/benchmark/agent-context/runner.js +3 -0
  2. package/dist/benchmark/agent-context/runner.test.js +22 -0
  3. package/dist/benchmark/agent-context/tool-runner.d.ts +7 -6
  4. package/dist/benchmark/agent-safe-query-context/io.d.ts +2 -0
  5. package/dist/benchmark/agent-safe-query-context/io.js +86 -0
  6. package/dist/benchmark/agent-safe-query-context/io.test.d.ts +1 -0
  7. package/dist/benchmark/agent-safe-query-context/io.test.js +13 -0
  8. package/dist/benchmark/agent-safe-query-context/report.d.ts +57 -0
  9. package/dist/benchmark/agent-safe-query-context/report.js +159 -0
  10. package/dist/benchmark/agent-safe-query-context/report.test.d.ts +1 -0
  11. package/dist/benchmark/agent-safe-query-context/report.test.js +362 -0
  12. package/dist/benchmark/agent-safe-query-context/runner.d.ts +44 -0
  13. package/dist/benchmark/agent-safe-query-context/runner.js +406 -0
  14. package/dist/benchmark/agent-safe-query-context/runner.test.d.ts +1 -0
  15. package/dist/benchmark/agent-safe-query-context/runner.test.js +290 -0
  16. package/dist/benchmark/agent-safe-query-context/semantic-tuple.d.ts +20 -0
  17. package/dist/benchmark/agent-safe-query-context/semantic-tuple.js +225 -0
  18. package/dist/benchmark/agent-safe-query-context/semantic-tuple.test.d.ts +1 -0
  19. package/dist/benchmark/agent-safe-query-context/semantic-tuple.test.js +122 -0
  20. package/dist/benchmark/agent-safe-query-context/subagent-live.d.ts +47 -0
  21. package/dist/benchmark/agent-safe-query-context/subagent-live.js +128 -0
  22. package/dist/benchmark/agent-safe-query-context/subagent-live.test.d.ts +1 -0
  23. package/dist/benchmark/agent-safe-query-context/subagent-live.test.js +155 -0
  24. package/dist/benchmark/agent-safe-query-context/telemetry-tool.d.ts +9 -0
  25. package/dist/benchmark/agent-safe-query-context/telemetry-tool.js +77 -0
  26. package/dist/benchmark/agent-safe-query-context/types.d.ts +61 -0
  27. package/dist/benchmark/agent-safe-query-context/types.js +8 -0
  28. package/dist/benchmark/runtime-poc/provenance-artifact.d.ts +47 -0
  29. package/dist/benchmark/runtime-poc/provenance-artifact.js +89 -0
  30. package/dist/benchmark/runtime-poc/runner.d.ts +31 -0
  31. package/dist/benchmark/runtime-poc/runner.js +163 -0
  32. package/dist/benchmark/u2-e2e/hydration-policy-repeatability-runner.d.ts +8 -0
  33. package/dist/benchmark/u2-e2e/hydration-policy-repeatability-runner.js +21 -0
  34. package/dist/benchmark/u2-e2e/phase2-runtime-claim-acceptance-runner.d.ts +0 -1
  35. package/dist/benchmark/u2-e2e/phase2-runtime-claim-acceptance-runner.js +53 -51
  36. package/dist/benchmark/u2-e2e/phase2-runtime-claim-acceptance-runner.test.js +0 -1
  37. package/dist/benchmark/u2-e2e/phase5-rule-lab-acceptance-runner.d.ts +1 -1
  38. package/dist/benchmark/u2-e2e/phase5-rule-lab-acceptance-runner.js +82 -18
  39. package/dist/benchmark/u2-e2e/phase5-rule-lab-acceptance-runner.test.js +1 -2
  40. package/dist/benchmark/u2-e2e/retrieval-runner.js +15 -7
  41. package/dist/benchmark/u2-e2e/retrieval-runner.test.js +46 -0
  42. package/dist/cli/ai-context.js +2 -12
  43. package/dist/cli/ai-context.test.js +8 -0
  44. package/dist/cli/analyze-runtime-summary.js +1 -0
  45. package/dist/cli/analyze-runtime-summary.test.js +2 -0
  46. package/dist/cli/analyze-summary.d.ts +2 -0
  47. package/dist/cli/analyze-summary.js +24 -0
  48. package/dist/cli/analyze-summary.test.js +65 -1
  49. package/dist/cli/analyze.js +5 -1
  50. package/dist/cli/benchmark-agent-safe-query-context.d.ts +20 -0
  51. package/dist/cli/benchmark-agent-safe-query-context.js +39 -0
  52. package/dist/cli/benchmark-agent-safe-query-context.test.d.ts +1 -0
  53. package/dist/cli/benchmark-agent-safe-query-context.test.js +271 -0
  54. package/dist/cli/benchmark.d.ts +29 -0
  55. package/dist/cli/benchmark.js +55 -0
  56. package/dist/cli/index.js +23 -0
  57. package/dist/cli/rule-lab.d.ts +3 -7
  58. package/dist/cli/rule-lab.js +13 -22
  59. package/dist/cli/rule-lab.test.js +23 -3
  60. package/dist/cli/tool.d.ts +2 -0
  61. package/dist/cli/tool.js +2 -0
  62. package/dist/core/config/unity-config.d.ts +0 -1
  63. package/dist/core/config/unity-config.js +0 -1
  64. package/dist/core/ingestion/pipeline.js +35 -6
  65. package/dist/core/ingestion/unity-lifecycle-synthetic-calls.test.js +18 -20
  66. package/dist/core/ingestion/unity-parity-seed.d.ts +2 -1
  67. package/dist/core/ingestion/unity-parity-seed.js +8 -0
  68. package/dist/core/ingestion/unity-resource-processor.d.ts +11 -0
  69. package/dist/core/ingestion/unity-resource-processor.js +102 -0
  70. package/dist/core/ingestion/unity-resource-processor.test.js +449 -0
  71. package/dist/core/ingestion/unity-runtime-binding-rules.d.ts +15 -0
  72. package/dist/core/ingestion/unity-runtime-binding-rules.js +178 -30
  73. package/dist/core/lbug/csv-generator.test.js +2 -2
  74. package/dist/core/unity/doc-contract.test.d.ts +1 -0
  75. package/dist/core/unity/doc-contract.test.js +30 -0
  76. package/dist/core/unity/prefab-source-scan.d.ts +25 -0
  77. package/dist/core/unity/prefab-source-scan.js +152 -0
  78. package/dist/core/unity/prefab-source-scan.test.d.ts +1 -0
  79. package/dist/core/unity/prefab-source-scan.test.js +70 -0
  80. package/dist/core/unity/scan-context.d.ts +12 -0
  81. package/dist/core/unity/scan-context.js +50 -2
  82. package/dist/core/unity/scan-context.test.js +74 -0
  83. package/dist/mcp/local/agent-safe-response.d.ts +10 -0
  84. package/dist/mcp/local/agent-safe-response.js +639 -0
  85. package/dist/mcp/local/derived-process-reader.js +1 -1
  86. package/dist/mcp/local/local-backend.d.ts +18 -1
  87. package/dist/mcp/local/local-backend.js +319 -125
  88. package/dist/mcp/local/process-confidence.d.ts +1 -2
  89. package/dist/mcp/local/process-confidence.js +0 -3
  90. package/dist/mcp/local/process-confidence.test.js +4 -2
  91. package/dist/mcp/local/process-evidence.d.ts +1 -8
  92. package/dist/mcp/local/process-evidence.js +1 -23
  93. package/dist/mcp/local/process-evidence.test.js +2 -16
  94. package/dist/mcp/local/process-ref.d.ts +1 -1
  95. package/dist/mcp/local/runtime-chain-closure-evaluator.d.ts +33 -0
  96. package/dist/mcp/local/runtime-chain-closure-evaluator.js +273 -0
  97. package/dist/mcp/local/runtime-chain-graph-candidates.d.ts +23 -0
  98. package/dist/mcp/local/runtime-chain-graph-candidates.js +131 -0
  99. package/dist/mcp/local/runtime-chain-verify.d.ts +1 -1
  100. package/dist/mcp/local/runtime-chain-verify.js +149 -138
  101. package/dist/mcp/local/runtime-chain-verify.test.js +126 -68
  102. package/dist/mcp/local/runtime-claim-rule-registry.d.ts +4 -0
  103. package/dist/mcp/local/runtime-claim-rule-registry.js +4 -0
  104. package/dist/mcp/local/runtime-claim-rule-registry.test.js +37 -4
  105. package/dist/mcp/local/runtime-claim.d.ts +11 -0
  106. package/dist/mcp/local/runtime-claim.js +28 -0
  107. package/dist/mcp/local/unity-evidence-view.d.ts +1 -1
  108. package/dist/mcp/local/unity-evidence-view.js +1 -1
  109. package/dist/mcp/local/unity-evidence-view.test.js +22 -0
  110. package/dist/mcp/tools.js +51 -21
  111. package/dist/rule-lab/analyze.d.ts +2 -1
  112. package/dist/rule-lab/analyze.js +94 -59
  113. package/dist/rule-lab/analyze.test.js +238 -20
  114. package/dist/rule-lab/curate.d.ts +2 -1
  115. package/dist/rule-lab/curate.js +24 -3
  116. package/dist/rule-lab/curate.test.js +65 -0
  117. package/dist/rule-lab/curation-input-builder.d.ts +45 -0
  118. package/dist/rule-lab/curation-input-builder.js +133 -0
  119. package/dist/rule-lab/promote.js +80 -7
  120. package/dist/rule-lab/promote.test.js +150 -0
  121. package/dist/rule-lab/review-pack.d.ts +3 -0
  122. package/dist/rule-lab/review-pack.js +41 -1
  123. package/dist/rule-lab/review-pack.test.js +67 -0
  124. package/dist/rule-lab/types.d.ts +29 -0
  125. package/dist/types/pipeline.d.ts +3 -0
  126. package/package.json +4 -3
  127. package/scripts/run-node-tests.mjs +61 -0
  128. package/skills/_shared/unity-rule-authoring-contract.md +64 -0
  129. package/skills/_shared/unity-runtime-process-contract.md +16 -0
  130. package/skills/gitnexus-cli.md +8 -0
  131. package/skills/gitnexus-debugging.md +9 -0
  132. package/skills/gitnexus-exploring.md +66 -18
  133. package/skills/gitnexus-guide.md +42 -3
  134. package/skills/gitnexus-impact-analysis.md +8 -0
  135. package/skills/gitnexus-pr-review.md +8 -0
  136. package/skills/gitnexus-refactoring.md +8 -0
  137. package/skills/gitnexus-unity-rule-gen.md +66 -312
@@ -2,27 +2,245 @@ import { describe, expect, it } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
5
  import { analyzeRuleLabSlice } from './analyze.js';
7
- describe('rule-lab analyze', () => {
8
- it('analyze emits multiple topology candidates with coverage/conflict stats', async () => {
9
- const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-'));
10
- const runRoot = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', 'run-x');
11
- const sliceDir = path.join(runRoot, 'slices', 'slice-a');
12
- await fs.mkdir(sliceDir, { recursive: true });
13
- const here = path.dirname(fileURLToPath(import.meta.url));
14
- const fixturePath = path.join(here, '__fixtures__', 'rule-lab-slice-input.json');
15
- const fixtureRaw = await fs.readFile(fixturePath, 'utf-8');
16
- await fs.writeFile(path.join(sliceDir, 'slice.json'), fixtureRaw, 'utf-8');
17
- const result = await analyzeRuleLabSlice({ repoPath: repoRoot, runId: 'run-x', sliceId: 'slice-a' });
18
- expect(result.candidates.length).toBeGreaterThan(1);
19
- expect(result.candidates[0]).toHaveProperty('topology');
20
- expect(result.candidates[0]).toHaveProperty('stats.coverage_rate');
21
- expect(result.candidates[0]).toHaveProperty('stats.conflict_rate');
22
- expect(result.candidates[0]).toHaveProperty('counter_examples');
23
- expect(result.candidates[0].evidence.hops[0].anchor).toMatch(/:\d+$/);
24
- const persisted = await fs.readFile(result.paths.candidatesPath, 'utf-8');
25
- expect(persisted.trim().length).toBeGreaterThan(0);
6
+ import { curateRuleLabSlice } from './curate.js';
7
+ import { promoteCuratedRules } from './promote.js';
8
+ async function writeJson(filePath, value) {
9
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
10
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
11
+ }
12
+ describe('rule-lab analyze (exact pair flow)', () => {
13
+ it('builds proposal candidates and curation input directly from exact_pairs', async () => {
14
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-'));
15
+ const runId = 'run-x';
16
+ const sliceId = 'slice-a';
17
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
18
+ await writeJson(slicePath, {
19
+ id: sliceId,
20
+ trigger_family: 'event_delegate',
21
+ resource_types: ['syncvar_hook'],
22
+ host_base_type: ['network_behaviour'],
23
+ required_hops: ['code_runtime'],
24
+ exact_pairs: [
25
+ {
26
+ id: 'pair-a',
27
+ binding_kind: 'method_triggers_method',
28
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'SourceA.Trigger' },
29
+ target_anchor: { file: 'Assets/Gameplay/TargetA.cs', line: 32, symbol: 'TargetA.OnTrigger' },
30
+ },
31
+ {
32
+ id: 'pair-b',
33
+ binding_kind: 'method_triggers_method',
34
+ source_anchor: { file: 'Assets/Gameplay/SourceB.cs', line: 15, symbol: 'SourceB.Trigger' },
35
+ target_anchor: { file: 'Assets/Gameplay/TargetB.cs', line: 36, symbol: 'TargetB.OnTrigger' },
36
+ },
37
+ ],
38
+ });
39
+ const out = await analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId });
40
+ expect(out.candidates).toHaveLength(2);
41
+ expect(out.candidates.every((candidate) => candidate.proposal_kind === 'per_anchor_rule')).toBe(true);
42
+ expect(out.candidates.every((candidate) => candidate.exact_pair)).toBe(true);
43
+ const curationPath = path.join(path.dirname(out.paths.candidatesPath), 'curation-input.json');
44
+ const curation = JSON.parse(await fs.readFile(curationPath, 'utf-8'));
45
+ expect(curation.curated).toHaveLength(2);
46
+ expect(curation.curated.every((item) => item.confirmed_chain.steps.length > 0)).toBe(true);
47
+ expect(curation.curated.every((item) => Array.isArray(item.resource_bindings) && item.resource_bindings.length > 0)).toBe(true);
48
+ await fs.rm(repoRoot, { recursive: true, force: true });
49
+ });
50
+ it('fails when exact_pairs are missing', async () => {
51
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-missing-'));
52
+ const runId = 'run-x';
53
+ const sliceId = 'slice-a';
54
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
55
+ await writeJson(slicePath, {
56
+ id: sliceId,
57
+ trigger_family: 'event_delegate',
58
+ resource_types: ['syncvar_hook'],
59
+ host_base_type: ['network_behaviour'],
60
+ required_hops: ['code_runtime'],
61
+ });
62
+ await expect(analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId })).rejects.toThrow(/exact_pairs/i);
63
+ await fs.rm(repoRoot, { recursive: true, force: true });
64
+ });
65
+ it('fails fast when exact_pairs contain duplicate non-empty ids', async () => {
66
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-dup-id-'));
67
+ const runId = 'run-x';
68
+ const sliceId = 'slice-a';
69
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
70
+ await writeJson(slicePath, {
71
+ id: sliceId,
72
+ trigger_family: 'event_delegate',
73
+ resource_types: ['syncvar_hook'],
74
+ host_base_type: ['network_behaviour'],
75
+ required_hops: ['code_runtime'],
76
+ exact_pairs: [
77
+ {
78
+ id: 'pair-dup',
79
+ binding_kind: 'method_triggers_method',
80
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'SourceA.Trigger' },
81
+ target_anchor: { file: 'Assets/Gameplay/TargetA.cs', line: 32, symbol: 'TargetA.OnTrigger' },
82
+ },
83
+ {
84
+ id: 'pair-dup',
85
+ binding_kind: 'method_triggers_method',
86
+ source_anchor: { file: 'Assets/Gameplay/SourceB.cs', line: 15, symbol: 'SourceB.Trigger' },
87
+ target_anchor: { file: 'Assets/Gameplay/TargetB.cs', line: 36, symbol: 'TargetB.OnTrigger' },
88
+ },
89
+ ],
90
+ });
91
+ await expect(analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId })).rejects.toThrow(/duplicate_exact_pair_id/i);
92
+ await fs.rm(repoRoot, { recursive: true, force: true });
93
+ });
94
+ it('supports exact-pair analyze -> curate -> promote flow', async () => {
95
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-e2e-'));
96
+ const runId = 'run-x';
97
+ const sliceId = 'slice-a';
98
+ const sliceDir = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId);
99
+ const rulesRoot = path.join(repoRoot, '.gitnexus', 'rules');
100
+ const slicePath = path.join(sliceDir, 'slice.json');
101
+ await fs.mkdir(path.join(rulesRoot, 'approved'), { recursive: true });
102
+ await fs.writeFile(path.join(rulesRoot, 'catalog.json'), JSON.stringify({ version: 1, rules: [] }, null, 2), 'utf-8');
103
+ await writeJson(slicePath, {
104
+ id: sliceId,
105
+ trigger_family: 'event_delegate',
106
+ resource_types: ['syncvar_hook'],
107
+ host_base_type: ['network_behaviour'],
108
+ required_hops: ['code_runtime'],
109
+ exact_pairs: [
110
+ {
111
+ id: 'pair-a',
112
+ binding_kind: 'method_triggers_method',
113
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'SourceA.Trigger' },
114
+ target_anchor: { file: 'Assets/Gameplay/TargetA.cs', line: 32, symbol: 'TargetA.OnTrigger' },
115
+ },
116
+ ],
117
+ });
118
+ const analyzed = await analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId });
119
+ const inputPath = path.join(path.dirname(analyzed.paths.candidatesPath), 'curation-input.json');
120
+ await curateRuleLabSlice({ repoPath: repoRoot, runId, sliceId, inputPath });
121
+ const promoted = await promoteCuratedRules({ repoPath: repoRoot, runId, sliceId, version: '1.0.0' });
122
+ expect(promoted.promotedFiles).toHaveLength(1);
123
+ await expect(fs.access(promoted.promotedFiles[0])).resolves.toBeUndefined();
124
+ await fs.rm(repoRoot, { recursive: true, force: true });
125
+ });
126
+ it('fails closed when exact-pair symbols cannot resolve Class.Method', async () => {
127
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-unresolved-'));
128
+ const runId = 'run-x';
129
+ const sliceId = 'slice-a';
130
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
131
+ await writeJson(slicePath, {
132
+ id: sliceId,
133
+ trigger_family: 'event_delegate',
134
+ resource_types: ['syncvar_hook'],
135
+ host_base_type: ['network_behaviour'],
136
+ required_hops: ['code_runtime'],
137
+ exact_pairs: [
138
+ {
139
+ id: 'pair-a',
140
+ binding_kind: 'method_triggers_method',
141
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'TriggerOnly' },
142
+ target_anchor: { file: 'Assets/Gameplay/TargetA.cs', line: 32, symbol: 'TargetA.OnTrigger' },
143
+ },
144
+ ],
145
+ });
146
+ await expect(analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId })).rejects.toThrow(/binding_unresolved/i);
147
+ await fs.rm(repoRoot, { recursive: true, force: true });
148
+ });
149
+ it('builds scene_load binding with host_class_pattern + loader_methods + scene_name', async () => {
150
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-scene-load-shape-'));
151
+ const runId = 'run-x';
152
+ const sliceId = 'slice-a';
153
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
154
+ await writeJson(slicePath, {
155
+ id: sliceId,
156
+ trigger_family: 'event_delegate',
157
+ resource_types: ['scene'],
158
+ host_base_type: ['network_behaviour'],
159
+ required_hops: ['code_runtime'],
160
+ exact_pairs: [
161
+ {
162
+ id: 'pair-scene',
163
+ binding_kind: 'method_triggers_scene_load',
164
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'SourceA.Trigger' },
165
+ target_anchor: { file: 'Assets/Scenes/BattleScene.unity', line: 1, symbol: 'BattleScene' },
166
+ },
167
+ ],
168
+ });
169
+ const analyzed = await analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId });
170
+ const curationPath = path.join(path.dirname(analyzed.paths.candidatesPath), 'curation-input.json');
171
+ const curation = JSON.parse(await fs.readFile(curationPath, 'utf-8'));
172
+ const binding = curation.curated[0].resource_bindings[0];
173
+ expect(binding.kind).toBe('method_triggers_scene_load');
174
+ expect(binding.host_class_pattern).toBe('SourceA');
175
+ expect(binding.loader_methods).toEqual(['Trigger']);
176
+ expect(binding.scene_name).toBe('BattleScene');
177
+ expect(binding.source_class_pattern).toBeUndefined();
178
+ expect(binding.source_method).toBeUndefined();
179
+ expect(binding.target_class_pattern).toBeUndefined();
180
+ expect(binding.target_method).toBeUndefined();
181
+ await fs.rm(repoRoot, { recursive: true, force: true });
182
+ });
183
+ it('fails closed for scene_load when target scene token is missing', async () => {
184
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-scene-load-missing-scene-'));
185
+ const runId = 'run-x';
186
+ const sliceId = 'slice-a';
187
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
188
+ await writeJson(slicePath, {
189
+ id: sliceId,
190
+ trigger_family: 'event_delegate',
191
+ resource_types: ['scene'],
192
+ host_base_type: ['network_behaviour'],
193
+ required_hops: ['code_runtime'],
194
+ exact_pairs: [
195
+ {
196
+ id: 'pair-scene',
197
+ binding_kind: 'method_triggers_scene_load',
198
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'SourceA.Trigger' },
199
+ target_anchor: { file: '', line: 1, symbol: '' },
200
+ },
201
+ ],
202
+ });
203
+ await expect(analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId })).rejects.toThrow(/binding_unresolved/i);
204
+ await fs.rm(repoRoot, { recursive: true, force: true });
205
+ });
206
+ it('ignores legacy parity/coverage fields when exact_pairs are valid', async () => {
207
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-legacy-'));
208
+ const runId = 'run-x';
209
+ const sliceId = 'slice-a';
210
+ const slicePath = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', runId, 'slices', sliceId, 'slice.json');
211
+ await writeJson(slicePath, {
212
+ id: sliceId,
213
+ trigger_family: 'event_delegate',
214
+ resource_types: ['syncvar_hook'],
215
+ host_base_type: ['network_behaviour'],
216
+ required_hops: ['code_runtime'],
217
+ exact_pairs: [
218
+ {
219
+ id: 'pair-a',
220
+ binding_kind: 'method_triggers_method',
221
+ source_anchor: { file: 'Assets/Gameplay/SourceA.cs', line: 12, symbol: 'SourceA.Trigger' },
222
+ target_anchor: { file: 'Assets/Gameplay/TargetA.cs', line: 32, symbol: 'TargetA.OnTrigger' },
223
+ },
224
+ ],
225
+ coverage_gate: {
226
+ status: 'blocked',
227
+ reason: 'coverage_incomplete',
228
+ processed_user_matches: 0,
229
+ user_raw_matches: 9,
230
+ },
231
+ parity_status: {
232
+ status: 'blocked',
233
+ reason: 'parity_missing_rules_slice',
234
+ },
235
+ });
236
+ const analyzed = await analyzeRuleLabSlice({ repoPath: repoRoot, runId, sliceId });
237
+ expect(analyzed.candidates).toHaveLength(1);
238
+ expect(analyzed.candidates[0].exact_pair?.id).toBe('pair-a');
239
+ await fs.rm(repoRoot, { recursive: true, force: true });
240
+ });
241
+ it('rejects placeholder run/slice ids', async () => {
242
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-analyze-exact-placeholder-'));
243
+ await expect(analyzeRuleLabSlice({ repoPath: repoRoot, runId: '<run_id>', sliceId: '<slice_id>' })).rejects.toThrow(/placeholder/i);
26
244
  await fs.rm(repoRoot, { recursive: true, force: true });
27
245
  });
28
246
  });
@@ -1,5 +1,5 @@
1
1
  import { getRuleLabPaths } from './paths.js';
2
- import type { RuleDslMatch, RuleDslTopologyHop, RuleDslClosure, RuleDslClaims } from './types.js';
2
+ import type { RuleDslMatch, RuleDslTopologyHop, RuleDslClosure, RuleDslClaims, UnityResourceBinding } from './types.js';
3
3
  export interface CurateInput {
4
4
  repoPath: string;
5
5
  runId: string;
@@ -19,6 +19,7 @@ export interface CuratedItem {
19
19
  topology?: RuleDslTopologyHop[];
20
20
  closure?: RuleDslClosure;
21
21
  claims?: RuleDslClaims;
22
+ resource_bindings?: UnityResourceBinding[];
22
23
  confirmed_chain: {
23
24
  steps: CuratedStep[];
24
25
  };
@@ -76,6 +76,12 @@ function validateCuratedItem(item) {
76
76
  if (hasPlaceholderText(JSON.stringify(item))) {
77
77
  throw new Error('curated item contains placeholder text');
78
78
  }
79
+ if (Array.isArray(item.resource_bindings)) {
80
+ const rawBindings = JSON.stringify(item.resource_bindings);
81
+ if (/UnknownClass|UnknownMethod|UnknownSource|UnknownTarget/i.test(rawBindings)) {
82
+ throw new Error('binding unresolved: unknown placeholder binding values are forbidden');
83
+ }
84
+ }
79
85
  const guaranteeSet = normalizeForSet(item.guarantees);
80
86
  const nonGuaranteeSet = normalizeForSet(item.non_guarantees);
81
87
  const overlap = [...guaranteeSet].filter((entry) => nonGuaranteeSet.has(entry));
@@ -92,6 +98,9 @@ function toDslDraft(item) {
92
98
  topology: item.topology,
93
99
  closure: item.closure,
94
100
  claims: item.claims,
101
+ ...(Array.isArray(item.resource_bindings) && item.resource_bindings.length > 0
102
+ ? { resource_bindings: item.resource_bindings }
103
+ : {}),
95
104
  };
96
105
  }
97
106
  function validateDslDraft(draft) {
@@ -122,11 +131,23 @@ export async function curateRuleLabSlice(input) {
122
131
  throw new Error('curated must contain at least one candidate');
123
132
  }
124
133
  curated.forEach(validateCuratedItem);
125
- const firstDraft = toDslDraft(curated[0]);
126
- validateDslDraft(firstDraft);
134
+ const drafts = curated.map((item) => toDslDraft(item));
135
+ drafts.forEach(validateDslDraft);
136
+ const firstDraft = drafts[0];
137
+ const sliceDir = path.dirname(paths.curatedPath);
127
138
  await fs.mkdir(path.dirname(paths.curatedPath), { recursive: true });
128
139
  await fs.writeFile(paths.curatedPath, `${JSON.stringify({ run_id: input.runId, slice_id: input.sliceId, curated }, null, 2)}\n`, 'utf-8');
129
- await fs.writeFile(path.join(path.dirname(paths.curatedPath), 'dsl-draft.json'), `${JSON.stringify(firstDraft, null, 2)}\n`, 'utf-8');
140
+ await fs.writeFile(path.join(sliceDir, 'dsl-drafts.json'), `${JSON.stringify({ run_id: input.runId, slice_id: input.sliceId, drafts }, null, 2)}\n`, 'utf-8');
141
+ if (drafts.length === 1) {
142
+ await fs.writeFile(path.join(sliceDir, 'dsl-draft.json'), `${JSON.stringify(firstDraft, null, 2)}\n`, 'utf-8');
143
+ }
144
+ else {
145
+ await fs.writeFile(path.join(sliceDir, 'dsl-draft.json'), `${JSON.stringify({
146
+ compatibility_warning: 'multi-draft mode active; use dsl-drafts.json for complete draft set',
147
+ primary_draft_id: firstDraft.id,
148
+ primary_draft: firstDraft,
149
+ }, null, 2)}\n`, 'utf-8');
150
+ }
130
151
  return {
131
152
  paths,
132
153
  curated,
@@ -69,4 +69,69 @@ describe('rule-lab curate', () => {
69
69
  await expect(curateRuleLabSlice({ repoPath: repoRoot, runId: 'run-x', sliceId: 'slice-a', inputPath })).rejects.toThrow(/failure_map/i);
70
70
  await fs.rm(repoRoot, { recursive: true, force: true });
71
71
  });
72
+ it('preserves multi-candidate curation and writes dsl-drafts with compatibility warning', async () => {
73
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-curate-'));
74
+ const inputPath = path.join(repoRoot, 'curation-input.json');
75
+ const curated = [
76
+ {
77
+ id: 'candidate-1',
78
+ rule_id: 'demo.rule.first.v1',
79
+ title: 'first rule',
80
+ match: {
81
+ trigger_tokens: ['reload'],
82
+ resource_types: ['syncvar_hook'],
83
+ host_base_type: ['network_behaviour'],
84
+ },
85
+ topology: [
86
+ { hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } },
87
+ ],
88
+ closure: { required_hops: ['code_runtime'], failure_map: { missing_evidence: 'rule_matched_but_evidence_missing' } },
89
+ claims: {
90
+ guarantees: ['reload_chain_closed'],
91
+ non_guarantees: ['no_runtime_execution'],
92
+ next_action: 'gitnexus query "reload"',
93
+ },
94
+ confirmed_chain: { steps: [{ hop_type: 'code_runtime', anchor: 'Assets/A.cs:1', snippet: 'A' }] },
95
+ guarantees: ['reload_chain_closed'],
96
+ non_guarantees: ['no_runtime_execution'],
97
+ },
98
+ {
99
+ id: 'candidate-2',
100
+ rule_id: 'demo.rule.second.v1',
101
+ title: 'second rule',
102
+ match: {
103
+ trigger_tokens: ['reload'],
104
+ resource_types: ['syncvar_hook'],
105
+ host_base_type: ['network_behaviour'],
106
+ },
107
+ topology: [
108
+ { hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } },
109
+ ],
110
+ closure: { required_hops: ['code_runtime'], failure_map: { missing_evidence: 'rule_matched_but_evidence_missing' } },
111
+ claims: {
112
+ guarantees: ['reload_chain_closed'],
113
+ non_guarantees: ['no_runtime_execution'],
114
+ next_action: 'gitnexus query "reload"',
115
+ },
116
+ confirmed_chain: { steps: [{ hop_type: 'code_runtime', anchor: 'Assets/B.cs:2', snippet: 'B' }] },
117
+ guarantees: ['reload_chain_closed'],
118
+ non_guarantees: ['no_runtime_execution'],
119
+ },
120
+ ];
121
+ await fs.writeFile(inputPath, JSON.stringify({ run_id: 'run-x', slice_id: 'slice-a', curated }, null, 2), 'utf-8');
122
+ const out = await curateRuleLabSlice({
123
+ repoPath: repoRoot,
124
+ runId: 'run-x',
125
+ sliceId: 'slice-a',
126
+ inputPath,
127
+ });
128
+ const baseDir = path.dirname(out.paths.curatedPath);
129
+ const curatedOut = JSON.parse(await fs.readFile(out.paths.curatedPath, 'utf-8'));
130
+ const drafts = JSON.parse(await fs.readFile(path.join(baseDir, 'dsl-drafts.json'), 'utf-8'));
131
+ const legacy = JSON.parse(await fs.readFile(path.join(baseDir, 'dsl-draft.json'), 'utf-8'));
132
+ expect(curatedOut.curated).toHaveLength(2);
133
+ expect(drafts.drafts).toHaveLength(2);
134
+ expect(legacy.compatibility_warning).toMatch(/multi-draft/i);
135
+ await fs.rm(repoRoot, { recursive: true, force: true });
136
+ });
72
137
  });
@@ -0,0 +1,45 @@
1
+ import type { RuleLabCandidate, RuleLabSlice, UnityResourceBinding } from './types.js';
2
+ interface CurationInputItem {
3
+ id: string;
4
+ rule_id: string;
5
+ title: string;
6
+ match: {
7
+ trigger_tokens: string[];
8
+ symbol_kind: string[];
9
+ module_scope: string[];
10
+ resource_types: string[];
11
+ host_base_type: string[];
12
+ };
13
+ topology: NonNullable<RuleLabCandidate['topology']>;
14
+ closure: {
15
+ required_hops: string[];
16
+ failure_map: Record<string, string>;
17
+ };
18
+ claims: {
19
+ guarantees: string[];
20
+ non_guarantees: string[];
21
+ next_action: string;
22
+ };
23
+ confirmed_chain: {
24
+ steps: Array<{
25
+ hop_type?: string;
26
+ anchor: string;
27
+ snippet: string;
28
+ }>;
29
+ };
30
+ guarantees: string[];
31
+ non_guarantees: string[];
32
+ resource_bindings: UnityResourceBinding[];
33
+ }
34
+ export interface CurationInputDocument {
35
+ run_id: string;
36
+ slice_id: string;
37
+ curated: CurationInputItem[];
38
+ }
39
+ export declare function buildCurationInput(input: {
40
+ runId: string;
41
+ sliceId: string;
42
+ slice: RuleLabSlice;
43
+ candidates: RuleLabCandidate[];
44
+ }): CurationInputDocument;
45
+ export {};
@@ -0,0 +1,133 @@
1
+ function unique(values) {
2
+ return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
3
+ }
4
+ function splitSymbol(symbol) {
5
+ const raw = String(symbol || '').trim();
6
+ const parts = raw.split('.');
7
+ if (parts.length < 2) {
8
+ return { className: '', methodName: '' };
9
+ }
10
+ const className = String(parts[parts.length - 2] || '').trim();
11
+ const methodName = String(parts[parts.length - 1] || '').trim();
12
+ return { className, methodName };
13
+ }
14
+ function assertResolvedBindingParts(parts, side, candidateId) {
15
+ const className = String(parts.className || '').trim();
16
+ const methodName = String(parts.methodName || '').trim();
17
+ if (!className || !methodName) {
18
+ throw new Error(`binding_unresolved: ${side} symbol unresolved for candidate ${candidateId}`);
19
+ }
20
+ if (/^unknown/i.test(className) || /^unknown/i.test(methodName)) {
21
+ throw new Error(`binding_unresolved: ${side} symbol contains unknown placeholder for candidate ${candidateId}`);
22
+ }
23
+ }
24
+ function assertResolvedSceneToken(value, candidateId) {
25
+ const token = String(value || '').trim();
26
+ if (!token) {
27
+ throw new Error(`binding_unresolved: scene token unresolved for candidate ${candidateId}`);
28
+ }
29
+ if (/^unknown/i.test(token)) {
30
+ throw new Error(`binding_unresolved: scene token contains unknown placeholder for candidate ${candidateId}`);
31
+ }
32
+ return token;
33
+ }
34
+ function buildBinding(candidate, pair) {
35
+ const source = splitSymbol(pair.source_anchor.symbol);
36
+ assertResolvedBindingParts(source, 'source', candidate.id);
37
+ const kind = candidate.binding_kind === 'method_triggers_scene_load'
38
+ ? 'method_triggers_scene_load'
39
+ : 'method_triggers_method';
40
+ if (kind === 'method_triggers_scene_load') {
41
+ const sceneName = assertResolvedSceneToken(String(pair.target_anchor.symbol || pair.target_anchor.file || ''), candidate.id);
42
+ return [{
43
+ kind,
44
+ host_class_pattern: source.className,
45
+ loader_methods: [source.methodName],
46
+ scene_name: sceneName,
47
+ description: `Derived from exact pair ${String(pair.id || candidate.id)}`,
48
+ }];
49
+ }
50
+ const target = splitSymbol(pair.target_anchor.symbol);
51
+ assertResolvedBindingParts(target, 'target', candidate.id);
52
+ return [{
53
+ kind,
54
+ source_class_pattern: source.className,
55
+ source_method: source.methodName,
56
+ target_class_pattern: target.className,
57
+ target_method: target.methodName,
58
+ description: `Derived from exact pair ${String(pair.id || candidate.id)}`,
59
+ }];
60
+ }
61
+ function buildConfirmedChain(candidate, pair) {
62
+ const hops = (candidate.evidence?.hops || []).filter((hop) => String(hop.anchor || '').trim() && String(hop.snippet || '').trim());
63
+ if (hops.length > 0)
64
+ return hops;
65
+ const sourceAnchor = String(pair.source_anchor.file || '').trim();
66
+ const targetAnchor = String(pair.target_anchor.file || '').trim();
67
+ const sourceSnippet = String(pair.source_anchor.symbol || '').trim();
68
+ const targetSnippet = String(pair.target_anchor.symbol || '').trim();
69
+ const fallback = [
70
+ sourceAnchor ? {
71
+ hop_type: 'code_runtime',
72
+ anchor: `${sourceAnchor}:${Number(pair.source_anchor.line || 1)}`,
73
+ snippet: sourceSnippet || 'source',
74
+ } : undefined,
75
+ targetAnchor ? {
76
+ hop_type: 'code_runtime',
77
+ anchor: `${targetAnchor}:${Number(pair.target_anchor.line || 1)}`,
78
+ snippet: targetSnippet || 'target',
79
+ } : undefined,
80
+ ].filter((item) => Boolean(item));
81
+ if (fallback.length === 0) {
82
+ throw new Error(`confirmed_chain_empty: no evidence or anchor fallback for candidate ${candidate.id}`);
83
+ }
84
+ return fallback;
85
+ }
86
+ export function buildCurationInput(input) {
87
+ const curated = input.candidates.map((candidate) => {
88
+ const pair = candidate.exact_pair;
89
+ if (!pair) {
90
+ throw new Error(`binding_unresolved: exact_pair missing for candidate ${candidate.id}`);
91
+ }
92
+ const requiredHops = unique((candidate.topology || []).map((hop) => hop.hop));
93
+ const guaranteed = unique(candidate.claims?.guarantees || [`exact pair candidate: ${candidate.id}`]);
94
+ const nonGuaranteed = unique(candidate.claims?.non_guarantees || ['sparse gap path only']);
95
+ const bindings = buildBinding(candidate, pair);
96
+ const confirmedChain = buildConfirmedChain(candidate, pair);
97
+ return {
98
+ id: candidate.id,
99
+ rule_id: String(candidate.draft_rule_id || candidate.id),
100
+ title: String(candidate.title || ''),
101
+ match: {
102
+ trigger_tokens: [input.slice.trigger_family],
103
+ symbol_kind: ['method'],
104
+ module_scope: [input.slice.id],
105
+ resource_types: [...input.slice.resource_types],
106
+ host_base_type: [...input.slice.host_base_type],
107
+ },
108
+ topology: candidate.topology || [],
109
+ closure: {
110
+ required_hops: requiredHops.length > 0 ? requiredHops : ['code_runtime'],
111
+ failure_map: {
112
+ missing_evidence: 'rule_matched_but_evidence_missing',
113
+ },
114
+ },
115
+ claims: {
116
+ guarantees: guaranteed,
117
+ non_guarantees: nonGuaranteed,
118
+ next_action: String(candidate.claims?.next_action || `gitnexus query "${input.slice.trigger_family}"`),
119
+ },
120
+ confirmed_chain: {
121
+ steps: confirmedChain,
122
+ },
123
+ guarantees: guaranteed,
124
+ non_guarantees: nonGuaranteed,
125
+ resource_bindings: bindings,
126
+ };
127
+ });
128
+ return {
129
+ run_id: input.runId,
130
+ slice_id: input.sliceId,
131
+ curated,
132
+ };
133
+ }