Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2bd3d90e | ||
|
|
b149dccdbe | ||
|
|
724a997002 | ||
|
|
59dabc9455 | ||
|
|
6b57a8ba8a |
@@ -78,6 +78,27 @@ These rules apply to every task across every project, regardless of harness.
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||
PR would buy a review of a state that was never deployed.
|
||||
|
||||
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||
green" is not the same claim.
|
||||
|
||||
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||
|
||||
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||
lane silently does not apply.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
@@ -106,6 +127,43 @@ These rules apply to every task across every project, regardless of harness.
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -249,7 +307,13 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||
were not.)
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
@@ -319,8 +383,20 @@ Workflow:
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
|
||||
@@ -73,6 +73,27 @@ These rules apply to every task across every project, regardless of harness.
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||
PR would buy a review of a state that was never deployed.
|
||||
|
||||
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||
green" is not the same claim.
|
||||
|
||||
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||
|
||||
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||
lane silently does not apply.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
@@ -101,6 +122,43 @@ These rules apply to every task across every project, regardless of harness.
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -244,7 +302,13 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||
were not.)
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
@@ -314,8 +378,20 @@ Workflow:
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
)
|
||||
|
||||
// auditTargetKeys are the only argument values copied into the audit record:
|
||||
// they identify what a call acted on without copying what it wrote. Every
|
||||
// other argument (file content, issue bodies) is reduced to args_bytes so the
|
||||
// audit trail never becomes a second copy of repo content (gitea-mcp#64).
|
||||
var auditTargetKeys = []string{"owner", "repo", "name", "number", "index", "branch", "path", "tag"}
|
||||
|
||||
// logToolCall emits one structured record per tools/call (OWASP MCP08).
|
||||
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
|
||||
attrs := []any{
|
||||
"tool", name,
|
||||
"caller", auth.Caller(ctx),
|
||||
"args_bytes", len(args),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
}
|
||||
if tok, ok := gitea.TokenFromContext(ctx); ok {
|
||||
attrs = append(attrs, "token_fp", chassisauth.Fingerprint(tok))
|
||||
}
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal(args, &parsed) == nil {
|
||||
for _, k := range auditTargetKeys {
|
||||
if v, ok := parsed[k]; ok {
|
||||
attrs = append(attrs, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome := "ok"
|
||||
if err != nil {
|
||||
outcome = err.Error()
|
||||
}
|
||||
attrs = append(attrs, "outcome", outcome)
|
||||
slog.InfoContext(ctx, "mcp tool call", attrs...)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type auditStubTool struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s auditStubTool) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{Name: s.name, InputSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
|
||||
func (s auditStubTool) Call(context.Context, json.RawMessage) (json.RawMessage, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
// captureAudit routes slog to a buffer and returns the decoded
|
||||
// "mcp tool call" records emitted while fn runs.
|
||||
func captureAudit(t *testing.T, fn func()) []map[string]any {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
fn()
|
||||
|
||||
var out []map[string]any
|
||||
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(l), &rec))
|
||||
if rec["msg"] == "mcp tool call" {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func auditServer(tools ...registry.Tool) http.Handler {
|
||||
reg := registry.New()
|
||||
for _, tl := range tools {
|
||||
reg.Register(tl)
|
||||
}
|
||||
srv := mcp.NewServer(mcp.ServerOptions{Registry: reg})
|
||||
return auth.CallerMiddleware(slog.Default(), srv)
|
||||
}
|
||||
|
||||
func callTool(t *testing.T, h http.Handler, name string, args any) {
|
||||
t.Helper()
|
||||
body := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": name, "arguments": args},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Auth-Request-User", "alice")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
// gitea-mcp#64: tools/call dispatched with no record at all.
|
||||
func TestToolsCallLogsOneAuditRecordWithToolCallerAndTarget(t *testing.T) {
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "repo_delete"}), "repo_delete",
|
||||
map[string]any{"owner": "mathias", "repo": "scratch", "confirm": "scratch"})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
r := recs[0]
|
||||
assert.Equal(t, "repo_delete", r["tool"])
|
||||
assert.Equal(t, "alice", r["caller"])
|
||||
assert.Equal(t, "mathias", r["owner"])
|
||||
assert.Equal(t, "scratch", r["repo"])
|
||||
assert.Equal(t, "ok", r["outcome"])
|
||||
assert.Contains(t, r, "duration_ms")
|
||||
}
|
||||
|
||||
func TestToolsCallAuditRecordsErrorOutcome(t *testing.T) {
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "pr_merge", err: errors.New("boom")}),
|
||||
"pr_merge", map[string]any{"owner": "mathias", "repo": "x", "number": 3})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
assert.Equal(t, "boom", recs[0]["outcome"])
|
||||
assert.EqualValues(t, 3, recs[0]["number"])
|
||||
}
|
||||
|
||||
// The audit line must not become a second copy of repo content.
|
||||
func TestToolsCallAuditOmitsArgumentValuesOtherThanTarget(t *testing.T) {
|
||||
const sentinel = "SENTINEL-FILE-CONTENT-7f3a"
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "file_write_branch"}), "file_write_branch",
|
||||
map[string]any{"owner": "mathias", "repo": "x", "path": "a.txt", "content": sentinel})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
raw, err := json.Marshal(recs[0])
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(raw), sentinel)
|
||||
assert.Contains(t, recs[0], "args_bytes")
|
||||
}
|
||||
|
||||
// #38 keeps `name` (for repo) and `index` (for number) as legacy aliases; a
|
||||
// caller using them must still leave a target in the audit record.
|
||||
func TestToolsCallAuditRecordsLegacyAliasTargets(t *testing.T) {
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "issue_close"}), "issue_close",
|
||||
map[string]any{"owner": "mathias", "name": "infra", "index": 7})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
assert.Equal(t, "infra", recs[0]["name"])
|
||||
assert.EqualValues(t, 7, recs[0]["index"])
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
@@ -90,7 +91,9 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
||||
NewErrorResponse(req.ID, -32602, "invalid params", nil))
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
|
||||
logToolCall(r.Context(), p.Name, p.Arguments, start, err)
|
||||
if err != nil {
|
||||
code := -32000
|
||||
if errors.Is(err, registry.ErrToolNotFound) {
|
||||
|
||||
@@ -58,7 +58,7 @@ if [ "$FORCE" = false ]; then
|
||||
while IFS= read -r _match; do
|
||||
_lineno="${_match%%:*}"
|
||||
_content="${_match#*:}"
|
||||
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||
_token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
|
||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||
done <<< "$_placeholders"
|
||||
echo "" >&2
|
||||
|
||||
Reference in New Issue
Block a user