@nanobpm/nano-workforce 0.71.0 → 0.72.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.
- package/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +14 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/openapi.yaml +24 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/package.json +3 -1
- package/pages/tasks.page.json +228 -172
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- package/scripts/reconcile-contracts.ts +104 -0
package/pages/tasks.page.json
CHANGED
|
@@ -7,28 +7,48 @@
|
|
|
7
7
|
"id": "nav",
|
|
8
8
|
"props": {
|
|
9
9
|
"variant": "bar",
|
|
10
|
-
"sticky": true,
|
|
11
10
|
"title": "Nano Workforce",
|
|
12
11
|
"items": [
|
|
13
|
-
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Convergence",
|
|
18
|
+
"page": "home"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Epics",
|
|
22
|
+
"page": "epic"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Feature",
|
|
26
|
+
"page": "feature"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Tasks",
|
|
30
|
+
"page": "tasks"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Cockpit",
|
|
34
|
+
"page": "cockpit"
|
|
35
|
+
}
|
|
19
36
|
]
|
|
20
37
|
}
|
|
21
38
|
},
|
|
22
39
|
{
|
|
23
40
|
"type": "text",
|
|
24
41
|
"id": "title",
|
|
25
|
-
"props": {
|
|
42
|
+
"props": {
|
|
43
|
+
"text": "Tasks",
|
|
44
|
+
"variant": "heading"
|
|
45
|
+
}
|
|
26
46
|
},
|
|
27
47
|
{
|
|
28
48
|
"type": "text",
|
|
29
49
|
"id": "subtitle",
|
|
30
50
|
"props": {
|
|
31
|
-
"text": "Open native user-task escalations awaiting a human decision
|
|
51
|
+
"text": "Open native user-task escalations awaiting a human decision — across feature runs, epics, and PR review loops. Each row parks a process on your decision; submit one and the process resumes on the same canonical path Urban's task inbox uses. Rows disappear once completed (here, via the inbox, or out-of-band).",
|
|
32
52
|
"variant": "sub"
|
|
33
53
|
}
|
|
34
54
|
},
|
|
@@ -37,57 +57,56 @@
|
|
|
37
57
|
"id": "feature-escalations",
|
|
38
58
|
"props": {
|
|
39
59
|
"title": "Feature escalations",
|
|
40
|
-
"collapsible": true,
|
|
41
|
-
"defaultCollapsed": false,
|
|
42
|
-
"showCount": true,
|
|
43
|
-
"rowKey": "user_task_key",
|
|
44
|
-
"refreshMs": 5000,
|
|
45
60
|
"data": {
|
|
46
61
|
"kind": "datasource",
|
|
47
62
|
"source": "app",
|
|
48
63
|
"table": "user_tasks",
|
|
49
|
-
"
|
|
50
|
-
|
|
64
|
+
"filter": [
|
|
65
|
+
{
|
|
66
|
+
"field": "element_id",
|
|
67
|
+
"in": [
|
|
68
|
+
"feature-escalation"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"orderBy": {
|
|
73
|
+
"field": "updated_at",
|
|
74
|
+
"dir": "desc"
|
|
75
|
+
}
|
|
51
76
|
},
|
|
52
77
|
"columns": [
|
|
53
|
-
{ "field": "subject_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "subject_key" } },
|
|
54
|
-
{ "field": "question", "header": "Question" },
|
|
55
|
-
{ "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
|
|
56
|
-
{ "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
57
|
-
{ "field": "updated_at", "header": "Updated" }
|
|
58
|
-
],
|
|
59
|
-
"rowActions": [
|
|
60
78
|
{
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
"field": "subject_key",
|
|
80
|
+
"header": "Feature"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"field": "subject_url",
|
|
84
|
+
"header": "Issue"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"field": "process_key",
|
|
88
|
+
"header": "Process",
|
|
89
|
+
"link": {
|
|
90
|
+
"kind": "processExplorer",
|
|
91
|
+
"keyField": "process_key"
|
|
67
92
|
}
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"field": "updated_at",
|
|
96
|
+
"header": "Updated"
|
|
68
97
|
}
|
|
69
98
|
],
|
|
99
|
+
"rowKey": "user_task_key",
|
|
70
100
|
"detail": {
|
|
71
101
|
"linkField": "subject_url",
|
|
72
|
-
"fields": [
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"promptField": "question",
|
|
77
|
-
"inputKey": "answer",
|
|
78
|
-
"inputLabel": "Guidance for the implementation agent",
|
|
79
|
-
"submitLabel": "Answer & re-dispatch",
|
|
80
|
-
"action": {
|
|
81
|
-
"path": "/app/api/actions/answer-escalation",
|
|
82
|
-
"successLabel": "Answered \u2014 the agent will resume",
|
|
83
|
-
"body": {
|
|
84
|
-
"userTaskKey": "{{row.user_task_key}}",
|
|
85
|
-
"resolution": "answer",
|
|
86
|
-
"answer": "{{form.answer}}"
|
|
87
|
-
}
|
|
102
|
+
"fields": [
|
|
103
|
+
{
|
|
104
|
+
"field": "question",
|
|
105
|
+
"label": "Escalation question"
|
|
88
106
|
}
|
|
89
|
-
|
|
90
|
-
}
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"refreshMs": 5000
|
|
91
110
|
}
|
|
92
111
|
},
|
|
93
112
|
{
|
|
@@ -95,56 +114,60 @@
|
|
|
95
114
|
"id": "plan-reviews",
|
|
96
115
|
"props": {
|
|
97
116
|
"title": "Plan-review decisions",
|
|
98
|
-
"collapsible": true,
|
|
99
|
-
"defaultCollapsed": false,
|
|
100
|
-
"showCount": true,
|
|
101
|
-
"rowKey": "user_task_key",
|
|
102
|
-
"refreshMs": 5000,
|
|
103
117
|
"data": {
|
|
104
118
|
"kind": "datasource",
|
|
105
119
|
"source": "app",
|
|
106
120
|
"table": "user_tasks",
|
|
107
|
-
"
|
|
108
|
-
|
|
121
|
+
"filter": [
|
|
122
|
+
{
|
|
123
|
+
"field": "element_id",
|
|
124
|
+
"in": [
|
|
125
|
+
"plan-review-decision"
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
"orderBy": {
|
|
130
|
+
"field": "updated_at",
|
|
131
|
+
"dir": "desc"
|
|
132
|
+
}
|
|
109
133
|
},
|
|
110
134
|
"columns": [
|
|
111
|
-
{ "field": "subject_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "subject_key" } },
|
|
112
|
-
{ "field": "question", "header": "Findings" },
|
|
113
|
-
{ "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
|
|
114
|
-
{ "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
115
|
-
{ "field": "updated_at", "header": "Updated" }
|
|
116
|
-
],
|
|
117
|
-
"rowActions": [
|
|
118
135
|
{
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
"field": "subject_key",
|
|
137
|
+
"header": "Epic"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"field": "question",
|
|
141
|
+
"header": "Findings"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"field": "subject_url",
|
|
145
|
+
"header": "Issue"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"field": "process_key",
|
|
149
|
+
"header": "Process",
|
|
150
|
+
"link": {
|
|
151
|
+
"kind": "processExplorer",
|
|
152
|
+
"keyField": "process_key"
|
|
125
153
|
}
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"field": "updated_at",
|
|
157
|
+
"header": "Updated"
|
|
126
158
|
}
|
|
127
159
|
],
|
|
160
|
+
"rowKey": "user_task_key",
|
|
128
161
|
"detail": {
|
|
129
162
|
"linkField": "subject_url",
|
|
130
|
-
"fields": [
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"promptField": "question",
|
|
135
|
-
"inputKey": "notes",
|
|
136
|
-
"inputLabel": "Revision guidance for the planner",
|
|
137
|
-
"submitLabel": "Revise \u2014 re-plan",
|
|
138
|
-
"action": {
|
|
139
|
-
"path": "/app/api/actions/complete-user-task",
|
|
140
|
-
"successLabel": "Sent back \u2014 the planner will revise",
|
|
141
|
-
"body": {
|
|
142
|
-
"userTaskKey": "{{row.user_task_key}}",
|
|
143
|
-
"variables": { "directive": "revise", "notes": "{{form.notes}}" }
|
|
144
|
-
}
|
|
163
|
+
"fields": [
|
|
164
|
+
{
|
|
165
|
+
"field": "question",
|
|
166
|
+
"label": "Review findings"
|
|
145
167
|
}
|
|
146
|
-
|
|
147
|
-
}
|
|
168
|
+
]
|
|
169
|
+
},
|
|
170
|
+
"refreshMs": 5000
|
|
148
171
|
}
|
|
149
172
|
},
|
|
150
173
|
{
|
|
@@ -152,58 +175,60 @@
|
|
|
152
175
|
"id": "trial-merges",
|
|
153
176
|
"props": {
|
|
154
177
|
"title": "Trial-merge decisions",
|
|
155
|
-
"collapsible": true,
|
|
156
|
-
"defaultCollapsed": false,
|
|
157
|
-
"showCount": true,
|
|
158
|
-
"rowKey": "user_task_key",
|
|
159
|
-
"refreshMs": 5000,
|
|
160
178
|
"data": {
|
|
161
179
|
"kind": "datasource",
|
|
162
180
|
"source": "app",
|
|
163
181
|
"table": "user_tasks",
|
|
164
|
-
"
|
|
165
|
-
|
|
182
|
+
"filter": [
|
|
183
|
+
{
|
|
184
|
+
"field": "element_id",
|
|
185
|
+
"in": [
|
|
186
|
+
"trial-merge-decision"
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
"orderBy": {
|
|
191
|
+
"field": "updated_at",
|
|
192
|
+
"dir": "desc"
|
|
193
|
+
}
|
|
166
194
|
},
|
|
167
195
|
"columns": [
|
|
168
|
-
{ "field": "subject_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "subject_key" } },
|
|
169
|
-
{ "field": "question", "header": "Trial merge" },
|
|
170
|
-
{ "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
|
|
171
|
-
{ "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
172
|
-
{ "field": "updated_at", "header": "Updated" }
|
|
173
|
-
],
|
|
174
|
-
"rowActions": [
|
|
175
196
|
{
|
|
176
|
-
"
|
|
177
|
-
"
|
|
178
|
-
"showWhenField": "user_task_key",
|
|
179
|
-
"action": {
|
|
180
|
-
"path": "/app/api/actions/complete-user-task",
|
|
181
|
-
"body": { "userTaskKey": "{{row.user_task_key}}", "variables": { "action": "proceed" } }
|
|
182
|
-
}
|
|
197
|
+
"field": "subject_key",
|
|
198
|
+
"header": "Epic"
|
|
183
199
|
},
|
|
184
200
|
{
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"showWhenField": "user_task_key",
|
|
188
|
-
"action": {
|
|
189
|
-
"path": "/app/api/actions/complete-user-task",
|
|
190
|
-
"body": { "userTaskKey": "{{row.user_task_key}}", "variables": { "action": "rebase" } }
|
|
191
|
-
}
|
|
201
|
+
"field": "question",
|
|
202
|
+
"header": "Trial merge"
|
|
192
203
|
},
|
|
193
204
|
{
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
"field": "subject_url",
|
|
206
|
+
"header": "Issue"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"field": "process_key",
|
|
210
|
+
"header": "Process",
|
|
211
|
+
"link": {
|
|
212
|
+
"kind": "processExplorer",
|
|
213
|
+
"keyField": "process_key"
|
|
200
214
|
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"field": "updated_at",
|
|
218
|
+
"header": "Updated"
|
|
201
219
|
}
|
|
202
220
|
],
|
|
221
|
+
"rowKey": "user_task_key",
|
|
203
222
|
"detail": {
|
|
204
223
|
"linkField": "subject_url",
|
|
205
|
-
"fields": [
|
|
206
|
-
|
|
224
|
+
"fields": [
|
|
225
|
+
{
|
|
226
|
+
"field": "question",
|
|
227
|
+
"label": "Trial merge"
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
"refreshMs": 5000
|
|
207
232
|
}
|
|
208
233
|
},
|
|
209
234
|
{
|
|
@@ -211,45 +236,56 @@
|
|
|
211
236
|
"id": "pr-reviews",
|
|
212
237
|
"props": {
|
|
213
238
|
"title": "PR review escalations",
|
|
214
|
-
"collapsible": true,
|
|
215
|
-
"defaultCollapsed": false,
|
|
216
|
-
"showCount": true,
|
|
217
|
-
"rowKey": "user_task_key",
|
|
218
|
-
"refreshMs": 5000,
|
|
219
239
|
"data": {
|
|
220
240
|
"kind": "datasource",
|
|
221
241
|
"source": "app",
|
|
222
242
|
"table": "user_tasks",
|
|
223
|
-
"
|
|
224
|
-
|
|
243
|
+
"filter": [
|
|
244
|
+
{
|
|
245
|
+
"field": "element_id",
|
|
246
|
+
"in": [
|
|
247
|
+
"wait-answer"
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
],
|
|
251
|
+
"orderBy": {
|
|
252
|
+
"field": "updated_at",
|
|
253
|
+
"dir": "desc"
|
|
254
|
+
}
|
|
225
255
|
},
|
|
226
256
|
"columns": [
|
|
227
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
{
|
|
257
|
+
{
|
|
258
|
+
"field": "subject_key",
|
|
259
|
+
"header": "PR"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"field": "subject_url",
|
|
263
|
+
"header": "PR link"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"field": "process_key",
|
|
267
|
+
"header": "Process",
|
|
268
|
+
"link": {
|
|
269
|
+
"kind": "processExplorer",
|
|
270
|
+
"keyField": "process_key"
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"field": "updated_at",
|
|
275
|
+
"header": "Updated"
|
|
276
|
+
}
|
|
232
277
|
],
|
|
278
|
+
"rowKey": "user_task_key",
|
|
233
279
|
"detail": {
|
|
234
280
|
"linkField": "subject_url",
|
|
235
|
-
"fields": [
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
"promptField": "question",
|
|
240
|
-
"inputKey": "answer",
|
|
241
|
-
"inputLabel": "Your answer (resumes the review loop, handed to the next round)",
|
|
242
|
-
"submitLabel": "Answer \u2014 resume review",
|
|
243
|
-
"action": {
|
|
244
|
-
"path": "/app/api/actions/complete-user-task",
|
|
245
|
-
"successLabel": "Answered \u2014 the review loop will resume",
|
|
246
|
-
"body": {
|
|
247
|
-
"userTaskKey": "{{row.user_task_key}}",
|
|
248
|
-
"variables": { "answer": "{{form.answer}}" }
|
|
249
|
-
}
|
|
281
|
+
"fields": [
|
|
282
|
+
{
|
|
283
|
+
"field": "question",
|
|
284
|
+
"label": "Escalation question"
|
|
250
285
|
}
|
|
251
|
-
|
|
252
|
-
}
|
|
286
|
+
]
|
|
287
|
+
},
|
|
288
|
+
"refreshMs": 5000
|
|
253
289
|
}
|
|
254
290
|
},
|
|
255
291
|
{
|
|
@@ -257,41 +293,61 @@
|
|
|
257
293
|
"id": "blocked-runs",
|
|
258
294
|
"props": {
|
|
259
295
|
"title": "Blocked feature runs",
|
|
260
|
-
"collapsible": true,
|
|
261
|
-
"defaultCollapsed": false,
|
|
262
|
-
"showCount": true,
|
|
263
|
-
"rowKey": "user_task_key",
|
|
264
|
-
"refreshMs": 5000,
|
|
265
296
|
"data": {
|
|
266
297
|
"kind": "datasource",
|
|
267
298
|
"source": "app",
|
|
268
299
|
"table": "user_tasks",
|
|
269
|
-
"
|
|
270
|
-
|
|
300
|
+
"filter": [
|
|
301
|
+
{
|
|
302
|
+
"field": "element_id",
|
|
303
|
+
"in": [
|
|
304
|
+
"feature-blocked"
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
],
|
|
308
|
+
"orderBy": {
|
|
309
|
+
"field": "updated_at",
|
|
310
|
+
"dir": "desc"
|
|
311
|
+
}
|
|
271
312
|
},
|
|
272
313
|
"columns": [
|
|
273
|
-
{ "field": "subject_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "subject_key" } },
|
|
274
|
-
{ "field": "question", "header": "Disposition" },
|
|
275
|
-
{ "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
|
|
276
|
-
{ "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
277
|
-
{ "field": "updated_at", "header": "Updated" }
|
|
278
|
-
],
|
|
279
|
-
"rowActions": [
|
|
280
314
|
{
|
|
281
|
-
"
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
315
|
+
"field": "subject_key",
|
|
316
|
+
"header": "Feature"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"field": "question",
|
|
320
|
+
"header": "Disposition"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"field": "subject_url",
|
|
324
|
+
"header": "Issue"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"field": "process_key",
|
|
328
|
+
"header": "Process",
|
|
329
|
+
"link": {
|
|
330
|
+
"kind": "processExplorer",
|
|
331
|
+
"keyField": "process_key"
|
|
287
332
|
}
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
"field": "updated_at",
|
|
336
|
+
"header": "Updated"
|
|
288
337
|
}
|
|
289
338
|
],
|
|
339
|
+
"rowKey": "user_task_key",
|
|
290
340
|
"detail": {
|
|
291
341
|
"linkField": "subject_url",
|
|
292
|
-
"fields": [
|
|
293
|
-
|
|
342
|
+
"fields": [
|
|
343
|
+
{
|
|
344
|
+
"field": "question",
|
|
345
|
+
"label": "Disposition"
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
},
|
|
349
|
+
"refreshMs": 5000
|
|
294
350
|
}
|
|
295
351
|
}
|
|
296
352
|
]
|
|
297
|
-
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Coverage for the config-key SCAN in the contract gate (scripts/check-contracts.ts, PR #229).
|
|
2
|
+
//
|
|
3
|
+
// The gate is only as strong as the read patterns it recognises. Before this fix it matched only
|
|
4
|
+
// dot-access (`process.env.KEY`), so a config-family key smuggled in through the `envVar("KEY")`
|
|
5
|
+
// helper or string-literal bracket-access silently bypassed the "must be declared" invariant — which
|
|
6
|
+
// is how NANO_PR_WEBHOOK_SECRET / NANO_AGENTIC* stayed undeclared while `check:contracts` passed
|
|
7
|
+
// green. These assert `envKeyReads` sees all three patterns so the gate can hold them to the registry.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import { envKeyReads, EXPLICIT_CONFIG_KEYS, toPosixRel } from "./check-contracts.ts";
|
|
11
|
+
import { ENV_CONTRACTS } from "../app/contracts.ts";
|
|
12
|
+
|
|
13
|
+
test("envKeyReads: dot-access is recognised", () => {
|
|
14
|
+
assertEquals(envKeyReads("const x = process.env.NANO_PR_POLL_MS;"), ["NANO_PR_POLL_MS"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("envKeyReads: string-literal bracket-access is recognised (double and single quote)", () => {
|
|
18
|
+
assert(envKeyReads('process.env["NANO_PR_WEBHOOK_SECRET"]').includes("NANO_PR_WEBHOOK_SECRET"));
|
|
19
|
+
assert(envKeyReads("process.env['NANO_AGENTIC']").includes("NANO_AGENTIC"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("envKeyReads: the envVar(\"KEY\") helper is recognised", () => {
|
|
23
|
+
assert(envKeyReads('const s = envVar("NANO_AGENTIC_SECRET") ?? "";').includes("NANO_AGENTIC_SECRET"));
|
|
24
|
+
assert(envKeyReads('envVar( "NANO_WORKFORCE_GIT_SHA" )').includes("NANO_WORKFORCE_GIT_SHA"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("envKeyReads: catches keys across all patterns in one source", () => {
|
|
28
|
+
const src = [
|
|
29
|
+
"const a = process.env.CAMUNDA_TRANSPORT;",
|
|
30
|
+
'const b = process.env["NANOBPMN_BASE_URL"];',
|
|
31
|
+
'const c = envVar("NANO_WORKFORCE_GIT_SHA");',
|
|
32
|
+
].join("\n");
|
|
33
|
+
const keys = new Set(envKeyReads(src));
|
|
34
|
+
assert(keys.has("CAMUNDA_TRANSPORT"));
|
|
35
|
+
assert(keys.has("NANOBPMN_BASE_URL"));
|
|
36
|
+
assert(keys.has("NANO_WORKFORCE_GIT_SHA"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("envKeyReads: a dynamic (non-literal) read is NOT matched", () => {
|
|
40
|
+
// `process.env[name]` (a variable) can't be resolved statically, so it isn't reported.
|
|
41
|
+
assertEquals(envKeyReads("const v = process.env[name];"), []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("GITHUB_TOKEN is enforced by the gate and declared in the registry (PR #229 suppressed advisory)", () => {
|
|
45
|
+
// GITHUB_TOKEN is read in production outside the config families (app/service.ts,
|
|
46
|
+
// operations/startPlanFanout.ts). Pin it into the enforced set so the gate holds it to its
|
|
47
|
+
// registry declaration — a future removal from ENV_CONTRACTS while code still reads it must fail.
|
|
48
|
+
assert(EXPLICIT_CONFIG_KEYS.has("GITHUB_TOKEN"), "GITHUB_TOKEN must be an enforced config key");
|
|
49
|
+
assert("GITHUB_TOKEN" in ENV_CONTRACTS, "GITHUB_TOKEN must be declared in ENV_CONTRACTS");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("toPosixRel: normalises Windows backslashes so EXEMPT_FILES matches cross-platform (PR #229 suppressed advisory)", () => {
|
|
53
|
+
// On Windows `join` yields backslashes, so `scripts\check-contracts.ts` would never match the
|
|
54
|
+
// POSIX-style EXEMPT_FILES entry and the checker would self-scan and explode. Normalise them.
|
|
55
|
+
assertEquals(toPosixRel("C:\\repo\\scripts\\check-contracts.ts", "C:\\repo"), "scripts/check-contracts.ts");
|
|
56
|
+
// POSIX paths are already correct and pass through unchanged.
|
|
57
|
+
assertEquals(toPosixRel("/repo/scripts/check-contracts.ts", "/repo"), "scripts/check-contracts.ts");
|
|
58
|
+
});
|