@redpanda-data/docs-extensions-and-macros 4.11.1 โ†’ 4.12.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 (31) hide show
  1. package/bin/doc-tools.js +201 -10
  2. package/package.json +3 -1
  3. package/tools/property-extractor/COMPUTED_CONSTANTS.md +173 -0
  4. package/tools/property-extractor/Makefile +12 -1
  5. package/tools/property-extractor/README.adoc +828 -97
  6. package/tools/property-extractor/compare-properties.js +38 -13
  7. package/tools/property-extractor/constant_resolver.py +610 -0
  8. package/tools/property-extractor/file_pair.py +42 -0
  9. package/tools/property-extractor/generate-handlebars-docs.js +41 -8
  10. package/tools/property-extractor/helpers/gt.js +9 -0
  11. package/tools/property-extractor/helpers/includes.js +17 -0
  12. package/tools/property-extractor/helpers/index.js +3 -0
  13. package/tools/property-extractor/helpers/isEnterpriseEnum.js +24 -0
  14. package/tools/property-extractor/helpers/renderPropertyExample.js +6 -5
  15. package/tools/property-extractor/overrides.json +248 -0
  16. package/tools/property-extractor/parser.py +254 -32
  17. package/tools/property-extractor/property_bag.py +40 -0
  18. package/tools/property-extractor/property_extractor.py +1417 -430
  19. package/tools/property-extractor/requirements.txt +1 -0
  20. package/tools/property-extractor/templates/property-backup.hbs +161 -0
  21. package/tools/property-extractor/templates/property.hbs +104 -49
  22. package/tools/property-extractor/templates/topic-property-backup.hbs +148 -0
  23. package/tools/property-extractor/templates/topic-property.hbs +72 -34
  24. package/tools/property-extractor/tests/test_known_values.py +617 -0
  25. package/tools/property-extractor/tests/transformers_test.py +81 -6
  26. package/tools/property-extractor/topic_property_extractor.py +23 -10
  27. package/tools/property-extractor/transformers.py +2191 -369
  28. package/tools/property-extractor/type_definition_extractor.py +669 -0
  29. package/tools/redpanda-connect/helpers/renderConnectFields.js +33 -1
  30. package/tools/redpanda-connect/report-delta.js +132 -9
  31. package/tools/property-extractor/definitions.json +0 -245
package/bin/doc-tools.js CHANGED
@@ -719,6 +719,18 @@ automation
719
719
  fs.unlinkSync(tmpFile);
720
720
  dataFile = finalFile;
721
721
  console.log(`โœ… Fetched and saved: ${finalFile}`);
722
+
723
+ // Keep only 2 most recent versions in docs-data
724
+ const dataFiles = fs.readdirSync(dataDir)
725
+ .filter(f => /^connect-\d+\.\d+\.\d+\.json$/.test(f))
726
+ .sort();
727
+
728
+ while (dataFiles.length > 2) {
729
+ const oldestFile = dataFiles.shift();
730
+ const oldestPath = path.join(dataDir, oldestFile);
731
+ fs.unlinkSync(oldestPath);
732
+ console.log(`๐Ÿงน Deleted old version from docs-data: ${oldestFile}`);
733
+ }
722
734
  } catch (err) {
723
735
  console.error(`โŒ Failed to fetch connectors: ${err.message}`);
724
736
  process.exit(1);
@@ -845,6 +857,45 @@ automation
845
857
  }
846
858
 
847
859
  const newIndex = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
860
+
861
+ // Publish merged version with overrides to modules/components/attachments
862
+ if (options.overrides && fs.existsSync(options.overrides)) {
863
+ try {
864
+ const { mergeOverrides, resolveReferences } = require('../tools/redpanda-connect/generate-rpcn-connector-docs.js');
865
+
866
+ // Create a copy of newIndex to merge overrides into
867
+ const mergedData = JSON.parse(JSON.stringify(newIndex));
868
+
869
+ // Read and apply overrides
870
+ const ovRaw = fs.readFileSync(options.overrides, 'utf8');
871
+ const ovObj = JSON.parse(ovRaw);
872
+ const resolvedOverrides = resolveReferences(ovObj, ovObj);
873
+ mergeOverrides(mergedData, resolvedOverrides);
874
+
875
+ // Publish to modules/components/attachments
876
+ const attachmentsRoot = path.resolve(process.cwd(), 'modules/components/attachments');
877
+ fs.mkdirSync(attachmentsRoot, { recursive: true });
878
+
879
+ // Delete older versions from modules/components/attachments
880
+ const existingFiles = fs.readdirSync(attachmentsRoot)
881
+ .filter(f => /^connect-\d+\.\d+\.\d+\.json$/.test(f))
882
+ .sort();
883
+
884
+ for (const oldFile of existingFiles) {
885
+ const oldFilePath = path.join(attachmentsRoot, oldFile);
886
+ fs.unlinkSync(oldFilePath);
887
+ console.log(`๐Ÿงน Deleted old version: ${oldFile}`);
888
+ }
889
+
890
+ // Save merged version to modules/components/attachments
891
+ const destFile = path.join(attachmentsRoot, `connect-${newVersion}.json`);
892
+ fs.writeFileSync(destFile, JSON.stringify(mergedData, null, 2), 'utf8');
893
+ console.log(`โœ… Published merged version to: ${path.relative(process.cwd(), destFile)}`);
894
+ } catch (err) {
895
+ console.error(`โŒ Failed to publish merged version: ${err.message}`);
896
+ }
897
+ }
898
+
848
899
  printDeltaReport(oldIndex, newIndex);
849
900
 
850
901
  // Generate JSON diff file for whats-new.adoc
@@ -895,6 +946,93 @@ automation
895
946
 
896
947
  // Optionally update whats-new.adoc
897
948
  if (options.updateWhatsNew) {
949
+ // Helper function to cap description to two sentences
950
+ const capToTwoSentences = (description) => {
951
+ if (!description) return '';
952
+
953
+ // Helper to check if text contains problematic content
954
+ const hasProblematicContent = (text) => {
955
+ return /```[\s\S]*?```/.test(text) || // code blocks
956
+ /`[^`]+`/.test(text) || // inline code
957
+ /^[=#]+\s+.+$/m.test(text) || // headings
958
+ /\n/.test(text); // newlines
959
+ };
960
+
961
+ // Step 1: Replace common abbreviations and ellipses with placeholders
962
+ const abbreviations = [
963
+ /\bv\d+\.\d+(?:\.\d+)?/gi, // version numbers like v4.12 or v4.12.0 (must come before decimal)
964
+ /\d+\.\d+/g, // decimal numbers
965
+ /\be\.g\./gi, // e.g.
966
+ /\bi\.e\./gi, // i.e.
967
+ /\betc\./gi, // etc.
968
+ /\bvs\./gi, // vs.
969
+ /\bDr\./gi, // Dr.
970
+ /\bMr\./gi, // Mr.
971
+ /\bMs\./gi, // Ms.
972
+ /\bMrs\./gi, // Mrs.
973
+ /\bSt\./gi, // St.
974
+ /\bNo\./gi // No.
975
+ ];
976
+
977
+ let normalized = description;
978
+ const placeholders = [];
979
+
980
+ // Replace abbreviations with placeholders
981
+ abbreviations.forEach((abbrevRegex, idx) => {
982
+ normalized = normalized.replace(abbrevRegex, (match) => {
983
+ const placeholder = `__ABBREV${idx}_${placeholders.length}__`;
984
+ placeholders.push({ placeholder, original: match });
985
+ return placeholder;
986
+ });
987
+ });
988
+
989
+ // Replace ellipses (three or more dots) with placeholder
990
+ normalized = normalized.replace(/\.{3,}/g, (match) => {
991
+ const placeholder = `__ELLIPSIS_${placeholders.length}__`;
992
+ placeholders.push({ placeholder, original: match });
993
+ return placeholder;
994
+ });
995
+
996
+ // Step 2: Split sentences using the regex
997
+ const sentenceRegex = /[^.!?]+[.!?]+(?:\s|$)/g;
998
+ const sentences = normalized.match(sentenceRegex);
999
+
1000
+ if (!sentences || sentences.length === 0) {
1001
+ // Restore placeholders and return original
1002
+ let result = normalized;
1003
+ placeholders.forEach(({ placeholder, original }) => {
1004
+ result = result.replace(placeholder, original);
1005
+ });
1006
+ return result;
1007
+ }
1008
+
1009
+ // Step 3: Determine how many sentences to include
1010
+ let maxSentences = 2;
1011
+
1012
+ // If we have at least 2 sentences, check if the second one has problematic content
1013
+ if (sentences.length >= 2) {
1014
+ // Restore placeholders in second sentence to check original content
1015
+ let secondSentence = sentences[1];
1016
+ placeholders.forEach(({ placeholder, original }) => {
1017
+ secondSentence = secondSentence.replace(new RegExp(placeholder, 'g'), original);
1018
+ });
1019
+
1020
+ // If second sentence has problematic content, only take first sentence
1021
+ if (hasProblematicContent(secondSentence)) {
1022
+ maxSentences = 1;
1023
+ }
1024
+ }
1025
+
1026
+ let result = sentences.slice(0, maxSentences).join('');
1027
+
1028
+ // Step 4: Restore placeholders back to original text
1029
+ placeholders.forEach(({ placeholder, original }) => {
1030
+ result = result.replace(new RegExp(placeholder, 'g'), original);
1031
+ });
1032
+
1033
+ return result.trim();
1034
+ };
1035
+
898
1036
  try {
899
1037
  const whatsNewPath = path.join(findRepoRoot(), 'modules/get-started/pages/whats-new.adoc');
900
1038
  if (!fs.existsSync(whatsNewPath)) {
@@ -956,7 +1094,7 @@ automation
956
1094
  for (const comp of comps) {
957
1095
  section += `** xref:components:${type}/${comp.name}.adoc[\`${comp.name}\`]`;
958
1096
  if (comp.status) section += ` (${comp.status})`;
959
- if (comp.description) section += `: ${comp.description}`;
1097
+ if (comp.description) section += `: ${capToTwoSentences(comp.description)}`;
960
1098
  section += '\n';
961
1099
  }
962
1100
  }
@@ -977,6 +1115,60 @@ automation
977
1115
  description: field.description || '',
978
1116
  });
979
1117
  }
1118
+ for (const [type, fields] of Object.entries(fieldsByType)) {
1119
+ section += `* ${type.charAt(0).toUpperCase() + type.slice(1)}:\n`;
1120
+ // Group by component name
1121
+ const byComp = {};
1122
+ for (const f of fields) {
1123
+ if (!byComp[f.compName]) byComp[f.compName] = [];
1124
+ byComp[f.compName].push(f);
1125
+ }
1126
+ for (const [comp, compFields] of Object.entries(byComp)) {
1127
+ section += `** xref:components:${type}/${comp}.adoc[\`${comp}\`]:`;
1128
+ section += '\n';
1129
+ for (const f of compFields) {
1130
+ section += `*** xref:components:${type}/${comp}.adoc#${f.field}[\`${f.field}\`]`;
1131
+ if (f.description) section += `: ${capToTwoSentences(f.description)}`;
1132
+ section += '\n';
1133
+ }
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ // Deprecated components
1139
+ if (diff.details.deprecatedComponents && diff.details.deprecatedComponents.length) {
1140
+ section += '\n=== Deprecations\n\n';
1141
+ section += 'The following components are now deprecated:\n\n';
1142
+ // Group by type
1143
+ const byType = {};
1144
+ for (const comp of diff.details.deprecatedComponents) {
1145
+ if (!byType[comp.type]) byType[comp.type] = [];
1146
+ byType[comp.type].push(comp);
1147
+ }
1148
+ for (const [type, comps] of Object.entries(byType)) {
1149
+ section += `* ${type.charAt(0).toUpperCase() + type.slice(1)}:\n`;
1150
+ for (const comp of comps) {
1151
+ section += `** xref:components:${type}/${comp.name}.adoc[\`${comp.name}\`]\n`;
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ // Deprecated fields
1157
+ if (diff.details.deprecatedFields && diff.details.deprecatedFields.length) {
1158
+ if (!diff.details.deprecatedComponents || diff.details.deprecatedComponents.length === 0) {
1159
+ section += '\n=== Deprecations\n\n';
1160
+ }
1161
+ section += '\nThe following fields are now deprecated:\n\n';
1162
+ // Group deprecated fields by component type
1163
+ const fieldsByType = {};
1164
+ for (const field of diff.details.deprecatedFields) {
1165
+ const [type, compName] = field.component.split(':');
1166
+ if (!fieldsByType[type]) fieldsByType[type] = [];
1167
+ fieldsByType[type].push({
1168
+ compName,
1169
+ field: field.field
1170
+ });
1171
+ }
980
1172
  for (const [type, fields] of Object.entries(fieldsByType)) {
981
1173
  section += `* ${type.charAt(0).toUpperCase() + type.slice(1)} components\n`;
982
1174
  // Group by component name
@@ -986,23 +1178,20 @@ automation
986
1178
  byComp[f.compName].push(f);
987
1179
  }
988
1180
  for (const [comp, compFields] of Object.entries(byComp)) {
989
- section += `** xref:components:${type}/${comp}.adoc['${comp}']`;
1181
+ section += `** xref:components:${type}/${comp}.adoc[\`${comp}\`]`;
990
1182
  if (compFields.length === 1) {
991
1183
  const f = compFields[0];
992
- section += `: xref:components:${type}/${comp}.adoc#${f.field}['${f.field}']`;
993
- if (f.description) section += ` - ${f.description}`;
994
- section += '\n';
1184
+ section += `: xref:components:${type}/${comp}.adoc#${f.field}[\`${f.field}\`]\n`;
995
1185
  } else {
996
1186
  section += '\n';
997
1187
  for (const f of compFields) {
998
- section += `*** xref:components:${type}/${comp}.adoc#${f.field}['${f.field}']`;
999
- if (f.description) section += ` - ${f.description}`;
1000
- section += '\n';
1188
+ section += `*** xref:components:${type}/${comp}.adoc#${f.field}[\`${f.field}\`]\n`;
1001
1189
  }
1002
1190
  }
1003
1191
  }
1004
1192
  }
1005
1193
  }
1194
+
1006
1195
  let updated;
1007
1196
  if (startIdx !== -1) {
1008
1197
  // Replace the existing section
@@ -1148,8 +1337,10 @@ automation
1148
1337
 
1149
1338
  // If we used Antora's latest-redpanda-tag for diff, update it to the new tag
1150
1339
  if (!options.diff && !tagsAreSame) {
1151
- setAntoraValue('asciidoc.attributes.latest-redpanda-tag', newTag);
1152
- console.log(`โœ… Updated Antora latest-redpanda-tag to: ${newTag}`);
1340
+ const success = setAntoraValue('asciidoc.attributes.latest-redpanda-tag', newTag);
1341
+ if (success) {
1342
+ console.log(`โœ… Updated Antora latest-redpanda-tag to: ${newTag}`);
1343
+ }
1153
1344
  }
1154
1345
 
1155
1346
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "4.11.1",
3
+ "version": "4.12.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -22,6 +22,8 @@
22
22
  "build": "antora --to-dir docs --fetch local-antora-playbook.yml",
23
23
  "serve": "wds --node-resolve --open preview/test/ --watch --root-dir docs",
24
24
  "test": "jest",
25
+ "test:python": "./__tests__/tools/property-extractor/setup-and-test.sh",
26
+ "test:all": "npm run test && npm run test:python",
25
27
  "bundle:admin": "doc-tools generate bundle-openapi --surface admin",
26
28
  "bundle:connect": "doc-tools generate bundle-openapi --surface connect",
27
29
  "bundle:both": "doc-tools generate bundle-openapi --surface both"
@@ -0,0 +1,173 @@
1
+ # Computed C++ Constants Resolution
2
+
3
+ ## Overview
4
+
5
+ Some C++ constants in Redpanda are defined with complex compile-time expressions that cannot be easily parsed by the property-extractor. These constants need to be pre-computed and mapped to their actual values.
6
+
7
+ ## Problem Statement
8
+
9
+ Properties like `log_message_timestamp_before_max_ms` use constants like `max_serializable_ms` as their default values. These constants are defined with complex expressions:
10
+
11
+ ```cpp
12
+ // From src/v/serde/rw/chrono.h:20
13
+ inline constexpr auto max_serializable_ms
14
+ = std::chrono::duration_cast<std::chrono::milliseconds>(
15
+ std::chrono::nanoseconds::max());
16
+ ```
17
+
18
+ Without resolution, the extracted schema would show:
19
+ ```json
20
+ {
21
+ "log_message_timestamp_before_max_ms": {
22
+ "default": "max_serializable_ms" // โŒ String instead of numeric value
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## Solution
28
+
29
+ ### 1. COMPUTED_CONSTANTS Dictionary
30
+
31
+ Added a dictionary in `transformers.py` that maps constant names to their computed values:
32
+
33
+ ```python
34
+ COMPUTED_CONSTANTS = {
35
+ # From src/v/serde/rw/chrono.h:20
36
+ # inline constexpr auto max_serializable_ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::nanoseconds::max());
37
+ # Calculation: std::numeric_limits<int64_t>::max() / 1,000,000 = 9223372036854775807 / 1000000 = 9223372036854 ms
38
+ "max_serializable_ms": 9223372036854, # ~292 years in milliseconds
39
+ }
40
+ ```
41
+
42
+ ### 2. FriendlyDefaultTransformer Enhancement
43
+
44
+ Updated the `FriendlyDefaultTransformer` to check the `COMPUTED_CONSTANTS` dictionary before falling back to string normalization:
45
+
46
+ ```python
47
+ # ------------------------------------------------------------------
48
+ # Computed C++ constants (max_serializable_ms, etc.)
49
+ # ------------------------------------------------------------------
50
+ if d in COMPUTED_CONSTANTS:
51
+ property["default"] = COMPUTED_CONSTANTS[d]
52
+ return property
53
+ ```
54
+
55
+ ### 3. Test Coverage
56
+
57
+ Added comprehensive test in `tests/test_known_values.py`:
58
+
59
+ ```python
60
+ def test_max_serializable_ms_constant_resolution(self):
61
+ """Test that max_serializable_ms constant is resolved to actual numeric value"""
62
+ info = create_complete_property_info(
63
+ name="log_message_timestamp_before_max_ms",
64
+ description="Maximum timestamp difference for record validation",
65
+ declaration="property<std::chrono::milliseconds> log_message_timestamp_before_max_ms;",
66
+ metadata="meta{.needs_restart = needs_restart::no, .visibility = visibility::user}",
67
+ default_value="max_serializable_ms"
68
+ )
69
+
70
+ property = apply_transformer_pipeline(info)
71
+
72
+ self.assertEqual(property["name"], "log_message_timestamp_before_max_ms")
73
+ self.assertEqual(property["type"], "integer")
74
+ # max_serializable_ms = std::numeric_limits<int64_t>::max() / 1,000,000 = 9223372036854 ms
75
+ self.assertEqual(property["default"], 9223372036854)
76
+ self.assertFalse(property["needs_restart"])
77
+ self.assertEqual(property["visibility"], "user")
78
+ ```
79
+
80
+ ## Calculation Details
81
+
82
+ ### max_serializable_ms
83
+
84
+ **Definition Location:** `src/v/serde/rw/chrono.h:20`
85
+
86
+ **C++ Expression:**
87
+ ```cpp
88
+ std::chrono::duration_cast<std::chrono::milliseconds>(
89
+ std::chrono::nanoseconds::max()
90
+ )
91
+ ```
92
+
93
+ **Calculation:**
94
+ 1. `std::chrono::nanoseconds::max()` = `std::numeric_limits<int64_t>::max()` = `9223372036854775807` nanoseconds
95
+ 2. Convert to milliseconds (truncating division): `9223372036854775807 / 1000000` = `9223372036854` milliseconds
96
+ 3. This is approximately **292.47 years**
97
+
98
+ **Verification:**
99
+ ```python
100
+ import sys
101
+
102
+ # std::chrono::nanoseconds uses int64_t for rep
103
+ max_int64 = 9223372036854775807
104
+
105
+ # Convert nanoseconds to milliseconds (duration_cast truncates)
106
+ max_ms = max_int64 // 1000000
107
+
108
+ print(f'max_serializable_ms = {max_ms} ms')
109
+ print(f'Which is approximately {max_ms / (1000 * 60 * 60 * 24 * 365):.2f} years')
110
+ ```
111
+
112
+ Output:
113
+ ```
114
+ max_serializable_ms = 9223372036854 ms
115
+ Which is approximately 292.47 years
116
+ ```
117
+
118
+ ## Result
119
+
120
+ After the fix, the extracted schema correctly shows:
121
+ ```json
122
+ {
123
+ "log_message_timestamp_before_max_ms": {
124
+ "default": 9223372036854, // โœ… Correct numeric value
125
+ "type": "integer"
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Adding New Computed Constants
131
+
132
+ To add support for new computed constants:
133
+
134
+ 1. **Find the definition** in the Redpanda source code
135
+ 2. **Compute the value** - either manually or with a Python/C++ test
136
+ 3. **Add to COMPUTED_CONSTANTS** dictionary in `transformers.py`:
137
+ ```python
138
+ COMPUTED_CONSTANTS = {
139
+ "existing_constant": 12345,
140
+ "new_constant_name": computed_value, # Add comment with source location
141
+ }
142
+ ```
143
+ 4. **Add a test** in `tests/test_known_values.py` to verify resolution
144
+ 5. **Document the calculation** with comments including:
145
+ - Source file location
146
+ - C++ expression
147
+ - Calculation steps
148
+ - Human-readable interpretation
149
+
150
+ ## Test Coverage
151
+
152
+ All 53 tests pass, including the new test for `max_serializable_ms` resolution:
153
+
154
+ ```bash
155
+ cd tools/property-extractor
156
+ python -m pytest tests/ -v --tb=short
157
+ # ================================================= 53 passed =================================================
158
+ ```
159
+
160
+ ## Benefits
161
+
162
+ โœ… **Accurate defaults** - Properties show actual numeric values instead of symbolic names
163
+ โœ… **Type safety** - Numeric properties have numeric defaults, not strings
164
+ โœ… **Documentation quality** - Users see real values they can use
165
+ โœ… **Maintainability** - Centralized mapping makes updates easy
166
+ โœ… **Test coverage** - Ensures constants resolve correctly
167
+
168
+ ## Related Files
169
+
170
+ - `tools/property-extractor/transformers.py` - COMPUTED_CONSTANTS dictionary and resolution logic
171
+ - `tools/property-extractor/tests/test_known_values.py` - Test for constant resolution
172
+ - `src/v/serde/rw/chrono.h` - Source definition of max_serializable_ms
173
+ - `tools/property-extractor/CI_INTEGRATION.md` - Updated test count documentation
@@ -119,7 +119,18 @@ generate-docs: node-deps
119
119
  fi
120
120
  @echo "๐Ÿ“„ Copying properties JSON files to $(OUTPUT_JSON_DIR)โ€ฆ"
121
121
  @if [ -f "$(TOOL_ROOT)/gen/$(TAG)-properties.json" ]; then \
122
- cp "$(TOOL_ROOT)/gen/$(TAG)-properties.json" "$(OUTPUT_JSON_DIR)/"; \
122
+ echo " Source: $(TOOL_ROOT)/gen/$(TAG)-properties.json"; \
123
+ echo " Target: $(OUTPUT_JSON_DIR)/$(TAG)-properties.json"; \
124
+ cp "$(TOOL_ROOT)/gen/$(TAG)-properties.json" "$(OUTPUT_JSON_DIR)/" && \
125
+ echo " โœ… Successfully copied $(TAG)-properties.json" || \
126
+ { echo " โŒ Failed to copy $(TAG)-properties.json"; exit 1; }; \
127
+ if [ -f "$(OUTPUT_JSON_DIR)/$(TAG)-properties.json" ]; then \
128
+ echo " ๐Ÿ“Š Verification: Target file exists and has $$(wc -c < "$(OUTPUT_JSON_DIR)/$(TAG)-properties.json") bytes"; \
129
+ else \
130
+ echo " โŒ Verification failed: Target file does not exist"; exit 1; \
131
+ fi; \
132
+ else \
133
+ echo " โš ๏ธ Source file $(TOOL_ROOT)/gen/$(TAG)-properties.json not found, skipping copy"; \
123
134
  fi
124
135
  @echo "โœ… Docs generated at $(OUTPUT_AUTOGENERATED_DIR)"
125
136