Let a module declare its config with type, description and validation (issue 660)
The config form's declaration and its registration refusals, each by name with what to do (ADR 0348, design 57 package K1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660).
|
||||
//
|
||||
// **One concept, two spellings for a while.** "Config" replaces "settings": the manifest says `config` for
|
||||
// `settings`, `takes-config` for `takes-settings` and `${config:<key>}` for `${setting:<key>}`. The old names stay
|
||||
// accepted as aliases until every module has moved (ADR 0348 §1), and a module uses one form or the other, never
|
||||
// both. The config form is read into the very fields the settings form fills — Settings, a mergeable file's
|
||||
// takes-settings and `${setting:<key>}` — so composition has one path and `${config:<key>}` fills exactly as
|
||||
// `${setting:<key>}` does: a module rewritten from settings to config composes byte-identically. It is written
|
||||
// back in the form it was read in, so what the mesh holds is what its author wrote.
|
||||
//
|
||||
// **A moved module is held to the whole declaration.** Under `config` every value has a type (number, string or
|
||||
// boolean; an object or a list only when trusted), a description including its unit, its validation (enum,
|
||||
// minimum and maximum, pattern), whether it is trusted, optionally the predefined key it falls back to, and either
|
||||
// a default or `required`; it lands in a file or is read at runtime (`runtime: true`); every mergeable file names
|
||||
// the keys it takes; a `reload-on` or `restart-on` names a file the module declares. A module not yet moved keeps
|
||||
// today's form and today's rules (SettingProblems, TakenSettingsProblems) until the catalogue's list of such
|
||||
// modules is empty (ADR 0348 §11).
|
||||
//
|
||||
// **A required value nothing sets** is the operator's own: it composes as an operator's setting nobody gave does
|
||||
// today, refused by name at composition (UnsetSettingError). Who reports it once mesh-config answers values is the
|
||||
// provisioner's (ADR 0349), not this file's.
|
||||
|
||||
// ConfigField is the manifest key a module declares its config values under.
|
||||
const ConfigField = "config"
|
||||
|
||||
// TakesConfigField is the key a mergeable file names the config values it takes by.
|
||||
const TakesConfigField = "takes-config"
|
||||
|
||||
// SettingsField is the old name of ConfigField, read as its alias.
|
||||
const SettingsField = "settings"
|
||||
|
||||
// configRef is how a file asks for a config value: ${config:<key>}, the same keys as settingRef.
|
||||
var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never
|
||||
// followed by a colon, and a quote inside a string is escaped, so these match only keys.
|
||||
var (
|
||||
takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`)
|
||||
takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`)
|
||||
)
|
||||
|
||||
// The types a config value may have (ADR 0348 §4).
|
||||
const (
|
||||
ConfigNumber = "number"
|
||||
ConfigString = "string"
|
||||
ConfigBoolean = "boolean"
|
||||
ConfigObject = "object"
|
||||
ConfigList = "list"
|
||||
)
|
||||
|
||||
// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11).
|
||||
var accountFacts = map[string]bool{"puid": true, "pgid": true}
|
||||
|
||||
// PredefinedKey is one key of the catalogue's list of predefined keys, as far as a module's value falling back
|
||||
// to it is judged here: its type, and whether it is trusted (ADR 0348 §7, §8).
|
||||
type PredefinedKey struct {
|
||||
Type string
|
||||
Trusted bool
|
||||
}
|
||||
|
||||
// predefinedKeys is the list of predefined keys, nil until it is present. The list (`predefined.json` at the
|
||||
// catalogue's root) arrives with design 57's package K2 (issue 661); until then a value naming one is refused
|
||||
// saying the list is not yet present.
|
||||
var predefinedKeys map[string]PredefinedKey
|
||||
|
||||
// configForms is which of the two forms a manifest was written in, by the words of each it uses.
|
||||
type configForms struct {
|
||||
config, settings []string
|
||||
}
|
||||
|
||||
// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or
|
||||
// `${config:<key>}` (ADR 0348 §1, §11).
|
||||
func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 }
|
||||
|
||||
// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and
|
||||
// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read
|
||||
// (UnmarshalJSON).
|
||||
func toSettingsForm(raw []byte) ([]byte, configForms) {
|
||||
var f configForms
|
||||
if configRef.Match(raw) {
|
||||
f.config = append(f.config, "${config:…}")
|
||||
}
|
||||
if takesConfigKey.Match(raw) {
|
||||
f.config = append(f.config, TakesConfigField)
|
||||
}
|
||||
if settingRef.Match(raw) {
|
||||
f.settings = append(f.settings, "${setting:…}")
|
||||
}
|
||||
if takesSettingsKey.Match(raw) {
|
||||
f.settings = append(f.settings, TakesSettingsField)
|
||||
}
|
||||
if len(f.config) == 0 {
|
||||
return raw, f
|
||||
}
|
||||
raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
||||
return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}")
|
||||
})
|
||||
raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
||||
return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":")
|
||||
})
|
||||
return raw, f
|
||||
}
|
||||
|
||||
// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what
|
||||
// toSettingsForm and UnmarshalJSON read, written back.
|
||||
func toConfigForm(raw []byte) ([]byte, error) {
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settings, has := keys[SettingsField]; has {
|
||||
keys[ConfigField] = settings
|
||||
delete(keys, SettingsField)
|
||||
var err error
|
||||
if raw, err = json.Marshal(keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
||||
return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}")
|
||||
})
|
||||
raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
||||
return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":")
|
||||
})
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// inConfigWords is a refusal said of a module in the config form in its words: `${config:<key>}` and
|
||||
// takes-config, where the rules shared with the settings form say `${setting:<key>}` and takes-settings.
|
||||
func inConfigWords(problem string) string {
|
||||
problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string {
|
||||
return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}"
|
||||
})
|
||||
return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField)
|
||||
}
|
||||
|
||||
// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1).
|
||||
func MixedFormProblems(m Manifest) []string {
|
||||
if len(m.forms.config) == 0 || len(m.forms.settings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+
|
||||
"or the other, never both; write %s, %s and ${config:<key>} throughout (novox/hq ADR 0348 §1)",
|
||||
m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "),
|
||||
ConfigField, TakesConfigField)}
|
||||
}
|
||||
|
||||
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
|
||||
// unsaid.
|
||||
type configDeclaration struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Runtime bool `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
|
||||
// form's kind and why, and without a default it does not have.
|
||||
func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
||||
type plain SettingDeclaration
|
||||
if d.Kind != "" || d.Why != "" {
|
||||
return json.Marshal(plain(d))
|
||||
}
|
||||
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
|
||||
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
|
||||
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
||||
}
|
||||
|
||||
// configShaped is whether a declaration says anything of the config form's.
|
||||
func configShaped(d SettingDeclaration) bool {
|
||||
return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil ||
|
||||
d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime
|
||||
}
|
||||
|
||||
// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type,
|
||||
// a description and its validation is declared in the config form (ADR 0348 §1, §4).
|
||||
func TypedSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
if configShaped(m.Settings[key]) {
|
||||
problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+
|
||||
"under %s: a value declared with those is declared under config, with ${config:<key>} and "+
|
||||
"takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with
|
||||
// what to do (ADR 0348 How it is checked, design 57 package K1).
|
||||
func ConfigProblems(m Manifest) []string {
|
||||
if !m.DeclaresConfig() {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
placed := configPlaced(m)
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
d := m.Settings[key]
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
if !settingRef.MatchString("${setting:" + key + "}") {
|
||||
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
|
||||
continue
|
||||
}
|
||||
if meshWords[key] || accountFacts[key] {
|
||||
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
|
||||
continue
|
||||
}
|
||||
if d.Kind != "" || d.Why != "" {
|
||||
say("kind and why are the settings form's; under config a value says its type and its description " +
|
||||
"(novox/hq ADR 0348 §1, §4)")
|
||||
}
|
||||
switch d.Type {
|
||||
case ConfigNumber, ConfigString, ConfigBoolean:
|
||||
case ConfigObject, ConfigList:
|
||||
if !d.Trusted {
|
||||
say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+
|
||||
"trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type)
|
||||
}
|
||||
case "":
|
||||
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
|
||||
default:
|
||||
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type)
|
||||
}
|
||||
if strings.TrimSpace(d.Description) == "" {
|
||||
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)")
|
||||
}
|
||||
switch {
|
||||
case d.Required && d.Default != nil:
|
||||
say("both a default and required: a required value is the operator's own and has no default; give " +
|
||||
"one or the other (novox/hq ADR 0348 §4)")
|
||||
case !d.Required && d.Default == nil:
|
||||
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
|
||||
"the operator's own (novox/hq ADR 0348 §4)")
|
||||
}
|
||||
for _, wrong := range validationProblems(d) {
|
||||
say("%s", wrong)
|
||||
}
|
||||
if word := operatorsWord(key); word != "" && d.Default != nil {
|
||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default "+
|
||||
"(novox/hq ADR 0348 §4, ADR 0112)", word)
|
||||
}
|
||||
if d.Predefined != "" {
|
||||
p, listed := predefinedKeys[d.Predefined]
|
||||
switch {
|
||||
case predefinedKeys == nil:
|
||||
say("falls back to the predefined key %q, and the list of predefined keys (the catalogue's "+
|
||||
"predefined.json, design 57 package K2) is not yet present: no value can fall back to one "+
|
||||
"until it is; leave \"predefined\" out for now (novox/hq ADR 0348 §7)", d.Predefined)
|
||||
case !listed:
|
||||
say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+
|
||||
"\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined)
|
||||
default:
|
||||
if p.Type != d.Type {
|
||||
say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+
|
||||
"back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type)
|
||||
}
|
||||
if p.Trusted && !d.Trusted {
|
||||
say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+
|
||||
"back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A template's text is read where its tree or its build is (WithTemplatesFrom, Resolve), and this is
|
||||
// judged again there.
|
||||
if !placed[key] && !d.Runtime && !m.Templated() {
|
||||
say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+
|
||||
"\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField)
|
||||
}
|
||||
}
|
||||
used := settingKeysUsedBy(m)
|
||||
for _, key := range sortedKeys(used) {
|
||||
if _, declared := m.Settings[key]; !declared {
|
||||
problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+
|
||||
"declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)",
|
||||
m.Module, key, ConfigField, key))
|
||||
}
|
||||
}
|
||||
problems = append(problems, configTakesProblems(m)...)
|
||||
problems = append(problems, reflectsProblems(m)...)
|
||||
return problems
|
||||
}
|
||||
|
||||
// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and
|
||||
// every value a mergeable file takes.
|
||||
func configPlaced(m Manifest) map[string]bool {
|
||||
placed := settingKeysUsedBy(m)
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range stringsOf(r[TakesSettingsField]) {
|
||||
placed[k] = true
|
||||
}
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||||
func validationProblems(d SettingDeclaration) []string {
|
||||
var problems []string
|
||||
isNumber := d.Type == ConfigNumber
|
||||
if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != ConfigString {
|
||||
problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type))
|
||||
}
|
||||
if (d.Minimum != nil || d.Maximum != nil) && !isNumber {
|
||||
problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type))
|
||||
}
|
||||
if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum {
|
||||
problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum))
|
||||
}
|
||||
var pattern *regexp.Regexp
|
||||
if d.Pattern != "" {
|
||||
if d.Type != ConfigString {
|
||||
problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type))
|
||||
}
|
||||
var err error
|
||||
if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err))
|
||||
}
|
||||
}
|
||||
for _, v := range d.Enum {
|
||||
if !ofType(v, d.Type) {
|
||||
problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type))
|
||||
}
|
||||
}
|
||||
if d.Default == nil {
|
||||
return problems
|
||||
}
|
||||
if !ofType(d.Default, d.Type) {
|
||||
if d.Type != "" {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum))
|
||||
}
|
||||
if n, ok := d.Default.(float64); ok {
|
||||
if d.Minimum != nil && n < *d.Minimum {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum))
|
||||
}
|
||||
if d.Maximum != nil && n > *d.Maximum {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum))
|
||||
}
|
||||
}
|
||||
if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) {
|
||||
problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ofType is whether a value read from JSON is of a config type.
|
||||
func ofType(v any, kind string) bool {
|
||||
switch v.(type) {
|
||||
case float64:
|
||||
return kind == ConfigNumber
|
||||
case string:
|
||||
return kind == ConfigString
|
||||
case bool:
|
||||
return kind == ConfigBoolean
|
||||
case map[string]any:
|
||||
return kind == ConfigObject
|
||||
case []any:
|
||||
return kind == ConfigList
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in
|
||||
// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file
|
||||
// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that
|
||||
// does not).
|
||||
func configTakesProblems(m Manifest) []string {
|
||||
var problems, silent []string
|
||||
naming := false
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
if _, said := r[TakesSettingsField]; !said {
|
||||
silent = append(silent, fmt.Sprint(r["id"]))
|
||||
continue
|
||||
}
|
||||
naming = true
|
||||
for _, k := range stringsOf(r[TakesSettingsField]) {
|
||||
if accountFacts[k] {
|
||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+
|
||||
"mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k,
|
||||
TakesConfigField, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !naming {
|
||||
for _, id := range silent {
|
||||
problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+
|
||||
"names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)",
|
||||
m.Module, id, TakesConfigField))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name
|
||||
// without declaring them; meshMadePrefixes are those named after what they are for.
|
||||
var (
|
||||
meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()}
|
||||
meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")}
|
||||
)
|
||||
|
||||
// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not
|
||||
// one of its own resources, not one the mesh composes for it, and not another module's, which is named
|
||||
// `<module>.<id>`.
|
||||
func reflectsProblems(m Manifest) []string {
|
||||
own := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
own[fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
meshMade := func(id string) bool {
|
||||
if slices.Contains(meshMadeIDs, id) {
|
||||
return true
|
||||
}
|
||||
for _, p := range meshMadePrefixes {
|
||||
if strings.HasPrefix(id, p) && len(id) > len(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
for _, field := range []string{"reload-on", "restart-on"} {
|
||||
for _, id := range stringsOf(r[field]) {
|
||||
if strings.Contains(id, ".") || own[id] || meshMade(id) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+
|
||||
"file: name a file of its own, or another module's as <module>.<id> (novox/hq ADR 0348 §6)",
|
||||
m.Module, r["id"], id, field, m.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
Reference in New Issue
Block a user