@birdapi/velinstyle 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.de.md +148 -11
  2. package/README.md +174 -16
  3. package/cli/__fixtures__/atelier-library/showcases/01-login/app.css +1 -0
  4. package/cli/__fixtures__/atelier-library/showcases/01-login/app.js +2 -0
  5. package/cli/__fixtures__/atelier-library/showcases/01-login/index.html +5 -0
  6. package/cli/__fixtures__/atelier-library/showcases/04-pricing/app.css +1 -0
  7. package/cli/__fixtures__/atelier-library/showcases/04-pricing/app.js +2 -0
  8. package/cli/__fixtures__/atelier-library/showcases/04-pricing/index.html +5 -0
  9. package/cli/__fixtures__/atelier-library/showcases/36-calendar/app.css +1 -0
  10. package/cli/__fixtures__/atelier-library/showcases/36-calendar/app.js +2 -0
  11. package/cli/__fixtures__/atelier-library/showcases/36-calendar/index.html +5 -0
  12. package/cli/atelier-catalog.json +1267 -0
  13. package/cli/atelier-formats.js +159 -0
  14. package/cli/atelier.js +382 -0
  15. package/cli/blueprints/empty-state.html +3 -5
  16. package/cli/cli-manifest.json +441 -161
  17. package/cli/docgen/extract-cli.js +2 -1
  18. package/cli/docs-generate.js +9 -0
  19. package/cli/experience.js +173 -0
  20. package/cli/index.js +1384 -361
  21. package/cli/motion.js +738 -0
  22. package/cli/production/component-graph.json +166 -0
  23. package/cli/production/explain.js +52 -0
  24. package/cli/production/extract.js +278 -0
  25. package/cli/production/graph.js +102 -0
  26. package/cli/production/report.js +168 -0
  27. package/cli/production/run.js +355 -0
  28. package/cli/production/trim-css.js +177 -0
  29. package/cli/production/trim-fonts.js +23 -0
  30. package/cli/production/trim-icons.js +38 -0
  31. package/cli/production/trim-js.js +48 -0
  32. package/cli/production/trim-motion.js +22 -0
  33. package/cli/production/trim-themes.js +50 -0
  34. package/cli/production/watch.js +38 -0
  35. package/cli/review.js +48 -1
  36. package/cli/scripts/write-atelier-fixtures.mjs +33 -0
  37. package/cli/security.js +190 -0
  38. package/cli/skills.js +53 -10
  39. package/cli/transparency.js +140 -41
  40. package/cli/workflow.js +32 -17
  41. package/components/index.js +3 -0
  42. package/components/runtime/component-loaders.js +3 -0
  43. package/components/velin-drawer.js +43 -4
  44. package/components/velin-empty-state.js +83 -0
  45. package/components/velin-modal.js +76 -15
  46. package/components/velin-otp-input.js +220 -0
  47. package/components/velin-password-strength.js +147 -0
  48. package/components/velin-sheet.js +49 -4
  49. package/components/velin-theme-toggle.js +15 -1
  50. package/core/a11y/component-contracts.json +391 -255
  51. package/core/attributes/registry.js +1 -1
  52. package/core/meta/knowledge/components.json +75 -3
  53. package/core/motion/analyze.js +38 -0
  54. package/core/motion/blueprint.js +51 -0
  55. package/core/motion/composer.js +114 -0
  56. package/core/motion/data/registry.json +352 -0
  57. package/core/motion/effects.js +17 -1
  58. package/core/motion/index.js +112 -9
  59. package/core/motion/optimize.js +44 -0
  60. package/core/motion/policy.js +77 -0
  61. package/core/motion/registry.js +131 -0
  62. package/core/motion/review.js +59 -0
  63. package/core/motion/scan.js +150 -0
  64. package/core/motion/timeline.js +68 -0
  65. package/core/motion/transitions.js +49 -0
  66. package/core/motion/triggers.js +168 -0
  67. package/core/security/checks/fs.js +92 -0
  68. package/core/security/detect.js +35 -0
  69. package/core/security/engine.js +116 -0
  70. package/core/security/index.js +18 -0
  71. package/core/security/registry.js +85 -0
  72. package/core/security/report/report.js +113 -0
  73. package/core/security/rules/ci/index.js +146 -0
  74. package/core/security/rules/consumer/index.js +174 -0
  75. package/core/security/rules/deps/index.js +182 -0
  76. package/core/security/rules/index.js +24 -0
  77. package/core/security/rules/publish/index.js +230 -0
  78. package/core/security/rules/repo/index.js +143 -0
  79. package/core/security/rules/secrets/index.js +133 -0
  80. package/core/security/score/dependency-health.js +53 -0
  81. package/core/security/score/release-health.js +66 -0
  82. package/core/security/score/score.js +102 -0
  83. package/core/security/timeline/timeline.js +76 -0
  84. package/dist/chunks/attributes-2ORR27KA.js +404 -0
  85. package/dist/chunks/attributes-HK7VIOLA.js +1080 -0
  86. package/dist/chunks/attributes-VRHHVNDO.js +1080 -0
  87. package/dist/chunks/chunk-MYKUI364.js +116 -0
  88. package/dist/chunks/chunk-NEVBPD5T.js +116 -0
  89. package/dist/chunks/chunk-Y6HN7HWX.js +116 -0
  90. package/dist/chunks/runtime-entry.js +1 -1
  91. package/dist/chunks/velin-drawer-A7Q7EBOS.js +162 -0
  92. package/dist/chunks/velin-empty-state-3NTUH2RF.js +81 -0
  93. package/dist/chunks/velin-modal-3MV4FYBK.js +231 -0
  94. package/dist/chunks/velin-otp-input-PSK42MM6.js +207 -0
  95. package/dist/chunks/velin-password-strength-TAXMLSED.js +136 -0
  96. package/dist/chunks/velin-sheet-N2VVYXFP.js +157 -0
  97. package/dist/chunks/velin-theme-toggle-J2NHC7IM.js +304 -0
  98. package/dist/llms.txt +4 -4
  99. package/dist/search-index.json +232 -5
  100. package/dist/velin-agent.json +433 -46
  101. package/dist/velinstyle-components.iife.js +3695 -2272
  102. package/dist/velinstyle-components.js +3698 -2271
  103. package/dist/velinstyle-components.min.js +262 -120
  104. package/dist/velinstyle.css +96 -13
  105. package/dist/velinstyle.d.ts +3 -0
  106. package/dist/velinstyle.min.css +1 -1
  107. package/package.json +19 -4
  108. package/packages/velinstyle-cli-core/package.json +11 -0
  109. package/packages/velinstyle-cli-core/src/config.js +84 -0
  110. package/packages/velinstyle-cli-core/src/contract.js +56 -0
  111. package/packages/velinstyle-cli-core/src/event-bus.js +50 -0
  112. package/packages/velinstyle-cli-core/src/index.js +18 -0
  113. package/packages/velinstyle-cli-core/src/lifecycle.js +33 -0
  114. package/packages/velinstyle-cli-core/src/runner.js +129 -0
  115. package/packages/velinstyle-cli-registry/data/commands.json +1216 -0
  116. package/packages/velinstyle-cli-registry/data/layouts.json +33 -0
  117. package/packages/velinstyle-cli-registry/data/widgets.json +35 -0
  118. package/packages/velinstyle-cli-registry/package.json +13 -0
  119. package/packages/velinstyle-cli-registry/src/index.js +88 -0
  120. package/packages/velinstyle-cli-renderer/package.json +8 -0
  121. package/packages/velinstyle-cli-renderer/src/index.js +286 -0
  122. package/packages/velinstyle-cli-theme/package.json +8 -0
  123. package/packages/velinstyle-cli-theme/src/index.js +84 -0
  124. package/packages/velinstyle-cli-ui/package.json +8 -0
  125. package/packages/velinstyle-cli-ui/src/index.js +34 -0
  126. package/packages/velinstyle-motion/README.md +81 -0
  127. package/packages/velinstyle-motion/package.json +37 -0
  128. package/packages/velinstyle-motion/src/ai/mini-prompt.js +119 -0
  129. package/packages/velinstyle-motion/src/ai/provider.js +59 -0
  130. package/packages/velinstyle-motion/src/ai/providers/pixverse-setup.js +402 -0
  131. package/packages/velinstyle-motion/src/ai/providers/pixverse.js +245 -0
  132. package/packages/velinstyle-motion/src/config.js +257 -0
  133. package/packages/velinstyle-motion/src/export/html-clip.js +141 -0
  134. package/packages/velinstyle-motion/src/index.js +304 -0
  135. package/packages/velinstyle-motion/src/jobs/cache.js +30 -0
  136. package/packages/velinstyle-motion/src/jobs/queue.js +69 -0
  137. package/packages/velinstyle-motion/src/jobs/usage.js +53 -0
  138. package/packages/velinstyle-motion/src/local/effects.js +139 -0
  139. package/packages/velinstyle-motion/src/local/engine.js +96 -0
  140. package/packages/velinstyle-motion/src/providers-catalog.js +44 -0
  141. package/packages/velinstyle-motion/src/registry/presets.js +25 -0
  142. package/packages/velinstyle-motion/src/registry/presets.json +85 -0
  143. package/packages/velinstyle-motion/src/ux/cost-gate.js +37 -0
  144. package/packages/velinstyle-motion/src/ux/pixverse-gate.js +153 -0
  145. package/packages/velinstyle-motion/src/ux/wizard.js +171 -0
  146. package/packages/velinstyle-motion/src/validate-image.js +91 -0
  147. package/packages/velinstyle-skills/catalog.json +1 -1
  148. package/packages/velinstyle-skills/registry.json +1 -142
  149. package/packages/velinstyle-skills/skills/velin-motion-reduced-safe/SKILL.md +1 -1
  150. package/packages/velinstyle-skills/skills/velin-motion-reveal-feedback/SKILL.md +1 -1
  151. package/src/components/data-table.css +19 -0
  152. package/src/components/empty-auth.css +33 -0
  153. package/src/components/table.css +16 -6
  154. package/src/tokens/motion.css +7 -0
  155. package/src/utilities/scroll-animation.css +66 -0
  156. package/src/velinstyle.css +1 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared FS helpers for Security Engine rules.
3
+ */
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
5
+ import { join, relative } from 'path';
6
+
7
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'vendor', 'coverage', '.velin']);
8
+
9
+ /**
10
+ * @param {string} root
11
+ * @param {(name: string, abs: string) => boolean} [filter]
12
+ * @param {number} [max=400]
13
+ */
14
+ export function walkFiles(root, filter, max = 400) {
15
+ const out = [];
16
+ const walk = (dir) => {
17
+ if (out.length >= max) return;
18
+ let entries;
19
+ try {
20
+ entries = readdirSync(dir);
21
+ } catch {
22
+ return;
23
+ }
24
+ for (const name of entries) {
25
+ if (SKIP.has(name)) continue;
26
+ const abs = join(dir, name);
27
+ let st;
28
+ try {
29
+ st = statSync(abs);
30
+ } catch {
31
+ continue;
32
+ }
33
+ if (st.isDirectory()) walk(abs);
34
+ else if (!filter || filter(name, abs)) out.push(abs);
35
+ if (out.length >= max) return;
36
+ }
37
+ };
38
+ if (existsSync(root)) walk(root);
39
+ return out;
40
+ }
41
+
42
+ export function readText(path) {
43
+ try {
44
+ return readFileSync(path, 'utf-8');
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function readJson(path) {
51
+ const t = readText(path);
52
+ if (!t) return null;
53
+ try {
54
+ return JSON.parse(t);
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function rel(root, abs) {
61
+ return relative(root, abs).replace(/\\/g, '/');
62
+ }
63
+
64
+ export function fileExists(path) {
65
+ return existsSync(path);
66
+ }
67
+
68
+ /**
69
+ * Lightweight YAML-ish peek for GitHub Actions (no full parser).
70
+ * @param {string} text
71
+ */
72
+ export function workflowHints(text) {
73
+ const lines = text.split(/\r?\n/);
74
+ const uses = [];
75
+ let hasPullRequestTarget = /pull_request_target\s*:/.test(text);
76
+ let hasPermissions = /^\s*permissions\s*:/m.test(text);
77
+ let hasIdToken = /id-token\s*:\s*write/.test(text);
78
+ let mentionsNpmToken = /NPM_TOKEN|NODE_AUTH_TOKEN/.test(text);
79
+ for (const line of lines) {
80
+ const m = line.match(/^\s*-\s*uses:\s*(.+)\s*$/);
81
+ if (m) uses.push(m[1].trim().replace(/['"]/g, ''));
82
+ }
83
+ return { uses, hasPullRequestTarget, hasPermissions, hasIdToken, mentionsNpmToken };
84
+ }
85
+
86
+ /** Pin check: owner/action@sha40 vs @v1 */
87
+ export function isShaPinned(usesRef) {
88
+ const at = usesRef.lastIndexOf('@');
89
+ if (at < 0) return false;
90
+ const ref = usesRef.slice(at + 1);
91
+ return /^[0-9a-f]{40}$/i.test(ref);
92
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Detect repo vs consumer vs both for Security Engine.
3
+ */
4
+ import { join } from 'path';
5
+ import { fileExists, readJson } from './checks/fs.js';
6
+
7
+ /**
8
+ * @param {string} root
9
+ * @returns {'repo'|'consumer'|'both'}
10
+ */
11
+ export function detectSecurityMode(root) {
12
+ const pkg = readJson(join(root, 'package.json'));
13
+ const isFramework =
14
+ pkg?.name === '@birdapi/velinstyle'
15
+ || fileExists(join(root, '.github', 'workflows', 'publish-npm.yml'))
16
+ || (fileExists(join(root, 'SECURITY.md')) && fileExists(join(root, 'cli', 'index.js')));
17
+
18
+ const hasVelinDep =
19
+ Boolean(pkg?.dependencies?.['@birdapi/velinstyle'] || pkg?.devDependencies?.['@birdapi/velinstyle'])
20
+ || fileExists(join(root, 'vendor', 'velinstyle'))
21
+ || fileExists(join(root, 'vendor', 'velinstyle.min.css'));
22
+
23
+ const consumerMarkers =
24
+ fileExists(join(root, 'artisan'))
25
+ || fileExists(join(root, 'composer.json'))
26
+ || hasVelinDep
27
+ || Boolean(pkg && pkg.name !== '@birdapi/velinstyle');
28
+
29
+ if (isFramework && consumerMarkers && hasVelinDep && pkg?.name !== '@birdapi/velinstyle') {
30
+ return 'both';
31
+ }
32
+ if (isFramework) return 'repo';
33
+ if (consumerMarkers || pkg) return 'consumer';
34
+ return 'consumer';
35
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Velin Security Engine — run registered rules.
3
+ */
4
+ import { selectSecurityRules } from './registry.js';
5
+ import { loadBuiltinSecurityRules } from './rules/index.js';
6
+ import { detectSecurityMode } from './detect.js';
7
+ import { scoreFindings } from './score/score.js';
8
+ import { computeDependencyHealth } from './score/dependency-health.js';
9
+ import { computeReleaseHealth } from './score/release-health.js';
10
+ import { loadTimeline, touchTimeline } from './timeline/timeline.js';
11
+ import { resolve } from 'path';
12
+
13
+ /**
14
+ * @param {object} opts
15
+ * @param {string} [opts.root]
16
+ * @param {string} [opts.mode] — doctor|repo|consumer|ci|publish|deps|secrets|scan|audit
17
+ * @param {string[]} [opts.categories]
18
+ * @param {boolean} [opts.updateTimeline=true]
19
+ * @param {boolean} [opts.includeReleaseHealth]
20
+ * @param {boolean} [opts.includeDependencyHealth]
21
+ */
22
+ export async function runSecurityEngine(opts = {}) {
23
+ loadBuiltinSecurityRules();
24
+ const root = resolve(opts.root || '.');
25
+ let mode = opts.mode || 'doctor';
26
+
27
+ let detected = detectSecurityMode(root);
28
+ if (mode === 'doctor') {
29
+ mode = detected === 'both' ? 'audit' : detected;
30
+ }
31
+
32
+ const categoryMap = {
33
+ deps: ['deps', 'malware'],
34
+ secrets: ['secrets'],
35
+ ci: ['ci'],
36
+ publish: ['publish', 'repo', 'secrets', 'deps', 'malware'],
37
+ scan: ['consumer', 'secrets', 'repo', 'malware'],
38
+ repo: ['repo', 'ci', 'secrets', 'deps', 'publish', 'malware'],
39
+ consumer: ['consumer', 'secrets', 'deps', 'malware'],
40
+ audit: null,
41
+ };
42
+
43
+ const categories = opts.categories || categoryMap[mode] || null;
44
+ const rules = selectSecurityRules({
45
+ mode: mode === 'audit' ? 'audit' : mode,
46
+ categories: categories || undefined,
47
+ });
48
+
49
+ const ctx = { root, mode, categories: categories || undefined, meta: { detected } };
50
+ /** @type {import('./registry.js').SecurityFinding[]} */
51
+ const findings = [];
52
+
53
+ for (const rule of rules) {
54
+ try {
55
+ const out = await rule.run(ctx);
56
+ for (const f of out || []) {
57
+ findings.push({
58
+ category: rule.category,
59
+ ...f,
60
+ ruleId: f.ruleId || rule.id,
61
+ });
62
+ }
63
+ } catch (err) {
64
+ findings.push({
65
+ ruleId: rule.id,
66
+ category: rule.category,
67
+ severity: 'warning',
68
+ message: `Rule failed: ${err?.message || err}`,
69
+ });
70
+ }
71
+ }
72
+
73
+ const scored = scoreFindings(findings);
74
+ const includeDep = opts.includeDependencyHealth !== false;
75
+ const includeRel =
76
+ opts.includeReleaseHealth === true
77
+ || mode === 'publish'
78
+ || (mode === 'audit' && detected === 'repo');
79
+
80
+ const dependencyHealth = includeDep ? computeDependencyHealth(root, findings) : null;
81
+ const releaseHealth = includeRel ? computeReleaseHealth(root, findings) : null;
82
+
83
+ const touched = [];
84
+ if (mode === 'ci' || categories?.includes('ci')) touched.push('ci');
85
+ if (mode === 'deps' || categories?.includes('deps')) touched.push('dependencies');
86
+ if (mode === 'secrets' || categories?.includes('secrets')) touched.push('secrets');
87
+ if (mode === 'publish') touched.push('publish');
88
+ if (mode === 'repo' || detected === 'repo') touched.push('repository');
89
+ if (mode === 'consumer' || detected === 'consumer') touched.push('consumer');
90
+ if (mode === 'audit' || mode === 'scan') touched.push('audit');
91
+
92
+ let timeline = loadTimeline(root);
93
+ if (opts.updateTimeline !== false && touched.length) {
94
+ timeline = touchTimeline(root, touched);
95
+ }
96
+
97
+ return {
98
+ ...scored,
99
+ root,
100
+ mode,
101
+ detected,
102
+ rulesRun: rules.length,
103
+ dependencyHealth,
104
+ releaseHealth,
105
+ timeline,
106
+ ok: scored.overallGate !== 'fail',
107
+ };
108
+ }
109
+
110
+ export function createSecurityEngine(options = {}) {
111
+ loadBuiltinSecurityRules();
112
+ return {
113
+ detect: (root) => detectSecurityMode(resolve(root || options.root || '.')),
114
+ run: (opts = {}) => runSecurityEngine({ ...options, ...opts }),
115
+ };
116
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Velin Security Engine — public API.
3
+ */
4
+ export {
5
+ registerSecurityRule,
6
+ clearSecurityRules,
7
+ listSecurityRules,
8
+ getSecurityRule,
9
+ selectSecurityRules,
10
+ } from './registry.js';
11
+ export { detectSecurityMode } from './detect.js';
12
+ export { runSecurityEngine, createSecurityEngine } from './engine.js';
13
+ export { scoreFindings, gateLabel } from './score/score.js';
14
+ export { computeDependencyHealth } from './score/dependency-health.js';
15
+ export { computeReleaseHealth } from './score/release-health.js';
16
+ export { loadTimeline, touchTimeline, timelineRows, timelinePath } from './timeline/timeline.js';
17
+ export { toExperiencePayload, toMarkdownReport } from './report/report.js';
18
+ export { loadBuiltinSecurityRules } from './rules/index.js';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Velin Security Engine — rule registry (Studio-/CLI-ready).
3
+ */
4
+
5
+ /** @typedef {'ci'|'secrets'|'deps'|'publish'|'repo'|'consumer'|'malware'} SecurityCategory */
6
+ /** @typedef {'info'|'warning'|'error'} SecuritySeverity */
7
+ /** @typedef {'repo'|'consumer'|'ci'|'publish'|'deps'|'secrets'|'scan'|'audit'} SecurityMode */
8
+
9
+ /**
10
+ * @typedef {object} SecurityFinding
11
+ * @property {string} ruleId
12
+ * @property {SecuritySeverity} severity
13
+ * @property {string} message
14
+ * @property {string} [path]
15
+ * @property {string} [fix]
16
+ * @property {string} [code]
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} SecurityRule
21
+ * @property {string} id
22
+ * @property {SecurityCategory} category
23
+ * @property {SecuritySeverity} severity
24
+ * @property {SecurityMode[]} [modes]
25
+ * @property {string} [title]
26
+ * @property {string} [description]
27
+ * @property {(ctx: SecurityContext) => Promise<SecurityFinding[]>|SecurityFinding[]} run
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} SecurityContext
32
+ * @property {string} root
33
+ * @property {SecurityMode} mode
34
+ * @property {string[]} [categories]
35
+ * @property {import('fs').PathLike} [fs]
36
+ * @property {Record<string, unknown>} [meta]
37
+ */
38
+
39
+ /** @type {Map<string, SecurityRule>} */
40
+ const RULES = new Map();
41
+
42
+ /**
43
+ * @param {SecurityRule} rule
44
+ */
45
+ export function registerSecurityRule(rule) {
46
+ if (!rule?.id || typeof rule.run !== 'function') {
47
+ throw new Error('registerSecurityRule: id and run() required');
48
+ }
49
+ RULES.set(rule.id, {
50
+ severity: 'warning',
51
+ modes: ['repo', 'consumer', 'audit'],
52
+ title: rule.id,
53
+ description: '',
54
+ ...rule,
55
+ });
56
+ return rule.id;
57
+ }
58
+
59
+ export function clearSecurityRules() {
60
+ RULES.clear();
61
+ }
62
+
63
+ export function listSecurityRules() {
64
+ return [...RULES.values()];
65
+ }
66
+
67
+ export function getSecurityRule(id) {
68
+ return RULES.get(id) || null;
69
+ }
70
+
71
+ /**
72
+ * @param {{ mode?: SecurityMode, categories?: string[] }} [filter]
73
+ */
74
+ export function selectSecurityRules(filter = {}) {
75
+ const mode = filter.mode;
76
+ const cats = filter.categories?.length ? new Set(filter.categories) : null;
77
+ return listSecurityRules().filter((r) => {
78
+ if (cats && !cats.has(r.category)) return false;
79
+ if (!mode) return true;
80
+ const modes = r.modes || [];
81
+ if (mode === 'audit' || mode === 'doctor') return true;
82
+ if (mode === 'scan') return modes.includes('scan') || modes.includes('consumer') || modes.includes('secrets');
83
+ return modes.includes(mode) || modes.includes('audit');
84
+ });
85
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Security report builders (JSON / Markdown / Experience payload).
3
+ */
4
+ import { gateLabel } from '../score/score.js';
5
+ import { timelineRows } from '../timeline/timeline.js';
6
+
7
+ /**
8
+ * @param {object} result — engine result
9
+ */
10
+ export function toExperiencePayload(result) {
11
+ const { securityScore, scores, overall, dependencyHealth, releaseHealth, timeline, mode, findings } = result;
12
+ const items = [
13
+ { label: 'Security Score', value: String(securityScore) },
14
+ { label: 'Overall', value: overall },
15
+ { label: 'Mode', value: mode },
16
+ ];
17
+ if (dependencyHealth) {
18
+ items.push({ label: 'Dependency Health', value: `${dependencyHealth.score} %` });
19
+ }
20
+ if (releaseHealth) {
21
+ items.push({ label: 'Release Health', value: releaseHealth.status });
22
+ }
23
+
24
+ const tableRows = Object.entries(scores || {}).map(([k, v]) => [
25
+ v.label || k,
26
+ gateLabel(v.gate),
27
+ v.na ? 'n/a' : String(v.score),
28
+ ]);
29
+
30
+ const notifications = [];
31
+ if (overall === 'BLOCKED') {
32
+ notifications.push({ tone: 'danger', message: 'Security gate BLOCKED — fix errors before publish' });
33
+ } else if (overall === 'READY_WITH_WARNINGS') {
34
+ notifications.push({ tone: 'warning', message: 'Ready with warnings — review WARNING findings' });
35
+ } else {
36
+ notifications.push({ tone: 'success', message: 'Security Overall READY' });
37
+ }
38
+
39
+ const tl = timelineRows(timeline || { entries: {} });
40
+ if (tl.length) {
41
+ notifications.push({
42
+ tone: 'info',
43
+ message: `Timeline: ${tl.map((r) => `${r.title} ${r.date}`).join(' · ')}`,
44
+ });
45
+ }
46
+
47
+ return {
48
+ message: `Security Score ${securityScore} / 100 · ${overall}`,
49
+ items,
50
+ table: {
51
+ columns: ['Area', 'Gate', 'Score'],
52
+ rows: tableRows,
53
+ },
54
+ score: { value: securityScore, label: 'Security' },
55
+ findings: (findings || []).slice(0, 40).map((f) => ({
56
+ severity: f.severity,
57
+ message: f.message,
58
+ path: f.path,
59
+ rule: f.ruleId,
60
+ })),
61
+ notifications,
62
+ meta: {
63
+ dependencyHealth,
64
+ releaseHealth,
65
+ timeline: tl,
66
+ mode,
67
+ },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * @param {object} result
73
+ */
74
+ export function toMarkdownReport(result) {
75
+ const lines = [
76
+ '# Velin Security Report',
77
+ '',
78
+ `**Score:** ${result.securityScore} / 100`,
79
+ `**Overall:** ${result.overall}`,
80
+ `**Mode:** ${result.mode}`,
81
+ '',
82
+ '## Categories',
83
+ '',
84
+ ];
85
+ for (const [k, v] of Object.entries(result.scores || {})) {
86
+ const scorePart = v.na ? 'n/a' : String(v.score);
87
+ const note = v.note ? ` — ${v.note}` : '';
88
+ lines.push(`- **${v.label || k}:** ${gateLabel(v.gate)} (${scorePart})${note}`);
89
+ }
90
+ if (result.dependencyHealth) {
91
+ lines.push('', '## Dependency Health', '');
92
+ const dh = result.dependencyHealth;
93
+ lines.push(
94
+ `Score ${dh.score}% · deprecated ${dh.deprecated} · no-lockfile ${dh.noLockfile ?? 0} · outdated (heuristic) ${dh.outdated} · critical ${dh.critical}`,
95
+ );
96
+ if (dh.note) lines.push(`_${dh.note}_`);
97
+ }
98
+ if (result.releaseHealth) {
99
+ lines.push('', '## Release Health', '');
100
+ lines.push(`**${result.releaseHealth.status}** (score ${result.releaseHealth.score})`);
101
+ }
102
+ const tl = timelineRows(result.timeline || { entries: {} });
103
+ if (tl.length) {
104
+ lines.push('', '## Security Timeline', '');
105
+ for (const r of tl) lines.push(`- ${r.title}: ${r.date}`);
106
+ }
107
+ lines.push('', '## Findings', '');
108
+ for (const f of result.findings || []) {
109
+ lines.push(`- **${f.severity}** \`${f.ruleId}\`: ${f.message}${f.path ? ` (${f.path})` : ''}`);
110
+ }
111
+ lines.push('');
112
+ return lines.join('\n');
113
+ }
@@ -0,0 +1,146 @@
1
+ import { registerSecurityRule } from '../../registry.js';
2
+ import { fileExists, readText, walkFiles, rel, workflowHints, isShaPinned } from '../../checks/fs.js';
3
+ import { join } from 'path';
4
+
5
+ export function registerCiRules() {
6
+ registerSecurityRule({
7
+ id: 'workflow.present',
8
+ category: 'ci',
9
+ severity: 'info',
10
+ modes: ['repo', 'ci', 'audit'],
11
+ title: 'Workflows directory',
12
+ run(ctx) {
13
+ if (fileExists(join(ctx.root, '.github', 'workflows'))) return [];
14
+ return [{
15
+ ruleId: 'workflow.present',
16
+ category: 'ci',
17
+ severity: 'info',
18
+ message: 'No .github/workflows directory',
19
+ path: '.github/workflows',
20
+ }];
21
+ },
22
+ });
23
+
24
+ registerSecurityRule({
25
+ id: 'workflow.sha-pinning',
26
+ category: 'ci',
27
+ severity: 'warning',
28
+ modes: ['repo', 'ci', 'audit', 'publish'],
29
+ title: 'SHA pinning',
30
+ description: 'GitHub Actions should pin actions by commit SHA.',
31
+ run(ctx) {
32
+ const dir = join(ctx.root, '.github', 'workflows');
33
+ if (!fileExists(dir)) return [];
34
+ const files = walkFiles(dir, (n) => /\.ya?ml$/i.test(n), 40);
35
+ const findings = [];
36
+ for (const abs of files) {
37
+ const hints = workflowHints(readText(abs) || '');
38
+ for (const u of hints.uses) {
39
+ if (!isShaPinned(u) && /@(v?\d|main|master|latest)/i.test(u)) {
40
+ findings.push({
41
+ ruleId: 'workflow.sha-pinning',
42
+ category: 'ci',
43
+ severity: 'warning',
44
+ message: `Action not SHA-pinned: ${u}`,
45
+ path: rel(ctx.root, abs),
46
+ fix: 'Pin uses: owner/action@<40-char-sha>',
47
+ });
48
+ }
49
+ }
50
+ }
51
+ return findings;
52
+ },
53
+ });
54
+
55
+ registerSecurityRule({
56
+ id: 'workflow.pull-request-target',
57
+ category: 'ci',
58
+ severity: 'error',
59
+ modes: ['repo', 'ci', 'audit'],
60
+ title: 'pull_request_target',
61
+ run(ctx) {
62
+ const dir = join(ctx.root, '.github', 'workflows');
63
+ if (!fileExists(dir)) return [];
64
+ const files = walkFiles(dir, (n) => /\.ya?ml$/i.test(n), 40);
65
+ const findings = [];
66
+ for (const abs of files) {
67
+ const hints = workflowHints(readText(abs) || '');
68
+ if (hints.hasPullRequestTarget) {
69
+ findings.push({
70
+ ruleId: 'workflow.pull-request-target',
71
+ category: 'ci',
72
+ severity: 'error',
73
+ message: 'Workflow uses pull_request_target — high risk if checkout PR code',
74
+ path: rel(ctx.root, abs),
75
+ fix: 'Avoid untrusted code execution on pull_request_target',
76
+ });
77
+ }
78
+ }
79
+ return findings;
80
+ },
81
+ });
82
+
83
+ registerSecurityRule({
84
+ id: 'workflow.permissions',
85
+ category: 'ci',
86
+ severity: 'warning',
87
+ modes: ['repo', 'ci', 'audit'],
88
+ title: 'Explicit permissions',
89
+ run(ctx) {
90
+ const dir = join(ctx.root, '.github', 'workflows');
91
+ if (!fileExists(dir)) return [];
92
+ const files = walkFiles(dir, (n) => /\.ya?ml$/i.test(n), 40);
93
+ const findings = [];
94
+ for (const abs of files) {
95
+ const hints = workflowHints(readText(abs) || '');
96
+ if (!hints.hasPermissions) {
97
+ findings.push({
98
+ ruleId: 'workflow.permissions',
99
+ category: 'ci',
100
+ severity: 'warning',
101
+ message: 'Workflow missing top-level permissions: block',
102
+ path: rel(ctx.root, abs),
103
+ fix: 'Set least-privilege permissions',
104
+ });
105
+ }
106
+ }
107
+ return findings;
108
+ },
109
+ });
110
+
111
+ registerSecurityRule({
112
+ id: 'workflow.publish-token',
113
+ category: 'ci',
114
+ severity: 'info',
115
+ modes: ['repo', 'ci', 'audit', 'publish'],
116
+ title: 'Publish token / EOTP',
117
+ run(ctx) {
118
+ const pub = join(ctx.root, '.github', 'workflows', 'publish-npm.yml');
119
+ if (!fileExists(pub)) return [];
120
+ const text = readText(pub) || '';
121
+ const findings = [];
122
+ if (/NPM_TOKEN|NODE_AUTH_TOKEN/.test(text)) {
123
+ findings.push({
124
+ ruleId: 'workflow.publish-token',
125
+ category: 'ci',
126
+ severity: 'info',
127
+ message: 'Publish workflow uses NPM_TOKEN — use npm Automation token (no OTP/EOTP)',
128
+ path: '.github/workflows/publish-npm.yml',
129
+ fix: 'Granular Automation token with publish access; avoid classic 2FA tokens in CI',
130
+ });
131
+ }
132
+ const hints = workflowHints(text);
133
+ if (!hints.hasIdToken) {
134
+ findings.push({
135
+ ruleId: 'workflow.publish-provenance',
136
+ category: 'ci',
137
+ severity: 'warning',
138
+ message: 'Publish workflow may lack id-token: write for npm provenance',
139
+ path: '.github/workflows/publish-npm.yml',
140
+ fix: 'Add permissions.id-token: write for provenance',
141
+ });
142
+ }
143
+ return findings;
144
+ },
145
+ });
146
+ }