@nan0web/ui 1.10.0 → 1.12.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 +69 -3
- package/package.json +65 -29
- package/src/App/Command/DepsCommand.js +3 -4
- package/src/Frame/Props.js +12 -18
- package/src/InterfaceTemplate/InterfaceTemplate.js +9 -7
- package/src/Model/index.js +61 -6
- package/src/StdIn.js +2 -6
- package/src/cli.js +1 -0
- package/src/core/GeneratorRunner.js +67 -7
- package/src/core/InputAdapter.js +22 -5
- package/src/core/Intent.js +230 -18
- package/src/core/Message/Message.js +4 -7
- package/src/core/Message/OutputMessage.js +4 -9
- package/src/core/StreamEntry.js +20 -28
- package/src/core/index.js +4 -0
- package/src/domain/Content.js +198 -0
- package/src/domain/Document.js +25 -0
- package/src/domain/FooterModel.js +37 -19
- package/src/domain/HeaderModel.js +47 -21
- package/src/domain/HeroModel.js +24 -22
- package/src/domain/LayoutModel.js +43 -0
- package/src/domain/ModelAsApp.js +46 -0
- package/src/domain/SandboxModel.js +19 -16
- package/src/domain/app/GalleryCommand.js +53 -0
- package/src/domain/app/GalleryRenderIntent.js +77 -0
- package/src/domain/app/SnapshotAuditor.js +399 -0
- package/src/domain/app/SnapshotRunner.js +264 -0
- package/src/domain/app/UIApp.js +78 -0
- package/src/domain/components/BreadcrumbModel.js +10 -6
- package/src/domain/components/FeatureGridModel.js +62 -0
- package/src/domain/components/MarkdownModel.js +24 -0
- package/src/domain/components/ShellModel.js +243 -0
- package/src/domain/components/TableModel.js +10 -6
- package/src/domain/components/ToastModel.js +10 -6
- package/src/domain/components/index.js +3 -1
- package/src/domain/index.js +14 -4
- package/src/index.js +23 -2
- package/src/inspect.js +2 -0
- package/src/test/ScenarioAdapter.js +59 -0
- package/src/test/ScenarioTest.js +51 -0
- package/src/test/ScenarioTest.story.js +56 -0
- package/src/testing/CrashReporter.js +56 -0
- package/src/testing/GalleryGenerator.js +15 -71
- package/src/testing/LogicInspector.js +4 -4
- package/src/testing/SnapshotRunner.js +22 -0
- package/src/testing/SpecAdapter.js +114 -0
- package/src/testing/SpecRunner.js +121 -0
- package/src/testing/VisualAdapter.js +24 -19
- package/src/testing/index.js +5 -1
- package/src/testing/verifySnapshot.js +17 -0
- package/types/App/Command/DepsCommand.d.ts +0 -2
- package/types/Model/index.d.ts +56 -62
- package/types/StdIn.d.ts +3 -3
- package/types/cli.d.ts +1 -0
- package/types/core/GeneratorRunner.d.ts +14 -1
- package/types/core/InputAdapter.d.ts +50 -6
- package/types/core/Intent.d.ts +280 -32
- package/types/core/Message/Message.d.ts +2 -2
- package/types/core/Message/OutputMessage.d.ts +0 -2
- package/types/core/index.d.ts +4 -0
- package/types/domain/Content.d.ts +344 -0
- package/types/domain/Document.d.ts +40 -0
- package/types/domain/FooterModel.d.ts +22 -12
- package/types/domain/HeaderModel.d.ts +36 -13
- package/types/domain/HeroModel.d.ts +19 -17
- package/types/domain/LayoutModel.d.ts +34 -0
- package/types/domain/ModelAsApp.d.ts +23 -0
- package/types/domain/SandboxModel.d.ts +10 -0
- package/types/domain/app/GalleryCommand.d.ts +55 -0
- package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
- package/types/domain/app/SnapshotAuditor.d.ts +99 -0
- package/types/domain/app/SnapshotRunner.d.ts +45 -0
- package/types/domain/app/UIApp.d.ts +60 -0
- package/types/domain/components/BreadcrumbModel.d.ts +6 -8
- package/types/domain/components/FeatureGridModel.d.ts +50 -0
- package/types/domain/components/MarkdownModel.d.ts +19 -0
- package/types/domain/components/ShellModel.d.ts +56 -0
- package/types/domain/components/TableModel.d.ts +4 -0
- package/types/domain/components/ToastModel.d.ts +4 -0
- package/types/domain/components/index.d.ts +3 -0
- package/types/domain/index.d.ts +10 -4
- package/types/index.d.ts +21 -1
- package/types/inspect.d.ts +2 -0
- package/types/test/ScenarioAdapter.d.ts +43 -0
- package/types/test/ScenarioTest.d.ts +24 -0
- package/types/test/ScenarioTest.story.d.ts +1 -0
- package/types/testing/CrashReporter.d.ts +13 -0
- package/types/testing/SnapshotRunner.d.ts +7 -0
- package/types/testing/SpecAdapter.d.ts +58 -0
- package/types/testing/SpecRunner.d.ts +41 -0
- package/types/testing/VisualAdapter.d.ts +0 -6
- package/types/testing/index.d.ts +5 -1
- package/types/testing/verifySnapshot.d.ts +14 -0
- package/src/testing/SnapshotInspector.js +0 -84
- package/types/App/Command/Options.d.ts +0 -43
- package/types/App/Command/index.d.ts +0 -8
- package/types/App/User/Command/Options.d.ts +0 -34
- package/types/core/Message/InputMessage.d.ts +0 -71
- package/types/domain/components/HeroModel.d.ts +0 -24
- package/types/domain/components/ShowcaseAppModel.d.ts +0 -32
- package/types/testing/SnapshotInspector.d.ts +0 -17
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { NaN0 } from '@nan0web/types'
|
|
2
|
+
import { AuditorModel } from '@nan0web/inspect'
|
|
3
|
+
import { progress, result, show } from '../../core/Intent.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SnapshotAuditor — Zero-Hallucination Snapshot Validation (Model-as-Schema v2).
|
|
7
|
+
* Parses snapshots without evaluating the app logic and detects artifacts.
|
|
8
|
+
*
|
|
9
|
+
* @extends {AuditorModel}
|
|
10
|
+
*/
|
|
11
|
+
export class SnapshotAuditor extends AuditorModel {
|
|
12
|
+
static alias = 'audit'
|
|
13
|
+
|
|
14
|
+
static dir = {
|
|
15
|
+
type: 'string',
|
|
16
|
+
help: 'Target directory to audit snapshots in',
|
|
17
|
+
positional: true,
|
|
18
|
+
default: 'snapshots/core',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static data = {
|
|
22
|
+
type: 'string',
|
|
23
|
+
help: 'Directory to scan for dictionaries',
|
|
24
|
+
default: 'data',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @type {Object<string, string>} Messages for UI */
|
|
28
|
+
static UI = {
|
|
29
|
+
title: 'Snapshot Auditor',
|
|
30
|
+
description: 'Validates UI snapshots against hallucinations and localization leaks.',
|
|
31
|
+
icon: '📸',
|
|
32
|
+
starting: 'Auditing snapshots in {dir}',
|
|
33
|
+
noSnapshots: 'No snapshots found to audit in {dir}',
|
|
34
|
+
doneSuccess: 'All snapshots passed the audit.',
|
|
35
|
+
doneErrors: 'Gallery audit failed with errors. Check above.',
|
|
36
|
+
auditPassed: 'Audit passed: {file}',
|
|
37
|
+
auditFailed: 'Audit failed for {file}: {errors}',
|
|
38
|
+
|
|
39
|
+
errorGlitch: 'Filename "{filename}" has multiple consecutive separators (glitch detected).',
|
|
40
|
+
errorShort: 'Filename "{filename}" is too short.',
|
|
41
|
+
errorSyntax: 'Syntax Error: Failed to parse NaN0 file. {msg}',
|
|
42
|
+
errorArtifact: 'Path {path}: Critical artifact "{artifact}" found.',
|
|
43
|
+
errorRouting: 'Path {path}: Routing error "Path not found".',
|
|
44
|
+
errorUntranslated: 'Path {path}: Possible untranslated key found: "{str}"',
|
|
45
|
+
errorEnglishLeak: 'Path {path}: English word "{word}" found in "{locale}" locale.',
|
|
46
|
+
errorEmptyRender:
|
|
47
|
+
'Path {path}.{key}: Snapshot is suspiciously empty (pure tag {compName} with NO properties or content).',
|
|
48
|
+
errorForeignLeak:
|
|
49
|
+
'Path {path}: Word "{word}" belongs to "{foreign}" but is missing in "{locale}".',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @type {string[]} Common UI components that can be empty in render */
|
|
53
|
+
static EXEMPT_EMPTY = ['ui-spinner', 'ui-themetoggle', 'ui-langselect', 'ui-sortable']
|
|
54
|
+
|
|
55
|
+
/** @type {string[]} Critical JS artifacts to detect in snapshots */
|
|
56
|
+
static ARTIFACTS = ['[object Object]', 'undefined', 'NaN']
|
|
57
|
+
|
|
58
|
+
/** @type {string[]} Words to ignore across all languages */
|
|
59
|
+
static EXEMPT_WORDS = ['true', 'false', 'value', 'max', 'min', 'step', 'open', 'first', 'what', 'how', 'start', 'code', 'successfully', 'enter', 'with', 'system']
|
|
60
|
+
|
|
61
|
+
/** @type {RegExp} Pattern for suspicious filenames */
|
|
62
|
+
static SUSPICIOUS_FILENAME = /__|--/
|
|
63
|
+
|
|
64
|
+
/** @type {number} Minimum filename length */
|
|
65
|
+
static MIN_FILENAME_LENGTH = 3
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {Partial<SnapshotAuditor> | Record<string, any>} [data={}]
|
|
69
|
+
* @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
|
|
70
|
+
*/
|
|
71
|
+
constructor(data = {}, options = {}) {
|
|
72
|
+
super(data, options)
|
|
73
|
+
/** @type {import('@nan0web/types').ModelOptions} */
|
|
74
|
+
this.options = options
|
|
75
|
+
/** @type {string} Target directory to audit */ this.dir
|
|
76
|
+
/** @type {string} Directory to scan for dictionaries */ this.data
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extracts all valid words from an object into a Set.
|
|
81
|
+
* @param {any} obj Node to extract from.
|
|
82
|
+
* @param {Set<string>} set Set to populate.
|
|
83
|
+
*/
|
|
84
|
+
static extractWords(obj, set) {
|
|
85
|
+
if (typeof obj === 'string') {
|
|
86
|
+
const words = obj.toLowerCase().split(/[\s,.:;!"'(){}\[\]\\/<>?=\-+_@&#*^|~`]+/)
|
|
87
|
+
for (const w of words) {
|
|
88
|
+
if (w.length > 2 && isNaN(Number(w))) set.add(w)
|
|
89
|
+
}
|
|
90
|
+
} else if (Array.isArray(obj)) {
|
|
91
|
+
for (const item of obj) SnapshotAuditor.extractWords(item, set)
|
|
92
|
+
} else if (obj && typeof obj === 'object') {
|
|
93
|
+
for (const val of Object.values(obj)) SnapshotAuditor.extractWords(val, set)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scans data directories to build a word set for each language.
|
|
99
|
+
* @param {any} fsDb FileSystem DB.
|
|
100
|
+
* @param {string} data
|
|
101
|
+
* @returns {Promise<Record<string, Set<string>>>}
|
|
102
|
+
*/
|
|
103
|
+
static async buildDictionaries(fsDb, data = 'data') {
|
|
104
|
+
/** @type {Record<string, Set<string>>} */
|
|
105
|
+
const dicts = {}
|
|
106
|
+
|
|
107
|
+
let entries = []
|
|
108
|
+
try {
|
|
109
|
+
let entriesList;
|
|
110
|
+
try {
|
|
111
|
+
entriesList = await fsDb.listDir(data)
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (/** @type {any} */ (e).code === 'ENOENT' && !data.startsWith('../')) {
|
|
114
|
+
entriesList = await fsDb.listDir('../' + data)
|
|
115
|
+
} else {
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const e of entriesList) entries.push(e)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return dicts
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry.stat.isDirectory && entry.name !== '_') {
|
|
126
|
+
const lang = entry.name
|
|
127
|
+
if (!dicts[lang]) dicts[lang] = new Set()
|
|
128
|
+
|
|
129
|
+
const scanLang = async (dirPath) => {
|
|
130
|
+
let files = []
|
|
131
|
+
try {
|
|
132
|
+
const entries = await fsDb.listDir(dirPath)
|
|
133
|
+
for (const f of entries) files.push(f)
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const f of files) {
|
|
139
|
+
if (f.stat.isDirectory) {
|
|
140
|
+
await scanLang(f.path)
|
|
141
|
+
} else {
|
|
142
|
+
try {
|
|
143
|
+
const _fsDb = /** @type {any} */ (fsDb)
|
|
144
|
+
const raw = _fsDb.FS ? await _fsDb.FS.loadTXT(_fsDb.location(f.path), '', true) : await fsDb.fetch(f.path)
|
|
145
|
+
SnapshotAuditor.extractWords(raw, dicts[lang])
|
|
146
|
+
} catch (e) {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await scanLang(entry.path)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return dicts
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run the snapshot audit inside the target directory.
|
|
158
|
+
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
|
|
159
|
+
*/
|
|
160
|
+
async *run() {
|
|
161
|
+
const { t } = this.options
|
|
162
|
+
const snapshotsDir = this.dir || '.'
|
|
163
|
+
|
|
164
|
+
yield show(t(SnapshotAuditor.UI.starting, { dir: snapshotsDir }))
|
|
165
|
+
|
|
166
|
+
const files = []
|
|
167
|
+
|
|
168
|
+
/** @type {import('@nan0web/db').DB} */
|
|
169
|
+
let fsDb = this.options.db
|
|
170
|
+
if (fsDb && fsDb.mounts && fsDb.mounts.has('')) {
|
|
171
|
+
fsDb = /** @type {import('@nan0web/db').DB} */ (fsDb.mounts.get(''))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!fsDb) {
|
|
175
|
+
yield show('FS Database not provided to auditor', 'error')
|
|
176
|
+
return result({ success: false })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const findSnapshots = async (dir) => {
|
|
180
|
+
try {
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = await fsDb.listDir(dir)
|
|
184
|
+
} catch (e) {
|
|
185
|
+
if (/** @type {any} */ (e).code === 'ENOENT' && !dir.startsWith('../')) {
|
|
186
|
+
entries = await fsDb.listDir('../' + dir)
|
|
187
|
+
} else {
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
if (entry.stat.isDirectory) {
|
|
193
|
+
await findSnapshots(entry.path)
|
|
194
|
+
} else if (entry.name.endsWith('.nan0') || entry.name.endsWith('.txt')) {
|
|
195
|
+
files.push(entry.path)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error('Error reading dir:', dir, e)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await findSnapshots(snapshotsDir)
|
|
204
|
+
|
|
205
|
+
if (files.length === 0) {
|
|
206
|
+
yield show(t(SnapshotAuditor.UI.noSnapshots, { dir: snapshotsDir }), 'error')
|
|
207
|
+
return result({ success: false })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Preload all dictionaries into memory across all languages
|
|
211
|
+
const dictionaries = await SnapshotAuditor.buildDictionaries(fsDb, this.data || 'data')
|
|
212
|
+
|
|
213
|
+
// Process all files in parallel for hyper-speed
|
|
214
|
+
const auditPromises = files.map(async (file) => {
|
|
215
|
+
const segments = file.split('/')
|
|
216
|
+
const locale = segments[segments.indexOf('core') + 1] || 'uk'
|
|
217
|
+
const componentName = segments.pop() || ''
|
|
218
|
+
|
|
219
|
+
const _fsDb = /** @type {any} */ (fsDb)
|
|
220
|
+
const content = _fsDb.FS ? await _fsDb.FS.loadTXT(_fsDb.location(file), '', true) : await fsDb.fetch(file)
|
|
221
|
+
const textContent = typeof content === 'string' ? content : JSON.stringify(content)
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
file,
|
|
225
|
+
audit: SnapshotAuditor.inspectText(textContent, locale, componentName, t, dictionaries),
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const results = await Promise.all(auditPromises)
|
|
230
|
+
const allErrors = []
|
|
231
|
+
let hasErrors = false
|
|
232
|
+
|
|
233
|
+
for (const { file, audit } of results) {
|
|
234
|
+
const displayFile = file.startsWith('../') ? file.slice(3) : file
|
|
235
|
+
if (audit.score < 100) {
|
|
236
|
+
const errorMessages = audit.errors.join('; ')
|
|
237
|
+
yield show(t(SnapshotAuditor.UI.auditFailed, { file: displayFile, errors: errorMessages }), 'error')
|
|
238
|
+
allErrors.push(...audit.errors.map((e) => ({ file: displayFile, error: e })))
|
|
239
|
+
hasErrors = true
|
|
240
|
+
} else {
|
|
241
|
+
yield show(t(SnapshotAuditor.UI.auditPassed, { file: displayFile }), 'success')
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (hasErrors) {
|
|
246
|
+
yield show(t(SnapshotAuditor.UI.doneErrors, {}), 'error')
|
|
247
|
+
return result({ success: false, errors: allErrors })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
yield show(t(SnapshotAuditor.UI.doneSuccess, {}), 'success')
|
|
251
|
+
return result({ success: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Inspects a single snapshot text.
|
|
256
|
+
* @param {string} content Content of the file.
|
|
257
|
+
* @param {string} locale Locale (uk, en).
|
|
258
|
+
* @param {string} filename Name of the file.
|
|
259
|
+
* @param {import('@nan0web/i18n').TFunction} t Translate function.
|
|
260
|
+
* @param {Record<string, Set<string>>} [dictionaries=undefined] Loaded dictionaries for mutual exclusion check.
|
|
261
|
+
* @returns {{ score: number, errors: string[] }}
|
|
262
|
+
*/
|
|
263
|
+
static inspectText(content, locale, filename, t, dictionaries = undefined) {
|
|
264
|
+
const errors = []
|
|
265
|
+
|
|
266
|
+
if (filename) {
|
|
267
|
+
if (SnapshotAuditor.SUSPICIOUS_FILENAME.test(filename)) {
|
|
268
|
+
errors.push(t(SnapshotAuditor.UI.errorGlitch, { filename }))
|
|
269
|
+
}
|
|
270
|
+
if (filename.length < SnapshotAuditor.MIN_FILENAME_LENGTH) {
|
|
271
|
+
errors.push(t(SnapshotAuditor.UI.errorShort, { filename }))
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let parsed
|
|
276
|
+
try {
|
|
277
|
+
parsed = NaN0.parse(content)
|
|
278
|
+
} catch (e) {
|
|
279
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
280
|
+
errors.push(t(SnapshotAuditor.UI.errorSyntax, { msg }))
|
|
281
|
+
return { score: 0, errors }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const context = { locale, errors, t, dictionaries }
|
|
285
|
+
SnapshotAuditor.checkNode(parsed, '$', context)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
score: errors.length === 0 ? 100 : Math.max(0, 100 - errors.length * 10),
|
|
289
|
+
errors,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Recursively checks a parsed node.
|
|
295
|
+
* @param {any} node Node.
|
|
296
|
+
* @param {string} path JSON path.
|
|
297
|
+
* @param {{ locale: string, errors: string[], t: import('@nan0web/i18n').TFunction, dictionaries?: Record<string, Set<string>> }} context Context.
|
|
298
|
+
*/
|
|
299
|
+
static checkNode(node, path, context) {
|
|
300
|
+
if (typeof node === 'string') {
|
|
301
|
+
SnapshotAuditor.checkString(node, path, context)
|
|
302
|
+
} else if (Array.isArray(node)) {
|
|
303
|
+
node.forEach((item, i) => SnapshotAuditor.checkNode(item, `${path}[${i}]`, context))
|
|
304
|
+
} else if (node && typeof node === 'object') {
|
|
305
|
+
for (const [key, value] of Object.entries(node)) {
|
|
306
|
+
SnapshotAuditor.checkString(key, `${path}.key(${key})`, context)
|
|
307
|
+
SnapshotAuditor.checkNode(value, `${path}.${key}`, context)
|
|
308
|
+
|
|
309
|
+
if (key === 'render' && value && typeof value === 'object') {
|
|
310
|
+
for (const [compName, compProps] of Object.entries(value)) {
|
|
311
|
+
if (compProps && typeof compProps === 'object' && Object.keys(compProps).length === 0) {
|
|
312
|
+
if (!SnapshotAuditor.EXEMPT_EMPTY.includes(compName)) {
|
|
313
|
+
context.errors.push(
|
|
314
|
+
context.t(SnapshotAuditor.UI.errorEmptyRender, {
|
|
315
|
+
path,
|
|
316
|
+
key,
|
|
317
|
+
compName,
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Checks a string node.
|
|
330
|
+
* @param {string} str String.
|
|
331
|
+
* @param {string} path Path.
|
|
332
|
+
* @param {{ locale: string, errors: string[], t: import('@nan0web/i18n').TFunction, dictionaries?: Record<string, Set<string>> }} context Context.
|
|
333
|
+
*/
|
|
334
|
+
static checkString(str, path, context) {
|
|
335
|
+
const { t, locale, errors } = context
|
|
336
|
+
|
|
337
|
+
for (const artifact of SnapshotAuditor.ARTIFACTS) {
|
|
338
|
+
if (str.includes(artifact)) {
|
|
339
|
+
// Special check for NaN to avoid false positives with NaN0 or NaN•
|
|
340
|
+
if (artifact === 'NaN' && (str.includes('NaN0') || str.includes('NaN•'))) continue
|
|
341
|
+
errors.push(t(SnapshotAuditor.UI.errorArtifact, { path, artifact }))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (str.includes('Path not found')) errors.push(t(SnapshotAuditor.UI.errorRouting, { path }))
|
|
346
|
+
|
|
347
|
+
const isSystemProp =
|
|
348
|
+
path.includes('.variant') || path.includes('.key(') || path.includes('.ask')
|
|
349
|
+
if (!isSystemProp) {
|
|
350
|
+
const isDotNumber = /^-?\d+\.\d+$/.test(str)
|
|
351
|
+
const hasParens = str.includes('(') || str.includes(')')
|
|
352
|
+
const isEmail = str.includes('@')
|
|
353
|
+
|
|
354
|
+
if (
|
|
355
|
+
/\w+\.\w+/.test(str) &&
|
|
356
|
+
!str.includes('ui-') &&
|
|
357
|
+
!str.includes('http') &&
|
|
358
|
+
!isDotNumber &&
|
|
359
|
+
!hasParens &&
|
|
360
|
+
!isEmail
|
|
361
|
+
) {
|
|
362
|
+
errors.push(t(SnapshotAuditor.UI.errorUntranslated, { path, str }))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (context.dictionaries && context.dictionaries[locale]) {
|
|
366
|
+
const myWords = context.dictionaries[locale]
|
|
367
|
+
const words = str.toLowerCase().split(/[\s,.:;!"'(){}\[\]\\/<>?=\-+_@&#*^|~`]+/)
|
|
368
|
+
|
|
369
|
+
for (const word of words) {
|
|
370
|
+
if (word.length <= 2 || !isNaN(Number(word))) continue
|
|
371
|
+
if (SnapshotAuditor.EXEMPT_WORDS.includes(word)) continue
|
|
372
|
+
if (myWords.has(word)) continue
|
|
373
|
+
|
|
374
|
+
/** @type {string | false} */
|
|
375
|
+
let foundInForeign = false
|
|
376
|
+
for (const [otherLoc, otherSet] of Object.entries(context.dictionaries)) {
|
|
377
|
+
if (otherLoc !== locale && otherSet.has(word)) {
|
|
378
|
+
foundInForeign = otherLoc
|
|
379
|
+
break
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (foundInForeign) {
|
|
384
|
+
errors.push(
|
|
385
|
+
t(SnapshotAuditor.UI.errorForeignLeak, {
|
|
386
|
+
path,
|
|
387
|
+
word,
|
|
388
|
+
foreign: foundInForeign,
|
|
389
|
+
locale,
|
|
390
|
+
}),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export default SnapshotAuditor
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { Model } from '@nan0web/types'
|
|
2
|
+
import { LogicInspector } from '../../testing/LogicInspector.js'
|
|
3
|
+
import { VisualAdapter } from '../../testing/VisualAdapter.js'
|
|
4
|
+
import { result, show, render } from '../../core/Intent.js'
|
|
5
|
+
import SnapshotAuditor from './SnapshotAuditor.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SnapshotRunner — Zero-Hallucination Snapshot Generation & Audit (Model-as-Schema v2).
|
|
9
|
+
* Operates entirely through DB-FS abstraction without raw FS/Path hardcodes.
|
|
10
|
+
*/
|
|
11
|
+
export class SnapshotRunner extends Model {
|
|
12
|
+
static UI = {
|
|
13
|
+
generating: '📸 Generating snapshots for {lang}/{comp}',
|
|
14
|
+
saved: '📸 Saved {file}',
|
|
15
|
+
auditFailed: '🚨 Audit failed for {file}: {errors}',
|
|
16
|
+
rootGallery:
|
|
17
|
+
'# 📸 Core Snapshots Gallery\n\n**Total Snapshots:** {count} | **Total Errors:** {errors}\n\n## Locales\n\n',
|
|
18
|
+
localeTitle: '🌍 Locale: {title}',
|
|
19
|
+
categoryTitle: '📂 Category: {title}',
|
|
20
|
+
backText: 'Back',
|
|
21
|
+
backLink: '[⬅ {text}](../index.md)',
|
|
22
|
+
galleryDescription: 'This gallery contains automatically generated interaction snapshots (Zero-Hallucination UI Core).',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static data = {
|
|
26
|
+
type: 'string',
|
|
27
|
+
help: 'Root directory containing locale folders with data.',
|
|
28
|
+
default: 'docs',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static snapshotsDir = {
|
|
32
|
+
type: 'string',
|
|
33
|
+
help: 'Directory where output text snapshots will be stored.',
|
|
34
|
+
default: 'snapshots/core',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {Partial<SnapshotRunner> | Record<string, any>} [data={}]
|
|
39
|
+
* @param {import('@nan0web/types').ModelOptions} [options={}]
|
|
40
|
+
*/
|
|
41
|
+
constructor(data = {}, options = {}) {
|
|
42
|
+
super(data, options)
|
|
43
|
+
/** @type {string} Directory containing snapshots */ this.snapshotsDir
|
|
44
|
+
/** @type {string} Root data directory */ this.data
|
|
45
|
+
/** @type {(compName: string) => string} */ this.getCategory = (comp) => 'Components'
|
|
46
|
+
/** @type {(compName: string, varData: any) => AsyncGenerator<any>} */ this.createModelStream
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get db() {
|
|
50
|
+
return /** @type {import('@nan0web/db').DB} */ (this._.db)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recursive drop for directories via DB-FS.
|
|
55
|
+
* @param {string} uri
|
|
56
|
+
*/
|
|
57
|
+
async dropRecursive(uri) {
|
|
58
|
+
let entries = []
|
|
59
|
+
try {
|
|
60
|
+
for await (const entry of this.db.readDir(uri)) entries.push(entry)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return // Dir doesn't exist
|
|
63
|
+
}
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.stat.isDirectory) {
|
|
66
|
+
await this.dropRecursive(entry.path)
|
|
67
|
+
} else {
|
|
68
|
+
await this.db.dropDocument(entry.path)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await this.db.dropDocument(uri)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async *run() {
|
|
75
|
+
const db = this.db
|
|
76
|
+
const t = this._.t || ((k) => k)
|
|
77
|
+
|
|
78
|
+
// Clean before generation
|
|
79
|
+
await this.dropRecursive(this.snapshotsDir)
|
|
80
|
+
|
|
81
|
+
const doc = (await this.db.fetch('index')) ?? {}
|
|
82
|
+
|
|
83
|
+
// Fetch languages
|
|
84
|
+
const langsData = (await this.db.fetch(`${this.data}/_/langs`)) || []
|
|
85
|
+
const langsIndex = {}
|
|
86
|
+
if (Array.isArray(langsData)) {
|
|
87
|
+
langsData.forEach((l) => {
|
|
88
|
+
if (l && l.locale) langsIndex[l.locale] = l.title || l.locale
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const langs = []
|
|
93
|
+
for await (const entry of this.db.readDir(this.data)) {
|
|
94
|
+
if (entry.stat.isDirectory && entry.name !== '_' && entry.name !== 'site') langs.push(entry.name)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const galleryTree = {}
|
|
98
|
+
let globalErrors = 0
|
|
99
|
+
let globalCount = 0
|
|
100
|
+
|
|
101
|
+
for (const lang of langs) {
|
|
102
|
+
galleryTree[lang] = {}
|
|
103
|
+
const componentsBase = `${this.data}/${lang}/components`
|
|
104
|
+
const components = []
|
|
105
|
+
try {
|
|
106
|
+
for await (const entry of this.db.readDir(componentsBase)) {
|
|
107
|
+
if (!entry.isDirectory) components.push(entry.name)
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {}
|
|
110
|
+
|
|
111
|
+
for (const file of components) {
|
|
112
|
+
const compName = file.replace(/\.[^/.]+$/, "")
|
|
113
|
+
const data = (await this.db.fetch(`${componentsBase}/${compName}`)) || {}
|
|
114
|
+
|
|
115
|
+
// Extract variations
|
|
116
|
+
const variations = data.content || []
|
|
117
|
+
const variationsData = []
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < variations.length; i++) {
|
|
120
|
+
const rawVar = variations[i]
|
|
121
|
+
let varData = rawVar[compName] !== undefined ? rawVar[compName] : rawVar
|
|
122
|
+
|
|
123
|
+
// Extract schema defaults
|
|
124
|
+
const schema = data['$' + compName] || {}
|
|
125
|
+
const defaultProps = {}
|
|
126
|
+
for (const [k, v] of Object.entries(schema)) {
|
|
127
|
+
if (v && v.default !== undefined) {
|
|
128
|
+
defaultProps[k] = v.default
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Merge defaults
|
|
133
|
+
if (typeof varData === 'object' && varData !== null) {
|
|
134
|
+
varData = { ...defaultProps, ...varData }
|
|
135
|
+
} else if (varData === true) {
|
|
136
|
+
varData = { ...defaultProps }
|
|
137
|
+
} else if (typeof varData === 'string' || typeof varData === 'number') {
|
|
138
|
+
varData = { ...defaultProps, content: String(varData) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let varName = rawVar.content || rawVar.title || rawVar.message || `var${i + 1}`
|
|
142
|
+
if (typeof varName !== 'string') {
|
|
143
|
+
if (typeof varData.title === 'string') varName = varData.title
|
|
144
|
+
else if (typeof varData.content === 'string') varName = varData.content
|
|
145
|
+
else varName = `var${i + 1}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const safeVarName = varName
|
|
149
|
+
.trim()
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.replace(/[./\\:]/g, '_')
|
|
152
|
+
.replace(/\s+/g, '_')
|
|
153
|
+
.replace(/_{2,}/g, '_')
|
|
154
|
+
.slice(0, 50)
|
|
155
|
+
|
|
156
|
+
// Build model stream
|
|
157
|
+
let intents
|
|
158
|
+
if (this.createModelStream) {
|
|
159
|
+
intents = await LogicInspector.capture(this.createModelStream(compName, varData))
|
|
160
|
+
} else {
|
|
161
|
+
const defaultModelStream = async function* () {
|
|
162
|
+
yield render(`ui-${compName.toLowerCase()}`, varData)
|
|
163
|
+
return result({})
|
|
164
|
+
}
|
|
165
|
+
intents = await LogicInspector.capture(defaultModelStream())
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const snapshot = intents.map((it) => VisualAdapter.render(it)).join('\n')
|
|
169
|
+
|
|
170
|
+
const categoryPath = this.getCategory ? this.getCategory(compName) : 'Components'
|
|
171
|
+
const outPath = `${this.snapshotsDir}/${lang}/${categoryPath}/${compName}`
|
|
172
|
+
|
|
173
|
+
if (!galleryTree[lang][categoryPath]) galleryTree[lang][categoryPath] = {}
|
|
174
|
+
if (!galleryTree[lang][categoryPath][compName])
|
|
175
|
+
galleryTree[lang][categoryPath][compName] = { score: 100, errors: [] }
|
|
176
|
+
|
|
177
|
+
const filePath = `${outPath}/${safeVarName}.nan0`
|
|
178
|
+
await this.db.saveDocument(filePath, snapshot)
|
|
179
|
+
|
|
180
|
+
yield show(t(SnapshotRunner.UI.generating, { lang, comp: `${compName}/${safeVarName}` }))
|
|
181
|
+
variationsData.push({ safeVarName, snapshot })
|
|
182
|
+
|
|
183
|
+
// Instant Audit
|
|
184
|
+
const audit = SnapshotAuditor.inspectText(snapshot, lang, filePath, t)
|
|
185
|
+
if (audit.score < 100) {
|
|
186
|
+
galleryTree[lang][categoryPath][compName].score = Math.min(
|
|
187
|
+
galleryTree[lang][categoryPath][compName].score,
|
|
188
|
+
audit.score,
|
|
189
|
+
)
|
|
190
|
+
galleryTree[lang][categoryPath][compName].errors.push(...audit.errors)
|
|
191
|
+
globalErrors += audit.errors.length
|
|
192
|
+
yield show(
|
|
193
|
+
t(SnapshotRunner.UI.auditFailed, {
|
|
194
|
+
file: filePath,
|
|
195
|
+
errors: audit.errors.join('; '),
|
|
196
|
+
}),
|
|
197
|
+
'error',
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
globalCount++
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Generate index.md for component
|
|
204
|
+
if (variationsData.length > 0) {
|
|
205
|
+
const categoryPath = this.getCategory ? this.getCategory(compName) : 'Components'
|
|
206
|
+
const outPath = `${this.snapshotsDir}/${lang}/${categoryPath}/${compName}`
|
|
207
|
+
const desc = t(SnapshotRunner.UI.galleryDescription, undefined)
|
|
208
|
+
|
|
209
|
+
const backPrefix = t(SnapshotRunner.UI.backLink, { text: t(SnapshotRunner.UI.backText, undefined) })
|
|
210
|
+
let markdown = `${backPrefix}\n\n# ${compName}\n\n> ${desc}\n\n`
|
|
211
|
+
for (const { safeVarName, snapshot } of variationsData) {
|
|
212
|
+
markdown += `## ${safeVarName}\n\n\`\`\`yaml\n${snapshot}\n\`\`\`\n\n`
|
|
213
|
+
}
|
|
214
|
+
await this.db.saveDocument(`${outPath}/index.md`, markdown)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Generate top-level indexes
|
|
220
|
+
let rootMd = t(SnapshotRunner.UI.rootGallery, { count: globalCount, errors: globalErrors })
|
|
221
|
+
|
|
222
|
+
for (const lang of Object.keys(galleryTree)) {
|
|
223
|
+
const langTitle = langsIndex[lang] || lang
|
|
224
|
+
rootMd += `- [${langTitle}](./${lang}/index.md) — ${t(SnapshotRunner.UI.galleryDescription, undefined)}\n`
|
|
225
|
+
|
|
226
|
+
const backPrefix = t(SnapshotRunner.UI.backLink, { text: t(SnapshotRunner.UI.backText, undefined) })
|
|
227
|
+
let langMd = `${backPrefix}\n\n# ${t(SnapshotRunner.UI.localeTitle, {
|
|
228
|
+
title: langTitle,
|
|
229
|
+
})}\n\n`
|
|
230
|
+
|
|
231
|
+
for (const category of Object.keys(galleryTree[lang])) {
|
|
232
|
+
langMd += `## [${category}](./${category}/index.md)\n\n`
|
|
233
|
+
|
|
234
|
+
const backPrefix = t(SnapshotRunner.UI.backLink, { text: t(SnapshotRunner.UI.backText, undefined) })
|
|
235
|
+
let catMd = `${backPrefix}\n\n# ${t(SnapshotRunner.UI.categoryTitle, {
|
|
236
|
+
title: category,
|
|
237
|
+
})}\n\n`
|
|
238
|
+
|
|
239
|
+
for (const compName of Object.keys(galleryTree[lang][category])) {
|
|
240
|
+
const compData = galleryTree[lang][category][compName]
|
|
241
|
+
const status = compData.score === 100 ? '✅' : '❌'
|
|
242
|
+
|
|
243
|
+
langMd += `- [${compName}](./${category}/${compName}/index.md) ${status}\n`
|
|
244
|
+
catMd += `- [${compName}](./${compName}/index.md) ${status}\n`
|
|
245
|
+
if (compData.errors.length) {
|
|
246
|
+
catMd += ` - ${compData.errors.join('\\n - ')}\n`
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
langMd += '\n'
|
|
250
|
+
|
|
251
|
+
const catDir = `${this.snapshotsDir}/${lang}/${category}`
|
|
252
|
+
await this.db.saveDocument(`${catDir}/index.md`, catMd)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const langDir = `${this.snapshotsDir}/${lang}`
|
|
256
|
+
await this.db.saveDocument(`${langDir}/index.md`, langMd)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await this.db.saveDocument(`${this.snapshotsDir}/index.md`, rootMd)
|
|
260
|
+
return result({ success: globalErrors === 0, count: globalCount, errors: globalErrors })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export default SnapshotRunner
|