@imwz/wp-pattern-sentinel 0.2.6 → 0.3.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,41 @@ 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.
159
194
 
160
195
  ## npm publish
161
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imwz/wp-pattern-sentinel",
3
- "version": "0.2.6",
3
+ "version": "0.3.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);
@@ -146,6 +224,8 @@ async function validatePatternFile(patternPath, options, context) {
146
224
 
147
225
  return {
148
226
  pattern: patternName,
227
+ patternPath,
228
+ hash: hashContent(fileContent),
149
229
  passed: saveResult.success && comparison.matches && blockErrors.length === 0,
150
230
  errors: saveResult.errors,
151
231
  warnings: saveResult.warnings,
@@ -155,15 +235,17 @@ async function validatePatternFile(patternPath, options, context) {
155
235
 
156
236
  } catch (error) {
157
237
  log(`Unexpected error — ${patternName}: ${error.message}`, 'red');
158
- return fail(patternName, startTime, 'validation_error', error.message);
238
+ return fail(patternName, patternPath, startTime, 'validation_error', error.message);
159
239
  } finally {
160
240
  await page.close();
161
241
  }
162
242
  }
163
243
 
164
- function fail(pattern, startTime, type, message) {
244
+ function fail(pattern, patternPath, startTime, type, message) {
165
245
  return {
166
246
  pattern,
247
+ patternPath,
248
+ hash: null,
167
249
  passed: false,
168
250
  errors: [{ type, message }],
169
251
  warnings: [],