Skip to main content
v2.7.1 is a patch release. It corrects seven defects in cognesy/instructor-utils — the package every other package depends on — and stops a documentation-deploy race from reporting healthy builds as failures. There are no API changes and no upgrade steps. Most of these defects were silent: a helper returned plausible-looking wrong data instead of raising, so callers had no signal. Two are worth a deliberate look if you use the helpers directly, because your output changes: Arrays::toBullets() no longer double-spaces its lists, and Arrays::flattenToString() now honours its separator at every nesting level.

Utils Fixes

String Predicates Rebuilt on the Native Functions

Str::endsWith($text, '') returned false. The implementation compared substr($text, -strlen($needle)) against the needle, and for an empty needle that is substr($text, -0) — which PHP reads as substr($text, 0), the whole string. Every other predicate in the class treats an empty needle as trivially contained, and str_ends_with() agrees, so this was an outlier. contains(), startsWith(), and endsWith() now delegate to str_contains(), str_starts_with(), and str_ends_with(), with stripos() for the case-insensitive path. Behaviour is otherwise unchanged. Four docblocks that described the wrong parameters or the wrong return value were corrected at the same time — after(), when(), limit(), and spaceSeparated().

Arrays::flattenToString() Honours Its Separator

The recursion joined nested levels with a hard-coded separator and applied the caller’s separator only to the outermost join, so flattenToString($nested, ' | ') came back with newlines inside it. Flattening and joining are now separate steps: flattenToStringParts() returns a flat list<string>, and the caller’s separator is applied exactly once.

Arrays::toBullets() No Longer Double-Spaces

Each item was suffixed with "\n" and the items were then joined on "\n", so every list came out with a blank line between entries and a stray trailing newline — contradicting the documented - item1\n- item2 shape. These lists are pasted straight into prompts, where the blank lines are noise. Output is now one line per item with no trailing newline.

Arrays::fromAny() Terminates on Shared References

A structure containing a reference back to one of its own ancestors sent fromAny() into unbounded recursion until the process ran out of memory. It now marks the active path in a WeakMap and unmarks in a finally, so a repeated node on the current path is not descended into again. A value that legitimately appears twice in sibling positions is still converted twice, which is the correct result. Arrays::isSubset() also gained its missing bool return type, and Arrays::valuesMatch() — unchanged in behaviour — now documents its actual contract: equal length plus mutual containment, which is set equality whenever one side is duplicate-free.

Cached Stopped Re-running Producers That Return Null

Cached memoized resolved values in a static WeakMap keyed on $this and probed it with isset(). WeakMap follows isset() semantics for a stored null, so a producer that legitimately resolved to null was indistinguishable from one that had never run — and re-ran on every get(). Switching the probe to offsetExists() does not help: it reports false for a stored null too. The map bought nothing over ordinary instance state, so it is gone. Cached now holds its value and a resolved flag directly, isResolved() reports the flag, and get() on an unresolved instance with no producer throws RuntimeException rather than returning null ambiguously. Cognesy\Utils\Cached has no callers in this repository, so this is contained.

Files::files() and Files::directories() Validate Eagerly

Both validated their argument inside a generator body. A generator body does not execute until the first iteration, so passing a nonexistent path handed back a Generator and threw nothing at the call site — and threw nothing at all for a caller that counted or discarded the result. Validation now happens before the generator is created, so InvalidArgumentException arrives where the mistake is.

The Package’s Own Regression Suite Was Not Running

packages/utils/phpunit.xml declared Unit, Feature, and Integration suites but not Regression, so ten existing regression tests were skipped whenever the split package was tested on its own. The root configuration had always run them. The suite is declared now. The fixes above ship with 75 new tests across nine files; each was checked by reverting the source fix and confirming the relevant assertions fail.

Documentation Deploys

A push to main that was overtaken by a newer commit failed its Documentation run. Two guards fired correctly — the artifact really was stale, and publishing it would have rolled the live site backwards — but both reported a benign race as a red build, which made a healthy main look broken and buried real failures. The guards are unchanged in what they refuse. What changed is how they report it: publish-mintlify-branch.sh now exits 3 for “superseded, nothing published”, distinct from 1 for “publish failed”, and the workflow maps 3 to a skip with a warning annotation. The Pages job gates its deploy steps on a freshness check rather than failing, and the production verification job runs only when both jobs actually published, so it can no longer assert against a deploy that never happened. The superseded path had never been exercised: the test fixture stamped the provenance file with the wrong commit, so an earlier guard fired first and the assertion only ever checked for a non-zero exit. The test now pins the exit code, an untouched branch on skip, a successful retry, and a distinct failure for provenance mismatch — and runs as part of qa:docs-sites:test.

Upgrade Notes

No breaking changes and no upgrade steps. If you call Arrays::toBullets() or Arrays::flattenToString() directly and assert on their exact output, update those expectations — both now produce what their documentation always described.