From 59d07701b15c6644ad37fccde4b0a7f59ffcdec2 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Sat, 19 Sep 2026 11:49:35 +1000 Subject: [PATCH 1/3] feat(config): add the versioned project configuration parser Add PasWeave.Config with a strict pasweave.json loader: version 1 schema, unknown-key and type rejection, validated theme/comments/targets/defines/ coverage values, and relative-path rules that reject absolute paths and parent traversal escaping the configuration directory. Options are exposed through TProjectConfig with presence flags where empty is meaningful. Covered by tests/PasWeave.ConfigTests.pas with a full valid document, a minimal document, and 22 rejection cases. --- src/cli/PasWeave.Config.pas | 556 +++++++++++++++++++++++++++++++++ tests/PasWeave.ConfigTests.pas | 205 ++++++++++++ tests/test_pasweave.pas | 3 +- 3 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 src/cli/PasWeave.Config.pas create mode 100644 tests/PasWeave.ConfigTests.pas diff --git a/src/cli/PasWeave.Config.pas b/src/cli/PasWeave.Config.pas new file mode 100644 index 0000000..27eceec --- /dev/null +++ b/src/cli/PasWeave.Config.pas @@ -0,0 +1,556 @@ +/// Loads and validates the versioned `pasweave.json` project configuration. +/// +/// The configuration is strictly declarative: unknown keys, wrong types, +/// unsupported versions, absolute paths, and parent traversal that escapes the +/// configuration directory are rejected with a descriptive error. Relative +/// paths are resolved against the configuration file's directory by the +/// caller, so the effective configuration stays reproducible across machines. +unit PasWeave.Config; + +{$mode objfpc}{$H+}{$J+} + +interface + +uses + Classes, SysUtils, PasWeave.Comments, PasWeave.Diagnostics, PasWeave.Model; + +const + /// Supported `version` value in `pasweave.json`. + ProjectConfigVersion = 1; + /// Default configuration filename used with `--config` discovery. + ProjectConfigFilename = 'pasweave.json'; + +type + /// Raised for any invalid, unreadable, or unsupported configuration. + EProjectConfigError = class(Exception); + + /// Which declarations become part of browseable documentation. + TRenderVisibilityPolicy = ( + rvpPublicAPI, + rvpAllDeclarations + ); + + /// Parsed and validated `pasweave.json` contents. + TProjectConfig = class + public + /// Absolute path of the loaded file. + Filename: string; + /// Directory used as the base for every relative path. + Directory: string; + /// Relative source input (`source`). + SourcePath: string; + /// Relative output directory (`output`). + OutputPath: string; + /// Project title (`project.name`). + ProjectName: string; + /// Header brand mark (`project.mark`). + ProjectMark: string; + /// Repository origin (`project.repositoryUrl`). + RepositoryURL: string; + /// Repository-relative line template (`project.sourceLinkTemplate`). + SourceLinkTemplate: string; + /// Primary accent color (`project.theme.accent`). + ThemeAccent: string; + /// Secondary accent color (`project.theme.accent2`). + ThemeAccentAlt: string; + /// Body font family (`project.theme.font`). + ThemeFont: string; + /// Documentation comment styles (`comments`); default `slash`. + Comments: TDocumentationCommentStyles; + /// True when `comments` was present. + HasComments: Boolean; + /// Declaration visibility policy (`visibility`). + Visibility: TRenderVisibilityPolicy; + /// Recursive discovery (`discovery.recursive`). + Recursive: Boolean; + /// Owned include/exclude globs (`discovery.include`/`exclude`). + IncludePatterns: TStringList; + ExcludePatterns: TStringList; + /// Owned compiler search paths and defines. + UnitPaths: TStringList; + IncludePaths: TStringList; + Defines: TStringList; + PackagePaths: TStringList; + /// Normalized target selection (`compiler.targetOs`/`targetCpu`). + TargetOS: string; + TargetCPU: string; + /// Lazarus build mode (`compiler.buildMode`). + BuildMode: string; + /// Documentation coverage minimum (`coverage.minimum`). + MinimumCoverage: Integer; + /// True when `coverage.minimum` was present. + HasMinimumCoverage: Boolean; + /// Failure threshold (`coverage.failOn`); empty uses the default. + FailOn: string; + constructor Create; + destructor Destroy; override; + end; + +/// Loads and validates one configuration file. +/// +/// @param AFilename Path to `pasweave.json`. +/// @returns An owned configuration; the caller frees it. +/// @raises EProjectConfigError for unreadable files, invalid JSON, unknown +/// keys, wrong types, unsupported versions, or unsafe paths. +function LoadProjectConfig(const AFilename: string): TProjectConfig; + +/// Stable lowercase name of a visibility policy. +function VisibilityPolicyName(APolicy: TRenderVisibilityPolicy): string; + +implementation + +uses + FPJSON, JSONParser, PasWeave.Compiler, PasWeave.FS, PasWeave.Parser; + +type + TJSONKind = (jkObject, jkString, jkBoolean, jkInteger, jkArray); + +procedure Fail(const AMessage: string); +begin + raise EProjectConfigError.Create(AMessage); +end; + +function KeyPath(const AParent, AKey: string): string; +begin + if AParent = '' then + Result := AKey + else + Result := AParent + '.' + AKey; +end; + +procedure EnsureKnownKeys(AObject: TJSONObject; + const AAllowed: array of string; const APath: string); +var + I: Integer; + J: Integer; + Allowed: Boolean; +begin + for I := 0 to AObject.Count - 1 do + begin + Allowed := False; + for J := Low(AAllowed) to High(AAllowed) do + if SameText(AObject.Names[I], AAllowed[J]) then + begin + Allowed := True; + Break; + end; + if not Allowed then + Fail('unknown configuration key: ' + KeyPath(APath, AObject.Names[I])); + end; +end; + +procedure ExpectKind(AItem: TJSONData; AKind: TJSONKind; + const APath: string); +var + Matches: Boolean; +begin + case AKind of + jkObject: Matches := AItem.JSONType = jtObject; + jkString: Matches := AItem.JSONType = jtString; + jkBoolean: Matches := AItem.JSONType = jtBoolean; + jkInteger: Matches := AItem.JSONType = jtNumber; + jkArray: Matches := AItem.JSONType = jtArray; + else + Matches := False; + end; + if not Matches then + Fail('configuration key has the wrong type: ' + APath); +end; + +function OptionalString(AObject: TJSONObject; const AKey, APath: string; + out AValue: string): Boolean; +var + Item: TJSONData; +begin + AValue := ''; + Item := AObject.Find(AKey); + Result := Assigned(Item); + if not Result then + Exit; + ExpectKind(Item, jkString, KeyPath(APath, AKey)); + AValue := Item.AsString; +end; + +function OptionalBoolean(AObject: TJSONObject; const AKey, APath: string; + out AValue: Boolean): Boolean; +var + Item: TJSONData; +begin + AValue := False; + Item := AObject.Find(AKey); + Result := Assigned(Item); + if not Result then + Exit; + ExpectKind(Item, jkBoolean, KeyPath(APath, AKey)); + AValue := Item.AsBoolean; +end; + +function OptionalInteger(AObject: TJSONObject; const AKey, APath: string; + out AValue: Integer): Boolean; +var + Item: TJSONData; +begin + AValue := 0; + Item := AObject.Find(AKey); + Result := Assigned(Item); + if not Result then + Exit; + ExpectKind(Item, jkInteger, KeyPath(APath, AKey)); + AValue := Item.AsInteger; +end; + +procedure AddStringArray(AObject: TJSONObject; const AKey, APath: string; + ATarget: TStrings); +var + Item: TJSONData; + Elements: TJSONArray; + I: Integer; +begin + Item := AObject.Find(AKey); + if not Assigned(Item) then + Exit; + ExpectKind(Item, jkArray, KeyPath(APath, AKey)); + Elements := TJSONArray(Item); + for I := 0 to Elements.Count - 1 do + begin + if Elements[I].JSONType <> jtString then + Fail(KeyPath(APath, AKey) + ' entries must be strings'); + ATarget.Add(Elements[I].AsString); + end; +end; + +{ Validates a relative path and rejects absolute paths and traversal that + escapes the configuration directory. Returns the normalized original so the + effective configuration does not depend on the host. } +function CheckRelativePath(const AValue: string; + const APath: string): string; +var + Normalised: string; + Start: Integer; + Stop: Integer; + Segment: string; + Depth: Integer; +begin + Normalised := StringReplace(Trim(AValue), '\', '/', [rfReplaceAll]); + if Normalised = '' then + Fail(APath + ' must not be empty'); + if (Normalised[1] = '/') or + ((Length(Normalised) >= 2) and (Normalised[2] = ':')) then + Fail(APath + ' must be relative to the configuration directory: ' + + AValue); + Depth := 0; + Start := 1; + while Start <= Length(Normalised) do + begin + Stop := Start; + while (Stop <= Length(Normalised)) and (Normalised[Stop] <> '/') do + Inc(Stop); + Segment := Copy(Normalised, Start, Stop - Start); + if Segment = '' then + Fail(APath + ' contains an empty path segment: ' + AValue) + else if Segment = '.' then + Fail(APath + ' must not contain "." segments: ' + AValue) + else if Segment = '..' then + begin + Dec(Depth); + if Depth < 0 then + Fail(APath + ' escapes the configuration directory: ' + AValue); + end + else + Inc(Depth); + Start := Stop + 1; + end; + Result := Normalised; +end; + +procedure AddPathArray(AObject: TJSONObject; const AKey, APath: string; + ATarget: TStrings); +var + Values: TStringList; + I: Integer; +begin + Values := TStringList.Create; + try + AddStringArray(AObject, AKey, APath, Values); + for I := 0 to Values.Count - 1 do + ATarget.Add(CheckRelativePath(Values[I], + KeyPath(APath, AKey) + '[' + IntToStr(I) + ']')); + finally + Values.Free; + end; +end; + +procedure LoadComments(const AValue, APath: string; + AConfig: TProjectConfig); +begin + if not TryParseDocumentationCommentStyles(AValue, AConfig.Comments) then + Fail(APath + ' is not a supported comment style: ' + AValue); + AConfig.HasComments := True; +end; + +procedure LoadVisibility(const AValue, APath: string; AConfig: TProjectConfig); +begin + if SameText(AValue, 'public') then + AConfig.Visibility := rvpPublicAPI + else if SameText(AValue, 'all') then + AConfig.Visibility := rvpAllDeclarations + else + Fail(APath + ' must be "public" or "all": ' + AValue); +end; + +procedure LoadProjectSection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Value: string; + Theme: TJSONData; + ThemeObject: TJSONObject; +begin + EnsureKnownKeys(AObject, + ['name', 'mark', 'repositoryUrl', 'sourceLinkTemplate', 'theme'], + 'project'); + if OptionalString(AObject, 'name', 'project', Value) then + AConfig.ProjectName := Value; + if OptionalString(AObject, 'mark', 'project', Value) then + begin + if not IsValidProjectMark(Value) then + Fail('project.mark must be 1 to 4 alphanumeric characters'); + AConfig.ProjectMark := Value; + end; + if OptionalString(AObject, 'repositoryUrl', 'project', Value) then + AConfig.RepositoryURL := Value; + if OptionalString(AObject, 'sourceLinkTemplate', 'project', Value) then + AConfig.SourceLinkTemplate := Value; + Theme := AObject.Find('theme'); + if not Assigned(Theme) then + Exit; + ExpectKind(Theme, jkObject, 'project.theme'); + ThemeObject := TJSONObject(Theme); + EnsureKnownKeys(ThemeObject, ['accent', 'accent2', 'font'], + 'project.theme'); + if OptionalString(ThemeObject, 'accent', 'project.theme', Value) then + begin + if not IsValidThemeColor(Value) then + Fail('project.theme.accent must be a #RGB, #RRGGBB, or #RRGGBBAA color'); + AConfig.ThemeAccent := Value; + end; + if OptionalString(ThemeObject, 'accent2', 'project.theme', Value) then + begin + if not IsValidThemeColor(Value) then + Fail('project.theme.accent2 must be a #RGB, #RRGGBB, or #RRGGBBAA color'); + AConfig.ThemeAccentAlt := Value; + end; + if OptionalString(ThemeObject, 'font', 'project.theme', Value) then + begin + if not IsValidThemeFont(Value) then + Fail('project.theme.font must be a safe font family name'); + AConfig.ThemeFont := Value; + end; +end; + +procedure LoadDiscoverySection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Value: Boolean; + Probe: TSourceDiscoveryOptions; + Include: TStringList; + Exclude: TStringList; + I: Integer; +begin + EnsureKnownKeys(AObject, ['recursive', 'include', 'exclude'], 'discovery'); + if OptionalBoolean(AObject, 'recursive', 'discovery', Value) then + AConfig.Recursive := Value; + { Reuse the discovery glob validator so config and CLI rules cannot drift. } + Include := TStringList.Create; + Exclude := TStringList.Create; + Probe := TSourceDiscoveryOptions.Create; + try + AddStringArray(AObject, 'include', 'discovery', Include); + AddStringArray(AObject, 'exclude', 'discovery', Exclude); + try + for I := 0 to Include.Count - 1 do + Probe.AddIncludePattern(Include[I]); + for I := 0 to Exclude.Count - 1 do + Probe.AddExcludePattern(Exclude[I]); + except + on E: EPasWeaveInputError do + Fail('discovery include/exclude ' + E.Message); + end; + for I := 0 to Probe.IncludePatterns.Count - 1 do + AConfig.IncludePatterns.Add(Probe.IncludePatterns[I]); + for I := 0 to Probe.ExcludePatterns.Count - 1 do + AConfig.ExcludePatterns.Add(Probe.ExcludePatterns[I]); + finally + Probe.Free; + Exclude.Free; + Include.Free; + end; +end; + +procedure LoadCompilerSection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Value: string; + Normalised: string; + I: Integer; +begin + EnsureKnownKeys(AObject, + ['unitPaths', 'includePaths', 'defines', 'targetOs', 'targetCpu', + 'buildMode', 'packagePaths'], 'compiler'); + AddPathArray(AObject, 'unitPaths', 'compiler', AConfig.UnitPaths); + AddPathArray(AObject, 'includePaths', 'compiler', AConfig.IncludePaths); + AddPathArray(AObject, 'packagePaths', 'compiler', AConfig.PackagePaths); + AddStringArray(AObject, 'defines', 'compiler', AConfig.Defines); + for I := 0 to AConfig.Defines.Count - 1 do + if not IsValidConditionalDefine(AConfig.Defines[I]) then + Fail('compiler.defines[' + IntToStr(I) + + '] must be a plain Pascal identifier: ' + AConfig.Defines[I]); + if OptionalString(AObject, 'targetOs', 'compiler', Value) then + begin + if not TryNormaliseTargetOS(Value, Normalised) then + Fail('compiler.targetOs is not a supported FPC target: ' + Value); + AConfig.TargetOS := Normalised; + end; + if OptionalString(AObject, 'targetCpu', 'compiler', Value) then + begin + if not TryNormaliseTargetCPU(Value, Normalised) then + Fail('compiler.targetCpu is not a supported FPC target: ' + Value); + AConfig.TargetCPU := Normalised; + end; + if OptionalString(AObject, 'buildMode', 'compiler', Value) then + begin + if Trim(Value) = '' then + Fail('compiler.buildMode must not be empty'); + AConfig.BuildMode := Value; + end; +end; + +procedure LoadCoverageSection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Minimum: Integer; + Value: string; + Severity: TDiagnosticSeverity; +begin + EnsureKnownKeys(AObject, ['minimum', 'failOn'], 'coverage'); + if OptionalInteger(AObject, 'minimum', 'coverage', Minimum) then + begin + if (Minimum < 0) or (Minimum > 100) then + Fail('coverage.minimum must be an integer from 0 to 100'); + AConfig.MinimumCoverage := Minimum; + AConfig.HasMinimumCoverage := True; + end; + if OptionalString(AObject, 'failOn', 'coverage', Value) then + begin + if not TryParseDiagnosticSeverity(Value, Severity) then + Fail('coverage.failOn must be warning or error'); + AConfig.FailOn := DiagnosticSeverityName(Severity); + end; +end; + +constructor TProjectConfig.Create; +begin + inherited Create; + Visibility := rvpPublicAPI; + Comments := DefaultDocumentationCommentStyles; + IncludePatterns := TStringList.Create; + ExcludePatterns := TStringList.Create; + UnitPaths := TStringList.Create; + IncludePaths := TStringList.Create; + Defines := TStringList.Create; + PackagePaths := TStringList.Create; +end; + +destructor TProjectConfig.Destroy; +begin + PackagePaths.Free; + Defines.Free; + IncludePaths.Free; + UnitPaths.Free; + ExcludePatterns.Free; + IncludePatterns.Free; + inherited Destroy; +end; + +function VisibilityPolicyName(APolicy: TRenderVisibilityPolicy): string; +begin + case APolicy of + rvpPublicAPI: Result := 'public'; + rvpAllDeclarations: Result := 'all'; + end; +end; + +function LoadProjectConfig(const AFilename: string): TProjectConfig; +var + Root: TJSONData; + RootObject: TJSONObject; + Section: TJSONData; + Version: Integer; + Value: string; +begin + if not FileExists(AFilename) then + raise EProjectConfigError.CreateFmt( + 'configuration file does not exist: %s', [AFilename]); + try + Root := GetJSON(ReadFileToString(AFilename)); + except + on E: Exception do + raise EProjectConfigError.CreateFmt( + 'configuration file is not valid JSON: %s: %s', + [AFilename, E.Message]); + end; + try + if Root.JSONType <> jtObject then + Fail('configuration root must be a JSON object'); + RootObject := TJSONObject(Root); + EnsureKnownKeys(RootObject, + ['version', 'source', 'output', 'project', 'comments', 'discovery', + 'compiler', 'visibility', 'coverage'], ''); + if not OptionalInteger(RootObject, 'version', '', Version) then + Fail('configuration is missing the required version key'); + if Version <> ProjectConfigVersion then + Fail(Format('unsupported configuration version: %d (expected %d)', + [Version, ProjectConfigVersion])); + + Result := TProjectConfig.Create; + try + Result.Filename := ExpandFileName(AFilename); + Result.Directory := ExtractFileDir(Result.Filename); + if OptionalString(RootObject, 'source', '', Value) then + Result.SourcePath := CheckRelativePath(Value, 'source'); + if OptionalString(RootObject, 'output', '', Value) then + Result.OutputPath := CheckRelativePath(Value, 'output'); + if OptionalString(RootObject, 'comments', '', Value) then + LoadComments(Value, 'comments', Result); + if OptionalString(RootObject, 'visibility', '', Value) then + LoadVisibility(Value, 'visibility', Result); + + Section := RootObject.Find('project'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'project'); + LoadProjectSection(TJSONObject(Section), Result); + end; + Section := RootObject.Find('discovery'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'discovery'); + LoadDiscoverySection(TJSONObject(Section), Result); + end; + Section := RootObject.Find('compiler'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'compiler'); + LoadCompilerSection(TJSONObject(Section), Result); + end; + Section := RootObject.Find('coverage'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'coverage'); + LoadCoverageSection(TJSONObject(Section), Result); + end; + except + Result.Free; + raise; + end; + finally + Root.Free; + end; +end; + +end. diff --git a/tests/PasWeave.ConfigTests.pas b/tests/PasWeave.ConfigTests.pas new file mode 100644 index 0000000..6491e6d --- /dev/null +++ b/tests/PasWeave.ConfigTests.pas @@ -0,0 +1,205 @@ +unit PasWeave.ConfigTests; + +{$mode objfpc}{$H+}{$J+} + +interface + +procedure RunConfigTests; + +implementation + +uses + SysUtils, PasWeave.Comments, PasWeave.Config, PasWeave.TestSupport; + +const + ConfigDirectory = 'build/config-test'; + +procedure WriteConfig(const AName, AJSON: string); +begin + WriteTextFile(ConfigDirectory + '/' + AName, AJSON); +end; + +procedure ExpectRejected(const AName, AJSON, AExpectedFragment: string); +var + ErrorMessage: string; +begin + WriteConfig(AName, AJSON); + ErrorMessage := ''; + try + LoadProjectConfig(ConfigDirectory + '/' + AName).Free; + except + on E: EProjectConfigError do + ErrorMessage := E.Message; + end; + Check(ErrorMessage <> '', AName + ' should be rejected'); + Check(Pos(AExpectedFragment, ErrorMessage) > 0, + AName + ' should explain the problem (got: ' + ErrorMessage + ')'); +end; + +procedure CheckValidConfiguration; +var + Config: TProjectConfig; +begin + WriteConfig('valid.json', + '{' + + ' "version": 1,' + + ' "source": "src",' + + ' "output": "build/docs",' + + ' "comments": "slash, brace",' + + ' "visibility": "all",' + + ' "project": {' + + ' "name": "Config Fixture",' + + ' "mark": "CFG",' + + ' "repositoryUrl": "https://github.com/example/project",' + + ' "sourceLinkTemplate": "blob/main/{path}#L{line}",' + + ' "theme": {' + + ' "accent": "#123456",' + + ' "accent2": "#abcdef",' + + ' "font": "Avenir Next"' + + ' }' + + ' },' + + ' "discovery": {' + + ' "recursive": true,' + + ' "include": ["src/**"],' + + ' "exclude": ["generated/**", "vendor/**"]' + + ' },' + + ' "compiler": {' + + ' "unitPaths": ["packages/core/src"],' + + ' "includePaths": ["include"],' + + ' "defines": ["USE_FAST_MATH"],' + + ' "targetOs": "macOS",' + + ' "targetCpu": "arm64",' + + ' "buildMode": "Release",' + + ' "packagePaths": ["packages"]' + + ' },' + + ' "coverage": {' + + ' "minimum": 90,' + + ' "failOn": "warning"' + + ' }' + + '}'); + Config := LoadProjectConfig(ConfigDirectory + '/valid.json'); + try + Check(Config.SourcePath = 'src', 'source should be retained'); + Check(Config.OutputPath = 'build/docs', 'output should be retained'); + Check(Config.HasComments and (Config.Comments = + [dcsSlash, dcsBrace]), 'comment styles should parse'); + Check(Config.Visibility = rvpAllDeclarations, + 'visibility "all" should select every declaration'); + Check(Config.ProjectName = 'Config Fixture', 'project name should parse'); + Check(Config.ProjectMark = 'CFG', 'project mark should parse'); + Check(Config.RepositoryURL = 'https://github.com/example/project', + 'repository URL should parse'); + Check(Config.SourceLinkTemplate = 'blob/main/{path}#L{line}', + 'source-link template should parse'); + Check((Config.ThemeAccent = '#123456') and + (Config.ThemeAccentAlt = '#abcdef') and + (Config.ThemeFont = 'Avenir Next'), 'theme tokens should parse'); + Check(Config.Recursive, 'recursive discovery should parse'); + Check((Config.IncludePatterns.Count = 1) and + (Config.IncludePatterns[0] = 'src/**'), 'include globs should parse'); + Check((Config.ExcludePatterns.Count = 2) and + (Config.ExcludePatterns[1] = 'vendor/**'), 'exclude globs should parse'); + Check((Config.UnitPaths.Count = 1) and + (Config.UnitPaths[0] = 'packages/core/src'), 'unit paths should parse'); + Check((Config.IncludePaths.Count = 1) and + (Config.IncludePaths[0] = 'include'), 'include paths should parse'); + Check((Config.Defines.Count = 1) and + (Config.Defines[0] = 'USE_FAST_MATH'), 'defines should parse'); + Check((Config.TargetOS = 'darwin') and (Config.TargetCPU = 'aarch64'), + 'target aliases should normalize like the CLI'); + Check(Config.BuildMode = 'Release', 'build mode should parse'); + Check((Config.PackagePaths.Count = 1) and + (Config.PackagePaths[0] = 'packages'), 'package paths should parse'); + Check(Config.HasMinimumCoverage and (Config.MinimumCoverage = 90), + 'coverage minimum should parse'); + Check(Config.FailOn = 'warning', 'failure threshold should parse'); + finally + Config.Free; + end; +end; + +procedure CheckMinimalConfiguration; +var + Config: TProjectConfig; +begin + WriteConfig('minimal.json', '{"version": 1}'); + Config := LoadProjectConfig(ConfigDirectory + '/minimal.json'); + try + Check((Config.SourcePath = '') and (Config.OutputPath = ''), + 'absent paths should stay empty for CLI defaults'); + Check(not Config.HasComments and (Config.Comments = + DefaultDocumentationCommentStyles), 'absent comments should use default'); + Check(Config.Visibility = rvpPublicAPI, + 'absent visibility should use the public API policy'); + Check(not Config.HasMinimumCoverage, 'absent coverage should stay unset'); + finally + Config.Free; + end; +end; + +procedure CheckRejectedConfigurations; +begin + ExpectRejected('unknown-root.json', + '{"version": 1, "sourcez": "src"}', 'unknown configuration key'); + ExpectRejected('unknown-nested.json', + '{"version": 1, "project": {"naem": "x"}}', + 'unknown configuration key: project.naem'); + ExpectRejected('wrong-type.json', '{"version": 1, "source": 5}', + 'wrong type: source'); + ExpectRejected('missing-version.json', '{"source": "src"}', + 'missing the required version'); + ExpectRejected('future-version.json', '{"version": 2}', + 'unsupported configuration version'); + ExpectRejected('non-object-root.json', '[1, 2]', + 'root must be a JSON object'); + ExpectRejected('invalid-json.json', '{not json', + 'not valid JSON'); + ExpectRejected('absolute-path.json', '{"version": 1, "source": "/etc"}', + 'must be relative'); + ExpectRejected('traversal.json', '{"version": 1, "source": "../outside"}', + 'escapes the configuration directory'); + ExpectRejected('nested-traversal.json', + '{"version": 1, "compiler": {"unitPaths": ["a/../../b"]}}', + 'escapes the configuration directory'); + ExpectRejected('empty-path.json', '{"version": 1, "output": ""}', + 'must not be empty'); + ExpectRejected('bad-comments.json', '{"version": 1, "comments": "stars"}', + 'not a supported comment style'); + ExpectRejected('bad-visibility.json', '{"version": 1, "visibility": "none"}', + 'must be "public" or "all"'); + ExpectRejected('bad-mark.json', '{"version": 1, "project": {"mark": "TOOLONG"}}', + 'mark must be 1 to 4 alphanumeric'); + ExpectRejected('bad-color.json', + '{"version": 1, "project": {"theme": {"accent": "red"}}}', + 'accent must be a #RGB'); + ExpectRejected('bad-font.json', + '{"version": 1, "project": {"theme": {"font": "x;}"}}}', + 'font must be a safe font family name'); + ExpectRejected('bad-target-os.json', + '{"version": 1, "compiler": {"targetOs": "plan9"}}', + 'not a supported FPC target'); + ExpectRejected('bad-target-cpu.json', + '{"version": 1, "compiler": {"targetCpu": "z80"}}', + 'not a supported FPC target'); + ExpectRejected('bad-define.json', '{"version": 1, "compiler": {"defines": ["2BAD"]}}', + 'plain Pascal identifier'); + ExpectRejected('bad-coverage.json', '{"version": 1, "coverage": {"minimum": 101}}', + 'integer from 0 to 100'); + ExpectRejected('bad-fail-on.json', '{"version": 1, "coverage": {"failOn": "info"}}', + 'must be warning or error'); + ExpectRejected('bad-glob.json', + '{"version": 1, "discovery": {"exclude": ["../outside"]}}', + 'discovery include/exclude'); +end; + +procedure RunConfigTests; +begin + BeginTest('project configuration'); + DeleteTree(ConfigDirectory); + CheckValidConfiguration; + CheckMinimalConfiguration; + CheckRejectedConfigurations; + DeleteTree(ConfigDirectory); +end; + +end. diff --git a/tests/test_pasweave.pas b/tests/test_pasweave.pas index 67b7d4e..a01eff2 100644 --- a/tests/test_pasweave.pas +++ b/tests/test_pasweave.pas @@ -11,7 +11,7 @@ PasWeave.Render.Markdown, PasWeave.Render.HTML, PasWeave.Render.HTML.Markdown, PasWeave.Render.HTML.Assets, PasWeave.NavigationTests, PasWeave.SymbolIndexAndThemeTests, - PasWeave.ValidationTests, PasWeave.IncrementalTests, + PasWeave.ValidationTests, PasWeave.IncrementalTests, PasWeave.ConfigTests, PasWeave.TestSupport, PasWeave.Version; procedure CheckDocumentedExampleGoldens; @@ -1657,6 +1657,7 @@ procedure RunCase(const AName: string; ACase: TTestCase); RunCase('mathematics', @CheckMathematics); RunCase('comment dialects', @CheckCommentDialects); RunCase('source discovery', @CheckSourceDiscovery); + RunCase('project configuration', @RunConfigTests); RunCase('partial failure', @CheckPartialFailure); if Failures > 0 then begin From 2022fcb1a24dfc60ce120c7f947b85ef1a9bcefe Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Sat, 19 Sep 2026 11:57:54 +1000 Subject: [PATCH 2/3] feat(config): apply pasweave.json in the build pipeline Load --config before option parsing so explicit CLI values override the file, with any CLI instance of a repeatable option replacing the configured list. Add --no-recursive and --visibility=public|all, include the config file in the input fingerprint, record the effective configuration in api-model.json and the fingerprint, and report the configuration file in the build summary. Config failures exit with code 2. The visibility policy lives in the model, is applied through PasWeave.Render.Support, and defaults to the public API so existing output is unchanged. CLI and config tests cover config-only builds, precedence, config-relative output, model exposure, strict rejection, and rendering private members under visibility=all. --- src/cli/PasWeave.CLI.pas | 247 ++++++++++++++++++++++++- src/cli/PasWeave.Config.pas | 6 - src/model/PasWeave.Model.JSON.pas | 2 + src/model/PasWeave.Model.pas | 12 ++ src/render/PasWeave.Render.Support.pas | 23 ++- tests/PasWeave.ConfigTests.pas | 3 +- tests/test_cli.pas | 89 +++++++++ 7 files changed, 364 insertions(+), 18 deletions(-) diff --git a/src/cli/PasWeave.CLI.pas b/src/cli/PasWeave.CLI.pas index 54968fe..d11f368 100644 --- a/src/cli/PasWeave.CLI.pas +++ b/src/cli/PasWeave.CLI.pas @@ -19,12 +19,13 @@ function RunPasWeave: Integer; implementation uses - Classes, SysUtils, PasWeave.Comments, PasWeave.Compiler, + Classes, SysUtils, PasWeave.Comments, PasWeave.Compiler, PasWeave.Config, PasWeave.Diagnostics, PasWeave.Incremental, PasWeave.Lazarus, PasWeave.Lazarus.Support, PasWeave.Model, PasWeave.Model.JSON, PasWeave.Output, PasWeave.Parser, PasWeave.SourceLinks, PasWeave.Render.Markdown, PasWeave.Render.HTML, - PasWeave.Render.HTML.Assets, PasWeave.Validation, PasWeave.Version; + PasWeave.Render.HTML.Assets, PasWeave.Render.Support, PasWeave.Validation, + PasWeave.Version; procedure PrintUsage; begin @@ -32,9 +33,11 @@ procedure PrintUsage; WriteLn; WriteLn('Usage:'); WriteLn(' pasweave --version'); - WriteLn(' pasweave build [--output ]'); + WriteLn(' pasweave build [] [--output ]'); + WriteLn(' [--config=] [--visibility=]'); WriteLn(' [--project-name ] [--doc-comments=]'); - WriteLn(' [--recursive] [--include=] [--exclude=]'); + WriteLn(' [--recursive|--no-recursive] [--include=]'); + WriteLn(' [--exclude=]'); WriteLn(' [--unit-path=] [--include-path=]'); WriteLn(' [--define=] [--target-os=] [--target-cpu=]'); WriteLn(' [--build-mode=] [--package-path=]'); @@ -84,6 +87,14 @@ procedure PrintUsage; WriteLn(' --theme-font= sets the primary body font family'); WriteLn(' defaults reproduce the built-in light and dark reader schemes'); WriteLn; + WriteLn('Project configuration:'); + WriteLn(' --config= loads a versioned project configuration'); + WriteLn(' explicit command-line values override configuration values'); + WriteLn(' relative file paths resolve from the configuration directory'); + WriteLn(' --no-recursive disables recursion inherited from a configuration'); + WriteLn(' --visibility=public (default) documents the public API only'); + WriteLn(' --visibility=all also documents private declarations'); + WriteLn; WriteLn('Documentation comment styles:'); WriteLn(' slash = /// lines (PasWeave convention; plain // is ignored)'); WriteLn(' brace = { ... }; paren = (* ... *)'); @@ -118,6 +129,17 @@ function MatchValueOption(const AParam, AName: string; var AIndex: Integer; AValue := Copy(AParam, Length(Prefix) + 1, MaxInt); end; +/// True when the command line asks for usage help before any build work. +function HelpRequestedOnCommandLine: Boolean; +var + Index: Integer; +begin + Result := False; + for Index := 2 to ParamCount do + if (ParamStr(Index) = '--help') or (ParamStr(Index) = '-h') then + Exit(True); +end; + procedure PrintDiagnostic(ADiagnostic: TDiagnostic; AVerbose: Boolean); var Location: string; @@ -188,6 +210,16 @@ function RunBuild: Integer; NewManifest: TManifest; NewPaths: TStringList; ParseHandled: Boolean; + ConfigFilename: string; + ConfigSource: string; + Config: TProjectConfig; + VisibilityPolicy: TRenderVisibilityPolicy; + IncludeExplicit: Boolean; + ExcludeExplicit: Boolean; + UnitPathExplicit: Boolean; + IncludePathExplicit: Boolean; + DefineExplicit: Boolean; + PackagePathExplicit: Boolean; procedure AppendConfigLine(const ALine: string); begin @@ -268,6 +300,113 @@ function RunBuild: Integer; end; end; + { Reads --config before the main parse so configuration values are applied + before CLI overrides. } + procedure ScanConfigOption; + var + Index: Integer; + Value: string; + Seen: Boolean; + begin + Seen := False; + Index := 2; + while Index <= ParamCount do + begin + if MatchValueOption(ParamStr(Index), '--config', Index, Value) then + begin + if Seen then + raise EPasWeaveInputError.Create( + 'only one --config option may be supplied'); + ConfigFilename := Value; + Seen := True; + end; + Inc(Index); + end; + end; + + { Resolves a validated configuration-relative path against the + configuration file's directory. } + function ResolveConfigPath(const AValue: string): string; + begin + Result := ExpandFileName(IncludeTrailingPathDelimiter(Config.Directory) + + AValue); + end; + + { Fills the build options from the loaded configuration. Values are applied + before ParseCommandLine, so explicit CLI values win. } + procedure ApplyProjectConfig; + var + Index: Integer; + begin + ConfigSource := Config.Filename; + VisibilityPolicy := Config.Visibility; + if Config.OutputPath <> '' then + OutputPath := ResolveConfigPath(Config.OutputPath); + if Config.SourcePath <> '' then + SourcePath := ResolveConfigPath(Config.SourcePath); + if Config.ProjectName <> '' then + begin + ProjectName := Config.ProjectName; + ProjectNameExplicit := True; + end; + if Config.ProjectMark <> '' then + begin + ProjectMark := Config.ProjectMark; + HasProjectMark := True; + end; + if Config.RepositoryURL <> '' then + RepositoryURL := Config.RepositoryURL; + if Config.SourceLinkTemplate <> '' then + SourceLinkTemplate := Config.SourceLinkTemplate; + if Config.ThemeAccent <> '' then + begin + ThemeAccent := Config.ThemeAccent; + HasThemeAccent := True; + end; + if Config.ThemeAccentAlt <> '' then + begin + ThemeAccentAlt := Config.ThemeAccentAlt; + HasThemeAccentAlt := True; + end; + if Config.ThemeFont <> '' then + begin + ThemeFont := Config.ThemeFont; + HasThemeFont := True; + end; + if Config.HasComments then + CommentStyles := Config.Comments; + if Config.Recursive then + DiscoveryOptions.Recursive := True; + for Index := 0 to Config.IncludePatterns.Count - 1 do + DiscoveryOptions.AddIncludePattern(Config.IncludePatterns[Index]); + for Index := 0 to Config.ExcludePatterns.Count - 1 do + DiscoveryOptions.AddExcludePattern(Config.ExcludePatterns[Index]); + for Index := 0 to Config.UnitPaths.Count - 1 do + CompilerOptions.AddUnitPath(ResolveConfigPath(Config.UnitPaths[Index])); + for Index := 0 to Config.IncludePaths.Count - 1 do + CompilerOptions.AddIncludePath( + ResolveConfigPath(Config.IncludePaths[Index])); + for Index := 0 to Config.Defines.Count - 1 do + CompilerOptions.AddDefine(Config.Defines[Index]); + if Config.TargetOS <> '' then + CompilerOptions.SetTargetOS(Config.TargetOS); + if Config.TargetCPU <> '' then + CompilerOptions.SetTargetCPU(Config.TargetCPU); + if Config.BuildMode <> '' then + BuildMode := Config.BuildMode; + for Index := 0 to Config.PackagePaths.Count - 1 do + PackagePaths.Add(ResolveConfigPath(Config.PackagePaths[Index])); + if Config.HasMinimumCoverage then + begin + MinimumCoverage := Config.MinimumCoverage; + HasMinimumCoverage := True; + end; + if (Config.FailOn <> '') and + not TryParseDiagnosticSeverity(Config.FailOn, FailureSeverity) then + raise EPasWeaveInputError.Create( + 'configuration coverage.failOn is invalid'); + end; + procedure ParseCommandLine; var OptionValue: string; @@ -291,18 +430,71 @@ function RunBuild: Integer; 'a comma-separated combination, or all)', [OptionValue]); end + else if MatchValueOption(ParamStr(I), '--config', I, OptionValue) then + begin + { Already read by ScanConfigOption; keep the parser total. } + if ConfigFilename = '' then + ConfigFilename := OptionValue; + end + else if MatchValueOption(ParamStr(I), '--visibility', I, OptionValue) then + begin + if SameText(OptionValue, 'public') then + VisibilityPolicy := rvpPublicAPI + else if SameText(OptionValue, 'all') then + VisibilityPolicy := rvpAllDeclarations + else + raise EPasWeaveInputError.Create( + '--visibility must be public or all'); + end else if ParamStr(I) = '--recursive' then DiscoveryOptions.Recursive := True + else if ParamStr(I) = '--no-recursive' then + DiscoveryOptions.Recursive := False else if MatchValueOption(ParamStr(I), '--include', I, OptionValue) then - DiscoveryOptions.AddIncludePattern(OptionValue) + begin + if not IncludeExplicit then + begin + DiscoveryOptions.IncludePatterns.Clear; + IncludeExplicit := True; + end; + DiscoveryOptions.AddIncludePattern(OptionValue); + end else if MatchValueOption(ParamStr(I), '--exclude', I, OptionValue) then - DiscoveryOptions.AddExcludePattern(OptionValue) + begin + if not ExcludeExplicit then + begin + DiscoveryOptions.ExcludePatterns.Clear; + ExcludeExplicit := True; + end; + DiscoveryOptions.AddExcludePattern(OptionValue); + end else if MatchValueOption(ParamStr(I), '--unit-path', I, OptionValue) then - CompilerOptions.AddUnitPath(OptionValue) + begin + if not UnitPathExplicit then + begin + CompilerOptions.UnitPaths.Clear; + UnitPathExplicit := True; + end; + CompilerOptions.AddUnitPath(OptionValue); + end else if MatchValueOption(ParamStr(I), '--include-path', I, OptionValue) then - CompilerOptions.AddIncludePath(OptionValue) + begin + if not IncludePathExplicit then + begin + CompilerOptions.IncludePaths.Clear; + IncludePathExplicit := True; + end; + CompilerOptions.AddIncludePath(OptionValue); + end else if MatchValueOption(ParamStr(I), '--define', I, OptionValue) then - CompilerOptions.AddDefine(OptionValue) + begin + if not DefineExplicit then + begin + CompilerOptions.Defines.Clear; + DefineExplicit := True; + end; + CompilerOptions.AddDefine(OptionValue); + end else if MatchValueOption(ParamStr(I), '--target-os', I, OptionValue) then CompilerOptions.SetTargetOS(OptionValue) else if MatchValueOption(ParamStr(I), '--target-cpu', I, OptionValue) then @@ -310,7 +502,14 @@ function RunBuild: Integer; else if MatchValueOption(ParamStr(I), '--build-mode', I, OptionValue) then BuildMode := OptionValue else if MatchValueOption(ParamStr(I), '--package-path', I, OptionValue) then - PackagePaths.Add(OptionValue) + begin + if not PackagePathExplicit then + begin + PackagePaths.Clear; + PackagePathExplicit := True; + end; + PackagePaths.Add(OptionValue); + end else if MatchValueOption(ParamStr(I), '--repository-url', I, OptionValue) then RepositoryURL := OptionValue else if MatchValueOption(ParamStr(I), '--source-link-template', I, OptionValue) then @@ -385,6 +584,7 @@ function RunBuild: Integer; var I: Integer; begin + SetRenderVisibilityPolicy(VisibilityPolicy); if SourcePath = '' then raise EPasWeaveInputError.Create('missing unit or source directory'); { Validate before fingerprinting so a typo reports a clean input error @@ -458,6 +658,10 @@ function RunBuild: Integer; AppendConfigValue('fail-on', DiagnosticSeverityName(FailureSeverity)); AppendConfigValue('output', StringReplace(OutputPath, '\', '/', [rfReplaceAll])); + AppendConfigValue('visibility', VisibilityPolicyName(VisibilityPolicy)); + if ConfigSource <> '' then + AppendConfigValue('config', StringReplace(ConfigSource, '\', '/', + [rfReplaceAll])); if IsLazarusInput then InputFiles := EnumerateInputFiles(SourcePath, True, nil, CompilerOptions, @@ -465,6 +669,8 @@ function RunBuild: Integer; else InputFiles := EnumerateInputFiles(SourcePath, False, DiscoveryOptions, CompilerOptions, nil, nil); + if ConfigSource <> '' then + InputFiles.Add(StringReplace(ConfigSource, '\', '/', [rfReplaceAll])); try Fingerprint := ComputeBuildFingerprint(ConfigText, InputFiles, ThirdPartyAssetFingerprint); @@ -526,6 +732,10 @@ function RunBuild: Integer; Project.ThemeAccentAlt := ThemeAccentAlt; if HasThemeFont then Project.ThemeFont := ThemeFont; + Project.ConfigurationSource := ConfigSource; + Project.ConfigurationText := ConfigText; + if ConfigSource <> '' then + WriteLn('Using configuration ', ConfigSource); if HasMinimumCoverage then AddDocumentationCoverageDiagnostic(Project, MinimumCoverage); OutputFile := IncludeTrailingPathDelimiter(OutputPath) + 'api-model.json'; @@ -625,6 +835,17 @@ function RunBuild: Integer; LazarusConfiguration := nil; ParseHandled := False; try + ScanConfigOption; + if (ConfigFilename <> '') and not HelpRequestedOnCommandLine then + begin + Config := LoadProjectConfig(ConfigFilename); + try + ApplyProjectConfig; + finally + Config.Free; + Config := nil; + end; + end; ParseCommandLine; if ParseHandled then Exit(0); @@ -666,6 +887,12 @@ function RunPasWeave: Integer; WriteLn(StdErr, 'Run "pasweave --help" for usage.'); Result := 2; end; + on E: EProjectConfigError do + begin + WriteLn(StdErr, 'pasweave: ', E.Message); + WriteLn(StdErr, 'Run "pasweave --help" for usage.'); + Result := 2; + end; on E: ECompilerConfigurationError do begin WriteLn(StdErr, 'pasweave: ', E.Message); diff --git a/src/cli/PasWeave.Config.pas b/src/cli/PasWeave.Config.pas index 27eceec..e650fe1 100644 --- a/src/cli/PasWeave.Config.pas +++ b/src/cli/PasWeave.Config.pas @@ -24,12 +24,6 @@ interface /// Raised for any invalid, unreadable, or unsupported configuration. EProjectConfigError = class(Exception); - /// Which declarations become part of browseable documentation. - TRenderVisibilityPolicy = ( - rvpPublicAPI, - rvpAllDeclarations - ); - /// Parsed and validated `pasweave.json` contents. TProjectConfig = class public diff --git a/src/model/PasWeave.Model.JSON.pas b/src/model/PasWeave.Model.JSON.pas index 2aea016..1fb5252 100644 --- a/src/model/PasWeave.Model.JSON.pas +++ b/src/model/PasWeave.Model.JSON.pas @@ -226,6 +226,8 @@ function ProjectToJSON(AProject: TDocProject): UTF8String; Root.Add('themeAccent', AProject.ThemeAccent); Root.Add('themeAccentAlt', AProject.ThemeAccentAlt); Root.Add('themeFont', AProject.ThemeFont); + Root.Add('configurationSource', AProject.ConfigurationSource); + Root.Add('configuration', AProject.ConfigurationText); Root.Add('units', UnitsToJSON(AProject.Units)); Root.Add('warnings', DiagnosticListToJSON(AProject.Warnings)); Root.Add('errors', DiagnosticListToJSON(AProject.Errors)); diff --git a/src/model/PasWeave.Model.pas b/src/model/PasWeave.Model.pas index d450a38..d939291 100644 --- a/src/model/PasWeave.Model.pas +++ b/src/model/PasWeave.Model.pas @@ -44,6 +44,14 @@ interface trkImplementation ); + /// Which declarations become part of browseable documentation and coverage. + TRenderVisibilityPolicy = ( + /// Public API only: private and strict-private stay JSON-only. + rvpPublicAPI, + /// Every declaration, including private and strict-private members. + rvpAllDeclarations + ); + /// One structured documentation directive from a comment group. TDocDirective = class public @@ -136,6 +144,10 @@ TDocProject = class ThemeAccentAlt: string; /// Body font family; see @link(IsValidThemeFont). ThemeFont: string; + /// Path of the project configuration used, when any. + ConfigurationSource: string; + /// Normalized effective configuration text used for the build fingerprint. + ConfigurationText: string; /// Owned `TDocUnit` objects in discovery order. Units: TObjectList; /// Owned warnings (`TDiagnostic`). diff --git a/src/render/PasWeave.Render.Support.pas b/src/render/PasWeave.Render.Support.pas index 41b2072..b56a3c1 100644 --- a/src/render/PasWeave.Render.Support.pas +++ b/src/render/PasWeave.Render.Support.pas @@ -48,6 +48,13 @@ function FindUnitByName(AProject: TDocProject; const AName: string): TDocUnit; function IsDirectlyRenderable(ASymbol: TDocSymbol): Boolean; function IsEffectivelyRenderable(AUnit: TDocUnit; ASymbol: TDocSymbol): Boolean; +/// Selects which declarations renderers and validation treat as documented. +/// +/// Defaults to the public API policy; the CLI applies the effective project +/// configuration before any rendering or validation runs. +procedure SetRenderVisibilityPolicy(APolicy: TRenderVisibilityPolicy); +/// Returns the active visibility policy. +function RenderVisibilityPolicy: TRenderVisibilityPolicy; function SortedSymbols(AUnit: TDocUnit; AKinds: TSymbolKinds): TStringList; function UnitSymbol(AUnit: TDocUnit): TDocSymbol; function IndexedSymbolCount(AUnit: TDocUnit): Integer; @@ -60,6 +67,9 @@ implementation uses SysUtils; +var + GRenderVisibilityPolicy: TRenderVisibilityPolicy = rvpPublicAPI; + procedure AppendLine(var AOutput: UTF8String; const ALine: UTF8String = ''); begin AOutput := AOutput + ALine + #10; @@ -241,7 +251,18 @@ function FindUnitByName(AProject: TDocProject; const AName: string): TDocUnit; function IsDirectlyRenderable(ASymbol: TDocSymbol): Boolean; begin - Result := not (ASymbol.Visibility in [svPrivate, svStrictPrivate]); + Result := (GRenderVisibilityPolicy = rvpAllDeclarations) or + not (ASymbol.Visibility in [svPrivate, svStrictPrivate]); +end; + +procedure SetRenderVisibilityPolicy(APolicy: TRenderVisibilityPolicy); +begin + GRenderVisibilityPolicy := APolicy; +end; + +function RenderVisibilityPolicy: TRenderVisibilityPolicy; +begin + Result := GRenderVisibilityPolicy; end; function IsEffectivelyRenderable(AUnit: TDocUnit; diff --git a/tests/PasWeave.ConfigTests.pas b/tests/PasWeave.ConfigTests.pas index 6491e6d..29acb3d 100644 --- a/tests/PasWeave.ConfigTests.pas +++ b/tests/PasWeave.ConfigTests.pas @@ -9,7 +9,8 @@ procedure RunConfigTests; implementation uses - SysUtils, PasWeave.Comments, PasWeave.Config, PasWeave.TestSupport; + SysUtils, PasWeave.Comments, PasWeave.Config, PasWeave.Model, + PasWeave.TestSupport; const ConfigDirectory = 'build/config-test'; diff --git a/tests/test_cli.pas b/tests/test_cli.pas index 8db9afb..3e9cb9a 100644 --- a/tests/test_cli.pas +++ b/tests/test_cli.pas @@ -248,6 +248,94 @@ procedure CheckFailurePolicies; Pos('[error ', StdOut) > 0); end; +procedure CheckProjectConfiguration; +const + ProjectDirectory = 'build/cli-test/config-project'; + ConfigFile = ProjectDirectory + '/pasweave.json'; +var + StdOut: string; + StdErr: string; + ExitCode: Integer; + ModelJSON: string; +begin + DeleteTree(ProjectDirectory); + WriteTextFile(ProjectDirectory + '/src/ConfigUnit.pas', + 'unit ConfigUnit;' + #10 + + '{$mode objfpc}{$H+}{$J+}' + #10 + + 'interface' + #10 + + 'type' + #10 + + ' TBox = class' + #10 + + ' private' + #10 + + ' FSecret: Integer;' + #10 + + ' public' + #10 + + ' function Value: Integer;' + #10 + + ' end;' + #10 + + 'function Add(const A, B: Integer): Integer;' + #10 + + 'implementation' + #10 + + 'end.' + #10); + WriteTextFile(ConfigFile, + '{' + + ' "version": 1,' + + ' "source": "src",' + + ' "output": "docs",' + + ' "project": {"name": "Configured Project", "mark": "CFG"},' + + ' "comments": "slash",' + + ' "visibility": "public",' + + ' "coverage": {"minimum": 0, "failOn": "error"}' + + '}'); + + ExitCode := RunCli(['build', '--config=' + ConfigFile], StdOut, StdErr); + Expect('a configuration build should exit 0', ExitCode = 0); + Expect('a configuration build should name the config file', + Pos('Using configuration', StdOut) > 0); + Expect('config-relative output should be written', + FileExists(ProjectDirectory + '/docs/html/index.html')); + ModelJSON := string(ReadUTF8File(ProjectDirectory + + '/docs/api-model.json')); + Expect('the model should record the configuration source', + Pos('"configurationSource"', ModelJSON) > 0); + Expect('the model should record the effective configuration', + Pos('"configuration"', ModelJSON) > 0); + Expect('the configured project name should reach the model', + Pos('Configured Project', ModelJSON) > 0); + Expect('the default visibility policy should exclude private members', + Pos('FSecret', string(ReadUTF8File(ProjectDirectory + + '/docs/html/units/ConfigUnit.html'))) = 0); + + ExitCode := RunCli(['build', '--config=' + ConfigFile, '--clean', + '--visibility=all'], StdOut, StdErr); + Expect('the all-declarations policy should exit 0', ExitCode = 0); + Expect('the all-declarations policy should render private members', + Pos('FSecret', string(ReadUTF8File(ProjectDirectory + + '/docs/html/units/ConfigUnit.html'))) > 0); + + ExitCode := RunCli(['build', '--config=' + ConfigFile, + '--project-name', 'CLI Override'], StdOut, StdErr); + Expect('CLI overrides with a configuration should exit 0', ExitCode = 0); + ModelJSON := string(ReadUTF8File(ProjectDirectory + + '/docs/api-model.json')); + Expect('explicit CLI values should override the configuration', + Pos('CLI Override', ModelJSON) > 0); + + WriteTextFile(ProjectDirectory + '/bad.json', + '{"version": 1, "unknownKey": true}'); + ExitCode := RunCli(['build', '--config=' + ProjectDirectory + '/bad.json'], + StdOut, StdErr); + Expect('an unknown configuration key should exit 2', ExitCode = 2); + Expect('an unknown configuration key should be explained', + Pos('unknown configuration key', StdErr) > 0); + + ExitCode := RunCli(['build', '--config=' + ProjectDirectory + + '/missing.json'], StdOut, StdErr); + Expect('a missing configuration file should exit 2', ExitCode = 2); + + ExitCode := RunCli(['build', '--config=' + ConfigFile, '--visibility=none'], + StdOut, StdErr); + Expect('an invalid visibility option should exit 2', ExitCode = 2); + + DeleteTree(ProjectDirectory); +end; + procedure CheckSourceLinkValidation; var StdOut: string; @@ -269,6 +357,7 @@ procedure CheckSourceLinkValidation; RunCase('usage errors', @CheckUsageErrors); RunCase('build and incremental parity', @CheckBuildAndIncremental); RunCase('failure policies', @CheckFailurePolicies); + RunCase('project configuration', @CheckProjectConfiguration); RunCase('source link validation', @CheckSourceLinkValidation); DeleteTree('build/cli-test'); if Failures > 0 then From 7e6de6da30ab8a3acf6e4175e57d31bdabf8b835 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Sat, 19 Sep 2026 12:00:48 +1000 Subject: [PATCH 3/3] docs(config): add the project configuration guide Document the pasweave.json schema, precedence, path rules, list-replacement semantics, visibility policy, reproducibility fields, validation errors, and usage examples. Link the guide from the README documentation table and the docs index, and record the new model fields and changelog entries. --- CHANGELOG.md | 20 +++++ README.md | 15 ++++ docs/README.md | 1 + docs/generated-output.md | 5 +- docs/project-configuration.md | 148 ++++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docs/project-configuration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 121835b..df8725a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- Versioned `pasweave.json` project configuration (`--config`) covering + source, output, comment styles, visibility, project title and branding, + repository/source-link settings, discovery, compiler settings, and coverage + thresholds. Relative paths resolve from the configuration directory and + cannot escape it; unknown keys, wrong types, unsupported versions, and + invalid values are rejected with input errors (exit code 2). +- `--visibility=public|all` selects whether private declarations are + documented, and `--no-recursive` disables recursion inherited from a + configuration. +- The effective configuration is recorded in `api-model.json` + (`configuration`, `configurationSource`) and the configuration file is part + of the incremental build fingerprint. + +### Documentation + +- Added the [project configuration](docs/project-configuration.md) guide and + linked it from the README and documentation index. + ## [0.7.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index 54c127b..1a604d0 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,20 @@ PasWeave writes a deterministic `manifest.json` and only ever removes files it created before. See [safe incremental builds](docs/incremental-builds.md) for the cache key, invalidation rules, and interruption recovery. +### Reproduce a build from a committed configuration + +Commit one `pasweave.json` and rebuild with a single flag; explicit +command-line values still override it: + +~~~text +pasweave build --config=pasweave.json +pasweave build --config=pasweave.json --project-name "Nightly API" +~~~ + +See [project configuration](docs/project-configuration.md) for the schema, +precedence rules, path validation, and the effective configuration recorded in +`api-model.json`. + ## 📖 Documentation Every guide is indexed in [docs/README.md](docs/README.md). Start with @@ -239,6 +253,7 @@ Every guide is indexed in [docs/README.md](docs/README.md). Start with | Guide | What it covers | |---|---| | [Documentation index](docs/README.md) | Grouped links to every guide, ADR, and release note | +| [Project configuration](docs/project-configuration.md) | One committed `pasweave.json`, precedence, and reproducibility | | [Documentation comments](docs/documentation-comments.md) | Comment forms, association, and directives | | [Generated output](docs/generated-output.md) | HTML, Markdown, JSON, diagnostics, and exit codes | | [Source discovery](docs/source-discovery.md) | Recursion, include/exclude globs, and safety | diff --git a/docs/README.md b/docs/README.md index 5c4e962..7b745b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ page that matches your task. | Guide | What it covers | |---|---| +| [Project configuration](project-configuration.md) | `pasweave.json` schema, precedence, path rules, visibility policy, and reproducibility | | [Source discovery](source-discovery.md) | Recursion, include/exclude globs, precedence, and safety | | [Compiler-aware parsing](compiler-aware-parsing.md) | Unit/include paths, defines, targets, and precedence | | [Lazarus projects and packages](lazarus-projects.md) | `.lpi`/`.lpk` inputs, build modes, package graphs, and diagnostics | diff --git a/docs/generated-output.md b/docs/generated-output.md index 66d882c..56c1695 100644 --- a/docs/generated-output.md +++ b/docs/generated-output.md @@ -86,7 +86,10 @@ deliberately unresolved. When source links are configured, the top-level model records the normalized `repositoryUrl` and `sourceLinkTemplate`. The effective branding tokens (`projectMark`, `themeAccent`, `themeAccentAlt`, and `themeFont`) are recorded -alongside them. These fields are additive schema-v1 changes. +alongside them. `configurationSource` names the `pasweave.json` file used, and +`configuration` records the effective normalized build options (the same text +hashed into the incremental fingerprint). These fields are additive schema-v1 +changes. See the [project configuration guide](project-configuration.md). `diagnostics.json` contains the same stable diagnostic codes shown on the HTML and Markdown indexes, ready for CI systems to consume. diff --git a/docs/project-configuration.md b/docs/project-configuration.md new file mode 100644 index 0000000..dbca52a --- /dev/null +++ b/docs/project-configuration.md @@ -0,0 +1,148 @@ +# Project configuration + +A project can commit one `pasweave.json` file and reproduce a build without +restating stable options on every command line: + +~~~text +pasweave build --config=pasweave.json +~~~ + +The file is declarative data only. It cannot run scripts, load plugins, fetch +remote resources, or expand environment variables; those stay out of scope +through `v1.0.0`. + +## Precedence + +Values are applied in this order, lowest first: + +1. documented defaults (`comments: slash`, `visibility: public`, output + `build/docs`, failure threshold `error`); +2. the project configuration file; +3. explicit command-line options. + +Command-line options therefore always win. For repeatable options +(`include`, `exclude`, `unitPaths`, `includePaths`, `defines`, +`packagePaths`), the first command-line instance replaces the configured list +rather than appending to it. `--no-recursive` overrides +`discovery.recursive: true`, and `--visibility` overrides the configured +policy. + +## Paths + +Every path in the file is relative to the directory containing +`pasweave.json`: + +- `source` (input unit, directory, or Lazarus project/package); +- `output` (generated documentation root); +- `compiler.unitPaths`, `compiler.includePaths`, `compiler.packagePaths`. + +Absolute paths and parent traversal that escapes the configuration directory +are rejected, so a committed configuration stays portable and cannot read or +write outside the project. + +## Schema (version 1) + +Only the listed keys are accepted at each level; unknown keys and wrong types +are errors. + +~~~json +{ + "version": 1, + "source": "src", + "output": "build/docs", + "comments": "slash", + "visibility": "public", + "project": { + "name": "My API", + "mark": "API", + "repositoryUrl": "https://github.com/example/project", + "sourceLinkTemplate": "blob/main/{path}#L{line}", + "theme": { + "accent": "#7c3aed", + "accent2": "#0e7490", + "font": "Avenir Next" + } + }, + "discovery": { + "recursive": true, + "include": ["src/**"], + "exclude": ["generated/**", "vendor/**"] + }, + "compiler": { + "unitPaths": ["packages/core/src"], + "includePaths": ["include"], + "defines": ["USE_FAST_MATH"], + "targetOs": "linux", + "targetCpu": "aarch64", + "buildMode": "Release", + "packagePaths": ["packages"] + }, + "coverage": { + "minimum": 90, + "failOn": "warning" + } +} +~~~ + +| Key | Meaning | +|---|---| +| `version` | Required; must be `1`. | +| `source` | Input to document when no positional input is given. | +| `output` | Output root; default `build/docs`. | +| `comments` | Comment styles: `slash`, `brace`, `paren`, comma-separated, or `all`. | +| `visibility` | `public` (default) documents the public API only; `all` also documents private and strict-private declarations. | +| `project.name` | Display title; otherwise derived from the input. | +| `project.mark` | 1–4 character header mark. | +| `project.repositoryUrl` | Repository origin; must be paired with `project.sourceLinkTemplate`. | +| `project.sourceLinkTemplate` | Repository-relative template with `{path}` and `{line}`. | +| `project.theme.*` | Accent colors and body font; same validation as the CLI options. | +| `discovery.recursive` | Enables recursive discovery; `--no-recursive` overrides it. | +| `discovery.include` / `exclude` | Repeatable globs with the same rules as `--include`/`--exclude`; exclusions win. | +| `compiler.unitPaths` / `includePaths` | Search paths, validated to exist. | +| `compiler.defines` | Conditional defines; plain Pascal identifiers. | +| `compiler.targetOs` / `targetCpu` | Normalized like `--target-os`/`--target-cpu`. | +| `compiler.buildMode` | Required for `.lpi` inputs when no default mode is unique. | +| `compiler.packagePaths` | Additional Lazarus package roots. | +| `coverage.minimum` | Documentation coverage gate (`0`–`100`). | +| `coverage.failOn` | `error` (default) or `warning`. | + +## Reproducibility + +The effective normalized configuration is written to `api-model.json` as +`configuration`, alongside `configurationSource` (the file path, or empty when +none was used). The configuration file is also part of the incremental build +fingerprint, so editing it invalidates cached output. Combined with +`manifest.json`, a recorded build can be reproduced from the same commit, +configuration file, and command line. + +## Validation and errors + +Configuration problems are input errors: the CLI exits with code `2` and +prints a message naming the offending key, for example +`unknown configuration key: project.naem` or +`compiler.targetOs is not a supported FPC target: plan9`. Invalid JSON, a +missing file, an unsupported `version`, absolute paths, and escaping path +traversal are rejected the same way. + +`pasweave build --help` lists `--config`, `--visibility`, and `--no-recursive`. + +## Examples + +Document a directory with committed sources and links: + +~~~text +pasweave build --config=pasweave.json +~~~ + +Override the title for one run without editing the file: + +~~~text +pasweave build --config=pasweave.json --project-name "Nightly API" +~~~ + +Document internal declarations too (the private members are rendered, and +coverage follows the same population): + +~~~text +pasweave build --config=pasweave.json --visibility=all --clean +~~~