@imwz/wp-pattern-sentinel 0.2.6 → 1.0.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
@@ -120,6 +120,9 @@ node bin/sentinel.js --concurrency=6 --url=... path/to/patterns/
120
120
  | `--keep-page` | `false` | Don't delete draft pages after validation |
121
121
  | `--width` | `1280` | Viewport width |
122
122
  | `--height` | `800` | Viewport height |
123
+ | `--cache` | `false` | Skip patterns that previously passed with the same file content (see [Pass cache](#pass-cache)) |
124
+ | `--clear-cache` | `false` | Delete `.sentinel-cache.json` and exit (or combine with a path to clear then validate) |
125
+ | `--log` | `false` | Always write `sentinel-<timestamp>.log.json`, even when all patterns pass |
123
126
 
124
127
  ## Architecture
125
128
 
@@ -153,9 +156,53 @@ Credential rejections (wrong password) are not retried — only timeout errors t
153
156
 
154
157
  Each pattern result is printed to the terminal as soon as that worker finishes, rather than buffering everything until the full batch completes. During a long concurrent run you see progress immediately.
155
158
 
159
+ ### Pass cache
160
+
161
+ `--cache` stores a `.sentinel-cache.json` file in the working directory. Each entry records the file's content hash and the last pass result:
162
+
163
+ ```json
164
+ {
165
+ "patterns/main-hero.php": {
166
+ "hash": "a1b2c3d4e5f6",
167
+ "passed": true,
168
+ "checkedAt": "2026-05-16T10:00:00.000Z"
169
+ }
170
+ }
171
+ ```
172
+
173
+ On subsequent runs, if a pattern file's content hash matches the cached entry **and** it previously passed, the pattern is skipped. If the file has changed (even by one byte), it is re-validated and the cache entry is updated. Failed patterns are always removed from the cache so they are never skipped.
174
+
175
+ ```bash
176
+ # First run — validates all, populates cache
177
+ sentinel --cache --log patterns/
178
+
179
+ # Later runs — only validates new or changed patterns
180
+ sentinel --cache --log patterns/
181
+
182
+ # Reset the cache (e.g. after a theme.json change that affects all patterns)
183
+ sentinel --clear-cache
184
+
185
+ # Clear and immediately re-validate
186
+ sentinel --clear-cache --cache --log patterns/
187
+ ```
188
+
189
+ Commit `.sentinel-cache.json` to track validated state across sessions. Add it to `.gitignore` if you prefer each developer to maintain their own local cache.
190
+
156
191
  ### Failure log
157
192
 
158
- When any pattern fails, sentinel automatically writes a `sentinel-<timestamp>.log.json` file in the current working directory and prints the path after the summary. This preserves error details for later inspection without needing to re-run.
193
+ When any pattern fails, sentinel automatically writes a `sentinel-<timestamp>.log.json` file in the current working directory and prints the path after the summary. This preserves error details for later inspection without needing to re-run. Use `--log` to write the file even on a fully-passing run.
194
+
195
+ Failing results include a `savedContent` field — the editor's serialized output — so you can diff it directly against the source file:
196
+
197
+ ```bash
198
+ node -e "
199
+ const log = JSON.parse(require('fs').readFileSync('sentinel-*.log.json'));
200
+ const r = log.results.find(r => !r.passed);
201
+ console.log(r.savedContent);
202
+ " | diff - patterns/my-pattern.php
203
+ ```
204
+
205
+ `block_validation` errors also surface Gutenberg's human-readable issue messages (e.g. `"Expected attribute 'class' of value '…' but got '…'"`), so you no longer need to open the browser console to identify what failed.
159
206
 
160
207
  ## npm publish
161
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imwz/wp-pattern-sentinel",
3
- "version": "0.2.6",
3
+ "version": "1.0.0",
4
4
  "description": "Browser-based WordPress block pattern validator using Playwright",
5
5
  "type": "module",
6
6
  "engines": {
package/src/args.js CHANGED
@@ -34,6 +34,10 @@ const ARG_OPTIONS = {
34
34
  concurrency: { type: 'string', default: '4' },
35
35
  width: { type: 'string', default: '1280' },
36
36
  height: { type: 'string', default: '800' },
37
+ // Persistence
38
+ cache: { type: 'boolean', default: false },
39
+ 'clear-cache': { type: 'boolean', default: false },
40
+ log: { type: 'boolean', default: false },
37
41
  };
38
42
 
39
43
  /**
@@ -126,6 +130,9 @@ export async function parseArgs(args) {
126
130
  headless: values.headless,
127
131
  json: values.json,
128
132
  keepPage: values['keep-page'],
133
+ cache: values.cache,
134
+ clearCache: values['clear-cache'],
135
+ log: values.log,
129
136
  concurrency: Math.max(1, parseInt(values.concurrency, 10)),
130
137
  viewport: {
131
138
  width: parseInt(values.width, 10),
package/src/format.js CHANGED
@@ -28,18 +28,19 @@ export function formatResult(result) {
28
28
  return lines.join('\n');
29
29
  }
30
30
 
31
- export function printSummary(results) {
31
+ export function printSummary(results, skipped = 0) {
32
32
  const passed = results.filter(r => r.passed).length;
33
33
  const failed = results.filter(r => !r.passed).length;
34
34
  const totalErrors = results.reduce((n, r) => n + r.errors.length, 0);
35
35
  const totalWarns = results.reduce((n, r) => n + r.warnings.length, 0);
36
36
  const totalMs = results.reduce((n, r) => n + r.duration, 0);
37
- const avgMs = Math.round(totalMs / results.length);
37
+ const avgMs = results.length > 0 ? Math.round(totalMs / results.length) : 0;
38
38
 
39
39
  log('\n' + '='.repeat(60));
40
40
  log('Validation Summary', 'cyan');
41
41
  log('='.repeat(60));
42
42
  log(`Patterns : ${results.length}`);
43
+ if (skipped > 0) log(`Skipped : ${skipped} (cached)`, 'gray');
43
44
  log(`Passed : ${passed}`, passed > 0 ? 'green' : 'gray');
44
45
  log(`Failed : ${failed}`, failed > 0 ? 'red' : 'gray');
45
46
  log(`Errors : ${totalErrors}`, totalErrors > 0 ? 'red' : 'gray');
package/src/main.js CHANGED
@@ -2,6 +2,7 @@ import { chromium } from 'playwright';
2
2
  import PQueue from 'p-queue';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
+ import crypto from 'crypto';
5
6
  import { parseArgs, resolveFiles } from './args.js';
6
7
  import { loginToWordPress } from './login.js';
7
8
  import {
@@ -14,15 +15,78 @@ import {
14
15
  import { checkBlockValidation, compareContent } from './validation.js';
15
16
  import { log, formatResult, printSummary } from './format.js';
16
17
 
18
+ const CACHE_FILE = '.sentinel-cache.json';
19
+
20
+ function loadCache() {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(path.join(process.cwd(), CACHE_FILE), 'utf8'));
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ async function saveCache(cache) {
29
+ try {
30
+ await fs.promises.writeFile(
31
+ path.join(process.cwd(), CACHE_FILE),
32
+ JSON.stringify(cache, null, 2)
33
+ );
34
+ } catch { /* ignore */ }
35
+ }
36
+
37
+ function hashContent(content) {
38
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
39
+ }
40
+
17
41
  export async function main() {
18
42
  const options = await parseArgs(process.argv.slice(2));
19
- const files = resolveFiles(options.files);
43
+
44
+ if (options.clearCache) {
45
+ try {
46
+ fs.unlinkSync(path.join(process.cwd(), CACHE_FILE));
47
+ log('Cache cleared.', 'green');
48
+ } catch {
49
+ log('No cache file found.', 'gray');
50
+ }
51
+ if (options.files.length === 0) process.exit(0);
52
+ }
53
+
54
+ let files = resolveFiles(options.files);
20
55
 
21
56
  if (files.length === 0) {
22
57
  log('No pattern files found.', 'red');
23
58
  process.exit(1);
24
59
  }
25
60
 
61
+ // Skip patterns that previously passed with the same file content.
62
+ const cache = options.cache ? loadCache() : {};
63
+ const skipped = [];
64
+ if (options.cache) {
65
+ const pending = [];
66
+ for (const file of files) {
67
+ try {
68
+ const content = fs.readFileSync(file, 'utf8');
69
+ const key = path.relative(process.cwd(), file);
70
+ if (cache[key]?.passed && cache[key]?.hash === hashContent(content)) {
71
+ skipped.push(file);
72
+ } else {
73
+ pending.push(file);
74
+ }
75
+ } catch {
76
+ pending.push(file);
77
+ }
78
+ }
79
+ files = pending;
80
+ if (skipped.length > 0) {
81
+ log(`Skipping ${skipped.length} previously-passed pattern(s) (cached)`, 'gray');
82
+ }
83
+ }
84
+
85
+ if (files.length === 0) {
86
+ log('All patterns already validated — nothing to do.', 'green');
87
+ process.exit(0);
88
+ }
89
+
26
90
  log(`\nFound ${files.length} pattern(s) — concurrency: ${options.concurrency}`, 'cyan');
27
91
 
28
92
  const browser = await chromium.launch({
@@ -82,11 +146,25 @@ export async function main() {
82
146
  for (const r of results) console.log(JSON.stringify(r));
83
147
  }
84
148
 
85
- printSummary(results);
149
+ printSummary(results, skipped.length);
150
+
151
+ // Update cache with results from this run.
152
+ if (options.cache) {
153
+ for (const result of results) {
154
+ const key = path.relative(process.cwd(), result.patternPath);
155
+ if (result.passed) {
156
+ cache[key] = { hash: result.hash, passed: true, checkedAt: new Date().toISOString() };
157
+ } else {
158
+ delete cache[key];
159
+ }
160
+ }
161
+ await saveCache(cache);
162
+ log(` Cache updated → ${CACHE_FILE}`, 'gray');
163
+ }
86
164
 
87
- // Write a log file when any pattern failed so details are preserved for inspection.
165
+ // Write a log file on failure, or always when --log is set.
88
166
  const hasFailed = results.some(r => !r.passed);
89
- if (hasFailed) {
167
+ if (hasFailed || options.log) {
90
168
  const logPath = await writeLogFile(results);
91
169
  if (logPath) log(` Log saved → ${logPath}`, 'gray');
92
170
  }
@@ -104,12 +182,12 @@ async function validatePatternFile(patternPath, options, context) {
104
182
  try {
105
183
  fileContent = await fs.promises.readFile(patternPath, 'utf8');
106
184
  } catch (error) {
107
- return fail(patternName, startTime, 'file_error', error.message);
185
+ return fail(patternName, patternPath, startTime, 'file_error', error.message);
108
186
  }
109
187
 
110
188
  const blockContent = extractBlockContent(fileContent);
111
189
  if (!blockContent) {
112
- return fail(patternName, startTime, 'extraction_error', 'Could not extract block content from file');
190
+ return fail(patternName, patternPath, startTime, 'extraction_error', 'Could not extract block content from file');
113
191
  }
114
192
 
115
193
  const page = await context.newPage();
@@ -118,12 +196,12 @@ async function validatePatternFile(patternPath, options, context) {
118
196
  try {
119
197
  const pageId = await createDraftPage(page, options.adminUrl);
120
198
  if (pageId === null) {
121
- return fail(patternName, startTime, 'page_creation_error', 'Failed to create test page');
199
+ return fail(patternName, patternPath, startTime, 'page_creation_error', 'Failed to create test page');
122
200
  }
123
201
 
124
202
  if (!(await insertPatternIntoEditor(page, blockContent))) {
125
203
  await deletePage(page, options.adminUrl, pageId);
126
- return fail(patternName, startTime, 'insertion_error', 'Failed to insert pattern into editor');
204
+ return fail(patternName, patternPath, startTime, 'insertion_error', 'Failed to insert pattern into editor');
127
205
  }
128
206
 
129
207
  const saveResult = await savePage(page);
@@ -132,7 +210,11 @@ async function validatePatternFile(patternPath, options, context) {
132
210
  if (blockErrors.length > 0) {
133
211
  saveResult.errors.push(...blockErrors.map(e => ({
134
212
  type: 'block_validation',
135
- message: `${e.blockName} (${e.blockId}): ${e.error}`,
213
+ message: `${e.blockName} (${e.blockId}): ${e.error}${
214
+ e.validationIssues?.length
215
+ ? '\n Issues: ' + e.validationIssues.join('\n ')
216
+ : ''
217
+ }`,
136
218
  })));
137
219
  }
138
220
 
@@ -146,6 +228,8 @@ async function validatePatternFile(patternPath, options, context) {
146
228
 
147
229
  return {
148
230
  pattern: patternName,
231
+ patternPath,
232
+ hash: hashContent(fileContent),
149
233
  passed: saveResult.success && comparison.matches && blockErrors.length === 0,
150
234
  errors: saveResult.errors,
151
235
  warnings: saveResult.warnings,
@@ -155,15 +239,17 @@ async function validatePatternFile(patternPath, options, context) {
155
239
 
156
240
  } catch (error) {
157
241
  log(`Unexpected error — ${patternName}: ${error.message}`, 'red');
158
- return fail(patternName, startTime, 'validation_error', error.message);
242
+ return fail(patternName, patternPath, startTime, 'validation_error', error.message);
159
243
  } finally {
160
244
  await page.close();
161
245
  }
162
246
  }
163
247
 
164
- function fail(pattern, startTime, type, message) {
248
+ function fail(pattern, patternPath, startTime, type, message) {
165
249
  return {
166
250
  pattern,
251
+ patternPath,
252
+ hash: null,
167
253
  passed: false,
168
254
  errors: [{ type, message }],
169
255
  warnings: [],
@@ -184,6 +270,7 @@ async function writeLogFile(results) {
184
270
  duration: r.duration,
185
271
  errors: r.errors,
186
272
  warnings: r.warnings,
273
+ ...(r.passed ? {} : { savedContent: r.savedContent ?? null }),
187
274
  })),
188
275
  };
189
276
  await fs.promises.writeFile(logPath, JSON.stringify(payload, null, 2));
package/src/validation.js CHANGED
@@ -8,7 +8,18 @@ export async function checkBlockValidation(page) {
8
8
  return await page.evaluate(() => {
9
9
  const walk = blocks => blocks.flatMap(block => [
10
10
  ...(block.isValid === false
11
- ? [{ blockId: block.clientId, blockName: block.name, error: 'Block validation failed' }]
11
+ ? [{
12
+ blockId: block.clientId,
13
+ blockName: block.name,
14
+ error: 'Block validation failed',
15
+ validationIssues: (block.validationIssues ?? []).map(issue => {
16
+ try {
17
+ return (issue.args ?? [])
18
+ .map(a => (typeof a === 'string' ? a : JSON.stringify(a)))
19
+ .join(' ');
20
+ } catch { return 'unknown issue'; }
21
+ }),
22
+ }]
12
23
  : []),
13
24
  ...walk(block.innerBlocks ?? []),
14
25
  ]);