@veewo/gitnexus 1.5.0 → 1.5.1

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 +3 -2
  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
@@ -24,6 +24,10 @@ function isForbiddenPlaceholder(value) {
24
24
  const token = toComparableToken(value);
25
25
  return token === 'unknown' || token === 'todo' || token === 'tbd' || /<[^>]+>/.test(token);
26
26
  }
27
+ function hasPlaceholderText(value) {
28
+ const raw = String(value || '').trim();
29
+ return !raw || /TODO|TBD|placeholder|<[^>]+>/i.test(raw);
30
+ }
27
31
  function assertNoPlaceholderScope(values, field) {
28
32
  if (values.length === 0) {
29
33
  throw new Error(`promote lint failed: ${field} must be non-empty`);
@@ -32,6 +36,48 @@ function assertNoPlaceholderScope(values, field) {
32
36
  throw new Error(`promote lint failed: unknown scope placeholder is forbidden (${field})`);
33
37
  }
34
38
  }
39
+ function assertResolvedBindings(resourceBindings, ruleId) {
40
+ if (!Array.isArray(resourceBindings) || resourceBindings.length === 0)
41
+ return;
42
+ const raw = JSON.stringify(resourceBindings);
43
+ if (/UnknownClass|UnknownMethod|UnknownSource|UnknownTarget|TODO|TBD|placeholder|<[^>]+>/i.test(raw)) {
44
+ throw new Error(`binding_unresolved: placeholder binding values are forbidden for rule ${ruleId}`);
45
+ }
46
+ }
47
+ function requireEvidenceGuard(item, ruleId) {
48
+ const steps = Array.isArray(item.confirmed_chain?.steps) ? item.confirmed_chain.steps : [];
49
+ if (steps.length === 0) {
50
+ throw new Error(`evidence_guard_failed: confirmed_chain.steps must be non-empty for rule ${ruleId}`);
51
+ }
52
+ for (let index = 0; index < steps.length; index += 1) {
53
+ const step = steps[index];
54
+ if (hasPlaceholderText(step.anchor)) {
55
+ throw new Error(`evidence_guard_failed: confirmed_chain.steps[${index}].anchor invalid for rule ${ruleId}`);
56
+ }
57
+ if (hasPlaceholderText(step.snippet)) {
58
+ throw new Error(`evidence_guard_failed: confirmed_chain.steps[${index}].snippet invalid for rule ${ruleId}`);
59
+ }
60
+ }
61
+ }
62
+ function isExactPairEventDelegateItem(item) {
63
+ const triggerTokens = Array.isArray(item.match?.trigger_tokens)
64
+ ? item.match?.trigger_tokens || []
65
+ : [];
66
+ const hasEventDelegateTrigger = triggerTokens.some((token) => toComparableToken(token) === 'event_delegate');
67
+ if (!hasEventDelegateTrigger)
68
+ return false;
69
+ const hasCodeRuntimeTopology = Array.isArray(item.topology)
70
+ && item.topology.some((hop) => toComparableToken(hop.hop) === 'code_runtime');
71
+ const hasEvidence = Array.isArray(item.confirmed_chain?.steps) && item.confirmed_chain.steps.length > 0;
72
+ return hasCodeRuntimeTopology && hasEvidence;
73
+ }
74
+ function requireBindingGuard(item, ruleId) {
75
+ const bindings = item.resource_bindings;
76
+ if (isExactPairEventDelegateItem(item) && (!Array.isArray(bindings) || bindings.length === 0)) {
77
+ throw new Error(`binding_unresolved: exact_pair_binding_missing for rule ${ruleId}`);
78
+ }
79
+ assertResolvedBindings(bindings, ruleId);
80
+ }
35
81
  function toDraftFromCurated(item) {
36
82
  const triggerTokens = unique(item.match?.trigger_tokens || [inferTriggerFamily(item)]);
37
83
  const topology = Array.isArray(item.topology) && item.topology.length > 0
@@ -69,6 +115,9 @@ function toDraftFromCurated(item) {
69
115
  non_guarantees: nonGuarantees,
70
116
  next_action: nextAction,
71
117
  },
118
+ ...(Array.isArray(item.resource_bindings)
119
+ ? { resource_bindings: item.resource_bindings }
120
+ : {}),
72
121
  };
73
122
  }
74
123
  function compileRule(ruleId, version, draft) {
@@ -87,6 +136,7 @@ function compileRule(ruleId, version, draft) {
87
136
  const nextAction = String(draft.claims.next_action || '').trim() || 'gitnexus query "runtime"';
88
137
  assertNoPlaceholderScope(resourceTypes, 'resource_types');
89
138
  assertNoPlaceholderScope(hostBaseType, 'host_base_type');
139
+ assertResolvedBindings(draft.resource_bindings, ruleId);
90
140
  return {
91
141
  id: ruleId,
92
142
  version,
@@ -204,6 +254,16 @@ function buildRuleYaml(rule) {
204
254
  lines.push(` field_name: ${quoteYaml(binding.field_name)}`);
205
255
  if (binding.loader_methods?.length)
206
256
  pushList(lines, 'loader_methods', binding.loader_methods, ' ');
257
+ if (binding.scene_name)
258
+ lines.push(` scene_name: ${quoteYaml(binding.scene_name)}`);
259
+ if (binding.source_class_pattern)
260
+ lines.push(` source_class_pattern: ${quoteYaml(binding.source_class_pattern)}`);
261
+ if (binding.source_method)
262
+ lines.push(` source_method: ${quoteYaml(binding.source_method)}`);
263
+ if (binding.target_class_pattern)
264
+ lines.push(` target_class_pattern: ${quoteYaml(binding.target_class_pattern)}`);
265
+ if (binding.target_method)
266
+ lines.push(` target_method: ${quoteYaml(binding.target_method)}`);
207
267
  }
208
268
  }
209
269
  if (rule.lifecycle_overrides) {
@@ -233,6 +293,17 @@ async function readCatalog(catalogPath) {
233
293
  throw error;
234
294
  }
235
295
  }
296
+ async function fileExists(filePath) {
297
+ try {
298
+ await fs.access(filePath);
299
+ return true;
300
+ }
301
+ catch (error) {
302
+ if (error?.code === 'ENOENT')
303
+ return false;
304
+ throw error;
305
+ }
306
+ }
236
307
  export async function promoteCuratedRules(input) {
237
308
  const normalizedRepoPath = path.resolve(input.repoPath);
238
309
  const paths = getRuleLabPaths(normalizedRepoPath, input.runId, input.sliceId);
@@ -262,8 +333,16 @@ export async function promoteCuratedRules(input) {
262
333
  if (!ruleId) {
263
334
  throw new Error('curated item missing rule id');
264
335
  }
336
+ requireEvidenceGuard(item, ruleId);
337
+ requireBindingGuard(item, ruleId);
338
+ if (catalog.rules.some((entry) => entry.id === ruleId)) {
339
+ throw new Error(`duplicate_rule_id: ${ruleId} already exists in catalog`);
340
+ }
265
341
  const relativeFile = path.join('approved', `${ruleId}.yaml`).split(path.sep).join('/');
266
342
  const absoluteFile = path.join(paths.rulesRoot, relativeFile);
343
+ if (await fileExists(absoluteFile)) {
344
+ throw new Error(`duplicate_rule_id: ${ruleId} already exists at ${relativeFile}`);
345
+ }
267
346
  const draft = dslDraftFromCurate && curatedItems.length === 1
268
347
  ? { ...dslDraftFromCurate, id: ruleId, version }
269
348
  : { ...toDraftFromCurated(item), id: ruleId, version };
@@ -279,13 +358,7 @@ export async function promoteCuratedRules(input) {
279
358
  file: relativeFile,
280
359
  ...(compiledRule.family ? { family: compiledRule.family } : {}),
281
360
  };
282
- const existingIndex = catalog.rules.findIndex((entry) => entry.id === ruleId);
283
- if (existingIndex >= 0) {
284
- catalog.rules[existingIndex] = nextEntry;
285
- }
286
- else {
287
- catalog.rules.push(nextEntry);
288
- }
361
+ catalog.rules.push(nextEntry);
289
362
  }
290
363
  await fs.mkdir(path.dirname(catalogPath), { recursive: true });
291
364
  await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf-8');
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promoteCuratedRules } from './promote.js';
6
+ import { loadRuleRegistry } from '../mcp/local/runtime-claim-rule-registry.js';
6
7
  describe('rule-lab promote', () => {
7
8
  it('promotes curated candidate into approved yaml and catalog entry', async () => {
8
9
  const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-promote-'));
@@ -161,4 +162,153 @@ describe('rule-lab promote', () => {
161
162
  await expect(promoteCuratedRules({ repoPath: repoRoot, runId: 'run-x', sliceId: 'slice-a' })).rejects.toThrow(/unknown/i);
162
163
  await fs.rm(repoRoot, { recursive: true, force: true });
163
164
  });
165
+ it('promotes every curated item when dsl-drafts includes multiple candidates', async () => {
166
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-promote-multi-'));
167
+ const rulesRoot = path.join(repoRoot, '.gitnexus', 'rules');
168
+ const sliceDir = path.join(rulesRoot, 'lab', 'runs', 'run-x', 'slices', 'slice-a');
169
+ await fs.mkdir(path.join(rulesRoot, 'approved'), { recursive: true });
170
+ await fs.mkdir(sliceDir, { recursive: true });
171
+ await fs.writeFile(path.join(rulesRoot, 'catalog.json'), JSON.stringify({ version: 1, rules: [] }, null, 2), 'utf-8');
172
+ const curated = {
173
+ run_id: 'run-x',
174
+ slice_id: 'slice-a',
175
+ curated: [
176
+ {
177
+ id: 'candidate-1',
178
+ rule_id: 'demo.multi.first.v1',
179
+ title: 'demo first',
180
+ match: { trigger_tokens: ['reload'], resource_types: ['syncvar_hook'], host_base_type: ['network_behaviour'] },
181
+ topology: [{ hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } }],
182
+ closure: { required_hops: ['code_runtime'], failure_map: { missing_evidence: 'rule_matched_but_evidence_missing' } },
183
+ claims: { guarantees: ['reload_chain_closed'], non_guarantees: ['no_runtime_execution'], next_action: 'gitnexus query "reload"' },
184
+ confirmed_chain: { steps: [{ hop_type: 'code_runtime', anchor: 'Assets/A.cs:1', snippet: 'A' }] },
185
+ guarantees: ['reload_chain_closed'],
186
+ non_guarantees: ['no_runtime_execution'],
187
+ },
188
+ {
189
+ id: 'candidate-2',
190
+ rule_id: 'demo.multi.second.v1',
191
+ title: 'demo second',
192
+ match: { trigger_tokens: ['reload'], resource_types: ['syncvar_hook'], host_base_type: ['network_behaviour'] },
193
+ topology: [{ hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } }],
194
+ closure: { required_hops: ['code_runtime'], failure_map: { missing_evidence: 'rule_matched_but_evidence_missing' } },
195
+ claims: { guarantees: ['reload_chain_closed'], non_guarantees: ['no_runtime_execution'], next_action: 'gitnexus query "reload"' },
196
+ confirmed_chain: { steps: [{ hop_type: 'code_runtime', anchor: 'Assets/B.cs:2', snippet: 'B' }] },
197
+ guarantees: ['reload_chain_closed'],
198
+ non_guarantees: ['no_runtime_execution'],
199
+ },
200
+ ],
201
+ };
202
+ await fs.writeFile(path.join(sliceDir, 'curated.json'), JSON.stringify(curated, null, 2), 'utf-8');
203
+ await fs.writeFile(path.join(sliceDir, 'dsl-drafts.json'), JSON.stringify({
204
+ drafts: [
205
+ {
206
+ id: 'demo.multi.first.v1',
207
+ version: '2.0.0',
208
+ match: curated.curated[0].match,
209
+ topology: curated.curated[0].topology,
210
+ closure: curated.curated[0].closure,
211
+ claims: curated.curated[0].claims,
212
+ },
213
+ {
214
+ id: 'demo.multi.second.v1',
215
+ version: '2.0.0',
216
+ match: curated.curated[1].match,
217
+ topology: curated.curated[1].topology,
218
+ closure: curated.curated[1].closure,
219
+ claims: curated.curated[1].claims,
220
+ },
221
+ ],
222
+ }, null, 2), 'utf-8');
223
+ await fs.writeFile(path.join(sliceDir, 'dsl-draft.json'), JSON.stringify({
224
+ compatibility_warning: 'multi-draft compatibility alias',
225
+ primary_draft_id: 'demo.multi.first.v1',
226
+ }, null, 2), 'utf-8');
227
+ const out = await promoteCuratedRules({ repoPath: repoRoot, runId: 'run-x', sliceId: 'slice-a' });
228
+ expect(out.promotedFiles).toHaveLength(2);
229
+ await fs.rm(repoRoot, { recursive: true, force: true });
230
+ });
231
+ it('writes all binding fields into yaml and keeps them parseable via loadRuleRegistry', async () => {
232
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-promote-bindings-roundtrip-'));
233
+ const rulesRoot = path.join(repoRoot, '.gitnexus', 'rules');
234
+ const sliceDir = path.join(rulesRoot, 'lab', 'runs', 'run-x', 'slices', 'slice-a');
235
+ await fs.mkdir(path.join(rulesRoot, 'approved'), { recursive: true });
236
+ await fs.mkdir(sliceDir, { recursive: true });
237
+ await fs.writeFile(path.join(rulesRoot, 'catalog.json'), JSON.stringify({ version: 1, rules: [] }, null, 2), 'utf-8');
238
+ await fs.writeFile(path.join(sliceDir, 'curated.json'), JSON.stringify({
239
+ run_id: 'run-x',
240
+ slice_id: 'slice-a',
241
+ curated: [
242
+ {
243
+ id: 'candidate-1',
244
+ rule_id: 'demo.bindings.roundtrip.v1',
245
+ title: 'demo bindings',
246
+ match: {
247
+ trigger_tokens: ['reload'],
248
+ resource_types: ['syncvar_hook'],
249
+ host_base_type: ['network_behaviour'],
250
+ },
251
+ topology: [
252
+ { hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } },
253
+ ],
254
+ closure: {
255
+ required_hops: ['code_runtime'],
256
+ failure_map: { missing_evidence: 'rule_matched_but_evidence_missing' },
257
+ },
258
+ claims: {
259
+ guarantees: ['binding_fields_roundtrip'],
260
+ non_guarantees: ['no_runtime_execution'],
261
+ next_action: 'gitnexus query "reload"',
262
+ },
263
+ confirmed_chain: {
264
+ steps: [{ hop_type: 'code_runtime', anchor: 'Assets/Demo.cs:12', snippet: 'Demo.Trigger' }],
265
+ },
266
+ guarantees: ['binding_fields_roundtrip'],
267
+ non_guarantees: ['no_runtime_execution'],
268
+ resource_bindings: [
269
+ {
270
+ kind: 'method_triggers_scene_load',
271
+ host_class_pattern: 'BattleController',
272
+ loader_methods: ['EnterBattle'],
273
+ scene_name: 'BattleScene',
274
+ },
275
+ {
276
+ kind: 'method_triggers_method',
277
+ source_class_pattern: 'SourceClass',
278
+ source_method: 'Emit',
279
+ target_class_pattern: 'TargetClass',
280
+ target_method: 'Handle',
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ }, null, 2), 'utf-8');
286
+ const out = await promoteCuratedRules({ repoPath: repoRoot, runId: 'run-x', sliceId: 'slice-a', version: '1.0.0' });
287
+ const yaml = await fs.readFile(out.promotedFiles[0], 'utf-8');
288
+ expect(yaml).toContain('scene_name: BattleScene');
289
+ expect(yaml).toContain('host_class_pattern: BattleController');
290
+ expect(yaml).toContain('loader_methods:');
291
+ expect(yaml).toContain('- EnterBattle');
292
+ expect(yaml).toContain('target_class_pattern: TargetClass');
293
+ expect(yaml).toContain('target_method: Handle');
294
+ await fs.rm(path.join(rulesRoot, 'compiled'), { recursive: true, force: true });
295
+ const registry = await loadRuleRegistry(repoRoot);
296
+ const rule = registry.activeRules.find((item) => item.id === 'demo.bindings.roundtrip.v1');
297
+ expect(rule).toBeTruthy();
298
+ expect(rule?.resource_bindings).toBeDefined();
299
+ expect(rule?.resource_bindings?.[0].kind).toBe('method_triggers_scene_load');
300
+ expect(rule?.resource_bindings?.[0].host_class_pattern).toBe('BattleController');
301
+ expect(rule?.resource_bindings?.[0].loader_methods).toEqual(['EnterBattle']);
302
+ expect(rule?.resource_bindings?.[0].scene_name).toBe('BattleScene');
303
+ expect(rule?.resource_bindings?.[0].source_class_pattern).toBeUndefined();
304
+ expect(rule?.resource_bindings?.[0].source_method).toBeUndefined();
305
+ expect(rule?.resource_bindings?.[0].target_class_pattern).toBeUndefined();
306
+ expect(rule?.resource_bindings?.[0].target_method).toBeUndefined();
307
+ expect(rule?.resource_bindings?.[1].kind).toBe('method_triggers_method');
308
+ expect(rule?.resource_bindings?.[1].source_class_pattern).toBe('SourceClass');
309
+ expect(rule?.resource_bindings?.[1].source_method).toBe('Emit');
310
+ expect(rule?.resource_bindings?.[1].target_class_pattern).toBe('TargetClass');
311
+ expect(rule?.resource_bindings?.[1].target_method).toBe('Handle');
312
+ await fs.rm(repoRoot, { recursive: true, force: true });
313
+ });
164
314
  });
@@ -14,6 +14,9 @@ export interface ReviewPackCard {
14
14
  failure_map: Record<string, string>;
15
15
  guarantees: string[];
16
16
  non_guarantees: string[];
17
+ draft_rule_ids: string[];
18
+ aggregation_modes: string[];
19
+ binding_kinds: string[];
17
20
  };
18
21
  }
19
22
  export interface ReviewPackMeta {
@@ -49,6 +49,15 @@ function collectClaims(candidates) {
49
49
  non_guarantees: nonGuarantees,
50
50
  };
51
51
  }
52
+ function collectDraftRuleIds(candidates) {
53
+ return unique(candidates.map((candidate) => String(candidate.draft_rule_id || '').trim()).filter(Boolean));
54
+ }
55
+ function collectAggregationModes(candidates) {
56
+ return unique(candidates.map((candidate) => String(candidate.aggregation_mode || '').trim()).filter(Boolean));
57
+ }
58
+ function collectBindingKinds(candidates) {
59
+ return unique(candidates.map((candidate) => String(candidate.binding_kind || '').trim()).filter(Boolean));
60
+ }
52
61
  function buildCards(candidates) {
53
62
  const cards = [];
54
63
  const chunkSize = 4;
@@ -64,6 +73,9 @@ function buildCards(candidates) {
64
73
  failure_map: mergeFailureMaps(chunk),
65
74
  guarantees: claims.guarantees,
66
75
  non_guarantees: claims.non_guarantees,
76
+ draft_rule_ids: collectDraftRuleIds(chunk),
77
+ aggregation_modes: collectAggregationModes(chunk),
78
+ binding_kinds: collectBindingKinds(chunk),
67
79
  },
68
80
  });
69
81
  }
@@ -87,15 +99,43 @@ function renderReviewPack(meta, cards) {
87
99
  lines.push(`- required_hops: ${card.decision_inputs.required_hops.join(', ')}`);
88
100
  lines.push(`- guarantees: ${card.decision_inputs.guarantees.join(', ')}`);
89
101
  lines.push(`- non_guarantees: ${card.decision_inputs.non_guarantees.join(', ')}`);
102
+ lines.push(`- draft_rule_ids: ${card.decision_inputs.draft_rule_ids.join(', ')}`);
103
+ lines.push(`- aggregation_modes: ${card.decision_inputs.aggregation_modes.join(', ')}`);
104
+ lines.push(`- binding_kinds: ${card.decision_inputs.binding_kinds.join(', ')}`);
90
105
  lines.push(`- failure_map: ${JSON.stringify(card.decision_inputs.failure_map)}`);
91
106
  lines.push('');
92
107
  }
93
108
  return `${lines.join('\n')}\n`;
94
109
  }
110
+ function isENOENT(error) {
111
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
112
+ }
113
+ function sleep(ms) {
114
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
+ }
116
+ async function readCandidatesFileWithRetry(candidatesPath, analyzeCommandHint, timeoutMs = 3000, intervalMs = 100) {
117
+ const deadline = Date.now() + timeoutMs;
118
+ while (true) {
119
+ try {
120
+ return await fs.readFile(candidatesPath, 'utf-8');
121
+ }
122
+ catch (error) {
123
+ if (!isENOENT(error))
124
+ throw error;
125
+ if (Date.now() >= deadline) {
126
+ throw new Error(`Missing candidates file for review-pack: ${candidatesPath}\n` +
127
+ `Run analyze first and wait for completion, then retry review-pack.\n` +
128
+ `Suggested command: ${analyzeCommandHint}`);
129
+ }
130
+ await sleep(intervalMs);
131
+ }
132
+ }
133
+ }
95
134
  export async function buildReviewPack(input) {
96
135
  const normalizedRepoPath = path.resolve(input.repoPath);
97
136
  const paths = getRuleLabPaths(normalizedRepoPath, input.runId, input.sliceId);
98
- const raw = await fs.readFile(paths.candidatesPath, 'utf-8');
137
+ const analyzeCommandHint = `gitnexus rule-lab analyze --repo-path "${normalizedRepoPath}" --run-id "${input.runId}" --slice-id "${input.sliceId}"`;
138
+ const raw = await readCandidatesFileWithRetry(paths.candidatesPath, analyzeCommandHint);
99
139
  const candidates = parseCandidates(raw);
100
140
  const included = [];
101
141
  let tokenEstimate = 0;
@@ -46,4 +46,71 @@ describe('rule-lab review-pack', () => {
46
46
  expect(persisted).toContain('token_budget_estimate');
47
47
  await fs.rm(repoRoot, { recursive: true, force: true });
48
48
  });
49
+ it('shows actionable guidance when candidates are missing', async () => {
50
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-review-pack-missing-'));
51
+ const sliceDir = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', 'run-y', 'slices', 'slice-b');
52
+ await fs.mkdir(sliceDir, { recursive: true });
53
+ const err = await buildReviewPack({ repoPath: repoRoot, runId: 'run-y', sliceId: 'slice-b', maxTokens: 6000 })
54
+ .then(() => null)
55
+ .catch((error) => error);
56
+ expect(err).toBeTruthy();
57
+ expect(String(err?.message || '')).toMatch(/Missing candidates file for review-pack/);
58
+ expect(String(err?.message || '')).toMatch(/rule-lab analyze --repo-path/);
59
+ await fs.rm(repoRoot, { recursive: true, force: true });
60
+ });
61
+ it('waits briefly for candidates to avoid analyze/review-pack races', async () => {
62
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-review-pack-race-'));
63
+ const sliceDir = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', 'run-z', 'slices', 'slice-c');
64
+ await fs.mkdir(sliceDir, { recursive: true });
65
+ const candidatesPath = path.join(sliceDir, 'candidates.jsonl');
66
+ const line = JSON.stringify({
67
+ id: 'cand-race',
68
+ title: 'race candidate',
69
+ topology: [{ hop: 'resource', from: { entity: 'resource' }, to: { entity: 'script' }, edge: { kind: 'binds_script' } }],
70
+ evidence: { hops: [{ hop_type: 'resource', anchor: 'Assets/Test.prefab:1', snippet: 'Race' }] },
71
+ });
72
+ const delayedWrite = new Promise((resolve, reject) => {
73
+ setTimeout(() => {
74
+ fs.writeFile(candidatesPath, `${line}\n`, 'utf-8').then(() => resolve()).catch(reject);
75
+ }, 150);
76
+ });
77
+ const out = await buildReviewPack({ repoPath: repoRoot, runId: 'run-z', sliceId: 'slice-c', maxTokens: 6000 });
78
+ await delayedWrite;
79
+ expect(out.meta.total_candidates).toBe(1);
80
+ await fs.rm(repoRoot, { recursive: true, force: true });
81
+ });
82
+ it('renders decision inputs without lineage-only fields', async () => {
83
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rule-lab-review-pack-direct-'));
84
+ const sliceDir = path.join(repoRoot, '.gitnexus', 'rules', 'lab', 'runs', 'run-h', 'slices', 'slice-h');
85
+ await fs.mkdir(sliceDir, { recursive: true });
86
+ const rows = [
87
+ {
88
+ id: 'proposal-1',
89
+ title: 'event_delegate proposal accepted-a',
90
+ proposal_kind: 'per_anchor_rule',
91
+ aggregation_mode: 'per_anchor_rules',
92
+ draft_rule_id: 'unity.event.netplayer-gameover-syncvar-hook-ondeadchange.v1',
93
+ binding_kind: 'method_triggers_method',
94
+ topology: [{ hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } }],
95
+ evidence: { hops: [{ hop_type: 'code_runtime', anchor: 'Assets/A.cs:1', snippet: 'A' }] },
96
+ },
97
+ {
98
+ id: 'proposal-2',
99
+ title: 'event_delegate proposal accepted-b',
100
+ proposal_kind: 'per_anchor_rule',
101
+ aggregation_mode: 'per_anchor_rules',
102
+ draft_rule_id: 'unity.event.mirrorbattlemgr-createnetplayer-syncvar-hook-changeroomgrid.v1',
103
+ binding_kind: 'method_triggers_method',
104
+ topology: [{ hop: 'code_runtime', from: { entity: 'script' }, to: { entity: 'runtime' }, edge: { kind: 'calls' } }],
105
+ evidence: { hops: [{ hop_type: 'code_runtime', anchor: 'Assets/B.cs:2', snippet: 'B' }] },
106
+ },
107
+ ];
108
+ await fs.writeFile(path.join(sliceDir, 'candidates.jsonl'), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf-8');
109
+ const out = await buildReviewPack({ repoPath: repoRoot, runId: 'run-h', sliceId: 'slice-h', maxTokens: 6000 });
110
+ const persisted = await fs.readFile(out.paths.reviewCardsPath, 'utf-8');
111
+ expect(persisted).not.toContain('Handoff Summary');
112
+ expect(persisted).not.toContain('source_gap_candidate_ids');
113
+ expect(persisted).toContain('draft_rule_ids: unity.event.netplayer-gameover-syncvar-hook-ondeadchange.v1, unity.event.mirrorbattlemgr-createnetplayer-syncvar-hook-changeroomgrid.v1');
114
+ await fs.rm(repoRoot, { recursive: true, force: true });
115
+ });
49
116
  });
@@ -6,6 +6,7 @@ export interface RuleLabSlice {
6
6
  resource_types: string[];
7
7
  host_base_type: string[];
8
8
  required_hops?: string[];
9
+ exact_pairs?: RuleLabExactPair[];
9
10
  }
10
11
  export interface RuleLabManifest {
11
12
  run_id: string;
@@ -25,6 +26,11 @@ export interface RuleLabCandidate {
25
26
  id: string;
26
27
  title: string;
27
28
  rule_hint?: string;
29
+ proposal_kind?: 'per_anchor_rule' | 'aggregate_rule';
30
+ source_slice_id?: string;
31
+ aggregation_mode?: 'per_anchor_rules' | 'aggregate_single_rule';
32
+ binding_kind?: string;
33
+ draft_rule_id?: string;
28
34
  topology?: Array<{
29
35
  hop: string;
30
36
  from: Record<string, unknown>;
@@ -46,10 +52,33 @@ export interface RuleLabCandidate {
46
52
  missing_hop?: string;
47
53
  evidence_anchor?: string;
48
54
  }>;
55
+ closure?: {
56
+ required_hops: string[];
57
+ failure_map: Record<string, RuntimeClaimFailureReason | string>;
58
+ };
59
+ claims?: {
60
+ guarantees: string[];
61
+ non_guarantees: string[];
62
+ next_action: string;
63
+ };
64
+ proposal_evidence_keys?: string[];
65
+ exact_pair?: RuleLabExactPair;
49
66
  evidence: {
50
67
  hops: RuleLabCandidateHop[];
51
68
  };
52
69
  }
70
+ export interface RuleLabExactPairAnchor {
71
+ file: string;
72
+ line?: number;
73
+ symbol?: string;
74
+ }
75
+ export interface RuleLabExactPair {
76
+ id?: string;
77
+ binding_kind?: UnityResourceBinding['kind'];
78
+ draft_rule_id?: string;
79
+ source_anchor: RuleLabExactPairAnchor;
80
+ target_anchor: RuleLabExactPairAnchor;
81
+ }
53
82
  export interface RuleDslMatch {
54
83
  trigger_tokens: string[];
55
84
  symbol_kind?: string[];
@@ -2,6 +2,7 @@ import { GraphNode, GraphRelationship, KnowledgeGraph } from '../core/graph/type
2
2
  import { CommunityDetectionResult } from '../core/ingestion/community-processor.js';
3
3
  import { ProcessDetectionResult } from '../core/ingestion/process-processor.js';
4
4
  import type { UnityResourceProcessingResult } from '../core/ingestion/unity-resource-processor.js';
5
+ import type { UnityRuntimeBindingResult } from '../core/ingestion/unity-runtime-binding-rules.js';
5
6
  import type { ScopeSelectionDiagnostics } from '../core/ingestion/scope-filter.js';
6
7
  export type PipelinePhase = 'idle' | 'extracting' | 'structure' | 'parsing' | 'imports' | 'calls' | 'heritage' | 'communities' | 'processes' | 'enriching' | 'complete' | 'error';
7
8
  export interface PipelineProgress {
@@ -39,6 +40,7 @@ export interface PipelineResult {
39
40
  communityResult?: CommunityDetectionResult;
40
41
  processResult?: ProcessDetectionResult;
41
42
  unityResult?: UnityResourceProcessingResult;
43
+ unityRuleBindingResult?: UnityRuntimeBindingResult;
42
44
  scopeDiagnostics?: ScopeSelectionDiagnostics;
43
45
  csharpPreprocDiagnostics?: CSharpPreprocDiagnostics;
44
46
  }
@@ -47,6 +49,7 @@ export interface PipelineRuntimeSummary {
47
49
  communityResult?: CommunityDetectionResult;
48
50
  processResult?: ProcessDetectionResult;
49
51
  unityResult?: UnityResourceProcessingResult;
52
+ unityRuleBindingResult?: UnityRuntimeBindingResult;
50
53
  scopeDiagnostics?: ScopeSelectionDiagnostics;
51
54
  csharpPreprocDiagnostics?: CSharpPreprocDiagnostics;
52
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veewo/gitnexus",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -41,7 +41,8 @@
41
41
  "dev": "tsx watch src/cli/index.ts",
42
42
  "test": "vitest run test/unit",
43
43
  "test:integration": "vitest run test/integration",
44
- "test:all": "vitest run",
44
+ "test:src:node": "npm run build && node scripts/run-node-tests.mjs",
45
+ "test:all": "vitest run && npm run test:src:node",
45
46
  "test:watch": "vitest",
46
47
  "test:coverage": "vitest run --coverage",
47
48
  "prepare": "npm run build",
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const repoRoot = path.resolve(__dirname, '..');
9
+ const srcRoot = path.join(repoRoot, 'src');
10
+ const distRoot = path.join(repoRoot, 'dist');
11
+
12
+ function walk(dir, out) {
13
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
14
+ const full = path.join(dir, entry.name);
15
+ if (entry.isDirectory()) {
16
+ walk(full, out);
17
+ continue;
18
+ }
19
+ if (entry.isFile() && entry.name.endsWith('.test.ts')) {
20
+ out.push(full);
21
+ }
22
+ }
23
+ }
24
+
25
+ const sourceTests = [];
26
+ walk(srcRoot, sourceTests);
27
+
28
+ const nodeTests = sourceTests
29
+ .filter((filePath) => {
30
+ const content = readFileSync(filePath, 'utf8');
31
+ return content.includes("from 'node:test'") || content.includes('from "node:test"');
32
+ })
33
+ .map((filePath) => {
34
+ const rel = path.relative(srcRoot, filePath).replace(/\.ts$/, '.js');
35
+ return path.join(distRoot, rel);
36
+ })
37
+ .sort();
38
+
39
+ if (nodeTests.length === 0) {
40
+ console.error('No node:test suites detected under src/**/*.test.ts');
41
+ process.exit(1);
42
+ }
43
+
44
+ const missing = nodeTests.filter((filePath) => !existsSync(filePath));
45
+ if (missing.length > 0) {
46
+ console.error('Missing compiled node:test files in dist/. Run build first.');
47
+ for (const filePath of missing.slice(0, 20)) {
48
+ console.error(`- ${filePath}`);
49
+ }
50
+ if (missing.length > 20) {
51
+ console.error(`... and ${missing.length - 20} more`);
52
+ }
53
+ process.exit(1);
54
+ }
55
+
56
+ const result = spawnSync(process.execPath, ['--test', ...nodeTests], {
57
+ stdio: 'inherit',
58
+ cwd: repoRoot,
59
+ });
60
+
61
+ process.exit(result.status ?? 1);