@drawbridge/drawbridge-agents 0.1.71 → 0.1.74
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/.claude-template/agents/drawbridge-qa.md +20 -0
- package/.claude-template/skills/drawbridge-feature/SKILL.md +11 -0
- package/conventions/cross-repo-contracts.md +11 -3
- package/conventions/testing.md +8 -0
- package/conventions/walk-the-app.md +20 -0
- package/hooks/drift-check.js +40 -5
- package/package.json +3 -2
- package/scripts/check-stories.js +48 -7
- package/scripts/check-streams.js +43 -0
|
@@ -36,6 +36,26 @@ session scratch directory that reuses the saved session
|
|
|
36
36
|
(`drawbridge-tests/e2e/.auth/user.json`, `lib/basic-auth.js`), performs the steps, and
|
|
37
37
|
screenshots after each. `drawbridge-tests/e2e/walk.js` is the pattern to copy.
|
|
38
38
|
|
|
39
|
+
## How to seed an entrant (a scenario that needs a lead, an entry or a winner)
|
|
40
|
+
|
|
41
|
+
Never insert a lead by hand. A hand-written lead carries subtotals the product never
|
|
42
|
+
counted, and removing it afterwards drives the campaign's Entries and Submissions below zero
|
|
43
|
+
(Asana 1218811920761541). Seed the way the product does, through
|
|
44
|
+
`drawbridge-tests/lib/seed.js`:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
const { removeEntrant, seedEntrant } = require( './lib/seed' );
|
|
48
|
+
|
|
49
|
+
const seeded = await seedEntrant({ campaign : '<campaign id>', email : 'qa+<task id>@…', entries : 1 });
|
|
50
|
+
// … walk the scenario; seeded.lead, seeded.entry, seeded.submission are the ids …
|
|
51
|
+
await removeEntrant( seeded );
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It writes a zero-count lead plus one pending submission and waits for the sync worker to
|
|
55
|
+
process it, exactly as a share-page submission does; removing it nets every counter back to
|
|
56
|
+
zero. If a scenario needs something the helper cannot seed, the scenario is `blocked` with
|
|
57
|
+
that as the reason, never seeded by hand.
|
|
58
|
+
|
|
39
59
|
## How to check an inbox (`@inbox` scenarios)
|
|
40
60
|
|
|
41
61
|
The connected Gmail account is the QA mailbox. Trigger the email with a recipient of the form
|
|
@@ -30,6 +30,17 @@ check refuses a touched story that fails it. The QA agent in step 5 checks exact
|
|
|
30
30
|
reads the mailbox for `@inbox`, and fails an untagged scenario that names nothing visible. Get
|
|
31
31
|
Darren's yes on the story before step 2.
|
|
32
32
|
|
|
33
|
+
**List every way the change is undone, before the questions.** Name each state the change
|
|
34
|
+
writes (a row, a status, a vendor record, a counter) and each way a person or the system
|
|
35
|
+
reverses it: resubscribe, START, disconnect, unpublish, a row deleted, a segment left. For
|
|
36
|
+
each one, write down what else has to follow the reversal, such as connected apps or
|
|
37
|
+
counters, and give it a scenario. If nothing can reverse it, tag the scenario `@one-way`.
|
|
38
|
+
This list goes at the top of the numbered list Darren reads. `check-stories` refuses a When
|
|
39
|
+
whose undo has no scenario in the domain folder. `check-streams` refuses a sync stream that
|
|
40
|
+
handles a write and says nothing about a delete. Darren, 2026-09-24: the opt-out fix carried
|
|
41
|
+
an unsubscribe to Mailchimp and never asked what a Resubscribe does, so people who opted
|
|
42
|
+
back in stayed unsubscribed.
|
|
43
|
+
|
|
33
44
|
**Ask for the access QA will need, in the same numbered list.** Walk the scenarios you just
|
|
34
45
|
wrote and name what the QA agent must reach to check them: a screen on dev (nothing to ask),
|
|
35
46
|
an inbox (`@inbox`: the connected Gmail mailbox with a `+<task id>` tag, or a shared box such
|
|
@@ -242,6 +242,13 @@ skill) so it stops living only in tribal memory.
|
|
|
242
242
|
breaks the audit. Detail: drawbridge-docs `reference/billing.md`.
|
|
243
243
|
- Every `$inc` on a `totals.*` counter needs a matching entry in `drawbridge-sync/lib/totals.js`
|
|
244
244
|
`SOURCES` — that map is the only drift backstop.
|
|
245
|
+
- **A count never reads below zero.** Every decrement of a `totals.*` counter goes through
|
|
246
|
+
`drawbridge-sync/lib/totals.js` `adjustTotals` / `flooredTotals`, a pipeline update that clamps
|
|
247
|
+
each field at zero in the same write. A bare `$inc` with a negative delta, or a `{ $gt : 0 }`
|
|
248
|
+
query guard that skips the whole write, is the shape that drove a campaign to Entries -5
|
|
249
|
+
(Asana 1218811920761541). Test data is seeded through `drawbridge-tests/lib/seed.js` so the
|
|
250
|
+
worker does the counting; a lead inserted by hand with subtotals was never counted and its
|
|
251
|
+
delete decrements what was never added.
|
|
245
252
|
- The `invoice` collection's **unique partial index on `stripeInvoiceId`** (drawbridge-api
|
|
246
253
|
`schema/invoice.js`) and sync's `upsertInvoice` E11000 catch (`lib/buffer.js`) are one contract:
|
|
247
254
|
sync upserts the same invoice from three concurrent webhooks (finalized/payment_succeeded/voided)
|
|
@@ -593,8 +600,8 @@ api's identity/organization routes); the merged keys keep the old names. Reading
|
|
|
593
600
|
- **No Shopify webhook reaches the embedded app.** Every `[[webhooks.subscriptions]]` in
|
|
594
601
|
drawbridge-shopify-app's `shopify.app.*.toml` — `app/uninstalled` included — is delivered to
|
|
595
602
|
drawbridge-webhooks (`route/shopify.js`, topic-agnostic → `buffer` doc), which drawbridge-sync
|
|
596
|
-
consumes (`lib/buffer.js` `shopify.app/uninstalled` → delete connections, hard-delete the
|
|
597
|
-
record). So `app/routes/webhooks.app.uninstalled.tsx` is unreachable and **nothing in the
|
|
603
|
+
consumes (`lib/buffer.js` `shopify.app/uninstalled` → delete connections, hard-delete the store's
|
|
604
|
+
`install` record). So `app/routes/webhooks.app.uninstalled.tsx` is unreachable and **nothing in the
|
|
598
605
|
embedded app may hold per-shop state that needs invalidating on uninstall** — no memo, no cache,
|
|
599
606
|
no "already done" set. Violated once: `ensureChannel` cached "this shop is bound to our channel
|
|
600
607
|
spec" in a process-local Set cleared only by that dead route, so uninstall → reinstall skipped
|
|
@@ -616,7 +623,8 @@ api's identity/organization routes); the merged keys keep the old names. Reading
|
|
|
616
623
|
`extend.organization({ connections : true })` (org GET + org menu opt in via
|
|
617
624
|
`hasOrganizationAccess`); app-web's `hasConnection` helper (`lib/helpers.js`) and api's
|
|
618
625
|
`lib/tasks.js connectionErrors` read it. Two invariants: the lookup's `$project`
|
|
619
|
-
(id/slug/status/
|
|
626
|
+
(id/slug/status/install/source/errors; `group` and `title` are added after from the manifest
|
|
627
|
+
registry, not the row) is a **security boundary** — the org response spreads to the
|
|
620
628
|
browser, so adding a credential field ships connection tokens to every dashboard user; and api
|
|
621
629
|
deploys before app-web — the gate treats a missing field as "no connection" and silently hides
|
|
622
630
|
connection-gated UI (e.g. the campaign Shop product columns row).
|
package/conventions/testing.md
CHANGED
|
@@ -17,6 +17,14 @@ Full strategy, conventions, and per-repo coverage map live in
|
|
|
17
17
|
export is a silent-undefined production throw — this has happened); every
|
|
18
18
|
fixed production incident gets a same-day regression test naming the
|
|
19
19
|
incident.
|
|
20
|
+
- **A test per state, not per ticket.** A handler that branches on a status
|
|
21
|
+
field or on a row's presence gets a case per value it can meet: each status,
|
|
22
|
+
the row absent, and the other side's timing (a cascade that runs before the
|
|
23
|
+
worker, a job that runs after the delete). Darren, 2026-09-24, after a fix
|
|
24
|
+
tested only the ticket's case and left the pending-submission case wrong.
|
|
25
|
+
- **A changed handler ships with a changed test.** The end-of-turn check
|
|
26
|
+
refuses a turn that changed `stream/`, `queue/`, `route/` or `lib/` source
|
|
27
|
+
with no `test/*.test.js` changed alongside it in that repo.
|
|
20
28
|
- **Next.js apps get pure-logic tests only** — no jsdom/component testing; UI
|
|
21
29
|
correctness is the smoke pack's job. Extract a pure seam into `lib/` rather
|
|
22
30
|
than importing a react/next module into a test.
|
|
@@ -66,6 +66,26 @@ write the comment from what is on it. Quote labels from the printed list, never
|
|
|
66
66
|
The full smoke pack (`npm test`) and the dashboard functional run (`npm run dashboard`)
|
|
67
67
|
use the same session; see `drawbridge-tests/readme.md`.
|
|
68
68
|
|
|
69
|
+
### Seeding an entrant for a walk
|
|
70
|
+
|
|
71
|
+
A walk that needs a lead, an entry or a winner seeds it through
|
|
72
|
+
`drawbridge-tests/lib/seed.js`, never with a hand-written `insertOne`:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const { removeEntrant, seedEntrant } = require( './lib/seed' );
|
|
76
|
+
|
|
77
|
+
const seeded = await seedEntrant({ campaign : '<campaign id>', email : 'walk+<date>@…', entries : 1 });
|
|
78
|
+
await removeEntrant( seeded ); // when the walk is done
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`seedEntrant` writes what the api's thin ingest writes — a zero-count lead and one submission
|
|
82
|
+
flagged pending — and waits for the sync worker to materialize the entries and every counter.
|
|
83
|
+
`removeEntrant` deletes the draws that picked the lead and then the lead; sync's cascade does
|
|
84
|
+
the rest and each counter nets back to zero. A lead inserted with its subtotals filled in was
|
|
85
|
+
never counted on its campaign, so deleting it decremented Entries and Submissions below zero
|
|
86
|
+
(Asana 1218811920761541, 2026-09-23). Counters now floor at zero in sync, but the seed still
|
|
87
|
+
has to go through the worker or the counts on the walked screen are wrong.
|
|
88
|
+
|
|
69
89
|
## 2. Claude in Chrome (optional, interactive sessions only)
|
|
70
90
|
|
|
71
91
|
Claude Code can drive the developer's own Chrome through the
|
package/hooks/drift-check.js
CHANGED
|
@@ -49,6 +49,18 @@ const changedFiles = (cwd) => {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// A CHANGED HANDLER SHIPS WITH A CHANGED TEST. The four directories are the
|
|
53
|
+
// ones a unit test can pin (conventions/testing.md); a Next.js page has none by
|
|
54
|
+
// convention. Darren, 2026-09-24: "every surface has required tests."
|
|
55
|
+
const TESTED_DIRS = /\/(stream|queue|route|lib)\/[^/]+\.(js|mjs|cjs|ts)$/
|
|
56
|
+
const TEST_FILE = /\/test\/.+\.test\.(js|mjs|cjs|ts)$/
|
|
57
|
+
|
|
58
|
+
const untestedSource = (changed) => {
|
|
59
|
+
const files = changed.map((file) => file.split(path.sep).join('/'))
|
|
60
|
+
if (files.some((file) => TEST_FILE.test(file))) return []
|
|
61
|
+
return files.filter((file) => TESTED_DIRS.test(file) && !file.split('/').some((part) => part === '.claude' || part === 'node_modules'))
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
const run = () => {
|
|
53
65
|
const input = readStdin()
|
|
54
66
|
|
|
@@ -98,6 +110,25 @@ const run = () => {
|
|
|
98
110
|
}
|
|
99
111
|
}
|
|
100
112
|
|
|
113
|
+
// 1c. The reverse direction (blocking) — a sync stream changed this turn that
|
|
114
|
+
// reacts to a write must say what a delete of that row does. The opt-out
|
|
115
|
+
// resync handled the insert and never the Resubscribe that deletes it.
|
|
116
|
+
const checkStreams = path.join(resolvePackageRoot(cwd), 'scripts', 'check-streams.js')
|
|
117
|
+
if (fs.existsSync(checkStreams)) {
|
|
118
|
+
try {
|
|
119
|
+
execFileSync('node', [ checkStreams, ...changedSource ], { cwd, encoding: 'utf8', stdio: 'pipe' })
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const detail = ((error.stdout || '') + (error.stderr || '')).trim()
|
|
122
|
+
block(`A sync stream changed this turn reacts to a write and never says what happens when that row is deleted.\n${ detail }\nAdd the delete handler (and the story scenario for the undo), or a // ONE-WAY: comment saying why a delete needs nothing, before finishing.`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 1d. A changed handler ships with a changed test (blocking). See untestedSource.
|
|
127
|
+
const untested = untestedSource(changed)
|
|
128
|
+
if (untested.length) {
|
|
129
|
+
block(`Source changed with no test changed alongside it:\n${ untested.map((file) => ' ' + path.relative(cwd, file)).join('\n') }\nAdd or extend a test/*.test.js in this repo, one case per state the change can meet (each status value, the row absent, the other side's timing), before finishing.`)
|
|
130
|
+
}
|
|
131
|
+
|
|
101
132
|
const skipGraph = process.env.DRAWBRIDGE_SKIP_GRAPHIFY === '1'
|
|
102
133
|
|
|
103
134
|
// 2. Graph freshness (non-blocking nudge) — a full re-extract is expensive, so alert rather
|
|
@@ -145,9 +176,13 @@ const run = () => {
|
|
|
145
176
|
process.exit(0)
|
|
146
177
|
}
|
|
147
178
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
179
|
+
module.exports = { untestedSource }
|
|
180
|
+
|
|
181
|
+
if (require.main === module) {
|
|
182
|
+
try {
|
|
183
|
+
run()
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// Never fail a user's turn because of a hook bug.
|
|
186
|
+
process.exit(0)
|
|
187
|
+
}
|
|
153
188
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drawbridge/drawbridge-agents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.74",
|
|
4
4
|
"description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"publishConfig": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"drawbridge-agents-sync": "bin/sync-claude.js",
|
|
16
16
|
"drawbridge-agents-graph": "bin/graph.js",
|
|
17
17
|
"drawbridge-agents-check-docs": "scripts/check-doc-links.js",
|
|
18
|
-
"drawbridge-agents-check-stories": "scripts/check-stories.js"
|
|
18
|
+
"drawbridge-agents-check-stories": "scripts/check-stories.js",
|
|
19
|
+
"drawbridge-agents-check-streams": "scripts/check-streams.js"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"claude",
|
package/scripts/check-stories.js
CHANGED
|
@@ -21,6 +21,23 @@ const path = require('path')
|
|
|
21
21
|
const TAGS = new Set([ '@inbox', '@sms', '@offscreen' ])
|
|
22
22
|
const VISIBLE = /\*\*[^*]+\*\*|"[^"]+"|“[^”]+”/
|
|
23
23
|
|
|
24
|
+
// EVERY ACTION THAT CAN BE UNDONE HAS ITS UNDO IN THE STORY. A scenario whose
|
|
25
|
+
// When does one side of a pair below needs a scenario in the same domain folder
|
|
26
|
+
// whose When does the other side, or the tag @one-way on the line above it (the
|
|
27
|
+
// action genuinely cannot be undone). Darren, 2026-09-24: the opt-out story
|
|
28
|
+
// said connected apps learn about an unsubscribe and never asked what a
|
|
29
|
+
// Resubscribe does, so Mailchimp kept people unsubscribed after they opted
|
|
30
|
+
// back in. Matched on When lines only, whole words, both directions.
|
|
31
|
+
const UNDO = [
|
|
32
|
+
[ /\bunsubscrib/i, /\bresubscrib/i, 'unsubscribe', 'resubscribe' ],
|
|
33
|
+
[ /\bconnect(s|ed|ing)?\b/i, /\bdisconnect/i, 'connect', 'disconnect' ],
|
|
34
|
+
[ /\bSTOP\b/, /\bSTART\b/, 'STOP', 'START' ],
|
|
35
|
+
[ /\bpublish(es|ed|ing)?\b/i, /\bunpublish/i, 'publish', 'unpublish' ],
|
|
36
|
+
[ /\bpaus(e|es|ed|ing)\b/i, /\bresum(e|es|ed|ing)\b/i, 'pause', 'resume' ],
|
|
37
|
+
[ /\benabl(e|es|ed|ing)\b/i, /\bdisabl(e|es|ed|ing)\b/i, 'enable', 'disable' ],
|
|
38
|
+
[ /\barchiv(e|es|ed|ing)\b/i, /\b(unarchiv|restor)/i, 'archive', 'restore' ]
|
|
39
|
+
]
|
|
40
|
+
|
|
24
41
|
const findFamilyRoot = () => {
|
|
25
42
|
let dir = process.env.INIT_CWD || process.cwd()
|
|
26
43
|
for (let i = 0; i < 8; i++) {
|
|
@@ -41,8 +58,10 @@ const walk = (dir, out = []) => {
|
|
|
41
58
|
return out
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
// Returns [{ line, name, reason }] for one story file.
|
|
45
|
-
|
|
61
|
+
// Returns [{ line, name, reason }] for one story file. `siblings` are the other
|
|
62
|
+
// story texts in the same domain folder: an undo written up in a sibling story
|
|
63
|
+
// (connect here, disconnect in the lifecycle story) counts.
|
|
64
|
+
const checkStory = (text, siblings = []) => {
|
|
46
65
|
const problems = []
|
|
47
66
|
const lines = text.split('\n')
|
|
48
67
|
let scenario = null
|
|
@@ -55,7 +74,9 @@ const checkStory = (text) => {
|
|
|
55
74
|
scenario = null
|
|
56
75
|
}
|
|
57
76
|
|
|
77
|
+
const scenarios = []
|
|
58
78
|
let pendingTag = false
|
|
79
|
+
let pendingOneWay = false
|
|
59
80
|
lines.forEach((raw, index) => {
|
|
60
81
|
const line = raw.trim()
|
|
61
82
|
if (line.startsWith('```')) {
|
|
@@ -64,23 +85,41 @@ const checkStory = (text) => {
|
|
|
64
85
|
if (!inFence) flush()
|
|
65
86
|
return
|
|
66
87
|
}
|
|
67
|
-
if (/^#/.test(line)) { flush(); pendingTag = false; return }
|
|
88
|
+
if (/^#/.test(line)) { flush(); pendingTag = false; pendingOneWay = false; return }
|
|
68
89
|
if (/^@\w/.test(line)) {
|
|
69
|
-
|
|
90
|
+
const tags = line.split(/\s+/)
|
|
91
|
+
pendingTag = tags.some((tag) => TAGS.has(tag))
|
|
92
|
+
pendingOneWay = tags.includes('@one-way')
|
|
70
93
|
return
|
|
71
94
|
}
|
|
72
95
|
if (/^Scenario( Outline)?:/.test(line)) {
|
|
73
96
|
flush()
|
|
74
|
-
scenario = { line: index + 1, name: line, hasThen: false, visible: false, tagged: pendingTag }
|
|
97
|
+
scenario = { line: index + 1, name: line, hasThen: false, visible: false, tagged: pendingTag, oneWay: pendingOneWay, when: '' }
|
|
98
|
+
scenarios.push(scenario)
|
|
75
99
|
pendingTag = false
|
|
100
|
+
pendingOneWay = false
|
|
76
101
|
return
|
|
77
102
|
}
|
|
78
103
|
if (!scenario) return
|
|
104
|
+
if (/^When\b/.test(line)) scenario.when += ' ' + line
|
|
79
105
|
if (/^Then\b/.test(line)) scenario.hasThen = true
|
|
80
106
|
if (/^(Then|And|But)\b/.test(line) && VISIBLE.test(line)) scenario.visible = true
|
|
81
107
|
})
|
|
82
108
|
flush()
|
|
83
|
-
|
|
109
|
+
|
|
110
|
+
const elsewhere = siblings.join('\n').split('\n').filter((line) => /^\s*When\b/.test(line)).join('\n')
|
|
111
|
+
for (const each of scenarios) {
|
|
112
|
+
if (each.oneWay) continue
|
|
113
|
+
for (const [ left, right, leftName, rightName ] of UNDO) {
|
|
114
|
+
for (const [ does, undoes, undoName ] of [ [ left, right, rightName ], [ right, left, leftName ] ]) {
|
|
115
|
+
if (!does.test(each.when)) continue
|
|
116
|
+
if (scenarios.some((other) => undoes.test(other.when)) || undoes.test(elsewhere)) continue
|
|
117
|
+
problems.push({ line: each.line, name: each.name, reason: `can be undone (${ undoName }) and no scenario's When does that; add the scenario, including what else must follow the undo, or tag this one @one-way` })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return problems.sort((a, b) => a.line - b.line)
|
|
84
123
|
}
|
|
85
124
|
|
|
86
125
|
const main = () => {
|
|
@@ -97,7 +136,9 @@ const main = () => {
|
|
|
97
136
|
let count = 0
|
|
98
137
|
for (const file of files) {
|
|
99
138
|
if (!fs.existsSync(file)) continue
|
|
100
|
-
|
|
139
|
+
const folder = path.dirname(file)
|
|
140
|
+
const siblings = fs.readdirSync(folder).filter((name) => name.endsWith('.md') && path.join(folder, name) !== file).map((name) => fs.readFileSync(path.join(folder, name), 'utf8'))
|
|
141
|
+
for (const problem of checkStory(fs.readFileSync(file, 'utf8'), siblings)) {
|
|
101
142
|
count += 1
|
|
102
143
|
console.error(` ${ path.relative(familyRoot, file) }:${ problem.line } ${ problem.name } — ${ problem.reason }`)
|
|
103
144
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A sync stream that reacts to a row being written must say what happens when
|
|
3
|
+
// that row is deleted: a `delete` handler, or a comment starting `ONE-WAY:`
|
|
4
|
+
// that says why a delete needs nothing. Darren, 2026-09-24: stream/suppression.js
|
|
5
|
+
// carried an opt-out to Mailchimp on insert and had no delete, so a person who
|
|
6
|
+
// pressed Resubscribe (which deletes the row) stayed unsubscribed there.
|
|
7
|
+
//
|
|
8
|
+
// Usage: node scripts/check-streams.js <file ...> Only drawbridge-sync
|
|
9
|
+
// stream/*.js files are checked; stream/index.js is the router, not a handler.
|
|
10
|
+
// Exits 1 naming each file that fails.
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
|
|
13
|
+
const STREAM = /\/drawbridge-sync[^/]*\/stream\/(?!index\.js$)[^/]+\.js$/
|
|
14
|
+
|
|
15
|
+
// Returns a reason string, or null when the file passes.
|
|
16
|
+
const checkStream = (text) => {
|
|
17
|
+
const writes = /^\s*(insert|update)\s*:/m.test(text)
|
|
18
|
+
if (!writes) return null
|
|
19
|
+
if (/^\s*delete\s*:/m.test(text)) return null
|
|
20
|
+
if (/\/\/\s*ONE-WAY:/.test(text)) return null
|
|
21
|
+
return 'handles a write but not a delete; add a `delete` handler, or a `// ONE-WAY:` comment saying why a deleted row needs nothing'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const main = () => {
|
|
25
|
+
const files = process.argv.slice(2).filter((file) => STREAM.test(file.split('\\').join('/')) && fs.existsSync(file))
|
|
26
|
+
let count = 0
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const reason = checkStream(fs.readFileSync(file, 'utf8'))
|
|
29
|
+
if (reason) {
|
|
30
|
+
count += 1
|
|
31
|
+
console.error(` ${ file } — ${ reason }`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (count) {
|
|
35
|
+
console.error(`drawbridge-agents-check-streams: ${ count } stream(s) never say what a delete does`)
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|
|
38
|
+
console.log(`drawbridge-agents-check-streams: every stream says what a delete does (${ files.length } file(s))`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { STREAM, checkStream }
|
|
42
|
+
|
|
43
|
+
if (require.main === module) main()
|