Tacking against the winds of Copilot
Published by marco on
The tweet ”Programming” by Andrej Karpathy (Twitter) is what some people are calling the future of programming—with the loudest claiming that it’s already here and that you’re all missing the boat if you’re not programming like this.
“There’s a new kind of coding I call “vibe coding”, where you fully give in to the vibes, embrace exponentials, and forget that the code even exists. It’s possible because the LLMs (e.g. Cursor Composer w Sonnet) are getting too good. Also I just talk to Composer with SuperWhisper so I barely even touch the keyboard. I ask for the dumbest things like “decrease the padding on the sidebar by half” because I’m too lazy to find it. I “Accept All” always, I don’t read the diffs anymore. When I get error messages I just copy paste them in with no comment, usually that fixes it. The code grows beyond my usual comprehension, I’d have to really read through it for a while. Sometimes the LLMs can’t fix a bug so I just work around it or ask for random changes until it goes away. It’s not too bad for throwaway weekend projects, but still quite amusing. I’m building a project or webapp, but it’s not really coding − I just see stuff, say stuff, run stuff, and copy paste stuff, and it mostly works.”
The people who claim that this represents the future of all human engineering effort are mostly fools and mountebanks, who are either not software developers or engineers, or who claim that they are, but never really understood what we do.
Karpathy is, at least, very explicit that he’s doing this for “throwaway” projects. He is building, at best, prototypes and, at worst, inscrutable toys that don’t do what he wants, and he’s not going to bother trying to fix them.
This style only works because Karpathy already kind of knows how to program and he’s smart. This style will not make someone magically able to produce anything other than a crude facsimile of what has already been produced before. They will likely get stuck in myriad cul-de-sacs where the LLM avows very confidently that something will work and it will not work—sometimes subtly, sometimes overtly.
The other day, I was confronted three times with outright inaccurate, or simply outdated or inapplicable suggestions from Copilot. I did not request these suggestions; they were introduced by colleagues during reviews and collaboration.
Clinging to the past
In one case, someone asked in a PR whether we really could eliminate the framework declaration in app.config
files.
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
</configuration>
The Microsoft documentation still confidently declares that you need to include this declaration but says nothing about why it’s not included in any of its starter templates.
Copilot therefore confidently writes that you absolutely need the declaration for reasons that might have been somewhat applicable in 2009, or maybe even 2012.
The app in question works with .NET Framework 4.8, a version that has been available for three years. There is no follow-up version in sight. A 4.x version of the framework has been included on all versions of Windows for years. The app doesn’t run with a lower version. It already declares this in the <TargetFramework>
element of the project file.
We don’t need to artificially pin the version with an outdated mechanism. The version of the framework give in the project file is not only sufficient, but adding an outdated alternative that says something slightly different but will be ignored is distracting, to say the least. It’s a recipe for configuration cargo cults and future goose chases, which is why I threw it out. Copilot would chirpily encourage people to put it right back in, and to waste time fine-tuning it.
Simplistic and incorrect
In another case, a colleague was having trouble upgrading Entity Framework from using the System.Data.SqlClient
to using the Microsoft.Data.SqlClient
instead. There was an error where a registration wasn’t being honored and the app couldn’t find the new provider.
The Copilot solution was to confidently recommend adding a whole bunch of crap that wouldn’t solve the problem in any sane manner—and couldn’t. It just amounted to vague instructions to include the EF framework—which my colleague was obviously already doing.
The solution to the problem was going to have to be more subtle, but my colleague was getting lost in the weeds of trying to figure out the subtlety underlying what Copilot might possibly mean with its naive suggestion.
I am continually amazed by how much leeway people give these LLM tools, very optimistically assuming that a given tool “understood” the intent and is suggesting a brilliant solution that will reveal itself if only the developer can rise to the occasion.
When I was called in, I read the error message and it was pretty clear that something in the system was overriding the settings we wanted to use. It turned out that there was a reference to the old data provider in one of the core libraries that we’d upgraded.
Instead, we ended up focusing on the fact that the EF support took a default dependency on a relatively old 5.x component but the latest version was 6.x. The suspicion was that perhaps something would work better in 6.x. When my colleague added the newer version to all projects, things started working again, so apparently a bug had been fixed. But which bug?
While it was good to have a workaround, adding the reference everywhere was an awkward solution. So my colleague took a small timebox to investigate further
He eventually discovered—without Copilot—that there was a registration for the old data provider buried in our library code. Removing that single line of configuration in the base library solved the error. Including another registration shouldn’t cause the configuration to ignore subsequent registrations of other data providers, though. I think that the 5.x version wasn’t capable of properly managing multiple, registered data providers but that the 6.x version could. When the library was forced to use a newer version, everything worked again.
The maintainable solution that we ended up using was:
- Remove the unwanted configuration entry in the base library.
- Add a single reference to the newer version of the transient library to ensure that we were using the 6.x version—with the bug fix that we technically no longer needed—in all consumers of the library. This step wasn’t even strictly necessary but there’s no harm in it. We marked the package-inclusion with a reason and a link to the work item that was tracking the work and included a write-up of what had happened.
A solution that doesn’t exist
Finally, I had updated a build-pipeline template to accept a new parameter: OutputFolder
, which was to default to a value based on the value of another parameter SolutionFolder
. The following solution feels like a natural way of expressing this intent.
parameters:
SolutionFolder: ''
OutputFolder: '${{ parameters.SolutionFolder }}\Output'
This is what it looked like in the actual template:
However, if you try it, you will get an error message saying that “A template expression is not allowed in this context”:
Template Expression not allowed
According to the machines—the screenshot below is DuckDuckGo, which is based on ChatGPT by default—it will cheerily tell you that you can totally do exactly what you clearly can’t do.
AI thinks parameters can reference each other
This is the cloud theory link referenced in the screenshot. There’s nothing in there that has anything to do with the question or its answer. The post is about Azure pipelines but it’s a pretty banal article that explains the basics and it will utterly waste your time if you were looking for a definitive answer on how to reference parameters from one another, as the LLM claims is possible.
The best-case scenario is a mediocre solution that works.
The worst-case scenario is a productive-looking rabbit-hole that leads nowhere.
This is my worry about this whole LLM-based tool thing: it’s wasting people’s time and making them inefficient. The best-case scenario is if it manages to give them a mediocre solution that works. The worst-case scenario is that they get routed down a productive-looking rabbit-hole that leads nowhere but which they can then claim that had been productive because they’d been doing “work” and who can blame them if it was the LLM that started them on that path?
How do you fix this for real? You use the solution that I came up with after quickly learning from Allow parameter defaults to be calculated based on other parameters #2737 on GitHub that the feature I was looking for had never been implemented.
I used a variable and coalesce, as shown below.
variables:
defaultOutputPath: '${{parameters.SolutionFolder}}/Output'
templateOutputFolder: ${{ coalesce(parameters.OutputFolder, defaultOutputPath) }}
Curious, though, I went to Copilot to find out what it would suggest (assuming that Copilot is a bit more advanced than the default ChatGPT used by DuckDuckGo). As usual, it returned a lot of boilerplate text that may or may not have actually corresponded to the code it provided. I didn’t bother reading most of it and just kept the code examples.
🧐 1️⃣ prompt
🤖 1️⃣ answer
parameters:
− name: repositoryName
type: string
default: ''
− name: sonarQubeProjectKey
type: string
default: ''
jobs:
− job: SetDefaults
steps:
− script: |
echo "##vso[task.setvariable variable=sonarQubeProjectKey]com.company.${{ parameters.repositoryName }}"
displayName: 'Set SonarQube Project Key'
This is an impenetrable and unmaintainable disaster that uses a script to write a variable to the job’s terminal, which also sets it in the pipeline environment as a side-effect. Why would you do it that way when you just need a local variable? I already know what the solution is, so I know that I can keep prompting instead of wasting my time trying out this version, which I think might work but which is not elegant, legible, or particularly maintainable..
That’s the advantage of being a senior developer: I will know a good answer when I see it, so the LLM can save me time as I pull the slot-machine arm until it comes up sevens.
In the brusque style I typically use with LLMs, I pressed on.
🤨 2️⃣ prompt
🤖 2️⃣ answer
parameters:
− name: repositoryName
type: string
default: 'default-repo'
− name: sonarQubeProjectKey
type: string
default: ''
variables:
− name: sonarQubeProjectKey
value: ${{ parameters.sonarQubeProjectKey != '' && parameters.sonarQubeProjectKey || 'com.company.' + parameters.repositoryName }}
jobs:
− job: Build
steps:
− script: |
echo "SonarQube Project Key: $(sonarQubeProjectKey)"
displayName: 'Display SonarQube Project Key'
That’s a bit better. Now there’s a variable declaration, even though it still uses the unfortunate old-style name/value syntax that is not super-intuitive. It also uses a bizarre bastardization of the ||
operator to coalesce to a default value if it’s not set. I’m not an expert on pipeline-expression syntax but the placement of the &&
looks pretty wonky.
As with the first answer, I didn’t even bother trying to see if that one-liner mess even compiles, to say nothing of getting me what I want. Even if it works, it’s an impenetrable and unmaintainable line that no-one will ever dare touch again.
Since I already knew the answer, I told it straight-up how I wanted it to write the expression.
😒 3️⃣ prompt
🤖 2️⃣ answer
parameters:
− name: repositoryName
type: string
default: 'default-repo'
− name: sonarQubeProjectKey
type: string
default: ''
variables:
sonarQubeProjectKey: $[coalesce(parameters.sonarQubeProjectKey, 'com.company.' + parameters.repositoryName)]
jobs:
− job: Build
steps:
− script: |
echo "SonarQube Project Key: $(sonarQubeProjectKey)"
displayName: 'Display SonarQube Project Key'
It’s unclear why it’s using Runtime expression syntax instead of the more-standard Macro syntax variables. It doesn’t matter, though because, according to the documentation (and as far I knew already), you have to use the Template expression syntax when referencing template parameters (which we most certainly are—it’s right there in the question).
Is it complicated that there’s a table as well as a section called What syntax should I use? that explains which form to use where and what to watch out for? Yes, it is. But I don’t make the rules. I just play by them.
The tool of the future that you’re a fool and knave for not using because junior devs super-powered by it will blow by your ancient ass if you don’t learn how to use it
Copilot, on the hand, is not constrained by petty rules, and would have cheerily helped a less-savvy developer piss away an entire afternoon if not a couple of days chasing phantom errors, trying desperately to get the code produced by the genius machine to work as advertised. The thing about pipeline scripts is…you have to execute them in the cloud. The turnaround time is murder.
Let’s take a quick peek at my version, the one I had before we starting playing around with the tool of the future that you’re a fool and knave for not using because junior devs super-powered by it will blow by your ancient ass if you don’t learn how to use it.
variables:
defaultOutputPath: '${{parameters.SolutionFolder}}/Output'
templateOutputFolder: ${{ coalesce(parameters.OutputFolder, defaultOutputPath) }}
Succinct, easy to read, with useful variable names. Easy, peasy, lemon-squeezy. Also, it works! That sets it apart from the Copilot suggestions above.
The only trouble I had in the PR was that my reviewer didn’t know what the coalesce function did…and the top German translation was for Verbinden, which means to link, tie, or join. That meant he thought it was a synonym for concatenate
, which it most certainly is not. It was a good learning opportunity, where I reminded him of the coalesce
function in SQL, as well as the null-coalescing operators—??
and ??=
—in C#.
Look, the world is complicated, human languages and creations are messy, and a glorified Plinko machine like an LLM doesn’t seem quite yet cut out for helping us navigate it.