cht-conf 6.5.2 → 6.6.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [6.6.0](https://github.com/medic/cht-conf/compare/v6.5.2...v6.6.0) (2026-08-21)
2
+
3
+
4
+ ### Features
5
+
6
+ * **#804:** support linting form field paths during validation ([#805](https://github.com/medic/cht-conf/issues/805)) ([30853a7](https://github.com/medic/cht-conf/commit/30853a795658e626a533871d67023be91ada7801)), closes [#804](https://github.com/medic/cht-conf/issues/804)
7
+
1
8
  ## [6.5.2](https://github.com/medic/cht-conf/compare/v6.5.1...v6.5.2) (2026-08-10)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "6.5.2",
3
+ "version": "6.6.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -81,9 +81,11 @@ module.exports = {
81
81
  /**
82
82
  * Returns the `bind` nodes for the given form XML.
83
83
  * @param {Document} xmlDoc the form XML document
84
+ * @param {string} filter the form XML bind nodes (optional).
85
+ * Example: [not(@nodeset = "/data/inputs/user/contact_id")]
84
86
  * @returns {Element}
85
87
  */
86
- getBindNodes: xmlDoc => getNodes(xmlDoc, `${XPATH_MODEL}/bind`),
88
+ getBindNodes: (xmlDoc, filter = '') => getNodes(xmlDoc, `${XPATH_MODEL}/bind${filter}`),
87
89
 
88
90
  /**
89
91
  * Returns the `instance` node with the given ID for the given form XML.
@@ -35,6 +35,14 @@ const validations = fs.readdir(VALIDATIONS_PATH)
35
35
  return a.name.localeCompare(b.name);
36
36
  });
37
37
 
38
+ const getPropsData = (propsJson) => {
39
+ if(!fs.exists(propsJson) || !fs.statSync(propsJson).isFile()){
40
+ return {};
41
+ }
42
+
43
+ return fs.readJson(propsJson) || {};
44
+ };
45
+
38
46
  module.exports = async (projectDir, subDirectory, options={}) => {
39
47
 
40
48
  const formsDir = getFormDir(projectDir, subDirectory);
@@ -53,13 +61,15 @@ module.exports = async (projectDir, subDirectory, options={}) => {
53
61
  for(const fileName of fileNames) {
54
62
  log.info(`Validating form: ${fileName}…`);
55
63
 
56
- const { xformPath } = getFormFilePaths(formsDir, fileName);
64
+ const { xformPath, baseFileName } = getFormFilePaths(formsDir, fileName);
57
65
  const xml = fs.read(xformPath);
66
+ const propsData = getPropsData(`${formsDir}/${baseFileName}.properties.json`);
58
67
 
59
68
  const valParams = {
60
69
  xformPath,
61
70
  xmlStr: xml,
62
71
  xmlDoc: domParser.parseFromString(xml),
72
+ propsData,
63
73
  apiVersion,
64
74
  };
65
75
  for(const validation of validations) {
@@ -20,7 +20,7 @@ module.exports = {
20
20
  |-------------------------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
21
21
  | `requiresInstance` | Optional, defaults to `true` | The validation needs the user to have provided a instance location, e.g. via `--local` or `--instance` |
22
22
  | `skipFurtherValidation` | Optional, defaults to `false` | When `true`, additional validations will not be performed on a form that fails the current validation. The goal is to avoid unnecessary noise in the validation log for forms that are grossly invalid (e.g. XML files that are not actually xForms at all). |
23
- | `execute(opts)` | Required | The function that is run when the validation is executed. The available ops are:<br>* `xformPath` - the file path to the form<br>* `xmlStr` - string value of the form XML<br>* `xmlDoc` - Document value of the form XML<br>* `apiVersion` - the version of the CHT instance (if connection information was provided) or `null` |
23
+ | `execute(opts)` | Required | The function that is run when the validation is executed. The available ops are:<br>* `xformPath` - the file path to the form<br>* `xmlStr` - string value of the form XML<br>* `xmlDoc` - Document value of the form XML<br>* `propsData` - JSON contents of the form XML's properties file<br>* `apiVersion` - the version of the CHT instance (if connection information was provided) or `null` |
24
24
 
25
25
  ## Result
26
26
 
@@ -0,0 +1,148 @@
1
+ const { getBindNodes } = require('../../forms-utils');
2
+ const joi = require('joi');
3
+
4
+ const XML_ATT_NODESET = 'nodeset';
5
+
6
+ const schema = joi.object({
7
+ warn_length: joi.number().integer().min(0).when('error_length', {
8
+ is: joi.number().required().greater(0),
9
+ then: joi.number().less(joi.ref('error_length')),
10
+ }).optional(),
11
+ error_length: joi.number().integer().min(0).optional(),
12
+ ignore_list: joi.array().items(joi.string().pattern(/[`'"]/, { invert: true })).optional().default([]),
13
+ reserved_list: joi.array().items(joi.string().pattern(/[`'"]/, { invert: true })).optional().default([]),
14
+ });
15
+
16
+ function formatFeedbackMsg(title, items, footer) {
17
+ return `${title}\n${items.join('\n')}\n${footer}`;
18
+ }
19
+
20
+ function checkListOverlap(ignoreList, reservedList) {
21
+ if (!ignoreList.length || !reservedList.length) {
22
+ return;
23
+ }
24
+
25
+ const reservedSet = new Set(reservedList);
26
+ const overlap = ignoreList.filter(x => reservedSet.has(x));
27
+
28
+ if (overlap.length > 0) {
29
+ throw new Error(formatFeedbackMsg(
30
+ 'Overlap between reserved and ignore lists:',
31
+ overlap,
32
+ 'Please remove where appropriate.'
33
+ ));
34
+ }
35
+ }
36
+
37
+ function validatePropDataOrThrow(props) {
38
+ const { error, value } = schema.validate(props, { abortEarly: false });
39
+ if (error) {
40
+ throw new Error(error.details.map(d => d.message).join('; '));
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function buildPath(entry) {
46
+ return `/data/${entry.replace(/^\/?(data\/)?/, '')}`;
47
+ }
48
+
49
+ function unpackProps(props) {
50
+ const warnLength = props['warn_length'];
51
+ const errorLength = props['error_length'];
52
+ const ignoreList = props['ignore_list'].map(buildPath);
53
+ const reservedList = props['reserved_list'].map(buildPath);
54
+
55
+ return { warnLength, errorLength, ignoreList, reservedList };
56
+ }
57
+
58
+ function buildExclusionPath(list) {
59
+ if (!list.length) {
60
+ return '';
61
+ }
62
+ const conditions = list.map(v => `@${XML_ATT_NODESET} = "${v}"`).join(' or ');
63
+ return `[not(${conditions})]`;
64
+ }
65
+
66
+ function getFilteredBindNodes(xmlDoc, ignoreList) {
67
+ return getBindNodes(xmlDoc, buildExclusionPath(ignoreList));
68
+ }
69
+
70
+ function getPathsWithLength(bindNodePaths, targetLength) {
71
+ if (!targetLength) {
72
+ return [];
73
+ }
74
+
75
+ return bindNodePaths.filter(path => path.length >= targetLength);
76
+ }
77
+
78
+ function validateReservedNodes(bindNodePaths, reserved) {
79
+ const reservedNodes = bindNodePaths.filter(path => reserved.includes(path));
80
+ if (reservedNodes.length > 0) {
81
+ throw new Error(formatFeedbackMsg(
82
+ 'The following reserved entries were found in the form:',
83
+ reservedNodes,
84
+ 'Please remove or rename as appropriate.'
85
+ ));
86
+ }
87
+ }
88
+
89
+ function validateWarnLength(bindNodePaths, warnLength) {
90
+ const warnLengthPaths = getPathsWithLength(bindNodePaths, warnLength);
91
+ if (warnLengthPaths.length > 0) {
92
+ return formatFeedbackMsg(
93
+ `The following vars are longer than the acceptable var length (${warnLength}):`,
94
+ warnLengthPaths,
95
+ 'Please consider simplifying nesting or removing verbosity.'
96
+ );
97
+ }
98
+ }
99
+
100
+ function validateErrorLength(bindNodePaths, errorLength) {
101
+ const errorLengthPaths = getPathsWithLength(bindNodePaths, errorLength);
102
+ if (errorLengthPaths.length > 0) {
103
+ throw new Error(formatFeedbackMsg(
104
+ `The following vars are longer than the acceptable var length (${errorLength}):`,
105
+ errorLengthPaths,
106
+ 'Please simplify nesting or remove verbosity.'
107
+ ));
108
+ }
109
+ }
110
+
111
+ function validateFieldPaths(xmlDoc, props) {
112
+ const validatedProps = validatePropDataOrThrow(props);
113
+
114
+ const { warnLength, errorLength, ignoreList, reservedList } = unpackProps(validatedProps);
115
+
116
+ checkListOverlap(ignoreList, reservedList);
117
+ const bindNodes = getFilteredBindNodes(xmlDoc, ignoreList);
118
+ const bindNodePaths = bindNodes.map(node => node.getAttribute(XML_ATT_NODESET));
119
+ validateReservedNodes(bindNodePaths, reservedList);
120
+ validateErrorLength(bindNodePaths, errorLength);
121
+ const warning = validateWarnLength(bindNodePaths, warnLength);
122
+
123
+ return warning ? [ warning ] : [];
124
+ }
125
+
126
+ async function execute({ xmlDoc, propsData }) {
127
+ const warnings = [];
128
+ const errors = [];
129
+ try {
130
+ const fieldPathLinting = propsData.field_path_linting;
131
+ if (!fieldPathLinting || Object.keys(fieldPathLinting).length === 0) {
132
+ return { warnings, errors };
133
+ }
134
+
135
+ warnings.push(...validateFieldPaths(xmlDoc, fieldPathLinting));
136
+ }
137
+ catch (e) {
138
+ errors.push(e.message);
139
+ }
140
+
141
+ return { warnings, errors };
142
+ }
143
+
144
+ module.exports = {
145
+ requiresInstance: false,
146
+ skipFurtherValidation: false,
147
+ execute
148
+ };