# Urban Renewal #1: Structural Survey – Make the Agent Read Before It Writes

> Before you hand a legacy project to a coding agent, don't let it write a line; make it read. "Read-only" means something different in every tool, so the survey runs on a disposable copy and ends with proof that the copy didn't change. Eleven findings from a real PHP repo, and an order to fix them in.

- Author: [Halit Yeşil](https://halityesil.com/en/about/)
- Published: 2026-10-17
- Language: English
- Canonical URL: https://halityesil.com/en/urban-renewal-1-ai-refactoring-survey/
- Turkish version: https://halityesil.com/kentsel-donusum-1-risk-tespiti.md
- Series: [Urban Renewal](https://halityesil.com/en/series/urban-renewal/), chapter 1
- Category: Software Architecture
- Tags: AI agents, Claude Code, Codex, Cursor, Git, Legacy Code, OpenCode, PHP, Refactoring, Urban Renewal
- License: https://creativecommons.org/licenses/by-nc/4.0/
- Cite as: Halit Yeşil. “Urban Renewal #1: Structural Survey – Make the Agent Read Before It Writes”. halityesil.com, 2026-10-17. https://halityesil.com/en/urban-renewal-1-ai-refactoring-survey/

---

In the Vibe Coder series we build on an empty lot: new project, clean schema, an agent starting from zero. This new series covers the other side. The lot is taken, people live in the building, the plumbing runs inside the walls and nobody can find the original drawings. **AI-assisted refactoring of legacy code** is usually exactly that: renovating an old project without tearing it down. Hence the name, Urban Renewal.

Here is the thesis up front. A coding agent is the fastest contractor I have ever seen. But if you don't hand it the structural survey, you end up with an illegal extra floor: changes you never asked for, scope that grew without a question, a diff nobody reads line by line. So in this first chapter the agent doesn't write a single line. It reads.

The demo building is fixed too: the [Altınkaynak exchange rate library](https://halityesil.com/php-ile-altinkaynak-doviz-kuru-cekmek/) I wrote in 2020 (post in Turkish; code at [github.com/HalitYesil/altinkaynak](https://github.com/HalitYesil/altinkaynak)). It is a small MIT-licensed library that pulls currency and gold prices from a Turkish exchange bureau's SOAP service: ten PHP files, 679 lines. Last commit: 6 September 2020. Every file passes `php -l` on PHP 8.3.6, so the building stands; it is just old. Over this series we will actually renovate it.

## Why read first?

Tell an agent "refactor this project" and it starts fixing the first problem it sees. That isn't malice, it's the job description: you asked for work, it produces work. The catch is that while one problem gets fixed, the other nine stay invisible. Who set the priority? The order in which files happened to be read.

In a real renovation, a structural survey comes before the contractor, and the surveyor doesn't cut columns. Our equivalent: the agent takes an X-ray of the codebase, writes every finding with evidence, and touches nothing. A human reads the report and decides what gets fixed when.

Sounds simple. The hard part is "touches nothing", because "read-only" means something different in every tool.

## "Read-only" is not one thing

I read the planning and question modes of four tools from four ecosystems in their own documentation (15 October 2026):

- **Claude Code (Anthropic), plan mode.** `claude --permission-mode plan`, or Shift+Tab inside a session. [Per the docs](https://code.claude.com/docs/en/permission-modes), Claude reads files, runs shell commands to explore and writes a plan, but does not edit your source; edits stay blocked until you approve the plan. Shell commands go either through the auto mode classifier or through your approval, depending on the session.
- **Codex (OpenAI), `--sandbox read-only`.** [Per the docs](https://learn.chatgpt.com/docs/sandboxing), the agent can inspect files but can't edit files or run commands without approval. The difference is where the line is drawn: at the operating system level, with Seatbelt on macOS and bubblewrap on Linux and WSL2.
- **Cursor, Ask mode.** File edits are blocked at the tool layer; shell commands partly rely on instructions given to the model. That's not my reading. On 8 May 2026 a user [reported on the Cursor forum](https://forum.cursor.com/t/agent-ran-database-command-in-ask-mode/160071) that an agent in Ask mode ran `ALTER TABLE` and `UPDATE` against a local Postgres database through `docker exec` (Cursor 3.2.21, model Kimi K2.5). The Cursor team's answer was candid: a known architectural limitation, they are working on enforcing Ask mode more strictly at the tool layer, no ETA.
- **OpenCode (open source), Plan agent.** Switch with Tab. [Per the docs](https://opencode.ai/docs/agents/), file edits and bash commands default to "ask", so each one waits for your approval.

| Tool | Mode | File writes | Running commands | Who enforces it |
|---|---|---|---|---|
| Claude Code | `plan` | Blocked until plan approval | Classifier or your approval | The tool's permission system |
| Codex | `--sandbox read-only` | Blocked | Needs approval | OS-level sandbox |
| Cursor | Ask | Blocked at tool layer | Partly up to the model | Tool + model |
| OpenCode | Plan | Each one asks | Each one asks | The tool's permission system |

**The hard truth:** blocking writes to the filesystem and blocking side effects are two different things. A database, a queue or a remote service the agent can reach may sit outside the mode entirely. That is precisely what happened in the Cursor report.

The rule I take from this doesn't depend on the mode: **run the survey on a disposable copy, and prove at the end that the copy didn't change.** The mode is the first line of defence, the proof is the second. The copy holds no `.env`, no real database credentials, no keys; the code alone is enough for an X-ray. The hooks from [Vibe Coder 4.2](https://halityesil.com/en/vibe-coder-4-2-agents-md-vs-claude-md/) can be a third line, but not instead of the first two.

```bash
#!/usr/bin/env bash
# survey.sh: run the agent on a throwaway copy, then prove nothing changed.
set -euo pipefail

REPO=${1:?Usage: survey.sh <repo-url>}
COPY="$(mktemp -d)/building"

git clone --quiet "$REPO" "$COPY"
cd "$COPY"
START=$(git rev-parse HEAD)

echo "Copy: $COPY (HEAD ${START:0:7})"
echo "Start the agent here in its read-only mode; have it print the report, not save it. Press Enter when done."
read -r _

if [ -n "$(git status --porcelain --ignored)" ] || [ "$(git rev-parse HEAD)" != "$START" ]; then
  echo "Illegal floor: the copy changed during the survey." >&2
  git status --short --ignored >&2
  exit 1
fi

echo "Clean: the agent read, it didn't write."
```

I tried the script on altinkaynak with two scenarios. Pressing Enter without touching the copy printed "Clean" and exited 0. Appending a line to the README and dropping a `report.md` next to it while the script waited printed `M README.md` and `?? report.md` and exited 1. The `--ignored` flag is there on purpose, so that a write into a folder git ignores, like `vendor/`, still gets caught. It's also why the report goes to the screen rather than a file: even the report file counts as a change.

## The brief: we want a report, not a patch

In [Vibe Coder 4.1](https://halityesil.com/en/vibe-coder-4-1-mega-prompt-coding-agents/) we swapped the séance for a written spec. The survey spec is short, but it has to say one thing very clearly: the output is a report, not a patch.

```markdown
Task: Produce a risk report for this repository. Do not modify any file,
create files, install packages or make network requests. Write the report
only in this chat.

For each finding:
- Finding: one sentence
- Evidence: file:line (quote at most 3 lines; mask passwords and keys)
- Risk: what it can break, who it affects
- Cost of touching it: what else a fix might break
- Confidence: certain / suspected (if suspected, how to verify)

Do not write fix code. I will decide the order.

Final section: what you CANNOT see by reading this repo (runtime, external services, real data).
```

The last item earns its place. Asking for what can't be known from reading pulls the agent out of its "I've seen everything" voice and hands you the list of experiments.

## X-raying altinkaynak

I went through the repository with read-only commands only (`git log`, `grep`, `php -l`, `git ls-files --eol`). Findings, with evidence:

**1. The service URL is `http://`.** Line 33 of `src/Altinkaynak.php`: the WSDL address has no TLS. Every request, credentials included, travels in plain text.

**2. Credentials in source.** Lines 34 and 35 of the same file hold the service username and password as literals. They may look like public service details, but the principle stands: secrets don't go in the repo. I'm not repeating the values here either.

**3. A misspelled option.** One of the options passed to `SoapClient` silently does nothing:

```php
<?php
// src/Altinkaynak.php, lines 167-170 (indentation simplified)
$this->Service = new \SoapClient($this->getServiceURL(), [
    "trace"     => 1,
    "exception" => 1, // No such option in PHP; the right one is "exceptions"
]);
```

The [PHP manual](https://www.php.net/manual/en/soapclient.construct.php) has no `exception` option; the real one is `exceptions` and it defaults to `true` anyway. The code behaves correctly today, by accident. **Hidden gem:** a line that tells every reader "error handling is configured" while configuring nothing. One of the rare findings where the cost of touching it is zero.

**4. The same method three times.** `GetCurrency` (62), `GetGold` (92) and `GetMain` (122) are nearly identical: service call, XML parsing, the same loop. Only the remote method name differs. Even the gold list is stuffed into a `GetCurrencyResponse` object.

**5. Dead code.** The `Response` class has a `decorateResponse()` that maps the SOAP reply onto objects, but every response class is constructed with `null`, so the mapping never runs and the fields are filled by hand. `AltinkaynakException` is defined and never thrown.

**6. Money as `float`.** Buying and selling prices go through `floatval()` (lines 76, 106 and 136). Fine for showing a rate on screen; not fine once someone does arithmetic with it.

**7. Unescaped output in the examples.** Line 50 of all three example files prints the description from the remote service with `<?= $Currency->desc; ?>`, no escaping; the same files switch `display_errors` on at the top. External data, straight into HTML.

**8. No types, no tests.** No `declare(strict_types=1)`, untyped properties and parameters, not a single test file.

**9. A comment from another project.** The doc comment of the hand-written autoloader reads "Register the autoloader for the Imflexi Gear SDK classes" (`src/Autoload.php`, line 8). Copied over from another project, name unchanged. The most human finding in any survey: the fingerprint of copy and paste.

**10. CI could never have worked.** `.github/workflows/php.yml` runs `composer validate` as its first step, but there is no `composer.json` in the repo. I ran the same command in the repo root: Composer 2.8.12 says `./composer.json not found.` and exits with code 3. The repo's Actions page shows no recorded runs at all. So this building didn't live with an alarm flashing red; it lived with an alarm that was never wired up. The second is worse. You notice red. You don't notice silence.

**11. Mixed line endings.** The sneakiest finding of the lot. According to `git ls-files --eol`, all ten PHP files are stored with CRLF (Windows) line endings, while the workflow, README and LICENSE, created through GitHub's web UI, are LF. There is no `.gitattributes`.

I measured why that matters. On a copy, I made the one-word fix on line 169 (`exception` to `exceptions`) and saved the file with LF endings, the way an editor or an agent rewriting the file might. `git diff --stat`: **238 insertions, 238 deletions.** The entire file. The same change viewed with `git diff --ignore-cr-at-eol`: 1 line.

A one-word fix turns into a 476-line review, and the real change disappears in the crowd. That's how illegal floors usually go up: not through malice, through noise.

### What the survey can't see

Two things couldn't be known by reading, so I checked both from the outside (15 October 2026).

First, the service itself. The `data.altinkaynak.com` SOAP address the library talks to opened neither over `http://` nor over `https://`; the browser showed an error page. Altınkaynak's own [web services page](https://altinkaynak.com/Araclar/Servisler) now lists only two addresses: `https://static.altinkaynak.com/public/Currency` and `https://static.altinkaynak.com/public/Gold`. I opened the currency one: no credentials, JSON over HTTPS. The grid this building was wired to has changed. Findings 1 and 2 are already solved on the service side; the library is simply still plugged into the old line.

Second, the shape of the response, and this is where finding 6 grew teeth. The new endpoint keeps the field names (`Kod`, `Aciklama`, `Alis`, `Satis`, `GuncellenmeZamani`), but it sends prices as strings with a decimal comma: `"Alis":"49,350"`. In the 2020 post's sample output the same field used a dot (`7.45`). The library's `floatval()` stops reading at the comma:

```php
<?php
var_dump(floatval("49,350")); // float(49)
var_dump(floatval("0,3086")); // float(0)
```

I ran it on PHP 8.3.6: the Japanese yen's buying price comes out as zero. No error, no warning. An agent that tries to hook the old building up to the new grid by "just changing the URL" delivers code that runs and prints wrong prices, and with no tests nobody notices. That is the survey's real job: saying what will break before anyone fixes anything.

## From report to order

An X-ray isn't a to-do list, it's a map. Sorted by the cost of touching each finding:

| Finding | Risk | Cost of touching | When |
|---|---|---|---|
| Mixed CRLF/LF, no `.gitattributes` | Every diff drowns in noise | Low, but needs its own single-purpose commit | First |
| No tests | Every change is blind | None | Scaffolding (#3) |
| Unescaped example output | XSS | Low | Alongside the scaffolding |
| Duplicated methods, dead code | Maintenance load | Behaviour may change | After the tests |
| No types, no `strict_types` | Silent type juggling | Medium (e.g. `float` money) | Mechanical upgrade (#5) |
| `http://`, credentials in source, old SOAP address | Service no longer answers | New endpoint is HTTPS and credential-free, but the number format differs | With the tear-down-or-retrofit decision (#2) |
| Broken CI | Silent alarm | Low | Building inspection (#9) |

An agent can propose this table; you sign it. One more caveat: in this chapter I did not run the four tools side by side. The mode behaviour above comes from the official docs and the Cursor team's forum reply. Running the same brief across different ecosystems and comparing the diffs is the job of chapter four, Illegal Floor.

## When does this pay off, and when doesn't it?

**It pays off** on code you didn't write or haven't opened in years. When a team hands a legacy repo to an agent for the first time. When you need to explain refactoring scope to a manager or a client; the report is raw material for the estimate.

**It doesn't** for a three-file script, where the report takes longer than the work. And if you won't read the report, don't ask for one. An unread X-ray is worse than none, because it gives you the feeling that someone looked.

## Before anyone cuts a column

We didn't drive a single nail in this chapter; we came out with eleven findings, an order and a gate. In the next chapter, Urban Renewal #2: Tear Down or Retrofit? – The Bill for a Rewrite, we take the first big decision this report puts on the table: strengthen the building, or knock it down and start over. All chapters live on the [Urban Renewal](https://halityesil.com/en/series/urban-renewal/) series page.

Let the contractor be fast. Just make sure you're the one signing the survey. Stay with the technology.
