Repository: VKCOM/noverify
Pull request #1043 (2021-07-07 17:21:19)

linter: improved error output in PHPDoc
The line on which the error is now is the context line.
Also, the underline now highlights the desired part, rather than
the entire line (for functions, for example).

Before:
```
WARNING phpdocLint: @param for non-existing argument $package at testdata/options-resolver/OptionsResolver.php:428
    public function setDeprecated(string $option/*, string $package, string $version*/): self
                    ^^^^^^^^^^^^^
```

After:

```
WARNING phpdocLint: @param for non-existing argument $package at testdata/options-resolver/OptionsResolver.php:424
     * @param string          $package The name of the composer package that is triggering the deprecation
                              ^^^^^^^^
```


Patch:
diff --git a/src/ir/irconv/irconv.go b/src/ir/irconv/irconv.go
index 36248fa..a0616ee 100644
--- a/src/ir/irconv/irconv.go
+++ b/src/ir/irconv/irconv.go
@@ -78,7 +78,7 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		//  */
 		// $_ = fn($x) => $x->b();
 		if arrowFn, ok := out.Expr.(*ir.ArrowFunctionExpr); ok {
-			doc, found := irutil.FindPhpDoc(out.Variable)
+			doc, found := irutil.FindPhpDoc(out.Variable, false)
 			if found {
 				arrowFn.Doc = c.parsePHPDoc(doc)
 			}
diff --git a/src/ir/irutil/irtutil.go b/src/ir/irutil/irtutil.go
index 937fb49..bf2e6df 100644
--- a/src/ir/irutil/irtutil.go
+++ b/src/ir/irutil/irtutil.go
@@ -103,7 +103,7 @@ func FindWithPredicate(what ir.Node, where ir.Node, pred findPredicate) bool {
 }
 
 // FindPhpDoc searches for phpdoc by traversing all subtree and all tokens.
-func FindPhpDoc(n ir.Node) (doc string, found bool) {
+func FindPhpDoc(n ir.Node, withSuspicious bool) (doc string, found bool) {
 	Inspect(n, func(n ir.Node) (continueTraverse bool) {
 		n.IterateTokens(func(t *token.Token) (continueTraverse bool) {
 			if t.ID == token.T_DOC_COMMENT {
@@ -111,6 +111,11 @@ func FindPhpDoc(n ir.Node) (doc string, found bool) {
 				return false
 			}
 
+			if withSuspicious && t.ID == token.T_COMMENT && phpdoc.IsSuspicious(t.Value) {
+				doc = string(t.Value)
+				return false
+			}
+
 			return true
 		})
 
diff --git a/src/linter/block.go b/src/linter/block.go
index 413f010..30c9993 100644
--- a/src/linter/block.go
+++ b/src/linter/block.go
@@ -234,7 +234,10 @@ func (b *blockWalker) handleCommentToken(n ir.Node, t *token.Token) {
 	doc := phpdoc.Parse(b.r.ctx.phpdocTypeParser, string(t.Value))
 
 	if phpdoc.IsSuspicious(t.Value) {
-		b.r.Report(n, LevelWarning, "phpdocLint", "Multiline PHPDoc comment should start with /**, not /*")
+		b.r.ReportPHPDoc(PHPDocLine(n, 1),
+			LevelWarning, "phpdocLint",
+			"Multiline PHPDoc comment should start with /**, not /*",
+		)
 	}
 
 	for _, p := range doc.Parsed {
@@ -247,8 +250,11 @@ func (b *blockWalker) handleCommentToken(n ir.Node, t *token.Token) {
 		moveShapesToContext(&b.r.ctx, converted.Shapes)
 		b.r.handleClosuresFromDoc(converted.Closures)
 
-		for _, warning := range converted.Warnings {
-			b.r.Report(n, LevelNotice, "phpdocType", "%s on line %d", warning, part.Line())
+		if converted.Warning != "" {
+			b.r.ReportPHPDoc(
+				PHPDocLineField(n, part.Line(), 1),
+				LevelNotice, "phpdocType", converted.Warning,
+			)
 		}
 
 		typesMap := types.NewMapWithNormalization(b.r.ctx.typeNormalizer, converted.Types)
@@ -1165,7 +1171,7 @@ func (b *blockWalker) enterArrowFunction(fun *ir.ArrowFunctionExpr) bool {
 
 	// Check stage.
 	errors := b.r.checkPHPDoc(fun, fun.Doc, fun.Params)
-	b.r.reportPHPDocErrors(fun, errors)
+	b.r.reportPHPDocErrors(errors)
 
 	funcParams := b.r.parseFuncParams(fun.Params, doc.ParamTypes, sc, nil)
 	b.r.handleArrowFuncExpr(funcParams.params, fun.Expr, sc, b)
@@ -1190,7 +1196,7 @@ func (b *blockWalker) enterClosure(fun *ir.ClosureExpr, haveThis bool, thisType
 
 	// Check stage.
 	errors := b.r.checkPHPDoc(fun, fun.Doc, fun.Params)
-	b.r.reportPHPDocErrors(fun, errors)
+	b.r.reportPHPDocErrors(errors)
 
 	var hintReturnType types.Map
 	if typ, ok := b.r.parseTypeHintNode(fun.ReturnType); ok {
diff --git a/src/linter/phpdoc_util.go b/src/linter/phpdoc_util.go
index 8f344b4..a248955 100644
--- a/src/linter/phpdoc_util.go
+++ b/src/linter/phpdoc_util.go
@@ -12,27 +12,61 @@ import (
 	"github.com/VKCOM/noverify/src/types"
 )
 
-type phpdocErrors struct {
-	phpdocLint []string
-	phpdocType []string
+type PHPDocLocation struct {
+	Node      ir.Node
+	Line      int
+	Field     int
+	WholeLine bool
 }
 
-func (e *phpdocErrors) pushLint(format string, args ...interface{}) {
-	e.phpdocLint = append(e.phpdocLint, fmt.Sprintf(format, args...))
+func PHPDocLine(n ir.Node, line int) PHPDocLocation {
+	return PHPDocLocation{
+		Node:      n,
+		Line:      line,
+		WholeLine: true,
+	}
+}
+
+func PHPDocLineField(n ir.Node, line int, field int) PHPDocLocation {
+	return PHPDocLocation{
+		Node:  n,
+		Line:  line,
+		Field: field,
+	}
 }
 
-func (e *phpdocErrors) pushType(format string, args ...interface{}) {
-	e.phpdocType = append(e.phpdocType, fmt.Sprintf(format, args...))
+type PHPDocError struct {
+	Location PHPDocLocation
+	Message  string
+}
+
+type PHPDocErrors struct {
+	types []PHPDocError
+	lint  []PHPDocError
+}
+
+func (e *PHPDocErrors) pushType(place PHPDocLocation, format string, args ...interface{}) {
+	e.types = append(e.types, PHPDocError{
+		Location: place,
+		Message:  fmt.Sprintf(format, args...),
+	})
+}
+
+func (e *PHPDocErrors) pushLint(place PHPDocLocation, format string, args ...interface{}) {
+	e.lint = append(e.lint, PHPDocError{
+		Location: place,
+		Message:  fmt.Sprintf(format, args...),
+	})
 }
 
 type classPhpDocParseResult struct {
 	properties meta.PropertiesMap
 	methods    meta.FunctionsMap
-	errs       phpdocErrors
+	errs       PHPDocErrors
 	mixins     []string
 }
 
-func parseClassPHPDocMethod(ctx *rootContext, result *classPhpDocParseResult, part *phpdoc.RawCommentPart) {
+func parseClassPHPDocMethod(classNode ir.Node, ctx *rootContext, result *classPhpDocParseResult, part *phpdoc.RawCommentPart) {
 	// The syntax is:
 	//	@method [[static] return type] [name]([[type] [parameter]<, ...>]) [<description>]
 	// Return type and method name are mandatory.
@@ -45,15 +79,22 @@ func parseClassPHPDocMethod(ctx *rootContext, result *classPhpDocParseResult, pa
 	}
 
 	if len(params) < 2 {
-		result.errs.pushLint("line %d: @method requires return type and method name fields", part.Line())
+		result.errs.pushLint(
+			PHPDocLine(classNode, part.Line()),
+			"@method requires return type and method name fields",
+		)
 		return
 	}
 
 	typ := ctx.phpdocTypeParser.Parse(params[0])
 	converted := phpdoctypes.ToRealType(ctx.typeNormalizer.ClassFQNProvider(), typ)
 	moveShapesToContext(ctx, converted.Shapes)
-	for _, warning := range converted.Warnings {
-		result.errs.pushType("%s on line %d", warning, part.Line())
+
+	if converted.Warning != "" {
+		result.errs.pushType(
+			PHPDocLineField(classNode, part.Line(), 1),
+			converted.Warning,
+		)
 	}
 
 	var methodName string
@@ -62,7 +103,11 @@ func parseClassPHPDocMethod(ctx *rootContext, result *classPhpDocParseResult, pa
 		methodName = params[1][:nameEnd]
 	} else {
 		methodName = params[1] // Could be a method name without `()`.
-		result.errs.pushLint("line %d: @method '(' is not found near the method name", part.Line())
+
+		result.errs.pushLint(
+			PHPDocLineField(classNode, part.Line(), 2),
+			"@method missing parentheses after method name",
+		)
 	}
 
 	var funcFlags meta.FuncFlags
@@ -85,28 +130,41 @@ func moveShapesToContext(ctx *rootContext, shapes types.ShapesMap) {
 	}
 }
 
-func parseClassPHPDocProperty(ctx *rootContext, result *classPhpDocParseResult, part *phpdoc.TypeVarCommentPart) {
+func parseClassPHPDocProperty(classNode ir.Node, ctx *rootContext, result *classPhpDocParseResult, part *phpdoc.TypeVarCommentPart) {
 	// The syntax is:
 	//	@property [Type] [name] [<description>]
 	// Type and name are mandatory.
 
 	if part.Type.IsEmpty() || part.Var == "" {
-		result.errs.pushLint("line %d: @property requires type and property name fields", part.Line())
+		result.errs.pushLint(
+			PHPDocLine(classNode, part.Line()),
+			"@property requires type and property name fields",
+		)
 		return
 	}
 
 	if part.VarIsFirst {
-		result.errs.pushLint("non-canonical order of name and type on line %d", part.Line())
+		result.errs.pushLint(
+			PHPDocLine(classNode, part.Line()),
+			"Non-canonical order of name and type",
+		)
 	}
 
 	converted := phpdoctypes.ToRealType(ctx.typeNormalizer.ClassFQNProvider(), part.Type)
 	moveShapesToContext(ctx, converted.Shapes)
-	for _, warning := range converted.Warnings {
-		result.errs.pushType("%s on line %d", warning, part.Line())
+
+	if converted.Warning != "" {
+		result.errs.pushType(
+			PHPDocLineField(classNode, part.Line(), 2),
+			converted.Warning,
+		)
 	}
 
 	if !strings.HasPrefix(part.Var, "$") {
-		result.errs.pushLint("@property %s field name must start with '$' on line %d", part.Var, part.Line())
+		result.errs.pushLint(
+			PHPDocLineField(classNode, part.Line(), 2),
+			"@property %s field name must start with '$'", part.Var,
+		)
 		return
 	}
 
@@ -117,7 +175,7 @@ func parseClassPHPDocProperty(ctx *rootContext, result *classPhpDocParseResult,
 	}
 }
 
-func parseClassPHPDocMixin(cs *meta.ClassParseState, result *classPhpDocParseResult, part *phpdoc.RawCommentPart) {
+func parseClassPHPDocMixin(classNode ir.Node, cs *meta.ClassParseState, result *classPhpDocParseResult, part *phpdoc.RawCommentPart) {
 	params := part.Params
 	if len(params) == 0 {
 		return
diff --git a/src/linter/root.go b/src/linter/root.go
index 9cbede3..6875b0d 100644
--- a/src/linter/root.go
+++ b/src/linter/root.go
@@ -212,8 +212,8 @@ func (d *rootWalker) EnterNode(n ir.Node) (res bool) {
 		}
 		d.checkCommentMisspellings(n.ClassName, n.Doc.Raw)
 		d.checkIdentMisspellings(n.ClassName)
-		doc := d.parseClassPHPDoc(n.ClassName, n.Doc)
-		d.reportPHPDocErrors(n.ClassName, doc.errs)
+		doc := d.parseClassPHPDoc(n, n.Doc)
+		d.reportPHPDocErrors(doc.errs)
 		// If we ever need to distinguish @property-annotated and real properties,
 		// more work will be required here.
 		for name, p := range doc.properties {
@@ -309,7 +309,7 @@ func (d *rootWalker) parseStartPos(pos *position.Position) (startLn []byte, star
 	return startLn, startChar
 }
 
-func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg string, args ...interface{}) {
+func (d *rootWalker) report(n ir.Node, lineNumber int, phpDocLocation PHPDocLocation, level int, checkName, msg string, args ...interface{}) {
 	if !d.metaInfo().IsIndexingComplete() {
 		return
 	}
@@ -326,8 +326,9 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 		return
 	}
 
-	isReportForNode := lineNumber == 0
-	isReportForLine := !isReportForNode
+	isReportForNode := phpDocLocation.Line == 0 && lineNumber == 0
+	isReportForLine := lineNumber != 0
+	isReportForPHPDoc := phpDocLocation.Line != 0
 
 	var pos position.Position
 	var startLn []byte
@@ -335,7 +336,8 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 	var endLn []byte
 	var endChar int
 
-	if isReportForNode {
+	switch {
+	case isReportForNode:
 		if n == nil {
 			// Hack to parse syntax error message from php-parser.
 			if strings.Contains(msg, "syntax error") && strings.Contains(msg, " at line ") {
@@ -370,13 +372,14 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 		if endChar == 0 {
 			endChar = len(endLn)
 		}
-	} else if isReportForLine {
+	case isReportForLine:
 		if lineNumber < 1 || lineNumber > d.file.NumLines() {
 			return
 		}
 
 		startLn = d.file.Line(lineNumber - 1)
 		startChar = 0
+
 		endChar = len(startLn)
 
 		if strings.HasSuffix(string(startLn), "\r") {
@@ -387,6 +390,59 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 			StartLine: lineNumber,
 			EndLine:   lineNumber,
 		}
+
+	case isReportForPHPDoc:
+		countLines := 0
+		doc, ok := irutil.FindPhpDoc(phpDocLocation.Node, true)
+		if ok {
+			countLines = strings.Count(doc, "\n") + 1
+		}
+
+		nodePos := ir.GetPosition(phpDocLocation.Node)
+		if nodePos == nil {
+			nodePos = &position.Position{}
+		}
+		phpDocLocation.Line = nodePos.StartLine - (countLines - phpDocLocation.Line) - 1
+
+		if phpDocLocation.Line < 1 || phpDocLocation.Line > d.file.NumLines() {
+			return
+		}
+
+		startLn = d.file.Line(phpDocLocation.Line - 1)
+
+		lineWithoutBeginning := startLn
+		if !bytes.Contains(startLn, []byte("/*")) || bytes.Contains(startLn, []byte("/**")) {
+			lineWithoutBeginning = bytes.TrimLeft(startLn, "/ *")
+		}
+
+		shift := len(startLn) - len(lineWithoutBeginning)
+
+		parts := bytes.Fields(lineWithoutBeginning)
+		if phpDocLocation.Field >= len(parts) {
+			phpDocLocation.Field = 0
+			phpDocLocation.WholeLine = true
+		}
+
+		if phpDocLocation.WholeLine {
+			startChar = shift
+			endChar = shift + len(lineWithoutBeginning)
+		} else {
+			part := parts[phpDocLocation.Field]
+			shiftStart := bytes.Index(lineWithoutBeginning, part)
+			shiftEnd := shiftStart + len(part)
+
+			startChar = shift + shiftStart
+			endChar = shift + shiftEnd
+		}
+
+		if endChar == len(startLn) && strings.HasSuffix(string(startLn), "\r") {
+			endChar--
+		}
+
+		pos = position.Position{
+			StartLine: phpDocLocation.Line,
+			EndLine:   phpDocLocation.Line,
+		}
 	}
 
 	msg = fmt.Sprintf(msg, args...)
@@ -420,12 +476,17 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 
 // Report registers a single report message about some found problem.
 func (d *rootWalker) Report(n ir.Node, level int, checkName, msg string, args ...interface{}) {
-	d.report(n, 0, level, checkName, msg, args...)
+	d.report(n, 0, PHPDocLocation{}, level, checkName, msg, args...)
 }
 
 // ReportByLine registers a single report message about some found problem in lineNumber code line.
 func (d *rootWalker) ReportByLine(lineNumber int, level int, checkName, msg string, args ...interface{}) {
-	d.report(nil, lineNumber, level, checkName, msg, args...)
+	d.report(nil, lineNumber, PHPDocLocation{}, level, checkName, msg, args...)
+}
+
+// ReportPHPDoc registers a single report message about some found problem in PHPDoc.
+func (d *rootWalker) ReportPHPDoc(location PHPDocLocation, level int, checkName, msg string, args ...interface{}) {
+	d.report(nil, 0, location, level, checkName, msg, args...)
 }
 
 // reportHash computes the report signature hash for the baseline.
@@ -1000,8 +1061,8 @@ func (d *rootWalker) enterClassMethod(meth *ir.ClassMethodStmt) bool {
 	d.handleClosuresFromDoc(doc.Closures)
 
 	// Check stage.
-	errors := d.checkPHPDoc(meth.MethodName, meth.Doc, meth.Params)
-	d.reportPHPDocErrors(meth.MethodName, errors)
+	errors := d.checkPHPDoc(meth, meth.Doc, meth.Params)
+	d.reportPHPDocErrors(errors)
 
 	phpDocReturnType := doc.ReturnType
 	phpDocParamTypes := doc.ParamTypes
@@ -1102,18 +1163,21 @@ func (d *rootWalker) enterClassMethod(meth *ir.ClassMethodStmt) bool {
 	return false
 }
 
-func (d *rootWalker) reportPHPDocErrors(n ir.Node, errs phpdocErrors) {
-	for _, err := range errs.phpdocLint {
-		d.Report(n, LevelWarning, "phpdocLint", "%s", err)
+func (d *rootWalker) reportPHPDocErrors(errs PHPDocErrors) {
+	for _, err := range errs.types {
+		d.ReportPHPDoc(err.Location, LevelNotice, "phpdocType", err.Message)
 	}
-	for _, err := range errs.phpdocType {
-		d.Report(n, LevelNotice, "phpdocType", "%s", err)
+	for _, err := range errs.lint {
+		d.ReportPHPDoc(err.Location, LevelWarning, "phpdocLint", err.Message)
 	}
 }
 
 func (d *rootWalker) parsePHPDocVar(n ir.Node, doc phpdoc.Comment) (typesMap types.Map) {
 	if phpdoc.IsSuspicious([]byte(doc.Raw)) {
-		d.Report(n, LevelWarning, "phpdocLint", "Multiline PHPDoc comment should start with /**, not /*")
+		d.ReportPHPDoc(PHPDocLine(n, 1),
+			LevelWarning, "phpdocLint",
+			"Multiline PHPDoc comment should start with /**, not /*",
+		)
 	}
 
 	for _, part := range doc.Parsed {
@@ -1125,8 +1189,15 @@ func (d *rootWalker) parsePHPDocVar(n ir.Node, doc phpdoc.Comment) (typesMap typ
 			moveShapesToContext(&d.ctx, converted.Shapes)
 			d.handleClosuresFromDoc(converted.Closures)
 
-			for _, warning := range converted.Warnings {
-				d.Report(n, LevelNotice, "phpdocType", "%s on line %d", warning, part.Line())
+			if converted.Warning != "" {
+				field := 1
+				if part.VarIsFirst {
+					field = 2
+				}
+				d.ReportPHPDoc(PHPDocLineField(n, part.Line(), field),
+					LevelNotice, "phpdocType",
+					converted.Warning,
+				)
 			}
 
 			typesMap = types.NewMapWithNormalization(d.ctx.typeNormalizer, converted.Types)
@@ -1270,8 +1341,10 @@ func (d *rootWalker) checkPHPDocSeeRef(n ir.Node, part phpdoc.CommentPart) {
 		// Sometimes people write references like `foo()` `foo...` `foo@`.
 		ref = strings.TrimRight(ref, "().;@")
 		if !d.isValidPHPDocRef(n, ref) {
-			d.Report(n, LevelWarning, "phpdocRef", "Line %d: @see tag refers to unknown symbol %s",
-				part.Line(), ref)
+			d.ReportPHPDoc(
+				PHPDocLineField(n, part.Line(), 1),
+				LevelWarning, "phpdocRef", "@see tag refers to unknown symbol %s", ref,
+			)
 		}
 	}
 }
@@ -1296,17 +1369,23 @@ func (d *rootWalker) checkPHPDocMixinRef(n ir.Node, part phpdoc.CommentPart) {
 	}
 
 	if _, ok := d.metaInfo().GetClass(name); !ok {
-		d.Report(n, LevelWarning, "phpdocRef", "Line %d: @mixin tag refers to unknown class %s", part.Line(), name)
+		d.ReportPHPDoc(
+			PHPDocLineField(n, part.Line(), 1),
+			LevelWarning, "phpdocRef", "@mixin tag refers to unknown class %s", name,
+		)
 	}
 }
 
-func (d *rootWalker) checkPHPDoc(n ir.Node, doc phpdoc.Comment, actualParams []ir.Node) (errors phpdocErrors) {
+func (d *rootWalker) checkPHPDoc(n ir.Node, doc phpdoc.Comment, actualParams []ir.Node) (errors PHPDocErrors) {
 	if doc.Raw == "" {
 		return errors
 	}
 
 	if phpdoc.IsSuspicious([]byte(doc.Raw)) {
-		errors.pushLint("Multiline PHPDoc comment should start with /**, not /*")
+		errors.pushLint(
+			PHPDocLine(n, 1),
+			"Multiline PHPDoc comment should start with /**, not /*",
+		)
 	}
 
 	actualParamNames := make(map[string]struct{}, len(actualParams))
@@ -1324,14 +1403,21 @@ func (d *rootWalker) checkPHPDoc(n ir.Node, doc phpdoc.Comment, actualParams []i
 			part := rawPart.(*phpdoc.TypeCommentPart)
 
 			converted := phpdoctypes.ToRealType(d.ctx.typeNormalizer.ClassFQNProvider(), part.Type)
-			for _, warning := range converted.Warnings {
-				errors.pushType("%s on line %d", warning, part.Line())
+
+			if converted.Warning != "" {
+				errors.pushType(
+					PHPDocLineField(n, part.Line(), 1),
+					converted.Warning,
+				)
 			}
 
 			returnType := types.NewMapWithNormalization(d.ctx.typeNormalizer, converted.Types)
 
 			if returnType.Contains("void") && returnType.Len() > 1 {
-				errors.pushType("Void type can only be used as a standalone type for the return type")
+				errors.pushType(
+					PHPDocLineField(n, part.Line(), 1),
+					"Void type can only be used as a standalone type for the return type",
+				)
 			}
 			continue
 		}
@@ -1343,19 +1429,28 @@ func (d *rootWalker) checkPHPDoc(n ir.Node, doc phpdoc.Comment, actualParams []i
 		}
 
 		part := rawPart.(*phpdoc.TypeVarCommentPart)
-		optional := strings.Contains(part.Rest, "[optional]")
 		switch {
 		case part.Var == "":
-			errors.pushLint("malformed @param tag (maybe var is missing?) on line %d", part.Line())
+			errors.pushLint(
+				PHPDocLineField(n, part.Line(), 1),
+				"Malformed @param tag (maybe var is missing?)",
+			)
+
 		case part.Type.IsEmpty():
-			errors.pushLint("malformed @param %s tag (maybe type is missing?) on line %d",
-				part.Var, part.Line())
+			errors.pushLint(
+				PHPDocLineField(n, part.Line(), 1),
+				"Malformed @param %s tag (maybe type is missing?)", part.Var,
+			)
+
 			continue
 		}
 
 		if part.VarIsFirst {
 			// Phpstorm gives the same message.
-			errors.pushLint("non-canonical order of variable and type on line %d", part.Line())
+			errors.pushLint(
+				PHPDocLine(n, part.Line()),
+				"Non-canonical order of variable and type",
+			)
 		}
 
 		variable := part.Var
@@ -1365,25 +1460,33 @@ func (d *rootWalker) checkPHPDoc(n ir.Node, doc phpdoc.Comment, actualParams []i
 			}
 		}
 		if _, ok := actualParamNames[strings.TrimPrefix(variable, "$")]; !ok {
-			errors.pushLint("@param for non-existing argument %s", variable)
+			errors.pushLint(
+				PHPDocLineField(n, part.Line(), 2),
+				"@param for non-existing argument %s", variable,
+			)
 			continue
 		}
 
 		curParam++
 
 		converted := phpdoctypes.ToRealType(d.ctx.typeNormalizer.ClassFQNProvider(), part.Type)
-		for _, warning := range converted.Warnings {
-			errors.pushType("%s on line %d", warning, part.Line())
+
+		if converted.Warning != "" {
+			errors.pushType(
+				PHPDocLineField(n, part.Line(), 1),
+				converted.Warning,
+			)
 		}
 
 		var param phpdoctypes.Param
 		param.Typ = types.NewMapWithNormalization(d.ctx.typeNormalizer, converted.Types)
 
 		if param.Typ.Contains("void") {
-			errors.pushType("Void type can only be used as a standalone type for the return type")
+			errors.pushType(
+				PHPDocLineField(n, part.Line(), 1),
+				"Void type can only be used as a standalone type for the return type",
+			)
 		}
-
-		param.Optional = optional
 	}
 
 	return errors
@@ -1643,7 +1746,7 @@ func (d *rootWalker) enterFunction(fun *ir.FunctionStmt) bool {
 
 	// Check stage.
 	errors := d.checkPHPDoc(fun, fun.Doc, fun.Params)
-	d.reportPHPDocErrors(fun, errors)
+	d.reportPHPDocErrors(errors)
 
 	phpDocReturnType := doc.ReturnType
 	phpDocParamTypes := doc.ParamTypes
@@ -2184,7 +2287,7 @@ func (d *rootWalker) compareKeywordWithTokenCase(n ir.Node, tok *token.Token, ke
 	}
 }
 
-func (d *rootWalker) parseClassPHPDoc(n ir.Node, doc phpdoc.Comment) classPhpDocParseResult {
+func (d *rootWalker) parseClassPHPDoc(class ir.Node, doc phpdoc.Comment) classPhpDocParseResult {
 	var result classPhpDocParseResult
 
 	if doc.Raw == "" {
@@ -2198,14 +2301,14 @@ func (d *rootWalker) parseClassPHPDoc(n ir.Node, doc phpdoc.Comment) classPhpDoc
 	result.methods = meta.NewFunctionsMap()
 
 	for _, part := range doc.Parsed {
-		d.checkPHPDocRef(n, part)
+		d.checkPHPDocRef(class, part)
 		switch part.Name() {
 		case "property", "property-read", "property-write":
-			parseClassPHPDocProperty(&d.ctx, &result, part.(*phpdoc.TypeVarCommentPart))
+			parseClassPHPDocProperty(class, &d.ctx, &result, part.(*phpdoc.TypeVarCommentPart))
 		case "method":
-			parseClassPHPDocMethod(&d.ctx, &result, part.(*phpdoc.RawCommentPart))
+			parseClassPHPDocMethod(class, &d.ctx, &result, part.(*phpdoc.RawCommentPart))
 		case "mixin":
-			parseClassPHPDocMixin(d.ctx.st, &result, part.(*phpdoc.RawCommentPart))
+			parseClassPHPDocMixin(class, d.ctx.st, &result, part.(*phpdoc.RawCommentPart))
 		}
 	}
 
diff --git a/src/phpdoc/parser.go b/src/phpdoc/parser.go
index 286b456..42e209f 100644
--- a/src/phpdoc/parser.go
+++ b/src/phpdoc/parser.go
@@ -77,7 +77,7 @@ func IsSuspicious(value []byte) bool {
 		return false
 	}
 
-	return ContainsTag(value)
+	return bytes.HasPrefix(value, []byte("/*")) && ContainsTag(value)
 }
 
 var tagRegexp = regexp.MustCompile(`\* +@\w+`)
diff --git a/src/phpdoctypes/converter.go b/src/phpdoctypes/converter.go
index 9a55beb..e6eaed1 100644
--- a/src/phpdoctypes/converter.go
+++ b/src/phpdoctypes/converter.go
@@ -13,7 +13,7 @@ type RealPHPDocTypes struct {
 	Types    []types.Type
 	Shapes   types.ShapesMap
 	Closures types.ClosureMap
-	Warnings []string
+	Warning  string
 }
 
 // ToRealType extracts types out of the PHPDoc type string.
@@ -35,7 +35,7 @@ func ToRealType(classFQNProvider func(string) (string, bool), typ phpdoc.Type) *
 		for _, tp := range result {
 			if tp.Elem == "null" {
 				alreadyHasNull = true
-				conv.warnings = append(conv.warnings, "repeated nullable doesn't make sense")
+				conv.warning = "Repeated nullable doesn't make sense"
 				break
 			}
 		}
@@ -49,14 +49,14 @@ func ToRealType(classFQNProvider func(string) (string, bool), typ phpdoc.Type) *
 		Types:    result,
 		Shapes:   conv.shapes,
 		Closures: conv.closures,
-		Warnings: conv.warnings,
+		Warning:  conv.warning,
 	}
 }
 
 type TypeConverter struct {
 	classFQNProvider   func(string) (string, bool)
 	shapeNameGenerator func([]types.ShapeProp) string
-	warnings           []string
+	warning            string
 	nullable           bool
 	shapes             types.ShapesMap
 	closures           types.ClosureMap
@@ -66,7 +66,7 @@ func (conv *TypeConverter) mapType(e phpdoc.TypeExpr) []types.Type {
 	switch e.Kind {
 	case phpdoc.ExprInvalid, phpdoc.ExprUnknown:
 		if e.Value == "-" {
-			conv.warn(`expected a type, found '-'; if you want to express 'any' type, use 'mixed'`)
+			conv.warn(`Expected a type, found '-'; if you want to express 'any' type, use 'mixed'`)
 			return []types.Type{{Elem: "mixed"}}
 		}
 
@@ -75,7 +75,7 @@ func (conv *TypeConverter) mapType(e phpdoc.TypeExpr) []types.Type {
 
 	case phpdoc.ExprName:
 		if suggest, has := types.Alias(e.Value); has {
-			conv.warn(fmt.Sprintf("use %s type instead of %s", suggest, e.Value))
+			conv.warn(fmt.Sprintf("Use %s type instead of %s", suggest, e.Value))
 		}
 		return []types.Type{{Elem: e.Value}}
 
@@ -117,7 +117,7 @@ func (conv *TypeConverter) mapType(e phpdoc.TypeExpr) []types.Type {
 
 	case phpdoc.ExprArray:
 		if e.Shape == phpdoc.ShapeArrayPrefix {
-			conv.warn(e.Value + ": array syntax is T[], not []T")
+			conv.warn("Array syntax is T[], not []T")
 		}
 		return conv.mapArrayType(e.Args[0])
 
@@ -133,7 +133,7 @@ func (conv *TypeConverter) mapType(e phpdoc.TypeExpr) []types.Type {
 		// function, while the optionality for the key is cleared, and the key itself is not
 		// processed by the mapType function, then in the mapType function the ExprOptional
 		// type can only be in one case, if it is an incorrect syntax of the optional type.
-		conv.warn(e.Value + ": nullable syntax is ?T, not T?")
+		conv.warn("Nullable syntax is ?T, not T?")
 
 	case phpdoc.ExprTypedCallable:
 		closureName := `\Closure$(`
@@ -198,7 +198,7 @@ func (conv *TypeConverter) mapShapeType(params []phpdoc.TypeExpr, allowedMixing
 		}
 		if p.Kind != phpdoc.ExprKeyVal {
 			if !allowedMixing {
-				conv.warn(fmt.Sprintf("shape param #%d: want key:type, found %s", i+1, p.Value))
+				conv.warn(fmt.Sprintf("Shape param #%d: want key:type, found %s", i+1, p.Value))
 				continue
 			}
 
@@ -220,7 +220,7 @@ func (conv *TypeConverter) mapShapeType(params []phpdoc.TypeExpr, allowedMixing
 		case phpdoc.ExprName, phpdoc.ExprInt:
 			// OK.
 		default:
-			conv.warn(fmt.Sprintf("invalid shape key: %s", key.Value))
+			conv.warn(fmt.Sprintf("Invalid shape key: %s", key.Value))
 			continue
 		}
 		typeList := conv.mapType(typeExpr)
@@ -299,5 +299,7 @@ func (conv *TypeConverter) mapTupleType(params []phpdoc.TypeExpr) []types.Type {
 }
 
 func (conv *TypeConverter) warn(msg string) {
-	conv.warnings = append(conv.warnings, msg)
+	if conv.warning == "" {
+		conv.warning = msg
+	}
 }
diff --git a/src/rules/parser.go b/src/rules/parser.go
index 9329746..0c201cb 100644
--- a/src/rules/parser.go
+++ b/src/rules/parser.go
@@ -412,7 +412,7 @@ func (p *parser) parseFuncComment(fn *ir.FunctionStmt) error {
 }
 
 func (p *parser) commentText(n ir.Node) string {
-	doc, found := irutil.FindPhpDoc(n)
+	doc, found := irutil.FindPhpDoc(n, false)
 	if !found {
 		return ""
 	}


Test patch:
diff --git a/src/tests/checkers/oop_test.go b/src/tests/checkers/oop_test.go
index 779b59c..57f4517 100644
--- a/src/tests/checkers/oop_test.go
+++ b/src/tests/checkers/oop_test.go
@@ -1471,7 +1471,7 @@ class Bar {
 `)
 	test.Expect = []string{
 		`Call to undefined method {\BarWithSomeMixin}->method3()`,
-		`Line 4: @mixin tag refers to unknown class \Boo`,
+		`@mixin tag refers to unknown class \Boo`,
 	}
 	test.RunAndMatch()
 }
diff --git a/src/tests/checkers/phpdoc_test.go b/src/tests/checkers/phpdoc_test.go
index 895bd95..18fb90c 100644
--- a/src/tests/checkers/phpdoc_test.go
+++ b/src/tests/checkers/phpdoc_test.go
@@ -132,11 +132,11 @@ class BadClass {
 `)
 
 	test.Expect = []string{
-		`Line 2: @see tag refers to unknown symbol \NonExisting::class`,
-		`Line 2: @see tag refers to unknown symbol HolyHandGrenade::hallelujah`,
-		`Line 2: @see tag refers to unknown symbol CONST43`,
-		`Line 2: @see tag refers to unknown symbol invalid1`,
-		`Line 2: @see tag refers to unknown symbol invalid2`,
+		`@see tag refers to unknown symbol \NonExisting::class`,
+		`@see tag refers to unknown symbol HolyHandGrenade::hallelujah`,
+		`@see tag refers to unknown symbol CONST43`,
+		`@see tag refers to unknown symbol invalid1`,
+		`@see tag refers to unknown symbol invalid2`,
 	}
 	linttest.RunFilterMatch(test, "phpdocRef")
 }
@@ -178,8 +178,8 @@ class Foo extends FooAbstract {
 function f() {}
 `)
 	test.Expect = []string{
-		`Line 6: @see tag refers to unknown symbol TYPE_TEXT_UNDEFINED`,
-		`Line 2: @see tag refers to unknown symbol TYPE_TEXT`,
+		`@see tag refers to unknown symbol TYPE_TEXT_UNDEFINED`,
+		`@see tag refers to unknown symbol TYPE_TEXT`,
 	}
 	linttest.RunFilterMatch(test, "phpdocRef")
 }
@@ -307,8 +307,8 @@ function fav_func($a, $b) {
 }
 `)
 	test.Expect = []string{
-		`malformed @param $a tag (maybe type is missing?)`,
-		`malformed @param $b tag (maybe type is missing?)`,
+		`Malformed @param $a tag (maybe type is missing?)`,
+		`Malformed @param $b tag (maybe type is missing?)`,
 	}
 	test.RunAndMatch()
 }
@@ -358,12 +358,12 @@ func TestPHPDocSyntax(t *testing.T) {
 		$_ = $z;
 	}`)
 	test.Expect = []string{
-		`non-canonical order of variable and type on line 2`,
-		`expected a type, found '-'; if you want to express 'any' type, use 'mixed' on line 3`,
-		`non-canonical order of variable and type on line 4`,
-		`expected a type, found '-'; if you want to express 'any' type, use 'mixed' on line 4`,
-		`malformed @param $a tag (maybe type is missing?) on line 5`,
-		`malformed @param tag (maybe var is missing?) on line 6`,
+		`Non-canonical order of variable and type`,
+		`Expected a type, found '-'; if you want to express 'any' type, use 'mixed'`,
+		`Non-canonical order of variable and type`,
+		`Expected a type, found '-'; if you want to express 'any' type, use 'mixed'`,
+		`Malformed @param $a tag (maybe type is missing?)`,
+		`Malformed @param tag (maybe var is missing?)`,
 	}
 	test.RunAndMatch()
 }
@@ -383,9 +383,9 @@ class Foo {
 }
 `)
 	test.Expect = []string{
-		`use int type instead of integer`,
-		`use float type instead of real`,
-		`int?: nullable syntax is ?T, not T?`,
+		`Use int type instead of integer`,
+		`Use float type instead of real`,
+		`Nullable syntax is ?T, not T?`,
 	}
 	test.RunAndMatch()
 }
@@ -405,14 +405,14 @@ func TestPHPDocProperty(t *testing.T) {
 class Foo {}
 `)
 	test.Expect = []string{
-		`use int type instead of integer on line 2`,
-		`[]t: array syntax is T[], not []T`,
-		`@property ts field name must start with '$' on line 3`,
-		`non-canonical order of name and type on line 6`,
-		`line 4: @property requires type and property name fields`,
-		`line 5: @property requires type and property name fields`,
-		`use bool type instead of boolean on line 7`,
-		`int?: nullable syntax is ?T, not T?`,
+		`Use int type instead of integer`,
+		`Array syntax is T[], not []T`,
+		`@property ts field name must start with '$'`,
+		`Non-canonical order of name and type`,
+		`@property requires type and property name fields`,
+		`@property requires type and property name fields`,
+		`Use bool type instead of boolean`,
+		`Nullable syntax is ?T, not T?`,
 	}
 	test.RunAndMatch()
 }
@@ -435,14 +435,13 @@ func TestPHPDocType(t *testing.T) {
 	}
 `)
 	test.Expect = []string{
-		`[][]string: array syntax is T[], not []T on line 2`,
-		`[]string: array syntax is T[], not []T on line 2`,
-		`use float type instead of double`,
-		`use float type instead of real`,
-		`use int type instead of integer`,
-		`use bool type instead of boolean`,
-		`int?: nullable syntax is ?T, not T?`,
-		`[]int: array syntax is T[], not []T on line 8`,
+		`Array syntax is T[], not []T`,
+		`Use float type instead of double`,
+		`Use float type instead of real`,
+		`Use int type instead of integer`,
+		`Use bool type instead of boolean`,
+		`Nullable syntax is ?T, not T?`,
+		`Array syntax is T[], not []T`,
 	}
 	test.RunAndMatch()
 }
@@ -464,9 +463,9 @@ func TestPHPDocIncorrectSyntaxOptionalTypesType(t *testing.T) {
 	}
 `)
 	test.Expect = []string{
-		`int?: nullable syntax is ?T, not T?`,
-		`Foo?: nullable syntax is ?T, not T?`,
-		`string[]?: nullable syntax is ?T, not T?`,
+		`Nullable syntax is ?T, not T?`,
+		`Nullable syntax is ?T, not T?`,
+		`Nullable syntax is ?T, not T?`,
 	}
 	test.RunAndMatch()
 }
@@ -543,10 +542,10 @@ function f($a, $b, $c, $d) {
 }
 `)
 	test.Expect = []string{
-		`repeated nullable doesn't make sense`,
-		`repeated nullable doesn't make sense`,
-		`repeated nullable doesn't make sense`,
-		`repeated nullable doesn't make sense`,
+		`Repeated nullable doesn't make sense`,
+		`Repeated nullable doesn't make sense`,
+		`Repeated nullable doesn't make sense`,
+		`Repeated nullable doesn't make sense`,
 	}
 	test.RunAndMatch()
 }
diff --git a/src/tests/checkers/shape_test.go b/src/tests/checkers/shape_test.go
index 3d0c906..d7987ed 100644
--- a/src/tests/checkers/shape_test.go
+++ b/src/tests/checkers/shape_test.go
@@ -121,11 +121,11 @@ echo $t->good6['x']->value;
 echo $t->good6['y']->value;
 `)
 	test.Expect = []string{
-		`invalid shape key: x[]`,
-		`shape param #1: want key:type, found x`,
+		`Invalid shape key: x[]`,
+		`Shape param #1: want key:type, found x`,
 		`Property {mixed}->bad1 does not exist`,
 		`Property {mixed}->bad2 does not exist`,
-		`shape param #1: want key:type, found int on line 1`,
+		`Shape param #1: want key:type, found int`,
 	}
 	test.RunAndMatch()
 }
diff --git a/src/tests/golden/golden_test.go b/src/tests/golden/golden_test.go
index 13058a4..c595b11 100644
--- a/src/tests/golden/golden_test.go
+++ b/src/tests/golden/golden_test.go
@@ -189,6 +189,10 @@ func TestGolden(t *testing.T) {
 			OnlyE2E:  true,
 			Baseline: true,
 		},
+
+		{
+			Name: "phpdoc",
+		},
 	}
 
 	e2eSuite := linttest.NewGoldenE2ETestSuite(t)
diff --git a/src/tests/golden/testdata/flysystem/golden.txt b/src/tests/golden/testdata/flysystem/golden.txt
index 04662e9..2f7c8fe 100644
--- a/src/tests/golden/testdata/flysystem/golden.txt
+++ b/src/tests/golden/testdata/flysystem/golden.txt
@@ -13,15 +13,15 @@ MAYBE   ternarySimplify: Could rewrite as `$mkdirError['message'] ?? ''` at test
 WARNING strictCmp: 3rd argument of in_array must be true when comparing strings at testdata/flysystem/src/Adapter/Local.php:324
         if (in_array($mimetype, ['application/octet-stream', 'inode/x-empty', 'application/x-empty'])) {
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-MAYBE   phpdocType: Void type can only be used as a standalone type for the return type at testdata/flysystem/src/Adapter/Local.php:448
-    protected function normalizeFileInfo(SplFileInfo $file)
-                       ^^^^^^^^^^^^^^^^^
-WARNING phpdocRef: Line 8: @see tag refers to unknown symbol League\Flysystem\ReadInterface::readStream at testdata/flysystem/src/Adapter/Polyfill/StreamedReadingTrait.php:19
-    public function readStream($path)
-                    ^^^^^^^^^^
-WARNING phpdocRef: Line 8: @see tag refers to unknown symbol League\Flysystem\ReadInterface::read at testdata/flysystem/src/Adapter/Polyfill/StreamedReadingTrait.php:43
-    abstract public function read($path);
-                             ^^^^
+MAYBE   phpdocType: Void type can only be used as a standalone type for the return type at testdata/flysystem/src/Adapter/Local.php:444
+     * @return array|void
+               ^^^^^^^^^^
+WARNING phpdocRef: @see tag refers to unknown symbol League\Flysystem\ReadInterface::readStream at testdata/flysystem/src/Adapter/Polyfill/StreamedReadingTrait.php:17
+     * @see League\Flysystem\ReadInterface::readStream()
+            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+WARNING phpdocRef: @see tag refers to unknown symbol League\Flysystem\ReadInterface::read at testdata/flysystem/src/Adapter/Polyfill/StreamedReadingTrait.php:41
+     * @see League\Flysystem\ReadInterface::read()
+            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 MAYBE   phpdoc: Missing PHPDoc for \League\Flysystem\Adapter\Polyfill\StreamedWritingTrait::write public method at testdata/flysystem/src/Adapter/Polyfill/StreamedWritingTrait.php:58
     abstract public function write($pash, $contents, Config $config);
                              ^^^^^
diff --git a/src/tests/golden/testdata/inflector/golden.txt b/src/tests/golden/testdata/inflector/golden.txt
index 3445fda..c5ce665 100644
--- a/src/tests/golden/testdata/inflector/golden.txt
+++ b/src/tests/golden/testdata/inflector/golden.txt
@@ -1,3 +1,3 @@
-MAYBE   phpdocType: use bool type instead of boolean on line 17 at testdata/inflector/lib/Doctrine/Common/Inflector/Inflector.php:149
-    public static function rules(string $type, iterable $rules, bool $reset = false) : void
-                           ^^^^^
+MAYBE   phpdocType: Use bool type instead of boolean at testdata/inflector/lib/Doctrine/Common/Inflector/Inflector.php:142
+     * @param boolean $reset        If true, will unset default inflections for all
+              ^^^^^^^
diff --git a/src/tests/golden/testdata/options-resolver/golden.txt b/src/tests/golden/testdata/options-resolver/golden.txt
index 6466d80..6ab5dcf 100644
--- a/src/tests/golden/testdata/options-resolver/golden.txt
+++ b/src/tests/golden/testdata/options-resolver/golden.txt
@@ -13,15 +13,15 @@ MAYBE   deprecated: Call to deprecated method {\ReflectionParameter}->getClass()
 MAYBE   deprecated: Call to deprecated method {\ReflectionParameter}->getClass() (7.4) at testdata/options-resolver/OptionsResolver.php:209
             if (isset($params[0]) && null !== ($class = $params[0]->getClass()) && self::class === $class->name && (!isset($params[1]) || (null !== ($class = $params[1]->getClass()) && Options::class === $class->name))) {
                                                                                                                                                                           ^^^^^^^^
-WARNING phpdocLint: @param for non-existing argument $package at testdata/options-resolver/OptionsResolver.php:428
-    public function setDeprecated(string $option/*, string $package, string $version, $message = 'The option "%name%" is deprecated.' */): self
-                    ^^^^^^^^^^^^^
-WARNING phpdocLint: @param for non-existing argument $version at testdata/options-resolver/OptionsResolver.php:428
-    public function setDeprecated(string $option/*, string $package, string $version, $message = 'The option "%name%" is deprecated.' */): self
-                    ^^^^^^^^^^^^^
-WARNING phpdocLint: @param for non-existing argument $message at testdata/options-resolver/OptionsResolver.php:428
-    public function setDeprecated(string $option/*, string $package, string $version, $message = 'The option "%name%" is deprecated.' */): self
-                    ^^^^^^^^^^^^^
+WARNING phpdocLint: @param for non-existing argument $package at testdata/options-resolver/OptionsResolver.php:424
+     * @param string          $package The name of the composer package that is triggering the deprecation
+                              ^^^^^^^^
+WARNING phpdocLint: @param for non-existing argument $version at testdata/options-resolver/OptionsResolver.php:425
+     * @param string          $version The version of the package that introduced the deprecation
+                              ^^^^^^^^
+WARNING phpdocLint: @param for non-existing argument $message at testdata/options-resolver/OptionsResolver.php:426
+     * @param string|\Closure $message The deprecation message to use
+                              ^^^^^^^^
 MAYBE   complexity: Too big method: more than 150 lines at testdata/options-resolver/OptionsResolver.php:917
     public function offsetGet($option, bool $triggerDeprecation = true)
                     ^^^^^^^^^
diff --git a/src/tests/golden/testdata/phpdoc/golden.txt b/src/tests/golden/testdata/phpdoc/golden.txt
new file mode 100644
index 0000000..4183894
--- /dev/null
+++ b/src/tests/golden/testdata/phpdoc/golden.txt
@@ -0,0 +1,90 @@
+WARNING phpdocLint: @method requires return type and method name fields at testdata/phpdoc/phpdoc.php:4
+ * @method
+   ^^^^^^^
+WARNING phpdocLint: @method requires return type and method name fields at testdata/phpdoc/phpdoc.php:5
+ * @method int
+   ^^^^^^^^^^^
+WARNING phpdocLint: @method missing parentheses after method name at testdata/phpdoc/phpdoc.php:6
+ * @method int    method1
+                  ^^^^^^^
+WARNING phpdocLint: @method missing parentheses after method name at testdata/phpdoc/phpdoc.php:7
+ * @method ?string  method2
+                    ^^^^^^^
+WARNING phpdocLint: @property requires type and property name fields at testdata/phpdoc/phpdoc.php:11
+ * @property
+   ^^^^^^^^^
+WARNING phpdocLint: @property requires type and property name fields at testdata/phpdoc/phpdoc.php:12
+ * @property   int
+   ^^^^^^^^^^^^^^^
+WARNING phpdocLint: Non-canonical order of name and type at testdata/phpdoc/phpdoc.php:14
+ * @property       $b  int
+   ^^^^^^^^^^^^^^^^^^^^^^^
+WARNING phpdocLint: @property c field name must start with '$' at testdata/phpdoc/phpdoc.php:15
+ * @property int c
+                 ^
+WARNING phpdocLint: Multiline PHPDoc comment should start with /**, not /* at testdata/phpdoc/phpdoc.php:18
+  /*
+^^^^
+MAYBE   phpdocType: Repeated nullable doesn't make sense at testdata/phpdoc/phpdoc.php:24
+   * @var ?int|null
+          ^^^^^^^^^
+MAYBE   phpdocType: Repeated nullable doesn't make sense at testdata/phpdoc/phpdoc.php:25
+   * @var $b ?int|null
+             ^^^^^^^^^
+MAYBE   phpdocType: Repeated nullable doesn't make sense at testdata/phpdoc/phpdoc.php:26
+   * @var ?int|null $b
+          ^^^^^^^^^
+WARNING phpdocLint: Multiline PHPDoc comment should start with /**, not /* at testdata/phpdoc/phpdoc.php:31
+/*
+^^
+WARNING phpdocRef: @see tag refers to unknown symbol FooUnExisting at testdata/phpdoc/phpdoc.php:54
+ * @see  FooUnExisting
+         ^^^^^^^^^^^^^
+WARNING phpdocRef: @see tag refers to unknown symbol FooUnExisting at testdata/phpdoc/phpdoc.php:55
+ * @see       FooUnExisting
+              ^^^^^^^^^^^^^
+MAYBE   phpdocType: Repeated nullable doesn't make sense at testdata/phpdoc/phpdoc.php:44
+ * @param    ?int|null $d
+             ^^^^^^^^^
+MAYBE   phpdocType: Expected a type, found '-'; if you want to express 'any' type, use 'mixed' at testdata/phpdoc/phpdoc.php:46
+ * @param - $e the y param
+          ^
+MAYBE   phpdocType: Expected a type, found '-'; if you want to express 'any' type, use 'mixed' at testdata/phpdoc/phpdoc.php:47
+ * @param    $f - the z param
+             ^^
+MAYBE   phpdocType: Use int type instead of integer at testdata/phpdoc/phpdoc.php:49
+ * @param integer   $g
+          ^^^^^^^
+MAYBE   phpdocType: Array syntax is T[], not []T at testdata/phpdoc/phpdoc.php:50
+ * @param  []int   $h
+           ^^^^^
+MAYBE   phpdocType: Nullable syntax is ?T, not T? at testdata/phpdoc/phpdoc.php:51
+ * @param   int? $a1
+            ^^^^
+WARNING phpdocLint: @param for non-existing argument $unexisting at testdata/phpdoc/phpdoc.php:40
+ * @param    int    $unexisting
+                    ^^^^^^^^^^^
+WARNING phpdocLint: Malformed @param tag (maybe var is missing?) at testdata/phpdoc/phpdoc.php:41
+ * @param int
+          ^^^
+WARNING phpdocLint: Non-canonical order of variable and type at testdata/phpdoc/phpdoc.php:42
+ * @param $b       int
+   ^^^^^^^^^^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $c tag (maybe type is missing?) at testdata/phpdoc/phpdoc.php:43
+ * @param $c
+          ^^
+WARNING phpdocLint: Non-canonical order of variable and type at testdata/phpdoc/phpdoc.php:47
+ * @param    $f - the z param
+   ^^^^^^^^^^^^^^^^^^^^^^^^^^
+MAYBE   phpdocType: Invalid shape key: x[] at testdata/phpdoc/phpdoc.php:62
+  /** @var    shape(x[]:a) */
+              ^^^^^^^^^^^^
+MAYBE   phpdocType: Shape param #1: want key:type, found x at testdata/phpdoc/phpdoc.php:66
+  /** @var shape(x)    $a */
+           ^^^^^^^^
+WARNING phpdocLint: Multiline PHPDoc comment should start with /**, not /* at testdata/phpdoc/phpdoc.php:70
+  /*
+^^^^
+WARNING phpdocLint: Multiline PHPDoc comment should start with /**, not /* at testdata/phpdoc/phpdoc.php:31
+/*
+^^
diff --git a/src/tests/golden/testdata/phpdoc/phpdoc.php b/src/tests/golden/testdata/phpdoc/phpdoc.php
new file mode 100644
index 0000000..6900374
--- /dev/null
+++ b/src/tests/golden/testdata/phpdoc/phpdoc.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * @method
+ * @method int
+ * @method int    method1
+ * @method ?string  method2
+ * @method  int  method3()          <- ok
+ * @method static   int   method4() <- ok
+ *
+ * @property
+ * @property   int
+ * @property int $a  <- ok
+ * @property       $b  int
+ * @property int c
+ */
+class Foo {
+  /*
+   * @var   int
+   */
+  public $a = 100;
+
+  /**
+   * @var ?int|null
+   * @var $b ?int|null
+   * @var ?int|null $b
+   */
+  public $b = 100;
+}
+
+/*
+ * @param int $a
+ */
+function g($a) {
+}
+
+/**
+ * @param int       $a <- ok
+ *
+ * @param    int    $unexisting
+ * @param int
+ * @param $b       int
+ * @param $c
+ * @param    ?int|null $d
+ *
+ * @param - $e the y param
+ * @param    $f - the z param
+ *
+ * @param integer   $g
+ * @param  []int   $h
+ * @param   int? $a1
+ *
+ * @see   Foo <- ok
+ * @see  FooUnExisting
+ * @see       FooUnExisting
+ */
+function f($a, $b, $c, $d, $e, $f, $g, $h, $a1, $b1, $c1, $d1, $e1, $f1, $g1, $h1) {
+  /** @var shape(foo: int) $a <- ok */
+  $a2 = [];
+  echo $a2;
+
+  /** @var    shape(x[]:a) */
+  $a2 = [];
+  echo $a2;
+
+  /** @var shape(x)    $a */
+  $a2 = [];
+  echo $a2;
+
+  /*
+   * @var int $a2
+   */
+  $a2 = [];
+  echo $a2;
+}
diff --git a/src/tests/golden/testdata/phprocksyd/golden.txt b/src/tests/golden/testdata/phprocksyd/golden.txt
index 0f28aa0..690d680 100644
--- a/src/tests/golden/testdata/phprocksyd/golden.txt
+++ b/src/tests/golden/testdata/phprocksyd/golden.txt
@@ -4,33 +4,33 @@ MAYBE   deprecated: Call to deprecated function dl (5.3) at testdata/phprocksyd/
 ERROR   undefined: Use null instead of NULL at testdata/phprocksyd/Phprocksyd.php:321
             $n = stream_select($read, $write, $except, NULL);
                                                        ^^^^
-WARNING phpdocLint: malformed @param $stream_id tag (maybe type is missing?) on line 2 at testdata/phprocksyd/Phprocksyd.php:367
-    private function requestFree($stream_id, $req)
-                     ^^^^^^^^^^^
-WARNING phpdocLint: malformed @param $req tag (maybe type is missing?) on line 3 at testdata/phprocksyd/Phprocksyd.php:367
-    private function requestFree($stream_id, $req)
-                     ^^^^^^^^^^^
-WARNING phpdocLint: malformed @param $stream_id tag (maybe type is missing?) on line 2 at testdata/phprocksyd/Phprocksyd.php:392
-    private function requestCheck($stream_id, $req)
-                     ^^^^^^^^^^^^
-WARNING phpdocLint: malformed @param $req tag (maybe type is missing?) on line 3 at testdata/phprocksyd/Phprocksyd.php:392
-    private function requestCheck($stream_id, $req)
-                     ^^^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $stream_id tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:364
+     * @param $stream_id
+              ^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $req tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:365
+     * @param $req
+              ^^^^
+WARNING phpdocLint: Malformed @param $stream_id tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:389
+     * @param $stream_id
+              ^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $req tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:390
+     * @param $req
+              ^^^^
 WARNING unused: Variable $status is unused (use $_ to ignore this inspection or specify --unused-var-regex flag) at testdata/phprocksyd/Phprocksyd.php:394
         $status = null;
         ^^^^^^^
-WARNING phpdocLint: malformed @param $stream_id tag (maybe type is missing?) on line 2 at testdata/phprocksyd/Phprocksyd.php:424
-    protected function requestTerminate($stream_id, $req)
-                       ^^^^^^^^^^^^^^^^
-WARNING phpdocLint: malformed @param $req tag (maybe type is missing?) on line 3 at testdata/phprocksyd/Phprocksyd.php:424
-    protected function requestTerminate($stream_id, $req)
-                       ^^^^^^^^^^^^^^^^
-WARNING phpdocLint: malformed @param $stream_id tag (maybe type is missing?) on line 2 at testdata/phprocksyd/Phprocksyd.php:447
-    protected function requestRun($stream_id, $req)
-                       ^^^^^^^^^^
-WARNING phpdocLint: malformed @param $req tag (maybe type is missing?) on line 3 at testdata/phprocksyd/Phprocksyd.php:447
-    protected function requestRun($stream_id, $req)
-                       ^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $stream_id tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:421
+     * @param $stream_id
+              ^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $req tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:422
+     * @param $req
+              ^^^^
+WARNING phpdocLint: Malformed @param $stream_id tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:443
+     * @param $stream_id
+              ^^^^^^^^^^
+WARNING phpdocLint: Malformed @param $req tag (maybe type is missing?) at testdata/phprocksyd/Phprocksyd.php:444
+     * @param $req
+              ^^^^
 ERROR   undefined: Call to undefined method {mixed}->run() at testdata/phprocksyd/Phprocksyd.php:479
                 $instance->run($req['params']);
                            ^^^
diff --git a/src/tests/golden/testdata/twitter-api-php/golden.txt b/src/tests/golden/testdata/twitter-api-php/golden.txt
index d1019d4..f43a10a 100644
--- a/src/tests/golden/testdata/twitter-api-php/golden.txt
+++ b/src/tests/golden/testdata/twitter-api-php/golden.txt
@@ -10,18 +10,18 @@ WARNING strictCmp: 3rd argument of in_array must be true when comparing strings
 MAYBE   trailingComma: Last element in a multi-line array should have a trailing comma at testdata/twitter-api-php/TwitterAPIExchange.php:223
             'oauth_version' => '1.0'
             ^^^^^^^^^^^^^^^^^^^^^^^^
-MAYBE   phpdocType: use bool type instead of boolean on line 4 at testdata/twitter-api-php/TwitterAPIExchange.php:274
-    public function performRequest($return = true, $curlOptions = array())
-                    ^^^^^^^^^^^^^^
+MAYBE   phpdocType: Use bool type instead of boolean at testdata/twitter-api-php/TwitterAPIExchange.php:267
+     * @param boolean $return      If true, returns data. This is left in for backward compatibility reasons
+              ^^^^^^^
 WARNING strictCmp: 3rd argument of in_array must be true when comparing strings at testdata/twitter-api-php/TwitterAPIExchange.php:286
         if (in_array(strtolower($this->requestMethod), array('put', 'delete')))
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 MAYBE   trailingComma: Last element in a multi-line array should have a trailing comma at testdata/twitter-api-php/TwitterAPIExchange.php:366
                 'oauth_signature_method', 'oauth_timestamp', 'oauth_token', 'oauth_version'))) {
                                                                             ^^^^^^^^^^^^^^^
-MAYBE   phpdocType: use int type instead of integer on line 4 at testdata/twitter-api-php/TwitterAPIExchange.php:406
-    public function getHttpStatusCode()
-                    ^^^^^^^^^^^^^^^^^
+MAYBE   phpdocType: Use int type instead of integer at testdata/twitter-api-php/TwitterAPIExchange.php:404
+     * @return integer
+               ^^^^^^^
 MAYBE   trailingComma: Last element in a multi-line array should have a trailing comma at testdata/twitter-api-php/index.php:10
     'consumer_secret' => ""
     ^^^^^^^^^^^^^^^^^^^^^^^