@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.1

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 (154) hide show
  1. package/README.md +433 -133
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/config/notis_app_design_rules.json +135 -0
  4. package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
  5. package/dist/base-skills/notis-apps/SKILL.md +70 -0
  6. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  7. package/dist/base-skills/notis-apps/references/context.md +81 -0
  8. package/dist/base-skills/notis-apps/references/design.md +165 -0
  9. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  10. package/dist/base-skills/notis-apps/references/release.md +99 -0
  11. package/dist/base-skills/notis-apps/references/sdk.md +62 -0
  12. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +140 -0
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  15. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  17. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  18. package/dist/base-skills/notis-query/SKILL.md +67 -0
  19. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  20. package/dist/base-skills/notis-query/references/documents.md +50 -0
  21. package/dist/base-skills/notis-query/references/query.md +543 -0
  22. package/dist/skill-sync/index.js +1626 -0
  23. package/dist/skill-sync/index.js.map +7 -0
  24. package/dist/skill-sync-worker.mjs +2990 -0
  25. package/package.json +16 -6
  26. package/skills/notis-apps/cli.md +313 -0
  27. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  28. package/skills/notis-onboarding/BRIEF.md +129 -0
  29. package/skills/notis-query/cli.md +39 -0
  30. package/src/agent-hook-entry.js +5 -0
  31. package/src/cli.js +294 -25
  32. package/src/command-specs/agents.js +392 -0
  33. package/src/command-specs/apps.js +1470 -202
  34. package/src/command-specs/auth.js +114 -137
  35. package/src/command-specs/diagnostics.js +716 -0
  36. package/src/command-specs/handover.js +374 -0
  37. package/src/command-specs/helpers.js +84 -82
  38. package/src/command-specs/index.js +25 -6
  39. package/src/command-specs/meta.js +150 -18
  40. package/src/command-specs/onboarding.js +290 -0
  41. package/src/command-specs/profile.js +358 -0
  42. package/src/command-specs/reports.js +86 -0
  43. package/src/command-specs/skills.js +75 -0
  44. package/src/command-specs/smoke.js +386 -0
  45. package/src/command-specs/tools.js +455 -139
  46. package/src/runtime/agent-browser.js +632 -0
  47. package/src/runtime/agent-memory-state.js +126 -0
  48. package/src/runtime/agent-setup.js +383 -0
  49. package/src/runtime/app-boundary-validator.js +404 -0
  50. package/src/runtime/app-changelog.js +79 -0
  51. package/src/runtime/app-platform.js +2633 -210
  52. package/src/runtime/app-registry-scaffolds.js +367 -0
  53. package/src/runtime/app-test-server.js +292 -0
  54. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  55. package/src/runtime/auth-recovery.js +110 -0
  56. package/src/runtime/base-skills.d.ts +20 -0
  57. package/src/runtime/base-skills.js +167 -0
  58. package/src/runtime/channel.js +133 -0
  59. package/src/runtime/delegated-context.js +68 -0
  60. package/src/runtime/errors.js +1 -0
  61. package/src/runtime/git.js +233 -0
  62. package/src/runtime/login-listener.js +15 -0
  63. package/src/runtime/oauth.js +2622 -0
  64. package/src/runtime/output.js +37 -5
  65. package/src/runtime/ports.js +31 -0
  66. package/src/runtime/profiles.js +906 -55
  67. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  68. package/src/runtime/skill-sync/index.ts +697 -0
  69. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  70. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  71. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  72. package/src/runtime/skill-sync/types.ts +110 -0
  73. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  74. package/src/runtime/skill-sync-service.js +109 -0
  75. package/src/runtime/store-screenshot.js +143 -0
  76. package/src/runtime/sync-skills.d.ts +37 -0
  77. package/src/runtime/sync-skills.js +231 -0
  78. package/src/runtime/telemetry.js +92 -0
  79. package/src/runtime/transport.js +324 -45
  80. package/src/skill-sync-worker-entry.js +2 -0
  81. package/src/skill-sync-worker.js +50 -0
  82. package/template/.harness/index.html.tmpl +430 -0
  83. package/template/CHANGELOG.md +5 -0
  84. package/template/app/layout.tsx +5 -2
  85. package/template/app/page.tsx +49 -42
  86. package/template/components/page-heading.tsx +23 -0
  87. package/template/components/ui/badge.tsx +7 -4
  88. package/template/components/ui/card.tsx +24 -11
  89. package/template/components/ui/native-select.tsx +24 -0
  90. package/template/notis.config.ts +24 -6
  91. package/template/package-lock.json +4137 -0
  92. package/template/package.json +5 -5
  93. package/template/packages/{notis-sdk → sdk}/package.json +13 -3
  94. package/template/packages/sdk/src/agentContext.ts +36 -0
  95. package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
  96. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  97. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  98. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  99. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  100. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  101. package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  102. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  103. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  104. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  105. package/template/packages/sdk/src/config.ts +257 -0
  106. package/template/packages/sdk/src/documents.ts +256 -0
  107. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  108. package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
  109. package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
  110. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
  111. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  112. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  113. package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
  114. package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
  115. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  116. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  117. package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
  118. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
  119. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
  120. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  121. package/template/packages/sdk/src/hooks/useTool.ts +65 -0
  122. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  123. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
  124. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  125. package/template/packages/sdk/src/index.ts +161 -0
  126. package/template/packages/sdk/src/interactions/actions.ts +59 -0
  127. package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
  128. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  129. package/template/packages/sdk/src/interactions.ts +45 -0
  130. package/template/packages/sdk/src/provider.tsx +44 -0
  131. package/template/packages/sdk/src/queryCache.ts +170 -0
  132. package/template/packages/sdk/src/runtime.ts +451 -0
  133. package/template/packages/sdk/src/styles.css +213 -0
  134. package/template/packages/sdk/src/tailwind.ts +56 -0
  135. package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
  136. package/template/tailwind.config.ts +1 -0
  137. package/src/command-specs/db.js +0 -163
  138. package/src/runtime/app-preview-server.js +0 -312
  139. package/template/packages/notis-sdk/src/config.ts +0 -48
  140. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  141. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  142. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  143. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  144. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  145. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  146. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  147. package/template/packages/notis-sdk/src/index.ts +0 -47
  148. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  149. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  150. package/template/packages/notis-sdk/src/styles.css +0 -123
  151. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  152. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  153. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  154. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -0,0 +1,404 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { dirname, extname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { usageError } from './errors.js';
6
+
7
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
8
+
9
+ // Candidate locations for the app boundary rules, in priority order:
10
+ // 1. The monorepo server source (always fresh during local development).
11
+ // 2. The copy bundled into the published package at build time (see
12
+ // scripts/copy-boundary-rules.js + package.json "files": config/). When the
13
+ // CLI is installed via npm, candidate 1 escapes the package and is absent,
14
+ // so the bundled copy is used. This is what was missing before: the package
15
+ // shipped only the escaping path and crashed with ENOENT on any command.
16
+ export const RULES_PATH_CANDIDATES = [
17
+ resolve(moduleDir, '../../../../server/config/notis_app_boundary_rules.json'),
18
+ resolve(moduleDir, '../../config/notis_app_boundary_rules.json'),
19
+ ];
20
+
21
+ export function resolveRulesPath() {
22
+ for (const candidate of RULES_PATH_CANDIDATES) {
23
+ if (existsSync(candidate)) {
24
+ return candidate;
25
+ }
26
+ }
27
+ // Fall back to the bundled-copy path so any error message points inside the
28
+ // package rather than at an escaping monorepo path.
29
+ return RULES_PATH_CANDIDATES[RULES_PATH_CANDIDATES.length - 1];
30
+ }
31
+
32
+ const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.pcss']);
33
+ const IGNORED_SOURCE_DIRS = new Set([
34
+ 'node_modules',
35
+ '.git',
36
+ '.next',
37
+ '.notis',
38
+ 'dist',
39
+ 'build',
40
+ 'coverage',
41
+ ]);
42
+
43
+ function compileRules(entries) {
44
+ // Tolerate a rules object whose javascript/css field is a non-array (a corrupt
45
+ // file can parse to {"javascript":"x"}); coerce anything non-array to [] so
46
+ // getCompiledRules degrades to an empty rule set instead of throwing on .map.
47
+ return (Array.isArray(entries) ? entries : []).map((entry) => ({
48
+ regex: new RegExp(entry.pattern, 'm'),
49
+ message: entry.message,
50
+ }));
51
+ }
52
+
53
+ // Loaded lazily on first validation rather than at module import time: a
54
+ // missing rules file must not prevent the whole CLI from booting (every
55
+ // command imports this module transitively). The server re-validates on
56
+ // save/deploy, so an empty client-side rule set degrades gracefully.
57
+ let compiledRules = null;
58
+
59
+ function getCompiledRules() {
60
+ if (compiledRules) {
61
+ return compiledRules;
62
+ }
63
+ let boundaryRules = { javascript: [], css: [] };
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(resolveRulesPath(), 'utf-8'));
66
+ // Keep the safe default unless the file is a real rules object: a valid but
67
+ // non-object payload (null, a number, an array) would otherwise throw on the
68
+ // `.javascript`/`.css` deref below, reintroducing the boot crash this guards.
69
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
70
+ boundaryRules = parsed;
71
+ } else {
72
+ process.stderr.write(
73
+ 'Warning: Notis app boundary rules are not a rules object; skipping local boundary checks.\n',
74
+ );
75
+ }
76
+ } catch (error) {
77
+ process.stderr.write(
78
+ `Warning: could not load Notis app boundary rules (${error.message}); skipping local boundary checks.\n`,
79
+ );
80
+ }
81
+ compiledRules = {
82
+ javascript: compileRules(boundaryRules.javascript),
83
+ css: compileRules(boundaryRules.css),
84
+ };
85
+ return compiledRules;
86
+ }
87
+
88
+ function collectProjectFiles(projectDir, dir, results) {
89
+ if (!existsSync(dir)) {
90
+ return;
91
+ }
92
+
93
+ const entries = readdirSync(dir, { withFileTypes: true });
94
+ for (const entry of entries) {
95
+ if (entry.name.startsWith('.')) {
96
+ if (entry.name !== '.storybook') {
97
+ continue;
98
+ }
99
+ }
100
+
101
+ const fullPath = join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ if (IGNORED_SOURCE_DIRS.has(entry.name)) {
104
+ continue;
105
+ }
106
+ collectProjectFiles(projectDir, fullPath, results);
107
+ continue;
108
+ }
109
+
110
+ if (!SOURCE_FILE_EXTENSIONS.has(extname(entry.name))) {
111
+ continue;
112
+ }
113
+
114
+ results.push({
115
+ path: fullPath,
116
+ relPath: fullPath.slice(projectDir.length + 1),
117
+ content: readFileSync(fullPath, 'utf-8'),
118
+ });
119
+ }
120
+ }
121
+
122
+ function applyRules(content, rules, relPath) {
123
+ const errors = [];
124
+ for (const rule of rules) {
125
+ if (rule.regex.test(content)) {
126
+ errors.push(`${relPath}: ${rule.message}`);
127
+ }
128
+ }
129
+ return errors;
130
+ }
131
+
132
+ function validateTextFile(relPath, content) {
133
+ const extension = extname(relPath);
134
+ const rules = getCompiledRules();
135
+ if (extension === '.css' || extension === '.scss' || extension === '.sass' || extension === '.pcss') {
136
+ return applyRules(content, rules.css, relPath);
137
+ }
138
+ if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
139
+ return applyRules(content, rules.javascript, relPath);
140
+ }
141
+ return [];
142
+ }
143
+
144
+ export function collectProjectBoundaryViolations(projectDir) {
145
+ const files = [];
146
+ collectProjectFiles(projectDir, projectDir, files);
147
+ return files.flatMap((file) => validateTextFile(file.relPath, file.content));
148
+ }
149
+
150
+ export function collectArtifactBoundaryViolations(files) {
151
+ return Object.entries(files).flatMap(([relPath, rawContent]) => {
152
+ const extension = extname(relPath);
153
+ if (!['.js', '.css', '.scss', '.sass', '.pcss'].includes(extension)) {
154
+ return [];
155
+ }
156
+
157
+ const content = Buffer.isBuffer(rawContent)
158
+ ? rawContent.toString('utf-8')
159
+ : typeof rawContent === 'string'
160
+ ? rawContent
161
+ : String(rawContent ?? '');
162
+
163
+ return validateTextFile(relPath, content);
164
+ });
165
+ }
166
+
167
+ export function validateProjectBoundary(projectDir) {
168
+ const violations = collectProjectBoundaryViolations(projectDir);
169
+ if (violations.length > 0) {
170
+ throw usageError(
171
+ `Project violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
172
+ );
173
+ }
174
+ }
175
+
176
+ export function validateArtifactBoundary(files) {
177
+ const violations = collectArtifactBoundaryViolations(files);
178
+ if (violations.length > 0) {
179
+ throw usageError(
180
+ `Built app violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
181
+ );
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Design rules
187
+ //
188
+ // The portal boundary rules above keep an app inside its surface. The design
189
+ // rules below keep it looking like a native, flat Notis page: no bordered
190
+ // boxes around items, no dividers, no palette colors, no eyebrows, no loading
191
+ // text. They are path-scoped, report line numbers, and only ever run on the
192
+ // app's own source files (never on the bundled artifact, where every
193
+ // dependency legitimately contains the word "border").
194
+ //
195
+ // The only override is an inline directive on the same line or the line
196
+ // before the match:
197
+ // // notis-design-allow: <rule-id> <reason of at least N characters>
198
+ // Allowed matches are reported with `allowed: true` so they stay visible.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ export const DESIGN_RULES_PATH_CANDIDATES = [
202
+ resolve(moduleDir, '../../../../server/config/notis_app_design_rules.json'),
203
+ resolve(moduleDir, '../../config/notis_app_design_rules.json'),
204
+ ];
205
+
206
+ export function resolveDesignRulesPath() {
207
+ for (const candidate of DESIGN_RULES_PATH_CANDIDATES) {
208
+ if (existsSync(candidate)) {
209
+ return candidate;
210
+ }
211
+ }
212
+ return DESIGN_RULES_PATH_CANDIDATES[DESIGN_RULES_PATH_CANDIDATES.length - 1];
213
+ }
214
+
215
+ const DEFAULT_DESIGN_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
216
+
217
+ function globToRegExp(glob) {
218
+ let source = '';
219
+ for (let index = 0; index < glob.length; index += 1) {
220
+ const char = glob[index];
221
+ if (char === '*') {
222
+ if (glob[index + 1] === '*') {
223
+ index += 1;
224
+ if (glob[index + 1] === '/') {
225
+ index += 1;
226
+ source += '(?:.*/)?';
227
+ } else {
228
+ source += '.*';
229
+ }
230
+ } else {
231
+ source += '[^/]*';
232
+ }
233
+ } else if (char === '?') {
234
+ source += '[^/]';
235
+ } else {
236
+ source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
237
+ }
238
+ }
239
+ return new RegExp(`^${source}$`);
240
+ }
241
+
242
+ function matchesAny(relPath, globs) {
243
+ return (globs || []).some((glob) => globToRegExp(glob).test(relPath));
244
+ }
245
+
246
+ let compiledDesignRules = null;
247
+
248
+ export function loadDesignRules({ rulesPath = resolveDesignRulesPath() } = {}) {
249
+ let payload = null;
250
+ try {
251
+ const parsed = JSON.parse(readFileSync(rulesPath, 'utf-8'));
252
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
253
+ payload = parsed;
254
+ }
255
+ } catch (error) {
256
+ process.stderr.write(
257
+ `Warning: could not load Notis app design rules (${error.message}); skipping design checks.\n`,
258
+ );
259
+ }
260
+ if (!payload) {
261
+ return { include: [], exclude: [], extensions: DEFAULT_DESIGN_EXTENSIONS, rules: [], allowDirective: 'notis-design-allow', allowReasonMinLength: 12 };
262
+ }
263
+ return {
264
+ include: Array.isArray(payload.include) ? payload.include : ['app/**', 'components/**'],
265
+ exclude: Array.isArray(payload.exclude) ? payload.exclude : [],
266
+ extensions: Array.isArray(payload.extensions) ? payload.extensions : DEFAULT_DESIGN_EXTENSIONS,
267
+ allowDirective: typeof payload.allow_directive === 'string' ? payload.allow_directive : 'notis-design-allow',
268
+ allowReasonMinLength: Number.isInteger(payload.allow_reason_min_length) ? payload.allow_reason_min_length : 12,
269
+ rules: (Array.isArray(payload.rules) ? payload.rules : []).map((rule) => ({
270
+ id: rule.id,
271
+ severity: rule.severity === 'warn' ? 'warn' : 'error',
272
+ scope: rule.scope === 'file' ? 'file' : 'line',
273
+ regex: new RegExp(rule.pattern, rule.scope === 'file' ? '' : 'gm'),
274
+ message: rule.message,
275
+ include: Array.isArray(rule.include) ? rule.include : null,
276
+ exclude: Array.isArray(rule.exclude) ? rule.exclude : [],
277
+ })),
278
+ };
279
+ }
280
+
281
+ function getCompiledDesignRules() {
282
+ if (!compiledDesignRules) {
283
+ compiledDesignRules = loadDesignRules();
284
+ }
285
+ return compiledDesignRules;
286
+ }
287
+
288
+ function lineNumberAt(content, index) {
289
+ let line = 1;
290
+ for (let cursor = 0; cursor < index; cursor += 1) {
291
+ if (content.charCodeAt(cursor) === 10) line += 1;
292
+ }
293
+ return line;
294
+ }
295
+
296
+ function findAllowDirective(lines, lineNumber, ruleId, directive) {
297
+ const pattern = new RegExp(`${directive}:\\s*${ruleId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(.*)$`);
298
+ for (const candidate of [lines[lineNumber - 1], lines[lineNumber - 2]]) {
299
+ if (typeof candidate !== 'string') continue;
300
+ const match = candidate.match(pattern);
301
+ if (match) {
302
+ return match[1].replace(/\*\/\s*}?\s*$/, '').replace(/-->\s*$/, '').trim();
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ export function collectDesignViolationsForFile(relPath, content, config = getCompiledDesignRules()) {
309
+ const normalized = relPath.split('\\').join('/');
310
+ if (!config.extensions.includes(extname(normalized))) return [];
311
+ if (!matchesAny(normalized, config.include)) return [];
312
+ if (matchesAny(normalized, config.exclude)) return [];
313
+
314
+ const lines = content.split('\n');
315
+ const violations = [];
316
+ for (const rule of config.rules) {
317
+ if (rule.include && !matchesAny(normalized, rule.include)) continue;
318
+ if (matchesAny(normalized, rule.exclude)) continue;
319
+
320
+ const matches = [];
321
+ if (rule.scope === 'file') {
322
+ const match = rule.regex.exec(content);
323
+ if (match) matches.push(match.index);
324
+ } else {
325
+ rule.regex.lastIndex = 0;
326
+ let match;
327
+ while ((match = rule.regex.exec(content)) !== null) {
328
+ matches.push(match.index);
329
+ if (match[0].length === 0) rule.regex.lastIndex += 1;
330
+ }
331
+ }
332
+
333
+ const seenLines = new Set();
334
+ for (const index of matches) {
335
+ const line = lineNumberAt(content, index);
336
+ if (seenLines.has(line)) continue;
337
+ seenLines.add(line);
338
+ const reason = findAllowDirective(lines, line, rule.id, config.allowDirective);
339
+ const allowed = reason !== null && reason.length >= config.allowReasonMinLength;
340
+ violations.push({
341
+ file: normalized,
342
+ line,
343
+ ruleId: rule.id,
344
+ severity: allowed ? 'warn' : rule.severity,
345
+ message: reason !== null && !allowed
346
+ ? `${rule.message} (a notis-design-allow directive needs a reason of at least ${config.allowReasonMinLength} characters)`
347
+ : rule.message,
348
+ allowed,
349
+ reason: allowed ? reason : null,
350
+ });
351
+ }
352
+ }
353
+ return violations;
354
+ }
355
+
356
+ export function collectProjectDesignViolations(projectDir, config = getCompiledDesignRules()) {
357
+ const files = [];
358
+ collectProjectFiles(projectDir, projectDir, files);
359
+ return files.flatMap((file) => collectDesignViolationsForFile(file.relPath, file.content, config));
360
+ }
361
+
362
+ export function collectSourceDesignViolations(files, config = getCompiledDesignRules()) {
363
+ return Object.entries(files).flatMap(([relPath, rawContent]) => {
364
+ const content = Buffer.isBuffer(rawContent)
365
+ ? rawContent.toString('utf-8')
366
+ : typeof rawContent === 'string'
367
+ ? rawContent
368
+ : String(rawContent ?? '');
369
+ return collectDesignViolationsForFile(relPath, content, config);
370
+ });
371
+ }
372
+
373
+ export function formatDesignViolation(violation) {
374
+ const prefix = violation.allowed ? 'allowed' : violation.severity;
375
+ const suffix = violation.allowed ? ` (${violation.reason})` : '';
376
+ return `${violation.file}:${violation.line} [${violation.ruleId}] ${prefix}: ${violation.message}${suffix}`;
377
+ }
378
+
379
+ /**
380
+ * Enforce the design rules on an app's source tree.
381
+ *
382
+ * Returns every violation (allowed ones included) so callers can print
383
+ * warnings. When `enforce` is true (build, verify, screenshot, deploy) any
384
+ * unallowed error-severity violation aborts with a usage error that lists the
385
+ * exact file and line to fix. Dev servers pass `enforce: false` so a
386
+ * half-edited page still reloads.
387
+ */
388
+ export function validateProjectDesign(projectDir, { enforce = true, log = null } = {}) {
389
+ const violations = collectProjectDesignViolations(projectDir);
390
+ const blocking = violations.filter((violation) => !violation.allowed && violation.severity === 'error');
391
+ const nonBlocking = violations.filter((violation) => !blocking.includes(violation));
392
+ if (typeof log === 'function') {
393
+ for (const violation of nonBlocking) log(`[design] ${formatDesignViolation(violation)}`);
394
+ if (!enforce) for (const violation of blocking) log(`[design] ${formatDesignViolation(violation)}`);
395
+ }
396
+ if (enforce && blocking.length > 0) {
397
+ throw usageError(
398
+ `Project violates the Notis app design bar (${blocking.length} issue${blocking.length === 1 ? '' : 's'}). `
399
+ + 'Fix each line below; the only override is an inline "// notis-design-allow: <rule-id> <reason>" comment on the line before.\n'
400
+ + blocking.map((violation) => ` - ${formatDesignViolation(violation)}`).join('\n'),
401
+ );
402
+ }
403
+ return violations;
404
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const APP_CHANGELOG_FILE = 'CHANGELOG.md';
5
+ export const CHANGELOG_MERGE_DATE = '{PR_MERGE_DATE}';
6
+
7
+ const ENTRY_HEADING = /^##\s+\[([^\]]+)]\s+-\s+(\{PR_MERGE_DATE\}|\d{4}-\d{2}-\d{2})\s*$/;
8
+
9
+ function validCalendarDate(value) {
10
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
11
+ const date = new Date(`${value}T00:00:00Z`);
12
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
13
+ }
14
+
15
+ export function parseAppChangelog(markdown, { sourcePath = APP_CHANGELOG_FILE } = {}) {
16
+ const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
17
+ const entries = [];
18
+ const errors = [];
19
+ let current = null;
20
+
21
+ function finishCurrent() {
22
+ if (!current) return;
23
+ entries.push({
24
+ title: current.title,
25
+ date: current.date,
26
+ body: current.body.join('\n').trim(),
27
+ });
28
+ }
29
+
30
+ for (const [index, line] of lines.entries()) {
31
+ // Only a level-2 heading (`## ...`, not `###`/`####`) can start a release
32
+ // entry; deeper sub-section headings are legitimate release-body content.
33
+ if (!/^##(?!#)/.test(line)) {
34
+ if (current) current.body.push(line);
35
+ continue;
36
+ }
37
+
38
+ const match = line.match(ENTRY_HEADING);
39
+ if (!match) {
40
+ errors.push(
41
+ `${sourcePath}:${index + 1} must use \`## [Release title] - YYYY-MM-DD\` or \`## [Release title] - {PR_MERGE_DATE}\`.`,
42
+ );
43
+ continue;
44
+ }
45
+
46
+ finishCurrent();
47
+ const title = match[1].trim();
48
+ const date = match[2];
49
+ if (!title) {
50
+ errors.push(`${sourcePath}:${index + 1} must include a release title inside square brackets.`);
51
+ }
52
+ if (date !== CHANGELOG_MERGE_DATE && !validCalendarDate(date)) {
53
+ errors.push(`${sourcePath}:${index + 1} contains an invalid calendar date.`);
54
+ }
55
+ current = { title, date, body: [] };
56
+ }
57
+ finishCurrent();
58
+
59
+ if (entries.length === 0 && errors.length === 0) {
60
+ errors.push(
61
+ `${sourcePath} must include at least one \`## [Release title] - YYYY-MM-DD\` entry.`,
62
+ );
63
+ }
64
+
65
+ return { source_path: sourcePath, entries, errors, exists: true };
66
+ }
67
+
68
+ export function readAppChangelog(projectDir) {
69
+ const path = join(projectDir, APP_CHANGELOG_FILE);
70
+ if (!existsSync(path)) {
71
+ return {
72
+ source_path: APP_CHANGELOG_FILE,
73
+ entries: [],
74
+ errors: [`${APP_CHANGELOG_FILE} is required for Store publication.`],
75
+ exists: false,
76
+ };
77
+ }
78
+ return parseAppChangelog(readFileSync(path, 'utf-8'));
79
+ }