dim-toolkit 2.1.1 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/dim/loader.rb CHANGED
@@ -1,581 +1,561 @@
1
- require 'yaml'
2
- require 'pathname'
3
-
4
- require_relative 'encoding'
5
- require_relative 'globals'
6
- require_relative 'ext/psych'
7
- require_relative 'requirement'
8
- require_relative 'consistency'
9
- require_relative 'exit_helper'
10
- require_relative 'helpers/attribute_helper'
11
- require_relative 'helpers/file_helper'
12
-
13
- using Dim::Refinements
14
-
15
- module Dim
16
- class Loader
17
- include Helpers::AttributeHelper
18
-
19
- attr_reader :requirements, :config, :property_table, :properties, :original_data, :module_data, :metadata, :dim_file, :all_attributes, :custom_attributes
20
-
21
- # YAML standard:
22
- # invalid C0 control characters: 0x00 - 0x1F (except TAB 0x09, LF 0x0A and CR 0x0D)
23
- # invalid control character DEL 0x7F
24
- # invalid C1 control characters: 0x80 - 0x9F (except NEL 0x85)
25
- #
26
- # in addition NEL will be also replaced which seems to be misused in CRS documents
27
- @@invalid_ccs = {
28
- "\x00" => '[NUL]', "\x01" => '[SOH]', "\x02" => '[STX]', "\x03" => '[ETX]',
29
- "\x04" => '[EOT]', "\x05" => '[ENQ]', "\x06" => '[ACK]', "\x07" => '[BEL]',
30
- "\x08" => '[BS]', "\x0B" => '[VT]',
31
- "\x0C" => '[FF]', "\x0E" => '[SO]', "\x0F" => '[SI]',
32
- "\x10" => '[DLE]', "\x11" => '[DC1]', "\x12" => '[DC2]', "\x13" => '[DC3]',
33
- "\x14" => '[DC4]', "\x15" => '[NAK]', "\x16" => '[SYN]', "\x17" => '[ETB]',
34
- "\x18" => '[CAN]', "\x19" => '[EM]', "\x1A" => '[SUB]', "\x1B" => '[ESC]',
35
- "\x1C" => '[FS]', "\x1D" => '[GS]', "\x1E" => '[RS]', "\x1F" => '[US]',
36
- "\x7F" => '[DEL]',
37
- "\u0080" => '[PAD]', "\u0081" => '[HOP]', "\u0082" => '[BPH]', "\u0083" => '[NBH]',
38
- "\u0084" => '[IND]', "\u0085" => '[NEL]', "\u0086" => '[SSA]', "\u0087" => '[ESA]',
39
- "\u0088" => '[HTS]', "\u0089" => '[HTJ]', "\u008A" => '[VTS]', "\u008B" => '[PLD]',
40
- "\u008C" => '[PLU]', "\u008D" => '[RI]', "\u008E" => '[SS2]', "\u008F" => '[SS3]',
41
- "\u0090" => '[DCS]', "\u0091" => '[PU1]', "\u0092" => '[PU2]', "\u0093" => '[STS]',
42
- "\u0094" => '[CCH]', "\u0095" => '[MW]', "\u0096" => '[SPA]', "\u0097" => '[EPA]',
43
- "\u0098" => '[SOS]', "\u0099" => '[SGCI]', "\u009A" => '[SCI]', "\u009B" => '[CSI]',
44
- "\u009C" => '[ST]', "\u009D" => '[OSC]', "\u009E" => '[PM]', "\u009F" => '[APC]'
45
- }
46
-
47
- def initialize
48
- @requirements = {}
49
- @module_data = {}
50
- @metadata = {}
51
- @config = {}
52
- @properties = {}
53
- @property_table = {}
54
- @original_data = {}
55
- @all_attributes = Requirement::SYNTAX.dup
56
- @custom_attributes = {}
57
- end
58
-
59
- def filter(str)
60
- @requirements.keep_if { |_id, r| r.filter(str) }
61
- end
62
-
63
- def load(file: nil, attributes_file: nil, allow_missing: false, no_check_enclosed: false, silent: true, input_filenames: [])
64
- ::Psych::Nodes::Scalar.add_patch
65
- ::Psych::Visitors::ToRuby.add_patch
66
-
67
- input_filenames = *input_filenames
68
- # If output format is vim then we do not need to read the file,
69
- # content will be read from the stdin and printed out to stdout. This is to enhance the
70
- # formatting in the vim.
71
- unless OPTIONS[:output_format] == 'stdout'
72
- if input_filenames.length > 0 and file
73
- Dim::ExitHelper.exit(code: 1, msg: 'use either file or input_filenames argument (deprecated) for load method')
74
- end
75
- if input_filenames.length > 1
76
- Dim::ExitHelper.exit(code: 1,
77
- msg: 'input_filenames argument (deprecated) of load method must have at maximum one entry')
78
- end
79
- file = input_filenames[0] if input_filenames.length == 1
80
- unless file
81
- Dim::ExitHelper.exit(code: 1,
82
- msg: 'neither file nor input_filenames argument (deprecated) argument specified for load method')
83
- end
84
- end
85
-
86
- unless silent
87
- if allow_missing && OPTIONS[:subcommand] != 'format'
88
- puts "Warning: Using 'allow-missing' might influence metrics when some references are ignored!\n"
89
- end
90
- puts 'Start Loading...'
91
- end
92
-
93
- Dim::ExitHelper.exit(code: 1, filename: file, msg: 'does not exist') unless File.exist?(file.to_s) || OPTIONS[:output_format] == 'stdout'
94
-
95
- @dim_file = OPTIONS[:output_format] == 'stdout' ? $stdin.read.chomp : File.binread(file).chomp
96
-
97
- if attributes_file
98
- fetch_attributes!(folder: File.dirname(attributes_file), filename: attributes_file.split('/').last, silent: silent)
99
- elsif !dim_file.match(/^Config:/)
100
- folder = search_attributes_file(file.to_s)
101
- fetch_attributes!(folder: folder, filename: 'attributes.dim', silent: silent) if folder
102
- end
103
-
104
- if dim_file.match(/^Config:/)
105
- load_config(config_filename: file.to_s, silent: silent)
106
- else
107
- load_pattern(
108
- config_filename: nil,
109
- pattern: file.to_s,
110
- origin: '',
111
- silent: silent,
112
- category: 'unspecified',
113
- disable_naming_convention_check: false,
114
- no_check_enclosed: no_check_enclosed
115
- )
116
- end
117
-
118
- puts 'Checking consistency...' unless silent
119
- checker = Dim::Consistency.new(self)
120
- checker.check(allow_missing: allow_missing)
121
- puts 'Done.' unless silent
122
- ensure
123
- ::Psych::Nodes::Scalar.revert_patch
124
- ::Psych::Visitors::ToRuby.revert_patch
125
- end
126
-
127
- def load_config(config_filename:, silent: true, no_check_enclosed: false)
128
- puts "Loading [config] #{config_filename}..." unless silent
129
- @config = open_yml_file(config_filename, '')
130
-
131
- allowed_attributes = %w[Config Properties Attributes]
132
-
133
- @config.each_key do |k|
134
- next unless allowed_attributes.none? { |a| a == k }
135
-
136
- Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "top level key in config file must be #{allowed_attributes.map do |a|
137
- "\"#{a}\""
138
- end.join(', ')}, found \"#{k}\"")
139
- end
140
-
141
- if @config.key?('Attributes')
142
- if @config['Attributes'].is_a?(String)
143
- fetch_attributes!(folder: File.dirname(config_filename), filename: @config['Attributes'], silent: silent)
144
- else
145
- Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "'Attributes' must be a string")
146
- end
147
- end
148
-
149
- if @config.key?('Properties')
150
- if @config['Properties'].is_a?(String)
151
- resolve_properties(folder: File.dirname(config_filename), properties_filename: @config['Properties'])
152
- else
153
- Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "'Properties' must be a string")
154
- end
155
- end
156
-
157
- # COP: Move to constants
158
- allowed_keys = %w[files category originator disable_naming_convention_check]
159
- allowed_categories = ALLOWED_CATEGORIES.values
160
-
161
- if @config['Config'].is_a?(Array)
162
- config_values = @config['Config']
163
- else
164
- Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "'Config' must be an array")
165
- end
166
-
167
- config_values.each do |value|
168
- validate_and_load_config(value, config_filename)
169
-
170
- unless value.is_a?(Hash) && value.keys.sort_by(&:length).eql?(allowed_keys.sort_by(&:length))
171
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
172
- msg: "each hash in 'Config' array must have key/value pairs for #{allowed_keys.join(', ')}.")
173
- end
174
-
175
- if value['category'].is_a?(String)
176
- value['category'].strip!
177
- unless allowed_categories.include?(value['category'])
178
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
179
- msg: "attribute \"category\" of '#{value['originator']}' reqs is '#{value['category']}' but must be one of #{allowed_categories.join(', ')}.")
180
- end
181
- else
182
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
183
- msg: "attribute \"category\" of '#{value['originator']}' reqs must be a string")
184
- end
185
-
186
- if value['files'].is_a?(Array) && value['files'].all? { |a| a.is_a?(String) } || value['files'].is_a?(String)
187
- value['files'] = *value['files']
188
- else
189
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
190
- msg: "attribute \"files\" of '#{value['originator']}' reqs must be a string or an array of strings.")
191
- end
192
- value['files'].each do |pattern|
193
- pattern.gsub!(%r{\A\./}, '')
194
- if pattern.empty?
195
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
196
- msg: "attribute \"files\" of '#{value['originator']}' must not have an empty string")
197
- end
198
- p = Pathname.new(pattern)
199
- if p.absolute?
200
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
201
- msg: "'#{pattern}' must not be an absolute path")
202
- end
203
- if p.each_filename.any? { |e| e == '..' }
204
- Dim::ExitHelper.exit(code: 1, filename: config_filename,
205
- msg: "'#{pattern}' must not include '..'")
206
- end
207
- load_pattern(
208
- config_filename: config_filename,
209
- pattern: pattern,
210
- origin: value['originator'],
211
- silent: silent,
212
- category: value['category'],
213
- disable_naming_convention_check: value['disable_naming_convention_check'],
214
- no_check_enclosed: no_check_enclosed
215
- )
216
- end
217
- end
218
- puts 'Done.' unless silent
219
- end
220
-
221
- def extract_type(attr)
222
- return {} if attr.empty?
223
-
224
- hscan = attr.scan(/\Ah(\d+)\s.+/)
225
- if hscan.length == 1
226
- level = hscan[0][0].to_i
227
- return { 'type' => "heading_#{level}", 'text' => attr[2 + hscan[0][0].length..-1].strip }
228
- else
229
- iscan = attr.scan(/\Ainfo\s.+/)
230
- return { 'type' => 'information', 'text' => attr[5..-1].strip } if iscan.length == 1
231
- end
232
- nil
233
- end
234
-
235
- def load_file(filename:, origin:, silent:, category:, disable_naming_convention_check: false, no_check_enclosed: false)
236
- puts "Loading [#{origin.empty? ? "unknown" : origin}] #{filename}..." unless silent
237
- binary_data = OPTIONS[:output_format] == 'stdout' ? @dim_file : File.binread(filename).force_encoding('UTF-8')
238
-
239
- # this looks expensive but measurement showed it's close to zero
240
- @@invalid_ccs.each { |k, v| binary_data = binary_data.gsub(k, v) }
241
-
242
- if binary_data.valid_encoding?
243
- begin
244
- psych_doc = YAML.parse(binary_data)
245
- rescue Psych::SyntaxError => e
246
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: e.message)
247
- end
248
- else
249
- begin
250
- psych_doc = YAML.parse(binary_data.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?'),
251
- filename: filename)
252
- rescue Psych::SyntaxError => e
253
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: e.message)
254
- end
255
- end
256
- unless psych_doc
257
- puts "Warning: empty file detected; skipped loading of #{filename}"
258
-
259
- return
260
- end
261
- reqs = psych_doc.to_ruby
262
-
263
- unless reqs.is_a?(Hash)
264
- Dim::ExitHelper.exit(code: 1, filename: filename,
265
- msg: 'top level must be a hash with keys "module", "enclosed", "metadata" and/or unique ids'
266
- )
267
- end
268
-
269
- # TODO: Remove module backward compatibility in future version
270
- if reqs.key?('document') && reqs.key?('module')
271
- Dim::ExitHelper.exit(
272
- code: 1,
273
- filename: filename,
274
- msg: 'module and document found in the file; please rename module to document'
275
- )
276
- end
277
-
278
- if reqs.key?('document')
279
- if !reqs['document'].is_a?(String) || reqs['document'].empty?
280
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'document must be a non-empty string')
281
- end
282
- document = reqs['document']
283
- # TODO: Remove module backward compatibility in future version
284
- reqs['module'] = document
285
- elsif reqs.key?('module')
286
- if !(reqs['module'].is_a? String) || reqs['module'].empty?
287
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'module name must be a non-empty string')
288
- end
289
- document = reqs['module']
290
- reqs['document'] = document
291
- else
292
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'Document name is missing; please add document name')
293
- end
294
-
295
- validate_srs_name(document, disable_naming_convention_check, category, filename)
296
-
297
- if @module_data.key?(document)
298
- if @module_data[document][:origin] != origin
299
- Dim::ExitHelper.exit(code: 1, filename: filename, msg:
300
- "files of the same module must have the same owner:\n" +
301
- "- #{@module_data[document][:files].first[0]} (#{@module_data[document][:origin]})\n" +
302
- "- #{filename} (#{origin})")
303
- elsif @module_data[document][:category] != category
304
- Dim::ExitHelper.exit(code: 1, filename: filename, msg:
305
- "files of the same module must have the same category:\n" +
306
- "- #{@module_data[document][:files].first[0]} (#{@module_data[document][:category]})\n" +
307
- "- #{filename} (#{category})")
308
- end
309
- else
310
- @module_data[document] = { origin: origin, category: category, files: {} }
311
- end
312
-
313
- if @module_data[document][:files].key?(filename)
314
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: "file #{filename} already loaded")
315
- end
316
-
317
- @module_data[document][:files][filename] = []
318
- @metadata[document] ||= ''
319
-
320
- if reqs.key?('metadata')
321
- unless reqs['metadata'].is_a?(String)
322
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'metadata must be a string')
323
- end
324
- unless reqs['metadata'].empty?
325
- unless @metadata[document].empty?
326
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'only one metadata per module allowed')
327
- end
328
- @metadata[document] = reqs['metadata'].strip
329
- end
330
- end
331
-
332
- if reqs.key?('enclosed') && !no_check_enclosed
333
- ecl = reqs['enclosed']
334
- ecl = [ecl] if ecl.is_a?(String)
335
- if !ecl.is_a?(Array) || ecl.empty? || ecl.any? { |s| !s.is_a?(String) || s.empty? }
336
- Dim::ExitHelper.exit(code: 1, filename: filename,
337
- msg: '"enclosed" must be a non-empty string or an array of non-empty strings')
338
- end
339
- # Remove superfluous ./ from path
340
- ecl = ecl.map do |path|
341
- if path.match?(/\\/)
342
- puts "Warning: Backward slashes detected in filepath #{path}. Use '/' over '\\' in filepath"
343
- path.gsub!('\\', '/')
344
- end
345
- Pathname.new(path).cleanpath.to_s
346
- end
347
- dir_file = File.dirname(filename)
348
- ecl.each do |l|
349
- p = Pathname.new(l)
350
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: "'#{l}' must not be an absolute path") if p.absolute?
351
- if p.each_filename.any? do |name|
352
- name == '..'
353
- end
354
- Dim::ExitHelper.exit(code: 1, filename: filename,
355
- msg: "'#{l}' must not include '..'")
356
- end
357
-
358
- src = File.join(dir_file, l)
359
- src_globbed = Dir.glob(src)
360
- if src_globbed.empty?
361
- Dim::ExitHelper.exit(code: 1, filename: filename,
362
- msg: "\"#{l}\" in \"enclosed\" does not refer to any existing file")
363
- end
364
- end
365
- @module_data[document][:files][filename] += ecl
366
- end
367
-
368
- line_numbers = psych_doc.line_numbers
369
-
370
- reqs.each do |id, attr|
371
- next if %w[module enclosed metadata document].include? id
372
-
373
- if @requirements.key?(id)
374
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: "id \"#{id}\" found more than once")
375
- end
376
-
377
- if id.include?(',')
378
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: "Disallowed ',' found in id \"#{id}\"")
379
- end
380
-
381
- validate_srs_name(id, disable_naming_convention_check, category, filename, 'ID')
382
-
383
- if attr.is_a?(String)
384
- attr.strip!
385
- res = extract_type(attr)
386
- if res
387
- attr = res
388
- else
389
- Dim::ExitHelper.exit(code: 1, filename: filename,
390
- msg: "Invalid short-form in requirement \"#{id}\", valid forms are \"h<level> <text>\" or \"info <text>\"")
391
- end
392
- elsif attr.is_a?(Hash)
393
- attr.keys.select do |k|
394
- @all_attributes.key?(k) && %i[multi split].include?(@all_attributes[k][:format_style])
395
- end.each do |k|
396
- attr[k] = attr[k].cleanUniqString if attr[k].is_a?(String)
397
- end
398
- attr['tags'] = attr['tags'].cleanUniqString if attr['tags'].is_a?(String)
399
- else
400
- Dim::ExitHelper.exit(code: 1, filename: filename, msg: "attributes for id \"#{id}\" must be key-value pairs")
401
- end
402
- unless attr.key?('verification_methods')
403
- attr['verification_methods'] = attr['test_setups'] if attr.key?('test_setups')
404
- end
405
- attr.each do |key, value|
406
- unless value.is_a?(String)
407
- Dim::ExitHelper.exit(code: 1, filename: filename,
408
- msg: "value of attribute \"#{key}\" must be String not #{value.class}")
409
- end
410
- attr[key].gsub!("\u00A0", ' ')
411
- attr[key].strip!
412
- end
413
-
414
- r = Requirement.new(id, document, filename, attr, origin, self, category, line_numbers[id], @all_attributes)
415
- reqs[id] = attr
416
- @requirements[id] = r
417
- end
418
-
419
- @original_data[filename] = Marshal.load(Marshal.dump(reqs))
420
- end
421
-
422
- def load_pattern(config_filename:, pattern:, origin:, silent:, category:, disable_naming_convention_check: false, no_check_enclosed: false)
423
- if pattern.match?(/\\/)
424
- puts "Warning: Backward slashes detected in pattern #{pattern}. Use '/' over '\\'"
425
- pattern.gsub!('\\', '/')
426
- end
427
- pattern_search = config_filename ? File.join(File.dirname(config_filename), pattern) : pattern
428
- fs = Dir.glob(pattern_search).sort
429
- if fs.empty? && !silent
430
- puts "Info: no matches for \"#{pattern}\" in \"#{config_filename}\""
431
- end
432
- fs.each do |f|
433
- load_file(filename: f, origin: origin, silent: silent, category: category, disable_naming_convention_check: disable_naming_convention_check, no_check_enclosed: no_check_enclosed)
434
- end
435
- return unless OPTIONS[:output_format] == 'stdout'
436
-
437
- load_file(filename: '', origin: origin, silent: silent, category: category, disable_naming_convention_check: disable_naming_convention_check, no_check_enclosed: no_check_enclosed)
438
- end
439
-
440
- def resolve_properties(folder:, properties_filename:)
441
- @properties = open_yml_file(folder, properties_filename, allow_empty_file: true)
442
- unless @properties
443
- puts "Warning: empty file detected; skipped loading of #{properties_filename}"
444
- return
445
- end
446
-
447
- @properties.each do |document, value|
448
- value.each do |attr, property_value|
449
- next unless @all_attributes.key?(attr)
450
-
451
- unless property_value.is_a?(String)
452
- Dim::ExitHelper.exit(code: 1, filename: properties_filename,
453
- msg: "The value for key #{attr} in properties files must be a string")
454
- end
455
-
456
- if @all_attributes.dig(attr, :allowed).nil? ||
457
- !property_value.cleanArray.select do |val|
458
- !@all_attributes[attr][:allowed].include?(val)
459
- end.any?
460
- @property_table[document] ||= {}
461
- @property_table[document][attr] = property_value.strip
462
- else
463
- Dim::ExitHelper.exit(code: 1, filename: properties_filename,
464
- msg: "The properties file includes an invalid #{attr} value '#{property_value}' for module: #{document}.")
465
- end
466
- end
467
- end
468
- end
469
-
470
- def fetch_attributes!(folder:, filename:, silent:)
471
- puts "Loading [attributes] #{File.join(folder, filename)}" unless silent
472
-
473
- @custom_attributes.merge!(resolve_attributes(folder: folder, filename: filename))
474
- @all_attributes.merge!(@custom_attributes)
475
- end
476
-
477
- def search_attributes_file(file)
478
- path = Pathname.new(file).parent.realpath
479
- if path.root?
480
- nil
481
- else
482
- return path if Dir.new(path).children.include?('attributes.dim')
483
- search_attributes_file(path)
484
- end
485
- end
486
-
487
- def validate_and_load_config(value, config_filename)
488
- disable_naming_convention_check = value['disable_naming_convention_check']
489
- unless disable_naming_convention_check
490
- value['disable_naming_convention_check'] = false
491
- return
492
- end
493
-
494
- if value['category'] != ALLOWED_CATEGORIES[:software]
495
- warn("Warning: disable_naming_convention_check attribute will only take effect when category is software")
496
- end
497
-
498
- if disable_naming_convention_check == 'yes'
499
- value['disable_naming_convention_check'] = true
500
- return
501
- elsif disable_naming_convention_check == 'no'
502
- value['disable_naming_convention_check'] = false
503
- return
504
- end
505
-
506
- Dim::ExitHelper.exit(
507
- code: 1,
508
- filename: config_filename,
509
- msg: 'disable_naming_convention_check in config must be either boolean value or a string "yes" or "no"'
510
- )
511
- end
512
-
513
- def validate_srs_name(name, disable_naming_convention_check, category, filename, attr = 'module')
514
- return if category != ALLOWED_CATEGORIES[:software] || disable_naming_convention_check
515
-
516
- # raise error if not starting with SRS_
517
- unless name.match?(/^(SRS_)/)
518
- Dim::ExitHelper.exit(
519
- code: 1,
520
- filename: filename,
521
- msg: "#{attr} #{name} in software requirement must start with \"SRS_\""
522
- )
523
- end
524
-
525
- _srs, feature, aspect, *rest = name.split('_')
526
-
527
- # Raise error if more than two _ detected
528
- unless rest.empty?
529
- Dim::ExitHelper.exit(
530
- code: 1,
531
- filename: filename,
532
- msg: "#{attr} #{name} in software requirement must contain exactly two \"_\""
533
- )
534
- end
535
-
536
- if feature.to_s.empty?
537
- Dim::ExitHelper.exit(
538
- code: 1,
539
- filename: filename,
540
- msg: "invalid #{attr} #{name} in software requirement; missing feature after \"SRS_\""
541
- )
542
- end
543
-
544
- # Raise error if feature or aspect is non alphanumeric
545
- if feature.match?(SRS_NAME_REGEX)
546
- Dim::ExitHelper.exit(
547
- code: 1,
548
- filename: filename,
549
- msg: "feature in #{attr} #{name} in software requirement contains non-alphanumeric characters"
550
- )
551
- end
552
-
553
- if attr == 'module' && !aspect.to_s.empty?
554
- Dim::ExitHelper.exit(
555
- code: 1,
556
- filename: filename,
557
- msg: "invalid module #{name} in software requirement; must contain exactly one \"_\""
558
- )
559
- end
560
-
561
- # Check naming convention for aspect only in case of IDs
562
- return if attr == 'module'
563
-
564
- if aspect.to_s.empty?
565
- Dim::ExitHelper.exit(
566
- code: 1,
567
- filename: filename,
568
- msg: "invalid ID #{name} in software requirement; missing aspect/ratio after \"SRS_feature\""
569
- )
570
- end
571
-
572
- if aspect.match?(SRS_NAME_REGEX)
573
- Dim::ExitHelper.exit(
574
- code: 1,
575
- filename: filename,
576
- msg: "aspect in ID #{name} in software requirement contains non-alphanumeric characters"
577
- )
578
- end
579
- end
580
- end
581
- end
1
+ require 'yaml'
2
+ require 'pathname'
3
+
4
+ require_relative 'encoding'
5
+ require_relative 'globals'
6
+ require_relative 'ext/psych'
7
+ require_relative 'requirement'
8
+ require_relative 'consistency'
9
+ require_relative 'exit_helper'
10
+ require_relative 'helpers/attribute_helper'
11
+ require_relative 'helpers/file_helper'
12
+
13
+ using Dim::Refinements
14
+
15
+ module Dim
16
+ class Loader
17
+ include Helpers::AttributeHelper
18
+
19
+ attr_reader :requirements, :config, :property_table, :properties, :original_data, :module_data, :metadata, :dim_file, :all_attributes, :custom_attributes
20
+
21
+ # YAML standard:
22
+ # invalid C0 control characters: 0x00 - 0x1F (except TAB 0x09, LF 0x0A and CR 0x0D)
23
+ # invalid control character DEL 0x7F
24
+ # invalid C1 control characters: 0x80 - 0x9F (except NEL 0x85)
25
+ #
26
+ # in addition NEL will be also replaced which seems to be misused in CRS documents
27
+ @@invalid_ccs = {
28
+ "\x00" => '[NUL]', "\x01" => '[SOH]', "\x02" => '[STX]', "\x03" => '[ETX]',
29
+ "\x04" => '[EOT]', "\x05" => '[ENQ]', "\x06" => '[ACK]', "\x07" => '[BEL]',
30
+ "\x08" => '[BS]', "\x0B" => '[VT]',
31
+ "\x0C" => '[FF]', "\x0E" => '[SO]', "\x0F" => '[SI]',
32
+ "\x10" => '[DLE]', "\x11" => '[DC1]', "\x12" => '[DC2]', "\x13" => '[DC3]',
33
+ "\x14" => '[DC4]', "\x15" => '[NAK]', "\x16" => '[SYN]', "\x17" => '[ETB]',
34
+ "\x18" => '[CAN]', "\x19" => '[EM]', "\x1A" => '[SUB]', "\x1B" => '[ESC]',
35
+ "\x1C" => '[FS]', "\x1D" => '[GS]', "\x1E" => '[RS]', "\x1F" => '[US]',
36
+ "\x7F" => '[DEL]',
37
+ "\u0080" => '[PAD]', "\u0081" => '[HOP]', "\u0082" => '[BPH]', "\u0083" => '[NBH]',
38
+ "\u0084" => '[IND]', "\u0085" => '[NEL]', "\u0086" => '[SSA]', "\u0087" => '[ESA]',
39
+ "\u0088" => '[HTS]', "\u0089" => '[HTJ]', "\u008A" => '[VTS]', "\u008B" => '[PLD]',
40
+ "\u008C" => '[PLU]', "\u008D" => '[RI]', "\u008E" => '[SS2]', "\u008F" => '[SS3]',
41
+ "\u0090" => '[DCS]', "\u0091" => '[PU1]', "\u0092" => '[PU2]', "\u0093" => '[STS]',
42
+ "\u0094" => '[CCH]', "\u0095" => '[MW]', "\u0096" => '[SPA]', "\u0097" => '[EPA]',
43
+ "\u0098" => '[SOS]', "\u0099" => '[SGCI]', "\u009A" => '[SCI]', "\u009B" => '[CSI]',
44
+ "\u009C" => '[ST]', "\u009D" => '[OSC]', "\u009E" => '[PM]', "\u009F" => '[APC]'
45
+ }
46
+
47
+ def initialize
48
+ @requirements = {}
49
+ @module_data = {}
50
+ @metadata = {}
51
+ @config = {}
52
+ @properties = {}
53
+ @property_table = {}
54
+ @original_data = {}
55
+ @all_attributes = Requirement::SYNTAX.dup
56
+ @custom_attributes = {}
57
+ end
58
+
59
+ def filter(str)
60
+ @requirements.keep_if { |_id, r| r.filter(str) }
61
+ end
62
+
63
+ def load(file: nil, attributes_file: nil, allow_missing: false, no_check_enclosed: false, silent: true, input_filenames: [])
64
+ ::Psych::Nodes::Scalar.add_patch
65
+ ::Psych::Visitors::ToRuby.add_patch
66
+
67
+ input_filenames = *input_filenames
68
+ # If output format is vim then we do not need to read the file,
69
+ # content will be read from the stdin and printed out to stdout. This is to enhance the
70
+ # formatting in the vim.
71
+ unless OPTIONS[:output_format] == 'stdout'
72
+ if input_filenames.length > 0 and file
73
+ Dim::ExitHelper.exit(code: 1, msg: 'use either file or input_filenames argument (deprecated) for load method')
74
+ end
75
+ if input_filenames.length > 1
76
+ Dim::ExitHelper.exit(code: 1,
77
+ msg: 'input_filenames argument (deprecated) of load method must have at maximum one entry')
78
+ end
79
+ file = input_filenames[0] if input_filenames.length == 1
80
+ unless file
81
+ Dim::ExitHelper.exit(code: 1,
82
+ msg: 'neither file nor input_filenames argument (deprecated) argument specified for load method')
83
+ end
84
+ end
85
+
86
+ unless silent
87
+ if allow_missing && OPTIONS[:subcommand] != 'format'
88
+ puts "Warning: Using 'allow-missing' might influence metrics when some references are ignored!\n"
89
+ end
90
+ puts 'Start Loading...'
91
+ end
92
+
93
+ Dim::ExitHelper.exit(code: 1, filename: file, msg: 'does not exist') unless File.exist?(file.to_s) || OPTIONS[:output_format] == 'stdout'
94
+
95
+ @dim_file = OPTIONS[:output_format] == 'stdout' ? $stdin.read.chomp : File.binread(file).chomp
96
+
97
+ if attributes_file
98
+ fetch_attributes!(folder: File.dirname(attributes_file), filename: attributes_file.split('/').last, silent: silent)
99
+ elsif !dim_file.match(/^Config:/)
100
+ folder = search_attributes_file(file.to_s)
101
+ fetch_attributes!(folder: folder, filename: 'attributes.dim', silent: silent) if folder
102
+ end
103
+
104
+ if dim_file.match(/^Config:/)
105
+ load_config(config_filename: file.to_s, silent: silent)
106
+ else
107
+ load_pattern(
108
+ config_filename: nil,
109
+ pattern: file.to_s,
110
+ origin: '',
111
+ silent: silent,
112
+ category: 'unspecified',
113
+ disable_naming_convention_check: false,
114
+ no_check_enclosed: no_check_enclosed
115
+ )
116
+ end
117
+
118
+ puts 'Checking consistency...' unless silent
119
+ checker = Dim::Consistency.new(self)
120
+ checker.check(allow_missing: allow_missing)
121
+ puts 'Done.' unless silent
122
+ ensure
123
+ ::Psych::Nodes::Scalar.revert_patch
124
+ ::Psych::Visitors::ToRuby.revert_patch
125
+ end
126
+
127
+ def load_config(config_filename:, silent: true, no_check_enclosed: false)
128
+ puts "Loading [config] #{config_filename}..." unless silent
129
+ @config = open_yml_file(config_filename, '')
130
+
131
+ allowed_attributes = %w[Config Properties Attributes]
132
+
133
+ @config.each_key do |k|
134
+ next unless allowed_attributes.none? { |a| a == k }
135
+
136
+ Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "top level key in config file must be #{allowed_attributes.map do |a|
137
+ "\"#{a}\""
138
+ end.join(', ')}, found \"#{k}\"")
139
+ end
140
+
141
+ if @config.key?('Attributes')
142
+ if @config['Attributes'].is_a?(String)
143
+ fetch_attributes!(folder: File.dirname(config_filename), filename: @config['Attributes'], silent: silent)
144
+ else
145
+ Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "'Attributes' must be a string")
146
+ end
147
+ end
148
+
149
+ if @config.key?('Properties')
150
+ if @config['Properties'].is_a?(String)
151
+ resolve_properties(folder: File.dirname(config_filename), properties_filename: @config['Properties'])
152
+ else
153
+ Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "'Properties' must be a string")
154
+ end
155
+ end
156
+
157
+ # COP: Move to constants
158
+ allowed_keys = %w[files category originator disable_naming_convention_check]
159
+ allowed_categories = ALLOWED_CATEGORIES.values
160
+
161
+ if @config['Config'].is_a?(Array)
162
+ config_values = @config['Config']
163
+ else
164
+ Dim::ExitHelper.exit(code: 1, filename: config_filename, msg: "'Config' must be an array")
165
+ end
166
+
167
+ config_values.each do |value|
168
+ validate_and_load_config(value, config_filename)
169
+
170
+ unless value.is_a?(Hash) && value.keys.sort_by(&:length).eql?(allowed_keys.sort_by(&:length))
171
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
172
+ msg: "each hash in 'Config' array must have key/value pairs for #{allowed_keys.join(', ')}.")
173
+ end
174
+
175
+ if value['category'].is_a?(String)
176
+ value['category'].strip!
177
+ unless allowed_categories.include?(value['category'])
178
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
179
+ msg: "attribute \"category\" of '#{value['originator']}' reqs is '#{value['category']}' but must be one of #{allowed_categories.join(', ')}.")
180
+ end
181
+ else
182
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
183
+ msg: "attribute \"category\" of '#{value['originator']}' reqs must be a string")
184
+ end
185
+
186
+ if value['files'].is_a?(Array) && value['files'].all? { |a| a.is_a?(String) } || value['files'].is_a?(String)
187
+ value['files'] = *value['files']
188
+ else
189
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
190
+ msg: "attribute \"files\" of '#{value['originator']}' reqs must be a string or an array of strings.")
191
+ end
192
+ value['files'].each do |pattern|
193
+ pattern.gsub!(%r{\A\./}, '')
194
+ if pattern.empty?
195
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
196
+ msg: "attribute \"files\" of '#{value['originator']}' must not have an empty string")
197
+ end
198
+ p = Pathname.new(pattern)
199
+ if p.absolute?
200
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
201
+ msg: "'#{pattern}' must not be an absolute path")
202
+ end
203
+ if p.each_filename.any? { |e| e == '..' }
204
+ Dim::ExitHelper.exit(code: 1, filename: config_filename,
205
+ msg: "'#{pattern}' must not include '..'")
206
+ end
207
+ load_pattern(
208
+ config_filename: config_filename,
209
+ pattern: pattern,
210
+ origin: value['originator'],
211
+ silent: silent,
212
+ category: value['category'],
213
+ disable_naming_convention_check: value['disable_naming_convention_check'],
214
+ no_check_enclosed: no_check_enclosed
215
+ )
216
+ end
217
+ end
218
+ puts 'Done.' unless silent
219
+ end
220
+
221
+ def extract_type(attr)
222
+ return {} if attr.empty?
223
+
224
+ hscan = attr.scan(/\Ah(\d+)\s.+/)
225
+ if hscan.length == 1
226
+ level = hscan[0][0].to_i
227
+ return { 'type' => "heading_#{level}", 'text' => attr[2 + hscan[0][0].length..-1].strip }
228
+ else
229
+ iscan = attr.scan(/\Ainfo\s.+/)
230
+ return { 'type' => 'information', 'text' => attr[5..-1].strip } if iscan.length == 1
231
+ end
232
+ nil
233
+ end
234
+
235
+ def load_file(filename:, origin:, silent:, category:, disable_naming_convention_check: false, no_check_enclosed: false)
236
+ puts "Loading [#{origin.empty? ? "unknown" : origin}] #{filename}..." unless silent
237
+ binary_data = OPTIONS[:output_format] == 'stdout' ? @dim_file : File.binread(filename).force_encoding('UTF-8')
238
+
239
+ # this looks expensive but measurement showed it's close to zero
240
+ @@invalid_ccs.each { |k, v| binary_data = binary_data.gsub(k, v) }
241
+
242
+ if binary_data.valid_encoding?
243
+ begin
244
+ psych_doc = YAML.parse(binary_data)
245
+ rescue Psych::SyntaxError => e
246
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: e.message)
247
+ end
248
+ else
249
+ begin
250
+ psych_doc = YAML.parse(binary_data.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?'),
251
+ filename: filename)
252
+ rescue Psych::SyntaxError => e
253
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: e.message)
254
+ end
255
+ end
256
+ unless psych_doc
257
+ puts "Warning: empty file detected; skipped loading of #{filename}"
258
+
259
+ return
260
+ end
261
+ reqs = psych_doc.to_ruby
262
+
263
+ unless reqs.is_a?(Hash)
264
+ Dim::ExitHelper.exit(code: 1, filename: filename,
265
+ msg: 'top level must be a hash with keys "document", "enclosed", "metadata" and/or unique ids'
266
+ )
267
+ end
268
+
269
+ if reqs.key?('document')
270
+ if !reqs['document'].is_a?(String) || reqs['document'].empty?
271
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'document name must be a non-empty string')
272
+ end
273
+ document = reqs['document']
274
+ else
275
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'Document name is missing; please add document name')
276
+ end
277
+
278
+ validate_srs_name(document, disable_naming_convention_check, category, filename)
279
+
280
+ if @module_data.key?(document)
281
+ if @module_data[document][:origin] != origin
282
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg:
283
+ "files of the same document must have the same owner:\n" +
284
+ "- #{@module_data[document][:files].first[0]} (#{@module_data[document][:origin]})\n" +
285
+ "- #{filename} (#{origin})")
286
+ elsif @module_data[document][:category] != category
287
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg:
288
+ "files of the same document must have the same category:\n" +
289
+ "- #{@module_data[document][:files].first[0]} (#{@module_data[document][:category]})\n" +
290
+ "- #{filename} (#{category})")
291
+ end
292
+ else
293
+ @module_data[document] = { origin: origin, category: category, files: {} }
294
+ end
295
+
296
+ if @module_data[document][:files].key?(filename)
297
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: "file #{filename} already loaded")
298
+ end
299
+
300
+ @module_data[document][:files][filename] = []
301
+ @metadata[document] ||= ''
302
+
303
+ if reqs.key?('metadata')
304
+ unless reqs['metadata'].is_a?(String)
305
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'metadata must be a string')
306
+ end
307
+ unless reqs['metadata'].empty?
308
+ unless @metadata[document].empty?
309
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: 'only one metadata per document allowed')
310
+ end
311
+ @metadata[document] = reqs['metadata'].strip
312
+ end
313
+ end
314
+
315
+ if reqs.key?('enclosed') && !no_check_enclosed
316
+ ecl = reqs['enclosed']
317
+ ecl = [ecl] if ecl.is_a?(String)
318
+ if !ecl.is_a?(Array) || ecl.empty? || ecl.any? { |s| !s.is_a?(String) || s.empty? }
319
+ Dim::ExitHelper.exit(code: 1, filename: filename,
320
+ msg: '"enclosed" must be a non-empty string or an array of non-empty strings')
321
+ end
322
+ # Remove superfluous ./ from path
323
+ ecl = ecl.map do |path|
324
+ if path.match?(/\\/)
325
+ puts "Warning: Backward slashes detected in filepath #{path}. Use '/' over '\\' in filepath"
326
+ path.gsub!('\\', '/')
327
+ end
328
+ Pathname.new(path).cleanpath.to_s
329
+ end
330
+ dir_file = File.dirname(filename)
331
+ ecl.each do |l|
332
+ p = Pathname.new(l)
333
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: "'#{l}' must not be an absolute path") if p.absolute?
334
+ if p.each_filename.any? do |name|
335
+ name == '..'
336
+ end
337
+ Dim::ExitHelper.exit(code: 1, filename: filename,
338
+ msg: "'#{l}' must not include '..'")
339
+ end
340
+
341
+ src = File.join(dir_file, l)
342
+ src_globbed = Dir.glob(src)
343
+ if src_globbed.empty?
344
+ Dim::ExitHelper.exit(code: 1, filename: filename,
345
+ msg: "\"#{l}\" in \"enclosed\" does not refer to any existing file")
346
+ end
347
+ end
348
+ @module_data[document][:files][filename] += ecl
349
+ end
350
+
351
+ line_numbers = psych_doc.line_numbers
352
+
353
+ reqs.each do |id, attr|
354
+ next if %w[enclosed metadata document].include? id
355
+
356
+ if @requirements.key?(id)
357
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: "id \"#{id}\" found more than once")
358
+ end
359
+
360
+ if id.include?(',')
361
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: "Disallowed ',' found in id \"#{id}\"")
362
+ end
363
+
364
+ validate_srs_name(id, disable_naming_convention_check, category, filename, 'ID')
365
+
366
+ if attr.is_a?(String)
367
+ attr.strip!
368
+ res = extract_type(attr)
369
+ if res
370
+ attr = res
371
+ else
372
+ Dim::ExitHelper.exit(code: 1, filename: filename,
373
+ msg: "Invalid short-form in requirement \"#{id}\", valid forms are \"h<level> <text>\" or \"info <text>\"")
374
+ end
375
+ elsif attr.is_a?(Hash)
376
+ attr.keys.select do |k|
377
+ @all_attributes.key?(k) && %i[multi split].include?(@all_attributes[k][:format_style])
378
+ end.each do |k|
379
+ attr[k] = attr[k].cleanUniqString if attr[k].is_a?(String)
380
+ end
381
+ attr['tags'] = attr['tags'].cleanUniqString if attr['tags'].is_a?(String)
382
+ else
383
+ Dim::ExitHelper.exit(code: 1, filename: filename, msg: "attributes for id \"#{id}\" must be key-value pairs")
384
+ end
385
+ attr.each do |key, value|
386
+ unless value.is_a?(String)
387
+ Dim::ExitHelper.exit(code: 1, filename: filename,
388
+ msg: "value of attribute \"#{key}\" must be String not #{value.class}")
389
+ end
390
+ attr[key].gsub!("\u00A0", ' ')
391
+ attr[key].strip!
392
+ end
393
+
394
+ r = Requirement.new(id, document, filename, attr, origin, self, category, line_numbers[id], @all_attributes)
395
+ reqs[id] = attr
396
+ @requirements[id] = r
397
+ end
398
+
399
+ @original_data[filename] = Marshal.load(Marshal.dump(reqs))
400
+ end
401
+
402
+ def load_pattern(config_filename:, pattern:, origin:, silent:, category:, disable_naming_convention_check: false, no_check_enclosed: false)
403
+ if pattern.match?(/\\/)
404
+ puts "Warning: Backward slashes detected in pattern #{pattern}. Use '/' over '\\'"
405
+ pattern.gsub!('\\', '/')
406
+ end
407
+ pattern_search = config_filename ? File.join(File.dirname(config_filename), pattern) : pattern
408
+ fs = Dir.glob(pattern_search).sort
409
+ if fs.empty? && !silent
410
+ puts "Info: no matches for \"#{pattern}\" in \"#{config_filename}\""
411
+ end
412
+ fs.each do |f|
413
+ load_file(filename: f, origin: origin, silent: silent, category: category, disable_naming_convention_check: disable_naming_convention_check, no_check_enclosed: no_check_enclosed)
414
+ end
415
+ return unless OPTIONS[:output_format] == 'stdout'
416
+
417
+ load_file(filename: '', origin: origin, silent: silent, category: category, disable_naming_convention_check: disable_naming_convention_check, no_check_enclosed: no_check_enclosed)
418
+ end
419
+
420
+ def resolve_properties(folder:, properties_filename:)
421
+ @properties = open_yml_file(folder, properties_filename, allow_empty_file: true)
422
+ unless @properties
423
+ puts "Warning: empty file detected; skipped loading of #{properties_filename}"
424
+ return
425
+ end
426
+
427
+ @properties.each do |document, value|
428
+ value.each do |attr, property_value|
429
+ next unless @all_attributes.key?(attr)
430
+
431
+ unless property_value.is_a?(String)
432
+ Dim::ExitHelper.exit(code: 1, filename: properties_filename,
433
+ msg: "The value for key #{attr} in properties files must be a string")
434
+ end
435
+
436
+ if @all_attributes.dig(attr, :allowed).nil? ||
437
+ !property_value.cleanArray.select do |val|
438
+ !@all_attributes[attr][:allowed].include?(val)
439
+ end.any?
440
+ @property_table[document] ||= {}
441
+ @property_table[document][attr] = property_value.strip
442
+ else
443
+ Dim::ExitHelper.exit(code: 1, filename: properties_filename,
444
+ msg: "The properties file includes an invalid #{attr} value '#{property_value}' for document: #{document}.")
445
+ end
446
+ end
447
+ end
448
+ end
449
+
450
+ def fetch_attributes!(folder:, filename:, silent:)
451
+ puts "Loading [attributes] #{File.join(folder, filename)}" unless silent
452
+
453
+ @custom_attributes.merge!(resolve_attributes(folder: folder, filename: filename))
454
+ @all_attributes.merge!(@custom_attributes)
455
+ end
456
+
457
+ def search_attributes_file(file)
458
+ path = Pathname.new(file).parent.realpath
459
+ if path.root?
460
+ nil
461
+ else
462
+ return path if Dir.new(path).children.include?('attributes.dim')
463
+ search_attributes_file(path)
464
+ end
465
+ end
466
+
467
+ def validate_and_load_config(value, config_filename)
468
+ disable_naming_convention_check = value['disable_naming_convention_check']
469
+ unless disable_naming_convention_check
470
+ value['disable_naming_convention_check'] = false
471
+ return
472
+ end
473
+
474
+ if value['category'] != ALLOWED_CATEGORIES[:software]
475
+ warn("Warning: disable_naming_convention_check attribute will only take effect when category is software")
476
+ end
477
+
478
+ if disable_naming_convention_check == 'yes'
479
+ value['disable_naming_convention_check'] = true
480
+ return
481
+ elsif disable_naming_convention_check == 'no'
482
+ value['disable_naming_convention_check'] = false
483
+ return
484
+ end
485
+
486
+ Dim::ExitHelper.exit(
487
+ code: 1,
488
+ filename: config_filename,
489
+ msg: 'disable_naming_convention_check in config must be either boolean value or a string "yes" or "no"'
490
+ )
491
+ end
492
+
493
+ def validate_srs_name(name, disable_naming_convention_check, category, filename, attr = 'document')
494
+ return if category != ALLOWED_CATEGORIES[:software] || disable_naming_convention_check
495
+
496
+ # raise error if not starting with SRS_
497
+ unless name.match?(/^(SRS_)/)
498
+ Dim::ExitHelper.exit(
499
+ code: 1,
500
+ filename: filename,
501
+ msg: "#{attr} #{name} in software requirement must start with \"SRS_\""
502
+ )
503
+ end
504
+
505
+ _srs, feature, aspect, *rest = name.split('_')
506
+
507
+ # Raise error if more than two _ detected
508
+ unless rest.empty?
509
+ Dim::ExitHelper.exit(
510
+ code: 1,
511
+ filename: filename,
512
+ msg: "#{attr} #{name} in software requirement must contain exactly two \"_\""
513
+ )
514
+ end
515
+
516
+ if feature.to_s.empty?
517
+ Dim::ExitHelper.exit(
518
+ code: 1,
519
+ filename: filename,
520
+ msg: "invalid #{attr} #{name} in software requirement; missing feature after \"SRS_\""
521
+ )
522
+ end
523
+
524
+ # Raise error if feature or aspect is non alphanumeric
525
+ if feature.match?(SRS_NAME_REGEX)
526
+ Dim::ExitHelper.exit(
527
+ code: 1,
528
+ filename: filename,
529
+ msg: "feature in #{attr} #{name} in software requirement contains non-alphanumeric characters"
530
+ )
531
+ end
532
+
533
+ if attr == 'document' && !aspect.to_s.empty?
534
+ Dim::ExitHelper.exit(
535
+ code: 1,
536
+ filename: filename,
537
+ msg: "invalid document #{name} in software requirement; must contain exactly one \"_\""
538
+ )
539
+ end
540
+
541
+ # Check naming convention for aspect only in case of IDs
542
+ return if attr == 'document'
543
+
544
+ if aspect.to_s.empty?
545
+ Dim::ExitHelper.exit(
546
+ code: 1,
547
+ filename: filename,
548
+ msg: "invalid ID #{name} in software requirement; missing aspect/ratio after \"SRS_feature\""
549
+ )
550
+ end
551
+
552
+ if aspect.match?(SRS_NAME_REGEX)
553
+ Dim::ExitHelper.exit(
554
+ code: 1,
555
+ filename: filename,
556
+ msg: "aspect in ID #{name} in software requirement contains non-alphanumeric characters"
557
+ )
558
+ end
559
+ end
560
+ end
561
+ end