@xenonbyte/da-vinci-workflow 0.1.21 → 0.1.23

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/lib/audit.js CHANGED
@@ -2,8 +2,33 @@ const fs = require("fs");
2
2
  const path = require("path");
3
3
  const { getStandardPenStatePath, readPenState, hashPenDocument, readPenDocument } = require("./pen-persistence");
4
4
  const { getSessionStatePath, readSessionState } = require("./pencil-session");
5
+ const { isPathInside, listFilesRecursiveSafe } = require("./fs-safety");
6
+ const {
7
+ normalizeCheckpointLabel,
8
+ parseCheckpointStatusMap,
9
+ hasContextDeltaExpectationSignals,
10
+ parseSupersedesTokens,
11
+ buildContextDeltaReferenceIndex,
12
+ resolveSupersedesReferenceIndices,
13
+ inspectContextDelta,
14
+ hasConfiguredDesignSupervisorReview,
15
+ isDesignSupervisorReviewRequired,
16
+ inspectDesignSupervisorReview
17
+ } = require("./audit-parsers");
5
18
 
6
19
  const IMAGE_EXPORT_PATTERN = /\.(png|jpe?g|webp|pdf)$/i;
20
+ const AUDIT_SCAN_LIMITS = Object.freeze({
21
+ maxDepth: 24,
22
+ maxEntries: 12000
23
+ });
24
+ const CHANGE_SCAN_LIMITS = Object.freeze({
25
+ maxDepth: 12,
26
+ maxEntries: 6000
27
+ });
28
+ const EXPORT_SCAN_LIMITS = Object.freeze({
29
+ maxDepth: 4,
30
+ maxEntries: 1200
31
+ });
7
32
 
8
33
  function pathExists(targetPath) {
9
34
  return fs.existsSync(targetPath);
@@ -16,17 +41,10 @@ function readTextIfExists(targetPath) {
16
41
  return fs.readFileSync(targetPath, "utf8");
17
42
  }
18
43
 
19
- function listFilesRecursive(rootDir) {
20
- if (!pathExists(rootDir)) {
21
- return [];
22
- }
23
-
24
- return fs.readdirSync(rootDir, { withFileTypes: true }).flatMap((entry) => {
25
- const fullPath = path.join(rootDir, entry.name);
26
- if (entry.isDirectory()) {
27
- return listFilesRecursive(fullPath);
28
- }
29
- return [fullPath];
44
+ function listFilesRecursive(rootDir, limits = {}) {
45
+ return listFilesRecursiveSafe(rootDir, {
46
+ includeDotfiles: true,
47
+ ...limits
30
48
  });
31
49
  }
32
50
 
@@ -45,18 +63,35 @@ function relativeTo(projectRoot, targetPath) {
45
63
  return path.relative(projectRoot, targetPath) || ".";
46
64
  }
47
65
 
48
- function escapeRegExp(value) {
49
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
- }
51
-
52
66
  function collectRegisteredPenPaths(projectRoot, designRegistryPath) {
53
67
  const registryText = readTextIfExists(designRegistryPath);
54
68
  const matches = registryText.match(/\.da-vinci\/designs\/[^\s`]+\.pen/g) || [];
55
- return [...new Set(matches)].map((relativePath) => path.join(projectRoot, relativePath));
69
+ const validPaths = [];
70
+ const escapedPaths = [];
71
+
72
+ for (const relativePath of [...new Set(matches)]) {
73
+ const resolvedPath = path.resolve(projectRoot, relativePath);
74
+ if (!isPathInside(projectRoot, resolvedPath)) {
75
+ escapedPaths.push({
76
+ relativePath,
77
+ resolvedPath
78
+ });
79
+ continue;
80
+ }
81
+ validPaths.push(resolvedPath);
82
+ }
83
+
84
+ return {
85
+ validPaths,
86
+ escapedPaths
87
+ };
56
88
  }
57
89
 
58
90
  function getNonEmptyChangeDirs(changesDir) {
59
- return listChildDirs(changesDir).filter((changeDir) => listFilesRecursive(changeDir).length > 0);
91
+ return listChildDirs(changesDir).filter((changeDir) => {
92
+ const scan = listFilesRecursive(changeDir, CHANGE_SCAN_LIMITS);
93
+ return scan.files.length > 0;
94
+ });
60
95
  }
61
96
 
62
97
  function getExpectedChangeArtifacts(changeDir) {
@@ -127,440 +162,37 @@ function pushUnique(targetList, message) {
127
162
  }
128
163
  }
129
164
 
130
- function getMarkdownSection(text, heading) {
131
- if (!text) {
132
- return "";
133
- }
134
-
135
- const escapedHeading = escapeRegExp(heading);
136
- const headingPattern = new RegExp(`^##\\s+${escapedHeading}\\s*$`, "i");
137
- const anyHeadingPattern = /^##\s+/;
138
- const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
139
- const sectionLines = [];
140
- let capturing = false;
141
-
142
- for (const line of lines) {
143
- if (capturing && anyHeadingPattern.test(line)) {
144
- break;
145
- }
146
-
147
- if (!capturing && headingPattern.test(line)) {
148
- capturing = true;
149
- continue;
150
- }
151
-
152
- if (capturing) {
153
- sectionLines.push(line);
154
- }
155
- }
156
-
157
- return sectionLines.join("\n").trim();
158
- }
159
-
160
- function normalizeCheckpointLabel(value) {
161
- return String(value || "")
162
- .toLowerCase()
163
- .replace(/`/g, "")
164
- .replace(/[_-]+/g, " ")
165
- .replace(/\s+/g, " ")
166
- .trim();
167
- }
168
-
169
- function parseCheckpointStatusMap(markdownText) {
170
- const section = getMarkdownSection(markdownText, "Checkpoint Status");
171
- if (!section) {
172
- return {};
173
- }
174
-
175
- const statuses = {};
176
- const matches = section.matchAll(/(?:^|\n)\s*-\s*`?([^`:\n]+?)`?\s*:\s*(PASS|WARN|BLOCK)\b/gi);
177
- for (const match of matches) {
178
- const label = normalizeCheckpointLabel(match[1]);
179
- if (!label) {
180
- continue;
181
- }
182
- statuses[label] = String(match[2]).toUpperCase();
183
- }
184
-
185
- return statuses;
186
- }
187
-
188
- function hasContextDeltaExpectationSignals(markdownText) {
189
- const text = String(markdownText || "");
190
- return (
191
- /##\s+(Checkpoint Status|MCP Runtime Gate)\b/i.test(text) ||
192
- /(?:^|\n)\s*(?:[-*]\s*)?`?Context Delta Required`?\s*:\s*(?:true|yes|on|1)\b/i.test(text)
193
- );
194
- }
195
-
196
- function parseSupersedesTokens(value) {
197
- return String(value || "")
198
- .split(/[,\n;]/)
199
- .map((token) => token.trim())
200
- .filter(Boolean);
201
- }
202
-
203
- function normalizeTimeToken(value) {
204
- const raw = String(value || "").trim();
205
- if (!raw) {
206
- return "";
207
- }
208
-
209
- const parseCandidates = [];
210
- const rawWithT = raw.includes(" ") ? raw.replace(/\s+/, "T") : raw;
211
- const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(rawWithT);
212
-
213
- if (!hasExplicitTimezone && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(rawWithT)) {
214
- parseCandidates.push(`${rawWithT}Z`);
215
- }
216
-
217
- parseCandidates.push(rawWithT);
218
- parseCandidates.push(raw);
219
-
220
- for (const candidate of parseCandidates) {
221
- const timestamp = Date.parse(candidate);
222
- if (!Number.isFinite(timestamp)) {
223
- continue;
224
- }
225
- return new Date(timestamp).toISOString();
226
- }
227
-
228
- return "";
229
- }
230
-
231
- function getTimeReferenceKeys(value) {
232
- const raw = String(value || "").trim();
233
- if (!raw) {
234
- return [];
235
- }
236
-
237
- const keys = new Set([raw]);
238
-
239
- if (raw.includes(" ")) {
240
- keys.add(raw.replace(/\s+/, "T"));
241
- }
242
-
243
- if (raw.endsWith(".000Z")) {
244
- keys.add(raw.replace(".000Z", "Z"));
245
- }
246
-
247
- const normalized = normalizeTimeToken(raw);
248
- if (normalized) {
249
- keys.add(normalized);
250
- if (normalized.endsWith(".000Z")) {
251
- keys.add(normalized.replace(".000Z", "Z"));
252
- }
253
- }
254
-
255
- return [...keys];
256
- }
257
-
258
- function buildContextDeltaReferenceIndex(entries) {
259
- const referenceIndex = new Map();
260
-
261
- function addReference(key, entryIndex) {
262
- const normalizedKey = String(key || "").trim();
263
- if (!normalizedKey) {
264
- return;
265
- }
266
-
267
- const existing = referenceIndex.get(normalizedKey) || [];
268
- existing.push(entryIndex);
269
- referenceIndex.set(normalizedKey, existing);
270
- }
271
-
272
- entries.forEach((entry, entryIndex) => {
273
- if (entry.time) {
274
- for (const timeKey of getTimeReferenceKeys(entry.time)) {
275
- addReference(timeKey, entryIndex);
276
- addReference(`time:${timeKey}`, entryIndex);
277
- }
278
- }
279
-
280
- if (entry.checkpointType && entry.time) {
281
- const normalizedCheckpointType = normalizeCheckpointLabel(entry.checkpointType);
282
- for (const timeKey of getTimeReferenceKeys(entry.time)) {
283
- addReference(`${entry.checkpointType}@${timeKey}`, entryIndex);
284
- addReference(`${normalizedCheckpointType}@${timeKey}`, entryIndex);
285
- }
286
- }
287
- });
288
-
289
- return referenceIndex;
290
- }
291
-
292
- function getSupersedesCandidateKeys(token) {
293
- const trimmed = String(token || "").trim();
294
- if (!trimmed) {
295
- return [];
165
+ function appendTraversalWarnings(projectRoot, rootDir, scan, warnings) {
166
+ if (!scan) {
167
+ return;
296
168
  }
297
169
 
298
- const keys = new Set([trimmed]);
299
- const prefixedTimeMatch = trimmed.match(/^time\s*:\s*(.+)$/i);
300
- if (prefixedTimeMatch) {
301
- for (const timeKey of getTimeReferenceKeys(prefixedTimeMatch[1])) {
302
- keys.add(timeKey);
303
- keys.add(`time:${timeKey}`);
304
- }
305
- } else {
306
- keys.add(`time:${trimmed}`);
307
- for (const timeKey of getTimeReferenceKeys(trimmed)) {
308
- keys.add(timeKey);
309
- keys.add(`time:${timeKey}`);
310
- }
311
- }
170
+ const scope = relativeTo(projectRoot, rootDir);
312
171
 
313
- const atIndex = trimmed.indexOf("@");
314
- if (atIndex > 0 && atIndex < trimmed.length - 1) {
315
- const checkpointType = trimmed.slice(0, atIndex).trim();
316
- const time = trimmed.slice(atIndex + 1).trim();
317
- if (checkpointType && time) {
318
- const normalizedCheckpointType = normalizeCheckpointLabel(checkpointType);
319
- for (const timeKey of getTimeReferenceKeys(time)) {
320
- keys.add(`${checkpointType}@${timeKey}`);
321
- keys.add(`${normalizedCheckpointType}@${timeKey}`);
322
- }
323
- }
172
+ if (scan.truncated) {
173
+ const reason = scan.entryLimitHit
174
+ ? `file limit ${scan.maxEntries} reached`
175
+ : `depth limit ${scan.maxDepth} reached`;
176
+ pushUnique(warnings, `File scan truncated under ${scope}: ${reason}.`);
324
177
  }
325
178
 
326
- return [...keys];
327
- }
328
-
329
- function resolveSupersedesReferenceIndices(token, referenceIndex) {
330
- const indices = new Set();
331
- for (const key of getSupersedesCandidateKeys(token)) {
332
- const matches = referenceIndex.get(key);
333
- if (!matches) {
334
- continue;
335
- }
336
- for (const entryIndex of matches) {
337
- indices.add(entryIndex);
338
- }
339
- }
340
- return [...indices].sort((a, b) => a - b);
341
- }
342
-
343
- function inspectContextDelta(markdownText) {
344
- const section = getMarkdownSection(markdownText, "Context Delta");
345
- if (!section) {
346
- return {
347
- found: false,
348
- hasConcreteEntry: false,
349
- entries: [],
350
- incompleteEntryCount: 0
351
- };
352
- }
353
-
354
- const lines = String(section).replace(/\r\n?/g, "\n").split("\n");
355
- const entries = [];
356
- let current = null;
357
-
358
- function ensureCurrent() {
359
- if (!current) {
360
- current = {};
361
- }
362
- }
363
-
364
- function flushCurrent() {
365
- if (!current) {
366
- return;
367
- }
368
-
369
- const hasAnyValue = [
370
- current.time,
371
- current.checkpointType,
372
- current.goal,
373
- current.decision,
374
- current.constraints,
375
- current.impact,
376
- current.status,
377
- current.nextAction,
378
- current.supersedes
379
- ].some((value) => Boolean(value));
380
-
381
- if (hasAnyValue) {
382
- entries.push(current);
383
- }
384
- current = null;
385
- }
386
-
387
- for (const rawLine of lines) {
388
- const line = rawLine.trim();
389
-
390
- if (!line) {
391
- continue;
392
- }
393
-
394
- const timeMatch = line.match(/^-+\s*`?time`?\s*:\s*(.+)$/i);
395
- if (timeMatch) {
396
- flushCurrent();
397
- current = {
398
- time: timeMatch[1].trim()
399
- };
400
- continue;
401
- }
402
-
403
- const checkpointTypeMatch = line.match(/^-+\s*`?checkpoint(?:[_ -]?type)?`?\s*:\s*(.+)$/i);
404
- if (checkpointTypeMatch) {
405
- ensureCurrent();
406
- current.checkpointType = checkpointTypeMatch[1].trim();
407
- continue;
408
- }
409
-
410
- const goalMatch = line.match(/^-+\s*`?goal`?\s*:\s*(.+)$/i);
411
- if (goalMatch) {
412
- ensureCurrent();
413
- current.goal = goalMatch[1].trim();
414
- continue;
415
- }
416
-
417
- const decisionMatch = line.match(/^-+\s*`?decision`?\s*:\s*(.+)$/i);
418
- if (decisionMatch) {
419
- ensureCurrent();
420
- current.decision = decisionMatch[1].trim();
421
- continue;
422
- }
423
-
424
- const constraintsMatch = line.match(/^-+\s*`?constraints`?\s*:\s*(.+)$/i);
425
- if (constraintsMatch) {
426
- ensureCurrent();
427
- current.constraints = constraintsMatch[1].trim();
428
- continue;
429
- }
430
-
431
- const impactMatch = line.match(/^-+\s*`?impact`?\s*:\s*(.+)$/i);
432
- if (impactMatch) {
433
- ensureCurrent();
434
- current.impact = impactMatch[1].trim();
435
- continue;
436
- }
437
-
438
- const statusMatch = line.match(/^-+\s*`?status`?\s*:\s*(PASS|WARN|BLOCK)\b/i);
439
- if (statusMatch) {
440
- ensureCurrent();
441
- current.status = String(statusMatch[1]).toUpperCase();
442
- continue;
443
- }
444
-
445
- const nextActionMatch = line.match(/^-+\s*`?next(?:[_ -]?action)?`?\s*:\s*(.+)$/i);
446
- if (nextActionMatch) {
447
- ensureCurrent();
448
- current.nextAction = nextActionMatch[1].trim();
449
- continue;
450
- }
451
-
452
- const supersedesMatch = line.match(/^-+\s*`?supersedes`?\s*:\s*(.+)$/i);
453
- if (supersedesMatch) {
454
- ensureCurrent();
455
- current.supersedes = supersedesMatch[1].trim();
456
- continue;
457
- }
458
- }
459
-
460
- flushCurrent();
461
-
462
- const hasConcreteEntry = entries.some(
463
- (entry) => entry.time || entry.checkpointType || entry.status || entry.decision || entry.nextAction
464
- );
465
- const incompleteEntryCount = entries.filter(
466
- (entry) => !entry.time || !entry.checkpointType || !entry.status
467
- ).length;
468
-
469
- return {
470
- found: true,
471
- hasConcreteEntry,
472
- entries,
473
- incompleteEntryCount
474
- };
475
- }
476
-
477
- function getVisualAssistFieldValues(daVinciText, fieldName) {
478
- const section = getMarkdownSection(daVinciText, "Visual Assist");
479
- if (!section) {
480
- return [];
481
- }
482
-
483
- const fieldPattern = new RegExp(`^\\s*-\\s*${escapeRegExp(fieldName)}\\s*:\\s*(.*)$`, "i");
484
- const nestedValuePattern = /^\s{2,}-\s*(.+?)\s*$/;
485
- const nextFieldPattern = /^\s*-\s+[^:]+:\s*.*$/;
486
- const values = [];
487
- let capturing = false;
488
-
489
- for (const rawLine of String(section).replace(/\r\n?/g, "\n").split("\n")) {
490
- const fieldMatch = rawLine.match(fieldPattern);
491
- if (!capturing && fieldMatch) {
492
- capturing = true;
493
- const inlineValue = (fieldMatch[1] || "").trim();
494
- if (inlineValue) {
495
- values.push(inlineValue);
496
- }
497
- continue;
498
- }
499
-
500
- if (capturing && nextFieldPattern.test(rawLine)) {
501
- break;
502
- }
503
-
504
- if (capturing) {
505
- const nestedMatch = rawLine.match(nestedValuePattern);
506
- if (nestedMatch) {
507
- const value = nestedMatch[1].trim();
508
- if (value) {
509
- values.push(value);
510
- }
511
- } else if (rawLine.trim() === "") {
512
- continue;
513
- } else {
514
- break;
515
- }
516
- }
517
- }
518
-
519
- return values;
520
- }
521
-
522
- function hasConfiguredDesignSupervisorReview(daVinciText) {
523
- return getVisualAssistFieldValues(daVinciText, "Design-supervisor reviewers").length > 0;
524
- }
525
-
526
- function isDesignSupervisorReviewRequired(daVinciText) {
527
- return getVisualAssistFieldValues(daVinciText, "Require Supervisor Review").some((value) =>
528
- /^true$/i.test(String(value).trim())
529
- );
530
- }
531
-
532
- function inspectDesignSupervisorReview(pencilDesignText) {
533
- const section = getMarkdownSection(pencilDesignText, "Design-Supervisor Review");
534
- if (!section) {
535
- return {
536
- found: false,
537
- status: null,
538
- acceptedWarn: false,
539
- hasIssueList: false,
540
- hasRevisionOutcome: false
541
- };
179
+ if (scan.skippedSymlinks > 0) {
180
+ pushUnique(
181
+ warnings,
182
+ `File scan skipped ${scan.skippedSymlinks} symbolic link entr${
183
+ scan.skippedSymlinks === 1 ? "y" : "ies"
184
+ } under ${scope}.`
185
+ );
542
186
  }
543
187
 
544
- const statusMatch = section.match(/(?:^|\n)\s*-\s*(?:Status|状态)\s*:\s*(PASS|WARN|BLOCK)\b/i);
545
- const status = statusMatch ? statusMatch[1].toUpperCase() : null;
546
- const issueListMatch = section.match(/(?:^|\n)\s*-\s*(?:Issue list|问题列表)\s*:\s*(.+)$/im);
547
- const revisionOutcomeMatch = section.match(
548
- /(?:^|\n)\s*-\s*(?:Revision outcome|修订结果)\s*:\s*(.+)$/im
549
- );
550
- const revisionOutcome = revisionOutcomeMatch ? revisionOutcomeMatch[1].trim() : "";
551
- const acceptedWarn =
552
- status === "WARN" &&
553
- /(accepted|accepted with follow-up|accepted warning|warn accepted|接受|已接受|接受警告)/i.test(
554
- revisionOutcome
188
+ if (scan.readErrors.length > 0) {
189
+ pushUnique(
190
+ warnings,
191
+ `File scan hit ${scan.readErrors.length} read error${
192
+ scan.readErrors.length === 1 ? "" : "s"
193
+ } under ${scope}.`
555
194
  );
556
-
557
- return {
558
- found: true,
559
- status,
560
- acceptedWarn,
561
- hasIssueList: Boolean(issueListMatch && issueListMatch[1].trim()),
562
- hasRevisionOutcome: Boolean(revisionOutcome)
563
- };
195
+ }
564
196
  }
565
197
 
566
198
  function auditProject(projectPathInput, options = {}) {
@@ -645,7 +277,9 @@ function auditProject(projectPathInput, options = {}) {
645
277
  }
646
278
  }
647
279
 
648
- const daVinciFiles = listFilesRecursive(daVinciDir);
280
+ const daVinciScan = listFilesRecursive(daVinciDir, AUDIT_SCAN_LIMITS);
281
+ appendTraversalWarnings(projectRoot, daVinciDir, daVinciScan, warnings);
282
+ const daVinciFiles = daVinciScan.files;
649
283
  const misplacedExports = daVinciFiles.filter(
650
284
  (filePath) => IMAGE_EXPORT_PATTERN.test(filePath) && !isAllowedExportPath(projectRoot, filePath)
651
285
  );
@@ -659,7 +293,9 @@ function auditProject(projectPathInput, options = {}) {
659
293
  );
660
294
  }
661
295
 
662
- const designFiles = listFilesRecursive(designsDir);
296
+ const designScan = listFilesRecursive(designsDir, AUDIT_SCAN_LIMITS);
297
+ appendTraversalWarnings(projectRoot, designsDir, designScan, warnings);
298
+ const designFiles = designScan.files;
663
299
  const penFiles = designFiles.filter((filePath) => filePath.endsWith(".pen"));
664
300
  const pollutedDesignFiles = designFiles.filter((filePath) => !filePath.endsWith(".pen"));
665
301
 
@@ -687,7 +323,21 @@ function auditProject(projectPathInput, options = {}) {
687
323
  );
688
324
  }
689
325
 
690
- const registeredPenPaths = collectRegisteredPenPaths(projectRoot, designRegistryPath);
326
+ const {
327
+ validPaths: registeredPenPaths,
328
+ escapedPaths: escapedRegisteredPenPaths
329
+ } = collectRegisteredPenPaths(projectRoot, designRegistryPath);
330
+ for (const escapedPath of escapedRegisteredPenPaths) {
331
+ const message =
332
+ `Registered design source escapes project root and will be ignored: ${escapedPath.relativePath} ` +
333
+ `(${escapedPath.resolvedPath})`;
334
+ if (mode === "completion") {
335
+ failures.push(message);
336
+ } else {
337
+ warnings.push(message);
338
+ }
339
+ }
340
+
691
341
  for (const registeredPenPath of registeredPenPaths) {
692
342
  if (!pathExists(registeredPenPath)) {
693
343
  failures.push(
@@ -765,7 +415,9 @@ function auditProject(projectPathInput, options = {}) {
765
415
  : changeDirs;
766
416
 
767
417
  for (const changeDir of changeDirs) {
768
- const changeFiles = listFilesRecursive(changeDir);
418
+ const changeScan = listFilesRecursive(changeDir, CHANGE_SCAN_LIMITS);
419
+ appendTraversalWarnings(projectRoot, changeDir, changeScan, warnings);
420
+ const changeFiles = changeScan.files;
769
421
  const changeRel = relativeTo(projectRoot, changeDir);
770
422
 
771
423
  if (changeFiles.length === 0) {
@@ -777,7 +429,9 @@ function auditProject(projectPathInput, options = {}) {
777
429
  }
778
430
 
779
431
  const exportsDir = path.join(changeDir, "exports");
780
- const exportsFiles = listFilesRecursive(exportsDir);
432
+ const exportsScan = listFilesRecursive(exportsDir, EXPORT_SCAN_LIMITS);
433
+ appendTraversalWarnings(projectRoot, exportsDir, exportsScan, warnings);
434
+ const exportsFiles = exportsScan.files;
781
435
  if (exportsFiles.length > 0) {
782
436
  notes.push(`Detected review exports under ${relativeTo(projectRoot, exportsDir)}.`);
783
437
  }