configorama 0.11.2 → 1.0.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 (52) hide show
  1. package/README.md +429 -123
  2. package/cli.js +282 -49
  3. package/index.d.ts +43 -1
  4. package/package.json +5 -1
  5. package/src/capabilities.js +59 -0
  6. package/src/capabilities.test.js +44 -0
  7. package/src/display.js +70 -7
  8. package/src/display.test.js +82 -0
  9. package/src/errors.js +73 -0
  10. package/src/index.js +91 -1
  11. package/src/main.js +117 -15
  12. package/src/resolvers/valueFromEval.js +11 -1
  13. package/src/resolvers/valueFromFile.js +5 -0
  14. package/src/resolvers/valueFromGit.js +43 -17
  15. package/src/resolvers/valueFromOptions.js +5 -4
  16. package/src/utils/filters/filterArgs.js +57 -0
  17. package/src/utils/filters/oneOf.js +77 -0
  18. package/src/utils/introspection/audit.js +78 -0
  19. package/src/utils/introspection/graph.js +43 -0
  20. package/src/utils/introspection/model.js +150 -0
  21. package/src/utils/introspection/model.test.js +93 -0
  22. package/src/utils/parsing/commentAnnotations.js +107 -0
  23. package/src/utils/parsing/commentAnnotations.test.js +123 -0
  24. package/src/utils/parsing/enrichMetadata.js +64 -1
  25. package/src/utils/parsing/enrichMetadata.test.js +84 -0
  26. package/src/utils/parsing/extractComment.js +145 -0
  27. package/src/utils/parsing/extractComment.test.js +182 -0
  28. package/src/utils/parsing/preProcess.js +2 -1
  29. package/src/utils/paths/findLineForKey.js +2 -2
  30. package/src/utils/paths/ignorePaths.js +21 -9
  31. package/src/utils/redaction/redact.js +78 -0
  32. package/src/utils/redaction/redact.test.js +38 -0
  33. package/src/utils/redaction/setupRedaction.js +47 -0
  34. package/src/utils/redaction/setupRedaction.test.js +68 -0
  35. package/src/utils/requirements/configRequirements.js +351 -0
  36. package/src/utils/requirements/configRequirements.test.js +380 -0
  37. package/src/utils/requirements/serializeRequirements.js +120 -0
  38. package/src/utils/requirements/serializeRequirements.test.js +211 -0
  39. package/src/utils/security/evalSafety.js +86 -0
  40. package/src/utils/security/evalSafety.test.js +61 -0
  41. package/src/utils/security/safetyPolicy.js +110 -0
  42. package/src/utils/security/safetyPolicy.test.js +29 -0
  43. package/src/utils/strings/didYouMean.js +70 -0
  44. package/src/utils/strings/didYouMean.test.js +52 -0
  45. package/src/utils/strings/formatFunctionArgs.js +6 -1
  46. package/src/utils/strings/splitByComma.js +5 -0
  47. package/src/utils/ui/configWizard.js +208 -34
  48. package/src/utils/ui/createEditorLink.js +17 -1
  49. package/src/utils/ui/promptDescriptors.js +196 -0
  50. package/src/utils/ui/promptDescriptors.test.js +162 -0
  51. package/src/utils/variables/cleanVariable.js +22 -0
  52. package/src/utils/variables/getVariableType.js +1 -0
@@ -0,0 +1,78 @@
1
+ const { severityForRisk } = require('./model')
2
+
3
+ function sortFindings(a, b) {
4
+ const severityOrder = { high: 0, medium: 1, low: 2, info: 3 }
5
+ const severityDiff = (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99)
6
+ if (severityDiff !== 0) return severityDiff
7
+ return String(a.id).localeCompare(String(b.id))
8
+ }
9
+
10
+ function buildAuditReport(introspection, options = {}) {
11
+ const findings = []
12
+
13
+ for (const node of introspection.nodes || []) {
14
+ if (!node.risk || node.risk === 'none') continue
15
+ findings.push({
16
+ id: node.id,
17
+ severity: node.severity || severityForRisk(node.risk),
18
+ risk: node.risk,
19
+ kind: node.kind,
20
+ variable: node.variable,
21
+ path: node.path,
22
+ relativePath: node.relativePath,
23
+ configPaths: node.paths || [],
24
+ message: messageForNode(node),
25
+ })
26
+ }
27
+
28
+ if (options.dotenv === true) {
29
+ findings.push({
30
+ id: 'dotenv:useDotenv',
31
+ severity: 'high',
32
+ risk: 'environment_mutation',
33
+ kind: 'source',
34
+ message: 'Configuration requests dotenv loading, which mutates process.env.',
35
+ })
36
+ }
37
+
38
+ if (options.customResolvers && options.customResolvers.length) {
39
+ for (const resolver of options.customResolvers.slice().sort()) {
40
+ findings.push({
41
+ id: `customResolver:${resolver}`,
42
+ severity: 'high',
43
+ risk: 'custom_extension',
44
+ kind: 'source',
45
+ variableType: resolver,
46
+ message: `Custom resolver "${resolver}" can execute user-provided code.`,
47
+ })
48
+ }
49
+ }
50
+
51
+ findings.sort(sortFindings)
52
+
53
+ return {
54
+ schemaVersion: 1,
55
+ safeMode: options.safeMode === true,
56
+ findings,
57
+ diagnostics: introspection.diagnostics || [],
58
+ summary: {
59
+ high: findings.filter(finding => finding.severity === 'high').length,
60
+ medium: findings.filter(finding => finding.severity === 'medium').length,
61
+ low: findings.filter(finding => finding.severity === 'low').length,
62
+ info: findings.filter(finding => finding.severity === 'info').length,
63
+ total: findings.length,
64
+ }
65
+ }
66
+ }
67
+
68
+ function messageForNode(node) {
69
+ if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
70
+ if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
71
+ if (node.risk === 'local_file_read') return 'Reference reads a local file.'
72
+ if (node.risk === 'data_flow_expression') return 'Expression can read resolved config values but is not JavaScript execution.'
73
+ return `Risk surface: ${node.risk}`
74
+ }
75
+
76
+ module.exports = {
77
+ buildAuditReport,
78
+ }
@@ -0,0 +1,43 @@
1
+ function quote(value) {
2
+ return JSON.stringify(String(value))
3
+ }
4
+
5
+ function toMermaid(graph) {
6
+ const lines = ['graph TD']
7
+ for (const node of graph.nodes) {
8
+ lines.push(` ${sanitizeId(node.id)}[${quote(node.label || node.variable || node.relativePath || node.id).slice(1, -1)}]`)
9
+ }
10
+ for (const edge of graph.edges) {
11
+ lines.push(` ${sanitizeId(edge.from)} -->|${edge.kind}| ${sanitizeId(edge.to)}`)
12
+ }
13
+ return lines.join('\n') + '\n'
14
+ }
15
+
16
+ function sanitizeId(value) {
17
+ return String(value).replace(/[^A-Za-z0-9_]/g, '_')
18
+ }
19
+
20
+ function toDot(graph) {
21
+ const lines = ['digraph configorama {']
22
+ for (const node of graph.nodes) {
23
+ lines.push(` ${quote(node.id)} [label=${quote(node.label || node.variable || node.relativePath || node.id)}];`)
24
+ }
25
+ for (const edge of graph.edges) {
26
+ lines.push(` ${quote(edge.from)} -> ${quote(edge.to)} [label=${quote(edge.kind)}];`)
27
+ }
28
+ lines.push('}')
29
+ return lines.join('\n') + '\n'
30
+ }
31
+
32
+ function formatGraph(graph, format = 'json') {
33
+ const normalized = String(format || 'json').toLowerCase()
34
+ if (normalized === 'mermaid' || normalized === 'mmd') return toMermaid(graph)
35
+ if (normalized === 'dot' || normalized === 'graphviz') return toDot(graph)
36
+ return JSON.stringify(graph, null, 2)
37
+ }
38
+
39
+ module.exports = {
40
+ formatGraph,
41
+ toDot,
42
+ toMermaid,
43
+ }
@@ -0,0 +1,150 @@
1
+ const path = require('path')
2
+ const { EXECUTABLE_EXTENSIONS } = require('../security/safetyPolicy')
3
+ const { redactRequirementValue } = require('../redaction/redact')
4
+
5
+ const SCHEMA_VERSION = 1
6
+
7
+ function sortBy(keys) {
8
+ return (a, b) => {
9
+ for (const key of keys) {
10
+ const av = a[key] === undefined || a[key] === null ? '' : String(a[key])
11
+ const bv = b[key] === undefined || b[key] === null ? '' : String(b[key])
12
+ if (av < bv) return -1
13
+ if (av > bv) return 1
14
+ }
15
+ return 0
16
+ }
17
+ }
18
+
19
+ function normalizeVariableType(type) {
20
+ if (type === 'options' || type === 'opt') return 'option'
21
+ if (type === 'dot.prop') return 'dotProp'
22
+ if (type === 'if') return 'eval'
23
+ return type || 'unknown'
24
+ }
25
+
26
+ function riskForVariable(variableType, variable) {
27
+ const type = normalizeVariableType(variableType)
28
+ if (type === 'eval') return 'data_flow_expression'
29
+ if (type === 'git') return 'process_spawn'
30
+ if (type === 'file' || type === 'text') {
31
+ const match = String(variable || '').match(/^(?:file|text)\((.+?)\)/)
32
+ const ext = match ? path.extname(match[1]).toLowerCase() : ''
33
+ return EXECUTABLE_EXTENSIONS.has(ext) ? 'executable_code' : 'local_file_read'
34
+ }
35
+ return 'none'
36
+ }
37
+
38
+ function severityForRisk(risk) {
39
+ if (risk === 'executable_code' || risk === 'custom_extension' || risk === 'environment_mutation') return 'high'
40
+ if (risk === 'process_spawn') return 'medium'
41
+ if (risk === 'local_file_read' || risk === 'data_flow_expression') return 'low'
42
+ return 'info'
43
+ }
44
+
45
+ function buildIntrospection(enrichedMetadata = {}, options = {}) {
46
+ const uniqueVariables = enrichedMetadata.uniqueVariables || {}
47
+ const requirements = options.requirements || []
48
+ const requirementsByVariable = new Map(requirements.map(req => [req.variable, req]))
49
+ const nodes = []
50
+ const edges = []
51
+ const diagnostics = []
52
+
53
+ for (const [key, entry] of Object.entries(uniqueVariables).sort(([a], [b]) => a.localeCompare(b))) {
54
+ const variable = entry.variable || key
55
+ const variableType = normalizeVariableType(entry.variableType)
56
+ const requirement = requirementsByVariable.get(variable)
57
+ const risk = riskForVariable(variableType, variable)
58
+ const node = {
59
+ id: `variable:${variable}`,
60
+ kind: variableType === 'file' || variableType === 'text'
61
+ ? 'file'
62
+ : (risk === 'executable_code' ? 'executable' : 'variable'),
63
+ variable,
64
+ variableType,
65
+ sourceClass: entry.variableSourceType || entry.sourceClass || requirement?.sourceClass || null,
66
+ risk,
67
+ severity: severityForRisk(risk),
68
+ paths: [...new Set((entry.occurrences || []).map(occ => occ.path).filter(Boolean))].sort(),
69
+ sensitive: requirement ? requirement.sensitive === true : false,
70
+ }
71
+ if (requirement) {
72
+ node.required = requirement.required
73
+ node.default = redactRequirementValue(requirement, requirement.default)
74
+ node.description = requirement.description
75
+ }
76
+ nodes.push(node)
77
+
78
+ for (const configPath of node.paths) {
79
+ edges.push({
80
+ from: `configPath:${configPath}`,
81
+ to: node.id,
82
+ kind: 'uses',
83
+ })
84
+ }
85
+
86
+ for (const inner of entry.innerVariables || []) {
87
+ edges.push({
88
+ from: node.id,
89
+ to: `variable:${inner.variable}`,
90
+ kind: 'depends_on',
91
+ })
92
+ }
93
+
94
+ if ((variableType === 'file' || variableType === 'text') && String(variable).includes('${')) {
95
+ diagnostics.push({
96
+ code: 'dynamic_file_target',
97
+ severity: 'info',
98
+ variable,
99
+ message: 'File target contains variables; static introspection records a partial edge.',
100
+ })
101
+ }
102
+ }
103
+
104
+ const fileDeps = enrichedMetadata.fileDependencies || {}
105
+ for (const dep of fileDeps.byConfigPath || []) {
106
+ const id = `file:${dep.relativePath || dep.filePath}`
107
+ if (!nodes.some(node => node.id === id)) {
108
+ nodes.push({
109
+ id,
110
+ kind: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable' : 'file',
111
+ path: dep.filePath,
112
+ relativePath: dep.relativePath,
113
+ exists: dep.exists,
114
+ risk: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable_code' : 'local_file_read',
115
+ severity: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'high' : 'low',
116
+ })
117
+ }
118
+ if (dep.location) {
119
+ edges.push({
120
+ from: `configPath:${dep.location}`,
121
+ to: id,
122
+ kind: 'reads',
123
+ })
124
+ }
125
+ }
126
+
127
+ nodes.sort(sortBy(['kind', 'id']))
128
+ edges.sort(sortBy(['from', 'kind', 'to']))
129
+ diagnostics.sort(sortBy(['code', 'variable']))
130
+
131
+ return {
132
+ schemaVersion: SCHEMA_VERSION,
133
+ nodes,
134
+ edges,
135
+ diagnostics,
136
+ summary: {
137
+ nodes: nodes.length,
138
+ edges: edges.length,
139
+ diagnostics: diagnostics.length,
140
+ }
141
+ }
142
+ }
143
+
144
+ module.exports = {
145
+ SCHEMA_VERSION,
146
+ buildIntrospection,
147
+ normalizeVariableType,
148
+ riskForVariable,
149
+ severityForRisk,
150
+ }
@@ -0,0 +1,93 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const { buildIntrospection, riskForVariable } = require('./model')
4
+ const { buildAuditReport } = require('./audit')
5
+ const { formatGraph } = require('./graph')
6
+
7
+ test('riskForVariable classifies eval as data-flow and js file refs as executable', () => {
8
+ assert.is(riskForVariable('eval', 'eval(1 + 1)'), 'data_flow_expression')
9
+ assert.is(riskForVariable('if', 'if(true)'), 'data_flow_expression')
10
+ assert.is(riskForVariable('file', 'file(./config.js)'), 'executable_code')
11
+ assert.is(riskForVariable('file', 'file(./config.yml)'), 'local_file_read')
12
+ })
13
+
14
+ test('buildIntrospection creates deterministic nodes edges and dynamic diagnostics', () => {
15
+ const graph = buildIntrospection({
16
+ uniqueVariables: {
17
+ 'file(./${opt:stage}.yml)': {
18
+ variable: 'file(./${opt:stage}.yml)',
19
+ variableType: 'file',
20
+ variableSourceType: 'config',
21
+ occurrences: [{ path: 'database' }],
22
+ innerVariables: [{ variable: 'opt:stage', variableType: 'options' }]
23
+ },
24
+ 'opt:stage': {
25
+ variable: 'opt:stage',
26
+ variableType: 'options',
27
+ variableSourceType: 'user',
28
+ occurrences: [{ path: 'stage' }]
29
+ }
30
+ },
31
+ fileDependencies: {
32
+ byConfigPath: []
33
+ }
34
+ }, {
35
+ requirements: [{ variable: 'opt:stage', sensitive: false, required: true, default: null }]
36
+ })
37
+
38
+ assert.is(graph.schemaVersion, 1)
39
+ assert.ok(graph.nodes.some(node => node.id === 'variable:file(./${opt:stage}.yml)'))
40
+ assert.ok(graph.edges.some(edge => edge.kind === 'depends_on'))
41
+ assert.is(graph.diagnostics[0].code, 'dynamic_file_target')
42
+ })
43
+
44
+ test('buildIntrospection redacts sensitive requirement defaults', () => {
45
+ const graph = buildIntrospection({
46
+ uniqueVariables: {
47
+ 'env:API_KEY': {
48
+ variable: 'env:API_KEY',
49
+ variableType: 'env',
50
+ variableSourceType: 'user',
51
+ occurrences: [{ path: 'apiKey' }]
52
+ }
53
+ },
54
+ fileDependencies: { byConfigPath: [] }
55
+ }, {
56
+ requirements: [{
57
+ variable: 'env:API_KEY',
58
+ sensitive: true,
59
+ required: false,
60
+ default: 'secret-value',
61
+ description: 'API key'
62
+ }]
63
+ })
64
+
65
+ const node = graph.nodes.find(item => item.variable === 'env:API_KEY')
66
+ assert.is(node.sensitive, true)
67
+ assert.is(node.default, '********')
68
+ })
69
+
70
+ test('audit report sorts executable findings before lower severity surfaces', () => {
71
+ const report = buildAuditReport({
72
+ nodes: [
73
+ { id: 'variable:eval(1)', risk: 'data_flow_expression', severity: 'low', kind: 'variable' },
74
+ { id: 'variable:file(./x.js)', risk: 'executable_code', severity: 'high', kind: 'executable' }
75
+ ],
76
+ diagnostics: []
77
+ })
78
+
79
+ assert.is(report.summary.total, 2)
80
+ assert.is(report.findings[0].severity, 'high')
81
+ })
82
+
83
+ test('formatGraph emits mermaid and dot formats', () => {
84
+ const graph = {
85
+ nodes: [{ id: 'configPath:stage', label: 'stage' }, { id: 'variable:opt:stage', variable: 'opt:stage' }],
86
+ edges: [{ from: 'configPath:stage', to: 'variable:opt:stage', kind: 'uses' }]
87
+ }
88
+
89
+ assert.match(formatGraph(graph, 'mermaid'), /graph TD/)
90
+ assert.match(formatGraph(graph, 'dot'), /digraph configorama/)
91
+ })
92
+
93
+ test.run()
@@ -0,0 +1,107 @@
1
+ const KNOWN_TAGS = new Set([
2
+ 'description',
3
+ 'from',
4
+ 'example',
5
+ 'default',
6
+ 'sensitive',
7
+ 'group',
8
+ 'deprecated',
9
+ ])
10
+
11
+ function toLines(input) {
12
+ if (Array.isArray(input)) return input
13
+ if (input === undefined || input === null) return []
14
+ return String(input).split(/\r?\n/)
15
+ }
16
+
17
+ function firstNonEmpty(values) {
18
+ return (values || []).find(value => value !== undefined && value !== null && String(value).trim() !== '')
19
+ }
20
+
21
+ function uniqueNonEmpty(values) {
22
+ const seen = new Set()
23
+ const result = []
24
+ for (const value of values || []) {
25
+ if (value === undefined || value === null) continue
26
+ const normalized = String(value).trim()
27
+ if (!normalized || seen.has(normalized)) continue
28
+ seen.add(normalized)
29
+ result.push(normalized)
30
+ }
31
+ return result
32
+ }
33
+
34
+ function parseSensitive(value) {
35
+ if (value === undefined || value === null) return undefined
36
+ const normalized = String(value).trim().toLowerCase()
37
+ if (normalized === 'true') return true
38
+ if (normalized === 'false') return false
39
+ return undefined
40
+ }
41
+
42
+ function normalizeAnnotations(tags, plainText) {
43
+ const descriptionFromTag = uniqueNonEmpty(tags.description).join(' ')
44
+ const obtainHint = firstNonEmpty(tags.from)
45
+ const examples = uniqueNonEmpty(tags.example)
46
+ const defaultHint = firstNonEmpty(tags.default)
47
+ const sensitive = parseSensitive(firstNonEmpty(tags.sensitive))
48
+ const group = firstNonEmpty(tags.group)
49
+ const deprecationMessage = firstNonEmpty(tags.deprecated)
50
+
51
+ const annotations = {}
52
+ if (descriptionFromTag) annotations.description = descriptionFromTag
53
+ if (obtainHint) annotations.obtainHint = String(obtainHint).trim()
54
+ if (examples.length) annotations.examples = examples
55
+ if (defaultHint) annotations.defaultHint = String(defaultHint).trim()
56
+ if (sensitive !== undefined) annotations.sensitive = sensitive
57
+ if (group) annotations.group = String(group).trim()
58
+ if (deprecationMessage) annotations.deprecationMessage = String(deprecationMessage).trim()
59
+
60
+ return {
61
+ annotations,
62
+ description: annotations.description || plainText || null,
63
+ descriptionSource: annotations.description ? 'commentTag' : null,
64
+ }
65
+ }
66
+
67
+ function parseCommentAnnotations(input) {
68
+ const tags = {}
69
+ const plainLines = []
70
+
71
+ for (const line of toLines(input)) {
72
+ const text = String(line || '').trim()
73
+ if (!text) continue
74
+
75
+ const match = text.match(/^@([a-z][a-z0-9_-]*)\b([\s\S]*)$/)
76
+ if (!match) {
77
+ plainLines.push(text)
78
+ continue
79
+ }
80
+
81
+ const tagName = match[1]
82
+ if (!KNOWN_TAGS.has(tagName)) {
83
+ plainLines.push(text)
84
+ continue
85
+ }
86
+
87
+ if (!tags[tagName]) tags[tagName] = []
88
+ tags[tagName].push(match[2].replace(/^\s/, ''))
89
+ }
90
+
91
+ const plainText = plainLines.join(' ').trim() || null
92
+ const normalized = normalizeAnnotations(tags, plainText)
93
+
94
+ return {
95
+ plainText,
96
+ tags,
97
+ annotations: normalized.annotations,
98
+ description: normalized.description,
99
+ descriptionSource: normalized.descriptionSource,
100
+ }
101
+ }
102
+
103
+ module.exports = {
104
+ KNOWN_TAGS,
105
+ parseCommentAnnotations,
106
+ parseSensitive,
107
+ }
@@ -0,0 +1,123 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { parseCommentAnnotations, parseSensitive } = require('./commentAnnotations')
6
+
7
+ test('parseCommentAnnotations parses supported tags into normalized annotation fields', () => {
8
+ const result = parseCommentAnnotations([
9
+ '@description Stripe live secret key',
10
+ '@from Stripe dashboard > Developers > API keys',
11
+ '@example sk_live_...',
12
+ '@default Set this in CI',
13
+ '@sensitive true',
14
+ '@group Payments',
15
+ '@deprecated Use STRIPE_RESTRICTED_KEY instead',
16
+ ])
17
+
18
+ assert.equal(result.tags, {
19
+ description: ['Stripe live secret key'],
20
+ from: ['Stripe dashboard > Developers > API keys'],
21
+ example: ['sk_live_...'],
22
+ default: ['Set this in CI'],
23
+ sensitive: ['true'],
24
+ group: ['Payments'],
25
+ deprecated: ['Use STRIPE_RESTRICTED_KEY instead'],
26
+ })
27
+ assert.equal(result.annotations, {
28
+ description: 'Stripe live secret key',
29
+ obtainHint: 'Stripe dashboard > Developers > API keys',
30
+ examples: ['sk_live_...'],
31
+ defaultHint: 'Set this in CI',
32
+ sensitive: true,
33
+ group: 'Payments',
34
+ deprecationMessage: 'Use STRIPE_RESTRICTED_KEY instead',
35
+ })
36
+ assert.is(result.description, 'Stripe live secret key')
37
+ assert.is(result.descriptionSource, 'commentTag')
38
+ })
39
+
40
+ test('parseCommentAnnotations keeps plain text separate from tag lines', () => {
41
+ const result = parseCommentAnnotations([
42
+ 'Stripe live secret key',
43
+ '@from Stripe dashboard > Developers > API keys',
44
+ ])
45
+
46
+ assert.is(result.plainText, 'Stripe live secret key')
47
+ assert.is(result.description, 'Stripe live secret key')
48
+ assert.is(result.descriptionSource, null)
49
+ assert.equal(result.annotations, {
50
+ obtainHint: 'Stripe dashboard > Developers > API keys',
51
+ })
52
+ })
53
+
54
+ test('parseCommentAnnotations treats unknown tag-shaped lines as plain description text', () => {
55
+ const result = parseCommentAnnotations([
56
+ '@david rotate this key after launch',
57
+ 'ping @ops before rotating this key',
58
+ ])
59
+
60
+ assert.is(result.plainText, '@david rotate this key after launch ping @ops before rotating this key')
61
+ assert.is(result.description, '@david rotate this key after launch ping @ops before rotating this key')
62
+ assert.equal(result.tags, {})
63
+ assert.equal(result.annotations, {})
64
+ })
65
+
66
+ test('parseCommentAnnotations preserves empty tag values in raw tags and omits normalized empty fields', () => {
67
+ const result = parseCommentAnnotations([
68
+ '@from',
69
+ '@default ',
70
+ '@group Platform',
71
+ ])
72
+
73
+ assert.equal(result.tags, {
74
+ from: [''],
75
+ default: [''],
76
+ group: ['Platform'],
77
+ })
78
+ assert.equal(result.annotations, {
79
+ group: 'Platform',
80
+ })
81
+ })
82
+
83
+ test('parseCommentAnnotations joins repeated descriptions and dedupes examples', () => {
84
+ const result = parseCommentAnnotations([
85
+ '@description Stripe live',
86
+ '@description secret key',
87
+ '@example sk_live_...',
88
+ '@example sk_live_...',
89
+ '@example stripe-key',
90
+ ])
91
+
92
+ assert.is(result.annotations.description, 'Stripe live secret key')
93
+ assert.equal(result.annotations.examples, ['sk_live_...', 'stripe-key'])
94
+ })
95
+
96
+ test('parseSensitive accepts only true and false case-insensitively', () => {
97
+ assert.is(parseSensitive('true'), true)
98
+ assert.is(parseSensitive('TRUE'), true)
99
+ assert.is(parseSensitive('false'), false)
100
+ assert.is(parseSensitive('False'), false)
101
+ assert.is(parseSensitive('yes'), undefined)
102
+ assert.is(parseSensitive('1'), undefined)
103
+ assert.is(parseSensitive(''), undefined)
104
+ })
105
+
106
+ test('parseCommentAnnotations ignores invalid sensitive values so heuristics can apply later', () => {
107
+ const result = parseCommentAnnotations([
108
+ '@sensitive yes',
109
+ '@description API token',
110
+ ])
111
+
112
+ assert.equal(result.tags.sensitive, ['yes'])
113
+ assert.equal(result.annotations, {
114
+ description: 'API token',
115
+ })
116
+ })
117
+
118
+ test('comment annotation parser does not require jsdoc-parser', () => {
119
+ const source = fs.readFileSync(path.join(__dirname, 'commentAnnotations.js'), 'utf8')
120
+ assert.not.match(source, /jsdoc-parser|doxxx|jsdoctypeparser/)
121
+ })
122
+
123
+ test.run()