pinokiod 7.3.14 → 7.4.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.
Files changed (51) hide show
  1. package/kernel/api/index.js +111 -15
  2. package/kernel/api/script/index.js +10 -0
  3. package/kernel/autolaunch.js +111 -0
  4. package/kernel/environment.js +89 -1
  5. package/kernel/git.js +9 -19
  6. package/kernel/index.js +142 -43
  7. package/kernel/launch_requirements.js +1115 -0
  8. package/kernel/ready.js +231 -0
  9. package/kernel/script.js +16 -0
  10. package/kernel/shells.js +9 -1
  11. package/kernel/workspace_status.js +111 -45
  12. package/package.json +1 -1
  13. package/server/autolaunch.js +725 -0
  14. package/server/index.js +244 -411
  15. package/server/public/logs.js +15 -1
  16. package/server/public/style.css +99 -31
  17. package/server/views/app.ejs +263 -159
  18. package/server/views/autolaunch.ejs +363 -75
  19. package/server/views/index.ejs +550 -26
  20. package/server/views/logs.ejs +14 -12
  21. package/server/views/partials/app_autolaunch_dependency_events.ejs +99 -0
  22. package/server/views/partials/app_autolaunch_dependency_save.ejs +10 -0
  23. package/server/views/partials/app_autolaunch_dependency_styles.ejs +357 -0
  24. package/server/views/partials/app_autolaunch_modal_helpers.ejs +347 -0
  25. package/server/views/partials/autolaunch_dependency_helpers.ejs +204 -0
  26. package/server/views/partials/autolaunch_dependency_save.ejs +13 -0
  27. package/server/views/partials/autolaunch_dependency_styles.ejs +347 -0
  28. package/server/views/partials/home_action_modal.ejs +4 -1
  29. package/server/views/partials/launch_requirements_status_client.ejs +271 -0
  30. package/server/views/partials/launch_requirements_status_styles.ejs +171 -0
  31. package/server/views/partials/launch_settings_dependency_save_factory.ejs +45 -0
  32. package/server/views/partials/launch_settings_dependency_script_loader_factory.ejs +25 -0
  33. package/server/views/terminal.ejs +196 -2
  34. package/test/home-autolaunch-live-ui.test.js +455 -0
  35. package/test/launch-requirements-browser.test.js +579 -0
  36. package/test/launch-requirements-contract-coverage.test.js +627 -0
  37. package/test/launch-requirements-real-browser.js +625 -0
  38. package/test/launch-requirements-status-client.test.js +132 -0
  39. package/test/launch-requirements.test.js +1806 -0
  40. package/test/launch-settings-ui.test.js +370 -0
  41. package/test/ready-state.test.js +49 -0
  42. package/test/server-autolaunch.test.js +1052 -0
  43. package/test/startup-git-index-benchmark.js +409 -0
  44. package/test/startup-git-index-browser.js +320 -0
  45. package/test/startup-git-index-performance.test.js +380 -0
  46. package/test/startup-git-index-refactor.test.js +450 -0
  47. package/test/startup-git-index-route.test.js +588 -0
  48. package/test/universal-launcher.smoke.spec.js +10 -9
  49. package/test/workspace-gitignore-benchmark.js +815 -0
  50. package/test/workspace-gitignore-path-scoped.test.js +256 -0
  51. package/spec/INSTRUCTION_SYNC.md +0 -432
@@ -0,0 +1,815 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs')
4
+ const fsp = fs.promises
5
+ const os = require('node:os')
6
+ const path = require('node:path')
7
+ const { execFileSync, spawn } = require('node:child_process')
8
+ const { performance } = require('node:perf_hooks')
9
+
10
+ const ignore = require('ignore')
11
+
12
+ const CURRENT_GITIGNORE_SCAN_SKIP_DIRS = new Set(['.git', 'node_modules', 'venv', '.venv'])
13
+ const CURRENT_REPO_SCAN_SKIP_DIRS = new Set(['node_modules', 'venv'])
14
+
15
+ const GIT_STATUS_IGNORE_PATTERNS = [
16
+ /(^|\/)node_modules\//,
17
+ /(^|\/)__pycache__\//,
18
+ /(^|\/)\.cache\//,
19
+ /(^|\/)\.ruff_cache\//,
20
+ /(^|\/)\.tox\//,
21
+ /(^|\/)\.terraform\//,
22
+ /(^|\/)\.parcel-cache\//,
23
+ /(^|\/)\.webpack\//,
24
+ /(^|\/)\.mypy_cache\//,
25
+ /(^|\/)\.pytest_cache\//,
26
+ /(^|\/)\.git\//,
27
+ ]
28
+
29
+ function usage() {
30
+ return [
31
+ 'Usage: node test/workspace-gitignore-benchmark.js [options]',
32
+ '',
33
+ 'Options:',
34
+ ' --api-root <path> API root. Defaults to $PINOKIO_API_ROOT or ~/pinokio/api.',
35
+ ' --apps <list> Comma-separated top-level app folders. Defaults to all folders.',
36
+ ' --max-apps <n> Limit number of app folders after sorting.',
37
+ ' --out <path> Output JSON path. Defaults to ~/.codex/benchmarks/pinokiod-gitignore/gitignore-<timestamp>.json.',
38
+ ' --markdown-out <path> Output Markdown summary path. Defaults to JSON path with .md extension.',
39
+ ' --no-markdown Do not write a Markdown summary.',
40
+ ' --help Show this help.',
41
+ '',
42
+ 'This is a focused component benchmark for replacing the workspace .gitignore pre-scan.',
43
+ 'It does not prove the runtime route no longer calls the pre-scan; route instrumentation must verify that after implementation.',
44
+ ].join('\n')
45
+ }
46
+
47
+ function parseArgs(argv) {
48
+ const args = {
49
+ apiRoot: process.env.PINOKIO_API_ROOT || path.join(os.homedir(), 'pinokio', 'api'),
50
+ apps: null,
51
+ maxApps: null,
52
+ out: null,
53
+ markdownOut: null,
54
+ noMarkdown: false,
55
+ help: false,
56
+ }
57
+
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const arg = argv[i]
60
+ const next = () => {
61
+ i += 1
62
+ if (i >= argv.length) throw new Error(`${arg} requires a value`)
63
+ return argv[i]
64
+ }
65
+ if (arg === '--help' || arg === '-h') {
66
+ args.help = true
67
+ } else if (arg === '--api-root') {
68
+ args.apiRoot = path.resolve(next())
69
+ } else if (arg === '--apps') {
70
+ args.apps = next().split(',').map((item) => item.trim()).filter(Boolean)
71
+ } else if (arg === '--max-apps') {
72
+ args.maxApps = Number.parseInt(next(), 10)
73
+ } else if (arg === '--out') {
74
+ args.out = path.resolve(next())
75
+ } else if (arg === '--markdown-out') {
76
+ args.markdownOut = path.resolve(next())
77
+ } else if (arg === '--no-markdown') {
78
+ args.noMarkdown = true
79
+ } else {
80
+ throw new Error(`Unknown argument: ${arg}`)
81
+ }
82
+ }
83
+
84
+ if (args.maxApps != null && (!Number.isInteger(args.maxApps) || args.maxApps < 1)) {
85
+ throw new Error('--max-apps must be a positive integer')
86
+ }
87
+
88
+ if (!args.out) {
89
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
90
+ args.out = path.join(os.homedir(), '.codex', 'benchmarks', 'pinokiod-gitignore', `gitignore-${stamp}.json`)
91
+ }
92
+
93
+ if (!args.markdownOut && !args.noMarkdown) {
94
+ args.markdownOut = args.out.replace(/\.json$/i, '.md')
95
+ if (args.markdownOut === args.out) {
96
+ args.markdownOut = `${args.out}.md`
97
+ }
98
+ }
99
+
100
+ return args
101
+ }
102
+
103
+ function normalizePath(value) {
104
+ return String(value || '').replace(/\\/g, '/').replace(/\/+/g, '/')
105
+ }
106
+
107
+ function posixRelative(from, to) {
108
+ return normalizePath(path.relative(from, to))
109
+ }
110
+
111
+ function execFileText(command, args, options = {}) {
112
+ return new Promise((resolve) => {
113
+ const started = performance.now()
114
+ const child = spawn(command, args, {
115
+ cwd: options.cwd,
116
+ env: options.env || process.env,
117
+ stdio: ['pipe', 'pipe', 'pipe'],
118
+ })
119
+ let stdout = ''
120
+ let stderr = ''
121
+ let done = false
122
+ const timeoutMs = options.timeout || 30000
123
+ const timeout = setTimeout(() => {
124
+ if (!done) {
125
+ child.kill('SIGKILL')
126
+ }
127
+ }, timeoutMs)
128
+
129
+ child.stdout.on('data', (chunk) => {
130
+ stdout += chunk.toString()
131
+ if (stdout.length > (options.maxBuffer || 50 * 1024 * 1024)) {
132
+ child.kill('SIGKILL')
133
+ }
134
+ })
135
+ child.stderr.on('data', (chunk) => {
136
+ stderr += chunk.toString()
137
+ })
138
+ child.stdin.on('error', () => {
139
+ // Some git commands can close stdin before Node finishes writing a large batch.
140
+ // The process exit code and captured output are the source of truth.
141
+ })
142
+ child.on('error', (error) => {
143
+ if (done) return
144
+ done = true
145
+ clearTimeout(timeout)
146
+ resolve({
147
+ ok: false,
148
+ code: 1,
149
+ error: error ? (error.message || String(error)) : null,
150
+ stdout,
151
+ stderr,
152
+ ms: performance.now() - started,
153
+ })
154
+ })
155
+ child.on('close', (code, signal) => {
156
+ if (done) return
157
+ done = true
158
+ clearTimeout(timeout)
159
+ resolve({
160
+ ok: code === 0,
161
+ code: typeof code === 'number' ? code : 1,
162
+ signal,
163
+ error: code === 0 ? null : `Command exited with code ${code}${signal ? ` signal ${signal}` : ''}`,
164
+ stdout,
165
+ stderr,
166
+ ms: performance.now() - started,
167
+ })
168
+ })
169
+ if (options.input != null) {
170
+ child.stdin.end(options.input)
171
+ } else {
172
+ child.stdin.end()
173
+ }
174
+ })
175
+ }
176
+
177
+ function createEmptyWalkMetrics() {
178
+ return {
179
+ ms: 0,
180
+ dirs: 0,
181
+ readdir: 0,
182
+ readdirEntries: 0,
183
+ readFile: 0,
184
+ readFileBytes: 0,
185
+ gitignoreFiles: 0,
186
+ errors: 0,
187
+ }
188
+ }
189
+
190
+ async function buildCurrentWorkspaceIgnoreEngine(workspaceRoot) {
191
+ const metrics = createEmptyWalkMetrics()
192
+ const engine = ignore()
193
+ const gitignoreFiles = []
194
+ const started = performance.now()
195
+
196
+ async function walk(dir) {
197
+ metrics.dirs += 1
198
+ let entries
199
+ try {
200
+ entries = await fsp.readdir(dir, { withFileTypes: true })
201
+ metrics.readdir += 1
202
+ metrics.readdirEntries += entries.length
203
+ } catch (_) {
204
+ metrics.errors += 1
205
+ return
206
+ }
207
+
208
+ for (const entry of entries) {
209
+ const child = path.join(dir, entry.name)
210
+ if (entry.isDirectory()) {
211
+ if (CURRENT_GITIGNORE_SCAN_SKIP_DIRS.has(entry.name)) {
212
+ continue
213
+ }
214
+ await walk(child)
215
+ } else if (entry.isFile() && entry.name === '.gitignore') {
216
+ gitignoreFiles.push(child)
217
+ }
218
+ }
219
+ }
220
+
221
+ await walk(workspaceRoot)
222
+
223
+ for (const gitignorePath of gitignoreFiles) {
224
+ let content
225
+ try {
226
+ content = await fsp.readFile(gitignorePath, 'utf8')
227
+ metrics.readFile += 1
228
+ metrics.readFileBytes += Buffer.byteLength(content)
229
+ metrics.gitignoreFiles += 1
230
+ } catch (_) {
231
+ metrics.errors += 1
232
+ continue
233
+ }
234
+
235
+ addGitignoreContent(engine, workspaceRoot, gitignorePath, content)
236
+ }
237
+
238
+ metrics.ms = performance.now() - started
239
+ return { engine, metrics, gitignoreFiles }
240
+ }
241
+
242
+ function addGitignoreContent(engine, root, gitignorePath, content) {
243
+ const relDir = path.relative(root, path.dirname(gitignorePath))
244
+ const prefix = relDir && relDir !== '.' ? normalizePath(relDir) + '/' : ''
245
+ for (let line of content.split(/\r?\n/)) {
246
+ if (!line) continue
247
+ line = line.trim()
248
+ if (!line || line.startsWith('#')) continue
249
+ let negated = false
250
+ if (line.startsWith('!')) {
251
+ negated = true
252
+ line = line.slice(1)
253
+ }
254
+ line = line.replace(/^\/+/, '')
255
+ if (!line) continue
256
+ const pattern = prefix + line
257
+ engine.add((negated ? '!' : '') + pattern)
258
+ }
259
+ }
260
+
261
+ async function candidateIgnoredByPathScopedGitignores(workspaceRoot, records) {
262
+ const ignored = new Set()
263
+ const contentCache = new Map()
264
+ const metrics = {
265
+ ms: 0,
266
+ checked: records.length,
267
+ readFile: 0,
268
+ readFileBytes: 0,
269
+ }
270
+ const started = performance.now()
271
+
272
+ const readGitignore = async (gitignorePath) => {
273
+ if (contentCache.has(gitignorePath)) {
274
+ return contentCache.get(gitignorePath)
275
+ }
276
+ try {
277
+ const content = await fsp.readFile(gitignorePath, 'utf8')
278
+ contentCache.set(gitignorePath, content)
279
+ metrics.readFile += 1
280
+ metrics.readFileBytes += Buffer.byteLength(content)
281
+ return content
282
+ } catch (_) {
283
+ contentCache.set(gitignorePath, null)
284
+ return null
285
+ }
286
+ }
287
+
288
+ for (const record of records) {
289
+ const engine = ignore()
290
+ const workspaceRelative = normalizePath(record.workspaceRelative)
291
+ const parts = workspaceRelative.split('/').filter(Boolean)
292
+ let stopped = false
293
+
294
+ for (let depth = 0; depth < parts.length; depth++) {
295
+ const relDir = depth === 0 ? '' : parts.slice(0, depth).join('/')
296
+ const gitignorePath = path.join(workspaceRoot, relDir, '.gitignore')
297
+ const content = await readGitignore(gitignorePath)
298
+ if (content) {
299
+ addGitignoreContent(engine, workspaceRoot, gitignorePath, content)
300
+ }
301
+
302
+ const nextPart = parts[depth]
303
+ if (CURRENT_GITIGNORE_SCAN_SKIP_DIRS.has(nextPart)) {
304
+ stopped = true
305
+ break
306
+ }
307
+ }
308
+
309
+ if (!stopped || parts.length > 0) {
310
+ if (workspaceRelative && engine.ignores(workspaceRelative)) {
311
+ ignored.add(record.key)
312
+ }
313
+ }
314
+ }
315
+
316
+ metrics.ms = performance.now() - started
317
+ return { ignored, metrics }
318
+ }
319
+
320
+ async function discoverReposCurrentStyle(workspaceRoot) {
321
+ const metrics = {
322
+ ms: 0,
323
+ dirs: 0,
324
+ readdir: 0,
325
+ readdirEntries: 0,
326
+ gitDirs: 0,
327
+ errors: 0,
328
+ }
329
+ const repos = []
330
+ const started = performance.now()
331
+
332
+ async function walk(dir) {
333
+ metrics.dirs += 1
334
+ let entries
335
+ try {
336
+ entries = await fsp.readdir(dir, { withFileTypes: true })
337
+ metrics.readdir += 1
338
+ metrics.readdirEntries += entries.length
339
+ } catch (_) {
340
+ metrics.errors += 1
341
+ return
342
+ }
343
+
344
+ for (const entry of entries) {
345
+ if (!entry.isDirectory()) continue
346
+ const child = path.join(dir, entry.name)
347
+ if (entry.name === '.git') {
348
+ repos.push(path.dirname(child))
349
+ metrics.gitDirs += 1
350
+ continue
351
+ }
352
+ if (CURRENT_REPO_SCAN_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
353
+ continue
354
+ }
355
+ await walk(child)
356
+ }
357
+ }
358
+
359
+ await walk(workspaceRoot)
360
+ metrics.ms = performance.now() - started
361
+ return { repos, metrics }
362
+ }
363
+
364
+ function extractGitStatusPath(statusLine) {
365
+ if (!statusLine || statusLine.length < 4) return ''
366
+ const rest = statusLine.slice(3)
367
+ const renameIdx = rest.indexOf(' -> ')
368
+ const candidate = renameIdx === -1 ? rest : rest.slice(renameIdx + 4)
369
+ return candidate.replace(/^"|"$/g, '')
370
+ }
371
+
372
+ async function gitStatusRecords(workspaceRoot, repoRoot) {
373
+ const result = await execFileText('git', [
374
+ '-c',
375
+ 'core.quotePath=false',
376
+ 'status',
377
+ '--porcelain=v1',
378
+ '--untracked-files=all',
379
+ ], {
380
+ cwd: repoRoot,
381
+ timeout: 30000,
382
+ })
383
+
384
+ const records = []
385
+ if (!result.ok) {
386
+ return { records, metrics: { ms: result.ms, ok: false, error: result.error } }
387
+ }
388
+
389
+ for (const line of result.stdout.split(/\r?\n/)) {
390
+ if (!line || line.length < 4) continue
391
+ const repoRelative = normalizePath(extractGitStatusPath(line))
392
+ if (!repoRelative) continue
393
+ const absolutePath = path.resolve(repoRoot, repoRelative)
394
+ records.push({
395
+ key: `${repoRoot}\0${repoRelative}`,
396
+ statusCode: line.slice(0, 2),
397
+ repoRoot,
398
+ repoRelative,
399
+ absolutePath,
400
+ workspaceRelative: posixRelative(workspaceRoot, absolutePath),
401
+ rawLine: line,
402
+ })
403
+ }
404
+
405
+ return { records, metrics: { ms: result.ms, ok: true, error: null } }
406
+ }
407
+
408
+ function isHardcodedStatusIgnored(relativePath) {
409
+ if (!relativePath) return false
410
+ const normalized = normalizePath(relativePath)
411
+ if (GIT_STATUS_IGNORE_PATTERNS.some((regex) => regex.test(normalized) || regex.test(`${normalized}/`))) {
412
+ return true
413
+ }
414
+ if (normalized.includes('/site-packages/')) return true
415
+ if (normalized.includes('/Scripts/')) return true
416
+ if (normalized.includes('/bin/activate')) return true
417
+ return false
418
+ }
419
+
420
+
421
+ function currentEngineIgnored(engine, records) {
422
+ const ignored = new Set()
423
+ const started = performance.now()
424
+ for (const record of records) {
425
+ const workspaceRelative = normalizePath(record.workspaceRelative)
426
+ if (workspaceRelative && engine.ignores(workspaceRelative)) {
427
+ ignored.add(record.key)
428
+ }
429
+ }
430
+ return { ignored, metrics: { ms: performance.now() - started } }
431
+ }
432
+
433
+ function compareSets(left, right) {
434
+ const onlyLeft = []
435
+ const onlyRight = []
436
+ for (const value of left) {
437
+ if (!right.has(value)) onlyLeft.push(value)
438
+ }
439
+ for (const value of right) {
440
+ if (!left.has(value)) onlyRight.push(value)
441
+ }
442
+ return { onlyLeft, onlyRight, same: onlyLeft.length === 0 && onlyRight.length === 0 }
443
+ }
444
+
445
+ async function listAppFolders(apiRoot, selectedApps, maxApps) {
446
+ const entries = await fsp.readdir(apiRoot, { withFileTypes: true })
447
+ let apps = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
448
+ if (selectedApps && selectedApps.length > 0) {
449
+ const selected = new Set(selectedApps)
450
+ apps = apps.filter((app) => selected.has(app))
451
+ }
452
+ if (maxApps) {
453
+ apps = apps.slice(0, maxApps)
454
+ }
455
+ return apps
456
+ }
457
+
458
+ async function measureWorkspace(apiRoot, appName) {
459
+ const workspaceRoot = path.join(apiRoot, appName)
460
+ const workspaceStarted = performance.now()
461
+
462
+ const currentPreScan = await buildCurrentWorkspaceIgnoreEngine(workspaceRoot)
463
+ const repoDiscovery = await discoverReposCurrentStyle(workspaceRoot)
464
+
465
+ const statusStarted = performance.now()
466
+ const records = []
467
+ const statusErrors = []
468
+ let statusMs = 0
469
+ for (const repoRoot of repoDiscovery.repos) {
470
+ const status = await gitStatusRecords(workspaceRoot, repoRoot)
471
+ statusMs += status.metrics.ms
472
+ records.push(...status.records)
473
+ if (!status.metrics.ok) {
474
+ statusErrors.push({ repoRoot, error: status.metrics.error })
475
+ }
476
+ }
477
+ const statusWallMs = performance.now() - statusStarted
478
+
479
+ const hardcodedIgnored = new Set()
480
+ for (const record of records) {
481
+ if (isHardcodedStatusIgnored(record.repoRelative)) {
482
+ hardcodedIgnored.add(record.key)
483
+ }
484
+ }
485
+
486
+ const currentFilter = currentEngineIgnored(currentPreScan.engine, records)
487
+ const candidateInputRecords = records.filter((record) => !hardcodedIgnored.has(record.key))
488
+ const candidateFilter = await candidateIgnoredByPathScopedGitignores(workspaceRoot, candidateInputRecords)
489
+
490
+ const currentFinalIgnored = new Set([...currentFilter.ignored, ...hardcodedIgnored])
491
+ const candidateFinalIgnored = new Set([...candidateFilter.ignored, ...hardcodedIgnored])
492
+ const comparison = compareSets(currentFinalIgnored, candidateFinalIgnored)
493
+ const byKey = new Map(records.map((record) => [record.key, record]))
494
+ const sampleFor = (keys) => keys.slice(0, 20).map((key) => {
495
+ const record = byKey.get(key)
496
+ return record ? {
497
+ repo: posixRelative(apiRoot, record.repoRoot),
498
+ file: record.repoRelative,
499
+ workspaceRelative: record.workspaceRelative,
500
+ statusCode: record.statusCode,
501
+ } : { key }
502
+ })
503
+
504
+ return {
505
+ app: appName,
506
+ workspaceRoot,
507
+ ok: true,
508
+ wallMs: performance.now() - workspaceStarted,
509
+ currentPreScan: currentPreScan.metrics,
510
+ repoDiscovery: {
511
+ ...repoDiscovery.metrics,
512
+ repos: repoDiscovery.repos.length,
513
+ },
514
+ gitStatus: {
515
+ ms: statusMs,
516
+ wallMs: statusWallMs,
517
+ records: records.length,
518
+ errors: statusErrors,
519
+ },
520
+ currentFilter: {
521
+ ...currentFilter.metrics,
522
+ ignored: currentFilter.ignored.size,
523
+ kept: records.length - currentFilter.ignored.size,
524
+ },
525
+ candidateFilter: {
526
+ ...candidateFilter.metrics,
527
+ checked: candidateInputRecords.length,
528
+ ignored: candidateFilter.ignored.size,
529
+ finalIgnored: candidateFinalIgnored.size,
530
+ finalKept: records.length - candidateFinalIgnored.size,
531
+ },
532
+ hardcodedFilter: {
533
+ ignored: hardcodedIgnored.size,
534
+ keptAfterCurrentAndHardcoded: records.filter((record) => (
535
+ !currentFilter.ignored.has(record.key) && !hardcodedIgnored.has(record.key)
536
+ )).length,
537
+ keptAfterCandidateAndHardcoded: records.filter((record) => (
538
+ !candidateFilter.ignored.has(record.key) && !hardcodedIgnored.has(record.key)
539
+ )).length,
540
+ },
541
+ parity: {
542
+ same: comparison.same,
543
+ onlyCurrentIgnored: comparison.onlyLeft.length,
544
+ onlyCandidateIgnored: comparison.onlyRight.length,
545
+ onlyCurrentIgnoredSample: sampleFor(comparison.onlyLeft),
546
+ onlyCandidateIgnoredSample: sampleFor(comparison.onlyRight),
547
+ },
548
+ }
549
+ }
550
+
551
+ function summarize(rows) {
552
+ const totals = {
553
+ apps: rows.length,
554
+ okApps: rows.filter((row) => row.ok).length,
555
+ currentPreScanMs: 0,
556
+ currentPreScanDirs: 0,
557
+ currentPreScanEntries: 0,
558
+ currentPreScanGitignoreFiles: 0,
559
+ repoDiscoveryMs: 0,
560
+ repoDiscoveryDirs: 0,
561
+ repoDiscoveryEntries: 0,
562
+ repos: 0,
563
+ gitStatusRecords: 0,
564
+ currentIgnored: 0,
565
+ candidateIgnored: 0,
566
+ hardcodedIgnored: 0,
567
+ candidateChecked: 0,
568
+ candidateFilterMs: 0,
569
+ finalCurrentKept: 0,
570
+ finalCandidateKept: 0,
571
+ parityMismatchedApps: 0,
572
+ parityOnlyCurrentIgnored: 0,
573
+ parityOnlyCandidateIgnored: 0,
574
+ }
575
+
576
+ for (const row of rows) {
577
+ if (!row.ok) continue
578
+ totals.currentPreScanMs += row.currentPreScan.ms
579
+ totals.currentPreScanDirs += row.currentPreScan.dirs
580
+ totals.currentPreScanEntries += row.currentPreScan.readdirEntries
581
+ totals.currentPreScanGitignoreFiles += row.currentPreScan.gitignoreFiles
582
+ totals.repoDiscoveryMs += row.repoDiscovery.ms
583
+ totals.repoDiscoveryDirs += row.repoDiscovery.dirs
584
+ totals.repoDiscoveryEntries += row.repoDiscovery.readdirEntries
585
+ totals.repos += row.repoDiscovery.repos
586
+ totals.gitStatusRecords += row.gitStatus.records
587
+ totals.currentIgnored += row.currentFilter.ignored
588
+ totals.candidateIgnored += row.candidateFilter.ignored
589
+ totals.hardcodedIgnored += row.hardcodedFilter.ignored
590
+ totals.candidateChecked += row.candidateFilter.checked
591
+ totals.candidateFilterMs += row.candidateFilter.ms
592
+ totals.finalCurrentKept += row.hardcodedFilter.keptAfterCurrentAndHardcoded
593
+ totals.finalCandidateKept += row.candidateFilter.finalKept
594
+ if (!row.parity.same) {
595
+ totals.parityMismatchedApps += 1
596
+ totals.parityOnlyCurrentIgnored += row.parity.onlyCurrentIgnored
597
+ totals.parityOnlyCandidateIgnored += row.parity.onlyCandidateIgnored
598
+ }
599
+ }
600
+
601
+ return {
602
+ totals,
603
+ topByCurrentPreScanMs: rows.filter((row) => row.ok).slice().sort((a, b) => b.currentPreScan.ms - a.currentPreScan.ms).slice(0, 20).map(summaryRow),
604
+ topByRepoDiscoveryMs: rows.filter((row) => row.ok).slice().sort((a, b) => b.repoDiscovery.ms - a.repoDiscovery.ms).slice(0, 20).map(summaryRow),
605
+ parityMismatches: rows.filter((row) => row.ok && !row.parity.same).map((row) => ({
606
+ app: row.app,
607
+ onlyCurrentIgnored: row.parity.onlyCurrentIgnored,
608
+ onlyCandidateIgnored: row.parity.onlyCandidateIgnored,
609
+ onlyCurrentIgnoredSample: row.parity.onlyCurrentIgnoredSample,
610
+ onlyCandidateIgnoredSample: row.parity.onlyCandidateIgnoredSample,
611
+ })),
612
+ }
613
+ }
614
+
615
+ function summaryRow(row) {
616
+ return {
617
+ app: row.app,
618
+ currentPreScanMs: Number(row.currentPreScan.ms.toFixed(1)),
619
+ currentPreScanDirs: row.currentPreScan.dirs,
620
+ currentPreScanEntries: row.currentPreScan.readdirEntries,
621
+ gitignoreFiles: row.currentPreScan.gitignoreFiles,
622
+ repoDiscoveryMs: Number(row.repoDiscovery.ms.toFixed(1)),
623
+ repos: row.repoDiscovery.repos,
624
+ statusRecords: row.gitStatus.records,
625
+ currentIgnored: row.currentFilter.ignored,
626
+ hardcodedIgnored: row.hardcodedFilter.ignored,
627
+ candidateChecked: row.candidateFilter.checked,
628
+ candidateIgnored: row.candidateFilter.ignored,
629
+ candidateFinalKept: row.candidateFilter.finalKept,
630
+ candidateFilterMs: Number(row.candidateFilter.ms.toFixed(1)),
631
+ paritySame: row.parity.same,
632
+ mismatchCount: row.parity.onlyCurrentIgnored + row.parity.onlyCandidateIgnored,
633
+ }
634
+ }
635
+
636
+ function markdownTable(rows) {
637
+ const header = [
638
+ '| App | Current pre-scan ms | Dirs | Entries | .gitignore files | Repo scan ms | Repos | Status paths | Hardcoded ignored | Candidate checked | Current gitignored | Candidate gitignored | Final kept | Candidate filter ms | Mismatches |',
639
+ '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
640
+ ]
641
+ const body = rows.map((row) => [
642
+ `| \`${row.app}\``,
643
+ Number(row.currentPreScan.ms).toFixed(1),
644
+ row.currentPreScan.dirs,
645
+ row.currentPreScan.readdirEntries,
646
+ row.currentPreScan.gitignoreFiles,
647
+ Number(row.repoDiscovery.ms).toFixed(1),
648
+ row.repoDiscovery.repos,
649
+ row.gitStatus.records,
650
+ row.hardcodedFilter.ignored,
651
+ row.candidateFilter.checked,
652
+ row.currentFilter.ignored,
653
+ row.candidateFilter.ignored,
654
+ row.candidateFilter.finalKept,
655
+ Number(row.candidateFilter.ms).toFixed(1),
656
+ row.parity.onlyCurrentIgnored + row.parity.onlyCandidateIgnored,
657
+ ].join(' | ') + ' |')
658
+ return header.concat(body).join('\n')
659
+ }
660
+
661
+ function renderMarkdown(report) {
662
+ const rows = report.rows.filter((row) => row.ok).slice().sort((a, b) => b.currentPreScan.ms - a.currentPreScan.ms)
663
+ const totals = report.summary.totals
664
+ const lines = []
665
+ lines.push(`# Workspace Gitignore Benchmark`)
666
+ lines.push('')
667
+ lines.push(`Created: ${report.createdAt}`)
668
+ lines.push(`API root: \`${report.apiRoot}\``)
669
+ lines.push(`Apps measured: ${totals.okApps}/${totals.apps}`)
670
+ lines.push('')
671
+ lines.push(`## Totals`)
672
+ lines.push('')
673
+ lines.push('| Metric | Value |')
674
+ lines.push('| --- | ---: |')
675
+ lines.push(`| Current recursive pre-scan wall time | ${totals.currentPreScanMs.toFixed(1)} ms |`)
676
+ lines.push(`| Current recursive pre-scan dirs | ${totals.currentPreScanDirs} |`)
677
+ lines.push(`| Current recursive pre-scan entries | ${totals.currentPreScanEntries} |`)
678
+ lines.push(`| Current .gitignore files read | ${totals.currentPreScanGitignoreFiles} |`)
679
+ lines.push(`| Current repo discovery wall time | ${totals.repoDiscoveryMs.toFixed(1)} ms |`)
680
+ lines.push(`| Current repo discovery dirs | ${totals.repoDiscoveryDirs} |`)
681
+ lines.push(`| Current repo discovery entries | ${totals.repoDiscoveryEntries} |`)
682
+ lines.push(`| Repos discovered | ${totals.repos} |`)
683
+ lines.push(`| Raw git status paths | ${totals.gitStatusRecords} |`)
684
+ lines.push(`| Current JS ignore-filter ignored paths | ${totals.currentIgnored} |`)
685
+ lines.push(`| Hard-coded ignored paths | ${totals.hardcodedIgnored} |`)
686
+ lines.push(`| Candidate paths checked after hard-coded prefilter | ${totals.candidateChecked} |`)
687
+ lines.push(`| Candidate path-scoped ignored paths | ${totals.candidateIgnored} |`)
688
+ lines.push(`| Final current kept paths | ${totals.finalCurrentKept} |`)
689
+ lines.push(`| Final candidate kept paths | ${totals.finalCandidateKept} |`)
690
+ lines.push(`| Candidate ignore-filter wall time | ${totals.candidateFilterMs.toFixed(1)} ms |`)
691
+ lines.push(`| Apps with filter mismatches | ${totals.parityMismatchedApps} |`)
692
+ lines.push(`| Current-only ignored paths | ${totals.parityOnlyCurrentIgnored} |`)
693
+ lines.push(`| Candidate-only ignored paths | ${totals.parityOnlyCandidateIgnored} |`)
694
+ lines.push('')
695
+ lines.push(`## Per-App Rows`)
696
+ lines.push('')
697
+ lines.push(markdownTable(rows))
698
+ if (report.summary.parityMismatches.length > 0) {
699
+ lines.push('')
700
+ lines.push('## Parity Mismatches')
701
+ lines.push('')
702
+ for (const mismatch of report.summary.parityMismatches) {
703
+ lines.push(`### ${mismatch.app}`)
704
+ lines.push('')
705
+ lines.push(`Current-only ignored: ${mismatch.onlyCurrentIgnored}`)
706
+ lines.push(`Candidate-only ignored: ${mismatch.onlyCandidateIgnored}`)
707
+ lines.push('')
708
+ lines.push('Current-only sample:')
709
+ lines.push('')
710
+ lines.push('```json')
711
+ lines.push(JSON.stringify(mismatch.onlyCurrentIgnoredSample, null, 2))
712
+ lines.push('```')
713
+ lines.push('')
714
+ lines.push('Candidate-only sample:')
715
+ lines.push('')
716
+ lines.push('```json')
717
+ lines.push(JSON.stringify(mismatch.onlyCandidateIgnoredSample, null, 2))
718
+ lines.push('```')
719
+ lines.push('')
720
+ }
721
+ }
722
+ lines.push('')
723
+ return lines.join('\n')
724
+ }
725
+
726
+ function gitCommit(repoRoot) {
727
+ try {
728
+ return fs.existsSync(path.join(repoRoot, '.git'))
729
+ ? execFileSync('git', ['rev-parse', 'HEAD'], {
730
+ cwd: repoRoot,
731
+ encoding: 'utf8',
732
+ stdio: ['ignore', 'pipe', 'ignore'],
733
+ }).trim()
734
+ : null
735
+ } catch (_) {
736
+ return null
737
+ }
738
+ }
739
+
740
+ async function main() {
741
+ const args = parseArgs(process.argv.slice(2))
742
+ if (args.help) {
743
+ console.log(usage())
744
+ return
745
+ }
746
+
747
+ const repoRoot = path.resolve(__dirname, '..')
748
+ const appNames = await listAppFolders(args.apiRoot, args.apps, args.maxApps)
749
+ const rows = []
750
+
751
+ for (const appName of appNames) {
752
+ try {
753
+ const row = await measureWorkspace(args.apiRoot, appName)
754
+ rows.push(row)
755
+ console.error(`${appName}: preScan=${row.currentPreScan.ms.toFixed(1)}ms repoScan=${row.repoDiscovery.ms.toFixed(1)}ms statusPaths=${row.gitStatus.records} mismatches=${row.parity.onlyCurrentIgnored + row.parity.onlyCandidateIgnored}`)
756
+ } catch (error) {
757
+ rows.push({
758
+ app: appName,
759
+ ok: false,
760
+ error: error && error.stack ? error.stack : String(error),
761
+ })
762
+ console.error(`${appName}: failed: ${error && error.message ? error.message : error}`)
763
+ }
764
+ }
765
+
766
+ const report = {
767
+ kind: 'pinokiod-workspace-gitignore-component-benchmark',
768
+ createdAt: new Date().toISOString(),
769
+ repoRoot,
770
+ commit: gitCommit(repoRoot),
771
+ node: process.version,
772
+ platform: process.platform,
773
+ apiRoot: args.apiRoot,
774
+ selectedApps: args.apps,
775
+ maxApps: args.maxApps,
776
+ notes: [
777
+ 'Current pre-scan mimics WorkspaceStatusManager.ensureGitIgnoreEngine().',
778
+ 'Current repo discovery approximates kernel.git.findGitDirs() for measurement only.',
779
+ 'Candidate filtering uses path-scoped .gitignore evaluation with the same parser rules as the current pre-scan.',
780
+ 'This benchmark measures component algorithms, not live route integration.',
781
+ ],
782
+ rows,
783
+ summary: null,
784
+ }
785
+ report.summary = summarize(rows)
786
+
787
+ await fsp.mkdir(path.dirname(args.out), { recursive: true })
788
+ await fsp.writeFile(args.out, `${JSON.stringify(report, null, 2)}\n`)
789
+ console.log(args.out)
790
+
791
+ if (!args.noMarkdown && args.markdownOut) {
792
+ await fsp.mkdir(path.dirname(args.markdownOut), { recursive: true })
793
+ await fsp.writeFile(args.markdownOut, renderMarkdown(report))
794
+ console.log(args.markdownOut)
795
+ }
796
+ }
797
+
798
+ if (require.main === module) {
799
+ main().catch((error) => {
800
+ console.error(error && error.stack ? error.stack : error)
801
+ process.exitCode = 1
802
+ })
803
+ }
804
+
805
+ module.exports = {
806
+ buildCurrentWorkspaceIgnoreEngine,
807
+ candidateIgnoredByPathScopedGitignores,
808
+ compareSets,
809
+ currentEngineIgnored,
810
+ discoverReposCurrentStyle,
811
+ gitStatusRecords,
812
+ isHardcodedStatusIgnored,
813
+ measureWorkspace,
814
+ normalizePath,
815
+ }