cadence-skill-installer 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,20 @@ npx cadence-skill-installer --tools codex,claude,gemini --yes
34
34
  - `windsurf`
35
35
  - `opencode`
36
36
 
37
+ ## Release guardrails
38
+
39
+ This repo enforces release preflight checks through npm lifecycle scripts:
40
+
41
+ - `preversion`: cleans generated Python artifacts and requires a clean git working tree.
42
+ - `prepack`: removes generated Python artifacts before packaging.
43
+
44
+ Manual checks:
45
+
46
+ ```bash
47
+ npm run release:preflight
48
+ npm pack --dry-run
49
+ ```
50
+
37
51
  ## CI/CD Trusted Publishing (recommended)
38
52
 
39
53
  This repo includes `/Users/sn0w/Documents/dev/cadence/.github/workflows/publish.yml` for npm trusted publishing (OIDC).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadence-skill-installer",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Install the Cadence skill into supported AI tool skill directories.",
5
5
  "repository": "https://github.com/snowdamiz/cadence",
6
6
  "private": false,
@@ -12,6 +12,12 @@
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
14
14
  },
15
+ "scripts": {
16
+ "clean:artifacts": "node scripts/clean-python-artifacts.mjs",
17
+ "release:preflight": "node scripts/release-preflight.mjs",
18
+ "prepack": "node scripts/clean-python-artifacts.mjs",
19
+ "preversion": "node scripts/release-preflight.mjs"
20
+ },
15
21
  "publishConfig": {
16
22
  "access": "public"
17
23
  },
@@ -653,8 +653,20 @@ def normalize_ideation_research(
653
653
  continue
654
654
  alias_lookup.setdefault(normalized_alias, set()).add(entity["entity_id"])
655
655
 
656
+ # Track block usage from explicit topic references before alias inference.
657
+ entity_blocks: dict[str, set[str]] = {}
658
+ entity_topic_refs: dict[str, list[tuple[str, str]]] = {}
659
+ for topic_ref in flat_topics:
660
+ block_id = topic_ref["block_id"]
661
+ topic_id = _string(topic_ref["topic"].get("topic_id"))
662
+ for entity_id in topic_ref["topic"]["related_entities"]:
663
+ entity_blocks.setdefault(entity_id, set()).add(block_id)
664
+ entity_topic_refs.setdefault(entity_id, []).append((block_id, topic_id))
665
+
656
666
  for topic_ref in flat_topics:
657
667
  topic = topic_ref["topic"]
668
+ topic_block_id = topic_ref["block_id"]
669
+ topic_id = _string(topic.get("topic_id"))
658
670
  haystack = " ".join(
659
671
  [
660
672
  _string(topic.get("title")),
@@ -675,14 +687,24 @@ def normalize_ideation_research(
675
687
  detected_ids.append(entity_id)
676
688
 
677
689
  for entity_id in detected_ids:
678
- if entity_id not in topic["related_entities"]:
679
- topic["related_entities"].append(entity_id)
690
+ if entity_id in topic["related_entities"]:
691
+ continue
680
692
 
681
- entity_blocks: dict[str, set[str]] = {}
682
- for topic_ref in flat_topics:
683
- block_id = topic_ref["block_id"]
684
- for entity_id in topic_ref["topic"]["related_entities"]:
685
- entity_blocks.setdefault(entity_id, set()).add(block_id)
693
+ entity = entity_index.get(entity_id)
694
+ if entity is None:
695
+ continue
696
+
697
+ owner_block_id = _string(entity.get("owner_block_id"))
698
+ referenced_blocks = entity_blocks.get(entity_id, set())
699
+ # Alias inference is advisory only and must never introduce cross-block links.
700
+ if owner_block_id and owner_block_id != topic_block_id:
701
+ continue
702
+ if referenced_blocks and topic_block_id not in referenced_blocks:
703
+ continue
704
+
705
+ topic["related_entities"].append(entity_id)
706
+ entity_blocks.setdefault(entity_id, set()).add(topic_block_id)
707
+ entity_topic_refs.setdefault(entity_id, []).append((topic_block_id, topic_id))
686
708
 
687
709
  for entity in normalized_entities:
688
710
  entity_id = entity["entity_id"]
@@ -690,9 +712,17 @@ def normalize_ideation_research(
690
712
  owner_block_id = _string(entity.get("owner_block_id"))
691
713
 
692
714
  if len(referenced_blocks) > 1:
693
- blocks = ", ".join(sorted(referenced_blocks))
715
+ refs = ", ".join(
716
+ sorted(
717
+ f"{topic_id}@{block_id}"
718
+ for block_id, topic_id in entity_topic_refs.get(entity_id, [])
719
+ if block_id and topic_id
720
+ )
721
+ )
694
722
  raise ResearchAgendaValidationError(
695
- f"ENTITY_BLOCK_CONFLICT: entity '{entity_id}' is referenced across multiple blocks ({blocks})."
723
+ f"ENTITY_BLOCK_CONFLICT: entity '{entity_id}' is referenced across multiple blocks "
724
+ f"({', '.join(sorted(referenced_blocks))})."
725
+ f"{f' References: {refs}.' if refs else ''}"
696
726
  )
697
727
 
698
728
  if owner_block_id:
@@ -702,9 +732,17 @@ def normalize_ideation_research(
702
732
  )
703
733
  if referenced_blocks and owner_block_id not in referenced_blocks:
704
734
  block_label = _ordered_block_choice(referenced_blocks, block_order)
735
+ refs = ", ".join(
736
+ sorted(
737
+ f"{topic_id}@{block_id}"
738
+ for block_id, topic_id in entity_topic_refs.get(entity_id, [])
739
+ if block_id and topic_id
740
+ )
741
+ )
705
742
  raise ResearchAgendaValidationError(
706
743
  f"ENTITY_OWNER_MISMATCH: entity '{entity_id}' owner block '{owner_block_id}' "
707
744
  f"does not match referenced block '{block_label}'."
745
+ f"{f' References: {refs}.' if refs else ''}"
708
746
  )
709
747
  elif referenced_blocks:
710
748
  entity["owner_block_id"] = _ordered_block_choice(referenced_blocks, block_order)
@@ -0,0 +1,146 @@
1
+ import copy
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
7
+ if str(SCRIPTS_DIR) not in sys.path:
8
+ sys.path.insert(0, str(SCRIPTS_DIR))
9
+
10
+ from ideation_research import ResearchAgendaValidationError, normalize_ideation_research
11
+
12
+
13
+ def base_payload() -> dict:
14
+ return {
15
+ "objective": "test",
16
+ "research_agenda": {
17
+ "blocks": [
18
+ {
19
+ "block_id": "block-a",
20
+ "title": "Block A",
21
+ "rationale": "",
22
+ "tags": [],
23
+ "topics": [
24
+ {
25
+ "topic_id": "topic-a1",
26
+ "title": "Topic A1",
27
+ "category": "general",
28
+ "priority": "high",
29
+ "why_it_matters": "",
30
+ "research_questions": ["q"],
31
+ "keywords": [],
32
+ "tags": [],
33
+ "related_entities": [],
34
+ }
35
+ ],
36
+ },
37
+ {
38
+ "block_id": "block-b",
39
+ "title": "Block B",
40
+ "rationale": "",
41
+ "tags": [],
42
+ "topics": [
43
+ {
44
+ "topic_id": "topic-b1",
45
+ "title": "Topic B1",
46
+ "category": "general",
47
+ "priority": "high",
48
+ "why_it_matters": "",
49
+ "research_questions": ["q"],
50
+ "keywords": [],
51
+ "tags": [],
52
+ "related_entities": [],
53
+ }
54
+ ],
55
+ },
56
+ ],
57
+ "entity_registry": [],
58
+ "topic_index": {},
59
+ },
60
+ }
61
+
62
+
63
+ class IdeationResearchNormalizationTests(unittest.TestCase):
64
+ def test_alias_inference_does_not_cross_owner_block(self) -> None:
65
+ payload = base_payload()
66
+ payload["research_agenda"]["blocks"][0]["topics"][0]["related_entities"] = ["entity-marketplace-fees"]
67
+ payload["research_agenda"]["blocks"][1]["topics"][0]["keywords"] = ["royalties"]
68
+ payload["research_agenda"]["entity_registry"] = [
69
+ {
70
+ "entity_id": "entity-marketplace-fees",
71
+ "label": "Marketplace fee system",
72
+ "kind": "economy-system",
73
+ "aliases": ["royalties", "platform fees"],
74
+ "owner_block_id": "block-a",
75
+ }
76
+ ]
77
+
78
+ normalized = normalize_ideation_research(copy.deepcopy(payload), require_topics=True)
79
+ topics = normalized["research_agenda"]["blocks"]
80
+
81
+ self.assertEqual(
82
+ topics[0]["topics"][0]["related_entities"],
83
+ ["entity-marketplace-fees"],
84
+ )
85
+ self.assertEqual(topics[1]["topics"][0]["related_entities"], [])
86
+
87
+ def test_alias_inference_does_not_cross_existing_ownerless_block_reference(self) -> None:
88
+ payload = base_payload()
89
+ payload["research_agenda"]["blocks"][0]["topics"][0]["related_entities"] = ["entity-royalties"]
90
+ payload["research_agenda"]["blocks"][1]["topics"][0]["keywords"] = ["royalties"]
91
+ payload["research_agenda"]["entity_registry"] = [
92
+ {
93
+ "entity_id": "entity-royalties",
94
+ "label": "Royalties",
95
+ "kind": "economy-system",
96
+ "aliases": ["royalties"],
97
+ "owner_block_id": "",
98
+ }
99
+ ]
100
+
101
+ normalized = normalize_ideation_research(copy.deepcopy(payload), require_topics=True)
102
+ topics = normalized["research_agenda"]["blocks"]
103
+ entity = normalized["research_agenda"]["entity_registry"][0]
104
+
105
+ self.assertEqual(topics[1]["topics"][0]["related_entities"], [])
106
+ self.assertEqual(entity["owner_block_id"], "block-a")
107
+
108
+ def test_explicit_cross_block_reference_still_fails(self) -> None:
109
+ payload = base_payload()
110
+ payload["research_agenda"]["blocks"][1]["topics"][0]["related_entities"] = ["entity-marketplace-fees"]
111
+ payload["research_agenda"]["entity_registry"] = [
112
+ {
113
+ "entity_id": "entity-marketplace-fees",
114
+ "label": "Marketplace fee system",
115
+ "kind": "economy-system",
116
+ "aliases": ["royalties"],
117
+ "owner_block_id": "block-a",
118
+ }
119
+ ]
120
+
121
+ with self.assertRaises(ResearchAgendaValidationError) as ctx:
122
+ normalize_ideation_research(copy.deepcopy(payload), require_topics=True)
123
+
124
+ self.assertIn("ENTITY_OWNER_MISMATCH", str(ctx.exception))
125
+
126
+ def test_same_block_alias_inference_still_links_entity(self) -> None:
127
+ payload = base_payload()
128
+ payload["research_agenda"]["blocks"][1]["topics"][0]["keywords"] = ["royalties"]
129
+ payload["research_agenda"]["entity_registry"] = [
130
+ {
131
+ "entity_id": "entity-marketplace-fees",
132
+ "label": "Marketplace fee system",
133
+ "kind": "economy-system",
134
+ "aliases": ["royalties"],
135
+ "owner_block_id": "block-b",
136
+ }
137
+ ]
138
+
139
+ normalized = normalize_ideation_research(copy.deepcopy(payload), require_topics=True)
140
+ block_b_topic = normalized["research_agenda"]["blocks"][1]["topics"][0]
141
+
142
+ self.assertEqual(block_b_topic["related_entities"], ["entity-marketplace-fees"])
143
+
144
+
145
+ if __name__ == "__main__":
146
+ unittest.main()