tracklint 0.1.0 → 0.2.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/README.md CHANGED
@@ -64,6 +64,48 @@ Exit code 1 when any `error` is found = a CI gate.(`warn` は既定では CI
64
64
  }
65
65
  ```
66
66
 
67
+ ## Presets / プリセット(WordPress・Meta Pixel)
68
+
69
+ フォームフレームワークや計測基盤ごとに「正しい配線」の形は違います。プリセットを有効化すると、その知識に基づく追加ルールが入り、同時に **フレームワーク特有の誤爆を抑制** します(例: Contact Form 7 は既定でサンクスへ遷移しないので `thankyou-*` を出さない)。各完了シグナルは公式ドキュメントで裏取り済み。
70
+
71
+ ```json
72
+ // tracklint.config.json
73
+ { "presets": ["wordpress", "meta"] }
74
+ ```
75
+
76
+ ```bash
77
+ npx tracklint --preset=wordpress,meta # CLI でも指定可("=" と "," 区切り)
78
+ ```
79
+
80
+ ### `wordpress`
81
+
82
+ | プラグイン | 検出 | 完了シグナル | 検証 |
83
+ |---|---|---|---|
84
+ | Contact Form 7 | `form.wpcf7-form` | `wpcf7mailsent`(成功時のみ発火) | ✅ 完了イベントに計測が配線されているか |
85
+ | Snow Monkey Forms | `form.snow-monkey-form` | `smf.complete` | ✅ 同上 |
86
+ | WPForms | `form.wpforms-form` | 確認タイプ3種で可変 | 🔍 検出のみ(誤爆抑制) |
87
+ | MW WP Form | `.mw_wp_form` / `submitSend` | 完了画面 or 別URL | 🔍 検出のみ(誤爆抑制) |
88
+
89
+ CF7 / Snow Monkey は「送信完了で別URLへ遷移しない」ため、`<button>` のクリック計測ではなく **完了イベントに計測を配線** するのが正解です。tracklint はこれを検証し、逆にクリック計測ルール(`submit-*`)を課しません。WPForms / MW WP Form は完了がサーバ再描画・設定依存で静的に確定できないため、**検出(=サンクス誤爆の抑制)のみ** にとどめます(正直に success-tracking は課しません)。
90
+
91
+ | rule | severity | 何を落とすか |
92
+ |---|---|---|
93
+ | `wp-form-no-success-tracking` | warn | CF7/Snow Monkey で、完了イベントのリスナ内に計測(gtag / dataLayer.push / fbq)が無い |
94
+ | `wp-form-tracking-on-wrong-event` | warn | CF7 で CV を `wpcf7submit`(無効送信・スパムでも発火)に紐付けている → CV 水増し |
95
+
96
+ ### `meta`
97
+
98
+ `fbq(...)` をコンバージョン呼び出しとして認識し、静的に確定できる破損だけを検出します。
99
+
100
+ | rule | severity | 何を落とすか |
101
+ |---|---|---|
102
+ | `meta-pixel-track-without-base` | error | `fbq('track', …)` があるのに base code(`fbq('init')` / `fbevents.js`)が無い → 送信されない |
103
+ | `meta-pixel-duplicate-init` | warn | 異なる Pixel ID で `fbq('init')` が複数 → PageView/CV の二重計上 |
104
+
105
+ > Meta Pixel の **実発火**(本当に `CompleteRegistration` が飛んだか)は静的には見えません(`--runtime` は Roadmap 参照)。GTM 経由でのみ設置している場合、生 HTML に `fbq` が現れないため検出対象外です(誤って「未設置」とは判定しません)。
106
+
107
+ サンプル: `examples/wordpress/`(`cf7-bad.html` / `cf7-good.html` / `meta-pixel-bad.html`)
108
+
67
109
  ## What this can and cannot see / できること・できないこと
68
110
 
69
111
  これは **静的解析** です。「配線が存在し正しく書かれている」ことを検証しますが、**GTM/GA4 が実行時に実際に発火したことは保証しません**。以下は構造的に見えないので、`warn`/`info` に留めるか設定で抑制します。動的検証(E2E)は置き換えではなく補完です。
@@ -81,7 +123,8 @@ Exit code 1 when any `error` is found = a CI gate.(`warn` は既定では CI
81
123
  - [ ] `--runtime` mode (Playwright): 実際に送信してイベント発火とサンクス遷移を検証
82
124
  - [ ] SARIF 出力(GitHub code-scanning 連携)
83
125
  - [ ] baseline/allowlist(既存リポは新規違反だけで落とす)
84
- - [ ] presets: Shopify / HubSpot / Contact Form 7 / WPForms
126
+ - [x] **presets: `wordpress`**(Contact Form 7 / Snow Monkey Forms / WPForms / MW WP Form)+ **`meta`**(Meta Pixel)
127
+ - [ ] presets: Shopify / HubSpot
85
128
 
86
129
  ## Dev
87
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracklint",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Conversion-tracking integrity linter — fail CI when a form or CTA isn't wired for tracking (submit button, dataLayer.push, noindex thank-you page). Zero-dependency, language-agnostic, runs in CI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -15,17 +15,29 @@ import { scan, collectIds, DEFAULT_CONFIG } from './scan.mjs';
15
15
  const EXT = /\.(html?|php|jsx|tsx|vue|svelte)$/i;
16
16
  const IGNORE_DIRS = new Set(['node_modules', 'dist', 'build', 'vendor', '.git', '.svn', 'coverage']);
17
17
 
18
- function loadConfig(root) {
18
+ // --preset=wordpress,meta を解釈(= 区切り必須。値が位置引数と衝突しないため空白形式は不可)
19
+ function parsePresets(argv) {
20
+ const out = [];
21
+ for (const a of argv) {
22
+ if (a.startsWith('--preset=')) out.push(...a.slice('--preset='.length).split(','));
23
+ }
24
+ return out.map((s) => s.trim()).filter(Boolean);
25
+ }
26
+
27
+ function loadConfig(root, cliPresets = []) {
28
+ let cfg = {};
19
29
  try {
20
- const cfg = JSON.parse(readFileSync(join(root, 'tracklint.config.json'), 'utf8'));
21
- return {
22
- ...DEFAULT_CONFIG,
23
- ...cfg,
24
- rules: { ...DEFAULT_CONFIG.rules, ...(cfg.rules || {}) },
25
- };
30
+ cfg = JSON.parse(readFileSync(join(root, 'tracklint.config.json'), 'utf8'));
26
31
  } catch {
27
- return DEFAULT_CONFIG;
32
+ cfg = {};
28
33
  }
34
+ const presets = [...new Set([...(cfg.presets || []), ...cliPresets])];
35
+ return {
36
+ ...DEFAULT_CONFIG,
37
+ ...cfg,
38
+ presets,
39
+ rules: { ...DEFAULT_CONFIG.rules, ...(cfg.rules || {}) },
40
+ };
29
41
  }
30
42
 
31
43
  function walk(root, dir, out) {
@@ -76,7 +88,7 @@ export function main(argv) {
76
88
  const inActions = process.env.GITHUB_ACTIONS === 'true';
77
89
  const root = process.cwd();
78
90
  const args = argv.filter((a) => a !== '--' && !a.startsWith('-'));
79
- const config = loadConfig(root);
91
+ const config = loadConfig(root, parsePresets(argv));
80
92
 
81
93
  // 明示指定されたパスが存在しない場合は「素通りで exit 0」にせず error にする
82
94
  // (files: のタイプミスやリネームで CI が黙って緑になる=偽の安心を防ぐ)。
package/src/scan.mjs CHANGED
@@ -10,6 +10,8 @@ export const DEFAULT_CONFIG = {
10
10
  trackingAttributes: ['data-gtm-event', 'data-ga4-event', 'data-track'],
11
11
  // 「コンバージョンが飛んだ」とみなす JS 呼び出し
12
12
  conversionCalls: ['dataLayer.push', 'gtag', 'analytics.track'],
13
+ // 有効化するプリセット(フォームフレームワーク/計測基盤ごとの知識): 'wordpress' | 'meta'
14
+ presets: [],
13
15
  // ルールごとの severity: 'error' | 'warn' | 'off'
14
16
  rules: {
15
17
  'submit-not-button': 'error',
@@ -20,6 +22,12 @@ export const DEFAULT_CONFIG = {
20
22
  'ajax-no-conversion': 'warn', // 誤検知が出やすいので既定 warn
21
23
  'thankyou-unresolved': 'error',
22
24
  'thankyou-indexable': 'error',
25
+ // preset:wordpress(該当フォーム検出時のみ発火)
26
+ 'wp-form-no-success-tracking': 'warn',
27
+ 'wp-form-tracking-on-wrong-event': 'warn',
28
+ // preset:meta(fbq 検出時のみ発火)
29
+ 'meta-pixel-track-without-base': 'error',
30
+ 'meta-pixel-duplicate-init': 'warn',
23
31
  },
24
32
  };
25
33
 
@@ -160,6 +168,56 @@ function submitControl(form) {
160
168
  return { proper, improper };
161
169
  }
162
170
 
171
+ // ---- WordPress フォームプリセット(公式ドキュメントで裏取りした完了シグナル) ----
172
+ //
173
+ // 各フォームプラグインの「送信完了」挙動は異なり、既定では別URLのサンクスへ遷移しない。
174
+ // そのため generic な thankyou 判定が誤爆する(=presetが要る核心理由)。ここでは
175
+ // - CF7 / Snow Monkey: 完了 DOM イベントに計測が配線されているかを静的に検証する
176
+ // - WPForms / MW WP Form: 完了がサーバ再描画/設定依存で静的検証できない → 検出(=誤爆抑制)のみ
177
+ const cls = (t) => t.attrs.get('class') || '';
178
+ const hasControlName = (form, name) =>
179
+ form.controls.some((c) => c.name === 'input' && (c.attrs.get('name') || '') === name);
180
+
181
+ const WP_FORM_TYPES = [
182
+ {
183
+ key: 'cf7',
184
+ name: 'Contact Form 7',
185
+ model: 'event', // 既定AJAX・同一URL・完了はDOMイベント(公式: 別サンクス遷移は不要)
186
+ match: (f) => /\bwpcf7-form\b/.test(cls(f)) || hasControlName(f, '_wpcf7'),
187
+ signals: ['wpcf7mailsent'], // メール送信成功時のみ発火=CVに使う正しいイベント
188
+ wrong: ['wpcf7submit'], // 「他の事象に関わらず毎回発火」=invalid/spam/failedでもCV水増し
189
+ },
190
+ {
191
+ key: 'smf',
192
+ name: 'Snow Monkey Forms',
193
+ model: 'event',
194
+ match: (f) => /\bsnow-monkey-form\b/.test(cls(f)),
195
+ signals: ['smf.complete'], // detail.status==='complete' で判定するのが正(smf.submit は全応答で発火)
196
+ wrong: [],
197
+ },
198
+ {
199
+ key: 'wpforms',
200
+ name: 'WPForms',
201
+ model: 'detect', // 確認タイプ3種・完了はDOM置換/設定依存で静的検証不能 → 検出のみ
202
+ match: (f) => /\bwpforms-form\b/.test(cls(f)),
203
+ signals: [],
204
+ wrong: [],
205
+ },
206
+ {
207
+ key: 'mwwp',
208
+ name: 'MW WP Form',
209
+ model: 'detect', // JSイベント無し・完了は同一URL再描画/別URL設定依存 → 検出のみ
210
+ match: (f) => hasControlName(f, 'submitSend') || hasControlName(f, 'submitConfirm'),
211
+ signals: [],
212
+ wrong: [],
213
+ },
214
+ ];
215
+
216
+ function detectWpForm(form) {
217
+ for (const t of WP_FORM_TYPES) if (t.match(form)) return t;
218
+ return null;
219
+ }
220
+
163
221
  /**
164
222
  * 1ファイル分の HTML/JSX を走査して findings を返す(純粋・テスト可能)。
165
223
  * @param opts.filename リポジトリ相対のファイル名(パス解決に使う)
@@ -178,7 +236,12 @@ export function scan(html, opts = {}) {
178
236
  } = opts;
179
237
  const rules = { ...DEFAULT_CONFIG.rules, ...(config.rules || {}) };
180
238
  const trackingAttributes = config.trackingAttributes || DEFAULT_CONFIG.trackingAttributes;
181
- const conversionCalls = config.conversionCalls || DEFAULT_CONFIG.conversionCalls;
239
+ const presets = new Set(config.presets || []);
240
+ const wordpressActive = presets.has('wordpress') || presets.has('wp');
241
+ const metaActive = presets.has('meta');
242
+ let conversionCalls = config.conversionCalls || DEFAULT_CONFIG.conversionCalls;
243
+ // Meta / WordPress を有効化したら fbq を「コンバージョン呼び出し」として認識する
244
+ if (metaActive || wordpressActive) conversionCalls = [...conversionCalls, 'fbq'];
182
245
 
183
246
  const findings = [];
184
247
  const push = (rule, ln, msg) => {
@@ -212,16 +275,20 @@ export function scan(html, opts = {}) {
212
275
  (/preventDefault\s*\(/.test(code) && hasSubmitHandler);
213
276
  const hasConversion = conversionCalls.some((c) => code.includes(c));
214
277
 
278
+ let sawWpForm = false;
215
279
  for (const form of forms) {
216
- // R1 / R2: 送信コントロール
280
+ const wp = wordpressActive ? detectWpForm(form) : null;
281
+ if (wp) sawWpForm = true;
282
+
283
+ // R1 / R2: 送信コントロール(WP フォームは完了イベントで計測するため、クリック配線ルールは適用しない)
217
284
  const { proper, improper } = submitControl(form);
218
- if (improper && !proper) {
285
+ if (!wp && improper && !proper) {
219
286
  const what =
220
287
  improper.name === 'input'
221
288
  ? `<input type="${(improper.attrs.get('type') || '').toLowerCase()}">`
222
289
  : `<${improper.name}>`;
223
290
  push('submit-not-button', improper.line, `送信コントロールが ${what} です。<button type="submit"> にしてください(GTM/GA4 のクリック計測が要素を特定できません)`);
224
- } else if (proper) {
291
+ } else if (!wp && proper) {
225
292
  const id = proper.attrs.get('id');
226
293
  const hasId = id != null && id !== '';
227
294
  const hasTrackAttr = trackingAttributes.some((a) => proper.attrs.has(a) && proper.attrs.get(a) !== '');
@@ -239,9 +306,9 @@ export function scan(html, opts = {}) {
239
306
  }
240
307
  }
241
308
 
242
- // R4: サンクスページ
309
+ // R4: サンクスページ(WP フォームの action は admin-ajax 等で静的サンクスではない → action 由来では推定しない)
243
310
  let dest = form.attrs.get('data-thankyou') || form.attrs.get('data-success-url');
244
- if (!dest) {
311
+ if (!dest && !wp) {
245
312
  const action = (form.attrs.get('action') || '').split(/[?#]/)[0];
246
313
  if (action && !/^(?:[a-z][\w+.-]*:)?\/\//i.test(action)) {
247
314
  const base = (action.replace(/\/$/, '').split('/').pop()) || '';
@@ -262,12 +329,48 @@ export function scan(html, opts = {}) {
262
329
  }
263
330
  }
264
331
  }
332
+
333
+ // preset:wordpress — CF7 / Snow Monkey は「完了イベントに計測が配線されているか」を検証する
334
+ if (wp && wp.model === 'event') {
335
+ const hasSignal = wp.signals.some((s) => code.includes(s));
336
+ if (!hasSignal || !hasConversion) {
337
+ const why = !hasSignal
338
+ ? `'${wp.signals[0]}' のリスナが見当たりません`
339
+ : '成功時の計測呼び出し(gtag / dataLayer.push / fbq)が見当たりません';
340
+ push('wp-form-no-success-tracking', form.line, `${wp.name} は送信完了でページ遷移しません。'${wp.signals[0]}' のリスナ内で計測を発火してください(${why})`);
341
+ }
342
+ if (wp.wrong.length && hasConversion) {
343
+ const hasWrong = wp.wrong.some((w) => code.includes(w));
344
+ const hasRight = wp.signals.some((s) => code.includes(s));
345
+ if (hasWrong && !hasRight) {
346
+ push('wp-form-tracking-on-wrong-event', form.line, `計測が '${wp.wrong[0]}' に紐付いています(無効送信・スパム時も発火し CV を水増しします)。成功時のみ発火する '${wp.signals[0]}' を使ってください`);
347
+ }
348
+ }
349
+ }
350
+ // wp.model === 'detect'(WPForms / MW WP Form)は完了がサーバ再描画/設定依存で静的検証不能。
351
+ // 検出=thankyou 誤爆の抑制のみ行い、success-tracking は課さない(誤検知回避)。
265
352
  }
266
353
 
267
354
  // R3: AJAX 送信なのに成功時の計測イベントが無い(ファイル単位・1回だけ)
268
- if (forms.length && hasAjax && !hasConversion) {
355
+ // WP フォーム検出時は、より具体的な wp-form-* ルールに委ねて二重警告を避ける
356
+ if (forms.length && hasAjax && !hasConversion && !(wordpressActive && sawWpForm)) {
269
357
  push('ajax-no-conversion', forms[0].line, `AJAX 送信のようですが、成功時の計測呼び出し(${conversionCalls.join(' / ')})がファイル内に見当たりません(ページ遷移しないため計測が飛びません)`);
270
358
  }
271
359
 
360
+ // preset:meta — Meta Pixel(fbq) の静的配線チェック(fbq が無ければ何も出さない)
361
+ if (metaActive) {
362
+ const initIds = [...code.matchAll(/fbq\s*\(\s*['"]init['"]\s*,\s*['"](\d{6,20})['"]/g)].map((m) => m[1]);
363
+ const distinctIds = [...new Set(initIds)];
364
+ const hasTrack = /fbq\s*\(\s*['"]track(?:Custom)?['"]/.test(code);
365
+ const hasBase = /fbevents\.js/.test(code) || /fbq\s*\(\s*['"]init['"]/.test(code);
366
+ const anchor = forms[0] ? forms[0].line : 1;
367
+ if (hasTrack && !hasBase) {
368
+ push('meta-pixel-track-without-base', anchor, `fbq('track', …) がありますが Meta Pixel の base code(fbq('init', …) / fbevents.js ローダー)が見当たりません(track がキューに積まれるだけで送信されません)`);
369
+ }
370
+ if (distinctIds.length > 1) {
371
+ push('meta-pixel-duplicate-init', anchor, `異なる Meta Pixel ID で fbq('init') が複数あります(${distinctIds.join(', ')})。PageView/CV が二重計上されます`);
372
+ }
373
+ }
374
+
272
375
  return findings.sort((a, b) => a.ln - b.ln);
273
376
  }